+8
-8
TODO.md
+8
-8
TODO.md
···
12
### Passkeys and 2FA
13
Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth.
14
15
-
- [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
16
-
- [ ] user_totp table (did, secret_encrypted, verified, created_at, last_used)
17
-
- [ ] WebAuthn registration challenge generation and attestation verification
18
-
- [ ] TOTP secret generation with QR code setup flow
19
-
- [ ] Backup codes (hashed, one-time use) with recovery flow
20
-
- [ ] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative)
21
- [ ] Passkey-only account creation (no password)
22
-
- [ ] Settings UI for managing passkeys, TOTP, backup codes
23
- [ ] Trusted devices option (remember this browser)
24
-
- [ ] Rate limit 2FA attempts
25
- [ ] Re-auth for sensitive actions (email change, adding new auth methods)
26
27
### Delegated accounts
···
12
### Passkeys and 2FA
13
Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth.
14
15
+
- [x] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
16
+
- [x] user_totp table (did, secret_encrypted, verified, created_at, last_used)
17
+
- [x] WebAuthn registration challenge generation and attestation verification
18
+
- [x] TOTP secret generation with QR code setup flow
19
+
- [x] Backup codes (hashed, one-time use) with recovery flow
20
+
- [x] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative)
21
- [ ] Passkey-only account creation (no password)
22
+
- [x] Settings UI for managing passkeys, TOTP, backup codes
23
- [ ] Trusted devices option (remember this browser)
24
+
- [x] Rate limit 2FA attempts
25
- [ ] Re-auth for sensitive actions (email change, adding new auth methods)
26
27
### Delegated accounts
+6
-4
frontend/src/routes/OAuthLogin.svelte
+6
-4
frontend/src/routes/OAuthLogin.svelte
···
341
/>
342
</div>
343
344
-
{#if securityStatusChecked && passkeySupported}
345
<button
346
type="button"
347
class="passkey-btn"
348
-
class:passkey-unavailable={!hasPasskeys}
349
onclick={handlePasskeyLogin}
350
-
disabled={submitting || !hasPasskeys || !username}
351
-
title={hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'}
352
>
353
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
354
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
···
358
<span class="passkey-text">
359
{#if submitting}
360
Authenticating...
361
{:else if hasPasskeys}
362
Sign in with passkey
363
{:else}
···
341
/>
342
</div>
343
344
+
{#if passkeySupported && username.length >= 3}
345
<button
346
type="button"
347
class="passkey-btn"
348
+
class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked}
349
onclick={handlePasskeyLogin}
350
+
disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked}
351
+
title={checkingSecurityStatus ? 'Checking passkey status...' : hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'}
352
>
353
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
354
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
···
358
<span class="passkey-text">
359
{#if submitting}
360
Authenticating...
361
+
{:else if checkingSecurityStatus || !securityStatusChecked}
362
+
Checking passkey...
363
{:else if hasPasskeys}
364
Sign in with passkey
365
{:else}
+9
-1
frontend/src/routes/Register.svelte
+9
-1
frontend/src/routes/Register.svelte
···
50
}
51
}
52
53
function validateForm(): string | null {
54
if (!handle.trim()) return 'Handle is required'
55
if (!password) return 'Password is required'
56
if (password.length < 8) return 'Password must be at least 8 characters'
57
if (password !== confirmPassword) return 'Passwords do not match'
···
152
disabled={submitting}
153
required
154
/>
155
-
{#if fullHandle()}
156
<p class="hint">Your full handle will be: @{fullHandle()}</p>
157
{/if}
158
</div>
···
389
font-size: 0.75rem;
390
color: var(--text-secondary);
391
margin: 0.25rem 0 0 0;
392
}
393
.verification-section {
394
border: 1px solid var(--border-color-light);
···
50
}
51
}
52
53
+
let handleHasDot = $derived(handle.includes('.'))
54
+
55
function validateForm(): string | null {
56
if (!handle.trim()) return 'Handle is required'
57
+
if (handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.'
58
if (!password) return 'Password is required'
59
if (password.length < 8) return 'Password must be at least 8 characters'
60
if (password !== confirmPassword) return 'Passwords do not match'
···
155
disabled={submitting}
156
required
157
/>
158
+
{#if handleHasDot}
159
+
<p class="hint warning">Custom domain handles can be set up after account creation in Settings.</p>
160
+
{:else if fullHandle()}
161
<p class="hint">Your full handle will be: @{fullHandle()}</p>
162
{/if}
163
</div>
···
394
font-size: 0.75rem;
395
color: var(--text-secondary);
396
margin: 0.25rem 0 0 0;
397
+
}
398
+
.hint.warning {
399
+
color: var(--warning-text, #856404);
400
}
401
.verification-section {
402
border: 1px solid var(--border-color-light);
+90
-19
migrations/20251211_initial_schema.sql
+90
-19
migrations/20251211_initial_schema.sql
···
1
-
CREATE TYPE notification_channel AS ENUM ('email', 'discord', 'telegram', 'signal');
2
-
CREATE TYPE notification_status AS ENUM ('pending', 'processing', 'sent', 'failed');
3
-
CREATE TYPE notification_type AS ENUM (
4
'welcome',
5
'email_verification',
6
'password_reset',
···
8
'account_deletion',
9
'admin_email',
10
'plc_operation',
11
-
'two_factor_code'
12
);
13
CREATE TABLE IF NOT EXISTS users (
14
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
···
21
deactivated_at TIMESTAMPTZ,
22
invites_disabled BOOLEAN DEFAULT FALSE,
23
takedown_ref TEXT,
24
-
preferred_notification_channel notification_channel NOT NULL DEFAULT 'email',
25
password_reset_code TEXT,
26
password_reset_code_expires_at TIMESTAMPTZ,
27
-
email_pending_verification TEXT,
28
-
email_confirmation_code TEXT,
29
-
email_confirmation_code_expires_at TIMESTAMPTZ,
30
-
email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
31
two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
32
discord_id TEXT,
33
discord_verified BOOLEAN NOT NULL DEFAULT FALSE,
34
telegram_username TEXT,
35
telegram_verified BOOLEAN NOT NULL DEFAULT FALSE,
36
signal_number TEXT,
37
-
signal_verified BOOLEAN NOT NULL DEFAULT FALSE
38
);
39
CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
40
-
CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL;
41
CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL;
42
CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL;
43
CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL;
44
CREATE TABLE IF NOT EXISTS invite_codes (
45
code TEXT PRIMARY KEY,
46
available_uses INT NOT NULL DEFAULT 1,
···
48
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
49
disabled BOOLEAN DEFAULT FALSE
50
);
51
CREATE TABLE IF NOT EXISTS invite_code_uses (
52
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
53
code TEXT NOT NULL REFERENCES invite_codes(code),
···
86
UNIQUE(repo_id, collection, rkey)
87
);
88
CREATE INDEX idx_records_repo_rev ON records(repo_rev);
89
CREATE TABLE IF NOT EXISTS blobs (
90
cid TEXT PRIMARY KEY,
91
mime_type TEXT NOT NULL,
···
95
takedown_ref TEXT,
96
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
97
);
98
CREATE TABLE IF NOT EXISTS app_passwords (
99
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
100
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
···
104
privileged BOOLEAN NOT NULL DEFAULT FALSE,
105
UNIQUE(user_id, name)
106
);
107
CREATE TABLE reports (
108
id BIGINT PRIMARY KEY,
109
reason_type TEXT NOT NULL,
···
118
expires_at TIMESTAMPTZ NOT NULL,
119
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
120
);
121
-
CREATE TABLE IF NOT EXISTS notification_queue (
122
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
123
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
124
-
channel notification_channel NOT NULL DEFAULT 'email',
125
-
notification_type notification_type NOT NULL,
126
-
status notification_status NOT NULL DEFAULT 'pending',
127
recipient TEXT NOT NULL,
128
subject TEXT,
129
body TEXT NOT NULL,
···
136
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT NOW(),
137
processed_at TIMESTAMPTZ
138
);
139
-
CREATE INDEX idx_notification_queue_status_scheduled
140
-
ON notification_queue(status, scheduled_for)
141
WHERE status = 'pending';
142
-
CREATE INDEX idx_notification_queue_user_id ON notification_queue(user_id);
143
CREATE TABLE IF NOT EXISTS reserved_signing_keys (
144
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
145
did TEXT,
···
160
prev_cid TEXT,
161
ops JSONB,
162
blobs TEXT[],
163
-
blocks_cids TEXT[]
164
);
165
CREATE INDEX idx_repo_seq_seq ON repo_seq(seq);
166
CREATE INDEX idx_repo_seq_did ON repo_seq(did);
167
CREATE TABLE IF NOT EXISTS session_tokens (
168
id SERIAL PRIMARY KEY,
169
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
···
275
);
276
CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
277
CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
···
1
+
CREATE TYPE comms_channel AS ENUM ('email', 'discord', 'telegram', 'signal');
2
+
CREATE TYPE comms_status AS ENUM ('pending', 'processing', 'sent', 'failed');
3
+
CREATE TYPE comms_type AS ENUM (
4
'welcome',
5
'email_verification',
6
'password_reset',
···
8
'account_deletion',
9
'admin_email',
10
'plc_operation',
11
+
'two_factor_code',
12
+
'channel_verification'
13
);
14
CREATE TABLE IF NOT EXISTS users (
15
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
···
22
deactivated_at TIMESTAMPTZ,
23
invites_disabled BOOLEAN DEFAULT FALSE,
24
takedown_ref TEXT,
25
+
preferred_comms_channel comms_channel NOT NULL DEFAULT 'email',
26
password_reset_code TEXT,
27
password_reset_code_expires_at TIMESTAMPTZ,
28
+
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
29
two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
30
discord_id TEXT,
31
discord_verified BOOLEAN NOT NULL DEFAULT FALSE,
32
telegram_username TEXT,
33
telegram_verified BOOLEAN NOT NULL DEFAULT FALSE,
34
signal_number TEXT,
35
+
signal_verified BOOLEAN NOT NULL DEFAULT FALSE,
36
+
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
37
+
migrated_to_pds TEXT,
38
+
migrated_at TIMESTAMPTZ
39
);
40
CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
41
CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL;
42
CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL;
43
CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL;
44
+
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL;
45
CREATE TABLE IF NOT EXISTS invite_codes (
46
code TEXT PRIMARY KEY,
47
available_uses INT NOT NULL DEFAULT 1,
···
49
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
50
disabled BOOLEAN DEFAULT FALSE
51
);
52
+
CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by ON invite_codes(created_by_user);
53
CREATE TABLE IF NOT EXISTS invite_code_uses (
54
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
55
code TEXT NOT NULL REFERENCES invite_codes(code),
···
88
UNIQUE(repo_id, collection, rkey)
89
);
90
CREATE INDEX idx_records_repo_rev ON records(repo_rev);
91
+
CREATE INDEX IF NOT EXISTS idx_records_repo_collection ON records(repo_id, collection);
92
+
CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created ON records(repo_id, collection, created_at DESC);
93
CREATE TABLE IF NOT EXISTS blobs (
94
cid TEXT PRIMARY KEY,
95
mime_type TEXT NOT NULL,
···
99
takedown_ref TEXT,
100
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
101
);
102
+
CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user ON blobs(created_by_user, created_at DESC);
103
CREATE TABLE IF NOT EXISTS app_passwords (
104
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
105
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
···
109
privileged BOOLEAN NOT NULL DEFAULT FALSE,
110
UNIQUE(user_id, name)
111
);
112
+
CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id ON app_passwords(user_id);
113
CREATE TABLE reports (
114
id BIGINT PRIMARY KEY,
115
reason_type TEXT NOT NULL,
···
124
expires_at TIMESTAMPTZ NOT NULL,
125
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
126
);
127
+
CREATE TABLE IF NOT EXISTS comms_queue (
128
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
129
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
130
+
channel comms_channel NOT NULL DEFAULT 'email',
131
+
comms_type comms_type NOT NULL,
132
+
status comms_status NOT NULL DEFAULT 'pending',
133
recipient TEXT NOT NULL,
134
subject TEXT,
135
body TEXT NOT NULL,
···
142
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT NOW(),
143
processed_at TIMESTAMPTZ
144
);
145
+
CREATE INDEX idx_comms_queue_status_scheduled
146
+
ON comms_queue(status, scheduled_for)
147
WHERE status = 'pending';
148
+
CREATE INDEX idx_comms_queue_user_id ON comms_queue(user_id);
149
CREATE TABLE IF NOT EXISTS reserved_signing_keys (
150
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
151
did TEXT,
···
166
prev_cid TEXT,
167
ops JSONB,
168
blobs TEXT[],
169
+
blocks_cids TEXT[],
170
+
prev_data_cid TEXT,
171
+
handle TEXT,
172
+
active BOOLEAN,
173
+
status TEXT
174
);
175
CREATE INDEX idx_repo_seq_seq ON repo_seq(seq);
176
CREATE INDEX idx_repo_seq_did ON repo_seq(did);
177
+
CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq ON repo_seq(did, seq DESC);
178
CREATE TABLE IF NOT EXISTS session_tokens (
179
id SERIAL PRIMARY KEY,
180
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
···
286
);
287
CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
288
CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
289
+
CREATE TABLE IF NOT EXISTS channel_verifications (
290
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
291
+
channel comms_channel NOT NULL,
292
+
code TEXT NOT NULL,
293
+
pending_identifier TEXT,
294
+
expires_at TIMESTAMPTZ NOT NULL,
295
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
296
+
PRIMARY KEY (user_id, channel)
297
+
);
298
+
CREATE INDEX IF NOT EXISTS idx_channel_verifications_expires ON channel_verifications(expires_at);
299
+
CREATE TABLE oauth_scope_preference (
300
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
301
+
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
302
+
client_id TEXT NOT NULL,
303
+
scope TEXT NOT NULL,
304
+
granted BOOLEAN NOT NULL DEFAULT TRUE,
305
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
306
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
307
+
UNIQUE(did, client_id, scope)
308
+
);
309
+
CREATE INDEX idx_oauth_scope_pref_lookup ON oauth_scope_preference(did, client_id);
310
+
CREATE TABLE user_totp (
311
+
did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE,
312
+
secret_encrypted BYTEA NOT NULL,
313
+
encryption_version INTEGER NOT NULL DEFAULT 1,
314
+
verified BOOLEAN NOT NULL DEFAULT FALSE,
315
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
316
+
last_used TIMESTAMPTZ
317
+
);
318
+
CREATE TABLE backup_codes (
319
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
320
+
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
321
+
code_hash TEXT NOT NULL,
322
+
used_at TIMESTAMPTZ,
323
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
324
+
);
325
+
CREATE INDEX idx_backup_codes_did ON backup_codes(did);
326
+
CREATE TABLE passkeys (
327
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
328
+
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
329
+
credential_id BYTEA NOT NULL UNIQUE,
330
+
public_key BYTEA NOT NULL,
331
+
sign_count INTEGER NOT NULL DEFAULT 0,
332
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
333
+
last_used TIMESTAMPTZ,
334
+
friendly_name TEXT,
335
+
aaguid BYTEA,
336
+
transports TEXT[]
337
+
);
338
+
CREATE INDEX idx_passkeys_did ON passkeys(did);
339
+
CREATE TABLE webauthn_challenges (
340
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
341
+
did TEXT NOT NULL,
342
+
challenge BYTEA NOT NULL,
343
+
challenge_type TEXT NOT NULL,
344
+
state_json TEXT NOT NULL,
345
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
346
+
expires_at TIMESTAMPTZ NOT NULL
347
+
);
348
+
CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
-15
migrations/20251213_performance_indexes.sql
-15
migrations/20251213_performance_indexes.sql
···
1
-
CREATE INDEX IF NOT EXISTS idx_records_repo_collection
2
-
ON records(repo_id, collection);
3
-
CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created
4
-
ON records(repo_id, collection, created_at DESC);
5
-
CREATE INDEX IF NOT EXISTS idx_users_email
6
-
ON users(email)
7
-
WHERE email IS NOT NULL;
8
-
CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user
9
-
ON blobs(created_by_user, created_at DESC);
10
-
CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq
11
-
ON repo_seq(did, seq DESC);
12
-
CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id
13
-
ON app_passwords(user_id);
14
-
CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by
15
-
ON invite_codes(created_by_user);
···
-1
migrations/20251214_add_prev_data_cid.sql
-1
migrations/20251214_add_prev_data_cid.sql
···
1
-
ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS prev_data_cid TEXT;
···
-3
migrations/20251215_add_identity_account_fields.sql
-3
migrations/20251215_add_identity_account_fields.sql
-12
migrations/20251216_add_channel_verification.sql
-12
migrations/20251216_add_channel_verification.sql
···
1
-
ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'channel_verification';
2
-
3
-
CREATE TABLE IF NOT EXISTS channel_verifications (
4
-
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5
-
channel notification_channel NOT NULL,
6
-
code TEXT NOT NULL,
7
-
expires_at TIMESTAMPTZ NOT NULL,
8
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9
-
PRIMARY KEY (user_id, channel)
10
-
);
11
-
12
-
CREATE INDEX IF NOT EXISTS idx_channel_verifications_expires ON channel_verifications(expires_at);
···
-11
migrations/20251217_migrate_email_to_channel_verifications.sql
-11
migrations/20251217_migrate_email_to_channel_verifications.sql
···
1
-
ALTER TABLE channel_verifications ADD COLUMN pending_identifier TEXT;
2
-
3
-
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
4
-
SELECT id, 'email', email_confirmation_code, email_pending_verification, email_confirmation_code_expires_at
5
-
FROM users
6
-
WHERE email_confirmation_code IS NOT NULL AND email_confirmation_code_expires_at IS NOT NULL;
7
-
8
-
ALTER TABLE users
9
-
DROP COLUMN email_confirmation_code,
10
-
DROP COLUMN email_confirmation_code_expires_at,
11
-
DROP COLUMN email_pending_verification;
···
-1
migrations/20251218_add_is_admin.sql
-1
migrations/20251218_add_is_admin.sql
···
1
-
ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
···
-6
migrations/20251219_rename_email_confirmed.sql
-6
migrations/20251219_rename_email_confirmed.sql
-27
migrations/20251220_rename_notifications_to_comms.sql
-27
migrations/20251220_rename_notifications_to_comms.sql
···
1
-
DO $$
2
-
BEGIN
3
-
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_channel') THEN
4
-
ALTER TYPE notification_channel RENAME TO comms_channel;
5
-
END IF;
6
-
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_status') THEN
7
-
ALTER TYPE notification_status RENAME TO comms_status;
8
-
END IF;
9
-
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_type') THEN
10
-
ALTER TYPE notification_type RENAME TO comms_type;
11
-
END IF;
12
-
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'notification_queue') THEN
13
-
ALTER TABLE notification_queue RENAME TO comms_queue;
14
-
END IF;
15
-
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comms_queue' AND column_name = 'notification_type') THEN
16
-
ALTER TABLE comms_queue RENAME COLUMN notification_type TO comms_type;
17
-
END IF;
18
-
IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_status_scheduled') THEN
19
-
ALTER INDEX idx_notification_queue_status_scheduled RENAME TO idx_comms_queue_status_scheduled;
20
-
END IF;
21
-
IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_user_id') THEN
22
-
ALTER INDEX idx_notification_queue_user_id RENAME TO idx_comms_queue_user_id;
23
-
END IF;
24
-
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'preferred_notification_channel') THEN
25
-
ALTER TABLE users RENAME COLUMN preferred_notification_channel TO preferred_comms_channel;
26
-
END IF;
27
-
END $$;
···
-12
migrations/20251221_oauth_scope_preferences.sql
-12
migrations/20251221_oauth_scope_preferences.sql
···
1
-
CREATE TABLE oauth_scope_preference (
2
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3
-
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
4
-
client_id TEXT NOT NULL,
5
-
scope TEXT NOT NULL,
6
-
granted BOOLEAN NOT NULL DEFAULT TRUE,
7
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8
-
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9
-
UNIQUE(did, client_id, scope)
10
-
);
11
-
12
-
CREATE INDEX idx_oauth_scope_pref_lookup ON oauth_scope_preference(did, client_id);
···
-2
migrations/20251222_add_did_web_migration_tracking.sql
-2
migrations/20251222_add_did_web_migration_tracking.sql
-42
migrations/20251223_add_passkeys_totp.sql
-42
migrations/20251223_add_passkeys_totp.sql
···
1
-
CREATE TABLE user_totp (
2
-
did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE,
3
-
secret_encrypted BYTEA NOT NULL,
4
-
encryption_version INTEGER NOT NULL DEFAULT 1,
5
-
verified BOOLEAN NOT NULL DEFAULT FALSE,
6
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
7
-
last_used TIMESTAMPTZ
8
-
);
9
-
10
-
CREATE TABLE backup_codes (
11
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
12
-
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
13
-
code_hash TEXT NOT NULL,
14
-
used_at TIMESTAMPTZ,
15
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
16
-
);
17
-
CREATE INDEX idx_backup_codes_did ON backup_codes(did);
18
-
19
-
CREATE TABLE passkeys (
20
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
21
-
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
22
-
credential_id BYTEA NOT NULL UNIQUE,
23
-
public_key BYTEA NOT NULL,
24
-
sign_count INTEGER NOT NULL DEFAULT 0,
25
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
26
-
last_used TIMESTAMPTZ,
27
-
friendly_name TEXT,
28
-
aaguid BYTEA,
29
-
transports TEXT[]
30
-
);
31
-
CREATE INDEX idx_passkeys_did ON passkeys(did);
32
-
33
-
CREATE TABLE webauthn_challenges (
34
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
35
-
did TEXT NOT NULL,
36
-
challenge BYTEA NOT NULL,
37
-
challenge_type TEXT NOT NULL,
38
-
state_json TEXT NOT NULL,
39
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
40
-
expires_at TIMESTAMPTZ NOT NULL
41
-
);
42
-
CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
···
+9
-3
src/api/admin/account/update.rs
+9
-3
src/api/admin/account/update.rs
···
67
Json(input): Json<UpdateAccountHandleInput>,
68
) -> Response {
69
let did = input.did.trim();
70
-
let handle = input.handle.trim();
71
-
if did.is_empty() || handle.is_empty() {
72
return (
73
StatusCode::BAD_REQUEST,
74
Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})),
75
)
76
.into_response();
77
}
78
-
if !handle
79
.chars()
80
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
81
{
···
87
)
88
.into_response();
89
}
90
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
91
.fetch_optional(&state.db)
92
.await
···
67
Json(input): Json<UpdateAccountHandleInput>,
68
) -> Response {
69
let did = input.did.trim();
70
+
let input_handle = input.handle.trim();
71
+
if did.is_empty() || input_handle.is_empty() {
72
return (
73
StatusCode::BAD_REQUEST,
74
Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})),
75
)
76
.into_response();
77
}
78
+
if !input_handle
79
.chars()
80
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
81
{
···
87
)
88
.into_response();
89
}
90
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
91
+
let handle = if !input_handle.contains('.') {
92
+
format!("{}.{}", input_handle, hostname)
93
+
} else {
94
+
input_handle.to_string()
95
+
};
96
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
97
.fetch_optional(&state.db)
98
.await
+45
-24
src/api/identity/account.rs
+45
-24
src/api/identity/account.rs
···
139
info!(did = %migration_did, "Processing account migration");
140
}
141
142
-
if input.handle.contains('!') || input.handle.contains('@') {
143
-
return (
144
-
StatusCode::BAD_REQUEST,
145
-
Json(
146
-
json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}),
147
-
),
148
-
)
149
-
.into_response();
150
-
}
151
let email: Option<String> = input
152
.email
153
.as_ref()
···
212
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
213
let pds_endpoint = format!("https://{}", hostname);
214
let suffix = format!(".{}", hostname);
215
-
let short_handle = if input.handle.ends_with(&suffix) {
216
-
input.handle.strip_suffix(&suffix).unwrap_or(&input.handle)
217
} else {
218
-
&input.handle
219
};
220
-
let full_handle = format!("{}.{}", short_handle, hostname);
221
let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) =
222
if let Some(signing_key_did) = &input.signing_key {
223
let reserved = sqlx::query!(
···
298
)
299
.into_response();
300
}
301
-
if let Err(e) = verify_did_web(d, &hostname, &input.handle).await {
302
return (
303
StatusCode::BAD_REQUEST,
304
Json(json!({"error": "InvalidDid", "message": e})),
···
314
info!(did = %d, "Migration with existing did:plc");
315
d.clone()
316
} else if d.starts_with("did:web:") {
317
-
if let Err(e) = verify_did_web(d, &hostname, &input.handle).await {
318
return (
319
StatusCode::BAD_REQUEST,
320
Json(json!({"error": "InvalidDid", "message": e})),
···
334
let genesis_result = match create_genesis_operation(
335
&signing_key,
336
&rotation_key,
337
-
&full_handle,
338
&pds_endpoint,
339
) {
340
Ok(r) => r,
···
371
let genesis_result = match create_genesis_operation(
372
&signing_key,
373
&rotation_key,
374
-
&full_handle,
375
&pds_endpoint,
376
) {
377
Ok(r) => r,
···
424
.unwrap_or(None);
425
if let Some((account_id, old_handle, deactivated_at)) = existing_account {
426
if deactivated_at.is_some() {
427
-
info!(did = %did, old_handle = %old_handle, new_handle = %short_handle, "Preparing existing account for inbound migration");
428
let update_result: Result<_, sqlx::Error> =
429
sqlx::query("UPDATE users SET handle = $1 WHERE id = $2")
430
-
.bind(short_handle)
431
.bind(account_id)
432
.execute(&mut *tx)
433
.await;
···
536
return (
537
StatusCode::OK,
538
Json(CreateAccountOutput {
539
-
handle: full_handle.clone(),
540
did,
541
access_jwt: Some(access_meta.token),
542
refresh_jwt: Some(refresh_meta.token),
···
556
}
557
let exists_result: Option<(i32,)> =
558
sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL")
559
-
.bind(short_handle)
560
.fetch_optional(&mut *tx)
561
.await
562
.unwrap_or(None);
···
660
is_admin, deactivated_at, email_verified
661
) VALUES ($1, $2, $3, $4, $5::comms_channel, $6, $7, $8, $9, $10, $11) RETURNING id"#,
662
)
663
-
.bind(short_handle)
664
.bind(&email)
665
.bind(&did)
666
.bind(&password_hash)
···
898
}
899
if !is_migration {
900
if let Err(e) =
901
-
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle))
902
.await
903
{
904
warn!("Failed to sequence identity event for {}: {}", did, e);
···
991
(
992
StatusCode::OK,
993
Json(CreateAccountOutput {
994
-
handle: full_handle.clone(),
995
did,
996
access_jwt,
997
refresh_jwt,
···
139
info!(did = %migration_did, "Processing account migration");
140
}
141
142
+
let hostname_for_validation = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
143
+
let pds_suffix = format!(".{}", hostname_for_validation);
144
+
145
+
let validated_short_handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
146
+
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
147
+
input.handle.strip_suffix(&pds_suffix).unwrap_or(&input.handle)
148
+
} else {
149
+
&input.handle
150
+
};
151
+
match crate::api::validation::validate_short_handle(handle_to_validate) {
152
+
Ok(h) => h,
153
+
Err(e) => {
154
+
return (
155
+
StatusCode::BAD_REQUEST,
156
+
Json(json!({"error": "InvalidHandle", "message": e.to_string()})),
157
+
)
158
+
.into_response();
159
+
}
160
+
}
161
+
} else {
162
+
if input.handle.contains(' ') || input.handle.contains('\t') {
163
+
return (
164
+
StatusCode::BAD_REQUEST,
165
+
Json(json!({"error": "InvalidHandle", "message": "Handle cannot contain spaces"})),
166
+
)
167
+
.into_response();
168
+
}
169
+
input.handle.to_lowercase()
170
+
};
171
let email: Option<String> = input
172
.email
173
.as_ref()
···
232
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
233
let pds_endpoint = format!("https://{}", hostname);
234
let suffix = format!(".{}", hostname);
235
+
let handle = if input.handle.ends_with(&suffix) {
236
+
format!("{}.{}", validated_short_handle, hostname)
237
+
} else if input.handle.contains('.') {
238
+
validated_short_handle.clone()
239
} else {
240
+
format!("{}.{}", validated_short_handle, hostname)
241
};
242
let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) =
243
if let Some(signing_key_did) = &input.signing_key {
244
let reserved = sqlx::query!(
···
319
)
320
.into_response();
321
}
322
+
if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await {
323
return (
324
StatusCode::BAD_REQUEST,
325
Json(json!({"error": "InvalidDid", "message": e})),
···
335
info!(did = %d, "Migration with existing did:plc");
336
d.clone()
337
} else if d.starts_with("did:web:") {
338
+
if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await {
339
return (
340
StatusCode::BAD_REQUEST,
341
Json(json!({"error": "InvalidDid", "message": e})),
···
355
let genesis_result = match create_genesis_operation(
356
&signing_key,
357
&rotation_key,
358
+
&handle,
359
&pds_endpoint,
360
) {
361
Ok(r) => r,
···
392
let genesis_result = match create_genesis_operation(
393
&signing_key,
394
&rotation_key,
395
+
&handle,
396
&pds_endpoint,
397
) {
398
Ok(r) => r,
···
445
.unwrap_or(None);
446
if let Some((account_id, old_handle, deactivated_at)) = existing_account {
447
if deactivated_at.is_some() {
448
+
info!(did = %did, old_handle = %old_handle, new_handle = %handle, "Preparing existing account for inbound migration");
449
let update_result: Result<_, sqlx::Error> =
450
sqlx::query("UPDATE users SET handle = $1 WHERE id = $2")
451
+
.bind(&handle)
452
.bind(account_id)
453
.execute(&mut *tx)
454
.await;
···
557
return (
558
StatusCode::OK,
559
Json(CreateAccountOutput {
560
+
handle: handle.clone(),
561
did,
562
access_jwt: Some(access_meta.token),
563
refresh_jwt: Some(refresh_meta.token),
···
577
}
578
let exists_result: Option<(i32,)> =
579
sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL")
580
+
.bind(&handle)
581
.fetch_optional(&mut *tx)
582
.await
583
.unwrap_or(None);
···
681
is_admin, deactivated_at, email_verified
682
) VALUES ($1, $2, $3, $4, $5::comms_channel, $6, $7, $8, $9, $10, $11) RETURNING id"#,
683
)
684
+
.bind(&handle)
685
.bind(&email)
686
.bind(&did)
687
.bind(&password_hash)
···
919
}
920
if !is_migration {
921
if let Err(e) =
922
+
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle))
923
.await
924
{
925
warn!("Failed to sequence identity event for {}: {}", did, e);
···
1012
(
1013
StatusCode::OK,
1014
Json(CreateAccountOutput {
1015
+
handle: handle.clone(),
1016
did,
1017
access_jwt,
1018
refresh_jwt,
+64
-56
src/api/identity/did.rs
+64
-56
src/api/identity/did.rs
···
36
if let Some(did) = state.cache.get(&cache_key).await {
37
return (StatusCode::OK, Json(json!({ "did": did }))).into_response();
38
}
39
-
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
40
-
let suffix = format!(".{}", hostname);
41
-
let short_handle = if handle.ends_with(&suffix) {
42
-
handle.strip_suffix(&suffix).unwrap_or(handle)
43
-
} else {
44
-
handle
45
-
};
46
-
let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", short_handle)
47
.fetch_optional(&state.db)
48
.await;
49
match user {
···
139
}
140
141
async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response {
142
let user = sqlx::query!(
143
"SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
144
-
handle
145
)
146
.fetch_optional(&state.db)
147
.await;
···
212
.into_response();
213
}
214
};
215
-
let full_handle = if handle.contains('.') {
216
-
handle.to_string()
217
-
} else {
218
-
format!("{}.{}", handle, hostname)
219
-
};
220
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
221
Json(json!({
222
"@context": [
···
225
"https://w3id.org/security/suites/secp256k1-2019/v1"
226
],
227
"id": did,
228
-
"alsoKnownAs": [format!("at://{}", full_handle)],
229
"verificationMethod": [{
230
"id": format!("{}#atproto", did),
231
"type": "Multikey",
···
243
244
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
245
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
246
let user = sqlx::query!(
247
"SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
248
-
handle
249
)
250
.fetch_optional(&state.db)
251
.await;
···
318
.into_response();
319
}
320
};
321
-
let full_handle = if handle.contains('.') {
322
-
handle.clone()
323
-
} else {
324
-
format!("{}.{}", handle, hostname)
325
-
};
326
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
327
Json(json!({
328
"@context": [
···
331
"https://w3id.org/security/suites/secp256k1-2019/v1"
332
],
333
"id": did,
334
-
"alsoKnownAs": [format!("at://{}", full_handle)],
335
"verificationMethod": [{
336
"id": format!("{}#atproto", did),
337
"type": "Multikey",
···
347
.into_response()
348
}
349
350
-
pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(), String> {
351
let subdomain_host = format!("{}.{}", handle, hostname);
352
let encoded_subdomain = subdomain_host.replace(':', "%3A");
353
let expected_subdomain_did = format!("did:web:{}", encoded_subdomain);
···
371
));
372
}
373
}
374
let parts: Vec<&str> = did.split(':').collect();
375
if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
376
return Err("Invalid did:web format".into());
···
411
let has_valid_service = services
412
.iter()
413
.any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint);
414
-
if has_valid_service {
415
-
Ok(())
416
-
} else {
417
-
Err(format!(
418
"DID document does not list this PDS ({}) as AtprotoPersonalDataServer",
419
pds_endpoint
420
-
))
421
}
422
}
423
424
#[derive(serde::Serialize)]
···
492
};
493
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
494
let pds_endpoint = format!("https://{}", hostname);
495
-
let full_handle = if user.handle.contains('.') {
496
-
user.handle.clone()
497
-
} else {
498
-
format!("{}.{}", user.handle, hostname)
499
-
};
500
let signing_key = match k256::ecdsa::SigningKey::from_slice(&key_bytes) {
501
Ok(k) => k,
502
Err(_) => return ApiError::InternalError.into_response(),
···
511
StatusCode::OK,
512
Json(GetRecommendedDidCredentialsOutput {
513
rotation_keys,
514
-
also_known_as: vec![format!("at://{}", full_handle)],
515
verification_methods: VerificationMethods { atproto: did_key },
516
services: Services {
517
atproto_pds: AtprotoPds {
···
577
.into_response();
578
}
579
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
580
let is_service_domain = crate::handle::is_service_domain_handle(new_handle, &hostname);
581
-
let (handle_to_store, full_handle) = if is_service_domain {
582
-
let suffix = format!(".{}", hostname);
583
-
let short_handle = if new_handle.ends_with(&suffix) {
584
new_handle.strip_suffix(&suffix).unwrap_or(new_handle)
585
} else {
586
new_handle
587
};
588
-
(
589
-
short_handle.to_string(),
590
-
format!("{}.{}", short_handle, hostname),
591
-
)
592
} else {
593
match crate::handle::verify_handle_ownership(new_handle, &did).await {
594
Ok(()) => {}
···
625
.into_response();
626
}
627
}
628
-
(new_handle.to_string(), new_handle.to_string())
629
};
630
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE id = $1", user_id)
631
.fetch_optional(&state.db)
···
634
.flatten();
635
let existing = sqlx::query!(
636
"SELECT id FROM users WHERE handle = $1 AND id != $2",
637
-
handle_to_store,
638
user_id
639
)
640
.fetch_optional(&state.db)
···
648
}
649
let result = sqlx::query!(
650
"UPDATE users SET handle = $1 WHERE id = $2",
651
-
handle_to_store,
652
user_id
653
)
654
.execute(&state.db)
···
660
}
661
let _ = state
662
.cache
663
-
.delete(&format!("handle:{}", handle_to_store))
664
.await;
665
-
let _ = state.cache.delete(&format!("handle:{}", full_handle)).await;
666
if let Err(e) =
667
-
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle))
668
.await
669
{
670
warn!("Failed to sequence identity event for handle update: {}", e);
671
}
672
-
if let Err(e) = update_plc_handle(&state, &did, &full_handle).await {
673
warn!("Failed to update PLC handle: {}", e);
674
}
675
(StatusCode::OK, Json(json!({}))).into_response()
···
723
Some(h) => h,
724
None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(),
725
};
726
-
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
727
-
let suffix = format!(".{}", hostname);
728
let handle = host.split(':').next().unwrap_or(host);
729
-
let short_handle = if handle.ends_with(&suffix) {
730
-
handle.strip_suffix(&suffix).unwrap_or(handle)
731
-
} else {
732
-
return (StatusCode::NOT_FOUND, "Handle not found").into_response();
733
-
};
734
-
let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", short_handle)
735
.fetch_optional(&state.db)
736
.await;
737
match user {
···
36
if let Some(did) = state.cache.get(&cache_key).await {
37
return (StatusCode::OK, Json(json!({ "did": did }))).into_response();
38
}
39
+
let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle)
40
.fetch_optional(&state.db)
41
.await;
42
match user {
···
132
}
133
134
async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response {
135
+
let full_handle = format!("{}.{}", handle, hostname);
136
let user = sqlx::query!(
137
"SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
138
+
full_handle
139
)
140
.fetch_optional(&state.db)
141
.await;
···
206
.into_response();
207
}
208
};
209
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
210
Json(json!({
211
"@context": [
···
214
"https://w3id.org/security/suites/secp256k1-2019/v1"
215
],
216
"id": did,
217
+
"alsoKnownAs": [format!("at://{}", handle)],
218
"verificationMethod": [{
219
"id": format!("{}#atproto", did),
220
"type": "Multikey",
···
232
233
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
234
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
235
+
let full_handle = format!("{}.{}", handle, hostname);
236
let user = sqlx::query!(
237
"SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
238
+
full_handle
239
)
240
.fetch_optional(&state.db)
241
.await;
···
308
.into_response();
309
}
310
};
311
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
312
Json(json!({
313
"@context": [
···
316
"https://w3id.org/security/suites/secp256k1-2019/v1"
317
],
318
"id": did,
319
+
"alsoKnownAs": [format!("at://{}", handle)],
320
"verificationMethod": [{
321
"id": format!("{}#atproto", did),
322
"type": "Multikey",
···
332
.into_response()
333
}
334
335
+
pub async fn verify_did_web(
336
+
did: &str,
337
+
hostname: &str,
338
+
handle: &str,
339
+
expected_signing_key: Option<&str>,
340
+
) -> Result<(), String> {
341
let subdomain_host = format!("{}.{}", handle, hostname);
342
let encoded_subdomain = subdomain_host.replace(':', "%3A");
343
let expected_subdomain_did = format!("did:web:{}", encoded_subdomain);
···
361
));
362
}
363
}
364
+
let expected_signing_key = expected_signing_key.ok_or_else(|| {
365
+
"External did:web requires a pre-reserved signing key. Call com.atproto.server.reserveSigningKey first, configure your DID document with the returned key, then provide the signingKey in createAccount.".to_string()
366
+
})?;
367
let parts: Vec<&str> = did.split(':').collect();
368
if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
369
return Err("Invalid did:web format".into());
···
404
let has_valid_service = services
405
.iter()
406
.any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint);
407
+
if !has_valid_service {
408
+
return Err(format!(
409
"DID document does not list this PDS ({}) as AtprotoPersonalDataServer",
410
pds_endpoint
411
+
));
412
}
413
+
let verification_methods = doc["verificationMethod"]
414
+
.as_array()
415
+
.ok_or("No verificationMethod found in DID doc")?;
416
+
let expected_multibase = expected_signing_key
417
+
.strip_prefix("did:key:")
418
+
.ok_or("Invalid signing key format")?;
419
+
let has_matching_key = verification_methods.iter().any(|vm| {
420
+
vm["publicKeyMultibase"]
421
+
.as_str()
422
+
.map(|pk| pk == expected_multibase)
423
+
.unwrap_or(false)
424
+
});
425
+
if !has_matching_key {
426
+
return Err(format!(
427
+
"DID document verification key does not match reserved signing key. Expected publicKeyMultibase: {}",
428
+
expected_multibase
429
+
));
430
+
}
431
+
Ok(())
432
}
433
434
#[derive(serde::Serialize)]
···
502
};
503
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
504
let pds_endpoint = format!("https://{}", hostname);
505
let signing_key = match k256::ecdsa::SigningKey::from_slice(&key_bytes) {
506
Ok(k) => k,
507
Err(_) => return ApiError::InternalError.into_response(),
···
516
StatusCode::OK,
517
Json(GetRecommendedDidCredentialsOutput {
518
rotation_keys,
519
+
also_known_as: vec![format!("at://{}", user.handle)],
520
verification_methods: VerificationMethods { atproto: did_key },
521
services: Services {
522
atproto_pds: AtprotoPds {
···
582
.into_response();
583
}
584
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
585
+
let suffix = format!(".{}", hostname);
586
let is_service_domain = crate::handle::is_service_domain_handle(new_handle, &hostname);
587
+
let handle = if is_service_domain {
588
+
let short_part = if new_handle.ends_with(&suffix) {
589
new_handle.strip_suffix(&suffix).unwrap_or(new_handle)
590
} else {
591
new_handle
592
};
593
+
if short_part.contains('.') {
594
+
return (
595
+
StatusCode::BAD_REQUEST,
596
+
Json(json!({
597
+
"error": "InvalidHandle",
598
+
"message": "Nested subdomains are not allowed. Use a simple handle without dots."
599
+
})),
600
+
)
601
+
.into_response();
602
+
}
603
+
if new_handle.ends_with(&suffix) {
604
+
new_handle.to_string()
605
+
} else {
606
+
format!("{}.{}", new_handle, hostname)
607
+
}
608
} else {
609
match crate::handle::verify_handle_ownership(new_handle, &did).await {
610
Ok(()) => {}
···
641
.into_response();
642
}
643
}
644
+
new_handle.to_string()
645
};
646
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE id = $1", user_id)
647
.fetch_optional(&state.db)
···
650
.flatten();
651
let existing = sqlx::query!(
652
"SELECT id FROM users WHERE handle = $1 AND id != $2",
653
+
handle,
654
user_id
655
)
656
.fetch_optional(&state.db)
···
664
}
665
let result = sqlx::query!(
666
"UPDATE users SET handle = $1 WHERE id = $2",
667
+
handle,
668
user_id
669
)
670
.execute(&state.db)
···
676
}
677
let _ = state
678
.cache
679
+
.delete(&format!("handle:{}", handle))
680
.await;
681
if let Err(e) =
682
+
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle))
683
.await
684
{
685
warn!("Failed to sequence identity event for handle update: {}", e);
686
}
687
+
if let Err(e) = update_plc_handle(&state, &did, &handle).await {
688
warn!("Failed to update PLC handle: {}", e);
689
}
690
(StatusCode::OK, Json(json!({}))).into_response()
···
738
Some(h) => h,
739
None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(),
740
};
741
let handle = host.split(':').next().unwrap_or(host);
742
+
let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle)
743
.fetch_optional(&state.db)
744
.await;
745
match user {
+2
-2
src/api/repo/blob.rs
+2
-2
src/api/repo/blob.rs
-24
src/api/repo/import.rs
-24
src/api/repo/import.rs
···
318
records.len(),
319
did
320
);
321
-
if is_migration {
322
-
if let Err(e) =
323
-
sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did)
324
-
.execute(&state.db)
325
-
.await
326
-
{
327
-
error!("Failed to reactivate account after import: {:?}", e);
328
-
}
329
-
let _ = state.cache.delete(&format!("handle:{}", user.handle)).await;
330
-
if let Err(e) = crate::api::repo::record::sequence_identity_event(
331
-
&state,
332
-
did,
333
-
Some(&user.handle),
334
-
)
335
-
.await
336
-
{
337
-
warn!("Failed to sequence identity event after import: {:?}", e);
338
-
}
339
-
if let Err(e) =
340
-
crate::api::repo::record::sequence_account_event(&state, did, true, None).await
341
-
{
342
-
warn!("Failed to sequence account event after import: {:?}", e);
343
-
}
344
-
}
345
if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await {
346
warn!("Failed to sequence import event: {:?}", e);
347
}
+7
-1
src/api/repo/meta.rs
+7
-1
src/api/repo/meta.rs
···
17
State(state): State<AppState>,
18
Query(input): Query<DescribeRepoInput>,
19
) -> Response {
20
let user_row = if input.repo.starts_with("did:") {
21
sqlx::query!(
22
"SELECT id, handle, did FROM users WHERE did = $1",
···
26
.await
27
.map(|opt| opt.map(|r| (r.id, r.handle, r.did)))
28
} else {
29
sqlx::query!(
30
"SELECT id, handle, did FROM users WHERE handle = $1",
31
-
input.repo
32
)
33
.fetch_optional(&state.db)
34
.await
···
17
State(state): State<AppState>,
18
Query(input): Query<DescribeRepoInput>,
19
) -> Response {
20
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
21
let user_row = if input.repo.starts_with("did:") {
22
sqlx::query!(
23
"SELECT id, handle, did FROM users WHERE did = $1",
···
27
.await
28
.map(|opt| opt.map(|r| (r.id, r.handle, r.did)))
29
} else {
30
+
let handle = if !input.repo.contains('.') {
31
+
format!("{}.{}", input.repo, hostname)
32
+
} else {
33
+
input.repo.clone()
34
+
};
35
sqlx::query!(
36
"SELECT id, handle, did FROM users WHERE handle = $1",
37
+
handle
38
)
39
.fetch_optional(&state.db)
40
.await
+8
-10
src/api/repo/record/read.rs
+8
-10
src/api/repo/record/read.rs
···
34
.await
35
.map(|opt| opt.map(|r| r.id))
36
} else {
37
-
let suffix = format!(".{}", hostname);
38
-
let short_handle = if input.repo.ends_with(&suffix) {
39
-
input.repo.strip_suffix(&suffix).unwrap_or(&input.repo)
40
} else {
41
-
&input.repo
42
};
43
-
sqlx::query!("SELECT id FROM users WHERE handle = $1", short_handle)
44
.fetch_optional(&state.db)
45
.await
46
.map(|opt| opt.map(|r| r.id))
···
212
.await
213
.map(|opt| opt.map(|r| r.id))
214
} else {
215
-
let suffix = format!(".{}", hostname);
216
-
let short_handle = if input.repo.ends_with(&suffix) {
217
-
input.repo.strip_suffix(&suffix).unwrap_or(&input.repo)
218
} else {
219
-
&input.repo
220
};
221
-
sqlx::query!("SELECT id FROM users WHERE handle = $1", short_handle)
222
.fetch_optional(&state.db)
223
.await
224
.map(|opt| opt.map(|r| r.id))
···
34
.await
35
.map(|opt| opt.map(|r| r.id))
36
} else {
37
+
let handle = if !input.repo.contains('.') {
38
+
format!("{}.{}", input.repo, hostname)
39
} else {
40
+
input.repo.clone()
41
};
42
+
sqlx::query!("SELECT id FROM users WHERE handle = $1", handle)
43
.fetch_optional(&state.db)
44
.await
45
.map(|opt| opt.map(|r| r.id))
···
211
.await
212
.map(|opt| opt.map(|r| r.id))
213
} else {
214
+
let handle = if !input.repo.contains('.') {
215
+
format!("{}.{}", input.repo, hostname)
216
} else {
217
+
input.repo.clone()
218
};
219
+
sqlx::query!("SELECT id FROM users WHERE handle = $1", handle)
220
.fetch_optional(&state.db)
221
.await
222
.map(|opt| opt.map(|r| r.id))
+10
-12
src/api/server/session.rs
+10
-12
src/api/server/session.rs
···
29
}
30
31
fn normalize_handle(identifier: &str, pds_hostname: &str) -> String {
32
-
let suffix = format!(".{}", pds_hostname);
33
-
if identifier.ends_with(&suffix) {
34
-
identifier[..identifier.len() - suffix.len()].to_string()
35
} else {
36
-
identifier.to_string()
37
}
38
}
39
40
-
fn full_handle(stored_handle: &str, pds_hostname: &str) -> String {
41
-
let suffix = format!(".{}", pds_hostname);
42
-
if stored_handle.ends_with(&suffix) || stored_handle.ends_with(pds_hostname) {
43
-
stored_handle.to_string()
44
-
} else {
45
-
format!("{}.{}", stored_handle, pds_hostname)
46
-
}
47
}
48
49
#[derive(Deserialize)]
···
66
headers: HeaderMap,
67
Json(input): Json<CreateSessionInput>,
68
) -> Response {
69
-
info!("create_session called");
70
let client_ip = extract_client_ip(&headers);
71
if !state
72
.check_rate_limit(RateLimitKind::Login, &client_ip)
···
84
}
85
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
86
let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname);
87
let row = match sqlx::query!(
88
r#"SELECT
89
u.id, u.did, u.handle, u.password_hash,
···
29
}
30
31
fn normalize_handle(identifier: &str, pds_hostname: &str) -> String {
32
+
let identifier = identifier.trim();
33
+
if identifier.contains('@') || identifier.starts_with("did:") {
34
+
identifier.to_string()
35
+
} else if !identifier.contains('.') {
36
+
format!("{}.{}", identifier.to_lowercase(), pds_hostname)
37
} else {
38
+
identifier.to_lowercase()
39
}
40
}
41
42
+
fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String {
43
+
stored_handle.to_string()
44
}
45
46
#[derive(Deserialize)]
···
63
headers: HeaderMap,
64
Json(input): Json<CreateSessionInput>,
65
) -> Response {
66
+
info!("create_session called with identifier: {}", input.identifier);
67
let client_ip = extract_client_ip(&headers);
68
if !state
69
.check_rate_limit(RateLimitKind::Login, &client_ip)
···
81
}
82
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
83
let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname);
84
+
info!("Normalized identifier: {} -> {}", input.identifier, normalized_identifier);
85
let row = match sqlx::query!(
86
r#"SELECT
87
u.id, u.did, u.handle, u.password_hash,
+100
src/api/validation.rs
+100
src/api/validation.rs
···
4
pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63;
5
const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-";
6
7
pub fn is_valid_email(email: &str) -> bool {
8
let email = email.trim();
9
if email.is_empty() || email.len() > MAX_EMAIL_LENGTH {
···
54
#[cfg(test)]
55
mod tests {
56
use super::*;
57
#[test]
58
fn test_valid_emails() {
59
assert!(is_valid_email("user@example.com"));
···
4
pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63;
5
const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-";
6
7
+
pub const MIN_HANDLE_LENGTH: usize = 3;
8
+
pub const MAX_HANDLE_LENGTH: usize = 253;
9
+
10
+
#[derive(Debug, PartialEq)]
11
+
pub enum HandleValidationError {
12
+
Empty,
13
+
TooShort,
14
+
TooLong,
15
+
InvalidCharacters,
16
+
StartsWithInvalidChar,
17
+
EndsWithInvalidChar,
18
+
ContainsSpaces,
19
+
}
20
+
21
+
impl std::fmt::Display for HandleValidationError {
22
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23
+
match self {
24
+
Self::Empty => write!(f, "Handle cannot be empty"),
25
+
Self::TooShort => write!(f, "Handle must be at least {} characters", MIN_HANDLE_LENGTH),
26
+
Self::TooLong => write!(f, "Handle exceeds maximum length of {} characters", MAX_HANDLE_LENGTH),
27
+
Self::InvalidCharacters => write!(f, "Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed"),
28
+
Self::StartsWithInvalidChar => write!(f, "Handle cannot start with a hyphen or underscore"),
29
+
Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen or underscore"),
30
+
Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"),
31
+
}
32
+
}
33
+
}
34
+
35
+
pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
36
+
let handle = handle.trim();
37
+
38
+
if handle.is_empty() {
39
+
return Err(HandleValidationError::Empty);
40
+
}
41
+
42
+
if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') {
43
+
return Err(HandleValidationError::ContainsSpaces);
44
+
}
45
+
46
+
if handle.len() < MIN_HANDLE_LENGTH {
47
+
return Err(HandleValidationError::TooShort);
48
+
}
49
+
50
+
if handle.len() > MAX_HANDLE_LENGTH {
51
+
return Err(HandleValidationError::TooLong);
52
+
}
53
+
54
+
let first_char = handle.chars().next().unwrap();
55
+
if first_char == '-' || first_char == '_' {
56
+
return Err(HandleValidationError::StartsWithInvalidChar);
57
+
}
58
+
59
+
let last_char = handle.chars().last().unwrap();
60
+
if last_char == '-' || last_char == '_' {
61
+
return Err(HandleValidationError::EndsWithInvalidChar);
62
+
}
63
+
64
+
for c in handle.chars() {
65
+
if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
66
+
return Err(HandleValidationError::InvalidCharacters);
67
+
}
68
+
}
69
+
70
+
Ok(handle.to_lowercase())
71
+
}
72
+
73
pub fn is_valid_email(email: &str) -> bool {
74
let email = email.trim();
75
if email.is_empty() || email.len() > MAX_EMAIL_LENGTH {
···
120
#[cfg(test)]
121
mod tests {
122
use super::*;
123
+
124
+
#[test]
125
+
fn test_valid_handles() {
126
+
assert_eq!(validate_short_handle("alice"), Ok("alice".to_string()));
127
+
assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string()));
128
+
assert_eq!(validate_short_handle("user-name"), Ok("user-name".to_string()));
129
+
assert_eq!(validate_short_handle("user_name"), Ok("user_name".to_string()));
130
+
assert_eq!(validate_short_handle("UPPERCASE"), Ok("uppercase".to_string()));
131
+
assert_eq!(validate_short_handle("MixedCase123"), Ok("mixedcase123".to_string()));
132
+
assert_eq!(validate_short_handle("abc"), Ok("abc".to_string()));
133
+
}
134
+
135
+
#[test]
136
+
fn test_invalid_handles() {
137
+
assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty));
138
+
assert_eq!(validate_short_handle(" "), Err(HandleValidationError::Empty));
139
+
assert_eq!(validate_short_handle("ab"), Err(HandleValidationError::TooShort));
140
+
assert_eq!(validate_short_handle("a"), Err(HandleValidationError::TooShort));
141
+
assert_eq!(validate_short_handle("test spaces"), Err(HandleValidationError::ContainsSpaces));
142
+
assert_eq!(validate_short_handle("test\ttab"), Err(HandleValidationError::ContainsSpaces));
143
+
assert_eq!(validate_short_handle("-starts"), Err(HandleValidationError::StartsWithInvalidChar));
144
+
assert_eq!(validate_short_handle("_starts"), Err(HandleValidationError::StartsWithInvalidChar));
145
+
assert_eq!(validate_short_handle("ends-"), Err(HandleValidationError::EndsWithInvalidChar));
146
+
assert_eq!(validate_short_handle("ends_"), Err(HandleValidationError::EndsWithInvalidChar));
147
+
assert_eq!(validate_short_handle("test@user"), Err(HandleValidationError::InvalidCharacters));
148
+
assert_eq!(validate_short_handle("test!user"), Err(HandleValidationError::InvalidCharacters));
149
+
assert_eq!(validate_short_handle("test.user"), Err(HandleValidationError::InvalidCharacters));
150
+
}
151
+
152
+
#[test]
153
+
fn test_handle_trimming() {
154
+
assert_eq!(validate_short_handle(" alice "), Ok("alice".to_string()));
155
+
}
156
+
157
#[test]
158
fn test_valid_emails() {
159
assert!(is_valid_email("user@example.com"));
+30
-29
tests/email_update.rs
+30
-29
tests/email_update.rs
···
17
base_url: &str,
18
handle: &str,
19
email: &str,
20
-
) -> String {
21
let res = client
22
.post(format!(
23
"{}/xrpc/com.atproto.server.createAccount",
···
33
.expect("Failed to create account");
34
assert_eq!(res.status(), StatusCode::OK);
35
let body: Value = res.json().await.expect("Invalid JSON");
36
-
let did = body["did"].as_str().expect("No did");
37
-
common::verify_new_account(client, did).await
38
}
39
40
#[tokio::test]
···
44
let pool = get_pool().await;
45
let handle = format!("emailup_{}", uuid::Uuid::new_v4());
46
let email = format!("{}@example.com", handle);
47
-
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
48
let new_email = format!("new_{}@example.com", handle);
49
let res = client
50
.post(format!(
···
61
assert_eq!(body["tokenRequired"], true);
62
63
let verification = sqlx::query!(
64
-
"SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
65
-
handle
66
)
67
.fetch_one(&pool)
68
.await
···
84
.await
85
.expect("Failed to confirm email");
86
assert_eq!(res.status(), StatusCode::OK);
87
-
let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle)
88
.fetch_one(&pool)
89
.await
90
.expect("User not found");
91
assert_eq!(user.email, Some(new_email));
92
93
let verification = sqlx::query!(
94
-
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
95
-
handle
96
)
97
.fetch_optional(&pool)
98
.await
···
106
let base_url = common::base_url().await;
107
let handle1 = format!("emailup_taken1_{}", uuid::Uuid::new_v4());
108
let email1 = format!("{}@example.com", handle1);
109
-
let _ = create_verified_account(&client, &base_url, &handle1, &email1).await;
110
let handle2 = format!("emailup_taken2_{}", uuid::Uuid::new_v4());
111
let email2 = format!("{}@example.com", handle2);
112
-
let access_jwt2 = create_verified_account(&client, &base_url, &handle2, &email2).await;
113
let res = client
114
.post(format!(
115
"{}/xrpc/com.atproto.server.requestEmailUpdate",
···
131
let base_url = common::base_url().await;
132
let handle = format!("emailup_inv_{}", uuid::Uuid::new_v4());
133
let email = format!("{}@example.com", handle);
134
-
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
135
let new_email = format!("new_{}@example.com", handle);
136
let res = client
137
.post(format!(
···
166
let pool = get_pool().await;
167
let handle = format!("emailup_wrong_{}", uuid::Uuid::new_v4());
168
let email = format!("{}@example.com", handle);
169
-
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
170
let new_email = format!("new_{}@example.com", handle);
171
let res = client
172
.post(format!(
···
180
.expect("Failed to request email update");
181
assert_eq!(res.status(), StatusCode::OK);
182
let verification = sqlx::query!(
183
-
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
184
-
handle
185
)
186
.fetch_one(&pool)
187
.await
···
209
let pool = get_pool().await;
210
let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4());
211
let email = format!("{}@example.com", handle);
212
-
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
213
let new_email = format!("direct_{}@example.com", handle);
214
let res = client
215
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
···
219
.await
220
.expect("Failed to update email");
221
assert_eq!(res.status(), StatusCode::OK);
222
-
let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle)
223
.fetch_one(&pool)
224
.await
225
.expect("User not found");
···
232
let base_url = common::base_url().await;
233
let handle = format!("emailup_same_{}", uuid::Uuid::new_v4());
234
let email = format!("{}@example.com", handle);
235
-
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
236
let res = client
237
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
238
.bearer_auth(&access_jwt)
···
253
let base_url = common::base_url().await;
254
let handle = format!("emailup_token_{}", uuid::Uuid::new_v4());
255
let email = format!("{}@example.com", handle);
256
-
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
257
let new_email = format!("pending_{}@example.com", handle);
258
let res = client
259
.post(format!(
···
285
let pool = get_pool().await;
286
let handle = format!("emailup_valid_{}", uuid::Uuid::new_v4());
287
let email = format!("{}@example.com", handle);
288
-
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
289
let new_email = format!("valid_{}@example.com", handle);
290
let res = client
291
.post(format!(
···
299
.expect("Failed to request email update");
300
assert_eq!(res.status(), StatusCode::OK);
301
let verification = sqlx::query!(
302
-
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
303
-
handle
304
)
305
.fetch_one(&pool)
306
.await
···
317
.await
318
.expect("Failed to update email");
319
assert_eq!(res.status(), StatusCode::OK);
320
-
let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle)
321
.fetch_one(&pool)
322
.await
323
.expect("User not found");
324
assert_eq!(user.email, Some(new_email));
325
let verification = sqlx::query!(
326
-
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
327
-
handle
328
)
329
.fetch_optional(&pool)
330
.await
···
338
let base_url = common::base_url().await;
339
let handle = format!("emailup_badtok_{}", uuid::Uuid::new_v4());
340
let email = format!("{}@example.com", handle);
341
-
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
342
let new_email = format!("badtok_{}@example.com", handle);
343
let res = client
344
.post(format!(
···
372
let base_url = common::base_url().await;
373
let handle1 = format!("emailup_dup1_{}", uuid::Uuid::new_v4());
374
let email1 = format!("{}@example.com", handle1);
375
-
let _ = create_verified_account(&client, &base_url, &handle1, &email1).await;
376
let handle2 = format!("emailup_dup2_{}", uuid::Uuid::new_v4());
377
let email2 = format!("{}@example.com", handle2);
378
-
let access_jwt2 = create_verified_account(&client, &base_url, &handle2, &email2).await;
379
let res = client
380
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
381
.bearer_auth(&access_jwt2)
···
412
let base_url = common::base_url().await;
413
let handle = format!("emailup_fmt_{}", uuid::Uuid::new_v4());
414
let email = format!("{}@example.com", handle);
415
-
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
416
let res = client
417
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
418
.bearer_auth(&access_jwt)
···
17
base_url: &str,
18
handle: &str,
19
email: &str,
20
+
) -> (String, String) {
21
let res = client
22
.post(format!(
23
"{}/xrpc/com.atproto.server.createAccount",
···
33
.expect("Failed to create account");
34
assert_eq!(res.status(), StatusCode::OK);
35
let body: Value = res.json().await.expect("Invalid JSON");
36
+
let did = body["did"].as_str().expect("No did").to_string();
37
+
let jwt = common::verify_new_account(client, &did).await;
38
+
(jwt, did)
39
}
40
41
#[tokio::test]
···
45
let pool = get_pool().await;
46
let handle = format!("emailup_{}", uuid::Uuid::new_v4());
47
let email = format!("{}@example.com", handle);
48
+
let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await;
49
let new_email = format!("new_{}@example.com", handle);
50
let res = client
51
.post(format!(
···
62
assert_eq!(body["tokenRequired"], true);
63
64
let verification = sqlx::query!(
65
+
"SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
66
+
did
67
)
68
.fetch_one(&pool)
69
.await
···
85
.await
86
.expect("Failed to confirm email");
87
assert_eq!(res.status(), StatusCode::OK);
88
+
let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did)
89
.fetch_one(&pool)
90
.await
91
.expect("User not found");
92
assert_eq!(user.email, Some(new_email));
93
94
let verification = sqlx::query!(
95
+
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
96
+
did
97
)
98
.fetch_optional(&pool)
99
.await
···
107
let base_url = common::base_url().await;
108
let handle1 = format!("emailup_taken1_{}", uuid::Uuid::new_v4());
109
let email1 = format!("{}@example.com", handle1);
110
+
let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await;
111
let handle2 = format!("emailup_taken2_{}", uuid::Uuid::new_v4());
112
let email2 = format!("{}@example.com", handle2);
113
+
let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await;
114
let res = client
115
.post(format!(
116
"{}/xrpc/com.atproto.server.requestEmailUpdate",
···
132
let base_url = common::base_url().await;
133
let handle = format!("emailup_inv_{}", uuid::Uuid::new_v4());
134
let email = format!("{}@example.com", handle);
135
+
let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await;
136
let new_email = format!("new_{}@example.com", handle);
137
let res = client
138
.post(format!(
···
167
let pool = get_pool().await;
168
let handle = format!("emailup_wrong_{}", uuid::Uuid::new_v4());
169
let email = format!("{}@example.com", handle);
170
+
let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await;
171
let new_email = format!("new_{}@example.com", handle);
172
let res = client
173
.post(format!(
···
181
.expect("Failed to request email update");
182
assert_eq!(res.status(), StatusCode::OK);
183
let verification = sqlx::query!(
184
+
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
185
+
did
186
)
187
.fetch_one(&pool)
188
.await
···
210
let pool = get_pool().await;
211
let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4());
212
let email = format!("{}@example.com", handle);
213
+
let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await;
214
let new_email = format!("direct_{}@example.com", handle);
215
let res = client
216
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
···
220
.await
221
.expect("Failed to update email");
222
assert_eq!(res.status(), StatusCode::OK);
223
+
let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did)
224
.fetch_one(&pool)
225
.await
226
.expect("User not found");
···
233
let base_url = common::base_url().await;
234
let handle = format!("emailup_same_{}", uuid::Uuid::new_v4());
235
let email = format!("{}@example.com", handle);
236
+
let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await;
237
let res = client
238
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
239
.bearer_auth(&access_jwt)
···
254
let base_url = common::base_url().await;
255
let handle = format!("emailup_token_{}", uuid::Uuid::new_v4());
256
let email = format!("{}@example.com", handle);
257
+
let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await;
258
let new_email = format!("pending_{}@example.com", handle);
259
let res = client
260
.post(format!(
···
286
let pool = get_pool().await;
287
let handle = format!("emailup_valid_{}", uuid::Uuid::new_v4());
288
let email = format!("{}@example.com", handle);
289
+
let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await;
290
let new_email = format!("valid_{}@example.com", handle);
291
let res = client
292
.post(format!(
···
300
.expect("Failed to request email update");
301
assert_eq!(res.status(), StatusCode::OK);
302
let verification = sqlx::query!(
303
+
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
304
+
did
305
)
306
.fetch_one(&pool)
307
.await
···
318
.await
319
.expect("Failed to update email");
320
assert_eq!(res.status(), StatusCode::OK);
321
+
let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did)
322
.fetch_one(&pool)
323
.await
324
.expect("User not found");
325
assert_eq!(user.email, Some(new_email));
326
let verification = sqlx::query!(
327
+
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
328
+
did
329
)
330
.fetch_optional(&pool)
331
.await
···
339
let base_url = common::base_url().await;
340
let handle = format!("emailup_badtok_{}", uuid::Uuid::new_v4());
341
let email = format!("{}@example.com", handle);
342
+
let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await;
343
let new_email = format!("badtok_{}@example.com", handle);
344
let res = client
345
.post(format!(
···
373
let base_url = common::base_url().await;
374
let handle1 = format!("emailup_dup1_{}", uuid::Uuid::new_v4());
375
let email1 = format!("{}@example.com", handle1);
376
+
let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await;
377
let handle2 = format!("emailup_dup2_{}", uuid::Uuid::new_v4());
378
let email2 = format!("{}@example.com", handle2);
379
+
let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await;
380
let res = client
381
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
382
.bearer_auth(&access_jwt2)
···
413
let base_url = common::base_url().await;
414
let handle = format!("emailup_fmt_{}", uuid::Uuid::new_v4());
415
let email = format!("{}@example.com", handle);
416
+
let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await;
417
let res = client
418
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
419
.bearer_auth(&access_jwt)
+5
-4
tests/identity.rs
+5
-4
tests/identity.rs
···
8
#[tokio::test]
9
async fn test_resolve_handle_success() {
10
let client = client();
11
-
let handle = format!("resolvetest_{}", uuid::Uuid::new_v4());
12
let payload = json!({
13
-
"handle": handle,
14
-
"email": format!("{}@example.com", handle),
15
"password": "password"
16
});
17
let res = client
···
26
assert_eq!(res.status(), StatusCode::OK);
27
let body: Value = res.json().await.expect("Invalid JSON");
28
let did = body["did"].as_str().expect("No DID").to_string();
29
-
let params = [("handle", handle.as_str())];
30
let res = client
31
.get(format!(
32
"{}/xrpc/com.atproto.identity.resolveHandle",
···
8
#[tokio::test]
9
async fn test_resolve_handle_success() {
10
let client = client();
11
+
let short_handle = format!("resolvetest_{}", uuid::Uuid::new_v4());
12
let payload = json!({
13
+
"handle": short_handle,
14
+
"email": format!("{}@example.com", short_handle),
15
"password": "password"
16
});
17
let res = client
···
26
assert_eq!(res.status(), StatusCode::OK);
27
let body: Value = res.json().await.expect("Invalid JSON");
28
let did = body["did"].as_str().expect("No DID").to_string();
29
+
let full_handle = body["handle"].as_str().expect("No handle in response").to_string();
30
+
let params = [("handle", full_handle.as_str())];
31
let res = client
32
.get(format!(
33
"{}/xrpc/com.atproto.identity.resolveHandle",