+52
.sqlx/query-3377750b73c3831cbd6c96b971ea8b6d4da38f1bc740afce3136d86c27b8ce8d.json
+52
.sqlx/query-3377750b73c3831cbd6c96b971ea8b6d4da38f1bc740afce3136d86c27b8ce8d.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT s.did, k.key_bytes, u.id as user_id, u.email_confirmation_code, u.email_confirmation_code_expires_at, u.email_pending_verification\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "did",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "key_bytes",
14
+
"type_info": "Bytea"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "user_id",
19
+
"type_info": "Uuid"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "email_confirmation_code",
24
+
"type_info": "Text"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "email_confirmation_code_expires_at",
29
+
"type_info": "Timestamptz"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "email_pending_verification",
34
+
"type_info": "Text"
35
+
}
36
+
],
37
+
"parameters": {
38
+
"Left": [
39
+
"Text"
40
+
]
41
+
},
42
+
"nullable": [
43
+
false,
44
+
false,
45
+
false,
46
+
true,
47
+
true,
48
+
true
49
+
]
50
+
},
51
+
"hash": "3377750b73c3831cbd6c96b971ea8b6d4da38f1bc740afce3136d86c27b8ce8d"
52
+
}
+17
.sqlx/query-4b9243c9ef4bf260d179a778536e815c8d563017ecda7dc530aeeebd5362d190.json
+17
.sqlx/query-4b9243c9ef4bf260d179a778536e815c8d563017ecda7dc530aeeebd5362d190.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET email_pending_verification = $1, email_confirmation_code = $2, email_confirmation_code_expires_at = $3 WHERE id = $4",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Text",
10
+
"Timestamptz",
11
+
"Uuid"
12
+
]
13
+
},
14
+
"nullable": []
15
+
},
16
+
"hash": "4b9243c9ef4bf260d179a778536e815c8d563017ecda7dc530aeeebd5362d190"
17
+
}
+40
.sqlx/query-6a233f0ca94195935bf32ee749c8429c2292bb3907f129e06aff033a31681175.json
+40
.sqlx/query-6a233f0ca94195935bf32ee749c8429c2292bb3907f129e06aff033a31681175.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT s.did, k.key_bytes, u.id as user_id, u.handle\n FROM sessions s\n JOIN users u ON s.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE s.access_jwt = $1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "did",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "key_bytes",
14
+
"type_info": "Bytea"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "user_id",
19
+
"type_info": "Uuid"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "handle",
24
+
"type_info": "Text"
25
+
}
26
+
],
27
+
"parameters": {
28
+
"Left": [
29
+
"Text"
30
+
]
31
+
},
32
+
"nullable": [
33
+
false,
34
+
false,
35
+
false,
36
+
false
37
+
]
38
+
},
39
+
"hash": "6a233f0ca94195935bf32ee749c8429c2292bb3907f129e06aff033a31681175"
40
+
}
+15
.sqlx/query-76a239da5103f43b16a768d6970cc7e04d9d27c88cc54072818033a03bf53057.json
+15
.sqlx/query-76a239da5103f43b16a768d6970cc7e04d9d27c88cc54072818033a03bf53057.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET email = $1, email_pending_verification = NULL, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE id = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Uuid"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "76a239da5103f43b16a768d6970cc7e04d9d27c88cc54072818033a03bf53057"
15
+
}
+22
.sqlx/query-8c9c899187a8b19747b1c25dbac1501de14985beafcfed5f0a23549e18da2c19.json
+22
.sqlx/query-8c9c899187a8b19747b1c25dbac1501de14985beafcfed5f0a23549e18da2c19.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT 1 as one FROM users WHERE LOWER(email) = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "one",
9
+
"type_info": "Int4"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
null
19
+
]
20
+
},
21
+
"hash": "8c9c899187a8b19747b1c25dbac1501de14985beafcfed5f0a23549e18da2c19"
22
+
}
+2
-2
TODO.md
+2
-2
TODO.md
···
35
- [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth).
36
- [x] Implement `com.atproto.server.listAppPasswords`.
37
- [x] Implement `com.atproto.server.requestAccountDelete`.
38
-
- [ ] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`.
39
- [x] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`.
40
- [ ] Implement `com.atproto.server.reserveSigningKey`.
41
- [x] Implement `com.atproto.server.revokeAppPassword`.
42
- [ ] Implement `com.atproto.server.updateEmail`.
43
-
- [ ] Implement `com.atproto.server.confirmEmail`.
44
45
## Repository Operations (`com.atproto.repo`)
46
- [ ] Record CRUD
···
35
- [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth).
36
- [x] Implement `com.atproto.server.listAppPasswords`.
37
- [x] Implement `com.atproto.server.requestAccountDelete`.
38
+
- [x] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`.
39
- [x] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`.
40
- [ ] Implement `com.atproto.server.reserveSigningKey`.
41
- [x] Implement `com.atproto.server.revokeAppPassword`.
42
- [ ] Implement `com.atproto.server.updateEmail`.
43
+
- [x] Implement `com.atproto.server.confirmEmail`.
44
45
## Repository Operations (`com.atproto.repo`)
46
- [ ] Record CRUD
+157
migrations/202512211400_initial_schema.sql
+157
migrations/202512211400_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',
7
+
'email_update',
8
+
'account_deletion',
9
+
'admin_email'
10
+
);
11
+
12
+
CREATE TABLE IF NOT EXISTS users (
13
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
14
+
handle TEXT NOT NULL UNIQUE,
15
+
email TEXT NOT NULL UNIQUE,
16
+
did TEXT NOT NULL UNIQUE,
17
+
password_hash TEXT NOT NULL,
18
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
19
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
20
+
21
+
-- status & moderation
22
+
deactivated_at TIMESTAMPTZ,
23
+
invites_disabled BOOLEAN DEFAULT FALSE,
24
+
takedown_ref TEXT,
25
+
26
+
-- notifs
27
+
preferred_notification_channel notification_channel NOT NULL DEFAULT 'email',
28
+
29
+
-- auth & verification
30
+
password_reset_code TEXT,
31
+
password_reset_code_expires_at TIMESTAMPTZ,
32
+
33
+
email_pending_verification TEXT,
34
+
email_confirmation_code TEXT,
35
+
email_confirmation_code_expires_at TIMESTAMPTZ
36
+
);
37
+
38
+
CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
39
+
CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL;
40
+
41
+
CREATE TABLE IF NOT EXISTS invite_codes (
42
+
code TEXT PRIMARY KEY,
43
+
available_uses INT NOT NULL DEFAULT 1,
44
+
created_by_user UUID NOT NULL REFERENCES users(id),
45
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
46
+
disabled BOOLEAN DEFAULT FALSE
47
+
);
48
+
49
+
CREATE TABLE IF NOT EXISTS invite_code_uses (
50
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
51
+
code TEXT NOT NULL REFERENCES invite_codes(code),
52
+
used_by_user UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
53
+
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
54
+
UNIQUE(code, used_by_user)
55
+
);
56
+
57
+
-- TODO: encrypt at rest!
58
+
CREATE TABLE IF NOT EXISTS user_keys (
59
+
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
60
+
key_bytes BYTEA NOT NULL,
61
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
62
+
);
63
+
64
+
CREATE TABLE IF NOT EXISTS repos (
65
+
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
66
+
repo_root_cid TEXT NOT NULL,
67
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
68
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
69
+
);
70
+
71
+
-- content addressable storage
72
+
CREATE TABLE IF NOT EXISTS blocks (
73
+
cid BYTEA PRIMARY KEY,
74
+
data BYTEA NOT NULL,
75
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
76
+
);
77
+
78
+
-- denormalized index for fast queries
79
+
CREATE TABLE IF NOT EXISTS records (
80
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
81
+
repo_id UUID NOT NULL REFERENCES repos(user_id) ON DELETE CASCADE,
82
+
collection TEXT NOT NULL,
83
+
rkey TEXT NOT NULL,
84
+
record_cid TEXT NOT NULL,
85
+
takedown_ref TEXT,
86
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
87
+
UNIQUE(repo_id, collection, rkey)
88
+
);
89
+
90
+
CREATE TABLE IF NOT EXISTS blobs (
91
+
cid TEXT PRIMARY KEY,
92
+
mime_type TEXT NOT NULL,
93
+
size_bytes BIGINT NOT NULL,
94
+
created_by_user UUID NOT NULL REFERENCES users(id),
95
+
storage_key TEXT NOT NULL,
96
+
takedown_ref TEXT,
97
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
98
+
);
99
+
100
+
CREATE TABLE IF NOT EXISTS sessions (
101
+
access_jwt TEXT PRIMARY KEY,
102
+
refresh_jwt TEXT NOT NULL UNIQUE,
103
+
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
104
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
105
+
);
106
+
107
+
CREATE TABLE IF NOT EXISTS app_passwords (
108
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
109
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
110
+
name TEXT NOT NULL,
111
+
password_hash TEXT NOT NULL,
112
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
113
+
privileged BOOLEAN NOT NULL DEFAULT FALSE,
114
+
UNIQUE(user_id, name)
115
+
);
116
+
117
+
-- naughty list
118
+
CREATE TABLE reports (
119
+
id BIGINT PRIMARY KEY,
120
+
reason_type TEXT NOT NULL,
121
+
reason TEXT,
122
+
subject_json JSONB NOT NULL,
123
+
reported_by_did TEXT NOT NULL,
124
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
125
+
);
126
+
127
+
CREATE TABLE IF NOT EXISTS account_deletion_requests (
128
+
token TEXT PRIMARY KEY,
129
+
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
130
+
expires_at TIMESTAMPTZ NOT NULL,
131
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
132
+
);
133
+
134
+
CREATE TABLE IF NOT EXISTS notification_queue (
135
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
136
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
137
+
channel notification_channel NOT NULL DEFAULT 'email',
138
+
notification_type notification_type NOT NULL,
139
+
status notification_status NOT NULL DEFAULT 'pending',
140
+
recipient TEXT NOT NULL,
141
+
subject TEXT,
142
+
body TEXT NOT NULL,
143
+
metadata JSONB,
144
+
attempts INT NOT NULL DEFAULT 0,
145
+
max_attempts INT NOT NULL DEFAULT 3,
146
+
last_error TEXT,
147
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
148
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
149
+
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT NOW(),
150
+
processed_at TIMESTAMPTZ
151
+
);
152
+
153
+
CREATE INDEX idx_notification_queue_status_scheduled
154
+
ON notification_queue(status, scheduled_for)
155
+
WHERE status = 'pending';
156
+
157
+
CREATE INDEX idx_notification_queue_user_id ON notification_queue(user_id);
-80
migrations/202512211400_initial_tables.sql
-80
migrations/202512211400_initial_tables.sql
···
1
-
-- A very basic schema to get started.
2
-
-- TODO: PRODUCTIONIZE BABY
3
-
4
-
CREATE TABLE IF NOT EXISTS users (
5
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
6
-
handle TEXT NOT NULL UNIQUE,
7
-
email TEXT NOT NULL UNIQUE,
8
-
did TEXT NOT NULL UNIQUE,
9
-
password_hash TEXT NOT NULL,
10
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
11
-
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12
-
);
13
-
14
-
CREATE TABLE IF NOT EXISTS invite_codes (
15
-
code TEXT PRIMARY KEY,
16
-
available_uses INT NOT NULL DEFAULT 1,
17
-
created_by_user UUID NOT NULL REFERENCES users(id),
18
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
19
-
);
20
-
21
-
CREATE TABLE IF NOT EXISTS invite_code_uses (
22
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
23
-
code TEXT NOT NULL REFERENCES invite_codes(code),
24
-
used_by_user UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
25
-
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
26
-
UNIQUE(code, used_by_user)
27
-
);
28
-
29
-
-- OIII THIS TABLE CONTAINS PLAINTEXT PRIVATE KEYS, TODO: encrypt at rest!
30
-
CREATE TABLE IF NOT EXISTS user_keys (
31
-
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
32
-
-- Storing as raw bytes
33
-
-- secp256k1 is 32 bytes
34
-
key_bytes BYTEA NOT NULL,
35
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
36
-
);
37
-
38
-
CREATE TABLE IF NOT EXISTS repos (
39
-
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
40
-
repo_root_cid TEXT NOT NULL,
41
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
42
-
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
43
-
);
44
-
45
-
CREATE TABLE IF NOT EXISTS blocks (
46
-
cid BYTEA PRIMARY KEY,
47
-
data BYTEA NOT NULL,
48
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
49
-
);
50
-
51
-
-- A denormalized table to quickly query for records
52
-
-- TODO: Do I actually need this?
53
-
CREATE TABLE IF NOT EXISTS records (
54
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
55
-
repo_id UUID NOT NULL REFERENCES repos(user_id) ON DELETE CASCADE,
56
-
collection TEXT NOT NULL,
57
-
rkey TEXT NOT NULL,
58
-
record_cid TEXT NOT NULL,
59
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
60
-
UNIQUE(repo_id, collection, rkey)
61
-
);
62
-
63
-
CREATE TABLE IF NOT EXISTS blobs (
64
-
cid TEXT PRIMARY KEY,
65
-
mime_type TEXT NOT NULL,
66
-
size_bytes BIGINT NOT NULL,
67
-
created_by_user UUID NOT NULL REFERENCES users(id),
68
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
69
-
70
-
-- The key/path in the S3 bucket
71
-
storage_key TEXT NOT NULL
72
-
);
73
-
74
-
CREATE TABLE IF NOT EXISTS sessions (
75
-
access_jwt TEXT PRIMARY KEY,
76
-
refresh_jwt TEXT NOT NULL UNIQUE,
77
-
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
78
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
79
-
);
80
-
···
-9
migrations/202512211500_app_passwords.sql
-9
migrations/202512211500_app_passwords.sql
···
1
-
CREATE TABLE IF NOT EXISTS app_passwords (
2
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3
-
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4
-
name TEXT NOT NULL,
5
-
password_hash TEXT NOT NULL,
6
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
7
-
privileged BOOLEAN NOT NULL DEFAULT FALSE,
8
-
UNIQUE(user_id, name)
9
-
);
···
-11
migrations/202512211600_moderation_and_status.sql
-11
migrations/202512211600_moderation_and_status.sql
···
1
-
ALTER TABLE users ADD COLUMN deactivated_at TIMESTAMPTZ;
2
-
3
-
-- * reports u *
4
-
CREATE TABLE reports (
5
-
id BIGINT PRIMARY KEY,
6
-
reason_type TEXT NOT NULL,
7
-
reason TEXT,
8
-
subject_json JSONB NOT NULL,
9
-
reported_by_did TEXT NOT NULL,
10
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
11
-
);
···
-3
migrations/202512211700_invite_enhancements.sql
-3
migrations/202512211700_invite_enhancements.sql
-5
migrations/202512211800_takedown_refs.sql
-5
migrations/202512211800_takedown_refs.sql
-6
migrations/202512211900_account_deletion_tokens.sql
-6
migrations/202512211900_account_deletion_tokens.sql
-36
migrations/202512212000_notification_queue.sql
-36
migrations/202512212000_notification_queue.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',
7
-
'email_update',
8
-
'account_deletion'
9
-
);
10
-
11
-
CREATE TABLE IF NOT EXISTS notification_queue (
12
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
13
-
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
14
-
channel notification_channel NOT NULL DEFAULT 'email',
15
-
notification_type notification_type NOT NULL,
16
-
status notification_status NOT NULL DEFAULT 'pending',
17
-
recipient TEXT NOT NULL,
18
-
subject TEXT,
19
-
body TEXT NOT NULL,
20
-
metadata JSONB,
21
-
attempts INT NOT NULL DEFAULT 0,
22
-
max_attempts INT NOT NULL DEFAULT 3,
23
-
last_error TEXT,
24
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
25
-
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
26
-
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT NOW(),
27
-
processed_at TIMESTAMPTZ
28
-
);
29
-
30
-
CREATE INDEX idx_notification_queue_status_scheduled
31
-
ON notification_queue(status, scheduled_for)
32
-
WHERE status = 'pending';
33
-
34
-
CREATE INDEX idx_notification_queue_user_id ON notification_queue(user_id);
35
-
36
-
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_notification_channel notification_channel NOT NULL DEFAULT 'email';
···
-4
migrations/202512212100_password_reset.sql
-4
migrations/202512212100_password_reset.sql
···
1
-
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_code TEXT;
2
-
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_code_expires_at TIMESTAMPTZ;
3
-
4
-
CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
···
-1
migrations/202512212200_admin_email_type.sql
-1
migrations/202512212200_admin_email_type.sql
···
1
-
ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'admin_email';
···
+3
-3
src/api/server/mod.rs
+3
-3
src/api/server/mod.rs
···
5
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
6
pub use meta::{describe_server, health};
7
pub use session::{
8
-
activate_account, check_account_status, create_app_password, create_session,
9
deactivate_account, delete_session, get_service_auth, get_session, list_app_passwords,
10
-
refresh_session, request_account_delete, request_password_reset, reset_password,
11
-
revoke_app_password,
12
};
···
5
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
6
pub use meta::{describe_server, health};
7
pub use session::{
8
+
activate_account, check_account_status, confirm_email, create_app_password, create_session,
9
deactivate_account, delete_session, get_service_auth, get_session, list_app_passwords,
10
+
refresh_session, request_account_delete, request_email_update, request_password_reset,
11
+
reset_password, revoke_app_password,
12
};
+268
src/api/server/session.rs
+268
src/api/server/session.rs
···
1419
1420
(StatusCode::OK, Json(json!({}))).into_response()
1421
}
1422
+
1423
+
#[derive(Deserialize)]
1424
+
#[serde(rename_all = "camelCase")]
1425
+
pub struct RequestEmailUpdateInput {
1426
+
pub email: String,
1427
+
}
1428
+
1429
+
pub async fn request_email_update(
1430
+
State(state): State<AppState>,
1431
+
headers: axum::http::HeaderMap,
1432
+
Json(input): Json<RequestEmailUpdateInput>,
1433
+
) -> Response {
1434
+
let auth_header = headers.get("Authorization");
1435
+
if auth_header.is_none() {
1436
+
return (
1437
+
StatusCode::UNAUTHORIZED,
1438
+
Json(json!({"error": "AuthenticationRequired"})),
1439
+
)
1440
+
.into_response();
1441
+
}
1442
+
1443
+
let token = auth_header
1444
+
.unwrap()
1445
+
.to_str()
1446
+
.unwrap_or("")
1447
+
.replace("Bearer ", "");
1448
+
1449
+
let session = sqlx::query!(
1450
+
r#"
1451
+
SELECT s.did, k.key_bytes, u.id as user_id, u.handle
1452
+
FROM sessions s
1453
+
JOIN users u ON s.did = u.did
1454
+
JOIN user_keys k ON u.id = k.user_id
1455
+
WHERE s.access_jwt = $1
1456
+
"#,
1457
+
token
1458
+
)
1459
+
.fetch_optional(&state.db)
1460
+
.await;
1461
+
1462
+
let (_did, key_bytes, user_id, handle) = match session {
1463
+
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id, row.handle),
1464
+
Ok(None) => {
1465
+
return (
1466
+
StatusCode::UNAUTHORIZED,
1467
+
Json(json!({"error": "AuthenticationFailed"})),
1468
+
)
1469
+
.into_response();
1470
+
}
1471
+
Err(e) => {
1472
+
error!("DB error in request_email_update: {:?}", e);
1473
+
return (
1474
+
StatusCode::INTERNAL_SERVER_ERROR,
1475
+
Json(json!({"error": "InternalError"})),
1476
+
)
1477
+
.into_response();
1478
+
}
1479
+
};
1480
+
1481
+
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
1482
+
return (
1483
+
StatusCode::UNAUTHORIZED,
1484
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
1485
+
)
1486
+
.into_response();
1487
+
}
1488
+
1489
+
let email = input.email.trim().to_lowercase();
1490
+
if email.is_empty() {
1491
+
return (
1492
+
StatusCode::BAD_REQUEST,
1493
+
Json(json!({"error": "InvalidRequest", "message": "email is required"})),
1494
+
)
1495
+
.into_response();
1496
+
}
1497
+
1498
+
let exists = sqlx::query!("SELECT 1 as one FROM users WHERE LOWER(email) = $1", email)
1499
+
.fetch_optional(&state.db)
1500
+
.await;
1501
+
1502
+
if let Ok(Some(_)) = exists {
1503
+
return (
1504
+
StatusCode::BAD_REQUEST,
1505
+
Json(json!({"error": "EmailTaken", "message": "Email already taken"})),
1506
+
)
1507
+
.into_response();
1508
+
}
1509
+
1510
+
let code = generate_reset_code();
1511
+
let expires_at = Utc::now() + Duration::minutes(10);
1512
+
1513
+
let update = sqlx::query!(
1514
+
"UPDATE users SET email_pending_verification = $1, email_confirmation_code = $2, email_confirmation_code_expires_at = $3 WHERE id = $4",
1515
+
email,
1516
+
code,
1517
+
expires_at,
1518
+
user_id
1519
+
)
1520
+
.execute(&state.db)
1521
+
.await;
1522
+
1523
+
if let Err(e) = update {
1524
+
error!("DB error setting email update code: {:?}", e);
1525
+
return (
1526
+
StatusCode::INTERNAL_SERVER_ERROR,
1527
+
Json(json!({"error": "InternalError"})),
1528
+
)
1529
+
.into_response();
1530
+
}
1531
+
1532
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
1533
+
if let Err(e) = crate::notifications::enqueue_email_update(
1534
+
&state.db,
1535
+
user_id,
1536
+
&email,
1537
+
&handle,
1538
+
&code,
1539
+
&hostname,
1540
+
)
1541
+
.await
1542
+
{
1543
+
warn!("Failed to enqueue email update notification: {:?}", e);
1544
+
}
1545
+
1546
+
info!("Email update requested for user {}", user_id);
1547
+
1548
+
(StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response()
1549
+
}
1550
+
1551
+
#[derive(Deserialize)]
1552
+
#[serde(rename_all = "camelCase")]
1553
+
pub struct ConfirmEmailInput {
1554
+
pub email: String,
1555
+
pub token: String,
1556
+
}
1557
+
1558
+
pub async fn confirm_email(
1559
+
State(state): State<AppState>,
1560
+
headers: axum::http::HeaderMap,
1561
+
Json(input): Json<ConfirmEmailInput>,
1562
+
) -> Response {
1563
+
let auth_header = headers.get("Authorization");
1564
+
if auth_header.is_none() {
1565
+
return (
1566
+
StatusCode::UNAUTHORIZED,
1567
+
Json(json!({"error": "AuthenticationRequired"})),
1568
+
)
1569
+
.into_response();
1570
+
}
1571
+
1572
+
let token = auth_header
1573
+
.unwrap()
1574
+
.to_str()
1575
+
.unwrap_or("")
1576
+
.replace("Bearer ", "");
1577
+
1578
+
let session = sqlx::query!(
1579
+
r#"
1580
+
SELECT s.did, k.key_bytes, u.id as user_id, u.email_confirmation_code, u.email_confirmation_code_expires_at, u.email_pending_verification
1581
+
FROM sessions s
1582
+
JOIN users u ON s.did = u.did
1583
+
JOIN user_keys k ON u.id = k.user_id
1584
+
WHERE s.access_jwt = $1
1585
+
"#,
1586
+
token
1587
+
)
1588
+
.fetch_optional(&state.db)
1589
+
.await;
1590
+
1591
+
let (_did, key_bytes, user_id, stored_code, expires_at, email_pending_verification) = match session {
1592
+
Ok(Some(row)) => (
1593
+
row.did,
1594
+
row.key_bytes,
1595
+
row.user_id,
1596
+
row.email_confirmation_code,
1597
+
row.email_confirmation_code_expires_at,
1598
+
row.email_pending_verification,
1599
+
),
1600
+
Ok(None) => {
1601
+
return (
1602
+
StatusCode::UNAUTHORIZED,
1603
+
Json(json!({"error": "AuthenticationFailed"})),
1604
+
)
1605
+
.into_response();
1606
+
}
1607
+
Err(e) => {
1608
+
error!("DB error in confirm_email: {:?}", e);
1609
+
return (
1610
+
StatusCode::INTERNAL_SERVER_ERROR,
1611
+
Json(json!({"error": "InternalError"})),
1612
+
)
1613
+
.into_response();
1614
+
}
1615
+
};
1616
+
1617
+
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
1618
+
return (
1619
+
StatusCode::UNAUTHORIZED,
1620
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
1621
+
)
1622
+
.into_response();
1623
+
}
1624
+
1625
+
let email = input.email.trim().to_lowercase();
1626
+
let confirmation_code = input.token.trim();
1627
+
1628
+
if email_pending_verification.is_none() || stored_code.is_none() || expires_at.is_none() {
1629
+
return (
1630
+
StatusCode::BAD_REQUEST,
1631
+
Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})),
1632
+
)
1633
+
.into_response();
1634
+
}
1635
+
1636
+
let email_pending_verification = email_pending_verification.unwrap();
1637
+
if email_pending_verification != email {
1638
+
return (
1639
+
StatusCode::BAD_REQUEST,
1640
+
Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})),
1641
+
)
1642
+
.into_response();
1643
+
}
1644
+
1645
+
if stored_code.unwrap() != confirmation_code {
1646
+
return (
1647
+
StatusCode::BAD_REQUEST,
1648
+
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
1649
+
)
1650
+
.into_response();
1651
+
}
1652
+
1653
+
if Utc::now() > expires_at.unwrap() {
1654
+
return (
1655
+
StatusCode::BAD_REQUEST,
1656
+
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
1657
+
)
1658
+
.into_response();
1659
+
}
1660
+
1661
+
let update = sqlx::query!(
1662
+
"UPDATE users SET email = $1, email_pending_verification = NULL, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE id = $2",
1663
+
email_pending_verification,
1664
+
user_id
1665
+
)
1666
+
.execute(&state.db)
1667
+
.await;
1668
+
1669
+
if let Err(e) = update {
1670
+
error!("DB error finalizing email update: {:?}", e);
1671
+
if e.as_database_error().map(|db_err| db_err.is_unique_violation()).unwrap_or(false) {
1672
+
return (
1673
+
StatusCode::BAD_REQUEST,
1674
+
Json(json!({"error": "EmailTaken", "message": "Email already taken"})),
1675
+
)
1676
+
.into_response();
1677
+
}
1678
+
1679
+
return (
1680
+
StatusCode::INTERNAL_SERVER_ERROR,
1681
+
Json(json!({"error": "InternalError"})),
1682
+
)
1683
+
.into_response();
1684
+
}
1685
+
1686
+
info!("Email updated for user {}", user_id);
1687
+
1688
+
(StatusCode::OK, Json(json!({}))).into_response()
1689
+
}
+8
src/lib.rs
+8
src/lib.rs
···
164
post(api::server::reset_password),
165
)
166
.route(
167
+
"/xrpc/com.atproto.server.requestEmailUpdate",
168
+
post(api::server::request_email_update),
169
+
)
170
+
.route(
171
+
"/xrpc/com.atproto.server.confirmEmail",
172
+
post(api::server::confirm_email),
173
+
)
174
+
.route(
175
"/xrpc/com.atproto.identity.updateHandle",
176
post(api::identity::update_handle),
177
)
+236
tests/email_update.rs
+236
tests/email_update.rs
···
···
1
+
mod common;
2
+
3
+
use reqwest::StatusCode;
4
+
use serde_json::{json, Value};
5
+
use sqlx::PgPool;
6
+
7
+
async fn get_pool() -> PgPool {
8
+
let conn_str = common::get_db_connection_string().await;
9
+
sqlx::postgres::PgPoolOptions::new()
10
+
.max_connections(5)
11
+
.connect(&conn_str)
12
+
.await
13
+
.expect("Failed to connect to test database")
14
+
}
15
+
16
+
#[tokio::test]
17
+
async fn test_email_update_flow_success() {
18
+
let client = common::client();
19
+
let base_url = common::base_url().await;
20
+
let pool = get_pool().await;
21
+
22
+
let handle = format!("emailup_{}", uuid::Uuid::new_v4());
23
+
let email = format!("{}@example.com", handle);
24
+
let payload = json!({
25
+
"handle": handle,
26
+
"email": email,
27
+
"password": "password"
28
+
});
29
+
30
+
let res = client
31
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
32
+
.json(&payload)
33
+
.send()
34
+
.await
35
+
.expect("Failed to create account");
36
+
assert_eq!(res.status(), StatusCode::OK);
37
+
let body: Value = res.json().await.expect("Invalid JSON");
38
+
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
39
+
40
+
let new_email = format!("new_{}@example.com", handle);
41
+
let res = client
42
+
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
43
+
.bearer_auth(access_jwt)
44
+
.json(&json!({"email": new_email}))
45
+
.send()
46
+
.await
47
+
.expect("Failed to request email update");
48
+
assert_eq!(res.status(), StatusCode::OK);
49
+
let body: Value = res.json().await.expect("Invalid JSON");
50
+
assert_eq!(body["tokenRequired"], true);
51
+
52
+
let user = sqlx::query!(
53
+
"SELECT email_pending_verification, email_confirmation_code, email FROM users WHERE handle = $1",
54
+
handle
55
+
)
56
+
.fetch_one(&pool)
57
+
.await
58
+
.expect("User not found");
59
+
60
+
assert_eq!(user.email_pending_verification.as_deref(), Some(new_email.as_str()));
61
+
assert!(user.email_confirmation_code.is_some());
62
+
let code = user.email_confirmation_code.unwrap();
63
+
64
+
let res = client
65
+
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
66
+
.bearer_auth(access_jwt)
67
+
.json(&json!({
68
+
"email": new_email,
69
+
"token": code
70
+
}))
71
+
.send()
72
+
.await
73
+
.expect("Failed to confirm email");
74
+
assert_eq!(res.status(), StatusCode::OK);
75
+
76
+
let user = sqlx::query!(
77
+
"SELECT email, email_pending_verification, email_confirmation_code FROM users WHERE handle = $1",
78
+
handle
79
+
)
80
+
.fetch_one(&pool)
81
+
.await
82
+
.expect("User not found");
83
+
84
+
assert_eq!(user.email, new_email);
85
+
assert!(user.email_pending_verification.is_none());
86
+
assert!(user.email_confirmation_code.is_none());
87
+
}
88
+
89
+
#[tokio::test]
90
+
async fn test_request_email_update_taken_email() {
91
+
let client = common::client();
92
+
let base_url = common::base_url().await;
93
+
94
+
let handle1 = format!("emailup_taken1_{}", uuid::Uuid::new_v4());
95
+
let email1 = format!("{}@example.com", handle1);
96
+
let res = client
97
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
98
+
.json(&json!({
99
+
"handle": handle1,
100
+
"email": email1,
101
+
"password": "password"
102
+
}))
103
+
.send()
104
+
.await
105
+
.expect("Failed to create account 1");
106
+
assert_eq!(res.status(), StatusCode::OK);
107
+
108
+
let handle2 = format!("emailup_taken2_{}", uuid::Uuid::new_v4());
109
+
let email2 = format!("{}@example.com", handle2);
110
+
let res = client
111
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
112
+
.json(&json!({
113
+
"handle": handle2,
114
+
"email": email2,
115
+
"password": "password"
116
+
}))
117
+
.send()
118
+
.await
119
+
.expect("Failed to create account 2");
120
+
assert_eq!(res.status(), StatusCode::OK);
121
+
let body: Value = res.json().await.expect("Invalid JSON");
122
+
let access_jwt2 = body["accessJwt"].as_str().expect("No accessJwt");
123
+
124
+
let res = client
125
+
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
126
+
.bearer_auth(access_jwt2)
127
+
.json(&json!({"email": email1}))
128
+
.send()
129
+
.await
130
+
.expect("Failed to request email update");
131
+
132
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
133
+
let body: Value = res.json().await.expect("Invalid JSON");
134
+
assert_eq!(body["error"], "EmailTaken");
135
+
}
136
+
137
+
#[tokio::test]
138
+
async fn test_confirm_email_invalid_token() {
139
+
let client = common::client();
140
+
let base_url = common::base_url().await;
141
+
142
+
let handle = format!("emailup_inv_{}", uuid::Uuid::new_v4());
143
+
let email = format!("{}@example.com", handle);
144
+
let res = client
145
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
146
+
.json(&json!({
147
+
"handle": handle,
148
+
"email": email,
149
+
"password": "password"
150
+
}))
151
+
.send()
152
+
.await
153
+
.expect("Failed to create account");
154
+
assert_eq!(res.status(), StatusCode::OK);
155
+
let body: Value = res.json().await.expect("Invalid JSON");
156
+
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
157
+
158
+
let new_email = format!("new_{}@example.com", handle);
159
+
let res = client
160
+
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
161
+
.bearer_auth(access_jwt)
162
+
.json(&json!({"email": new_email}))
163
+
.send()
164
+
.await
165
+
.expect("Failed to request email update");
166
+
assert_eq!(res.status(), StatusCode::OK);
167
+
168
+
let res = client
169
+
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
170
+
.bearer_auth(access_jwt)
171
+
.json(&json!({
172
+
"email": new_email,
173
+
"token": "wrong-token"
174
+
}))
175
+
.send()
176
+
.await
177
+
.expect("Failed to confirm email");
178
+
179
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
180
+
let body: Value = res.json().await.expect("Invalid JSON");
181
+
assert_eq!(body["error"], "InvalidToken");
182
+
}
183
+
184
+
#[tokio::test]
185
+
async fn test_confirm_email_wrong_email() {
186
+
let client = common::client();
187
+
let base_url = common::base_url().await;
188
+
let pool = get_pool().await;
189
+
190
+
let handle = format!("emailup_wrong_{}", uuid::Uuid::new_v4());
191
+
let email = format!("{}@example.com", handle);
192
+
let res = client
193
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
194
+
.json(&json!({
195
+
"handle": handle,
196
+
"email": email,
197
+
"password": "password"
198
+
}))
199
+
.send()
200
+
.await
201
+
.expect("Failed to create account");
202
+
assert_eq!(res.status(), StatusCode::OK);
203
+
let body: Value = res.json().await.expect("Invalid JSON");
204
+
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
205
+
206
+
let new_email = format!("new_{}@example.com", handle);
207
+
let res = client
208
+
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
209
+
.bearer_auth(access_jwt)
210
+
.json(&json!({"email": new_email}))
211
+
.send()
212
+
.await
213
+
.expect("Failed to request email update");
214
+
assert_eq!(res.status(), StatusCode::OK);
215
+
216
+
let user = sqlx::query!("SELECT email_confirmation_code FROM users WHERE handle = $1", handle)
217
+
.fetch_one(&pool)
218
+
.await
219
+
.expect("User not found");
220
+
let code = user.email_confirmation_code.unwrap();
221
+
222
+
let res = client
223
+
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
224
+
.bearer_auth(access_jwt)
225
+
.json(&json!({
226
+
"email": "another_random@example.com",
227
+
"token": code
228
+
}))
229
+
.send()
230
+
.await
231
+
.expect("Failed to confirm email");
232
+
233
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
234
+
let body: Value = res.json().await.expect("Invalid JSON");
235
+
assert_eq!(body["message"], "Email does not match pending update");
236
+
}