+5
-5
.sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json
.sqlx/query-de72338f80b4f7b5bc7c9fc44100b6eb9e75f442b5b37a5a1cd761cd3b6950d9.json
+5
-5
.sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json
.sqlx/query-de72338f80b4f7b5bc7c9fc44100b6eb9e75f442b5b37a5a1cd761cd3b6950d9.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT\n handle, email, email_confirmed, is_admin,\n preferred_notification_channel as \"preferred_channel: crate::notifications::NotificationChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1",
3
+
"query": "SELECT\n handle, email, email_verified, is_admin,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
15
15
},
16
16
{
17
17
"ordinal": 2,
18
-
"name": "email_confirmed",
18
+
"name": "email_verified",
19
19
"type_info": "Bool"
20
20
},
21
21
{
···
25
25
},
26
26
{
27
27
"ordinal": 4,
28
-
"name": "preferred_channel: crate::notifications::NotificationChannel",
28
+
"name": "preferred_channel: crate::comms::CommsChannel",
29
29
"type_info": {
30
30
"Custom": {
31
-
"name": "notification_channel",
31
+
"name": "comms_channel",
32
32
"kind": {
33
33
"Enum": [
34
34
"email",
···
72
72
false
73
73
]
74
74
},
75
-
"hash": "088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1"
75
+
"hash": "de72338f80b4f7b5bc7c9fc44100b6eb9e75f442b5b37a5a1cd761cd3b6950d9"
76
76
}
-30
.sqlx/query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json
-30
.sqlx/query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n INSERT INTO notification_queue (user_id, channel, notification_type, recipient, subject, body, metadata)\n VALUES ($1, $2::notification_channel, 'channel_verification', $3, 'Verify your channel', $4, $5)\n ",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Uuid",
9
-
{
10
-
"Custom": {
11
-
"name": "notification_channel",
12
-
"kind": {
13
-
"Enum": [
14
-
"email",
15
-
"discord",
16
-
"telegram",
17
-
"signal"
18
-
]
19
-
}
20
-
}
21
-
},
22
-
"Text",
23
-
"Text",
24
-
"Jsonb"
25
-
]
26
-
},
27
-
"nullable": []
28
-
},
29
-
"hash": "0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac"
30
-
}
+4
-4
.sqlx/query-0cbeeffaf2cf782de4e9d886e26b9884e874735e76b50c42933a94d9fa70425e.json
.sqlx/query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json
+4
-4
.sqlx/query-0cbeeffaf2cf782de4e9d886e26b9884e874735e76b50c42933a94d9fa70425e.json
.sqlx/query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT preferred_notification_channel as \"channel: NotificationChannel\" FROM users WHERE did = $1",
3
+
"query": "SELECT preferred_comms_channel as \"channel: CommsChannel\" FROM users WHERE did = $1",
4
4
"describe": {
5
5
"columns": [
6
6
{
7
7
"ordinal": 0,
8
-
"name": "channel: NotificationChannel",
8
+
"name": "channel: CommsChannel",
9
9
"type_info": {
10
10
"Custom": {
11
-
"name": "notification_channel",
11
+
"name": "comms_channel",
12
12
"kind": {
13
13
"Enum": [
14
14
"email",
···
30
30
false
31
31
]
32
32
},
33
-
"hash": "0cbeeffaf2cf782de4e9d886e26b9884e874735e76b50c42933a94d9fa70425e"
33
+
"hash": "94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c"
34
34
}
+14
.sqlx/query-17bd3bd354a6ee0a86a1c868207eb4ea454844828c8aca63b1252fefa8f5afad.json
+14
.sqlx/query-17bd3bd354a6ee0a86a1c868207eb4ea454844828c8aca63b1252fefa8f5afad.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n UPDATE comms_queue\n SET status = 'sent', processed_at = NOW(), updated_at = NOW()\n WHERE id = $1\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "17bd3bd354a6ee0a86a1c868207eb4ea454844828c8aca63b1252fefa8f5afad"
14
+
}
+3
-3
.sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json
.sqlx/query-d61c982dac3a508393b31a30bad50c0088ce6e117fe63c5a1062a97000dedf89.json
+3
-3
.sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json
.sqlx/query-d61c982dac3a508393b31a30bad50c0088ce6e117fe63c5a1062a97000dedf89.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1",
3
+
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
25
25
},
26
26
{
27
27
"ordinal": 4,
28
-
"name": "email_confirmed",
28
+
"name": "email_verified",
29
29
"type_info": "Bool"
30
30
},
31
31
{
···
72
72
true
73
73
]
74
74
},
75
-
"hash": "1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd"
75
+
"hash": "d61c982dac3a508393b31a30bad50c0088ce6e117fe63c5a1062a97000dedf89"
76
76
}
-15
.sqlx/query-2c6cb8f15fe71cb5f38ffd7f5085b60bc852c4f1042c95a76fce773efd369511.json
-15
.sqlx/query-2c6cb8f15fe71cb5f38ffd7f5085b60bc852c4f1042c95a76fce773efd369511.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n UPDATE notification_queue\n SET\n status = CASE\n WHEN attempts + 1 >= max_attempts THEN 'failed'::notification_status\n ELSE 'pending'::notification_status\n END,\n attempts = attempts + 1,\n last_error = $2,\n updated_at = NOW(),\n scheduled_for = NOW() + (INTERVAL '1 minute' * (attempts + 1))\n WHERE id = $1\n ",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Uuid",
9
-
"Text"
10
-
]
11
-
},
12
-
"nullable": []
13
-
},
14
-
"hash": "2c6cb8f15fe71cb5f38ffd7f5085b60bc852c4f1042c95a76fce773efd369511"
15
-
}
+4
-4
.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json
.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json
+4
-4
.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json
.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "\n INSERT INTO notification_queue\n (user_id, channel, notification_type, recipient, subject, body, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ",
3
+
"query": "\n INSERT INTO comms_queue\n (user_id, channel, comms_type, recipient, subject, body, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
14
14
"Uuid",
15
15
{
16
16
"Custom": {
17
-
"name": "notification_channel",
17
+
"name": "comms_channel",
18
18
"kind": {
19
19
"Enum": [
20
20
"email",
···
27
27
},
28
28
{
29
29
"Custom": {
30
-
"name": "notification_type",
30
+
"name": "comms_type",
31
31
"kind": {
32
32
"Enum": [
33
33
"welcome",
···
53
53
false
54
54
]
55
55
},
56
-
"hash": "303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6"
56
+
"hash": "17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5"
57
57
}
-14
.sqlx/query-344c851d3f1b026e8632aa2f04052dcbc957b7077c856da6a1a256ec2fe85ad3.json
-14
.sqlx/query-344c851d3f1b026e8632aa2f04052dcbc957b7077c856da6a1a256ec2fe85ad3.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n UPDATE notification_queue\n SET status = 'sent', processed_at = NOW(), updated_at = NOW()\n WHERE id = $1\n ",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Uuid"
9
-
]
10
-
},
11
-
"nullable": []
12
-
},
13
-
"hash": "344c851d3f1b026e8632aa2f04052dcbc957b7077c856da6a1a256ec2fe85ad3"
14
-
}
-76
.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json
-76
.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT id, did, email, password_hash, two_factor_enabled,\n preferred_notification_channel as \"preferred_notification_channel: NotificationChannel\",\n deactivated_at, takedown_ref\n FROM users\n WHERE handle = $1 OR email = $1\n ",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "id",
9
-
"type_info": "Uuid"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "did",
14
-
"type_info": "Text"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "email",
19
-
"type_info": "Text"
20
-
},
21
-
{
22
-
"ordinal": 3,
23
-
"name": "password_hash",
24
-
"type_info": "Text"
25
-
},
26
-
{
27
-
"ordinal": 4,
28
-
"name": "two_factor_enabled",
29
-
"type_info": "Bool"
30
-
},
31
-
{
32
-
"ordinal": 5,
33
-
"name": "preferred_notification_channel: NotificationChannel",
34
-
"type_info": {
35
-
"Custom": {
36
-
"name": "notification_channel",
37
-
"kind": {
38
-
"Enum": [
39
-
"email",
40
-
"discord",
41
-
"telegram",
42
-
"signal"
43
-
]
44
-
}
45
-
}
46
-
}
47
-
},
48
-
{
49
-
"ordinal": 6,
50
-
"name": "deactivated_at",
51
-
"type_info": "Timestamptz"
52
-
},
53
-
{
54
-
"ordinal": 7,
55
-
"name": "takedown_ref",
56
-
"type_info": "Text"
57
-
}
58
-
],
59
-
"parameters": {
60
-
"Left": [
61
-
"Text"
62
-
]
63
-
},
64
-
"nullable": [
65
-
false,
66
-
false,
67
-
true,
68
-
false,
69
-
false,
70
-
false,
71
-
true,
72
-
true
73
-
]
74
-
},
75
-
"hash": "458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810"
76
-
}
+3
-3
.sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json
.sqlx/query-f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5.json
+3
-3
.sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json
.sqlx/query-f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT code, pending_identifier, expires_at FROM channel_verifications\n WHERE user_id = $1 AND channel = $2::notification_channel\n ",
3
+
"query": "\n SELECT code, pending_identifier, expires_at FROM channel_verifications\n WHERE user_id = $1 AND channel = $2::comms_channel\n ",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
24
24
"Uuid",
25
25
{
26
26
"Custom": {
27
-
"name": "notification_channel",
27
+
"name": "comms_channel",
28
28
"kind": {
29
29
"Enum": [
30
30
"email",
···
43
43
false
44
44
]
45
45
},
46
-
"hash": "4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26"
46
+
"hash": "f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5"
47
47
}
+6
-6
.sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json
.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json
+6
-6
.sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json
.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT\n created_at,\n channel as \"channel: String\",\n notification_type as \"notification_type: String\",\n status as \"status: String\",\n subject,\n body\n FROM notification_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT 50\n ",
3
+
"query": "\n SELECT\n created_at,\n channel as \"channel: String\",\n comms_type as \"comms_type: String\",\n status as \"status: String\",\n subject,\n body\n FROM comms_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT 50\n ",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
13
13
"name": "channel: String",
14
14
"type_info": {
15
15
"Custom": {
16
-
"name": "notification_channel",
16
+
"name": "comms_channel",
17
17
"kind": {
18
18
"Enum": [
19
19
"email",
···
27
27
},
28
28
{
29
29
"ordinal": 2,
30
-
"name": "notification_type: String",
30
+
"name": "comms_type: String",
31
31
"type_info": {
32
32
"Custom": {
33
-
"name": "notification_type",
33
+
"name": "comms_type",
34
34
"kind": {
35
35
"Enum": [
36
36
"welcome",
···
52
52
"name": "status: String",
53
53
"type_info": {
54
54
"Custom": {
55
-
"name": "notification_status",
55
+
"name": "comms_status",
56
56
"kind": {
57
57
"Enum": [
58
58
"pending",
···
89
89
false
90
90
]
91
91
},
92
-
"hash": "4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a"
92
+
"hash": "fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4"
93
93
}
+4
-4
.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json
.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json
+4
-4
.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json
.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "\n INSERT INTO notification_queue\n (user_id, channel, notification_type, recipient, subject, body, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ",
3
+
"query": "\n INSERT INTO comms_queue\n (user_id, channel, comms_type, recipient, subject, body, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
14
14
"Uuid",
15
15
{
16
16
"Custom": {
17
-
"name": "notification_channel",
17
+
"name": "comms_channel",
18
18
"kind": {
19
19
"Enum": [
20
20
"email",
···
27
27
},
28
28
{
29
29
"Custom": {
30
-
"name": "notification_type",
30
+
"name": "comms_type",
31
31
"kind": {
32
32
"Enum": [
33
33
"welcome",
···
53
53
false
54
54
]
55
55
},
56
-
"hash": "5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf"
56
+
"hash": "3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc"
57
57
}
-46
.sqlx/query-62f66fad54498d5c598af54de795e395f71596a6d6a88d2be64ce86256a9860f.json
-46
.sqlx/query-62f66fad54498d5c598af54de795e395f71596a6d6a88d2be64ce86256a9860f.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT id, two_factor_enabled,\n preferred_notification_channel as \"preferred_notification_channel: NotificationChannel\"\n FROM users\n WHERE did = $1\n ",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "id",
9
-
"type_info": "Uuid"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "two_factor_enabled",
14
-
"type_info": "Bool"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "preferred_notification_channel: NotificationChannel",
19
-
"type_info": {
20
-
"Custom": {
21
-
"name": "notification_channel",
22
-
"kind": {
23
-
"Enum": [
24
-
"email",
25
-
"discord",
26
-
"telegram",
27
-
"signal"
28
-
]
29
-
}
30
-
}
31
-
}
32
-
}
33
-
],
34
-
"parameters": {
35
-
"Left": [
36
-
"Text"
37
-
]
38
-
},
39
-
"nullable": [
40
-
false,
41
-
false,
42
-
false
43
-
]
44
-
},
45
-
"hash": "62f66fad54498d5c598af54de795e395f71596a6d6a88d2be64ce86256a9860f"
46
-
}
+15
.sqlx/query-64510156f2b79cdc41f08867952abbea919b9a90167958f018ceb9972b9e8230.json
+15
.sqlx/query-64510156f2b79cdc41f08867952abbea919b9a90167958f018ceb9972b9e8230.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n UPDATE comms_queue\n SET\n status = CASE\n WHEN attempts + 1 >= max_attempts THEN 'failed'::comms_status\n ELSE 'pending'::comms_status\n END,\n attempts = attempts + 1,\n last_error = $2,\n updated_at = NOW(),\n scheduled_for = NOW() + (INTERVAL '1 minute' * (attempts + 1))\n WHERE id = $1\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
"Text"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "64510156f2b79cdc41f08867952abbea919b9a90167958f018ceb9972b9e8230"
15
+
}
+3
-3
.sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json
.sqlx/query-57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e.json
+3
-3
.sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json
.sqlx/query-57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel",
3
+
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
4
4
"describe": {
5
5
"columns": [],
6
6
"parameters": {
···
8
8
"Uuid",
9
9
{
10
10
"Custom": {
11
-
"name": "notification_channel",
11
+
"name": "comms_channel",
12
12
"kind": {
13
13
"Enum": [
14
14
"email",
···
23
23
},
24
24
"nullable": []
25
25
},
26
-
"hash": "90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849"
26
+
"hash": "57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e"
27
27
}
+3
-3
.sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json
.sqlx/query-c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6.json
+3
-3
.sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json
.sqlx/query-c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, $2::notification_channel, $3, $4, $5)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()\n ",
3
+
"query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, $2::comms_channel, $3, $4, $5)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()\n ",
4
4
"describe": {
5
5
"columns": [],
6
6
"parameters": {
···
8
8
"Uuid",
9
9
{
10
10
"Custom": {
11
-
"name": "notification_channel",
11
+
"name": "comms_channel",
12
12
"kind": {
13
13
"Enum": [
14
14
"email",
···
26
26
},
27
27
"nullable": []
28
28
},
29
-
"hash": "9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546"
29
+
"hash": "c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6"
30
30
}
+5
-5
.sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json
.sqlx/query-4a77184e491ed1f011966fd7fa1332bfeaf782a7787784008f15254c02ef57d5.json
+5
-5
.sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json
.sqlx/query-4a77184e491ed1f011966fd7fa1332bfeaf782a7787784008f15254c02ef57d5.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT\n id, handle, email,\n preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n discord_id, telegram_username, signal_number,\n email_confirmed, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1",
3
+
"query": "SELECT\n id, handle, email,\n preferred_comms_channel as \"channel: crate::comms::CommsChannel\",\n discord_id, telegram_username, signal_number,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
20
20
},
21
21
{
22
22
"ordinal": 3,
23
-
"name": "channel: crate::notifications::NotificationChannel",
23
+
"name": "channel: crate::comms::CommsChannel",
24
24
"type_info": {
25
25
"Custom": {
26
-
"name": "notification_channel",
26
+
"name": "comms_channel",
27
27
"kind": {
28
28
"Enum": [
29
29
"email",
···
52
52
},
53
53
{
54
54
"ordinal": 7,
55
-
"name": "email_confirmed",
55
+
"name": "email_verified",
56
56
"type_info": "Bool"
57
57
},
58
58
{
···
90
90
false
91
91
]
92
92
},
93
-
"hash": "ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6"
93
+
"hash": "4a77184e491ed1f011966fd7fa1332bfeaf782a7787784008f15254c02ef57d5"
94
94
}
+4
-4
.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json
.sqlx/query-8c69c5f98e3ee59b50346094ff39eed73bb602f0b5ab48c11e53c82839a66721.json
+4
-4
.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json
.sqlx/query-8c69c5f98e3ee59b50346094ff39eed73bb602f0b5ab48c11e53c82839a66721.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT\n email,\n handle,\n preferred_notification_channel as \"channel: NotificationChannel\"\n FROM users\n WHERE id = $1\n ",
3
+
"query": "\n SELECT\n email,\n handle,\n preferred_comms_channel as \"channel: CommsChannel\"\n FROM users\n WHERE id = $1\n ",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
15
15
},
16
16
{
17
17
"ordinal": 2,
18
-
"name": "channel: NotificationChannel",
18
+
"name": "channel: CommsChannel",
19
19
"type_info": {
20
20
"Custom": {
21
-
"name": "notification_channel",
21
+
"name": "comms_channel",
22
22
"kind": {
23
23
"Enum": [
24
24
"email",
···
42
42
false
43
43
]
44
44
},
45
-
"hash": "bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508"
45
+
"hash": "8c69c5f98e3ee59b50346094ff39eed73bb602f0b5ab48c11e53c82839a66721"
46
46
}
+8
-8
.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json
.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
+8
-8
.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json
.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "\n UPDATE notification_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM notification_queue\n WHERE status = 'pending'\n AND scheduled_for <= $1\n AND attempts < max_attempts\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: NotificationChannel\",\n notification_type as \"notification_type: super::types::NotificationType\",\n status as \"status: NotificationStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at\n ",
3
+
"query": "\n UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE status = 'pending'\n AND scheduled_for <= $1\n AND attempts < max_attempts\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: super::types::CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at\n ",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
15
15
},
16
16
{
17
17
"ordinal": 2,
18
-
"name": "channel: NotificationChannel",
18
+
"name": "channel: CommsChannel",
19
19
"type_info": {
20
20
"Custom": {
21
-
"name": "notification_channel",
21
+
"name": "comms_channel",
22
22
"kind": {
23
23
"Enum": [
24
24
"email",
···
32
32
},
33
33
{
34
34
"ordinal": 3,
35
-
"name": "notification_type: super::types::NotificationType",
35
+
"name": "comms_type: super::types::CommsType",
36
36
"type_info": {
37
37
"Custom": {
38
-
"name": "notification_type",
38
+
"name": "comms_type",
39
39
"kind": {
40
40
"Enum": [
41
41
"welcome",
···
54
54
},
55
55
{
56
56
"ordinal": 4,
57
-
"name": "status: NotificationStatus",
57
+
"name": "status: CommsStatus",
58
58
"type_info": {
59
59
"Custom": {
60
-
"name": "notification_status",
60
+
"name": "comms_status",
61
61
"kind": {
62
62
"Enum": [
63
63
"pending",
···
150
150
true
151
151
]
152
152
},
153
-
"hash": "cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de"
153
+
"hash": "20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08"
154
154
}
+34
.sqlx/query-d41e1b7d5e22c06896ae28c6790d5c7c8e6a7c9489133bb9357d012d7a75813b.json
+34
.sqlx/query-d41e1b7d5e22c06896ae28c6790d5c7c8e6a7c9489133bb9357d012d7a75813b.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT u.id, uk.key_bytes, uk.encryption_version\n FROM users u\n JOIN user_keys uk ON u.id = uk.user_id\n WHERE u.did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "key_bytes",
14
+
"type_info": "Bytea"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "encryption_version",
19
+
"type_info": "Int4"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Text"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
false,
30
+
true
31
+
]
32
+
},
33
+
"hash": "d41e1b7d5e22c06896ae28c6790d5c7c8e6a7c9489133bb9357d012d7a75813b"
34
+
}
+70
.sqlx/query-daa235d54827ca9b2803da732d3d35c6012b7cc6aac81c5e46be9d24cfd42c24.json
+70
.sqlx/query-daa235d54827ca9b2803da732d3d35c6012b7cc6aac81c5e46be9d24cfd42c24.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT id, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "two_factor_enabled",
14
+
"type_info": "Bool"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "preferred_comms_channel: CommsChannel",
19
+
"type_info": {
20
+
"Custom": {
21
+
"name": "comms_channel",
22
+
"kind": {
23
+
"Enum": [
24
+
"email",
25
+
"discord",
26
+
"telegram",
27
+
"signal"
28
+
]
29
+
}
30
+
}
31
+
}
32
+
},
33
+
{
34
+
"ordinal": 3,
35
+
"name": "email_verified",
36
+
"type_info": "Bool"
37
+
},
38
+
{
39
+
"ordinal": 4,
40
+
"name": "discord_verified",
41
+
"type_info": "Bool"
42
+
},
43
+
{
44
+
"ordinal": 5,
45
+
"name": "telegram_verified",
46
+
"type_info": "Bool"
47
+
},
48
+
{
49
+
"ordinal": 6,
50
+
"name": "signal_verified",
51
+
"type_info": "Bool"
52
+
}
53
+
],
54
+
"parameters": {
55
+
"Left": [
56
+
"Text"
57
+
]
58
+
},
59
+
"nullable": [
60
+
false,
61
+
false,
62
+
false,
63
+
false,
64
+
false,
65
+
false,
66
+
false
67
+
]
68
+
},
69
+
"hash": "daa235d54827ca9b2803da732d3d35c6012b7cc6aac81c5e46be9d24cfd42c24"
70
+
}
+4
-4
.sqlx/query-dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3.json
.sqlx/query-efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a.json
+4
-4
.sqlx/query-dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3.json
.sqlx/query-efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
3
+
"query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: crate::comms::CommsChannel\",\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
25
25
},
26
26
{
27
27
"ordinal": 4,
28
-
"name": "channel: crate::notifications::NotificationChannel",
28
+
"name": "channel: crate::comms::CommsChannel",
29
29
"type_info": {
30
30
"Custom": {
31
-
"name": "notification_channel",
31
+
"name": "comms_channel",
32
32
"kind": {
33
33
"Enum": [
34
34
"email",
···
66
66
true
67
67
]
68
68
},
69
-
"hash": "dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3"
69
+
"hash": "efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a"
70
70
}
+30
.sqlx/query-e774d655b838c219c8291a5bc8e6fb90b793c78402c648dd380538b6e2b47134.json
+30
.sqlx/query-e774d655b838c219c8291a5bc8e6fb90b793c78402c648dd380538b6e2b47134.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n INSERT INTO comms_queue (user_id, channel, comms_type, recipient, subject, body, metadata)\n VALUES ($1, $2::comms_channel, 'channel_verification', $3, 'Verify your channel', $4, $5)\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
{
10
+
"Custom": {
11
+
"name": "comms_channel",
12
+
"kind": {
13
+
"Enum": [
14
+
"email",
15
+
"discord",
16
+
"telegram",
17
+
"signal"
18
+
]
19
+
}
20
+
}
21
+
},
22
+
"Text",
23
+
"Text",
24
+
"Jsonb"
25
+
]
26
+
},
27
+
"nullable": []
28
+
},
29
+
"hash": "e774d655b838c219c8291a5bc8e6fb90b793c78402c648dd380538b6e2b47134"
30
+
}
+100
.sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json
+100
.sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT id, did, email, password_hash, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE handle = $1 OR email = $1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "did",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "email",
19
+
"type_info": "Text"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "password_hash",
24
+
"type_info": "Text"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "two_factor_enabled",
29
+
"type_info": "Bool"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "preferred_comms_channel: CommsChannel",
34
+
"type_info": {
35
+
"Custom": {
36
+
"name": "comms_channel",
37
+
"kind": {
38
+
"Enum": [
39
+
"email",
40
+
"discord",
41
+
"telegram",
42
+
"signal"
43
+
]
44
+
}
45
+
}
46
+
}
47
+
},
48
+
{
49
+
"ordinal": 6,
50
+
"name": "deactivated_at",
51
+
"type_info": "Timestamptz"
52
+
},
53
+
{
54
+
"ordinal": 7,
55
+
"name": "takedown_ref",
56
+
"type_info": "Text"
57
+
},
58
+
{
59
+
"ordinal": 8,
60
+
"name": "email_verified",
61
+
"type_info": "Bool"
62
+
},
63
+
{
64
+
"ordinal": 9,
65
+
"name": "discord_verified",
66
+
"type_info": "Bool"
67
+
},
68
+
{
69
+
"ordinal": 10,
70
+
"name": "telegram_verified",
71
+
"type_info": "Bool"
72
+
},
73
+
{
74
+
"ordinal": 11,
75
+
"name": "signal_verified",
76
+
"type_info": "Bool"
77
+
}
78
+
],
79
+
"parameters": {
80
+
"Left": [
81
+
"Text"
82
+
]
83
+
},
84
+
"nullable": [
85
+
false,
86
+
false,
87
+
true,
88
+
false,
89
+
false,
90
+
false,
91
+
true,
92
+
true,
93
+
false,
94
+
false,
95
+
false,
96
+
false
97
+
]
98
+
},
99
+
"hash": "f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0"
100
+
}
+1
Cargo.lock
+1
Cargo.lock
+1
Cargo.toml
+1
Cargo.toml
···
51
51
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
52
52
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
53
53
tower-http = { version = "0.6", features = ["fs", "cors"] }
54
+
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
54
55
metrics = "0.24"
55
56
metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] }
56
57
[features]
+3
frontend/src/App.svelte
+3
frontend/src/App.svelte
···
3
3
import { initAuth, getAuthState } from './lib/auth.svelte'
4
4
import Login from './routes/Login.svelte'
5
5
import Register from './routes/Register.svelte'
6
+
import Verify from './routes/Verify.svelte'
6
7
import ResetPassword from './routes/ResetPassword.svelte'
7
8
import Dashboard from './routes/Dashboard.svelte'
8
9
import AppPasswords from './routes/AppPasswords.svelte'
···
25
26
return Login
26
27
case '/register':
27
28
return Register
29
+
case '/verify':
30
+
return Verify
28
31
case '/reset-password':
29
32
return ResetPassword
30
33
case '/dashboard':
-5
frontend/src/routes/Login.svelte
-5
frontend/src/routes/Login.svelte
···
8
8
let resendMessage = $state<string | null>(null)
9
9
let showNewLogin = $state(false)
10
10
const auth = getAuthState()
11
-
$effect(() => {
12
-
if (auth.session) {
13
-
navigate('/dashboard')
14
-
}
15
-
})
16
11
async function handleSwitchAccount(did: string) {
17
12
submitting = true
18
13
try {
+15
-121
frontend/src/routes/Register.svelte
+15
-121
frontend/src/routes/Register.svelte
···
1
1
<script lang="ts">
2
-
import { register, confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
2
+
import { register, getAuthState } from '../lib/auth.svelte'
3
3
import { navigate } from '../lib/router.svelte'
4
4
import { api, ApiError, type VerificationChannel } from '../lib/api'
5
+
6
+
const STORAGE_KEY = 'bspds_pending_verification'
7
+
5
8
let handle = $state('')
6
9
let email = $state('')
7
10
let password = $state('')
···
13
16
let signalNumber = $state('')
14
17
let submitting = $state(false)
15
18
let error = $state<string | null>(null)
16
-
let pendingVerification = $state<{ did: string; handle: string; channel: string } | null>(null)
17
-
let verificationCode = $state('')
18
-
let resendingCode = $state(false)
19
-
let resendMessage = $state<string | null>(null)
20
19
let serverInfo = $state<{
21
20
availableUserDomains: string[]
22
21
inviteCodeRequired: boolean
23
22
} | null>(null)
24
23
let loadingServerInfo = $state(true)
25
24
let serverInfoLoaded = false
25
+
26
26
const auth = getAuthState()
27
+
27
28
$effect(() => {
28
29
if (auth.session) {
29
30
navigate('/dashboard')
30
31
}
31
32
})
33
+
32
34
$effect(() => {
33
35
if (!serverInfoLoaded) {
34
36
serverInfoLoaded = true
35
37
loadServerInfo()
36
38
}
37
39
})
40
+
38
41
async function loadServerInfo() {
39
42
try {
40
43
serverInfo = await api.describeServer()
···
44
47
loadingServerInfo = false
45
48
}
46
49
}
50
+
47
51
function validateForm(): string | null {
48
52
if (!handle.trim()) return 'Handle is required'
49
53
if (!password) return 'Password is required'
···
68
72
}
69
73
return null
70
74
}
75
+
71
76
async function handleSubmit(e: Event) {
72
77
e.preventDefault()
73
-
console.log('[Register] handleSubmit called')
74
78
const validationError = validateForm()
75
79
if (validationError) {
76
-
console.log('[Register] validation error:', validationError)
77
80
error = validationError
78
81
return
79
82
}
80
83
submitting = true
81
84
error = null
82
-
console.log('[Register] starting registration...')
83
85
try {
84
86
const result = await register({
85
87
handle: handle.trim(),
···
91
93
telegramUsername: telegramUsername.trim() || undefined,
92
94
signalNumber: signalNumber.trim() || undefined,
93
95
})
94
-
console.log('[Register] registration result:', result)
95
96
if (result.verificationRequired) {
96
-
console.log('[Register] setting pendingVerification')
97
-
pendingVerification = {
97
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({
98
98
did: result.did,
99
99
handle: result.handle,
100
100
channel: result.verificationChannel,
101
-
}
102
-
console.log('[Register] pendingVerification set to:', pendingVerification)
101
+
}))
102
+
navigate('/verify')
103
103
} else {
104
-
console.log('[Register] no verification required, navigating to dashboard')
105
104
navigate('/dashboard')
106
105
}
107
106
} catch (err: any) {
108
-
console.error('[Register] error:', err)
109
107
if (err instanceof ApiError) {
110
108
error = err.message || 'Registration failed'
111
109
} else if (err instanceof Error) {
···
115
113
}
116
114
} finally {
117
115
submitting = false
118
-
console.log('[Register] finished, submitting=false')
119
116
}
120
117
}
121
-
async function handleVerification(e: Event) {
122
-
e.preventDefault()
123
-
if (!pendingVerification || !verificationCode.trim()) return
124
-
submitting = true
125
-
error = null
126
-
try {
127
-
await confirmSignup(pendingVerification.did, verificationCode.trim())
128
-
navigate('/dashboard')
129
-
} catch (e: any) {
130
-
error = e.message || 'Verification failed'
131
-
} finally {
132
-
submitting = false
133
-
}
134
-
}
135
-
async function handleResendCode() {
136
-
if (!pendingVerification || resendingCode) return
137
-
resendingCode = true
138
-
resendMessage = null
139
-
error = null
140
-
try {
141
-
await resendVerification(pendingVerification.did)
142
-
resendMessage = 'Verification code resent!'
143
-
} catch (e: any) {
144
-
error = e.message || 'Failed to resend code'
145
-
} finally {
146
-
resendingCode = false
147
-
}
148
-
}
118
+
149
119
let fullHandle = $derived(() => {
150
120
if (!handle.trim()) return ''
151
121
if (handle.includes('.')) return handle.trim()
···
153
123
if (domain) return `${handle.trim()}.${domain}`
154
124
return handle.trim()
155
125
})
156
-
function channelLabel(ch: string): string {
157
-
switch (ch) {
158
-
case 'email': return 'Email'
159
-
case 'discord': return 'Discord'
160
-
case 'telegram': return 'Telegram'
161
-
case 'signal': return 'Signal'
162
-
default: return ch
163
-
}
164
-
}
165
126
</script>
166
127
<div class="register-container">
167
128
{#if error}
168
129
<div class="error">{error}</div>
169
130
{/if}
170
-
{#if pendingVerification}
171
-
<h1>Verify Your Account</h1>
172
-
<p class="subtitle">
173
-
We've sent a verification code to your {channelLabel(pendingVerification.channel)}.
174
-
Enter it below to complete registration.
175
-
</p>
176
-
{#if resendMessage}
177
-
<div class="success">{resendMessage}</div>
178
-
{/if}
179
-
<form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
180
-
<div class="field">
181
-
<label for="verification-code">Verification Code</label>
182
-
<input
183
-
id="verification-code"
184
-
type="text"
185
-
bind:value={verificationCode}
186
-
placeholder="Enter 6-digit code"
187
-
disabled={submitting}
188
-
required
189
-
maxlength="6"
190
-
inputmode="numeric"
191
-
autocomplete="one-time-code"
192
-
/>
193
-
</div>
194
-
<button type="submit" disabled={submitting || !verificationCode.trim()}>
195
-
{submitting ? 'Verifying...' : 'Verify Account'}
196
-
</button>
197
-
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
198
-
{resendingCode ? 'Resending...' : 'Resend Code'}
199
-
</button>
200
-
</form>
201
-
{:else}
202
-
<h1>Create Account</h1>
131
+
<h1>Create Account</h1>
203
132
<p class="subtitle">Create a new account on this PDS</p>
204
133
{#if loadingServerInfo}
205
134
<p class="loading">Loading...</p>
···
322
251
required
323
252
/>
324
253
</div>
325
-
{:else}
326
-
<div class="field optional">
327
-
<label for="invite-code">Invite Code <span class="optional-label">(optional)</span></label>
328
-
<input
329
-
id="invite-code"
330
-
type="text"
331
-
bind:value={inviteCode}
332
-
placeholder="Enter invite code if you have one"
333
-
disabled={submitting}
334
-
/>
335
-
</div>
336
254
{/if}
337
255
<button type="submit" disabled={submitting}>
338
256
{submitting ? 'Creating account...' : 'Create Account'}
···
342
260
Already have an account? <a href="#/login">Sign in</a>
343
261
</p>
344
262
{/if}
345
-
{/if}
346
263
</div>
347
264
<style>
348
265
.register-container {
···
371
288
flex-direction: column;
372
289
gap: 0.25rem;
373
290
}
374
-
.field.optional {
375
-
opacity: 0.8;
376
-
}
377
291
label {
378
292
font-size: 0.875rem;
379
293
font-weight: 500;
···
381
295
.required {
382
296
color: var(--error-text);
383
297
}
384
-
.optional-label {
385
-
color: var(--text-secondary);
386
-
font-weight: normal;
387
-
}
388
298
input, select {
389
299
padding: 0.75rem;
390
300
border: 1px solid var(--border-color-light);
···
435
345
opacity: 0.6;
436
346
cursor: not-allowed;
437
347
}
438
-
button.secondary {
439
-
background: transparent;
440
-
color: var(--accent);
441
-
border: 1px solid var(--accent);
442
-
}
443
-
button.secondary:hover:not(:disabled) {
444
-
background: var(--accent);
445
-
color: white;
446
-
}
447
348
.error {
448
349
padding: 0.75rem;
449
350
background: var(--error-bg);
450
351
border: 1px solid var(--error-border);
451
352
border-radius: 4px;
452
353
color: var(--error-text);
453
-
}
454
-
.success {
455
-
padding: 0.75rem;
456
-
background: var(--success-bg);
457
-
border: 1px solid var(--success-border);
458
-
border-radius: 4px;
459
-
color: var(--success-text);
460
354
}
461
355
.login-link {
462
356
text-align: center;
+145
-15
frontend/src/routes/Settings.svelte
+145
-15
frontend/src/routes/Settings.svelte
···
19
19
let currentPassword = $state('')
20
20
let newPassword = $state('')
21
21
let confirmNewPassword = $state('')
22
+
let showBYOHandle = $state(false)
22
23
$effect(() => {
23
24
if (!auth.loading && !auth.session) {
24
25
navigate('/login')
···
230
231
{#if auth.session}
231
232
<p class="current">Current: @{auth.session.handle}</p>
232
233
{/if}
233
-
<form onsubmit={handleUpdateHandle}>
234
-
<div class="field">
235
-
<label for="new-handle">New Handle</label>
236
-
<input
237
-
id="new-handle"
238
-
type="text"
239
-
bind:value={newHandle}
240
-
placeholder="newhandle.bsky.social"
241
-
disabled={handleLoading}
242
-
required
243
-
/>
234
+
<div class="tabs">
235
+
<button
236
+
type="button"
237
+
class="tab"
238
+
class:active={!showBYOHandle}
239
+
onclick={() => showBYOHandle = false}
240
+
>
241
+
PDS Handle
242
+
</button>
243
+
<button
244
+
type="button"
245
+
class="tab"
246
+
class:active={showBYOHandle}
247
+
onclick={() => showBYOHandle = true}
248
+
>
249
+
Custom Domain
250
+
</button>
251
+
</div>
252
+
{#if showBYOHandle}
253
+
<div class="byo-handle">
254
+
<p class="description">Use your own domain as your handle. You need to verify domain ownership first.</p>
255
+
{#if auth.session}
256
+
<div class="verification-info">
257
+
<h3>Setup Instructions</h3>
258
+
<p>Choose one of these verification methods:</p>
259
+
<div class="method">
260
+
<h4>Option 1: DNS TXT Record (Recommended)</h4>
261
+
<p>Add this TXT record to your domain:</p>
262
+
<code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={auth.session.did}"</code>
263
+
</div>
264
+
<div class="method">
265
+
<h4>Option 2: HTTP Well-Known File</h4>
266
+
<p>Serve your DID at this URL:</p>
267
+
<code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code>
268
+
<p>The file should contain only:</p>
269
+
<code class="record">{auth.session.did}</code>
270
+
</div>
271
+
</div>
272
+
{/if}
273
+
<form onsubmit={handleUpdateHandle}>
274
+
<div class="field">
275
+
<label for="new-handle-byo">Your Domain</label>
276
+
<input
277
+
id="new-handle-byo"
278
+
type="text"
279
+
bind:value={newHandle}
280
+
placeholder="example.com"
281
+
disabled={handleLoading}
282
+
required
283
+
/>
284
+
</div>
285
+
<button type="submit" disabled={handleLoading || !newHandle}>
286
+
{handleLoading ? 'Verifying...' : 'Verify & Update Handle'}
287
+
</button>
288
+
</form>
244
289
</div>
245
-
<button type="submit" disabled={handleLoading || !newHandle}>
246
-
{handleLoading ? 'Updating...' : 'Change Handle'}
247
-
</button>
248
-
</form>
290
+
{:else}
291
+
<form onsubmit={handleUpdateHandle}>
292
+
<div class="field">
293
+
<label for="new-handle">New Handle</label>
294
+
<input
295
+
id="new-handle"
296
+
type="text"
297
+
bind:value={newHandle}
298
+
placeholder="yourhandle"
299
+
disabled={handleLoading}
300
+
required
301
+
/>
302
+
</div>
303
+
<button type="submit" disabled={handleLoading || !newHandle}>
304
+
{handleLoading ? 'Updating...' : 'Change Handle'}
305
+
</button>
306
+
</form>
307
+
{/if}
249
308
</section>
250
309
<section>
251
310
<h2>Change Password</h2>
···
457
516
color: var(--error-text);
458
517
font-size: 0.875rem;
459
518
margin-bottom: 1rem;
519
+
}
520
+
.tabs {
521
+
display: flex;
522
+
gap: 0.25rem;
523
+
margin-bottom: 1rem;
524
+
}
525
+
.tab {
526
+
flex: 1;
527
+
padding: 0.5rem 1rem;
528
+
background: transparent;
529
+
border: 1px solid var(--border-color-light);
530
+
cursor: pointer;
531
+
font-size: 0.875rem;
532
+
color: var(--text-secondary);
533
+
}
534
+
.tab:first-child {
535
+
border-radius: 4px 0 0 4px;
536
+
}
537
+
.tab:last-child {
538
+
border-radius: 0 4px 4px 0;
539
+
}
540
+
.tab.active {
541
+
background: var(--accent);
542
+
border-color: var(--accent);
543
+
color: white;
544
+
}
545
+
.tab:hover:not(.active) {
546
+
background: var(--bg-card);
547
+
}
548
+
.byo-handle .description {
549
+
margin-bottom: 1rem;
550
+
}
551
+
.verification-info {
552
+
background: var(--bg-card);
553
+
border: 1px solid var(--border-color-light);
554
+
border-radius: 6px;
555
+
padding: 1rem;
556
+
margin-bottom: 1rem;
557
+
}
558
+
.verification-info h3 {
559
+
margin: 0 0 0.5rem 0;
560
+
font-size: 1rem;
561
+
}
562
+
.verification-info h4 {
563
+
margin: 0.75rem 0 0.25rem 0;
564
+
font-size: 0.875rem;
565
+
color: var(--text-secondary);
566
+
}
567
+
.verification-info p {
568
+
margin: 0.25rem 0;
569
+
font-size: 0.8rem;
570
+
color: var(--text-secondary);
571
+
}
572
+
.method {
573
+
margin-top: 0.75rem;
574
+
padding-top: 0.75rem;
575
+
border-top: 1px solid var(--border-color-light);
576
+
}
577
+
.method:first-of-type {
578
+
margin-top: 0.5rem;
579
+
padding-top: 0;
580
+
border-top: none;
581
+
}
582
+
code.record {
583
+
display: block;
584
+
background: var(--bg-input);
585
+
padding: 0.5rem;
586
+
border-radius: 4px;
587
+
font-size: 0.75rem;
588
+
word-break: break-all;
589
+
margin: 0.25rem 0;
460
590
}
461
591
</style>
+277
frontend/src/routes/Verify.svelte
+277
frontend/src/routes/Verify.svelte
···
1
+
<script lang="ts">
2
+
import { confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
3
+
import { navigate } from '../lib/router.svelte'
4
+
5
+
const STORAGE_KEY = 'bspds_pending_verification'
6
+
7
+
interface PendingVerification {
8
+
did: string
9
+
handle: string
10
+
channel: string
11
+
}
12
+
13
+
let pendingVerification = $state<PendingVerification | null>(null)
14
+
let verificationCode = $state('')
15
+
let submitting = $state(false)
16
+
let resendingCode = $state(false)
17
+
let error = $state<string | null>(null)
18
+
let resendMessage = $state<string | null>(null)
19
+
20
+
const auth = getAuthState()
21
+
22
+
$effect(() => {
23
+
if (auth.session) {
24
+
clearPendingVerification()
25
+
navigate('/dashboard')
26
+
}
27
+
})
28
+
29
+
$effect(() => {
30
+
const stored = localStorage.getItem(STORAGE_KEY)
31
+
if (stored) {
32
+
try {
33
+
pendingVerification = JSON.parse(stored)
34
+
} catch {
35
+
pendingVerification = null
36
+
}
37
+
}
38
+
})
39
+
40
+
function clearPendingVerification() {
41
+
localStorage.removeItem(STORAGE_KEY)
42
+
pendingVerification = null
43
+
}
44
+
45
+
async function handleVerification(e: Event) {
46
+
e.preventDefault()
47
+
if (!pendingVerification || !verificationCode.trim()) return
48
+
49
+
submitting = true
50
+
error = null
51
+
52
+
try {
53
+
await confirmSignup(pendingVerification.did, verificationCode.trim())
54
+
clearPendingVerification()
55
+
navigate('/dashboard')
56
+
} catch (e: any) {
57
+
error = e.message || 'Verification failed'
58
+
} finally {
59
+
submitting = false
60
+
}
61
+
}
62
+
63
+
async function handleResendCode() {
64
+
if (!pendingVerification || resendingCode) return
65
+
66
+
resendingCode = true
67
+
resendMessage = null
68
+
error = null
69
+
70
+
try {
71
+
await resendVerification(pendingVerification.did)
72
+
resendMessage = 'Verification code resent!'
73
+
} catch (e: any) {
74
+
error = e.message || 'Failed to resend code'
75
+
} finally {
76
+
resendingCode = false
77
+
}
78
+
}
79
+
80
+
function channelLabel(ch: string): string {
81
+
switch (ch) {
82
+
case 'email': return 'Email'
83
+
case 'discord': return 'Discord'
84
+
case 'telegram': return 'Telegram'
85
+
case 'signal': return 'Signal'
86
+
default: return ch
87
+
}
88
+
}
89
+
</script>
90
+
91
+
<div class="verify-container">
92
+
{#if error}
93
+
<div class="error">{error}</div>
94
+
{/if}
95
+
96
+
{#if pendingVerification}
97
+
<h1>Verify Your Account</h1>
98
+
<p class="subtitle">
99
+
We've sent a verification code to your {channelLabel(pendingVerification.channel)}.
100
+
Enter it below to complete registration.
101
+
</p>
102
+
<p class="handle-info">Verifying account: <strong>@{pendingVerification.handle}</strong></p>
103
+
104
+
{#if resendMessage}
105
+
<div class="success">{resendMessage}</div>
106
+
{/if}
107
+
108
+
<form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
109
+
<div class="field">
110
+
<label for="verification-code">Verification Code</label>
111
+
<input
112
+
id="verification-code"
113
+
type="text"
114
+
bind:value={verificationCode}
115
+
placeholder="Enter 6-digit code"
116
+
disabled={submitting}
117
+
required
118
+
maxlength="6"
119
+
inputmode="numeric"
120
+
autocomplete="one-time-code"
121
+
/>
122
+
</div>
123
+
124
+
<button type="submit" disabled={submitting || !verificationCode.trim()}>
125
+
{submitting ? 'Verifying...' : 'Verify Account'}
126
+
</button>
127
+
128
+
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
129
+
{resendingCode ? 'Resending...' : 'Resend Code'}
130
+
</button>
131
+
</form>
132
+
133
+
<p class="cancel-link">
134
+
<a href="#/register" onclick={() => clearPendingVerification()}>Start over with a different account</a>
135
+
</p>
136
+
{:else}
137
+
<h1>Account Verification</h1>
138
+
<p class="subtitle">No pending verification found.</p>
139
+
<p class="no-pending-info">
140
+
If you recently created an account and need to verify it, you may need to create a new account.
141
+
If you already verified your account, you can sign in.
142
+
</p>
143
+
<div class="actions">
144
+
<a href="#/register" class="btn">Create Account</a>
145
+
<a href="#/login" class="btn secondary">Sign In</a>
146
+
</div>
147
+
{/if}
148
+
</div>
149
+
150
+
<style>
151
+
.verify-container {
152
+
max-width: 400px;
153
+
margin: 4rem auto;
154
+
padding: 2rem;
155
+
}
156
+
157
+
h1 {
158
+
margin: 0 0 0.5rem 0;
159
+
}
160
+
161
+
.subtitle {
162
+
color: var(--text-secondary);
163
+
margin: 0 0 1rem 0;
164
+
}
165
+
166
+
.handle-info {
167
+
font-size: 0.9rem;
168
+
color: var(--text-secondary);
169
+
margin: 0 0 1.5rem 0;
170
+
}
171
+
172
+
.no-pending-info {
173
+
color: var(--text-secondary);
174
+
margin: 1rem 0 1.5rem 0;
175
+
}
176
+
177
+
form {
178
+
display: flex;
179
+
flex-direction: column;
180
+
gap: 1rem;
181
+
}
182
+
183
+
.field {
184
+
display: flex;
185
+
flex-direction: column;
186
+
gap: 0.25rem;
187
+
}
188
+
189
+
label {
190
+
font-size: 0.875rem;
191
+
font-weight: 500;
192
+
}
193
+
194
+
input {
195
+
padding: 0.75rem;
196
+
border: 1px solid var(--border-color-light);
197
+
border-radius: 4px;
198
+
font-size: 1rem;
199
+
background: var(--bg-input);
200
+
color: var(--text-primary);
201
+
}
202
+
203
+
input:focus {
204
+
outline: none;
205
+
border-color: var(--accent);
206
+
}
207
+
208
+
button, .btn {
209
+
padding: 0.75rem;
210
+
background: var(--accent);
211
+
color: white;
212
+
border: none;
213
+
border-radius: 4px;
214
+
font-size: 1rem;
215
+
cursor: pointer;
216
+
text-decoration: none;
217
+
text-align: center;
218
+
display: inline-block;
219
+
}
220
+
221
+
button:hover:not(:disabled), .btn:hover {
222
+
background: var(--accent-hover);
223
+
}
224
+
225
+
button:disabled {
226
+
opacity: 0.6;
227
+
cursor: not-allowed;
228
+
}
229
+
230
+
button.secondary, .btn.secondary {
231
+
background: transparent;
232
+
color: var(--accent);
233
+
border: 1px solid var(--accent);
234
+
}
235
+
236
+
button.secondary:hover:not(:disabled), .btn.secondary:hover {
237
+
background: var(--accent);
238
+
color: white;
239
+
}
240
+
241
+
.error {
242
+
padding: 0.75rem;
243
+
background: var(--error-bg);
244
+
border: 1px solid var(--error-border);
245
+
border-radius: 4px;
246
+
color: var(--error-text);
247
+
margin-bottom: 1rem;
248
+
}
249
+
250
+
.success {
251
+
padding: 0.75rem;
252
+
background: var(--success-bg);
253
+
border: 1px solid var(--success-border);
254
+
border-radius: 4px;
255
+
color: var(--success-text);
256
+
margin-bottom: 1rem;
257
+
}
258
+
259
+
.cancel-link {
260
+
text-align: center;
261
+
margin-top: 1.5rem;
262
+
font-size: 0.875rem;
263
+
}
264
+
265
+
.cancel-link a {
266
+
color: var(--text-secondary);
267
+
}
268
+
269
+
.actions {
270
+
display: flex;
271
+
gap: 1rem;
272
+
}
273
+
274
+
.actions .btn {
275
+
flex: 1;
276
+
}
277
+
</style>
+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 $$;
+3
-3
src/api/admin/account/email.rs
+3
-3
src/api/admin/account/email.rs
···
87
87
.subject
88
88
.clone()
89
89
.unwrap_or_else(|| format!("Message from {}", hostname));
90
-
let notification = crate::notifications::NewNotification::email(
90
+
let item = crate::comms::NewComms::email(
91
91
user_id,
92
-
crate::notifications::NotificationType::AdminEmail,
92
+
crate::comms::CommsType::AdminEmail,
93
93
email,
94
94
subject,
95
95
content.to_string(),
96
96
);
97
-
let result = crate::notifications::enqueue_notification(&state.db, notification).await;
97
+
let result = crate::comms::enqueue_comms(&state.db, item).await;
98
98
match result {
99
99
Ok(_) => {
100
100
tracing::info!("Admin email queued for {} ({})", handle, recipient_did);
+3
-3
src/api/admin/account/info.rs
+3
-3
src/api/admin/account/info.rs
···
24
24
pub indexed_at: String,
25
25
pub invite_note: Option<String>,
26
26
pub invites_disabled: bool,
27
-
pub email_confirmed_at: Option<String>,
27
+
pub email_verified_at: Option<String>,
28
28
pub deactivated_at: Option<String>,
29
29
}
30
30
···
67
67
indexed_at: row.created_at.to_rfc3339(),
68
68
invite_note: None,
69
69
invites_disabled: false,
70
-
email_confirmed_at: None,
70
+
email_verified_at: None,
71
71
deactivated_at: None,
72
72
}),
73
73
)
···
143
143
indexed_at: row.created_at.to_rfc3339(),
144
144
invite_note: None,
145
145
invites_disabled: false,
146
-
email_confirmed_at: None,
146
+
email_verified_at: None,
147
147
deactivated_at: None,
148
148
});
149
149
}
+4
-4
src/api/admin/account/search.rs
+4
-4
src/api/admin/account/search.rs
···
31
31
pub email: Option<String>,
32
32
pub indexed_at: String,
33
33
#[serde(skip_serializing_if = "Option::is_none")]
34
-
pub email_confirmed_at: Option<String>,
34
+
pub email_verified_at: Option<String>,
35
35
#[serde(skip_serializing_if = "Option::is_none")]
36
36
pub deactivated_at: Option<String>,
37
37
#[serde(skip_serializing_if = "Option::is_none")]
···
56
56
let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h));
57
57
let result = sqlx::query_as::<_, (String, String, Option<String>, chrono::DateTime<chrono::Utc>, bool, Option<chrono::DateTime<chrono::Utc>>)>(
58
58
r#"
59
-
SELECT did, handle, email, created_at, email_confirmed, deactivated_at
59
+
SELECT did, handle, email, created_at, email_verified, deactivated_at
60
60
FROM users
61
61
WHERE did > $1 AND ($2::text IS NULL OR handle ILIKE $2)
62
62
ORDER BY did ASC
···
74
74
let accounts: Vec<AccountView> = rows
75
75
.into_iter()
76
76
.take(limit as usize)
77
-
.map(|(did, handle, email, created_at, email_confirmed, deactivated_at)| AccountView {
77
+
.map(|(did, handle, email, created_at, email_verified, deactivated_at)| AccountView {
78
78
did: did.clone(),
79
79
handle,
80
80
email,
81
81
indexed_at: created_at.to_rfc3339(),
82
-
email_confirmed_at: if email_confirmed {
82
+
email_verified_at: if email_verified {
83
83
Some(created_at.to_rfc3339())
84
84
} else {
85
85
None
+63
-49
src/api/identity/account.rs
+63
-49
src/api/identity/account.rs
···
322
322
}
323
323
Ok(None) => {}
324
324
}
325
+
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
326
+
.map(|v| v == "true" || v == "1")
327
+
.unwrap_or(false);
328
+
if invite_code_required && input.invite_code.as_ref().map(|c| c.trim().is_empty()).unwrap_or(true) {
329
+
return (
330
+
StatusCode::BAD_REQUEST,
331
+
Json(json!({"error": "InvalidInviteCode", "message": "Invite code is required"})),
332
+
)
333
+
.into_response();
334
+
}
325
335
if let Some(code) = &input.invite_code {
326
-
let invite_query = sqlx::query!(
327
-
"SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE",
328
-
code
329
-
)
330
-
.fetch_optional(&mut *tx)
331
-
.await;
332
-
match invite_query {
333
-
Ok(Some(row)) => {
334
-
if row.available_uses <= 0 {
335
-
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response();
336
+
if !code.trim().is_empty() {
337
+
let invite_query = sqlx::query!(
338
+
"SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE",
339
+
code
340
+
)
341
+
.fetch_optional(&mut *tx)
342
+
.await;
343
+
match invite_query {
344
+
Ok(Some(row)) => {
345
+
if row.available_uses <= 0 {
346
+
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response();
347
+
}
348
+
let update_invite = sqlx::query!(
349
+
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
350
+
code
351
+
)
352
+
.execute(&mut *tx)
353
+
.await;
354
+
if let Err(e) = update_invite {
355
+
error!("Error updating invite code: {:?}", e);
356
+
return (
357
+
StatusCode::INTERNAL_SERVER_ERROR,
358
+
Json(json!({"error": "InternalError"})),
359
+
)
360
+
.into_response();
361
+
}
336
362
}
337
-
let update_invite = sqlx::query!(
338
-
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
339
-
code
340
-
)
341
-
.execute(&mut *tx)
342
-
.await;
343
-
if let Err(e) = update_invite {
344
-
error!("Error updating invite code: {:?}", e);
363
+
Ok(None) => {
364
+
return (
365
+
StatusCode::BAD_REQUEST,
366
+
Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})),
367
+
)
368
+
.into_response();
369
+
}
370
+
Err(e) => {
371
+
error!("Error checking invite code: {:?}", e);
345
372
return (
346
373
StatusCode::INTERNAL_SERVER_ERROR,
347
374
Json(json!({"error": "InternalError"})),
···
349
376
.into_response();
350
377
}
351
378
}
352
-
Ok(None) => {
353
-
return (
354
-
StatusCode::BAD_REQUEST,
355
-
Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})),
356
-
)
357
-
.into_response();
358
-
}
359
-
Err(e) => {
360
-
error!("Error checking invite code: {:?}", e);
361
-
return (
362
-
StatusCode::INTERNAL_SERVER_ERROR,
363
-
Json(json!({"error": "InternalError"})),
364
-
)
365
-
.into_response();
366
-
}
367
379
}
368
380
}
369
381
let password_hash = match hash(&input.password, DEFAULT_COST) {
···
387
399
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
388
400
r#"INSERT INTO users (
389
401
handle, email, did, password_hash,
390
-
preferred_notification_channel,
402
+
preferred_comms_channel,
391
403
discord_id, telegram_username, signal_number,
392
404
is_admin
393
-
) VALUES ($1, $2, $3, $4, $5::notification_channel, $6, $7, $8, $9) RETURNING id"#,
405
+
) VALUES ($1, $2, $3, $4, $5::comms_channel, $6, $7, $8, $9) RETURNING id"#,
394
406
)
395
407
.bind(short_handle)
396
408
.bind(&email)
···
598
610
.into_response();
599
611
}
600
612
if let Some(code) = &input.invite_code {
601
-
let use_insert = sqlx::query!(
602
-
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
603
-
code,
604
-
user_id
605
-
)
606
-
.execute(&mut *tx)
607
-
.await;
608
-
if let Err(e) = use_insert {
609
-
error!("Error recording invite usage: {:?}", e);
610
-
return (
611
-
StatusCode::INTERNAL_SERVER_ERROR,
612
-
Json(json!({"error": "InternalError"})),
613
+
if !code.trim().is_empty() {
614
+
let use_insert = sqlx::query!(
615
+
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
616
+
code,
617
+
user_id
613
618
)
614
-
.into_response();
619
+
.execute(&mut *tx)
620
+
.await;
621
+
if let Err(e) = use_insert {
622
+
error!("Error recording invite usage: {:?}", e);
623
+
return (
624
+
StatusCode::INTERNAL_SERVER_ERROR,
625
+
Json(json!({"error": "InternalError"})),
626
+
)
627
+
.into_response();
628
+
}
615
629
}
616
630
}
617
631
if let Err(e) = tx.commit().await {
···
646
660
{
647
661
warn!("Failed to create default profile for {}: {}", did, e);
648
662
}
649
-
if let Err(e) = crate::notifications::enqueue_signup_verification(
663
+
if let Err(e) = crate::comms::enqueue_signup_verification(
650
664
&state.db,
651
665
user_id,
652
666
verification_channel,
+106
-11
src/api/identity/did.rs
+106
-11
src/api/identity/did.rs
···
53
53
.await;
54
54
(StatusCode::OK, Json(json!({ "did": row.did }))).into_response()
55
55
}
56
-
Ok(None) => (
57
-
StatusCode::NOT_FOUND,
58
-
Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})),
59
-
)
60
-
.into_response(),
56
+
Ok(None) => {
57
+
match crate::handle::resolve_handle(handle).await {
58
+
Ok(did) => {
59
+
let _ = state
60
+
.cache
61
+
.set(&cache_key, &did, std::time::Duration::from_secs(300))
62
+
.await;
63
+
(StatusCode::OK, Json(json!({ "did": did }))).into_response()
64
+
}
65
+
Err(_) => (
66
+
StatusCode::NOT_FOUND,
67
+
Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})),
68
+
)
69
+
.into_response(),
70
+
}
71
+
}
61
72
Err(e) => {
62
73
error!("DB error resolving handle: {:?}", e);
63
74
(
···
396
407
)
397
408
.into_response();
398
409
}
410
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
411
+
let is_service_domain = crate::handle::is_service_domain_handle(new_handle, &hostname);
412
+
let (handle_to_store, full_handle) = if is_service_domain {
413
+
let suffix = format!(".{}", hostname);
414
+
let short_handle = if new_handle.ends_with(&suffix) {
415
+
new_handle.strip_suffix(&suffix).unwrap_or(new_handle)
416
+
} else {
417
+
new_handle
418
+
};
419
+
(short_handle.to_string(), format!("{}.{}", short_handle, hostname))
420
+
} else {
421
+
match crate::handle::verify_handle_ownership(new_handle, &did).await {
422
+
Ok(()) => {}
423
+
Err(crate::handle::HandleResolutionError::NotFound) => {
424
+
return (
425
+
StatusCode::BAD_REQUEST,
426
+
Json(json!({
427
+
"error": "HandleNotAvailable",
428
+
"message": "Handle verification failed. Please set up DNS TXT record at _atproto.{} or serve your DID at https://{}/.well-known/atproto-did",
429
+
"handle": new_handle
430
+
})),
431
+
)
432
+
.into_response();
433
+
}
434
+
Err(crate::handle::HandleResolutionError::DidMismatch { expected, actual }) => {
435
+
return (
436
+
StatusCode::BAD_REQUEST,
437
+
Json(json!({
438
+
"error": "HandleNotAvailable",
439
+
"message": format!("Handle points to different DID. Expected {}, got {}", expected, actual)
440
+
})),
441
+
)
442
+
.into_response();
443
+
}
444
+
Err(e) => {
445
+
warn!("Handle verification failed: {}", e);
446
+
return (
447
+
StatusCode::BAD_REQUEST,
448
+
Json(json!({
449
+
"error": "HandleNotAvailable",
450
+
"message": format!("Handle verification failed: {}", e)
451
+
})),
452
+
)
453
+
.into_response();
454
+
}
455
+
}
456
+
(new_handle.to_string(), new_handle.to_string())
457
+
};
399
458
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE id = $1", user_id)
400
459
.fetch_optional(&state.db)
401
460
.await
···
403
462
.flatten();
404
463
let existing = sqlx::query!(
405
464
"SELECT id FROM users WHERE handle = $1 AND id != $2",
406
-
new_handle,
465
+
handle_to_store,
407
466
user_id
408
467
)
409
468
.fetch_optional(&state.db)
···
417
476
}
418
477
let result = sqlx::query!(
419
478
"UPDATE users SET handle = $1 WHERE id = $2",
420
-
new_handle,
479
+
handle_to_store,
421
480
user_id
422
481
)
423
482
.execute(&state.db)
···
427
486
if let Some(old) = old_handle {
428
487
let _ = state.cache.delete(&format!("handle:{}", old)).await;
429
488
}
430
-
let _ = state.cache.delete(&format!("handle:{}", new_handle)).await;
431
-
let hostname =
432
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
433
-
let full_handle = format!("{}.{}", new_handle, hostname);
489
+
let _ = state
490
+
.cache
491
+
.delete(&format!("handle:{}", handle_to_store))
492
+
.await;
493
+
let _ = state.cache.delete(&format!("handle:{}", full_handle)).await;
434
494
if let Err(e) =
435
495
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle))
436
496
.await
437
497
{
438
498
warn!("Failed to sequence identity event for handle update: {}", e);
499
+
}
500
+
if let Err(e) = update_plc_handle(&state, &did, &full_handle).await {
501
+
warn!("Failed to update PLC handle: {}", e);
439
502
}
440
503
(StatusCode::OK, Json(json!({}))).into_response()
441
504
}
···
448
511
.into_response()
449
512
}
450
513
}
514
+
}
515
+
516
+
async fn update_plc_handle(
517
+
state: &AppState,
518
+
did: &str,
519
+
new_handle: &str,
520
+
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
521
+
if !did.starts_with("did:plc:") {
522
+
return Ok(());
523
+
}
524
+
let user_row = sqlx::query!(
525
+
r#"SELECT u.id, uk.key_bytes, uk.encryption_version
526
+
FROM users u
527
+
JOIN user_keys uk ON u.id = uk.user_id
528
+
WHERE u.did = $1"#,
529
+
did
530
+
)
531
+
.fetch_optional(&state.db)
532
+
.await?;
533
+
let user_row = match user_row {
534
+
Some(r) => r,
535
+
None => return Ok(()),
536
+
};
537
+
let key_bytes = crate::config::decrypt_key(&user_row.key_bytes, user_row.encryption_version)?;
538
+
let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes)?;
539
+
let plc_client = crate::plc::PlcClient::new(None);
540
+
let last_op = plc_client.get_last_op(did).await?;
541
+
let new_also_known_as = vec![format!("at://{}", new_handle)];
542
+
let update_op = crate::plc::create_update_op(&last_op, None, None, Some(new_also_known_as), None)?;
543
+
let signed_op = crate::plc::sign_operation(&update_op, &signing_key)?;
544
+
plc_client.send_operation(did, &signed_op).await?;
545
+
Ok(())
451
546
}
452
547
453
548
pub async fn well_known_atproto_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
+1
-1
src/api/identity/plc/request.rs
+1
-1
src/api/identity/plc/request.rs
···
68
68
}
69
69
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
70
70
if let Err(e) =
71
-
crate::notifications::enqueue_plc_operation(&state.db, user.id, &plc_token, &hostname).await
71
+
crate::comms::enqueue_plc_operation(&state.db, user.id, &plc_token, &hostname).await
72
72
{
73
73
warn!("Failed to enqueue PLC operation notification: {:?}", e);
74
74
}
+10
-10
src/api/notification_prefs.rs
+10
-10
src/api/notification_prefs.rs
···
60
60
r#"
61
61
SELECT
62
62
email,
63
-
preferred_notification_channel::text as channel,
63
+
preferred_comms_channel::text as channel,
64
64
discord_id,
65
65
discord_verified,
66
66
telegram_username,
···
110
110
pub struct NotificationHistoryEntry {
111
111
pub created_at: String,
112
112
pub channel: String,
113
-
pub notification_type: String,
113
+
pub comms_type: String,
114
114
pub status: String,
115
115
pub subject: Option<String>,
116
116
pub body: String,
···
164
164
SELECT
165
165
created_at,
166
166
channel as "channel: String",
167
-
notification_type as "notification_type: String",
167
+
comms_type as "comms_type: String",
168
168
status as "status: String",
169
169
subject,
170
170
body
171
-
FROM notification_queue
171
+
FROM comms_queue
172
172
WHERE user_id = $1
173
173
ORDER BY created_at DESC
174
174
LIMIT 50
···
190
190
NotificationHistoryEntry {
191
191
created_at: row.created_at.to_rfc3339(),
192
192
channel: row.channel.clone(),
193
-
notification_type: row.notification_type.clone(),
193
+
comms_type: row.comms_type.clone(),
194
194
status: row.status.clone(),
195
195
subject: row.subject.clone(),
196
196
body: row.body.clone(),
···
231
231
sqlx::query!(
232
232
r#"
233
233
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
234
-
VALUES ($1, $2::notification_channel, $3, $4, $5)
234
+
VALUES ($1, $2::comms_channel, $3, $4, $5)
235
235
ON CONFLICT (user_id, channel) DO UPDATE
236
236
SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()
237
237
"#,
···
248
248
if channel == "email" {
249
249
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
250
250
let handle_str = handle.unwrap_or("user");
251
-
crate::notifications::enqueue_email_update(db, user_id, identifier, handle_str, &code, &hostname)
251
+
crate::comms::enqueue_email_update(db, user_id, identifier, handle_str, &code, &hostname)
252
252
.await
253
253
.map_err(|e| format!("Failed to enqueue email notification: {}", e))?;
254
254
} else {
255
255
sqlx::query!(
256
256
r#"
257
-
INSERT INTO notification_queue (user_id, channel, notification_type, recipient, subject, body, metadata)
258
-
VALUES ($1, $2::notification_channel, 'channel_verification', $3, 'Verify your channel', $4, $5)
257
+
INSERT INTO comms_queue (user_id, channel, comms_type, recipient, subject, body, metadata)
258
+
VALUES ($1, $2::comms_channel, 'channel_verification', $3, 'Verify your channel', $4, $5)
259
259
"#,
260
260
user_id,
261
261
channel as _,
···
331
331
.into_response();
332
332
}
333
333
if let Err(e) = sqlx::query(
334
-
r#"UPDATE users SET preferred_notification_channel = $1::notification_channel, updated_at = NOW() WHERE did = $2"#
334
+
r#"UPDATE users SET preferred_comms_channel = $1::comms_channel, updated_at = NOW() WHERE did = $2"#
335
335
)
336
336
.bind(channel)
337
337
.bind(&user.did)
+2
-2
src/api/repo/record/batch.rs
+2
-2
src/api/repo/record/batch.rs
···
1
1
use super::validation::validate_record;
2
-
use super::write::has_verified_notification_channel;
2
+
use super::write::has_verified_comms_channel;
3
3
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
4
4
use crate::repo::tracking::TrackingBlockStore;
5
5
use crate::state::AppState;
···
109
109
)
110
110
.into_response();
111
111
}
112
-
match has_verified_notification_channel(&state.db, &did).await {
112
+
match has_verified_comms_channel(&state.db, &did).await {
113
113
Ok(true) => {}
114
114
Ok(false) => {
115
115
return (
+5
-5
src/api/repo/record/write.rs
+5
-5
src/api/repo/record/write.rs
···
22
22
use tracing::error;
23
23
use uuid::Uuid;
24
24
25
-
pub async fn has_verified_notification_channel(
25
+
pub async fn has_verified_comms_channel(
26
26
db: &PgPool,
27
27
did: &str,
28
28
) -> Result<bool, sqlx::Error> {
29
29
let row = sqlx::query(
30
30
r#"
31
31
SELECT
32
-
email_confirmed,
32
+
email_verified,
33
33
discord_verified,
34
34
telegram_verified,
35
35
signal_verified
···
42
42
.await?;
43
43
match row {
44
44
Some(r) => {
45
-
let email_confirmed: bool = r.get("email_confirmed");
45
+
let email_verified: bool = r.get("email_verified");
46
46
let discord_verified: bool = r.get("discord_verified");
47
47
let telegram_verified: bool = r.get("telegram_verified");
48
48
let signal_verified: bool = r.get("signal_verified");
49
-
Ok(email_confirmed || discord_verified || telegram_verified || signal_verified)
49
+
Ok(email_verified || discord_verified || telegram_verified || signal_verified)
50
50
}
51
51
None => Ok(false),
52
52
}
···
96
96
)
97
97
.into_response());
98
98
}
99
-
match has_verified_notification_channel(&state.db, &auth_user.did).await {
99
+
match has_verified_comms_channel(&state.db, &auth_user.did).await {
100
100
Ok(true) => {}
101
101
Ok(false) => {
102
102
return Err((
+1
-1
src/api/server/account_status.rs
+1
-1
src/api/server/account_status.rs
···
299
299
.into_response();
300
300
}
301
301
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
302
-
if let Err(e) = crate::notifications::enqueue_account_deletion(
302
+
if let Err(e) = crate::comms::enqueue_account_deletion(
303
303
&state.db,
304
304
user_id,
305
305
&confirmation_token,
+1
-1
src/api/server/password.rs
+1
-1
src/api/server/password.rs
···
100
100
}
101
101
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
102
102
if let Err(e) =
103
-
crate::notifications::enqueue_password_reset(&state.db, user_id, &code, &hostname).await
103
+
crate::comms::enqueue_password_reset(&state.db, user_id, &code, &hostname).await
104
104
{
105
105
warn!("Failed to enqueue password reset notification: {:?}", e);
106
106
}
+52
-44
src/api/server/session.rs
+52
-44
src/api/server/session.rs
···
35
35
}
36
36
}
37
37
38
+
fn full_handle(stored_handle: &str, pds_hostname: &str) -> String {
39
+
if stored_handle.contains('.') {
40
+
stored_handle.to_string()
41
+
} else {
42
+
format!("{}.{}", stored_handle, pds_hostname)
43
+
}
44
+
}
45
+
38
46
#[derive(Deserialize)]
39
47
pub struct CreateSessionInput {
40
48
pub identifier: String,
···
76
84
let row = match sqlx::query!(
77
85
r#"SELECT
78
86
u.id, u.did, u.handle, u.password_hash,
79
-
u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,
87
+
u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,
80
88
k.key_bytes, k.encryption_version
81
89
FROM users u
82
90
JOIN user_keys k ON u.id = k.user_id
···
128
136
.into_response();
129
137
}
130
138
let is_verified =
131
-
row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified;
139
+
row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified;
132
140
if !is_verified {
133
141
warn!("Login attempt for unverified account: {}", row.did);
134
142
return (
···
169
177
error!("Failed to insert session: {:?}", e);
170
178
return ApiError::InternalError.into_response();
171
179
}
172
-
let full_handle = format!("{}.{}", row.handle, pds_hostname);
180
+
let handle = full_handle(&row.handle, &pds_hostname);
173
181
Json(CreateSessionOutput {
174
182
access_jwt: access_meta.token,
175
183
refresh_jwt: refresh_meta.token,
176
-
handle: full_handle,
184
+
handle,
177
185
did: row.did,
178
186
})
179
187
.into_response()
···
185
193
) -> Response {
186
194
match sqlx::query!(
187
195
r#"SELECT
188
-
handle, email, email_confirmed, is_admin,
189
-
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
196
+
handle, email, email_verified, is_admin,
197
+
preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel",
190
198
discord_verified, telegram_verified, signal_verified
191
199
FROM users WHERE did = $1"#,
192
200
auth_user.did
···
196
204
{
197
205
Ok(Some(row)) => {
198
206
let (preferred_channel, preferred_channel_verified) = match row.preferred_channel {
199
-
crate::notifications::NotificationChannel::Email => ("email", row.email_confirmed),
200
-
crate::notifications::NotificationChannel::Discord => ("discord", row.discord_verified),
201
-
crate::notifications::NotificationChannel::Telegram => ("telegram", row.telegram_verified),
202
-
crate::notifications::NotificationChannel::Signal => ("signal", row.signal_verified),
207
+
crate::comms::CommsChannel::Email => ("email", row.email_verified),
208
+
crate::comms::CommsChannel::Discord => ("discord", row.discord_verified),
209
+
crate::comms::CommsChannel::Telegram => ("telegram", row.telegram_verified),
210
+
crate::comms::CommsChannel::Signal => ("signal", row.signal_verified),
203
211
};
204
212
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
205
-
let full_handle = format!("{}.{}", row.handle, pds_hostname);
213
+
let handle = full_handle(&row.handle, &pds_hostname);
206
214
Json(json!({
207
-
"handle": full_handle,
215
+
"handle": handle,
208
216
"did": auth_user.did,
209
217
"email": row.email,
210
-
"emailConfirmed": row.email_confirmed,
218
+
"emailVerified": row.email_verified,
211
219
"preferredChannel": preferred_channel,
212
220
"preferredChannelVerified": preferred_channel_verified,
213
221
"isAdmin": row.is_admin,
···
407
415
}
408
416
match sqlx::query!(
409
417
r#"SELECT
410
-
handle, email, email_confirmed, is_admin,
411
-
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
418
+
handle, email, email_verified, is_admin,
419
+
preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel",
412
420
discord_verified, telegram_verified, signal_verified
413
421
FROM users WHERE did = $1"#,
414
422
session_row.did
···
418
426
{
419
427
Ok(Some(u)) => {
420
428
let (preferred_channel, preferred_channel_verified) = match u.preferred_channel {
421
-
crate::notifications::NotificationChannel::Email => ("email", u.email_confirmed),
422
-
crate::notifications::NotificationChannel::Discord => ("discord", u.discord_verified),
423
-
crate::notifications::NotificationChannel::Telegram => ("telegram", u.telegram_verified),
424
-
crate::notifications::NotificationChannel::Signal => ("signal", u.signal_verified),
429
+
crate::comms::CommsChannel::Email => ("email", u.email_verified),
430
+
crate::comms::CommsChannel::Discord => ("discord", u.discord_verified),
431
+
crate::comms::CommsChannel::Telegram => ("telegram", u.telegram_verified),
432
+
crate::comms::CommsChannel::Signal => ("signal", u.signal_verified),
425
433
};
426
434
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
427
-
let full_handle = format!("{}.{}", u.handle, pds_hostname);
435
+
let handle = full_handle(&u.handle, &pds_hostname);
428
436
Json(json!({
429
437
"accessJwt": new_access_meta.token,
430
438
"refreshJwt": new_refresh_meta.token,
431
-
"handle": full_handle,
439
+
"handle": handle,
432
440
"did": session_row.did,
433
441
"email": u.email,
434
-
"emailConfirmed": u.email_confirmed,
442
+
"emailVerified": u.email_verified,
435
443
"preferredChannel": preferred_channel,
436
444
"preferredChannelVerified": preferred_channel_verified,
437
445
"isAdmin": u.is_admin,
···
464
472
pub handle: String,
465
473
pub did: String,
466
474
pub email: Option<String>,
467
-
pub email_confirmed: bool,
475
+
pub email_verified: bool,
468
476
pub preferred_channel: String,
469
477
pub preferred_channel_verified: bool,
470
478
}
···
477
485
let row = match sqlx::query!(
478
486
r#"SELECT
479
487
u.id, u.did, u.handle, u.email,
480
-
u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
488
+
u.preferred_comms_channel as "channel: crate::comms::CommsChannel",
481
489
k.key_bytes, k.encryption_version
482
490
FROM users u
483
491
JOIN user_keys k ON u.id = k.user_id
···
534
542
}
535
543
};
536
544
let verified_column = match row.channel {
537
-
crate::notifications::NotificationChannel::Email => "email_confirmed",
538
-
crate::notifications::NotificationChannel::Discord => "discord_verified",
539
-
crate::notifications::NotificationChannel::Telegram => "telegram_verified",
540
-
crate::notifications::NotificationChannel::Signal => "signal_verified",
545
+
crate::comms::CommsChannel::Email => "email_verified",
546
+
crate::comms::CommsChannel::Discord => "discord_verified",
547
+
crate::comms::CommsChannel::Telegram => "telegram_verified",
548
+
crate::comms::CommsChannel::Signal => "signal_verified",
541
549
};
542
550
let update_query = format!(
543
551
"UPDATE users SET {} = TRUE WHERE did = $1",
···
590
598
return ApiError::InternalError.into_response();
591
599
}
592
600
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
593
-
if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await {
601
+
if let Err(e) = crate::comms::enqueue_welcome(&state.db, row.id, &hostname).await {
594
602
warn!("Failed to enqueue welcome notification: {:?}", e);
595
603
}
596
-
let email_confirmed = matches!(
604
+
let email_verified = matches!(
597
605
row.channel,
598
-
crate::notifications::NotificationChannel::Email
606
+
crate::comms::CommsChannel::Email
599
607
);
600
608
let preferred_channel = match row.channel {
601
-
crate::notifications::NotificationChannel::Email => "email",
602
-
crate::notifications::NotificationChannel::Discord => "discord",
603
-
crate::notifications::NotificationChannel::Telegram => "telegram",
604
-
crate::notifications::NotificationChannel::Signal => "signal",
609
+
crate::comms::CommsChannel::Email => "email",
610
+
crate::comms::CommsChannel::Discord => "discord",
611
+
crate::comms::CommsChannel::Telegram => "telegram",
612
+
crate::comms::CommsChannel::Signal => "signal",
605
613
};
606
614
Json(ConfirmSignupOutput {
607
615
access_jwt: access_meta.token,
···
609
617
handle: row.handle,
610
618
did: row.did,
611
619
email: row.email,
612
-
email_confirmed,
620
+
email_verified,
613
621
preferred_channel: preferred_channel.to_string(),
614
622
preferred_channel_verified: true,
615
623
})
···
630
638
let row = match sqlx::query!(
631
639
r#"SELECT
632
640
id, handle, email,
633
-
preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
641
+
preferred_comms_channel as "channel: crate::comms::CommsChannel",
634
642
discord_id, telegram_username, signal_number,
635
-
email_confirmed, discord_verified, telegram_verified, signal_verified
643
+
email_verified, discord_verified, telegram_verified, signal_verified
636
644
FROM users
637
645
WHERE did = $1"#,
638
646
input.did
···
650
658
}
651
659
};
652
660
let is_verified =
653
-
row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified;
661
+
row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified;
654
662
if is_verified {
655
663
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
656
664
}
···
678
686
return ApiError::InternalError.into_response();
679
687
}
680
688
let (channel_str, recipient) = match row.channel {
681
-
crate::notifications::NotificationChannel::Email => {
689
+
crate::comms::CommsChannel::Email => {
682
690
("email", row.email.unwrap_or_default())
683
691
}
684
-
crate::notifications::NotificationChannel::Discord => {
692
+
crate::comms::CommsChannel::Discord => {
685
693
("discord", row.discord_id.unwrap_or_default())
686
694
}
687
-
crate::notifications::NotificationChannel::Telegram => {
695
+
crate::comms::CommsChannel::Telegram => {
688
696
("telegram", row.telegram_username.unwrap_or_default())
689
697
}
690
-
crate::notifications::NotificationChannel::Signal => {
698
+
crate::comms::CommsChannel::Signal => {
691
699
("signal", row.signal_number.unwrap_or_default())
692
700
}
693
701
};
694
-
if let Err(e) = crate::notifications::enqueue_signup_verification(
702
+
if let Err(e) = crate::comms::enqueue_signup_verification(
695
703
&state.db,
696
704
row.id,
697
705
channel_str,
+2
-2
src/api/verification.rs
+2
-2
src/api/verification.rs
···
68
68
let record = match sqlx::query!(
69
69
r#"
70
70
SELECT code, pending_identifier, expires_at FROM channel_verifications
71
-
WHERE user_id = $1 AND channel = $2::notification_channel
71
+
WHERE user_id = $1 AND channel = $2::comms_channel
72
72
"#,
73
73
user_id,
74
74
channel_str as _
···
163
163
}
164
164
165
165
if let Err(e) = sqlx::query!(
166
-
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel",
166
+
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
167
167
user_id,
168
168
channel_str as _
169
169
)
+16
src/comms/mod.rs
+16
src/comms/mod.rs
···
1
+
mod sender;
2
+
mod service;
3
+
mod types;
4
+
5
+
pub use sender::{
6
+
CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender,
7
+
is_valid_phone_number, sanitize_header_value,
8
+
};
9
+
10
+
pub use service::{
11
+
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion,
12
+
enqueue_comms, enqueue_email_update, enqueue_email_verification, enqueue_password_reset,
13
+
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
14
+
};
15
+
16
+
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+121
src/handle/mod.rs
+121
src/handle/mod.rs
···
1
+
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
2
+
use hickory_resolver::TokioAsyncResolver;
3
+
use reqwest::Client;
4
+
use std::time::Duration;
5
+
use thiserror::Error;
6
+
7
+
#[derive(Error, Debug)]
8
+
pub enum HandleResolutionError {
9
+
#[error("DNS lookup failed: {0}")]
10
+
DnsError(String),
11
+
#[error("HTTP request failed: {0}")]
12
+
HttpError(String),
13
+
#[error("No DID found for handle")]
14
+
NotFound,
15
+
#[error("Invalid DID format in record")]
16
+
InvalidDid,
17
+
#[error("DID mismatch: expected {expected}, got {actual}")]
18
+
DidMismatch { expected: String, actual: String },
19
+
}
20
+
21
+
pub async fn resolve_handle_dns(handle: &str) -> Result<String, HandleResolutionError> {
22
+
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
23
+
let query_name = format!("_atproto.{}", handle);
24
+
let txt_lookup = resolver
25
+
.txt_lookup(&query_name)
26
+
.await
27
+
.map_err(|e| HandleResolutionError::DnsError(e.to_string()))?;
28
+
for record in txt_lookup.iter() {
29
+
for txt in record.txt_data() {
30
+
let txt_str = String::from_utf8_lossy(txt);
31
+
if let Some(did) = txt_str.strip_prefix("did=") {
32
+
let did = did.trim();
33
+
if did.starts_with("did:") {
34
+
return Ok(did.to_string());
35
+
}
36
+
}
37
+
}
38
+
}
39
+
Err(HandleResolutionError::NotFound)
40
+
}
41
+
42
+
pub async fn resolve_handle_http(handle: &str) -> Result<String, HandleResolutionError> {
43
+
let url = format!("https://{}/.well-known/atproto-did", handle);
44
+
let client = Client::builder()
45
+
.timeout(Duration::from_secs(10))
46
+
.redirect(reqwest::redirect::Policy::limited(5))
47
+
.build()
48
+
.map_err(|e| HandleResolutionError::HttpError(e.to_string()))?;
49
+
let response = client
50
+
.get(&url)
51
+
.header("Accept", "text/plain")
52
+
.send()
53
+
.await
54
+
.map_err(|e| HandleResolutionError::HttpError(e.to_string()))?;
55
+
if !response.status().is_success() {
56
+
return Err(HandleResolutionError::NotFound);
57
+
}
58
+
let body = response
59
+
.text()
60
+
.await
61
+
.map_err(|e| HandleResolutionError::HttpError(e.to_string()))?;
62
+
let did = body.trim();
63
+
if did.starts_with("did:") {
64
+
Ok(did.to_string())
65
+
} else {
66
+
Err(HandleResolutionError::InvalidDid)
67
+
}
68
+
}
69
+
70
+
pub async fn resolve_handle(handle: &str) -> Result<String, HandleResolutionError> {
71
+
match resolve_handle_dns(handle).await {
72
+
Ok(did) => return Ok(did),
73
+
Err(e) => {
74
+
tracing::debug!("DNS resolution failed for {}: {}, trying HTTP", handle, e);
75
+
}
76
+
}
77
+
resolve_handle_http(handle).await
78
+
}
79
+
80
+
pub async fn verify_handle_ownership(
81
+
handle: &str,
82
+
expected_did: &str,
83
+
) -> Result<(), HandleResolutionError> {
84
+
let resolved_did = resolve_handle(handle).await?;
85
+
if resolved_did == expected_did {
86
+
Ok(())
87
+
} else {
88
+
Err(HandleResolutionError::DidMismatch {
89
+
expected: expected_did.to_string(),
90
+
actual: resolved_did,
91
+
})
92
+
}
93
+
}
94
+
95
+
pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool {
96
+
let service_domains: Vec<String> = std::env::var("PDS_SERVICE_HANDLE_DOMAINS")
97
+
.map(|s| s.split(',').map(|d| d.trim().to_string()).collect())
98
+
.unwrap_or_else(|_| vec![hostname.to_string()]);
99
+
for domain in service_domains {
100
+
if handle.ends_with(&format!(".{}", domain)) {
101
+
return true;
102
+
}
103
+
if handle == domain {
104
+
return true;
105
+
}
106
+
}
107
+
false
108
+
}
109
+
110
+
#[cfg(test)]
111
+
mod tests {
112
+
use super::*;
113
+
114
+
#[test]
115
+
fn test_is_service_domain_handle() {
116
+
assert!(is_service_domain_handle("user.example.com", "example.com"));
117
+
assert!(is_service_domain_handle("example.com", "example.com"));
118
+
assert!(!is_service_domain_handle("user.other.com", "example.com"));
119
+
assert!(!is_service_domain_handle("myhandle.xyz", "example.com"));
120
+
}
121
+
}
+2
-1
src/lib.rs
+2
-1
src/lib.rs
+13
-15
src/main.rs
+13
-15
src/main.rs
···
1
+
use bspds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender};
1
2
use bspds::crawlers::{Crawlers, start_crawlers_service};
2
-
use bspds::notifications::{
3
-
DiscordSender, EmailSender, NotificationService, SignalSender, TelegramSender,
4
-
};
5
3
use bspds::state::AppState;
6
4
use std::net::SocketAddr;
7
5
use std::process::ExitCode;
···
68
66
69
67
let (shutdown_tx, shutdown_rx) = watch::channel(false);
70
68
71
-
let mut notification_service = NotificationService::new(pool);
69
+
let mut comms_service = CommsService::new(pool);
72
70
73
71
if let Some(email_sender) = EmailSender::from_env() {
74
-
info!("Email notifications enabled");
75
-
notification_service = notification_service.register_sender(email_sender);
72
+
info!("Email comms enabled");
73
+
comms_service = comms_service.register_sender(email_sender);
76
74
} else {
77
-
warn!("Email notifications disabled (MAIL_FROM_ADDRESS not set)");
75
+
warn!("Email comms disabled (MAIL_FROM_ADDRESS not set)");
78
76
}
79
77
80
78
if let Some(discord_sender) = DiscordSender::from_env() {
81
-
info!("Discord notifications enabled");
82
-
notification_service = notification_service.register_sender(discord_sender);
79
+
info!("Discord comms enabled");
80
+
comms_service = comms_service.register_sender(discord_sender);
83
81
}
84
82
85
83
if let Some(telegram_sender) = TelegramSender::from_env() {
86
-
info!("Telegram notifications enabled");
87
-
notification_service = notification_service.register_sender(telegram_sender);
84
+
info!("Telegram comms enabled");
85
+
comms_service = comms_service.register_sender(telegram_sender);
88
86
}
89
87
90
88
if let Some(signal_sender) = SignalSender::from_env() {
91
-
info!("Signal notifications enabled");
92
-
notification_service = notification_service.register_sender(signal_sender);
89
+
info!("Signal comms enabled");
90
+
comms_service = comms_service.register_sender(signal_sender);
93
91
}
94
92
95
-
let notification_handle = tokio::spawn(notification_service.run(shutdown_rx.clone()));
93
+
let comms_handle = tokio::spawn(comms_service.run(shutdown_rx.clone()));
96
94
97
95
let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() {
98
96
let crawlers = Arc::new(
···
122
120
.with_graceful_shutdown(shutdown_signal(shutdown_tx))
123
121
.await;
124
122
125
-
notification_handle.await.ok();
123
+
comms_handle.await.ok();
126
124
127
125
if let Some(handle) = crawlers_handle {
128
126
handle.await.ok();
+4
-4
src/metrics.rs
+4
-4
src/metrics.rs
···
54
54
"Total number of S3/blob storage operations"
55
55
);
56
56
metrics::describe_gauge!(
57
-
"bspds_notification_queue_size",
58
-
"Current size of the notification queue"
57
+
"bspds_comms_queue_size",
58
+
"Current size of the comms queue"
59
59
);
60
60
metrics::describe_counter!(
61
61
"bspds_rate_limit_rejections_total",
···
167
167
.increment(1);
168
168
}
169
169
170
-
pub fn set_notification_queue_size(size: usize) {
171
-
gauge!("bspds_notification_queue_size").set(size as f64);
170
+
pub fn set_comms_queue_size(size: usize) {
171
+
gauge!("bspds_comms_queue_size").set(size as f64);
172
172
}
173
173
174
174
pub fn record_rate_limit_rejection(limiter: &str) {
-18
src/notifications/mod.rs
-18
src/notifications/mod.rs
···
1
-
mod sender;
2
-
mod service;
3
-
mod types;
4
-
5
-
pub use sender::{
6
-
DiscordSender, EmailSender, NotificationSender, SendError, SignalSender, TelegramSender,
7
-
is_valid_phone_number, sanitize_header_value,
8
-
};
9
-
10
-
pub use service::{
11
-
NotificationService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion,
12
-
enqueue_email_update, enqueue_email_verification, enqueue_notification, enqueue_password_reset,
13
-
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
14
-
};
15
-
16
-
pub use types::{
17
-
NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification,
18
-
};
+22
-22
src/notifications/sender.rs
src/comms/sender.rs
+22
-22
src/notifications/sender.rs
src/comms/sender.rs
···
6
6
use tokio::io::AsyncWriteExt;
7
7
use tokio::process::Command;
8
8
9
-
use super::types::{NotificationChannel, QueuedNotification};
9
+
use super::types::{CommsChannel, QueuedComms};
10
10
11
11
const HTTP_TIMEOUT_SECS: u64 = 30;
12
12
const MAX_RETRIES: u32 = 3;
13
13
const INITIAL_RETRY_DELAY_MS: u64 = 500;
14
14
15
15
#[async_trait]
16
-
pub trait NotificationSender: Send + Sync {
17
-
fn channel(&self) -> NotificationChannel;
18
-
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError>;
16
+
pub trait CommsSender: Send + Sync {
17
+
fn channel(&self) -> CommsChannel;
18
+
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError>;
19
19
}
20
20
21
21
#[derive(Debug, thiserror::Error)]
···
25
25
#[error("Sendmail exited with non-zero status: {0}")]
26
26
SendmailFailed(String),
27
27
#[error("Channel not configured: {0:?}")]
28
-
NotConfigured(NotificationChannel),
28
+
NotConfigured(CommsChannel),
29
29
#[error("External service error: {0}")]
30
30
ExternalService(String),
31
31
#[error("Invalid recipient format: {0}")]
···
91
91
Some(Self::new(from_address, from_name))
92
92
}
93
93
94
-
pub fn format_email(&self, notification: &QueuedNotification) -> String {
94
+
pub fn format_email(&self, notification: &QueuedComms) -> String {
95
95
let subject =
96
96
sanitize_header_value(notification.subject.as_deref().unwrap_or("Notification"));
97
97
let recipient = sanitize_header_value(¬ification.recipient);
···
112
112
}
113
113
114
114
#[async_trait]
115
-
impl NotificationSender for EmailSender {
116
-
fn channel(&self) -> NotificationChannel {
117
-
NotificationChannel::Email
115
+
impl CommsSender for EmailSender {
116
+
fn channel(&self) -> CommsChannel {
117
+
CommsChannel::Email
118
118
}
119
119
120
-
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
120
+
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
121
121
let email_content = self.format_email(notification);
122
122
let mut child = Command::new(&self.sendmail_path)
123
123
.arg("-t")
···
158
158
}
159
159
160
160
#[async_trait]
161
-
impl NotificationSender for DiscordSender {
162
-
fn channel(&self) -> NotificationChannel {
163
-
NotificationChannel::Discord
161
+
impl CommsSender for DiscordSender {
162
+
fn channel(&self) -> CommsChannel {
163
+
CommsChannel::Discord
164
164
}
165
165
166
-
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
166
+
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
167
167
let subject = notification.subject.as_deref().unwrap_or("Notification");
168
168
let content = format!("**{}**\n\n{}", subject, notification.body);
169
169
let payload = json!({
···
237
237
}
238
238
239
239
#[async_trait]
240
-
impl NotificationSender for TelegramSender {
241
-
fn channel(&self) -> NotificationChannel {
242
-
NotificationChannel::Telegram
240
+
impl CommsSender for TelegramSender {
241
+
fn channel(&self) -> CommsChannel {
242
+
CommsChannel::Telegram
243
243
}
244
244
245
-
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
245
+
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
246
246
let chat_id = ¬ification.recipient;
247
247
let subject = notification.subject.as_deref().unwrap_or("Notification");
248
248
let text = format!("*{}*\n\n{}", subject, notification.body);
···
316
316
}
317
317
318
318
#[async_trait]
319
-
impl NotificationSender for SignalSender {
320
-
fn channel(&self) -> NotificationChannel {
321
-
NotificationChannel::Signal
319
+
impl CommsSender for SignalSender {
320
+
fn channel(&self) -> CommsChannel {
321
+
CommsChannel::Signal
322
322
}
323
323
324
-
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
324
+
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
325
325
let recipient = ¬ification.recipient;
326
326
if !is_valid_phone_number(recipient) {
327
327
return Err(SendError::InvalidRecipient(format!(
+110
-113
src/notifications/service.rs
src/comms/service.rs
+110
-113
src/notifications/service.rs
src/comms/service.rs
···
9
9
use tracing::{debug, error, info, warn};
10
10
use uuid::Uuid;
11
11
12
-
use super::sender::{NotificationSender, SendError};
13
-
use super::types::{NewNotification, NotificationChannel, NotificationStatus, QueuedNotification};
12
+
use super::sender::{CommsSender, SendError};
13
+
use super::types::{NewComms, CommsChannel, CommsStatus, QueuedComms};
14
14
15
-
pub struct NotificationService {
15
+
pub struct CommsService {
16
16
db: PgPool,
17
-
senders: HashMap<NotificationChannel, Arc<dyn NotificationSender>>,
17
+
senders: HashMap<CommsChannel, Arc<dyn CommsSender>>,
18
18
poll_interval: Duration,
19
19
batch_size: i64,
20
20
}
21
21
22
-
impl NotificationService {
22
+
impl CommsService {
23
23
pub fn new(db: PgPool) -> Self {
24
24
let poll_interval_ms: u64 = std::env::var("NOTIFICATION_POLL_INTERVAL_MS")
25
25
.ok()
···
47
47
self
48
48
}
49
49
50
-
pub fn register_sender<S: NotificationSender + 'static>(mut self, sender: S) -> Self {
50
+
pub fn register_sender<S: CommsSender + 'static>(mut self, sender: S) -> Self {
51
51
self.senders.insert(sender.channel(), Arc::new(sender));
52
52
self
53
53
}
54
54
55
-
pub async fn enqueue(&self, notification: NewNotification) -> Result<Uuid, sqlx::Error> {
55
+
pub async fn enqueue(&self, item: NewComms) -> Result<Uuid, sqlx::Error> {
56
56
let id = sqlx::query_scalar!(
57
57
r#"
58
-
INSERT INTO notification_queue
59
-
(user_id, channel, notification_type, recipient, subject, body, metadata)
58
+
INSERT INTO comms_queue
59
+
(user_id, channel, comms_type, recipient, subject, body, metadata)
60
60
VALUES ($1, $2, $3, $4, $5, $6, $7)
61
61
RETURNING id
62
62
"#,
63
-
notification.user_id,
64
-
notification.channel as NotificationChannel,
65
-
notification.notification_type as super::types::NotificationType,
66
-
notification.recipient,
67
-
notification.subject,
68
-
notification.body,
69
-
notification.metadata
63
+
item.user_id,
64
+
item.channel as CommsChannel,
65
+
item.comms_type as super::types::CommsType,
66
+
item.recipient,
67
+
item.subject,
68
+
item.body,
69
+
item.metadata
70
70
)
71
71
.fetch_one(&self.db)
72
72
.await?;
73
-
debug!(notification_id = %id, "Notification enqueued");
73
+
debug!(comms_id = %id, "Comms enqueued");
74
74
Ok(id)
75
75
}
76
76
···
81
81
pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
82
82
if self.senders.is_empty() {
83
83
warn!(
84
-
"Notification service starting with no senders configured. Notifications will be queued but not delivered until senders are configured."
84
+
"Comms service starting with no senders configured. Messages will be queued but not delivered until senders are configured."
85
85
);
86
86
}
87
87
info!(
88
88
poll_interval_secs = self.poll_interval.as_secs(),
89
89
batch_size = self.batch_size,
90
90
channels = ?self.senders.keys().collect::<Vec<_>>(),
91
-
"Starting notification service"
91
+
"Starting comms service"
92
92
);
93
93
let mut ticker = interval(self.poll_interval);
94
94
loop {
95
95
tokio::select! {
96
96
_ = ticker.tick() => {
97
97
if let Err(e) = self.process_batch().await {
98
-
error!(error = %e, "Failed to process notification batch");
98
+
error!(error = %e, "Failed to process comms batch");
99
99
}
100
100
}
101
101
_ = shutdown.changed() => {
102
102
if *shutdown.borrow() {
103
-
info!("Notification service shutting down");
103
+
info!("Comms service shutting down");
104
104
break;
105
105
}
106
106
}
···
109
109
}
110
110
111
111
async fn process_batch(&self) -> Result<(), sqlx::Error> {
112
-
let notifications = self.fetch_pending_notifications().await?;
113
-
if notifications.is_empty() {
112
+
let items = self.fetch_pending().await?;
113
+
if items.is_empty() {
114
114
return Ok(());
115
115
}
116
-
debug!(count = notifications.len(), "Processing notification batch");
117
-
for notification in notifications {
118
-
self.process_notification(notification).await;
116
+
debug!(count = items.len(), "Processing comms batch");
117
+
for item in items {
118
+
self.process_item(item).await;
119
119
}
120
120
Ok(())
121
121
}
122
122
123
-
async fn fetch_pending_notifications(&self) -> Result<Vec<QueuedNotification>, sqlx::Error> {
123
+
async fn fetch_pending(&self) -> Result<Vec<QueuedComms>, sqlx::Error> {
124
124
let now = Utc::now();
125
125
sqlx::query_as!(
126
-
QueuedNotification,
126
+
QueuedComms,
127
127
r#"
128
-
UPDATE notification_queue
128
+
UPDATE comms_queue
129
129
SET status = 'processing', updated_at = NOW()
130
130
WHERE id IN (
131
-
SELECT id FROM notification_queue
131
+
SELECT id FROM comms_queue
132
132
WHERE status = 'pending'
133
133
AND scheduled_for <= $1
134
134
AND attempts < max_attempts
···
138
138
)
139
139
RETURNING
140
140
id, user_id,
141
-
channel as "channel: NotificationChannel",
142
-
notification_type as "notification_type: super::types::NotificationType",
143
-
status as "status: NotificationStatus",
141
+
channel as "channel: CommsChannel",
142
+
comms_type as "comms_type: super::types::CommsType",
143
+
status as "status: CommsStatus",
144
144
recipient, subject, body, metadata,
145
145
attempts, max_attempts, last_error,
146
146
created_at, updated_at, scheduled_for, processed_at
···
152
152
.await
153
153
}
154
154
155
-
async fn process_notification(&self, notification: QueuedNotification) {
156
-
let notification_id = notification.id;
157
-
let channel = notification.channel;
155
+
async fn process_item(&self, item: QueuedComms) {
156
+
let comms_id = item.id;
157
+
let channel = item.channel;
158
158
let result = match self.senders.get(&channel) {
159
-
Some(sender) => sender.send(¬ification).await,
159
+
Some(sender) => sender.send(&item).await,
160
160
None => {
161
161
warn!(
162
-
notification_id = %notification_id,
162
+
comms_id = %comms_id,
163
163
channel = ?channel,
164
164
"No sender registered for channel"
165
165
);
···
168
168
};
169
169
match result {
170
170
Ok(()) => {
171
-
debug!(notification_id = %notification_id, "Notification sent successfully");
172
-
if let Err(e) = self.mark_sent(notification_id).await {
171
+
debug!(comms_id = %comms_id, "Comms sent successfully");
172
+
if let Err(e) = self.mark_sent(comms_id).await {
173
173
error!(
174
-
notification_id = %notification_id,
174
+
comms_id = %comms_id,
175
175
error = %e,
176
-
"Failed to mark notification as sent"
176
+
"Failed to mark comms as sent"
177
177
);
178
178
}
179
179
}
180
180
Err(e) => {
181
181
let error_msg = e.to_string();
182
182
warn!(
183
-
notification_id = %notification_id,
183
+
comms_id = %comms_id,
184
184
error = %error_msg,
185
-
"Failed to send notification"
185
+
"Failed to send comms"
186
186
);
187
-
if let Err(db_err) = self.mark_failed(notification_id, &error_msg).await {
187
+
if let Err(db_err) = self.mark_failed(comms_id, &error_msg).await {
188
188
error!(
189
-
notification_id = %notification_id,
189
+
comms_id = %comms_id,
190
190
error = %db_err,
191
-
"Failed to mark notification as failed"
191
+
"Failed to mark comms as failed"
192
192
);
193
193
}
194
194
}
···
198
198
async fn mark_sent(&self, id: Uuid) -> Result<(), sqlx::Error> {
199
199
sqlx::query!(
200
200
r#"
201
-
UPDATE notification_queue
201
+
UPDATE comms_queue
202
202
SET status = 'sent', processed_at = NOW(), updated_at = NOW()
203
203
WHERE id = $1
204
204
"#,
···
212
212
async fn mark_failed(&self, id: Uuid, error: &str) -> Result<(), sqlx::Error> {
213
213
sqlx::query!(
214
214
r#"
215
-
UPDATE notification_queue
215
+
UPDATE comms_queue
216
216
SET
217
217
status = CASE
218
-
WHEN attempts + 1 >= max_attempts THEN 'failed'::notification_status
219
-
ELSE 'pending'::notification_status
218
+
WHEN attempts + 1 >= max_attempts THEN 'failed'::comms_status
219
+
ELSE 'pending'::comms_status
220
220
END,
221
221
attempts = attempts + 1,
222
222
last_error = $2,
···
233
233
}
234
234
}
235
235
236
-
pub async fn enqueue_notification(
237
-
db: &PgPool,
238
-
notification: NewNotification,
239
-
) -> Result<Uuid, sqlx::Error> {
236
+
pub async fn enqueue_comms(db: &PgPool, item: NewComms) -> Result<Uuid, sqlx::Error> {
240
237
sqlx::query_scalar!(
241
238
r#"
242
-
INSERT INTO notification_queue
243
-
(user_id, channel, notification_type, recipient, subject, body, metadata)
239
+
INSERT INTO comms_queue
240
+
(user_id, channel, comms_type, recipient, subject, body, metadata)
244
241
VALUES ($1, $2, $3, $4, $5, $6, $7)
245
242
RETURNING id
246
243
"#,
247
-
notification.user_id,
248
-
notification.channel as NotificationChannel,
249
-
notification.notification_type as super::types::NotificationType,
250
-
notification.recipient,
251
-
notification.subject,
252
-
notification.body,
253
-
notification.metadata
244
+
item.user_id,
245
+
item.channel as CommsChannel,
246
+
item.comms_type as super::types::CommsType,
247
+
item.recipient,
248
+
item.subject,
249
+
item.body,
250
+
item.metadata
254
251
)
255
252
.fetch_one(db)
256
253
.await
257
254
}
258
255
259
-
pub struct UserNotificationPrefs {
260
-
pub channel: NotificationChannel,
256
+
pub struct UserCommsPrefs {
257
+
pub channel: CommsChannel,
261
258
pub email: Option<String>,
262
259
pub handle: String,
263
260
}
264
261
265
-
pub async fn get_user_notification_prefs(
262
+
pub async fn get_user_comms_prefs(
266
263
db: &PgPool,
267
264
user_id: Uuid,
268
-
) -> Result<UserNotificationPrefs, sqlx::Error> {
265
+
) -> Result<UserCommsPrefs, sqlx::Error> {
269
266
let row = sqlx::query!(
270
267
r#"
271
268
SELECT
272
269
email,
273
270
handle,
274
-
preferred_notification_channel as "channel: NotificationChannel"
271
+
preferred_comms_channel as "channel: CommsChannel"
275
272
FROM users
276
273
WHERE id = $1
277
274
"#,
···
279
276
)
280
277
.fetch_one(db)
281
278
.await?;
282
-
Ok(UserNotificationPrefs {
279
+
Ok(UserCommsPrefs {
283
280
channel: row.channel,
284
281
email: row.email,
285
282
handle: row.handle,
···
291
288
user_id: Uuid,
292
289
hostname: &str,
293
290
) -> Result<Uuid, sqlx::Error> {
294
-
let prefs = get_user_notification_prefs(db, user_id).await?;
291
+
let prefs = get_user_comms_prefs(db, user_id).await?;
295
292
let body = format!(
296
293
"Welcome to {}!\n\nYour handle is: @{}\n\nThank you for joining us.",
297
294
hostname, prefs.handle
298
295
);
299
-
enqueue_notification(
296
+
enqueue_comms(
300
297
db,
301
-
NewNotification::new(
298
+
NewComms::new(
302
299
user_id,
303
300
prefs.channel,
304
-
super::types::NotificationType::Welcome,
301
+
super::types::CommsType::Welcome,
305
302
prefs.email.clone().unwrap_or_default(),
306
303
Some(format!("Welcome to {}", hostname)),
307
304
body,
···
322
319
"Hello @{},\n\nYour email verification code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.",
323
320
handle, code
324
321
);
325
-
enqueue_notification(
322
+
enqueue_comms(
326
323
db,
327
-
NewNotification::email(
324
+
NewComms::email(
328
325
user_id,
329
-
super::types::NotificationType::EmailVerification,
326
+
super::types::CommsType::EmailVerification,
330
327
email.to_string(),
331
328
format!("Verify your email - {}", hostname),
332
329
body,
···
341
338
code: &str,
342
339
hostname: &str,
343
340
) -> Result<Uuid, sqlx::Error> {
344
-
let prefs = get_user_notification_prefs(db, user_id).await?;
341
+
let prefs = get_user_comms_prefs(db, user_id).await?;
345
342
let body = format!(
346
343
"Hello @{},\n\nYour password reset code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.",
347
344
prefs.handle, code
348
345
);
349
-
enqueue_notification(
346
+
enqueue_comms(
350
347
db,
351
-
NewNotification::new(
348
+
NewComms::new(
352
349
user_id,
353
350
prefs.channel,
354
-
super::types::NotificationType::PasswordReset,
351
+
super::types::CommsType::PasswordReset,
355
352
prefs.email.clone().unwrap_or_default(),
356
353
Some(format!("Password Reset - {}", hostname)),
357
354
body,
···
372
369
"Hello @{},\n\nYour email update confirmation code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.",
373
370
handle, code
374
371
);
375
-
enqueue_notification(
372
+
enqueue_comms(
376
373
db,
377
-
NewNotification::email(
374
+
NewComms::email(
378
375
user_id,
379
-
super::types::NotificationType::EmailUpdate,
376
+
super::types::CommsType::EmailUpdate,
380
377
new_email.to_string(),
381
378
format!("Confirm your new email - {}", hostname),
382
379
body,
···
391
388
code: &str,
392
389
hostname: &str,
393
390
) -> Result<Uuid, sqlx::Error> {
394
-
let prefs = get_user_notification_prefs(db, user_id).await?;
391
+
let prefs = get_user_comms_prefs(db, user_id).await?;
395
392
let body = format!(
396
393
"Hello @{},\n\nYour account deletion confirmation code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.",
397
394
prefs.handle, code
398
395
);
399
-
enqueue_notification(
396
+
enqueue_comms(
400
397
db,
401
-
NewNotification::new(
398
+
NewComms::new(
402
399
user_id,
403
400
prefs.channel,
404
-
super::types::NotificationType::AccountDeletion,
401
+
super::types::CommsType::AccountDeletion,
405
402
prefs.email.clone().unwrap_or_default(),
406
403
Some(format!("Account Deletion Request - {}", hostname)),
407
404
body,
···
416
413
token: &str,
417
414
hostname: &str,
418
415
) -> Result<Uuid, sqlx::Error> {
419
-
let prefs = get_user_notification_prefs(db, user_id).await?;
416
+
let prefs = get_user_comms_prefs(db, user_id).await?;
420
417
let body = format!(
421
418
"Hello @{},\n\nYou requested to sign a PLC operation for your account.\n\nYour verification token is: {}\n\nThis token will expire in 10 minutes.\n\nIf you did not request this, you can safely ignore this message.",
422
419
prefs.handle, token
423
420
);
424
-
enqueue_notification(
421
+
enqueue_comms(
425
422
db,
426
-
NewNotification::new(
423
+
NewComms::new(
427
424
user_id,
428
425
prefs.channel,
429
-
super::types::NotificationType::PlcOperation,
426
+
super::types::CommsType::PlcOperation,
430
427
prefs.email.clone().unwrap_or_default(),
431
428
Some(format!("{} - PLC Operation Token", hostname)),
432
429
body,
···
441
438
code: &str,
442
439
hostname: &str,
443
440
) -> Result<Uuid, sqlx::Error> {
444
-
let prefs = get_user_notification_prefs(db, user_id).await?;
441
+
let prefs = get_user_comms_prefs(db, user_id).await?;
445
442
let body = format!(
446
443
"Hello @{},\n\nYour sign-in verification code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.",
447
444
prefs.handle, code
448
445
);
449
-
enqueue_notification(
446
+
enqueue_comms(
450
447
db,
451
-
NewNotification::new(
448
+
NewComms::new(
452
449
user_id,
453
450
prefs.channel,
454
-
super::types::NotificationType::TwoFactorCode,
451
+
super::types::CommsType::TwoFactorCode,
455
452
prefs.email.clone().unwrap_or_default(),
456
453
Some(format!("Sign-in Verification - {}", hostname)),
457
454
body,
···
460
457
.await
461
458
}
462
459
463
-
pub fn channel_display_name(channel: NotificationChannel) -> &'static str {
460
+
pub fn channel_display_name(channel: CommsChannel) -> &'static str {
464
461
match channel {
465
-
NotificationChannel::Email => "email",
466
-
NotificationChannel::Discord => "Discord",
467
-
NotificationChannel::Telegram => "Telegram",
468
-
NotificationChannel::Signal => "Signal",
462
+
CommsChannel::Email => "email",
463
+
CommsChannel::Discord => "Discord",
464
+
CommsChannel::Telegram => "Telegram",
465
+
CommsChannel::Signal => "Signal",
469
466
}
470
467
}
471
468
···
477
474
code: &str,
478
475
) -> Result<Uuid, sqlx::Error> {
479
476
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
480
-
let notification_channel = match channel {
481
-
"email" => NotificationChannel::Email,
482
-
"discord" => NotificationChannel::Discord,
483
-
"telegram" => NotificationChannel::Telegram,
484
-
"signal" => NotificationChannel::Signal,
485
-
_ => NotificationChannel::Email,
477
+
let comms_channel = match channel {
478
+
"email" => CommsChannel::Email,
479
+
"discord" => CommsChannel::Discord,
480
+
"telegram" => CommsChannel::Telegram,
481
+
"signal" => CommsChannel::Signal,
482
+
_ => CommsChannel::Email,
486
483
};
487
484
let body = format!(
488
485
"Welcome! Your account verification code is: {}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {}.",
489
486
code, hostname
490
487
);
491
-
let subject = match notification_channel {
492
-
NotificationChannel::Email => Some(format!("Verify your account - {}", hostname)),
488
+
let subject = match comms_channel {
489
+
CommsChannel::Email => Some(format!("Verify your account - {}", hostname)),
493
490
_ => None,
494
491
};
495
-
enqueue_notification(
492
+
enqueue_comms(
496
493
db,
497
-
NewNotification::new(
494
+
NewComms::new(
498
495
user_id,
499
-
notification_channel,
500
-
super::types::NotificationType::EmailVerification,
496
+
comms_channel,
497
+
super::types::CommsType::EmailVerification,
501
498
recipient.to_string(),
502
499
subject,
503
500
body,
+20
-20
src/notifications/types.rs
src/comms/types.rs
+20
-20
src/notifications/types.rs
src/comms/types.rs
···
4
4
use uuid::Uuid;
5
5
6
6
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type, Serialize, Deserialize)]
7
-
#[sqlx(type_name = "notification_channel", rename_all = "lowercase")]
8
-
pub enum NotificationChannel {
7
+
#[sqlx(type_name = "comms_channel", rename_all = "lowercase")]
8
+
pub enum CommsChannel {
9
9
Email,
10
10
Discord,
11
11
Telegram,
···
13
13
}
14
14
15
15
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
16
-
#[sqlx(type_name = "notification_status", rename_all = "lowercase")]
17
-
pub enum NotificationStatus {
16
+
#[sqlx(type_name = "comms_status", rename_all = "lowercase")]
17
+
pub enum CommsStatus {
18
18
Pending,
19
19
Processing,
20
20
Sent,
···
22
22
}
23
23
24
24
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
25
-
#[sqlx(type_name = "notification_type", rename_all = "snake_case")]
26
-
pub enum NotificationType {
25
+
#[sqlx(type_name = "comms_type", rename_all = "snake_case")]
26
+
pub enum CommsType {
27
27
Welcome,
28
28
EmailVerification,
29
29
PasswordReset,
···
35
35
}
36
36
37
37
#[derive(Debug, Clone, FromRow)]
38
-
pub struct QueuedNotification {
38
+
pub struct QueuedComms {
39
39
pub id: Uuid,
40
40
pub user_id: Uuid,
41
-
pub channel: NotificationChannel,
42
-
pub notification_type: NotificationType,
43
-
pub status: NotificationStatus,
41
+
pub channel: CommsChannel,
42
+
pub comms_type: CommsType,
43
+
pub status: CommsStatus,
44
44
pub recipient: String,
45
45
pub subject: Option<String>,
46
46
pub body: String,
···
54
54
pub processed_at: Option<DateTime<Utc>>,
55
55
}
56
56
57
-
pub struct NewNotification {
57
+
pub struct NewComms {
58
58
pub user_id: Uuid,
59
-
pub channel: NotificationChannel,
60
-
pub notification_type: NotificationType,
59
+
pub channel: CommsChannel,
60
+
pub comms_type: CommsType,
61
61
pub recipient: String,
62
62
pub subject: Option<String>,
63
63
pub body: String,
64
64
pub metadata: Option<serde_json::Value>,
65
65
}
66
66
67
-
impl NewNotification {
67
+
impl NewComms {
68
68
pub fn new(
69
69
user_id: Uuid,
70
-
channel: NotificationChannel,
71
-
notification_type: NotificationType,
70
+
channel: CommsChannel,
71
+
comms_type: CommsType,
72
72
recipient: String,
73
73
subject: Option<String>,
74
74
body: String,
···
76
76
Self {
77
77
user_id,
78
78
channel,
79
-
notification_type,
79
+
comms_type,
80
80
recipient,
81
81
subject,
82
82
body,
···
86
86
87
87
pub fn email(
88
88
user_id: Uuid,
89
-
notification_type: NotificationType,
89
+
comms_type: CommsType,
90
90
recipient: String,
91
91
subject: String,
92
92
body: String,
93
93
) -> Self {
94
94
Self::new(
95
95
user_id,
96
-
NotificationChannel::Email,
97
-
notification_type,
96
+
CommsChannel::Email,
97
+
comms_type,
98
98
recipient,
99
99
Some(subject),
100
100
body,
+1
-1
src/oauth/templates.rs
+1
-1
src/oauth/templates.rs
···
369
369
</div>
370
370
<div class="buttons">
371
371
<button type="submit" class="btn btn-primary">Sign In</button>
372
-
<button type="submit" formaction="/oauth/authorize/deny" class="btn btn-secondary">Cancel</button>
372
+
<button type="submit" formaction="/oauth/authorize/deny" formnovalidate class="btn btn-secondary">Cancel</button>
373
373
</div>
374
374
</form>
375
375
<p class="help-text">
+4
-4
tests/account_notifications.rs
+4
-4
tests/account_notifications.rs
···
1
1
mod common;
2
2
use common::{base_url, client, create_account_and_login, get_db_connection_string};
3
-
use bspds::notifications::{NewNotification, NotificationType, enqueue_notification};
3
+
use bspds::comms::{NewComms, CommsType, enqueue_comms};
4
4
use serde_json::{Value, json};
5
5
use sqlx::PgPool;
6
6
···
26
26
.expect("User not found");
27
27
28
28
for i in 0..3 {
29
-
let notification = NewNotification::email(
29
+
let comms = NewComms::email(
30
30
user_id,
31
-
NotificationType::Welcome,
31
+
CommsType::Welcome,
32
32
"test@example.com".to_string(),
33
33
format!("Subject {}", i),
34
34
format!("Body {}", i),
35
35
);
36
-
enqueue_notification(&pool, notification).await.expect("Failed to enqueue");
36
+
enqueue_comms(&pool, comms).await.expect("Failed to enqueue");
37
37
}
38
38
39
39
let resp = client
+2
-2
tests/admin_email.rs
+2
-2
tests/admin_email.rs
···
39
39
.await
40
40
.expect("User not found");
41
41
let notification = sqlx::query!(
42
-
"SELECT subject, body, notification_type as \"notification_type: String\" FROM notification_queue WHERE user_id = $1 AND notification_type = 'admin_email' ORDER BY created_at DESC LIMIT 1",
42
+
"SELECT subject, body, comms_type as \"comms_type: String\" FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' ORDER BY created_at DESC LIMIT 1",
43
43
user.id
44
44
)
45
45
.fetch_one(&pool)
···
78
78
.await
79
79
.expect("User not found");
80
80
let notification = sqlx::query!(
81
-
"SELECT subject FROM notification_queue WHERE user_id = $1 AND notification_type = 'admin_email' AND body = 'Email without subject' LIMIT 1",
81
+
"SELECT subject FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' AND body = 'Email without subject' LIMIT 1",
82
82
user.id
83
83
)
84
84
.fetch_one(&pool)
+29
-30
tests/notifications.rs
+29
-30
tests/notifications.rs
···
1
1
mod common;
2
-
use bspds::notifications::{
3
-
NewNotification, NotificationChannel, NotificationStatus, NotificationType,
4
-
enqueue_notification, enqueue_welcome,
2
+
use bspds::comms::{
3
+
CommsChannel, CommsStatus, CommsType, NewComms, enqueue_comms, enqueue_welcome,
5
4
};
6
5
use sqlx::PgPool;
7
6
···
15
14
}
16
15
17
16
#[tokio::test]
18
-
async fn test_enqueue_notification() {
17
+
async fn test_enqueue_comms() {
19
18
let pool = get_pool().await;
20
19
let (_, did) = common::create_account_and_login(&common::client()).await;
21
20
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
22
21
.fetch_one(&pool)
23
22
.await
24
23
.expect("User not found");
25
-
let notification = NewNotification::email(
24
+
let item = NewComms::email(
26
25
user_id,
27
-
NotificationType::Welcome,
26
+
CommsType::Welcome,
28
27
"test@example.com".to_string(),
29
28
"Test Subject".to_string(),
30
29
"Test body".to_string(),
31
30
);
32
-
let notification_id = enqueue_notification(&pool, notification)
31
+
let comms_id = enqueue_comms(&pool, item)
33
32
.await
34
-
.expect("Failed to enqueue notification");
33
+
.expect("Failed to enqueue comms");
35
34
let row = sqlx::query!(
36
35
r#"
37
36
SELECT
38
37
id, user_id, recipient, subject, body,
39
-
channel as "channel: NotificationChannel",
40
-
notification_type as "notification_type: NotificationType",
41
-
status as "status: NotificationStatus"
42
-
FROM notification_queue
38
+
channel as "channel: CommsChannel",
39
+
comms_type as "comms_type: CommsType",
40
+
status as "status: CommsStatus"
41
+
FROM comms_queue
43
42
WHERE id = $1
44
43
"#,
45
-
notification_id
44
+
comms_id
46
45
)
47
46
.fetch_one(&pool)
48
47
.await
49
-
.expect("Notification not found");
48
+
.expect("Comms not found");
50
49
assert_eq!(row.user_id, user_id);
51
50
assert_eq!(row.recipient, "test@example.com");
52
51
assert_eq!(row.subject.as_deref(), Some("Test Subject"));
53
52
assert_eq!(row.body, "Test body");
54
-
assert_eq!(row.channel, NotificationChannel::Email);
55
-
assert_eq!(row.notification_type, NotificationType::Welcome);
56
-
assert_eq!(row.status, NotificationStatus::Pending);
53
+
assert_eq!(row.channel, CommsChannel::Email);
54
+
assert_eq!(row.comms_type, CommsType::Welcome);
55
+
assert_eq!(row.status, CommsStatus::Pending);
57
56
}
58
57
59
58
#[tokio::test]
···
64
63
.fetch_one(&pool)
65
64
.await
66
65
.expect("User not found");
67
-
let notification_id = enqueue_welcome(&pool, user_row.id, "example.com")
66
+
let comms_id = enqueue_welcome(&pool, user_row.id, "example.com")
68
67
.await
69
-
.expect("Failed to enqueue welcome notification");
68
+
.expect("Failed to enqueue welcome comms");
70
69
let row = sqlx::query!(
71
70
r#"
72
71
SELECT
73
72
recipient, subject, body,
74
-
notification_type as "notification_type: NotificationType"
75
-
FROM notification_queue
73
+
comms_type as "comms_type: CommsType"
74
+
FROM comms_queue
76
75
WHERE id = $1
77
76
"#,
78
-
notification_id
77
+
comms_id
79
78
)
80
79
.fetch_one(&pool)
81
80
.await
82
-
.expect("Notification not found");
81
+
.expect("Comms not found");
83
82
assert_eq!(Some(row.recipient), user_row.email);
84
83
assert_eq!(row.subject.as_deref(), Some("Welcome to example.com"));
85
84
assert!(row.body.contains(&format!("@{}", user_row.handle)));
86
-
assert_eq!(row.notification_type, NotificationType::Welcome);
85
+
assert_eq!(row.comms_type, CommsType::Welcome);
87
86
}
88
87
89
88
#[tokio::test]
90
-
async fn test_notification_queue_status_index() {
89
+
async fn test_comms_queue_status_index() {
91
90
let pool = get_pool().await;
92
91
let (_, did) = common::create_account_and_login(&common::client()).await;
93
92
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
···
95
94
.await
96
95
.expect("User not found");
97
96
let initial_count: i64 = sqlx::query_scalar!(
98
-
"SELECT COUNT(*) FROM notification_queue WHERE status = 'pending' AND user_id = $1",
97
+
"SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1",
99
98
user_id
100
99
)
101
100
.fetch_one(&pool)
···
103
102
.expect("Failed to count")
104
103
.unwrap_or(0);
105
104
for i in 0..5 {
106
-
let notification = NewNotification::email(
105
+
let item = NewComms::email(
107
106
user_id,
108
-
NotificationType::PasswordReset,
107
+
CommsType::PasswordReset,
109
108
format!("test{}@example.com", i),
110
109
"Test".to_string(),
111
110
"Body".to_string(),
112
111
);
113
-
enqueue_notification(&pool, notification)
112
+
enqueue_comms(&pool, item)
114
113
.await
115
114
.expect("Failed to enqueue");
116
115
}
117
116
let final_count: i64 = sqlx::query_scalar!(
118
-
"SELECT COUNT(*) FROM notification_queue WHERE status = 'pending' AND user_id = $1",
117
+
"SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1",
119
118
user_id
120
119
)
121
120
.fetch_one(&pool)
+9
-2
tests/oauth.rs
+9
-2
tests/oauth.rs
···
2
2
mod helpers;
3
3
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
4
4
use chrono::Utc;
5
-
use common::{base_url, client, create_account_and_login, get_db_connection_string};
5
+
use common::{base_url, client, get_db_connection_string};
6
+
use helpers::verify_new_account;
6
7
use reqwest::{StatusCode, redirect};
7
8
use serde_json::{Value, json};
8
9
use sha2::{Digest, Sha256};
···
124
125
assert_eq!(create_res.status(), StatusCode::OK);
125
126
let account: Value = create_res.json().await.unwrap();
126
127
let user_did = account["did"].as_str().unwrap();
128
+
verify_new_account(&http_client, user_did).await;
127
129
let redirect_uri = "https://example.com/oauth/callback";
128
130
let mock_client = setup_mock_client_metadata(redirect_uri).await;
129
131
let client_id = mock_client.uri();
···
261
263
assert_eq!(create_res.status(), StatusCode::OK);
262
264
let account: Value = create_res.json().await.unwrap();
263
265
let user_did = account["did"].as_str().unwrap();
266
+
verify_new_account(&http_client, user_did).await;
264
267
let db_url = get_db_connection_string().await;
265
268
let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap();
266
269
sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
···
324
327
.send().await.unwrap();
325
328
let account: Value = create_res.json().await.unwrap();
326
329
let user_did = account["did"].as_str().unwrap();
330
+
verify_new_account(&http_client, user_did).await;
327
331
let db_url = get_db_connection_string().await;
328
332
let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap();
329
333
sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
···
375
379
.send().await.unwrap();
376
380
let account: Value = create_res.json().await.unwrap();
377
381
let user_did = account["did"].as_str().unwrap().to_string();
382
+
verify_new_account(&http_client, &user_did).await;
378
383
let redirect_uri = "https://example.com/selector-2fa-callback";
379
384
let mock_client = setup_mock_client_metadata(redirect_uri).await;
380
385
let client_id = mock_client.uri();
···
451
456
let handle = format!("state-special-{}", ts);
452
457
let email = format!("state-special-{}@example.com", ts);
453
458
let password = "state-special-password";
454
-
http_client
459
+
let create_res = http_client
455
460
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
456
461
.json(&json!({ "handle": handle, "email": email, "password": password }))
457
462
.send().await.unwrap();
463
+
let account: Value = create_res.json().await.unwrap();
464
+
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
458
465
let redirect_uri = "https://example.com/state-special-callback";
459
466
let mock_client = setup_mock_client_metadata(redirect_uri).await;
460
467
let client_id = mock_client.uri();
+13
-4
tests/oauth_security.rs
+13
-4
tests/oauth_security.rs
···
45
45
async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String, String, String) {
46
46
let ts = Utc::now().timestamp_millis();
47
47
let handle = format!("sec-test-{}", ts);
48
-
http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
48
+
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
49
49
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "security-test-password" }))
50
50
.send().await.unwrap();
51
+
let account: Value = create_res.json().await.unwrap();
52
+
let did = account["did"].as_str().unwrap();
53
+
verify_new_account(http_client, did).await;
51
54
let redirect_uri = "https://example.com/sec-callback";
52
55
let mock_client = setup_mock_client_metadata(redirect_uri).await;
53
56
let client_id = mock_client.uri();
···
129
132
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Missing PKCE challenge should be rejected");
130
133
let ts = Utc::now().timestamp_millis();
131
134
let handle = format!("pkce-attack-{}", ts);
132
-
http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
135
+
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
133
136
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "pkce-password" }))
134
137
.send().await.unwrap();
138
+
let account: Value = create_res.json().await.unwrap();
139
+
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
135
140
let (_, code_challenge) = generate_pkce();
136
141
let (attacker_verifier, _) = generate_pkce();
137
142
let par_body: Value = http_client.post(format!("{}/oauth/par", url))
···
158
163
let http_client = client();
159
164
let ts = Utc::now().timestamp_millis();
160
165
let handle = format!("replay-{}", ts);
161
-
http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
166
+
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
162
167
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "replay-password" }))
163
168
.send().await.unwrap();
169
+
let account: Value = create_res.json().await.unwrap();
170
+
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
164
171
let redirect_uri = "https://example.com/replay-callback";
165
172
let mock_client = setup_mock_client_metadata(redirect_uri).await;
166
173
let client_id = mock_client.uri();
···
243
250
let client_id_b = mock_b.uri();
244
251
let ts2 = Utc::now().timestamp_millis();
245
252
let handle2 = format!("cross-{}", ts2);
246
-
http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
253
+
let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
247
254
.json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "cross-password" }))
248
255
.send().await.unwrap();
256
+
let account2: Value = create_res2.json().await.unwrap();
257
+
verify_new_account(&http_client, account2["did"].as_str().unwrap()).await;
249
258
let (code_verifier2, code_challenge2) = generate_pkce();
250
259
let par_a: Value = http_client.post(format!("{}/oauth/par", url))
251
260
.form(&[("response_type", "code"), ("client_id", &client_id_a), ("redirect_uri", redirect_uri_a),
+2
-2
tests/password_reset.rs
+2
-2
tests/password_reset.rs
···
373
373
.await
374
374
.expect("User not found");
375
375
let initial_count: i64 = sqlx::query_scalar!(
376
-
"SELECT COUNT(*) FROM notification_queue WHERE user_id = $1 AND notification_type = 'password_reset'",
376
+
"SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'",
377
377
user.id
378
378
)
379
379
.fetch_one(&pool)
···
391
391
.expect("Failed to request password reset");
392
392
assert_eq!(res.status(), StatusCode::OK);
393
393
let final_count: i64 = sqlx::query_scalar!(
394
-
"SELECT COUNT(*) FROM notification_queue WHERE user_id = $1 AND notification_type = 'password_reset'",
394
+
"SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'",
395
395
user.id
396
396
)
397
397
.fetch_one(&pool)
+1
-1
tests/security_fixes.rs
+1
-1
tests/security_fixes.rs
···
1
1
mod common;
2
2
use bspds::image::{ImageError, ImageProcessor};
3
-
use bspds::notifications::{SendError, is_valid_phone_number, sanitize_header_value};
3
+
use bspds::comms::{SendError, is_valid_phone_number, sanitize_header_value};
4
4
use bspds::oauth::templates::{error_page, login_page, success_page};
5
5
6
6
#[test]