- Using a little locking on writing so that we can queue things up instead of immediately just erroring out on NOWAIT
- telegram UX improvement for verification since replying to /start means that it's guaranteed verified already, no token needed
+2
-1
.env.example
+2
-1
.env.example
···
96
96
# Discord notifications (via webhook)
97
97
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
98
98
# Telegram notifications (via bot)
99
-
# TELEGRAM_BOT_TOKEN=your-bot-token
99
+
# TELEGRAM_BOT_TOKEN=bot-token
100
+
# TELEGRAM_WEBHOOK_SECRET=random-secret
100
101
# Signal notifications (via signal-cli)
101
102
# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
102
103
# SIGNAL_SENDER_NUMBER=+1234567890
-76
.sqlx/query-247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43.json
-76
.sqlx/query-247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "SELECT\n email,\n preferred_comms_channel as \"preferred_channel!: CommsChannel\",\n discord_id,\n discord_verified,\n telegram_username,\n telegram_verified,\n signal_number,\n signal_verified\n FROM users WHERE did = $1",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "email",
9
-
"type_info": "Text"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "preferred_channel!: CommsChannel",
14
-
"type_info": {
15
-
"Custom": {
16
-
"name": "comms_channel",
17
-
"kind": {
18
-
"Enum": [
19
-
"email",
20
-
"discord",
21
-
"telegram",
22
-
"signal"
23
-
]
24
-
}
25
-
}
26
-
}
27
-
},
28
-
{
29
-
"ordinal": 2,
30
-
"name": "discord_id",
31
-
"type_info": "Text"
32
-
},
33
-
{
34
-
"ordinal": 3,
35
-
"name": "discord_verified",
36
-
"type_info": "Bool"
37
-
},
38
-
{
39
-
"ordinal": 4,
40
-
"name": "telegram_username",
41
-
"type_info": "Text"
42
-
},
43
-
{
44
-
"ordinal": 5,
45
-
"name": "telegram_verified",
46
-
"type_info": "Bool"
47
-
},
48
-
{
49
-
"ordinal": 6,
50
-
"name": "signal_number",
51
-
"type_info": "Text"
52
-
},
53
-
{
54
-
"ordinal": 7,
55
-
"name": "signal_verified",
56
-
"type_info": "Bool"
57
-
}
58
-
],
59
-
"parameters": {
60
-
"Left": [
61
-
"Text"
62
-
]
63
-
},
64
-
"nullable": [
65
-
true,
66
-
false,
67
-
true,
68
-
false,
69
-
true,
70
-
false,
71
-
true,
72
-
false
73
-
]
74
-
},
75
-
"hash": "247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43"
76
-
}
+2
-1
.sqlx/query-25309f4a08845a49557d694ad9b5b9a137be4dcce28e9293551c8c3fd40fdd86.json
+2
-1
.sqlx/query-25309f4a08845a49557d694ad9b5b9a137be4dcce28e9293551c8c3fd40fdd86.json
+2
-1
.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json
+2
-1
.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json
+70
.sqlx/query-63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67.json
+70
.sqlx/query-63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale, telegram_chat_id, discord_id, signal_number\n FROM users WHERE id = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "email",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "handle",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "preferred_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": "preferred_locale",
36
+
"type_info": "Varchar"
37
+
},
38
+
{
39
+
"ordinal": 4,
40
+
"name": "telegram_chat_id",
41
+
"type_info": "Int8"
42
+
},
43
+
{
44
+
"ordinal": 5,
45
+
"name": "discord_id",
46
+
"type_info": "Text"
47
+
},
48
+
{
49
+
"ordinal": 6,
50
+
"name": "signal_number",
51
+
"type_info": "Text"
52
+
}
53
+
],
54
+
"parameters": {
55
+
"Left": [
56
+
"Uuid"
57
+
]
58
+
},
59
+
"nullable": [
60
+
true,
61
+
false,
62
+
false,
63
+
true,
64
+
true,
65
+
true,
66
+
true
67
+
]
68
+
},
69
+
"hash": "63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67"
70
+
}
+2
-1
.sqlx/query-8047fda41bd94f819213decb8b3e0aba49a8dbdb10217eefd77e3567f8c9694a.json
+2
-1
.sqlx/query-8047fda41bd94f819213decb8b3e0aba49a8dbdb10217eefd77e3567f8c9694a.json
+23
.sqlx/query-9d17e25776c67f96022010840c1d04bdd542b5bcc511b1778bab0159a4566e9c.json
+23
.sqlx/query-9d17e25776c67f96022010840c1d04bdd542b5bcc511b1778bab0159a4566e9c.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW()\n WHERE id = (\n SELECT id FROM users\n WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND deactivated_at IS NULL\n LIMIT 1\n ) RETURNING id",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text",
15
+
"Int8"
16
+
]
17
+
},
18
+
"nullable": [
19
+
false
20
+
]
21
+
},
22
+
"hash": "9d17e25776c67f96022010840c1d04bdd542b5bcc511b1778bab0159a4566e9c"
23
+
}
+22
.sqlx/query-a71a76724a3a7406e30a998c03d52554efaf649bb10b35b6e5d64ac59a479023.json
+22
.sqlx/query-a71a76724a3a7406e30a998c03d52554efaf649bb10b35b6e5d64ac59a479023.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT telegram_chat_id FROM users WHERE id = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "telegram_chat_id",
9
+
"type_info": "Int8"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Uuid"
15
+
]
16
+
},
17
+
"nullable": [
18
+
true
19
+
]
20
+
},
21
+
"hash": "a71a76724a3a7406e30a998c03d52554efaf649bb10b35b6e5d64ac59a479023"
22
+
}
+82
.sqlx/query-c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740.json
+82
.sqlx/query-c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT\n email,\n preferred_comms_channel as \"preferred_channel!: CommsChannel\",\n discord_id,\n discord_verified,\n telegram_username,\n telegram_verified,\n telegram_chat_id,\n signal_number,\n signal_verified\n FROM users WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "email",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "preferred_channel!: CommsChannel",
14
+
"type_info": {
15
+
"Custom": {
16
+
"name": "comms_channel",
17
+
"kind": {
18
+
"Enum": [
19
+
"email",
20
+
"discord",
21
+
"telegram",
22
+
"signal"
23
+
]
24
+
}
25
+
}
26
+
}
27
+
},
28
+
{
29
+
"ordinal": 2,
30
+
"name": "discord_id",
31
+
"type_info": "Text"
32
+
},
33
+
{
34
+
"ordinal": 3,
35
+
"name": "discord_verified",
36
+
"type_info": "Bool"
37
+
},
38
+
{
39
+
"ordinal": 4,
40
+
"name": "telegram_username",
41
+
"type_info": "Text"
42
+
},
43
+
{
44
+
"ordinal": 5,
45
+
"name": "telegram_verified",
46
+
"type_info": "Bool"
47
+
},
48
+
{
49
+
"ordinal": 6,
50
+
"name": "telegram_chat_id",
51
+
"type_info": "Int8"
52
+
},
53
+
{
54
+
"ordinal": 7,
55
+
"name": "signal_number",
56
+
"type_info": "Text"
57
+
},
58
+
{
59
+
"ordinal": 8,
60
+
"name": "signal_verified",
61
+
"type_info": "Bool"
62
+
}
63
+
],
64
+
"parameters": {
65
+
"Left": [
66
+
"Text"
67
+
]
68
+
},
69
+
"nullable": [
70
+
true,
71
+
false,
72
+
true,
73
+
false,
74
+
true,
75
+
false,
76
+
true,
77
+
true,
78
+
false
79
+
]
80
+
},
81
+
"hash": "c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740"
82
+
}
-14
.sqlx/query-c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d.json
-14
.sqlx/query-c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Uuid"
9
-
]
10
-
},
11
-
"nullable": []
12
-
},
13
-
"hash": "c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d"
14
-
}
+2
-1
.sqlx/query-d54660032ca184c20d9cf4563d9a34934ff717796d4f564f1384c1c31fd76594.json
+2
-1
.sqlx/query-d54660032ca184c20d9cf4563d9a34934ff717796d4f564f1384c1c31fd76594.json
+15
.sqlx/query-d7f32a31b4edeebbbf54a1878dfa05f2fcb3c57fe063be4e6a78fe5e74fb9dc3.json
+15
.sqlx/query-d7f32a31b4edeebbbf54a1878dfa05f2fcb3c57fe063be4e6a78fe5e74fb9dc3.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET\n telegram_username = $1,\n telegram_verified = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_verified ELSE FALSE END,\n telegram_chat_id = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_chat_id ELSE NULL END,\n updated_at = NOW()\n WHERE id = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Uuid"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "d7f32a31b4edeebbbf54a1878dfa05f2fcb3c57fe063be4e6a78fe5e74fb9dc3"
15
+
}
-52
.sqlx/query-d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee.json
-52
.sqlx/query-d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale\n FROM users WHERE id = $1",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "email",
9
-
"type_info": "Text"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "handle",
14
-
"type_info": "Text"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "preferred_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": "preferred_locale",
36
-
"type_info": "Varchar"
37
-
}
38
-
],
39
-
"parameters": {
40
-
"Left": [
41
-
"Uuid"
42
-
]
43
-
},
44
-
"nullable": [
45
-
true,
46
-
false,
47
-
false,
48
-
true
49
-
]
50
-
},
51
-
"hash": "d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee"
52
-
}
+14
.sqlx/query-e49cbb17eb279cd12874fc3e7f5cbdbf0072c42127e225b79c0fe90de78670bd.json
+14
.sqlx/query-e49cbb17eb279cd12874fc3e7f5cbdbf0072c42127e225b79c0fe90de78670bd.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, telegram_chat_id = NULL, updated_at = NOW() WHERE id = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "e49cbb17eb279cd12874fc3e7f5cbdbf0072c42127e225b79c0fe90de78670bd"
14
+
}
+24
.sqlx/query-fc1ef8c0979206bf95cccee7c39614f7c882860c39cfe94e411cf6e1d55b63f4.json
+24
.sqlx/query-fc1ef8c0979206bf95cccee7c39614f7c882860c39cfe94e411cf6e1d55b63f4.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW() WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND handle = $3 RETURNING id",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text",
15
+
"Int8",
16
+
"Text"
17
+
]
18
+
},
19
+
"nullable": [
20
+
false
21
+
]
22
+
},
23
+
"hash": "fc1ef8c0979206bf95cccee7c39614f7c882860c39cfe94e411cf6e1d55b63f4"
24
+
}
+14
crates/tranquil-comms/src/locale.rs
+14
crates/tranquil-comms/src/locale.rs
···
31
31
pub legacy_login_body: &'static str,
32
32
pub migration_verification_subject: &'static str,
33
33
pub migration_verification_body: &'static str,
34
+
pub channel_verified_subject: &'static str,
35
+
pub channel_verified_body: &'static str,
34
36
}
35
37
36
38
pub fn get_strings(locale: &str) -> &'static NotificationStrings {
···
66
68
legacy_login_body: "Hello @{handle},\n\nA login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\nDetails:\n- Time: {timestamp}\n- IP Address: {ip}\n\nYour TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\nIf this wasn't you, please:\n1. Change your password immediately\n2. Review your active sessions\n3. Consider disabling legacy app logins in your security settings\n\nStay safe,\n{hostname}",
67
69
migration_verification_subject: "Verify your email - {hostname}",
68
70
migration_verification_body: "Welcome to {hostname}!\n\nYour account has been migrated successfully. To complete the setup, please verify your email address.\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 48 hours.\n\nOr if you like to live dangerously:\n{verify_link}\n\nIf you did not migrate your account, please ignore this email.",
71
+
channel_verified_subject: "Channel verified - {hostname}",
72
+
channel_verified_body: "Hello {handle},\n\n{channel} has been verified as a notification channel for your account on {hostname}.",
69
73
};
70
74
71
75
static STRINGS_ZH: NotificationStrings = NotificationStrings {
···
90
94
legacy_login_body: "您好 @{handle},\n\n检测到使用不支持 TOTP 验证的传统应用(如 Bluesky)登录您的账户。\n\n详细信息:\n- 时间:{timestamp}\n- IP 地址:{ip}\n\n此次登录绕过了 TOTP 保护。该会话对敏感操作的权限有限。\n\n如果这不是您的操作,请:\n1. 立即更改密码\n2. 检查您的活跃会话\n3. 考虑在安全设置中禁用传统应用登录\n\n请注意安全,\n{hostname}",
91
95
migration_verification_subject: "验证您的邮箱 - {hostname}",
92
96
migration_verification_body: "欢迎来到 {hostname}!\n\n您的账户已成功迁移。要完成设置,请验证您的邮箱地址。\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在 48 小时后过期。\n\n或者直接点击链接:\n{verify_link}\n\n如果您没有迁移账户,请忽略此邮件。",
97
+
channel_verified_subject: "通知渠道已验证 - {hostname}",
98
+
channel_verified_body: "您好 {handle},\n\n{channel} 已被验证为您在 {hostname} 上的通知渠道。",
93
99
};
94
100
95
101
static STRINGS_JA: NotificationStrings = NotificationStrings {
···
114
120
legacy_login_body: "@{handle} 様\n\nTOTP 認証に対応していないレガシーアプリ(Bluesky など)からのログインが検出されました。\n\n詳細:\n- 時刻:{timestamp}\n- IP アドレス:{ip}\n\nこのログインでは TOTP 保護がバイパスされました。このセッションは機密操作に対する権限が制限されています。\n\n心当たりがない場合は:\n1. 直ちにパスワードを変更してください\n2. アクティブなセッションを確認してください\n3. セキュリティ設定でレガシーアプリのログインを無効にすることを検討してください\n\nご注意ください。\n{hostname}",
115
121
migration_verification_subject: "メールアドレスの認証 - {hostname}",
116
122
migration_verification_body: "{hostname} へようこそ!\n\nアカウントの移行が完了しました。設定を完了するには、メールアドレスを認証してください。\n\n認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは48時間後に期限切れとなります。\n\n自己責任でワンクリック認証:\n{verify_link}\n\nアカウントを移行していない場合は、このメールを無視してください。",
123
+
channel_verified_subject: "通知チャンネル認証完了 - {hostname}",
124
+
channel_verified_body: "{handle} 様\n\n{channel} が {hostname} の通知チャンネルとして認証されました。",
117
125
};
118
126
119
127
static STRINGS_KO: NotificationStrings = NotificationStrings {
···
138
146
legacy_login_body: "안녕하세요 @{handle}님,\n\nTOTP 인증을 지원하지 않는 레거시 앱(예: Bluesky)을 사용한 로그인이 감지되었습니다.\n\n세부 정보:\n- 시간: {timestamp}\n- IP 주소: {ip}\n\n이 로그인에서 TOTP 보호가 우회되었습니다. 이 세션은 민감한 작업에 대한 권한이 제한됩니다.\n\n본인이 아닌 경우:\n1. 즉시 비밀번호를 변경하세요\n2. 활성 세션을 검토하세요\n3. 보안 설정에서 레거시 앱 로그인 비활성화를 고려하세요\n\n{hostname} 드림",
139
147
migration_verification_subject: "이메일 인증 - {hostname}",
140
148
migration_verification_body: "{hostname}에 오신 것을 환영합니다!\n\n계정 마이그레이션이 완료되었습니다. 설정을 완료하려면 이메일 주소를 인증하세요.\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 48시간 후에 만료됩니다.\n\n위험을 감수하고 원클릭 인증:\n{verify_link}\n\n계정을 마이그레이션하지 않았다면 이 이메일을 무시하세요.",
149
+
channel_verified_subject: "알림 채널 인증 완료 - {hostname}",
150
+
channel_verified_body: "안녕하세요 {handle}님,\n\n{channel}이(가) {hostname}의 알림 채널로 인증되었습니다.",
141
151
};
142
152
143
153
static STRINGS_SV: NotificationStrings = NotificationStrings {
···
162
172
legacy_login_body: "Hej @{handle},\n\nEn inloggning till ditt konto upptäcktes med en äldre app (som Bluesky) som inte stöder TOTP-verifiering.\n\nDetaljer:\n- Tid: {timestamp}\n- IP-adress: {ip}\n\nDitt TOTP-skydd kringgicks för denna inloggning. Sessionen har begränsade behörigheter för känsliga operationer.\n\nOm detta inte var du:\n1. Ändra ditt lösenord omedelbart\n2. Granska dina aktiva sessioner\n3. Överväg att inaktivera äldre appinloggningar i dina säkerhetsinställningar\n\nVar försiktig,\n{hostname}",
163
173
migration_verification_subject: "Verifiera din e-post - {hostname}",
164
174
migration_verification_body: "Välkommen till {hostname}!\n\nDitt konto har migrerats framgångsrikt. För att slutföra installationen, verifiera din e-postadress.\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 48 timmar.\n\nEller om du gillar att leva farligt:\n{verify_link}\n\nOm du inte migrerade ditt konto kan du ignorera detta meddelande.",
175
+
channel_verified_subject: "Aviseringskanal verifierad - {hostname}",
176
+
channel_verified_body: "Hej {handle},\n\n{channel} har verifierats som aviseringskanal för ditt konto på {hostname}.",
165
177
};
166
178
167
179
static STRINGS_FI: NotificationStrings = NotificationStrings {
···
186
198
legacy_login_body: "Hei @{handle},\n\nTilillesi havaittiin kirjautuminen vanhalla sovelluksella (kuten Bluesky), joka ei tue TOTP-vahvistusta.\n\nTiedot:\n- Aika: {timestamp}\n- IP-osoite: {ip}\n\nTOTP-suojauksesi ohitettiin tässä kirjautumisessa. Istunnolla on rajoitetut oikeudet arkaluontoisiin toimintoihin.\n\nJos tämä et ollut sinä:\n1. Vaihda salasanasi välittömästi\n2. Tarkista aktiiviset istuntosi\n3. Harkitse vanhojen sovellusten kirjautumisen poistamista käytöstä turvallisuusasetuksissa\n\nOle varovainen,\n{hostname}",
187
199
migration_verification_subject: "Vahvista sähköpostisi - {hostname}",
188
200
migration_verification_body: "Tervetuloa palveluun {hostname}!\n\nTilisi on siirretty onnistuneesti. Viimeistele asennus vahvistamalla sähköpostiosoitteesi.\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 48 tunnissa.\n\nTai jos pidät vaarallisesta elämästä:\n{verify_link}\n\nJos et siirtänyt tiliäsi, voit jättää tämän viestin huomiotta.",
201
+
channel_verified_subject: "Ilmoituskanava vahvistettu - {hostname}",
202
+
channel_verified_body: "Hei {handle},\n\n{channel} on vahvistettu ilmoituskanavaksi tilillesi palvelussa {hostname}.",
189
203
};
190
204
191
205
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
+64
-3
crates/tranquil-comms/src/sender.rs
+64
-3
crates/tranquil-comms/src/sender.rs
···
67
67
}
68
68
}
69
69
70
+
pub fn escape_html(text: &str) -> String {
71
+
text.replace('&', "&")
72
+
.replace('<', "<")
73
+
.replace('>', ">")
74
+
}
75
+
70
76
pub fn is_valid_phone_number(number: &str) -> bool {
71
77
if number.len() < 2 || number.len() > 20 {
72
78
return false;
···
244
250
let bot_token = std::env::var("TELEGRAM_BOT_TOKEN").ok()?;
245
251
Some(Self::new(bot_token))
246
252
}
253
+
254
+
pub async fn set_webhook(
255
+
&self,
256
+
webhook_url: &str,
257
+
secret_token: Option<&str>,
258
+
) -> Result<(), SendError> {
259
+
let url = format!("https://api.telegram.org/bot{}/setWebhook", self.bot_token);
260
+
let mut payload = json!({ "url": webhook_url });
261
+
if let Some(secret) = secret_token {
262
+
payload["secret_token"] = json!(secret);
263
+
}
264
+
let response = self
265
+
.http_client
266
+
.post(&url)
267
+
.json(&payload)
268
+
.send()
269
+
.await
270
+
.map_err(|e| SendError::ExternalService(format!("setWebhook request failed: {}", e)))?;
271
+
if !response.status().is_success() {
272
+
let body = response.text().await.unwrap_or_default();
273
+
return Err(SendError::ExternalService(format!(
274
+
"setWebhook returned error: {}",
275
+
body
276
+
)));
277
+
}
278
+
Ok(())
279
+
}
280
+
281
+
pub async fn resolve_bot_username(&self) -> Result<String, SendError> {
282
+
let url = format!("https://api.telegram.org/bot{}/getMe", self.bot_token);
283
+
let response = self.http_client.get(&url).send().await.map_err(|e| {
284
+
SendError::ExternalService(format!("Telegram getMe request failed: {}", e))
285
+
})?;
286
+
287
+
if !response.status().is_success() {
288
+
let body = response.text().await.unwrap_or_default();
289
+
return Err(SendError::ExternalService(format!(
290
+
"Telegram getMe returned error: {}",
291
+
body
292
+
)));
293
+
}
294
+
295
+
let data: serde_json::Value = response.json().await.map_err(|e| {
296
+
SendError::ExternalService(format!("Failed to parse getMe response: {}", e))
297
+
})?;
298
+
299
+
data.get("result")
300
+
.and_then(|r| r.get("username"))
301
+
.and_then(|u| u.as_str())
302
+
.map(|s| s.to_string())
303
+
.ok_or_else(|| {
304
+
SendError::ExternalService("getMe response missing username".to_string())
305
+
})
306
+
}
247
307
}
248
308
249
309
#[async_trait]
···
254
314
255
315
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
256
316
let chat_id = ¬ification.recipient;
257
-
let subject = notification.subject.as_deref().unwrap_or("Notification");
258
-
let text = format!("*{}*\n\n{}", subject, notification.body);
317
+
let subject = escape_html(notification.subject.as_deref().unwrap_or("Notification"));
318
+
let body = escape_html(¬ification.body);
319
+
let text = format!("<b>{}</b>\n\n{}", subject, body);
259
320
let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token);
260
321
let payload = json!({
261
322
"chat_id": chat_id,
262
323
"text": text,
263
-
"parse_mode": "Markdown"
324
+
"parse_mode": "HTML"
264
325
});
265
326
let mut last_error = None;
266
327
for attempt in 0..MAX_RETRIES {
+96
-2
crates/tranquil-db/src/postgres/user.rs
+96
-2
crates/tranquil-db/src/postgres/user.rs
···
311
311
312
312
async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> {
313
313
let row = sqlx::query!(
314
-
r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale
314
+
r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_number
315
315
FROM users WHERE id = $1"#,
316
316
user_id
317
317
)
···
323
323
handle: Handle::from(r.handle),
324
324
preferred_channel: r.preferred_channel,
325
325
preferred_locale: r.preferred_locale,
326
+
telegram_chat_id: r.telegram_chat_id,
327
+
discord_id: r.discord_id,
328
+
signal_number: r.signal_number,
326
329
}))
327
330
}
328
331
···
561
564
Ok(row)
562
565
}
563
566
567
+
async fn check_channel_verified_by_did(
568
+
&self,
569
+
did: &Did,
570
+
channel: CommsChannel,
571
+
) -> Result<Option<bool>, DbError> {
572
+
let row = sqlx::query!(
573
+
r#"SELECT
574
+
email_verified,
575
+
discord_verified,
576
+
telegram_verified,
577
+
signal_verified
578
+
FROM users
579
+
WHERE did = $1"#,
580
+
did.as_str()
581
+
)
582
+
.fetch_optional(&self.pool)
583
+
.await
584
+
.map_err(map_sqlx_error)?;
585
+
586
+
Ok(row.map(|r| match channel {
587
+
CommsChannel::Email => r.email_verified,
588
+
CommsChannel::Discord => r.discord_verified,
589
+
CommsChannel::Telegram => r.telegram_verified,
590
+
CommsChannel::Signal => r.signal_verified,
591
+
}))
592
+
}
593
+
564
594
async fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, DbError> {
565
595
let result = sqlx::query!(
566
596
"UPDATE users SET email = $1 WHERE did = $2",
···
609
639
discord_verified,
610
640
telegram_username,
611
641
telegram_verified,
642
+
telegram_chat_id,
612
643
signal_number,
613
644
signal_verified
614
645
FROM users WHERE did = $1"#,
···
624
655
discord_verified: r.discord_verified,
625
656
telegram_username: r.telegram_username,
626
657
telegram_verified: r.telegram_verified,
658
+
telegram_chat_id: r.telegram_chat_id,
627
659
signal_number: r.signal_number,
628
660
signal_verified: r.signal_verified,
629
661
}))
···
676
708
677
709
async fn clear_telegram(&self, user_id: Uuid) -> Result<(), DbError> {
678
710
sqlx::query!(
679
-
"UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1",
711
+
"UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, telegram_chat_id = NULL, updated_at = NOW() WHERE id = $1",
680
712
user_id
681
713
)
682
714
.execute(&self.pool)
···
3135
3167
Ok(tranquil_db_traits::RecoverPasskeyAccountResult {
3136
3168
passkeys_deleted: deleted.rows_affected(),
3137
3169
})
3170
+
}
3171
+
3172
+
async fn set_unverified_telegram(
3173
+
&self,
3174
+
user_id: Uuid,
3175
+
telegram_username: &str,
3176
+
) -> Result<(), DbError> {
3177
+
sqlx::query!(
3178
+
r#"UPDATE users SET
3179
+
telegram_username = $1,
3180
+
telegram_verified = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_verified ELSE FALSE END,
3181
+
telegram_chat_id = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_chat_id ELSE NULL END,
3182
+
updated_at = NOW()
3183
+
WHERE id = $2"#,
3184
+
telegram_username,
3185
+
user_id
3186
+
)
3187
+
.execute(&self.pool)
3188
+
.await
3189
+
.map_err(map_sqlx_error)?;
3190
+
Ok(())
3191
+
}
3192
+
3193
+
async fn store_telegram_chat_id(
3194
+
&self,
3195
+
telegram_username: &str,
3196
+
chat_id: i64,
3197
+
handle: Option<&str>,
3198
+
) -> Result<Option<Uuid>, DbError> {
3199
+
let result = match handle {
3200
+
Some(h) => sqlx::query_scalar!(
3201
+
"UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW() WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND handle = $3 RETURNING id",
3202
+
telegram_username,
3203
+
chat_id,
3204
+
h
3205
+
)
3206
+
.fetch_optional(&self.pool)
3207
+
.await
3208
+
.map_err(map_sqlx_error)?,
3209
+
None => sqlx::query_scalar!(
3210
+
r#"UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW()
3211
+
WHERE id = (
3212
+
SELECT id FROM users
3213
+
WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND deactivated_at IS NULL
3214
+
LIMIT 1
3215
+
) RETURNING id"#,
3216
+
telegram_username,
3217
+
chat_id
3218
+
)
3219
+
.fetch_optional(&self.pool)
3220
+
.await
3221
+
.map_err(map_sqlx_error)?,
3222
+
};
3223
+
Ok(result)
3224
+
}
3225
+
3226
+
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError> {
3227
+
let row = sqlx::query_scalar!("SELECT telegram_chat_id FROM users WHERE id = $1", user_id)
3228
+
.fetch_optional(&self.pool)
3229
+
.await
3230
+
.map_err(map_sqlx_error)?;
3231
+
Ok(row.flatten())
3138
3232
}
3139
3233
}
+1
crates/tranquil-db-traits/src/infra.rs
+1
crates/tranquil-db-traits/src/infra.rs
+25
crates/tranquil-db-traits/src/user.rs
+25
crates/tranquil-db-traits/src/user.rs
···
196
196
identifier: &str,
197
197
) -> Result<Option<bool>, DbError>;
198
198
199
+
async fn check_channel_verified_by_did(
200
+
&self,
201
+
did: &Did,
202
+
channel: CommsChannel,
203
+
) -> Result<Option<bool>, DbError>;
204
+
199
205
async fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, DbError>;
200
206
201
207
async fn admin_update_handle(&self, did: &Did, handle: &Handle) -> Result<u64, DbError>;
···
221
227
async fn clear_telegram(&self, user_id: Uuid) -> Result<(), DbError>;
222
228
223
229
async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError>;
230
+
231
+
async fn set_unverified_telegram(
232
+
&self,
233
+
user_id: Uuid,
234
+
telegram_username: &str,
235
+
) -> Result<(), DbError>;
236
+
237
+
async fn store_telegram_chat_id(
238
+
&self,
239
+
telegram_username: &str,
240
+
chat_id: i64,
241
+
handle: Option<&str>,
242
+
) -> Result<Option<Uuid>, DbError>;
243
+
244
+
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError>;
224
245
225
246
async fn get_verification_info(
226
247
&self,
···
575
596
pub handle: Handle,
576
597
pub preferred_channel: CommsChannel,
577
598
pub preferred_locale: Option<String>,
599
+
pub telegram_chat_id: Option<i64>,
600
+
pub discord_id: Option<String>,
601
+
pub signal_number: Option<String>,
578
602
}
579
603
580
604
#[derive(Debug, Clone)]
···
635
659
pub discord_verified: bool,
636
660
pub telegram_username: Option<String>,
637
661
pub telegram_verified: bool,
662
+
pub telegram_chat_id: Option<i64>,
638
663
pub signal_number: Option<String>,
639
664
pub signal_verified: bool,
640
665
}
+10
-2
crates/tranquil-pds/src/api/identity/account.rs
+10
-2
crates/tranquil-pds/src/api/identity/account.rs
···
208
208
_ => return ApiError::MissingDiscordId.into_response(),
209
209
},
210
210
"telegram" => match &input.telegram_username {
211
-
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
211
+
Some(username) if !username.trim().is_empty() => {
212
+
let clean = username.trim().trim_start_matches('@');
213
+
if !crate::api::validation::is_valid_telegram_username(clean) {
214
+
return ApiError::InvalidRequest(
215
+
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
216
+
).into_response();
217
+
}
218
+
clean.to_string()
219
+
}
212
220
_ => return ApiError::MissingTelegramUsername.into_response(),
213
221
},
214
222
"signal" => match &input.signal_number {
···
634
642
telegram_username: input
635
643
.telegram_username
636
644
.as_deref()
637
-
.map(|s| s.trim())
645
+
.map(|s| s.trim().trim_start_matches('@'))
638
646
.filter(|s| !s.is_empty())
639
647
.map(String::from),
640
648
signal_number: input
+1
crates/tranquil-pds/src/api/mod.rs
+1
crates/tranquil-pds/src/api/mod.rs
+72
-13
crates/tranquil-pds/src/api/notification_prefs.rs
+72
-13
crates/tranquil-pds/src/api/notification_prefs.rs
···
165
165
"signal" => tranquil_db_traits::CommsChannel::Signal,
166
166
_ => return Err("Invalid channel".to_string()),
167
167
};
168
+
let hostname = pds_hostname();
169
+
let encoded_token = urlencoding::encode(&formatted_token);
170
+
let encoded_identifier = urlencoding::encode(identifier);
171
+
let verify_link = format!(
172
+
"https://{}/app/verify?token={}&identifier={}",
173
+
hostname, encoded_token, encoded_identifier
174
+
);
175
+
let body = format!(
176
+
"Your verification code is: {}\n\nOr verify directly:\n{}",
177
+
formatted_token, verify_link
178
+
);
179
+
let recipient = match comms_channel {
180
+
tranquil_db_traits::CommsChannel::Telegram => state
181
+
.user_repo
182
+
.get_telegram_chat_id(user_id)
183
+
.await
184
+
.ok()
185
+
.flatten()
186
+
.map(|id| id.to_string())
187
+
.unwrap_or_else(|| identifier.to_string()),
188
+
_ => identifier.to_string(),
189
+
};
168
190
state
169
191
.infra_repo
170
192
.enqueue_comms(
171
193
Some(user_id),
172
194
comms_channel,
173
195
tranquil_db_traits::CommsType::ChannelVerification,
174
-
identifier,
196
+
&recipient,
175
197
Some("Verify your channel"),
176
-
&format!("Your verification code is: {}", formatted_token),
198
+
&body,
177
199
Some(json!({"code": formatted_token})),
178
200
)
179
201
.await
···
199
221
let handle = user_row.handle;
200
222
let current_email = user_row.email;
201
223
224
+
let current_prefs = state
225
+
.user_repo
226
+
.get_notification_prefs(&auth.did)
227
+
.await
228
+
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?
229
+
.ok_or(ApiError::AccountNotFound)?;
230
+
231
+
let effective_channel = input
232
+
.preferred_channel
233
+
.as_deref()
234
+
.map(|ch| match ch {
235
+
"email" => Ok(CommsChannel::Email),
236
+
"discord" => Ok(CommsChannel::Discord),
237
+
"telegram" => Ok(CommsChannel::Telegram),
238
+
"signal" => Ok(CommsChannel::Signal),
239
+
_ => Err(ApiError::InvalidRequest(
240
+
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
241
+
)),
242
+
})
243
+
.transpose()?
244
+
.unwrap_or(current_prefs.preferred_channel);
245
+
202
246
let mut verification_required: Vec<String> = Vec::new();
203
247
204
248
if let Some(ref channel_str) = input.preferred_channel {
···
249
293
250
294
if let Some(ref discord_id) = input.discord_id {
251
295
if discord_id.is_empty() {
296
+
if effective_channel == CommsChannel::Discord {
297
+
return Err(ApiError::InvalidRequest(
298
+
"Cannot remove Discord while it is the preferred notification channel".into(),
299
+
));
300
+
}
252
301
state
253
302
.user_repo
254
303
.clear_discord(user_id)
···
267
316
if let Some(ref telegram) = input.telegram_username {
268
317
let telegram_clean = telegram.trim_start_matches('@');
269
318
if telegram_clean.is_empty() {
319
+
if effective_channel == CommsChannel::Telegram {
320
+
return Err(ApiError::InvalidRequest(
321
+
"Cannot remove Telegram while it is the preferred notification channel".into(),
322
+
));
323
+
}
270
324
state
271
325
.user_repo
272
326
.clear_telegram(user_id)
273
327
.await
274
328
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
275
329
info!(did = %auth.did, "Cleared Telegram username");
330
+
} else if !crate::api::validation::is_valid_telegram_username(telegram_clean) {
331
+
return Err(ApiError::InvalidRequest(
332
+
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore"
333
+
.into(),
334
+
));
276
335
} else {
277
-
request_channel_verification(
278
-
&state,
279
-
user_id,
280
-
&auth.did,
281
-
"telegram",
282
-
telegram_clean,
283
-
None,
284
-
)
285
-
.await
286
-
.map_err(|e| ApiError::InternalError(Some(e)))?;
336
+
state
337
+
.user_repo
338
+
.set_unverified_telegram(user_id, telegram_clean)
339
+
.await
340
+
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
287
341
verification_required.push("telegram".to_string());
288
-
info!(did = %auth.did, "Requested Telegram verification");
342
+
info!(did = %auth.did, telegram_username = %telegram_clean, "Stored unverified Telegram username");
289
343
}
290
344
}
291
345
292
346
if let Some(ref signal) = input.signal_number {
293
347
if signal.is_empty() {
348
+
if effective_channel == CommsChannel::Signal {
349
+
return Err(ApiError::InvalidRequest(
350
+
"Cannot remove Signal while it is the preferred notification channel".into(),
351
+
));
352
+
}
294
353
state
295
354
.user_repo
296
355
.clear_signal(user_id)
+1
crates/tranquil-pds/src/api/repo/import.rs
+1
crates/tranquil-pds/src/api/repo/import.rs
···
190
190
.ok()
191
191
.and_then(|s| s.parse().ok())
192
192
.unwrap_or(DEFAULT_MAX_BLOCKS);
193
+
let _write_lock = state.repo_write_locks.lock(user_id).await;
193
194
match apply_import(&state.repo_repo, user_id, root, blocks.clone(), max_blocks).await {
194
195
Ok(import_result) => {
195
196
info!(
+3
crates/tranquil-pds/src/api/repo/record/batch.rs
+3
crates/tranquil-pds/src/api/repo/record/batch.rs
+8
-2
crates/tranquil-pds/src/api/repo/record/delete.rs
+8
-2
crates/tranquil-pds/src/api/repo/record/delete.rs
···
1
1
use crate::api::error::ApiError;
2
-
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
2
+
use crate::api::repo::record::utils::{
3
+
CommitParams, RecordOp, commit_and_log, get_current_root_cid,
4
+
};
3
5
use crate::api::repo::record::write::{CommitInfo, prepare_repo_write};
4
6
use crate::auth::{Active, Auth, VerifyScope};
5
7
use crate::cid_types::CommitCid;
···
56
58
57
59
let did = repo_auth.did;
58
60
let user_id = repo_auth.user_id;
59
-
let current_root_cid = repo_auth.current_root_cid;
60
61
let controller_did = repo_auth.controller_did;
62
+
63
+
let _write_lock = state.repo_write_locks.lock(user_id).await;
64
+
let current_root_cid = get_current_root_cid(&state, user_id).await?;
61
65
62
66
if let Some(swap_commit) = &input.swap_commit
63
67
&& CommitCid::from_str(swap_commit).ok().as_ref() != Some(¤t_root_cid)
···
238
242
collection: &Nsid,
239
243
rkey: &Rkey,
240
244
) -> Result<(), String> {
245
+
let _write_lock = state.repo_write_locks.lock(user_id).await;
246
+
241
247
let root_cid_str = state
242
248
.repo_repo
243
249
.get_repo_root_cid_by_user_id(user_id)
+20
crates/tranquil-pds/src/api/repo/record/utils.rs
+20
crates/tranquil-pds/src/api/repo/record/utils.rs
···
1
+
use crate::api::error::ApiError;
2
+
use crate::cid_types::CommitCid;
1
3
use crate::state::AppState;
2
4
use crate::types::{Did, Handle, Nsid, Rkey};
3
5
use bytes::Bytes;
···
8
10
use k256::ecdsa::SigningKey;
9
11
use serde_json::{Value, json};
10
12
use std::str::FromStr;
13
+
use tracing::error;
11
14
use tranquil_db_traits::SequenceNumber;
12
15
use uuid::Uuid;
16
+
17
+
pub async fn get_current_root_cid(state: &AppState, user_id: Uuid) -> Result<CommitCid, ApiError> {
18
+
let root_cid_str = state
19
+
.repo_repo
20
+
.get_repo_root_cid_by_user_id(user_id)
21
+
.await
22
+
.map_err(|e| {
23
+
error!("DB error fetching repo root: {}", e);
24
+
ApiError::InternalError(None)
25
+
})?
26
+
.ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())))?;
27
+
CommitCid::from_str(&root_cid_str)
28
+
.map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())))
29
+
}
13
30
14
31
pub fn extract_blob_cids(record: &Value) -> Vec<String> {
15
32
let mut blobs = Vec::new();
···
328
345
.await
329
346
.map_err(|e| format!("DB error: {}", e))?
330
347
.ok_or_else(|| "User not found".to_string())?;
348
+
349
+
let _write_lock = state.repo_write_locks.lock(user_id).await;
350
+
331
351
let root_cid_link = state
332
352
.repo_repo
333
353
.get_repo_root_cid_by_user_id(user_id)
+8
-18
crates/tranquil-pds/src/api/repo/record/write.rs
+8
-18
crates/tranquil-pds/src/api/repo/record/write.rs
···
3
3
use crate::api::error::ApiError;
4
4
use crate::api::repo::record::utils::{
5
5
CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids,
6
+
get_current_root_cid,
6
7
};
7
8
use crate::auth::{
8
9
Active, Auth, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
···
31
32
pub struct RepoWriteAuth {
32
33
pub did: Did,
33
34
pub user_id: Uuid,
34
-
pub current_root_cid: CommitCid,
35
35
pub is_oauth: bool,
36
36
pub scope: Option<String>,
37
37
pub controller_did: Option<Did>,
···
62
62
ApiError::InternalError(None).into_response()
63
63
})?
64
64
.ok_or_else(|| ApiError::InternalError(Some("User not found".into())).into_response())?;
65
-
let root_cid_str = state
66
-
.repo_repo
67
-
.get_repo_root_cid_by_user_id(user_id)
68
-
.await
69
-
.map_err(|e| {
70
-
error!("DB error fetching repo root: {}", e);
71
-
ApiError::InternalError(None).into_response()
72
-
})?
73
-
.ok_or_else(|| {
74
-
ApiError::InternalError(Some("Repo root not found".into())).into_response()
75
-
})?;
76
-
let current_root_cid = CommitCid::from_str(&root_cid_str).map_err(|_| {
77
-
ApiError::InternalError(Some("Invalid repo root CID".into())).into_response()
78
-
})?;
65
+
79
66
Ok(RepoWriteAuth {
80
67
did: principal_did.into_did(),
81
68
user_id,
82
-
current_root_cid,
83
69
is_oauth: user.is_oauth(),
84
70
scope: user.scope.clone(),
85
71
controller_did: scope_proof.controller_did().map(|c| c.into_did()),
···
130
116
131
117
let did = repo_auth.did;
132
118
let user_id = repo_auth.user_id;
133
-
let current_root_cid = repo_auth.current_root_cid;
134
119
let controller_did = repo_auth.controller_did;
120
+
121
+
let _write_lock = state.repo_write_locks.lock(user_id).await;
122
+
let current_root_cid = get_current_root_cid(&state, user_id).await?;
135
123
136
124
if let Some(swap_commit) = &input.swap_commit
137
125
&& CommitCid::from_str(swap_commit).ok().as_ref() != Some(¤t_root_cid)
···
433
421
434
422
let did = repo_auth.did;
435
423
let user_id = repo_auth.user_id;
436
-
let current_root_cid = repo_auth.current_root_cid;
437
424
let controller_did = repo_auth.controller_did;
425
+
426
+
let _write_lock = state.repo_write_locks.lock(user_id).await;
427
+
let current_root_cid = get_current_root_cid(&state, user_id).await?;
438
428
439
429
if let Some(swap_commit) = &input.swap_commit
440
430
&& CommitCid::from_str(swap_commit).ok().as_ref() != Some(¤t_root_cid)
+39
crates/tranquil-pds/src/api/server/email.rs
+39
crates/tranquil-pds/src/api/server/email.rs
···
420
420
}
421
421
422
422
#[derive(Deserialize)]
423
+
pub struct CheckChannelVerifiedInput {
424
+
pub did: String,
425
+
pub channel: String,
426
+
}
427
+
428
+
pub async fn check_channel_verified(
429
+
State(state): State<AppState>,
430
+
_rate_limit: RateLimited<VerificationCheckLimit>,
431
+
Json(input): Json<CheckChannelVerifiedInput>,
432
+
) -> Response {
433
+
let channel = match input.channel.to_lowercase().as_str() {
434
+
"email" => CommsChannel::Email,
435
+
"discord" => CommsChannel::Discord,
436
+
"telegram" => CommsChannel::Telegram,
437
+
"signal" => CommsChannel::Signal,
438
+
_ => {
439
+
return ApiError::InvalidRequest("invalid channel".into()).into_response();
440
+
}
441
+
};
442
+
443
+
let did = match crate::Did::new(input.did) {
444
+
Ok(d) => d,
445
+
Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(),
446
+
};
447
+
match state
448
+
.user_repo
449
+
.check_channel_verified_by_did(&did, channel)
450
+
.await
451
+
{
452
+
Ok(Some(verified)) => VerifiedResponse::response(verified).into_response(),
453
+
Ok(None) => ApiError::AccountNotFound.into_response(),
454
+
Err(e) => {
455
+
error!("DB error checking channel verified: {:?}", e);
456
+
ApiError::InternalError(None).into_response()
457
+
}
458
+
}
459
+
}
460
+
461
+
#[derive(Deserialize)]
423
462
pub struct AuthorizeEmailUpdateQuery {
424
463
pub token: String,
425
464
}
+7
-3
crates/tranquil-pds/src/api/server/meta.rs
+7
-3
crates/tranquil-pds/src/api/server/meta.rs
···
1
1
use crate::state::AppState;
2
-
use crate::util::pds_hostname;
2
+
use crate::util::{pds_hostname, telegram_bot_username};
3
3
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
4
4
use serde_json::json;
5
5
···
52
52
if let Some(email) = contact_email {
53
53
contact.insert("email".to_string(), json!(email));
54
54
}
55
-
Json(json!({
55
+
let mut response = json!({
56
56
"availableUserDomains": domains,
57
57
"inviteCodeRequired": invite_code_required,
58
58
"did": format!("did:web:{}", pds_hostname),
···
61
61
"version": env!("CARGO_PKG_VERSION"),
62
62
"availableCommsChannels": get_available_comms_channels(),
63
63
"selfHostedDidWebEnabled": is_self_hosted_did_web_enabled()
64
-
}))
64
+
});
65
+
if let Some(bot_username) = telegram_bot_username() {
66
+
response["telegramBotUsername"] = json!(bot_username);
67
+
}
68
+
Json(response)
65
69
}
66
70
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
67
71
match state.infra_repo.health_check().await {
+1
-1
crates/tranquil-pds/src/api/server/mod.rs
+1
-1
crates/tranquil-pds/src/api/server/mod.rs
···
23
23
};
24
24
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
25
25
pub use email::{
26
-
authorize_email_update, check_comms_channel_in_use, check_email_in_use,
26
+
authorize_email_update, check_channel_verified, check_comms_channel_in_use, check_email_in_use,
27
27
check_email_update_status, check_email_verified, confirm_email, request_email_update,
28
28
update_email,
29
29
};
+10
-2
crates/tranquil-pds/src/api/server/passkey_account.rs
+10
-2
crates/tranquil-pds/src/api/server/passkey_account.rs
···
172
172
_ => return ApiError::MissingDiscordId.into_response(),
173
173
},
174
174
"telegram" => match &input.telegram_username {
175
-
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
175
+
Some(username) if !username.trim().is_empty() => {
176
+
let clean = username.trim().trim_start_matches('@');
177
+
if !crate::api::validation::is_valid_telegram_username(clean) {
178
+
return ApiError::InvalidRequest(
179
+
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
180
+
).into_response();
181
+
}
182
+
clean.to_string()
183
+
}
176
184
_ => return ApiError::MissingTelegramUsername.into_response(),
177
185
},
178
186
"signal" => match &input.signal_number {
···
410
418
telegram_username: input
411
419
.telegram_username
412
420
.as_deref()
413
-
.map(|s| s.trim())
421
+
.map(|s| s.trim().trim_start_matches('@'))
414
422
.filter(|s| !s.is_empty())
415
423
.map(String::from),
416
424
signal_number: input
+50
-1
crates/tranquil-pds/src/api/server/verify_token.rs
+50
-1
crates/tranquil-pds/src/api/server/verify_token.rs
···
1
1
use crate::api::error::{ApiError, DbResultExt};
2
+
use crate::comms::comms_repo;
2
3
use crate::types::Did;
4
+
use crate::util::pds_hostname;
3
5
use axum::{Json, extract::State};
4
6
use serde::{Deserialize, Serialize};
5
7
use tracing::{info, warn};
···
161
163
162
164
info!(did = %did, channel = %channel, "Channel verified successfully");
163
165
166
+
let recipient = resolve_verified_recipient(state, user_id, channel, identifier).await;
167
+
if let Err(e) = comms_repo::enqueue_channel_verified(
168
+
state.user_repo.as_ref(),
169
+
state.infra_repo.as_ref(),
170
+
user_id,
171
+
channel,
172
+
&recipient,
173
+
pds_hostname(),
174
+
)
175
+
.await
176
+
{
177
+
warn!(error = %e, "Failed to enqueue channel verified notification");
178
+
}
179
+
164
180
Ok(Json(VerifyTokenOutput {
165
181
success: true,
166
182
did: did.to_string().into(),
···
169
185
}))
170
186
}
171
187
188
+
async fn resolve_verified_recipient(
189
+
state: &AppState,
190
+
user_id: uuid::Uuid,
191
+
channel: &str,
192
+
identifier: &str,
193
+
) -> String {
194
+
match channel {
195
+
"telegram" => state
196
+
.user_repo
197
+
.get_telegram_chat_id(user_id)
198
+
.await
199
+
.ok()
200
+
.flatten()
201
+
.map(|id| id.to_string())
202
+
.unwrap_or_else(|| identifier.to_string()),
203
+
_ => identifier.to_string(),
204
+
}
205
+
}
206
+
172
207
async fn handle_signup_verification(
173
208
state: &AppState,
174
209
did: &str,
175
210
channel: &str,
176
-
_identifier: &str,
211
+
identifier: &str,
177
212
) -> Result<Json<VerifyTokenOutput>, ApiError> {
178
213
let did_typed: Did = did
179
214
.parse()
···
231
266
};
232
267
233
268
info!(did = %did, channel = %channel, "Signup verified successfully");
269
+
270
+
let recipient = resolve_verified_recipient(state, user.id, channel, identifier).await;
271
+
if let Err(e) = comms_repo::enqueue_channel_verified(
272
+
state.user_repo.as_ref(),
273
+
state.infra_repo.as_ref(),
274
+
user.id,
275
+
channel,
276
+
&recipient,
277
+
pds_hostname(),
278
+
)
279
+
.await
280
+
{
281
+
warn!(error = %e, "Failed to enqueue channel verified notification");
282
+
}
234
283
235
284
Ok(Json(VerifyTokenOutput {
236
285
success: true,
+172
crates/tranquil-pds/src/api/telegram_webhook.rs
+172
crates/tranquil-pds/src/api/telegram_webhook.rs
···
1
+
use axum::{
2
+
extract::State,
3
+
http::{HeaderMap, StatusCode},
4
+
response::IntoResponse,
5
+
};
6
+
use serde::Deserialize;
7
+
use tracing::{debug, info, warn};
8
+
9
+
use crate::comms::comms_repo;
10
+
use crate::state::AppState;
11
+
use crate::util::pds_hostname;
12
+
13
+
#[derive(Deserialize)]
14
+
struct TelegramUpdate {
15
+
message: Option<TelegramMessage>,
16
+
}
17
+
18
+
#[derive(Deserialize)]
19
+
struct TelegramMessage {
20
+
text: Option<String>,
21
+
from: Option<TelegramUser>,
22
+
}
23
+
24
+
#[derive(Deserialize)]
25
+
struct TelegramUser {
26
+
id: i64,
27
+
username: Option<String>,
28
+
}
29
+
30
+
pub async fn handle_telegram_webhook(
31
+
State(state): State<AppState>,
32
+
headers: HeaderMap,
33
+
body: String,
34
+
) -> impl IntoResponse {
35
+
let expected_secret = match std::env::var("TELEGRAM_WEBHOOK_SECRET") {
36
+
Ok(s) => s,
37
+
Err(_) => {
38
+
warn!("Telegram webhook called but TELEGRAM_WEBHOOK_SECRET is not configured");
39
+
return StatusCode::FORBIDDEN;
40
+
}
41
+
};
42
+
let provided = headers
43
+
.get("x-telegram-bot-api-secret-token")
44
+
.and_then(|v| v.to_str().ok())
45
+
.unwrap_or_default();
46
+
if provided != expected_secret {
47
+
warn!("Telegram webhook received with invalid secret token");
48
+
return StatusCode::UNAUTHORIZED;
49
+
}
50
+
51
+
let update: TelegramUpdate = match serde_json::from_str(&body) {
52
+
Ok(u) => u,
53
+
Err(_) => return StatusCode::OK,
54
+
};
55
+
56
+
if let Some(message) = update.message {
57
+
let is_start = message
58
+
.text
59
+
.as_deref()
60
+
.is_some_and(|t| t.starts_with("/start"));
61
+
62
+
if is_start
63
+
&& let Some(from) = message.from
64
+
&& let Some(username) = from.username
65
+
{
66
+
let handle = parse_start_handle(message.text.as_deref());
67
+
68
+
debug!(
69
+
telegram_username = %username,
70
+
chat_id = from.id,
71
+
handle = ?handle,
72
+
"Received /start from Telegram user"
73
+
);
74
+
match state
75
+
.user_repo
76
+
.store_telegram_chat_id(&username, from.id, handle.as_deref())
77
+
.await
78
+
{
79
+
Ok(Some(user_id)) => {
80
+
info!(
81
+
telegram_username = %username,
82
+
chat_id = from.id,
83
+
"Verified Telegram user and stored chat_id"
84
+
);
85
+
if let Err(e) = comms_repo::enqueue_channel_verified(
86
+
state.user_repo.as_ref(),
87
+
state.infra_repo.as_ref(),
88
+
user_id,
89
+
"telegram",
90
+
&from.id.to_string(),
91
+
pds_hostname(),
92
+
)
93
+
.await
94
+
{
95
+
warn!(error = %e, "Failed to enqueue channel verified notification");
96
+
}
97
+
}
98
+
Ok(None) => {
99
+
debug!(
100
+
telegram_username = %username,
101
+
"No matching user found for Telegram username"
102
+
);
103
+
}
104
+
Err(e) => {
105
+
warn!(
106
+
telegram_username = %username,
107
+
error = %e,
108
+
"Failed to store Telegram chat_id"
109
+
);
110
+
}
111
+
}
112
+
}
113
+
}
114
+
115
+
StatusCode::OK
116
+
}
117
+
118
+
fn parse_start_handle(text: Option<&str>) -> Option<String> {
119
+
text.and_then(|t| t.strip_prefix("/start "))
120
+
.map(|payload| payload.trim())
121
+
.filter(|p| !p.is_empty())
122
+
.map(|payload| payload.replace('_', "."))
123
+
}
124
+
125
+
#[cfg(test)]
126
+
mod tests {
127
+
use super::*;
128
+
129
+
#[test]
130
+
fn deep_link_underscores_decoded_to_dots() {
131
+
assert_eq!(
132
+
parse_start_handle(Some("/start lewis_buttercup_wizardry_systems")),
133
+
Some("lewis.buttercup.wizardry.systems".to_string()),
134
+
);
135
+
}
136
+
137
+
#[test]
138
+
fn manual_handle_with_dots_passes_through() {
139
+
assert_eq!(
140
+
parse_start_handle(Some("/start lewis.buttercup.wizardry.systems")),
141
+
Some("lewis.buttercup.wizardry.systems".to_string()),
142
+
);
143
+
}
144
+
145
+
#[test]
146
+
fn bare_start_returns_none() {
147
+
assert_eq!(parse_start_handle(Some("/start")), None);
148
+
}
149
+
150
+
#[test]
151
+
fn start_with_trailing_space_returns_none() {
152
+
assert_eq!(parse_start_handle(Some("/start ")), None);
153
+
}
154
+
155
+
#[test]
156
+
fn none_text_returns_none() {
157
+
assert_eq!(parse_start_handle(None), None);
158
+
}
159
+
160
+
#[test]
161
+
fn non_start_command_returns_none() {
162
+
assert_eq!(parse_start_handle(Some("/help")), None);
163
+
}
164
+
165
+
#[test]
166
+
fn payload_with_extra_whitespace_trimmed() {
167
+
assert_eq!(
168
+
parse_start_handle(Some("/start alice_example_com ")),
169
+
Some("alice.example.com".to_string()),
170
+
);
171
+
}
172
+
}
+8
crates/tranquil-pds/src/api/validation.rs
+8
crates/tranquil-pds/src/api/validation.rs
···
349
349
})
350
350
}
351
351
352
+
pub fn is_valid_telegram_username(username: &str) -> bool {
353
+
let clean = username.strip_prefix('@').unwrap_or(username);
354
+
(5..=32).contains(&clean.len())
355
+
&& clean
356
+
.chars()
357
+
.all(|c| c.is_ascii_alphanumeric() || c == '_')
358
+
}
359
+
352
360
#[cfg(test)]
353
361
mod tests {
354
362
use super::*;
+110
-21
crates/tranquil-pds/src/comms/service.rs
+110
-21
crates/tranquil-pds/src/comms/service.rs
···
10
10
CommsChannel, CommsSender, CommsStatus, CommsType, NewComms, SendError, format_message,
11
11
get_strings,
12
12
};
13
-
use tranquil_db_traits::{InfraRepository, QueuedComms, UserRepository};
13
+
use tranquil_db_traits::{InfraRepository, QueuedComms, UserCommsPrefs, UserRepository};
14
14
use uuid::Uuid;
15
15
16
16
pub struct CommsService {
···
75
75
tranquil_db_traits::CommsType::MigrationVerification
76
76
}
77
77
CommsType::ChannelVerification => tranquil_db_traits::CommsType::ChannelVerification,
78
+
CommsType::ChannelVerified => tranquil_db_traits::CommsType::ChannelVerified,
78
79
};
79
80
let id = self
80
81
.infra_repo
···
170
171
tranquil_db_traits::CommsType::ChannelVerification => {
171
172
CommsType::ChannelVerification
172
173
}
174
+
tranquil_db_traits::CommsType::ChannelVerified => CommsType::ChannelVerified,
173
175
},
174
176
status: match item.status {
175
177
tranquil_db_traits::CommsStatus::Pending => CommsStatus::Pending,
···
247
249
}
248
250
}
249
251
252
+
struct ResolvedRecipient {
253
+
channel: tranquil_db_traits::CommsChannel,
254
+
recipient: String,
255
+
}
256
+
257
+
fn resolve_recipient(
258
+
prefs: &UserCommsPrefs,
259
+
channel: tranquil_db_traits::CommsChannel,
260
+
) -> ResolvedRecipient {
261
+
let email_fallback = || ResolvedRecipient {
262
+
channel: tranquil_db_traits::CommsChannel::Email,
263
+
recipient: prefs.email.clone().unwrap_or_default(),
264
+
};
265
+
match channel {
266
+
tranquil_db_traits::CommsChannel::Email => email_fallback(),
267
+
tranquil_db_traits::CommsChannel::Telegram => prefs
268
+
.telegram_chat_id
269
+
.map(|id| ResolvedRecipient {
270
+
channel,
271
+
recipient: id.to_string(),
272
+
})
273
+
.unwrap_or_else(email_fallback),
274
+
tranquil_db_traits::CommsChannel::Discord => prefs
275
+
.discord_id
276
+
.as_ref()
277
+
.filter(|id| !id.is_empty())
278
+
.map(|id| ResolvedRecipient {
279
+
channel,
280
+
recipient: id.clone(),
281
+
})
282
+
.unwrap_or_else(email_fallback),
283
+
tranquil_db_traits::CommsChannel::Signal => prefs
284
+
.signal_number
285
+
.as_ref()
286
+
.filter(|n| !n.is_empty())
287
+
.map(|n| ResolvedRecipient {
288
+
channel,
289
+
recipient: n.clone(),
290
+
})
291
+
.unwrap_or_else(email_fallback),
292
+
}
293
+
}
294
+
250
295
fn channel_from_str(s: &str) -> tranquil_db_traits::CommsChannel {
251
296
match s {
252
297
"discord" => tranquil_db_traits::CommsChannel::Discord,
···
276
321
&[("hostname", hostname), ("handle", &prefs.handle)],
277
322
);
278
323
let subject = format_message(strings.welcome_subject, &[("hostname", hostname)]);
279
-
let channel = prefs.preferred_channel;
324
+
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
280
325
infra_repo
281
326
.enqueue_comms(
282
327
Some(user_id),
283
-
channel,
328
+
resolved.channel,
284
329
CommsType::Welcome,
285
-
&prefs.email.unwrap_or_default(),
330
+
&resolved.recipient,
286
331
Some(&subject),
287
332
&body,
288
333
None,
···
307
352
&[("handle", &prefs.handle), ("code", code)],
308
353
);
309
354
let subject = format_message(strings.password_reset_subject, &[("hostname", hostname)]);
310
-
let channel = prefs.preferred_channel;
355
+
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
311
356
infra_repo
312
357
.enqueue_comms(
313
358
Some(user_id),
314
-
channel,
359
+
resolved.channel,
315
360
CommsType::PasswordReset,
316
-
&prefs.email.unwrap_or_default(),
361
+
&resolved.recipient,
317
362
Some(&subject),
318
363
&body,
319
364
None,
···
471
516
&[("handle", &prefs.handle), ("code", code)],
472
517
);
473
518
let subject = format_message(strings.account_deletion_subject, &[("hostname", hostname)]);
474
-
let channel = prefs.preferred_channel;
519
+
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
475
520
infra_repo
476
521
.enqueue_comms(
477
522
Some(user_id),
478
-
channel,
523
+
resolved.channel,
479
524
CommsType::AccountDeletion,
480
-
&prefs.email.unwrap_or_default(),
525
+
&resolved.recipient,
481
526
Some(&subject),
482
527
&body,
483
528
None,
···
502
547
&[("handle", &prefs.handle), ("token", token)],
503
548
);
504
549
let subject = format_message(strings.plc_operation_subject, &[("hostname", hostname)]);
505
-
let channel = prefs.preferred_channel;
550
+
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
506
551
infra_repo
507
552
.enqueue_comms(
508
553
Some(user_id),
509
-
channel,
554
+
resolved.channel,
510
555
CommsType::PlcOperation,
511
-
&prefs.email.unwrap_or_default(),
556
+
&resolved.recipient,
512
557
Some(&subject),
513
558
&body,
514
559
None,
···
533
578
&[("handle", &prefs.handle), ("url", recovery_url)],
534
579
);
535
580
let subject = format_message(strings.passkey_recovery_subject, &[("hostname", hostname)]);
536
-
let channel = prefs.preferred_channel;
581
+
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
537
582
infra_repo
538
583
.enqueue_comms(
539
584
Some(user_id),
540
-
channel,
585
+
resolved.channel,
541
586
CommsType::PasskeyRecovery,
542
-
&prefs.email.unwrap_or_default(),
587
+
&resolved.recipient,
543
588
Some(&subject),
544
589
&body,
545
590
None,
···
663
708
&[("handle", &prefs.handle), ("code", code)],
664
709
);
665
710
let subject = format_message(strings.two_factor_code_subject, &[("hostname", hostname)]);
666
-
let channel = prefs.preferred_channel;
711
+
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
667
712
infra_repo
668
713
.enqueue_comms(
669
714
Some(user_id),
670
-
channel,
715
+
resolved.channel,
671
716
CommsType::TwoFactorCode,
672
-
&prefs.email.unwrap_or_default(),
717
+
&resolved.recipient,
673
718
Some(&subject),
674
719
&body,
675
720
None,
···
703
748
],
704
749
);
705
750
let subject = format_message(strings.legacy_login_subject, &[("hostname", hostname)]);
751
+
let resolved = resolve_recipient(&prefs, channel);
706
752
infra_repo
707
753
.enqueue_comms(
708
754
Some(user_id),
709
-
channel,
755
+
resolved.channel,
710
756
CommsType::LegacyLoginAlert,
711
-
&prefs.email.unwrap_or_default(),
757
+
&resolved.recipient,
758
+
Some(&subject),
759
+
&body,
760
+
None,
761
+
)
762
+
.await
763
+
}
764
+
765
+
pub async fn enqueue_channel_verified(
766
+
user_repo: &dyn UserRepository,
767
+
infra_repo: &dyn InfraRepository,
768
+
user_id: Uuid,
769
+
channel_name: &str,
770
+
recipient: &str,
771
+
hostname: &str,
772
+
) -> Result<Uuid, DbError> {
773
+
let prefs = user_repo
774
+
.get_comms_prefs(user_id)
775
+
.await?
776
+
.ok_or(DbError::NotFound)?;
777
+
let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en"));
778
+
let display_name = match channel_name {
779
+
"email" => "Email",
780
+
"discord" => "Discord",
781
+
"telegram" => "Telegram",
782
+
"signal" => "Signal",
783
+
other => other,
784
+
};
785
+
let body = format_message(
786
+
strings.channel_verified_body,
787
+
&[
788
+
("handle", &prefs.handle),
789
+
("channel", display_name),
790
+
("hostname", hostname),
791
+
],
792
+
);
793
+
let subject = format_message(strings.channel_verified_subject, &[("hostname", hostname)]);
794
+
let comms_channel = channel_from_str(channel_name);
795
+
infra_repo
796
+
.enqueue_comms(
797
+
Some(user_id),
798
+
comms_channel,
799
+
CommsType::ChannelVerified,
800
+
recipient,
712
801
Some(&subject),
713
802
&body,
714
803
None,
+9
crates/tranquil-pds/src/lib.rs
+9
crates/tranquil-pds/src/lib.rs
···
16
16
pub mod plc;
17
17
pub mod rate_limit;
18
18
pub mod repo;
19
+
pub mod repo_write_lock;
19
20
pub mod scheduled;
20
21
pub mod sso;
21
22
pub mod state;
···
279
280
.route(
280
281
"/_checkEmailVerified",
281
282
post(api::server::check_email_verified),
283
+
)
284
+
.route(
285
+
"/_checkChannelVerified",
286
+
post(api::server::check_channel_verified),
282
287
)
283
288
.route(
284
289
"/com.atproto.server.confirmEmail",
···
638
643
.route("/robots.txt", get(api::server::robots_txt))
639
644
.route("/logo", get(api::server::get_logo))
640
645
.route("/u/{handle}/did.json", get(api::identity::user_did_doc))
646
+
.route(
647
+
"/webhook/telegram",
648
+
post(api::telegram_webhook::handle_telegram_webhook),
649
+
)
641
650
.layer(DefaultBodyLimit::max(util::get_max_blob_size()))
642
651
.layer(middleware::from_fn(metrics::metrics_middleware))
643
652
.layer(
+27
crates/tranquil-pds/src/main.rs
+27
crates/tranquil-pds/src/main.rs
···
78
78
}
79
79
80
80
if let Some(telegram_sender) = TelegramSender::from_env() {
81
+
let secret_token = match std::env::var("TELEGRAM_WEBHOOK_SECRET") {
82
+
Ok(s) => s,
83
+
Err(_) => {
84
+
return Err(
85
+
"TELEGRAM_BOT_TOKEN is set but TELEGRAM_WEBHOOK_SECRET is missing. Both are required for secure Telegram integration.".into()
86
+
);
87
+
}
88
+
};
81
89
info!("Telegram comms enabled");
90
+
match telegram_sender.resolve_bot_username().await {
91
+
Ok(username) => {
92
+
info!(bot_username = %username, "Resolved Telegram bot username");
93
+
tranquil_pds::util::set_telegram_bot_username(username);
94
+
let hostname =
95
+
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
96
+
let webhook_url = format!("https://{}/webhook/telegram", hostname);
97
+
match telegram_sender
98
+
.set_webhook(&webhook_url, Some(&secret_token))
99
+
.await
100
+
{
101
+
Ok(()) => info!(url = %webhook_url, "Telegram webhook registered"),
102
+
Err(e) => warn!("Failed to register Telegram webhook: {}", e),
103
+
}
104
+
}
105
+
Err(e) => {
106
+
warn!("Failed to resolve Telegram bot username: {}", e);
107
+
}
108
+
}
82
109
comms_service = comms_service.register_sender(telegram_sender);
83
110
}
84
111
+180
crates/tranquil-pds/src/repo_write_lock.rs
+180
crates/tranquil-pds/src/repo_write_lock.rs
···
1
+
use std::collections::HashMap;
2
+
use std::sync::Arc;
3
+
use std::time::Duration;
4
+
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
5
+
use uuid::Uuid;
6
+
7
+
const SWEEP_INTERVAL: Duration = Duration::from_secs(300);
8
+
9
+
pub struct RepoWriteLocks {
10
+
locks: Arc<RwLock<HashMap<Uuid, Arc<Mutex<()>>>>>,
11
+
}
12
+
13
+
impl Default for RepoWriteLocks {
14
+
fn default() -> Self {
15
+
Self::new()
16
+
}
17
+
}
18
+
19
+
impl RepoWriteLocks {
20
+
pub fn new() -> Self {
21
+
let locks = Arc::new(RwLock::new(HashMap::new()));
22
+
let sweep_locks = Arc::clone(&locks);
23
+
tokio::spawn(async move {
24
+
sweep_loop(sweep_locks).await;
25
+
});
26
+
Self { locks }
27
+
}
28
+
29
+
pub async fn lock(&self, user_id: Uuid) -> OwnedMutexGuard<()> {
30
+
let mutex = {
31
+
let read_guard = self.locks.read().await;
32
+
read_guard.get(&user_id).cloned()
33
+
};
34
+
35
+
match mutex {
36
+
Some(m) => m.lock_owned().await,
37
+
None => {
38
+
let mut write_guard = self.locks.write().await;
39
+
let mutex = write_guard
40
+
.entry(user_id)
41
+
.or_insert_with(|| Arc::new(Mutex::new(())))
42
+
.clone();
43
+
drop(write_guard);
44
+
mutex.lock_owned().await
45
+
}
46
+
}
47
+
}
48
+
}
49
+
50
+
async fn sweep_loop(locks: Arc<RwLock<HashMap<Uuid, Arc<Mutex<()>>>>>) {
51
+
tokio::time::sleep(SWEEP_INTERVAL).await;
52
+
let mut write_guard = locks.write().await;
53
+
let before = write_guard.len();
54
+
write_guard.retain(|_, mutex| Arc::strong_count(mutex) > 1);
55
+
let evicted = before - write_guard.len();
56
+
if evicted > 0 {
57
+
tracing::debug!(
58
+
evicted,
59
+
remaining = write_guard.len(),
60
+
"repo write lock sweep"
61
+
);
62
+
}
63
+
drop(write_guard);
64
+
Box::pin(sweep_loop(locks)).await;
65
+
}
66
+
67
+
#[cfg(test)]
68
+
mod tests {
69
+
use super::*;
70
+
use std::sync::atomic::{AtomicU32, Ordering};
71
+
use std::time::Duration;
72
+
73
+
#[tokio::test]
74
+
async fn test_locks_serialize_same_user() {
75
+
let locks = Arc::new(RepoWriteLocks::new());
76
+
let user_id = Uuid::new_v4();
77
+
let counter = Arc::new(AtomicU32::new(0));
78
+
let max_concurrent = Arc::new(AtomicU32::new(0));
79
+
80
+
let handles: Vec<_> = (0..10)
81
+
.map(|_| {
82
+
let locks = locks.clone();
83
+
let counter = counter.clone();
84
+
let max_concurrent = max_concurrent.clone();
85
+
86
+
tokio::spawn(async move {
87
+
let _guard = locks.lock(user_id).await;
88
+
let current = counter.fetch_add(1, Ordering::SeqCst) + 1;
89
+
max_concurrent.fetch_max(current, Ordering::SeqCst);
90
+
tokio::time::sleep(Duration::from_millis(1)).await;
91
+
counter.fetch_sub(1, Ordering::SeqCst);
92
+
})
93
+
})
94
+
.collect();
95
+
96
+
futures::future::join_all(handles).await;
97
+
98
+
assert_eq!(
99
+
max_concurrent.load(Ordering::SeqCst),
100
+
1,
101
+
"Only one task should hold the lock at a time for same user"
102
+
);
103
+
}
104
+
105
+
#[tokio::test]
106
+
async fn test_different_users_can_run_concurrently() {
107
+
let locks = Arc::new(RepoWriteLocks::new());
108
+
let user1 = Uuid::new_v4();
109
+
let user2 = Uuid::new_v4();
110
+
let concurrent_count = Arc::new(AtomicU32::new(0));
111
+
let max_concurrent = Arc::new(AtomicU32::new(0));
112
+
113
+
let locks1 = locks.clone();
114
+
let count1 = concurrent_count.clone();
115
+
let max1 = max_concurrent.clone();
116
+
let handle1 = tokio::spawn(async move {
117
+
let _guard = locks1.lock(user1).await;
118
+
let current = count1.fetch_add(1, Ordering::SeqCst) + 1;
119
+
max1.fetch_max(current, Ordering::SeqCst);
120
+
tokio::time::sleep(Duration::from_millis(50)).await;
121
+
count1.fetch_sub(1, Ordering::SeqCst);
122
+
});
123
+
124
+
tokio::time::sleep(Duration::from_millis(10)).await;
125
+
126
+
let locks2 = locks.clone();
127
+
let count2 = concurrent_count.clone();
128
+
let max2 = max_concurrent.clone();
129
+
let handle2 = tokio::spawn(async move {
130
+
let _guard = locks2.lock(user2).await;
131
+
let current = count2.fetch_add(1, Ordering::SeqCst) + 1;
132
+
max2.fetch_max(current, Ordering::SeqCst);
133
+
tokio::time::sleep(Duration::from_millis(50)).await;
134
+
count2.fetch_sub(1, Ordering::SeqCst);
135
+
});
136
+
137
+
handle1.await.unwrap();
138
+
handle2.await.unwrap();
139
+
140
+
assert_eq!(
141
+
max_concurrent.load(Ordering::SeqCst),
142
+
2,
143
+
"Different users should be able to run concurrently"
144
+
);
145
+
}
146
+
147
+
#[tokio::test]
148
+
async fn test_sweep_evicts_idle_entries() {
149
+
let locks = Arc::new(RwLock::new(HashMap::new()));
150
+
let user_id = Uuid::new_v4();
151
+
152
+
{
153
+
let mut write_guard = locks.write().await;
154
+
write_guard.insert(user_id, Arc::new(Mutex::new(())));
155
+
}
156
+
157
+
assert_eq!(locks.read().await.len(), 1);
158
+
159
+
let mut write_guard = locks.write().await;
160
+
write_guard.retain(|_, mutex| Arc::strong_count(mutex) > 1);
161
+
assert_eq!(write_guard.len(), 0, "Idle entry should be evicted");
162
+
}
163
+
164
+
#[tokio::test]
165
+
async fn test_sweep_preserves_active_entries() {
166
+
let locks = Arc::new(RwLock::new(HashMap::new()));
167
+
let user_id = Uuid::new_v4();
168
+
let active_mutex = Arc::new(Mutex::new(()));
169
+
let _held_ref = active_mutex.clone();
170
+
171
+
{
172
+
let mut write_guard = locks.write().await;
173
+
write_guard.insert(user_id, active_mutex);
174
+
}
175
+
176
+
let mut write_guard = locks.write().await;
177
+
write_guard.retain(|_, mutex| Arc::strong_count(mutex) > 1);
178
+
assert_eq!(write_guard.len(), 1, "Active entry should be preserved");
179
+
}
180
+
}
+12
-4
crates/tranquil-pds/src/sso/endpoints.rs
+12
-4
crates/tranquil-pds/src/sso/endpoints.rs
···
119
119
let auth_header = headers
120
120
.get(axum::http::header::AUTHORIZATION)
121
121
.and_then(|v| v.to_str().ok());
122
-
let extracted = extract_auth_token_from_header(auth_header)
123
-
.ok_or(ApiError::SsoNotAuthenticated)?;
122
+
let extracted =
123
+
extract_auth_token_from_header(auth_header).ok_or(ApiError::SsoNotAuthenticated)?;
124
124
let auth_user = validate_bearer_token_cached(
125
125
state.user_repo.as_ref(),
126
126
state.cache.as_ref(),
···
899
899
_ => return Err(ApiError::MissingDiscordId),
900
900
},
901
901
"telegram" => match &input.telegram_username {
902
-
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
902
+
Some(username) if !username.trim().is_empty() => {
903
+
let clean = username.trim().trim_start_matches('@');
904
+
if !crate::api::validation::is_valid_telegram_username(clean) {
905
+
return Err(ApiError::InvalidRequest(
906
+
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
907
+
));
908
+
}
909
+
clean.to_string()
910
+
}
903
911
_ => return Err(ApiError::MissingTelegramUsername),
904
912
},
905
913
"signal" => match &input.signal_number {
···
1104
1112
telegram_username: input
1105
1113
.telegram_username
1106
1114
.clone()
1107
-
.map(|s| s.trim().to_string())
1115
+
.map(|s| s.trim().trim_start_matches('@').to_string())
1108
1116
.filter(|s| !s.is_empty()),
1109
1117
signal_number: input
1110
1118
.signal_number
+4
crates/tranquil-pds/src/state.rs
+4
crates/tranquil-pds/src/state.rs
···
5
5
use crate::config::AuthConfig;
6
6
use crate::rate_limit::RateLimiters;
7
7
use crate::repo::PostgresBlockStore;
8
+
use crate::repo_write_lock::RepoWriteLocks;
8
9
use crate::sso::{SsoConfig, SsoManager};
9
10
use crate::storage::{BackupStorage, BlobStorage, create_backup_storage, create_blob_storage};
10
11
use crate::sync::firehose::SequencedEvent;
···
38
39
pub backup_storage: Option<Arc<dyn BackupStorage>>,
39
40
pub firehose_tx: broadcast::Sender<SequencedEvent>,
40
41
pub rate_limiters: Arc<RateLimiters>,
42
+
pub repo_write_locks: Arc<RepoWriteLocks>,
41
43
pub circuit_breakers: Arc<CircuitBreakers>,
42
44
pub cache: Arc<dyn Cache>,
43
45
pub distributed_rate_limiter: Arc<dyn DistributedRateLimiter>,
···
181
183
182
184
let (firehose_tx, _) = broadcast::channel(firehose_buffer_size);
183
185
let rate_limiters = Arc::new(RateLimiters::new());
186
+
let repo_write_locks = Arc::new(RepoWriteLocks::new());
184
187
let circuit_breakers = Arc::new(CircuitBreakers::new());
185
188
let (cache, distributed_rate_limiter) = create_cache().await;
186
189
let did_resolver = Arc::new(DidResolver::new());
···
209
212
backup_storage,
210
213
firehose_tx,
211
214
rate_limiters,
215
+
repo_write_locks,
212
216
circuit_breakers,
213
217
cache,
214
218
distributed_rate_limiter,
+9
crates/tranquil-pds/src/util.rs
+9
crates/tranquil-pds/src/util.rs
···
14
14
static MAX_BLOB_SIZE: OnceLock<usize> = OnceLock::new();
15
15
static PDS_HOSTNAME: OnceLock<String> = OnceLock::new();
16
16
static PDS_HOSTNAME_WITHOUT_PORT: OnceLock<String> = OnceLock::new();
17
+
static TELEGRAM_BOT_USERNAME: OnceLock<String> = OnceLock::new();
17
18
18
19
pub fn get_max_blob_size() -> usize {
19
20
*MAX_BLOB_SIZE.get_or_init(|| {
···
102
103
let hostname = pds_hostname();
103
104
hostname.split(':').next().unwrap_or(hostname).to_string()
104
105
})
106
+
}
107
+
108
+
pub fn set_telegram_bot_username(username: String) {
109
+
TELEGRAM_BOT_USERNAME.set(username).ok();
110
+
}
111
+
112
+
pub fn telegram_bot_username() -> Option<&'static str> {
113
+
TELEGRAM_BOT_USERNAME.get().map(|s| s.as_str())
105
114
}
106
115
107
116
pub fn pds_public_url() -> String {
+228
crates/tranquil-pds/tests/helpers/mod.rs
+228
crates/tranquil-pds/tests/helpers/mod.rs
···
4
4
5
5
pub use crate::common::*;
6
6
7
+
#[allow(dead_code)]
8
+
pub async fn paginate_records(
9
+
client: &reqwest::Client,
10
+
base: &str,
11
+
jwt: &str,
12
+
did: &str,
13
+
collection: &str,
14
+
limit: usize,
15
+
) -> Vec<Value> {
16
+
paginate_records_inner(client, base, jwt, did, collection, limit, None, Vec::new()).await
17
+
}
18
+
19
+
#[allow(clippy::too_many_arguments)]
20
+
async fn paginate_records_inner(
21
+
client: &reqwest::Client,
22
+
base: &str,
23
+
jwt: &str,
24
+
did: &str,
25
+
collection: &str,
26
+
limit: usize,
27
+
cursor: Option<String>,
28
+
mut acc: Vec<Value>,
29
+
) -> Vec<Value> {
30
+
let limit_str = limit.to_string();
31
+
let mut query: Vec<(&str, &str)> = vec![
32
+
("repo", did),
33
+
("collection", collection),
34
+
("limit", &limit_str),
35
+
];
36
+
if let Some(ref c) = cursor {
37
+
query.push(("cursor", c.as_str()));
38
+
}
39
+
40
+
let res = client
41
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
42
+
.bearer_auth(jwt)
43
+
.query(&query)
44
+
.send()
45
+
.await;
46
+
47
+
let Ok(response) = res else { return acc };
48
+
let Ok(body) = response.json::<Value>().await else {
49
+
return acc;
50
+
};
51
+
52
+
let Some(records) = body["records"].as_array() else {
53
+
return acc;
54
+
};
55
+
acc.extend(records.iter().cloned());
56
+
57
+
match body["cursor"].as_str() {
58
+
Some(next) => {
59
+
Box::pin(paginate_records_inner(
60
+
client,
61
+
base,
62
+
jwt,
63
+
did,
64
+
collection,
65
+
limit,
66
+
Some(next.to_string()),
67
+
acc,
68
+
))
69
+
.await
70
+
}
71
+
None => acc,
72
+
}
73
+
}
74
+
75
+
#[allow(dead_code)]
76
+
pub async fn count_records(
77
+
client: &reqwest::Client,
78
+
base: &str,
79
+
jwt: &str,
80
+
did: &str,
81
+
collection: &str,
82
+
) -> usize {
83
+
paginate_records(client, base, jwt, did, collection, 100)
84
+
.await
85
+
.len()
86
+
}
87
+
7
88
fn unique_id() -> String {
8
89
uuid::Uuid::new_v4().simple().to_string()[..12].to_string()
9
90
}
···
246
327
.await
247
328
.expect("Failed to update deactivated_at");
248
329
}
330
+
331
+
#[allow(dead_code)]
332
+
pub fn make_cid(data: &[u8]) -> cid::Cid {
333
+
use sha2::{Digest, Sha256};
334
+
let hash = Sha256::digest(data);
335
+
let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap();
336
+
cid::Cid::new_v1(0x71, multihash)
337
+
}
338
+
339
+
#[allow(dead_code)]
340
+
pub fn write_varint(buf: &mut Vec<u8>, value: u64) {
341
+
buf.extend(encode_varint_bytes(value));
342
+
}
343
+
344
+
fn encode_varint_bytes(value: u64) -> Vec<u8> {
345
+
match value < 0x80 {
346
+
true => vec![value as u8],
347
+
false => {
348
+
let mut rest = encode_varint_bytes(value >> 7);
349
+
rest.insert(0, ((value & 0x7F) as u8) | 0x80);
350
+
rest
351
+
}
352
+
}
353
+
}
354
+
355
+
#[allow(dead_code)]
356
+
pub fn encode_car_block(cid: &cid::Cid, data: &[u8]) -> Vec<u8> {
357
+
let cid_bytes = cid.to_bytes();
358
+
let mut result = Vec::new();
359
+
write_varint(&mut result, (cid_bytes.len() + data.len()) as u64);
360
+
result.extend_from_slice(&cid_bytes);
361
+
result.extend_from_slice(data);
362
+
result
363
+
}
364
+
365
+
#[allow(dead_code)]
366
+
pub fn create_test_record() -> (Vec<u8>, cid::Cid) {
367
+
use ipld_core::ipld::Ipld;
368
+
use std::collections::BTreeMap;
369
+
let record = Ipld::Map(BTreeMap::from([
370
+
(
371
+
"$type".to_string(),
372
+
Ipld::String("app.bsky.feed.post".to_string()),
373
+
),
374
+
(
375
+
"text".to_string(),
376
+
Ipld::String("Test post for verification".to_string()),
377
+
),
378
+
(
379
+
"createdAt".to_string(),
380
+
Ipld::String("2024-01-01T00:00:00Z".to_string()),
381
+
),
382
+
]));
383
+
let bytes = serde_ipld_dagcbor::to_vec(&record).unwrap();
384
+
let cid = make_cid(&bytes);
385
+
(bytes, cid)
386
+
}
387
+
388
+
#[allow(dead_code)]
389
+
pub fn create_mst_node(entries: Vec<(String, cid::Cid)>) -> (Vec<u8>, cid::Cid) {
390
+
use ipld_core::ipld::Ipld;
391
+
use std::collections::BTreeMap;
392
+
let ipld_entries: Vec<Ipld> = entries
393
+
.into_iter()
394
+
.map(|(key, value_cid)| {
395
+
Ipld::Map(BTreeMap::from([
396
+
("k".to_string(), Ipld::Bytes(key.into_bytes())),
397
+
("v".to_string(), Ipld::Link(value_cid)),
398
+
("p".to_string(), Ipld::Integer(0)),
399
+
]))
400
+
})
401
+
.collect();
402
+
let node = Ipld::Map(BTreeMap::from([(
403
+
"e".to_string(),
404
+
Ipld::List(ipld_entries),
405
+
)]));
406
+
let bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
407
+
let cid = make_cid(&bytes);
408
+
(bytes, cid)
409
+
}
410
+
411
+
#[allow(dead_code)]
412
+
pub fn create_car_signed_commit(
413
+
did: &str,
414
+
data_cid: &cid::Cid,
415
+
signing_key: &k256::ecdsa::SigningKey,
416
+
) -> (Vec<u8>, cid::Cid) {
417
+
use jacquard_common::types::{integer::LimitedU32, string::Tid};
418
+
use jacquard_repo::commit::Commit;
419
+
let rev = Tid::now(LimitedU32::MIN);
420
+
let did = jacquard_common::types::string::Did::new(did).expect("valid DID");
421
+
let unsigned = Commit::new_unsigned(did, *data_cid, rev, None);
422
+
let signed = unsigned.sign(signing_key).expect("signing failed");
423
+
let signed_bytes = signed.to_cbor().expect("serialization failed");
424
+
let cid = make_cid(&signed_bytes);
425
+
(signed_bytes, cid)
426
+
}
427
+
428
+
#[allow(dead_code)]
429
+
pub fn build_car_with_signature(
430
+
did: &str,
431
+
signing_key: &k256::ecdsa::SigningKey,
432
+
) -> (Vec<u8>, cid::Cid) {
433
+
let (record_bytes, record_cid) = create_test_record();
434
+
let (mst_bytes, mst_cid) =
435
+
create_mst_node(vec![("app.bsky.feed.post/test123".to_string(), record_cid)]);
436
+
let (commit_bytes, commit_cid) = create_car_signed_commit(did, &mst_cid, signing_key);
437
+
let header = iroh_car::CarHeader::new_v1(vec![commit_cid]);
438
+
let header_bytes = header.encode().unwrap();
439
+
let mut car = Vec::new();
440
+
write_varint(&mut car, header_bytes.len() as u64);
441
+
car.extend_from_slice(&header_bytes);
442
+
car.extend(encode_car_block(&commit_cid, &commit_bytes));
443
+
car.extend(encode_car_block(&mst_cid, &mst_bytes));
444
+
car.extend(encode_car_block(&record_cid, &record_bytes));
445
+
(car, commit_cid)
446
+
}
447
+
448
+
#[allow(dead_code)]
449
+
pub fn get_multikey_from_signing_key(signing_key: &k256::ecdsa::SigningKey) -> String {
450
+
let public_key = signing_key.verifying_key();
451
+
let compressed = public_key.to_sec1_bytes();
452
+
let buf: Vec<u8> = encode_varint_bytes(0xE7)
453
+
.into_iter()
454
+
.chain(compressed.iter().copied())
455
+
.collect();
456
+
multibase::encode(multibase::Base::Base58Btc, buf)
457
+
}
458
+
459
+
#[allow(dead_code)]
460
+
pub async fn get_user_signing_key(did: &str) -> Option<Vec<u8>> {
461
+
let db_url = get_db_connection_string().await;
462
+
let pool = sqlx::PgPool::connect(&db_url).await.ok()?;
463
+
let row = sqlx::query!(
464
+
r#"
465
+
SELECT k.key_bytes, k.encryption_version
466
+
FROM user_keys k
467
+
JOIN users u ON k.user_id = u.id
468
+
WHERE u.did = $1
469
+
"#,
470
+
did
471
+
)
472
+
.fetch_optional(&pool)
473
+
.await
474
+
.ok()??;
475
+
tranquil_pds::config::decrypt_key(&row.key_bytes, row.encryption_version).ok()
476
+
}
+3
-136
crates/tranquil-pds/tests/import_with_verification.rs
+3
-136
crates/tranquil-pds/tests/import_with_verification.rs
···
1
1
mod common;
2
-
use cid::Cid;
2
+
mod helpers;
3
3
use common::*;
4
-
use ipld_core::ipld::Ipld;
5
-
use jacquard_common::types::{integer::LimitedU32, string::Tid};
6
-
use jacquard_repo::commit::Commit;
4
+
use helpers::*;
7
5
use k256::ecdsa::SigningKey;
8
6
use reqwest::StatusCode;
9
7
use serde_json::json;
10
-
use sha2::{Digest, Sha256};
11
-
use sqlx::PgPool;
12
-
use std::collections::BTreeMap;
13
8
use wiremock::matchers::{method, path};
14
9
use wiremock::{Mock, MockServer, ResponseTemplate};
15
10
16
-
fn make_cid(data: &[u8]) -> Cid {
17
-
let mut hasher = Sha256::new();
18
-
hasher.update(data);
19
-
let hash = hasher.finalize();
20
-
let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap();
21
-
Cid::new_v1(0x71, multihash)
22
-
}
23
-
24
-
fn write_varint(buf: &mut Vec<u8>, mut value: u64) {
25
-
loop {
26
-
let mut byte = (value & 0x7F) as u8;
27
-
value >>= 7;
28
-
if value != 0 {
29
-
byte |= 0x80;
30
-
}
31
-
buf.push(byte);
32
-
if value == 0 {
33
-
break;
34
-
}
35
-
}
36
-
}
37
-
38
-
fn encode_car_block(cid: &Cid, data: &[u8]) -> Vec<u8> {
39
-
let cid_bytes = cid.to_bytes();
40
-
let mut result = Vec::new();
41
-
write_varint(&mut result, (cid_bytes.len() + data.len()) as u64);
42
-
result.extend_from_slice(&cid_bytes);
43
-
result.extend_from_slice(data);
44
-
result
45
-
}
46
-
47
-
fn get_multikey_from_signing_key(signing_key: &SigningKey) -> String {
48
-
let public_key = signing_key.verifying_key();
49
-
let compressed = public_key.to_sec1_bytes();
50
-
fn encode_uvarint(mut x: u64) -> Vec<u8> {
51
-
let mut out = Vec::new();
52
-
while x >= 0x80 {
53
-
out.push(((x as u8) & 0x7F) | 0x80);
54
-
x >>= 7;
55
-
}
56
-
out.push(x as u8);
57
-
out
58
-
}
59
-
let mut buf = encode_uvarint(0xE7);
60
-
buf.extend_from_slice(&compressed);
61
-
multibase::encode(multibase::Base::Base58Btc, buf)
62
-
}
63
-
64
11
fn create_did_document(
65
12
did: &str,
66
13
handle: &str,
···
89
36
})
90
37
}
91
38
92
-
fn create_signed_commit(did: &str, data_cid: &Cid, signing_key: &SigningKey) -> (Vec<u8>, Cid) {
93
-
let rev = Tid::now(LimitedU32::MIN);
94
-
let did = jacquard_common::types::string::Did::new(did).expect("valid DID");
95
-
let unsigned = Commit::new_unsigned(did, *data_cid, rev, None);
96
-
let signed = unsigned.sign(signing_key).expect("signing failed");
97
-
let signed_bytes = signed.to_cbor().expect("serialization failed");
98
-
let cid = make_cid(&signed_bytes);
99
-
(signed_bytes, cid)
100
-
}
101
-
102
-
fn create_mst_node(entries: Vec<(String, Cid)>) -> (Vec<u8>, Cid) {
103
-
let ipld_entries: Vec<Ipld> = entries
104
-
.into_iter()
105
-
.map(|(key, value_cid)| {
106
-
Ipld::Map(BTreeMap::from([
107
-
("k".to_string(), Ipld::Bytes(key.into_bytes())),
108
-
("v".to_string(), Ipld::Link(value_cid)),
109
-
("p".to_string(), Ipld::Integer(0)),
110
-
]))
111
-
})
112
-
.collect();
113
-
let node = Ipld::Map(BTreeMap::from([(
114
-
"e".to_string(),
115
-
Ipld::List(ipld_entries),
116
-
)]));
117
-
let bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
118
-
let cid = make_cid(&bytes);
119
-
(bytes, cid)
120
-
}
121
-
122
-
fn create_record() -> (Vec<u8>, Cid) {
123
-
let record = Ipld::Map(BTreeMap::from([
124
-
(
125
-
"$type".to_string(),
126
-
Ipld::String("app.bsky.feed.post".to_string()),
127
-
),
128
-
(
129
-
"text".to_string(),
130
-
Ipld::String("Test post for verification".to_string()),
131
-
),
132
-
(
133
-
"createdAt".to_string(),
134
-
Ipld::String("2024-01-01T00:00:00Z".to_string()),
135
-
),
136
-
]));
137
-
let bytes = serde_ipld_dagcbor::to_vec(&record).unwrap();
138
-
let cid = make_cid(&bytes);
139
-
(bytes, cid)
140
-
}
141
-
fn build_car_with_signature(did: &str, signing_key: &SigningKey) -> (Vec<u8>, Cid) {
142
-
let (record_bytes, record_cid) = create_record();
143
-
let (mst_bytes, mst_cid) =
144
-
create_mst_node(vec![("app.bsky.feed.post/test123".to_string(), record_cid)]);
145
-
let (commit_bytes, commit_cid) = create_signed_commit(did, &mst_cid, signing_key);
146
-
let header = iroh_car::CarHeader::new_v1(vec![commit_cid]);
147
-
let header_bytes = header.encode().unwrap();
148
-
let mut car = Vec::new();
149
-
write_varint(&mut car, header_bytes.len() as u64);
150
-
car.extend_from_slice(&header_bytes);
151
-
car.extend(encode_car_block(&commit_cid, &commit_bytes));
152
-
car.extend(encode_car_block(&mst_cid, &mst_bytes));
153
-
car.extend(encode_car_block(&record_cid, &record_bytes));
154
-
(car, commit_cid)
155
-
}
156
39
async fn setup_mock_plc_directory(did: &str, did_doc: serde_json::Value) -> MockServer {
157
40
let mock_server = MockServer::start().await;
158
41
let did_encoded = urlencoding::encode(did);
···
164
47
.await;
165
48
mock_server
166
49
}
167
-
async fn get_user_signing_key(did: &str) -> Option<Vec<u8>> {
168
-
let db_url = get_db_connection_string().await;
169
-
let pool = PgPool::connect(&db_url).await.ok()?;
170
-
let row = sqlx::query!(
171
-
r#"
172
-
SELECT k.key_bytes, k.encryption_version
173
-
FROM user_keys k
174
-
JOIN users u ON k.user_id = u.id
175
-
WHERE u.did = $1
176
-
"#,
177
-
did
178
-
)
179
-
.fetch_optional(&pool)
180
-
.await
181
-
.ok()??;
182
-
tranquil_pds::config::decrypt_key(&row.key_bytes, row.encryption_version).ok()
183
-
}
50
+
184
51
#[tokio::test]
185
52
#[ignore = "requires exclusive env var access; run with: cargo test test_import_with_valid_signature_and_mock_plc -- --ignored --test-threads=1"]
186
53
async fn test_import_with_valid_signature_and_mock_plc() {
+2013
crates/tranquil-pds/tests/whole_story.rs
+2013
crates/tranquil-pds/tests/whole_story.rs
···
1
+
mod common;
2
+
mod helpers;
3
+
4
+
use chrono::Utc;
5
+
use common::*;
6
+
use futures::{StreamExt, future::join_all};
7
+
use helpers::*;
8
+
use k256::ecdsa::SigningKey;
9
+
use reqwest::{StatusCode, header};
10
+
use serde_json::{Value, json};
11
+
12
+
#[tokio::test]
13
+
async fn test_complete_user_journey_signup_to_deletion() {
14
+
let client = client();
15
+
let base = base_url().await;
16
+
let uid = uuid::Uuid::new_v4().simple().to_string();
17
+
let handle = format!("journey{}", &uid[..8]);
18
+
let email = format!("journey{}@test.com", &uid[..8]);
19
+
let password = "JourneyPass123!";
20
+
21
+
let create_res = client
22
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
23
+
.json(&json!({
24
+
"handle": handle,
25
+
"email": email,
26
+
"password": password
27
+
}))
28
+
.send()
29
+
.await
30
+
.expect("Account creation failed");
31
+
assert_eq!(create_res.status(), StatusCode::OK);
32
+
let account: Value = create_res.json().await.unwrap();
33
+
let did = account["did"].as_str().unwrap().to_string();
34
+
35
+
let jwt = verify_new_account(&client, &did).await;
36
+
37
+
let blob_data = b"This is my avatar image data for the complete journey test";
38
+
let upload_res = client
39
+
.post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base))
40
+
.header(header::CONTENT_TYPE, "image/png")
41
+
.bearer_auth(&jwt)
42
+
.body(blob_data.to_vec())
43
+
.send()
44
+
.await
45
+
.expect("Blob upload failed");
46
+
assert_eq!(upload_res.status(), StatusCode::OK);
47
+
let upload_body: Value = upload_res.json().await.unwrap();
48
+
let avatar_blob = upload_body["blob"].clone();
49
+
50
+
let profile_res = client
51
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base))
52
+
.bearer_auth(&jwt)
53
+
.json(&json!({
54
+
"repo": did,
55
+
"collection": "app.bsky.actor.profile",
56
+
"rkey": "self",
57
+
"record": {
58
+
"$type": "app.bsky.actor.profile",
59
+
"displayName": "Journey Test User",
60
+
"description": "Testing the complete user journey",
61
+
"avatar": avatar_blob
62
+
}
63
+
}))
64
+
.send()
65
+
.await
66
+
.expect("Profile creation failed");
67
+
assert_eq!(profile_res.status(), StatusCode::OK);
68
+
69
+
let post1_res = client
70
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
71
+
.bearer_auth(&jwt)
72
+
.json(&json!({
73
+
"repo": did,
74
+
"collection": "app.bsky.feed.post",
75
+
"record": {
76
+
"$type": "app.bsky.feed.post",
77
+
"text": "My first post on this journey!",
78
+
"createdAt": Utc::now().to_rfc3339()
79
+
}
80
+
}))
81
+
.send()
82
+
.await
83
+
.expect("First post failed");
84
+
assert_eq!(post1_res.status(), StatusCode::OK);
85
+
let post1_body: Value = post1_res.json().await.unwrap();
86
+
let post1_uri = post1_body["uri"].as_str().unwrap();
87
+
let post1_cid = post1_body["cid"].as_str().unwrap();
88
+
89
+
let post2_res = client
90
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
91
+
.bearer_auth(&jwt)
92
+
.json(&json!({
93
+
"repo": did,
94
+
"collection": "app.bsky.feed.post",
95
+
"record": {
96
+
"$type": "app.bsky.feed.post",
97
+
"text": "Second post in my journey",
98
+
"createdAt": Utc::now().to_rfc3339()
99
+
}
100
+
}))
101
+
.send()
102
+
.await
103
+
.expect("Second post failed");
104
+
assert_eq!(post2_res.status(), StatusCode::OK);
105
+
106
+
let list_res = client
107
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
108
+
.bearer_auth(&jwt)
109
+
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
110
+
.send()
111
+
.await
112
+
.expect("List records failed");
113
+
assert_eq!(list_res.status(), StatusCode::OK);
114
+
let list_body: Value = list_res.json().await.unwrap();
115
+
assert_eq!(list_body["records"].as_array().unwrap().len(), 2);
116
+
117
+
let post1_rkey = post1_uri.split('/').next_back().unwrap();
118
+
let edit_res = client
119
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base))
120
+
.bearer_auth(&jwt)
121
+
.json(&json!({
122
+
"repo": did,
123
+
"collection": "app.bsky.feed.post",
124
+
"rkey": post1_rkey,
125
+
"record": {
126
+
"$type": "app.bsky.feed.post",
127
+
"text": "My first post on this journey! (edited)",
128
+
"createdAt": Utc::now().to_rfc3339()
129
+
},
130
+
"swapRecord": post1_cid
131
+
}))
132
+
.send()
133
+
.await
134
+
.expect("Edit post failed");
135
+
assert_eq!(edit_res.status(), StatusCode::OK);
136
+
137
+
let backup_res = client
138
+
.post(format!("{}/xrpc/_backup.createBackup", base))
139
+
.bearer_auth(&jwt)
140
+
.send()
141
+
.await
142
+
.expect("Backup creation failed");
143
+
assert_eq!(backup_res.status(), StatusCode::OK);
144
+
let backup_body: Value = backup_res.json().await.unwrap();
145
+
let backup_id = backup_body["id"].as_str().unwrap();
146
+
147
+
let download_res = client
148
+
.get(format!("{}/xrpc/_backup.getBackup?id={}", base, backup_id))
149
+
.bearer_auth(&jwt)
150
+
.send()
151
+
.await
152
+
.expect("Backup download failed");
153
+
assert_eq!(download_res.status(), StatusCode::OK);
154
+
let backup_bytes = download_res.bytes().await.unwrap();
155
+
assert!(backup_bytes.len() > 100, "Backup should have content");
156
+
157
+
let delete_res = client
158
+
.post(format!("{}/xrpc/com.atproto.server.deleteSession", base))
159
+
.bearer_auth(&jwt)
160
+
.send()
161
+
.await
162
+
.expect("Logout failed");
163
+
assert!(delete_res.status() == StatusCode::OK || delete_res.status() == StatusCode::NO_CONTENT);
164
+
165
+
let login_res = client
166
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
167
+
.json(&json!({
168
+
"identifier": handle,
169
+
"password": password
170
+
}))
171
+
.send()
172
+
.await
173
+
.expect("Re-login failed");
174
+
assert_eq!(login_res.status(), StatusCode::OK);
175
+
let login_body: Value = login_res.json().await.unwrap();
176
+
let new_jwt = login_body["accessJwt"].as_str().unwrap();
177
+
178
+
let verify_res = client
179
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
180
+
.bearer_auth(new_jwt)
181
+
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
182
+
.send()
183
+
.await
184
+
.expect("Verify after re-login failed");
185
+
assert_eq!(verify_res.status(), StatusCode::OK);
186
+
let verify_body: Value = verify_res.json().await.unwrap();
187
+
assert_eq!(verify_body["records"].as_array().unwrap().len(), 2);
188
+
189
+
let request_delete_res = client
190
+
.post(format!(
191
+
"{}/xrpc/com.atproto.server.requestAccountDelete",
192
+
base
193
+
))
194
+
.bearer_auth(new_jwt)
195
+
.send()
196
+
.await
197
+
.expect("Request delete failed");
198
+
assert_eq!(request_delete_res.status(), StatusCode::OK);
199
+
200
+
let pool = get_test_db_pool().await;
201
+
let row = sqlx::query!(
202
+
"SELECT token FROM account_deletion_requests WHERE did = $1",
203
+
did
204
+
)
205
+
.fetch_one(pool)
206
+
.await
207
+
.expect("Failed to get deletion token");
208
+
209
+
let final_delete_res = client
210
+
.post(format!("{}/xrpc/com.atproto.server.deleteAccount", base))
211
+
.json(&json!({
212
+
"did": did,
213
+
"password": password,
214
+
"token": row.token
215
+
}))
216
+
.send()
217
+
.await
218
+
.expect("Final delete failed");
219
+
assert_eq!(final_delete_res.status(), StatusCode::OK);
220
+
221
+
let user_gone = sqlx::query!("SELECT id FROM users WHERE did = $1", did)
222
+
.fetch_optional(pool)
223
+
.await
224
+
.expect("Failed to check user");
225
+
assert!(user_gone.is_none(), "User should be deleted");
226
+
}
227
+
228
+
#[tokio::test]
229
+
async fn test_multi_user_social_graph_lifecycle() {
230
+
let client = client();
231
+
let base = base_url().await;
232
+
233
+
let (alice_did, alice_jwt) = setup_new_user("alice-social").await;
234
+
let (bob_did, bob_jwt) = setup_new_user("bob-social").await;
235
+
let (carol_did, carol_jwt) = setup_new_user("carol-social").await;
236
+
237
+
let (alice_post_uri, alice_post_cid) =
238
+
create_post(&client, &alice_did, &alice_jwt, "Hello from Alice!").await;
239
+
240
+
let (bob_post_uri, bob_post_cid) =
241
+
create_post(&client, &bob_did, &bob_jwt, "Hello from Bob!").await;
242
+
243
+
let (_bob_follows_alice_uri, _) = create_follow(&client, &bob_did, &bob_jwt, &alice_did).await;
244
+
let (_carol_follows_alice_uri, _) =
245
+
create_follow(&client, &carol_did, &carol_jwt, &alice_did).await;
246
+
let (_carol_follows_bob_uri, _) =
247
+
create_follow(&client, &carol_did, &carol_jwt, &bob_did).await;
248
+
249
+
let (_bob_likes_alice_uri, _) = create_like(
250
+
&client,
251
+
&bob_did,
252
+
&bob_jwt,
253
+
&alice_post_uri,
254
+
&alice_post_cid,
255
+
)
256
+
.await;
257
+
let (_carol_likes_alice_uri, _) = create_like(
258
+
&client,
259
+
&carol_did,
260
+
&carol_jwt,
261
+
&alice_post_uri,
262
+
&alice_post_cid,
263
+
)
264
+
.await;
265
+
266
+
let (_bob_reposts_alice_uri, _) = create_repost(
267
+
&client,
268
+
&bob_did,
269
+
&bob_jwt,
270
+
&alice_post_uri,
271
+
&alice_post_cid,
272
+
)
273
+
.await;
274
+
275
+
let reply_res = client
276
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
277
+
.bearer_auth(&carol_jwt)
278
+
.json(&json!({
279
+
"repo": carol_did,
280
+
"collection": "app.bsky.feed.post",
281
+
"record": {
282
+
"$type": "app.bsky.feed.post",
283
+
"text": "Great post Alice!",
284
+
"createdAt": Utc::now().to_rfc3339(),
285
+
"reply": {
286
+
"root": { "uri": alice_post_uri, "cid": alice_post_cid },
287
+
"parent": { "uri": alice_post_uri, "cid": alice_post_cid }
288
+
}
289
+
}
290
+
}))
291
+
.send()
292
+
.await
293
+
.expect("Reply failed");
294
+
assert_eq!(reply_res.status(), StatusCode::OK);
295
+
let reply_body: Value = reply_res.json().await.unwrap();
296
+
let carol_reply_uri = reply_body["uri"].as_str().unwrap();
297
+
let carol_reply_cid = reply_body["cid"].as_str().unwrap();
298
+
299
+
let alice_reply_res = client
300
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
301
+
.bearer_auth(&alice_jwt)
302
+
.json(&json!({
303
+
"repo": alice_did,
304
+
"collection": "app.bsky.feed.post",
305
+
"record": {
306
+
"$type": "app.bsky.feed.post",
307
+
"text": "Thanks Carol!",
308
+
"createdAt": Utc::now().to_rfc3339(),
309
+
"reply": {
310
+
"root": { "uri": alice_post_uri, "cid": alice_post_cid },
311
+
"parent": { "uri": carol_reply_uri, "cid": carol_reply_cid }
312
+
}
313
+
}
314
+
}))
315
+
.send()
316
+
.await
317
+
.expect("Alice reply failed");
318
+
assert_eq!(alice_reply_res.status(), StatusCode::OK);
319
+
320
+
let alice_follows_res = client
321
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
322
+
.query(&[
323
+
("repo", alice_did.as_str()),
324
+
("collection", "app.bsky.graph.follow"),
325
+
])
326
+
.send()
327
+
.await
328
+
.unwrap();
329
+
let alice_follows: Value = alice_follows_res.json().await.unwrap();
330
+
assert_eq!(
331
+
alice_follows["records"].as_array().unwrap().len(),
332
+
0,
333
+
"Alice follows nobody"
334
+
);
335
+
336
+
let bob_follows_res = client
337
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
338
+
.query(&[
339
+
("repo", bob_did.as_str()),
340
+
("collection", "app.bsky.graph.follow"),
341
+
])
342
+
.send()
343
+
.await
344
+
.unwrap();
345
+
let bob_follows: Value = bob_follows_res.json().await.unwrap();
346
+
assert_eq!(
347
+
bob_follows["records"].as_array().unwrap().len(),
348
+
1,
349
+
"Bob follows 1 person"
350
+
);
351
+
352
+
let carol_follows_res = client
353
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
354
+
.query(&[
355
+
("repo", carol_did.as_str()),
356
+
("collection", "app.bsky.graph.follow"),
357
+
])
358
+
.send()
359
+
.await
360
+
.unwrap();
361
+
let carol_follows: Value = carol_follows_res.json().await.unwrap();
362
+
assert_eq!(
363
+
carol_follows["records"].as_array().unwrap().len(),
364
+
2,
365
+
"Carol follows 2 people"
366
+
);
367
+
368
+
let bob_likes_res = client
369
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
370
+
.query(&[
371
+
("repo", bob_did.as_str()),
372
+
("collection", "app.bsky.feed.like"),
373
+
])
374
+
.send()
375
+
.await
376
+
.unwrap();
377
+
let bob_likes: Value = bob_likes_res.json().await.unwrap();
378
+
assert_eq!(bob_likes["records"].as_array().unwrap().len(), 1);
379
+
380
+
let alice_posts_res = client
381
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
382
+
.query(&[
383
+
("repo", alice_did.as_str()),
384
+
("collection", "app.bsky.feed.post"),
385
+
])
386
+
.send()
387
+
.await
388
+
.unwrap();
389
+
let alice_posts: Value = alice_posts_res.json().await.unwrap();
390
+
assert_eq!(
391
+
alice_posts["records"].as_array().unwrap().len(),
392
+
2,
393
+
"Alice has 2 posts (original + reply)"
394
+
);
395
+
396
+
let bob_likes_rkey = bob_likes["records"][0]["uri"]
397
+
.as_str()
398
+
.unwrap()
399
+
.split('/')
400
+
.next_back()
401
+
.unwrap();
402
+
let unlike_res = client
403
+
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base))
404
+
.bearer_auth(&bob_jwt)
405
+
.json(&json!({
406
+
"repo": bob_did,
407
+
"collection": "app.bsky.feed.like",
408
+
"rkey": bob_likes_rkey
409
+
}))
410
+
.send()
411
+
.await
412
+
.expect("Unlike failed");
413
+
assert_eq!(unlike_res.status(), StatusCode::OK);
414
+
415
+
let bob_likes_after = client
416
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
417
+
.query(&[
418
+
("repo", bob_did.as_str()),
419
+
("collection", "app.bsky.feed.like"),
420
+
])
421
+
.send()
422
+
.await
423
+
.unwrap();
424
+
let bob_likes_after_body: Value = bob_likes_after.json().await.unwrap();
425
+
assert_eq!(bob_likes_after_body["records"].as_array().unwrap().len(), 0);
426
+
427
+
let relike_res = client
428
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
429
+
.bearer_auth(&bob_jwt)
430
+
.json(&json!({
431
+
"repo": bob_did,
432
+
"collection": "app.bsky.feed.like",
433
+
"record": {
434
+
"$type": "app.bsky.feed.like",
435
+
"subject": { "uri": alice_post_uri, "cid": alice_post_cid },
436
+
"createdAt": Utc::now().to_rfc3339()
437
+
}
438
+
}))
439
+
.send()
440
+
.await
441
+
.expect("Relike failed");
442
+
assert_eq!(relike_res.status(), StatusCode::OK);
443
+
444
+
let (_alice_likes_bob_uri, _) = create_like(
445
+
&client,
446
+
&alice_did,
447
+
&alice_jwt,
448
+
&bob_post_uri,
449
+
&bob_post_cid,
450
+
)
451
+
.await;
452
+
453
+
let mutual_follow_res = client
454
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
455
+
.bearer_auth(&alice_jwt)
456
+
.json(&json!({
457
+
"repo": alice_did,
458
+
"collection": "app.bsky.graph.follow",
459
+
"record": {
460
+
"$type": "app.bsky.graph.follow",
461
+
"subject": bob_did,
462
+
"createdAt": Utc::now().to_rfc3339()
463
+
}
464
+
}))
465
+
.send()
466
+
.await
467
+
.expect("Mutual follow failed");
468
+
assert_eq!(mutual_follow_res.status(), StatusCode::OK);
469
+
470
+
let alice_final_follows = client
471
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
472
+
.query(&[
473
+
("repo", alice_did.as_str()),
474
+
("collection", "app.bsky.graph.follow"),
475
+
])
476
+
.send()
477
+
.await
478
+
.unwrap();
479
+
let alice_final: Value = alice_final_follows.json().await.unwrap();
480
+
assert_eq!(alice_final["records"].as_array().unwrap().len(), 1);
481
+
}
482
+
483
+
#[tokio::test]
484
+
async fn test_blob_lifecycle_upload_use_remove() {
485
+
let client = client();
486
+
let base = base_url().await;
487
+
let (did, jwt) = setup_new_user("blob-lifecycle").await;
488
+
489
+
let blob1_data = b"First blob for testing lifecycle";
490
+
let upload1_res = client
491
+
.post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base))
492
+
.header(header::CONTENT_TYPE, "text/plain")
493
+
.bearer_auth(&jwt)
494
+
.body(blob1_data.to_vec())
495
+
.send()
496
+
.await
497
+
.expect("Upload 1 failed");
498
+
assert_eq!(upload1_res.status(), StatusCode::OK);
499
+
let upload1_body: Value = upload1_res.json().await.unwrap();
500
+
let blob1 = upload1_body["blob"].clone();
501
+
let blob1_cid = blob1["ref"]["$link"].as_str().unwrap();
502
+
503
+
let blob2_data = b"Second blob for testing lifecycle";
504
+
let upload2_res = client
505
+
.post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base))
506
+
.header(header::CONTENT_TYPE, "text/plain")
507
+
.bearer_auth(&jwt)
508
+
.body(blob2_data.to_vec())
509
+
.send()
510
+
.await
511
+
.expect("Upload 2 failed");
512
+
assert_eq!(upload2_res.status(), StatusCode::OK);
513
+
let upload2_body: Value = upload2_res.json().await.unwrap();
514
+
let blob2 = upload2_body["blob"].clone();
515
+
let _blob2_cid = blob2["ref"]["$link"].as_str().unwrap();
516
+
517
+
let post_res = client
518
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
519
+
.bearer_auth(&jwt)
520
+
.json(&json!({
521
+
"repo": did,
522
+
"collection": "app.bsky.feed.post",
523
+
"record": {
524
+
"$type": "app.bsky.feed.post",
525
+
"text": "Post with images",
526
+
"createdAt": Utc::now().to_rfc3339(),
527
+
"embed": {
528
+
"$type": "app.bsky.embed.images",
529
+
"images": [
530
+
{ "alt": "First image", "image": blob1 },
531
+
{ "alt": "Second image", "image": blob2 }
532
+
]
533
+
}
534
+
}
535
+
}))
536
+
.send()
537
+
.await
538
+
.expect("Post with blobs failed");
539
+
assert_eq!(post_res.status(), StatusCode::OK);
540
+
let post_body: Value = post_res.json().await.unwrap();
541
+
let post_uri = post_body["uri"].as_str().unwrap();
542
+
let post_cid = post_body["cid"].as_str().unwrap();
543
+
let post_rkey = post_uri.split('/').next_back().unwrap();
544
+
545
+
let get_blob1 = client
546
+
.get(format!("{}/xrpc/com.atproto.sync.getBlob", base))
547
+
.query(&[("did", did.as_str()), ("cid", blob1_cid)])
548
+
.send()
549
+
.await
550
+
.expect("Get blob 1 failed");
551
+
assert_eq!(get_blob1.status(), StatusCode::OK);
552
+
let blob1_content = get_blob1.bytes().await.unwrap();
553
+
assert_eq!(blob1_content.as_ref(), blob1_data);
554
+
555
+
let get_record = client
556
+
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base))
557
+
.query(&[
558
+
("repo", did.as_str()),
559
+
("collection", "app.bsky.feed.post"),
560
+
("rkey", post_rkey),
561
+
])
562
+
.send()
563
+
.await
564
+
.expect("Get record failed");
565
+
let record_body: Value = get_record.json().await.unwrap();
566
+
let images = record_body["value"]["embed"]["images"].as_array().unwrap();
567
+
assert_eq!(images.len(), 2);
568
+
569
+
let edit_res = client
570
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base))
571
+
.bearer_auth(&jwt)
572
+
.json(&json!({
573
+
"repo": did,
574
+
"collection": "app.bsky.feed.post",
575
+
"rkey": post_rkey,
576
+
"record": {
577
+
"$type": "app.bsky.feed.post",
578
+
"text": "Post with single image now",
579
+
"createdAt": Utc::now().to_rfc3339(),
580
+
"embed": {
581
+
"$type": "app.bsky.embed.images",
582
+
"images": [
583
+
{ "alt": "Only first image", "image": blob1 }
584
+
]
585
+
}
586
+
},
587
+
"swapRecord": post_cid
588
+
}))
589
+
.send()
590
+
.await
591
+
.expect("Edit failed");
592
+
assert_eq!(edit_res.status(), StatusCode::OK);
593
+
594
+
let get_edited = client
595
+
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base))
596
+
.query(&[
597
+
("repo", did.as_str()),
598
+
("collection", "app.bsky.feed.post"),
599
+
("rkey", post_rkey),
600
+
])
601
+
.send()
602
+
.await
603
+
.unwrap();
604
+
let edited_body: Value = get_edited.json().await.unwrap();
605
+
let edited_images = edited_body["value"]["embed"]["images"].as_array().unwrap();
606
+
assert_eq!(edited_images.len(), 1);
607
+
608
+
let profile_res = client
609
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base))
610
+
.bearer_auth(&jwt)
611
+
.json(&json!({
612
+
"repo": did,
613
+
"collection": "app.bsky.actor.profile",
614
+
"rkey": "self",
615
+
"record": {
616
+
"$type": "app.bsky.actor.profile",
617
+
"displayName": "Blob Test User",
618
+
"avatar": blob1
619
+
}
620
+
}))
621
+
.send()
622
+
.await
623
+
.expect("Profile failed");
624
+
assert_eq!(profile_res.status(), StatusCode::OK);
625
+
626
+
let list_blobs = client
627
+
.get(format!("{}/xrpc/com.atproto.sync.listBlobs", base))
628
+
.query(&[("did", did.as_str())])
629
+
.send()
630
+
.await
631
+
.expect("List blobs failed");
632
+
assert_eq!(list_blobs.status(), StatusCode::OK);
633
+
let blobs_body: Value = list_blobs.json().await.unwrap();
634
+
let cids = blobs_body["cids"].as_array().unwrap();
635
+
assert!(
636
+
cids.iter().any(|c| c.as_str() == Some(blob1_cid)),
637
+
"blob1 should still exist (referenced by profile and post)"
638
+
);
639
+
}
640
+
641
+
#[tokio::test]
642
+
async fn test_session_and_record_interaction() {
643
+
let client = client();
644
+
let base = base_url().await;
645
+
let uid = uuid::Uuid::new_v4().simple().to_string();
646
+
let handle = format!("sess{}", &uid[..8]);
647
+
let email = format!("sess{}@test.com", &uid[..8]);
648
+
let password = "SessionTest123!";
649
+
650
+
let create_res = client
651
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
652
+
.json(&json!({
653
+
"handle": handle,
654
+
"email": email,
655
+
"password": password
656
+
}))
657
+
.send()
658
+
.await
659
+
.expect("Account creation failed");
660
+
assert_eq!(create_res.status(), StatusCode::OK);
661
+
let account: Value = create_res.json().await.unwrap();
662
+
let did = account["did"].as_str().unwrap().to_string();
663
+
let jwt = verify_new_account(&client, &did).await;
664
+
665
+
let (post1_uri, _) = create_post(&client, &did, &jwt, "Post from session 1").await;
666
+
667
+
let session2_res = client
668
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
669
+
.json(&json!({
670
+
"identifier": handle,
671
+
"password": password
672
+
}))
673
+
.send()
674
+
.await
675
+
.expect("Session 2 creation failed");
676
+
assert_eq!(session2_res.status(), StatusCode::OK);
677
+
let session2: Value = session2_res.json().await.unwrap();
678
+
let jwt2 = session2["accessJwt"].as_str().unwrap();
679
+
let refresh2 = session2["refreshJwt"].as_str().unwrap();
680
+
681
+
let (post2_uri, _) = create_post(&client, &did, jwt2, "Post from session 2").await;
682
+
683
+
let list_res = client
684
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
685
+
.bearer_auth(&jwt)
686
+
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
687
+
.send()
688
+
.await
689
+
.unwrap();
690
+
let list_body: Value = list_res.json().await.unwrap();
691
+
assert_eq!(
692
+
list_body["records"].as_array().unwrap().len(),
693
+
2,
694
+
"Both posts visible from session 1"
695
+
);
696
+
697
+
let refresh_res = client
698
+
.post(format!("{}/xrpc/com.atproto.server.refreshSession", base))
699
+
.bearer_auth(refresh2)
700
+
.send()
701
+
.await
702
+
.expect("Refresh failed");
703
+
assert_eq!(refresh_res.status(), StatusCode::OK);
704
+
let refresh_body: Value = refresh_res.json().await.unwrap();
705
+
let new_jwt2 = refresh_body["accessJwt"].as_str().unwrap();
706
+
707
+
let verify_posts = client
708
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
709
+
.bearer_auth(new_jwt2)
710
+
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
711
+
.send()
712
+
.await
713
+
.unwrap();
714
+
let verify_body: Value = verify_posts.json().await.unwrap();
715
+
assert_eq!(
716
+
verify_body["records"].as_array().unwrap().len(),
717
+
2,
718
+
"Posts still visible after token refresh"
719
+
);
720
+
721
+
let post1_rkey = post1_uri.split('/').next_back().unwrap();
722
+
let delete_res = client
723
+
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base))
724
+
.bearer_auth(new_jwt2)
725
+
.json(&json!({
726
+
"repo": did,
727
+
"collection": "app.bsky.feed.post",
728
+
"rkey": post1_rkey
729
+
}))
730
+
.send()
731
+
.await
732
+
.expect("Delete from session 2 failed");
733
+
assert_eq!(delete_res.status(), StatusCode::OK);
734
+
735
+
let final_list = client
736
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
737
+
.bearer_auth(&jwt)
738
+
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
739
+
.send()
740
+
.await
741
+
.unwrap();
742
+
let final_body: Value = final_list.json().await.unwrap();
743
+
let remaining_posts = final_body["records"].as_array().unwrap();
744
+
assert_eq!(remaining_posts.len(), 1);
745
+
assert!(
746
+
remaining_posts[0]["uri"]
747
+
.as_str()
748
+
.unwrap()
749
+
.contains(post2_uri.split('/').next_back().unwrap())
750
+
);
751
+
}
752
+
753
+
#[tokio::test]
754
+
async fn test_app_password_record_lifecycle() {
755
+
let client = client();
756
+
let base = base_url().await;
757
+
let uid = uuid::Uuid::new_v4().simple().to_string();
758
+
let handle = format!("apprec{}", &uid[..8]);
759
+
let email = format!("apprec{}@test.com", &uid[..8]);
760
+
let password = "AppRecTest123!";
761
+
762
+
let create_res = client
763
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
764
+
.json(&json!({
765
+
"handle": handle,
766
+
"email": email,
767
+
"password": password
768
+
}))
769
+
.send()
770
+
.await
771
+
.expect("Account creation failed");
772
+
assert_eq!(create_res.status(), StatusCode::OK);
773
+
let account: Value = create_res.json().await.unwrap();
774
+
let did = account["did"].as_str().unwrap().to_string();
775
+
let main_jwt = verify_new_account(&client, &did).await;
776
+
777
+
let create_app_pass = client
778
+
.post(format!(
779
+
"{}/xrpc/com.atproto.server.createAppPassword",
780
+
base
781
+
))
782
+
.bearer_auth(&main_jwt)
783
+
.json(&json!({ "name": "Test App" }))
784
+
.send()
785
+
.await
786
+
.expect("App password creation failed");
787
+
assert_eq!(create_app_pass.status(), StatusCode::OK);
788
+
let app_pass_body: Value = create_app_pass.json().await.unwrap();
789
+
let app_password = app_pass_body["password"].as_str().unwrap();
790
+
791
+
let app_login = client
792
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
793
+
.json(&json!({
794
+
"identifier": handle,
795
+
"password": app_password
796
+
}))
797
+
.send()
798
+
.await
799
+
.expect("App password login failed");
800
+
assert_eq!(app_login.status(), StatusCode::OK);
801
+
let app_session: Value = app_login.json().await.unwrap();
802
+
let app_jwt = app_session["accessJwt"].as_str().unwrap();
803
+
804
+
create_post(&client, &did, app_jwt, "Post from app password session").await;
805
+
806
+
let verify_main = client
807
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
808
+
.bearer_auth(&main_jwt)
809
+
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
810
+
.send()
811
+
.await
812
+
.unwrap();
813
+
let verify_body: Value = verify_main.json().await.unwrap();
814
+
assert_eq!(
815
+
verify_body["records"].as_array().unwrap().len(),
816
+
1,
817
+
"Post visible from main session"
818
+
);
819
+
820
+
let revoke_res = client
821
+
.post(format!(
822
+
"{}/xrpc/com.atproto.server.revokeAppPassword",
823
+
base
824
+
))
825
+
.bearer_auth(&main_jwt)
826
+
.json(&json!({ "name": "Test App" }))
827
+
.send()
828
+
.await
829
+
.expect("Revoke failed");
830
+
assert_eq!(revoke_res.status(), StatusCode::OK);
831
+
832
+
let post_after_revoke = client
833
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
834
+
.bearer_auth(app_jwt)
835
+
.json(&json!({
836
+
"repo": did,
837
+
"collection": "app.bsky.feed.post",
838
+
"record": {
839
+
"$type": "app.bsky.feed.post",
840
+
"text": "Should fail",
841
+
"createdAt": Utc::now().to_rfc3339()
842
+
}
843
+
}))
844
+
.send()
845
+
.await
846
+
.expect("Post attempt failed");
847
+
assert!(
848
+
post_after_revoke.status() == StatusCode::UNAUTHORIZED
849
+
|| post_after_revoke.status() == StatusCode::BAD_REQUEST,
850
+
"Revoked app password should not create posts"
851
+
);
852
+
853
+
let final_list = client
854
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
855
+
.bearer_auth(&main_jwt)
856
+
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
857
+
.send()
858
+
.await
859
+
.unwrap();
860
+
let final_body: Value = final_list.json().await.unwrap();
861
+
assert_eq!(
862
+
final_body["records"].as_array().unwrap().len(),
863
+
1,
864
+
"Only the valid post should exist"
865
+
);
866
+
}
867
+
868
+
#[tokio::test]
869
+
async fn test_handle_change_with_existing_content() {
870
+
let client = client();
871
+
let base = base_url().await;
872
+
let (did, jwt) = setup_new_user("handlechange").await;
873
+
874
+
let (post_uri, post_cid) = create_post(&client, &did, &jwt, "Post before handle change").await;
875
+
876
+
let profile_res = client
877
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base))
878
+
.bearer_auth(&jwt)
879
+
.json(&json!({
880
+
"repo": did,
881
+
"collection": "app.bsky.actor.profile",
882
+
"rkey": "self",
883
+
"record": {
884
+
"$type": "app.bsky.actor.profile",
885
+
"displayName": "Original Handle User"
886
+
}
887
+
}))
888
+
.send()
889
+
.await
890
+
.expect("Profile creation failed");
891
+
assert_eq!(profile_res.status(), StatusCode::OK);
892
+
893
+
let (other_did, other_jwt) = setup_new_user("other-user").await;
894
+
let (_like_uri, _) = create_like(&client, &other_did, &other_jwt, &post_uri, &post_cid).await;
895
+
let (_follow_uri, _) = create_follow(&client, &other_did, &other_jwt, &did).await;
896
+
897
+
let new_handle = format!("newh{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
898
+
let update_res = client
899
+
.post(format!("{}/xrpc/com.atproto.identity.updateHandle", base))
900
+
.bearer_auth(&jwt)
901
+
.json(&json!({ "handle": new_handle }))
902
+
.send()
903
+
.await
904
+
.expect("Handle update failed");
905
+
assert_eq!(update_res.status(), StatusCode::OK);
906
+
907
+
let session_res = client
908
+
.get(format!("{}/xrpc/com.atproto.server.getSession", base))
909
+
.bearer_auth(&jwt)
910
+
.send()
911
+
.await
912
+
.expect("Get session failed");
913
+
let session_body: Value = session_res.json().await.unwrap();
914
+
assert!(
915
+
session_body["handle"]
916
+
.as_str()
917
+
.unwrap()
918
+
.starts_with(&new_handle),
919
+
"Handle should be updated"
920
+
);
921
+
922
+
let posts_res = client
923
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
924
+
.bearer_auth(&jwt)
925
+
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
926
+
.send()
927
+
.await
928
+
.unwrap();
929
+
let posts_body: Value = posts_res.json().await.unwrap();
930
+
assert_eq!(
931
+
posts_body["records"].as_array().unwrap().len(),
932
+
1,
933
+
"Post should still exist after handle change"
934
+
);
935
+
936
+
let profile_check = client
937
+
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base))
938
+
.query(&[
939
+
("repo", did.as_str()),
940
+
("collection", "app.bsky.actor.profile"),
941
+
("rkey", "self"),
942
+
])
943
+
.send()
944
+
.await
945
+
.unwrap();
946
+
let profile_body: Value = profile_check.json().await.unwrap();
947
+
assert_eq!(
948
+
profile_body["value"]["displayName"], "Original Handle User",
949
+
"Profile should be intact"
950
+
);
951
+
952
+
let other_follows = client
953
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
954
+
.query(&[
955
+
("repo", other_did.as_str()),
956
+
("collection", "app.bsky.graph.follow"),
957
+
])
958
+
.send()
959
+
.await
960
+
.unwrap();
961
+
let follows_body: Value = other_follows.json().await.unwrap();
962
+
let follows = follows_body["records"].as_array().unwrap();
963
+
assert_eq!(follows.len(), 1);
964
+
assert_eq!(
965
+
follows[0]["value"]["subject"], did,
966
+
"Follow should still point to the DID"
967
+
);
968
+
969
+
let resolve_res = client
970
+
.get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base))
971
+
.query(&[("handle", session_body["handle"].as_str().unwrap())])
972
+
.send()
973
+
.await
974
+
.expect("Resolve failed");
975
+
assert_eq!(resolve_res.status(), StatusCode::OK);
976
+
let resolve_body: Value = resolve_res.json().await.unwrap();
977
+
assert_eq!(
978
+
resolve_body["did"], did,
979
+
"New handle should resolve to same DID"
980
+
);
981
+
}
982
+
983
+
#[tokio::test]
984
+
async fn test_deactivation_preserves_data() {
985
+
let client = client();
986
+
let base = base_url().await;
987
+
let (did, jwt) = setup_new_user("deactivate-data").await;
988
+
989
+
create_post(&client, &did, &jwt, "Post 1 before deactivation").await;
990
+
create_post(&client, &did, &jwt, "Post 2 before deactivation").await;
991
+
992
+
let profile_res = client
993
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base))
994
+
.bearer_auth(&jwt)
995
+
.json(&json!({
996
+
"repo": did,
997
+
"collection": "app.bsky.actor.profile",
998
+
"rkey": "self",
999
+
"record": {
1000
+
"$type": "app.bsky.actor.profile",
1001
+
"displayName": "Deactivation Test User"
1002
+
}
1003
+
}))
1004
+
.send()
1005
+
.await
1006
+
.unwrap();
1007
+
assert_eq!(profile_res.status(), StatusCode::OK);
1008
+
1009
+
let deactivate_res = client
1010
+
.post(format!(
1011
+
"{}/xrpc/com.atproto.server.deactivateAccount",
1012
+
base
1013
+
))
1014
+
.bearer_auth(&jwt)
1015
+
.json(&json!({}))
1016
+
.send()
1017
+
.await
1018
+
.expect("Deactivation failed");
1019
+
assert_eq!(deactivate_res.status(), StatusCode::OK);
1020
+
1021
+
let posts_while_deactivated = client
1022
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
1023
+
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
1024
+
.send()
1025
+
.await
1026
+
.unwrap();
1027
+
assert_eq!(posts_while_deactivated.status(), StatusCode::OK);
1028
+
let posts_body: Value = posts_while_deactivated.json().await.unwrap();
1029
+
assert_eq!(
1030
+
posts_body["records"].as_array().unwrap().len(),
1031
+
2,
1032
+
"Posts should still be readable while deactivated"
1033
+
);
1034
+
1035
+
let activate_res = client
1036
+
.post(format!("{}/xrpc/com.atproto.server.activateAccount", base))
1037
+
.bearer_auth(&jwt)
1038
+
.json(&json!({}))
1039
+
.send()
1040
+
.await
1041
+
.expect("Activation failed");
1042
+
assert_eq!(activate_res.status(), StatusCode::OK);
1043
+
1044
+
create_post(&client, &did, &jwt, "Post 3 after reactivation").await;
1045
+
1046
+
let final_posts = client
1047
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
1048
+
.bearer_auth(&jwt)
1049
+
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
1050
+
.send()
1051
+
.await
1052
+
.unwrap();
1053
+
let final_body: Value = final_posts.json().await.unwrap();
1054
+
assert_eq!(
1055
+
final_body["records"].as_array().unwrap().len(),
1056
+
3,
1057
+
"All three posts should exist"
1058
+
);
1059
+
}
1060
+
1061
+
#[tokio::test]
1062
+
async fn test_password_change_session_behavior() {
1063
+
let client = client();
1064
+
let base = base_url().await;
1065
+
let uid = uuid::Uuid::new_v4().simple().to_string();
1066
+
let handle = format!("pwch{}", &uid[..8]);
1067
+
let email = format!("pwch{}@test.com", &uid[..8]);
1068
+
let old_password = "OldPassword123!";
1069
+
let new_password = "NewPassword456!";
1070
+
1071
+
let create_res = client
1072
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
1073
+
.json(&json!({
1074
+
"handle": handle,
1075
+
"email": email,
1076
+
"password": old_password
1077
+
}))
1078
+
.send()
1079
+
.await
1080
+
.expect("Account creation failed");
1081
+
assert_eq!(create_res.status(), StatusCode::OK);
1082
+
let account: Value = create_res.json().await.unwrap();
1083
+
let did = account["did"].as_str().unwrap().to_string();
1084
+
let jwt1 = verify_new_account(&client, &did).await;
1085
+
1086
+
create_post(&client, &did, &jwt1, "Post before password change").await;
1087
+
1088
+
let change_pw_res = client
1089
+
.post(format!("{}/xrpc/_account.changePassword", base))
1090
+
.bearer_auth(&jwt1)
1091
+
.json(&json!({
1092
+
"currentPassword": old_password,
1093
+
"newPassword": new_password
1094
+
}))
1095
+
.send()
1096
+
.await
1097
+
.expect("Password change failed");
1098
+
assert_eq!(change_pw_res.status(), StatusCode::OK);
1099
+
1100
+
let old_pw_login = client
1101
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
1102
+
.json(&json!({
1103
+
"identifier": handle,
1104
+
"password": old_password
1105
+
}))
1106
+
.send()
1107
+
.await
1108
+
.unwrap();
1109
+
assert!(
1110
+
old_pw_login.status() == StatusCode::UNAUTHORIZED
1111
+
|| old_pw_login.status() == StatusCode::BAD_REQUEST,
1112
+
"Old password should not work"
1113
+
);
1114
+
1115
+
let new_pw_login = client
1116
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
1117
+
.json(&json!({
1118
+
"identifier": handle,
1119
+
"password": new_password
1120
+
}))
1121
+
.send()
1122
+
.await
1123
+
.expect("New password login failed");
1124
+
assert_eq!(new_pw_login.status(), StatusCode::OK);
1125
+
let new_session: Value = new_pw_login.json().await.unwrap();
1126
+
let new_jwt = new_session["accessJwt"].as_str().unwrap();
1127
+
1128
+
let posts_res = client
1129
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
1130
+
.bearer_auth(new_jwt)
1131
+
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
1132
+
.send()
1133
+
.await
1134
+
.unwrap();
1135
+
let posts_body: Value = posts_res.json().await.unwrap();
1136
+
assert_eq!(
1137
+
posts_body["records"].as_array().unwrap().len(),
1138
+
1,
1139
+
"Post should still exist after password change"
1140
+
);
1141
+
}
1142
+
1143
+
#[tokio::test]
1144
+
async fn test_backup_restore_workflow() {
1145
+
let client = client();
1146
+
let base = base_url().await;
1147
+
let (did, jwt) = setup_new_user("backup-restore").await;
1148
+
1149
+
futures::future::join_all((0..3).map(|i| {
1150
+
let client = client.clone();
1151
+
let did = did.clone();
1152
+
let jwt = jwt.clone();
1153
+
async move {
1154
+
create_post(&client, &did, &jwt, &format!("Post {} for backup test", i)).await;
1155
+
}
1156
+
}))
1157
+
.await;
1158
+
1159
+
let blob_data = b"Blob data for backup test";
1160
+
let upload_res = client
1161
+
.post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base))
1162
+
.header(header::CONTENT_TYPE, "text/plain")
1163
+
.bearer_auth(&jwt)
1164
+
.body(blob_data.to_vec())
1165
+
.send()
1166
+
.await
1167
+
.expect("Blob upload failed");
1168
+
assert_eq!(upload_res.status(), StatusCode::OK);
1169
+
let upload_body: Value = upload_res.json().await.unwrap();
1170
+
let blob = upload_body["blob"].clone();
1171
+
1172
+
let profile_res = client
1173
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base))
1174
+
.bearer_auth(&jwt)
1175
+
.json(&json!({
1176
+
"repo": did,
1177
+
"collection": "app.bsky.actor.profile",
1178
+
"rkey": "self",
1179
+
"record": {
1180
+
"$type": "app.bsky.actor.profile",
1181
+
"displayName": "Backup Test User",
1182
+
"avatar": blob
1183
+
}
1184
+
}))
1185
+
.send()
1186
+
.await
1187
+
.unwrap();
1188
+
assert_eq!(profile_res.status(), StatusCode::OK);
1189
+
1190
+
let backup1_res = client
1191
+
.post(format!("{}/xrpc/_backup.createBackup", base))
1192
+
.bearer_auth(&jwt)
1193
+
.send()
1194
+
.await
1195
+
.expect("Backup 1 failed");
1196
+
assert_eq!(backup1_res.status(), StatusCode::OK);
1197
+
let backup1: Value = backup1_res.json().await.unwrap();
1198
+
let backup1_id = backup1["id"].as_str().unwrap();
1199
+
let backup1_rev = backup1["repoRev"].as_str().unwrap();
1200
+
1201
+
create_post(&client, &did, &jwt, "Post 4 after first backup").await;
1202
+
create_post(&client, &did, &jwt, "Post 5 after first backup").await;
1203
+
1204
+
let backup2_res = client
1205
+
.post(format!("{}/xrpc/_backup.createBackup", base))
1206
+
.bearer_auth(&jwt)
1207
+
.send()
1208
+
.await
1209
+
.expect("Backup 2 failed");
1210
+
assert_eq!(backup2_res.status(), StatusCode::OK);
1211
+
let backup2: Value = backup2_res.json().await.unwrap();
1212
+
let backup2_id = backup2["id"].as_str().unwrap();
1213
+
let backup2_rev = backup2["repoRev"].as_str().unwrap();
1214
+
1215
+
assert_ne!(
1216
+
backup1_rev, backup2_rev,
1217
+
"Backups should have different revs"
1218
+
);
1219
+
1220
+
let list_res = client
1221
+
.get(format!("{}/xrpc/_backup.listBackups", base))
1222
+
.bearer_auth(&jwt)
1223
+
.send()
1224
+
.await
1225
+
.expect("List backups failed");
1226
+
let list_body: Value = list_res.json().await.unwrap();
1227
+
let backups = list_body["backups"].as_array().unwrap();
1228
+
assert_eq!(backups.len(), 2, "Should have 2 backups");
1229
+
1230
+
let download1 = client
1231
+
.get(format!("{}/xrpc/_backup.getBackup?id={}", base, backup1_id))
1232
+
.bearer_auth(&jwt)
1233
+
.send()
1234
+
.await
1235
+
.expect("Download backup 1 failed");
1236
+
assert_eq!(download1.status(), StatusCode::OK);
1237
+
let backup1_bytes = download1.bytes().await.unwrap();
1238
+
1239
+
let download2 = client
1240
+
.get(format!("{}/xrpc/_backup.getBackup?id={}", base, backup2_id))
1241
+
.bearer_auth(&jwt)
1242
+
.send()
1243
+
.await
1244
+
.expect("Download backup 2 failed");
1245
+
assert_eq!(download2.status(), StatusCode::OK);
1246
+
let backup2_bytes = download2.bytes().await.unwrap();
1247
+
1248
+
assert!(
1249
+
backup2_bytes.len() > backup1_bytes.len(),
1250
+
"Second backup should be larger (more posts)"
1251
+
);
1252
+
1253
+
let delete_old = client
1254
+
.post(format!(
1255
+
"{}/xrpc/_backup.deleteBackup?id={}",
1256
+
base, backup1_id
1257
+
))
1258
+
.bearer_auth(&jwt)
1259
+
.send()
1260
+
.await
1261
+
.expect("Delete backup failed");
1262
+
assert_eq!(delete_old.status(), StatusCode::OK);
1263
+
1264
+
let final_list = client
1265
+
.get(format!("{}/xrpc/_backup.listBackups", base))
1266
+
.bearer_auth(&jwt)
1267
+
.send()
1268
+
.await
1269
+
.unwrap();
1270
+
let final_body: Value = final_list.json().await.unwrap();
1271
+
assert_eq!(final_body["backups"].as_array().unwrap().len(), 1);
1272
+
}
1273
+
1274
+
#[tokio::test]
1275
+
async fn test_scale_100_posts_with_pagination() {
1276
+
let client = client();
1277
+
let base = base_url().await;
1278
+
let (did, jwt) = setup_new_user("scale-posts").await;
1279
+
1280
+
let post_count = 1000;
1281
+
let post_futures: Vec<_> = (0..post_count)
1282
+
.map(|i| {
1283
+
let client = client.clone();
1284
+
let base = base.to_string();
1285
+
let did = did.clone();
1286
+
let jwt = jwt.clone();
1287
+
async move {
1288
+
let res = client
1289
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
1290
+
.bearer_auth(&jwt)
1291
+
.json(&json!({
1292
+
"repo": did,
1293
+
"collection": "app.bsky.feed.post",
1294
+
"record": {
1295
+
"$type": "app.bsky.feed.post",
1296
+
"text": format!("Scale test post number {}", i),
1297
+
"createdAt": Utc::now().to_rfc3339()
1298
+
}
1299
+
}))
1300
+
.send()
1301
+
.await
1302
+
.expect("Post creation failed");
1303
+
let status = res.status();
1304
+
let body: Value = res.json().await.unwrap_or_default();
1305
+
assert_eq!(
1306
+
status,
1307
+
StatusCode::OK,
1308
+
"Failed to create post {}: {:?}",
1309
+
i,
1310
+
body
1311
+
);
1312
+
}
1313
+
})
1314
+
.collect();
1315
+
1316
+
join_all(post_futures).await;
1317
+
1318
+
let count_res = client
1319
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
1320
+
.bearer_auth(&jwt)
1321
+
.query(&[
1322
+
("repo", did.as_str()),
1323
+
("collection", "app.bsky.feed.post"),
1324
+
("limit", "1"),
1325
+
])
1326
+
.send()
1327
+
.await
1328
+
.unwrap();
1329
+
assert_eq!(count_res.status(), StatusCode::OK);
1330
+
1331
+
let all_uris: Vec<String> =
1332
+
paginate_records(&client, base, &jwt, &did, "app.bsky.feed.post", 25)
1333
+
.await
1334
+
.iter()
1335
+
.filter_map(|r| r["uri"].as_str().map(String::from))
1336
+
.collect();
1337
+
1338
+
assert_eq!(
1339
+
all_uris.len(),
1340
+
post_count,
1341
+
"Should have paginated through all {} posts",
1342
+
post_count
1343
+
);
1344
+
1345
+
let unique_uris: std::collections::HashSet<_> = all_uris.iter().collect();
1346
+
assert_eq!(
1347
+
unique_uris.len(),
1348
+
post_count,
1349
+
"All posts should have unique URIs"
1350
+
);
1351
+
1352
+
let delete_futures: Vec<_> = all_uris
1353
+
.iter()
1354
+
.take(500)
1355
+
.map(|uri| {
1356
+
let client = client.clone();
1357
+
let base = base.to_string();
1358
+
let did = did.clone();
1359
+
let jwt = jwt.clone();
1360
+
let rkey = uri.split('/').next_back().unwrap().to_string();
1361
+
async move {
1362
+
let res = client
1363
+
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base))
1364
+
.bearer_auth(&jwt)
1365
+
.json(&json!({
1366
+
"repo": did,
1367
+
"collection": "app.bsky.feed.post",
1368
+
"rkey": rkey
1369
+
}))
1370
+
.send()
1371
+
.await
1372
+
.expect("Delete failed");
1373
+
assert_eq!(res.status(), StatusCode::OK);
1374
+
}
1375
+
})
1376
+
.collect();
1377
+
1378
+
join_all(delete_futures).await;
1379
+
1380
+
let final_count = count_records(&client, base, &jwt, &did, "app.bsky.feed.post").await;
1381
+
assert_eq!(
1382
+
final_count, 500,
1383
+
"Should have 500 posts remaining after deleting 500"
1384
+
);
1385
+
}
1386
+
1387
+
#[tokio::test]
1388
+
async fn test_scale_many_users_social_graph() {
1389
+
let client = client();
1390
+
let base = base_url().await;
1391
+
1392
+
let user_count = 50;
1393
+
let user_futures: Vec<_> = (0..user_count)
1394
+
.map(|i| async move { setup_new_user(&format!("graph{}", i)).await })
1395
+
.collect();
1396
+
1397
+
let users: Vec<(String, String)> = join_all(user_futures).await;
1398
+
1399
+
let follow_futures: Vec<_> = users
1400
+
.iter()
1401
+
.enumerate()
1402
+
.flat_map(|(i, (follower_did, follower_jwt))| {
1403
+
let client = client.clone();
1404
+
let base = base.to_string();
1405
+
users.iter().enumerate().filter(move |(j, _)| *j != i).map({
1406
+
let client = client.clone();
1407
+
let base = base.clone();
1408
+
let follower_did = follower_did.clone();
1409
+
let follower_jwt = follower_jwt.clone();
1410
+
move |(_, (followee_did, _))| {
1411
+
let client = client.clone();
1412
+
let base = base.clone();
1413
+
let follower_did = follower_did.clone();
1414
+
let follower_jwt = follower_jwt.clone();
1415
+
let followee_did = followee_did.clone();
1416
+
async move {
1417
+
let rkey = format!(
1418
+
"follow_{}",
1419
+
&uuid::Uuid::new_v4().simple().to_string()[..12]
1420
+
);
1421
+
let res = client
1422
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base))
1423
+
.bearer_auth(&follower_jwt)
1424
+
.json(&json!({
1425
+
"repo": follower_did,
1426
+
"collection": "app.bsky.graph.follow",
1427
+
"rkey": rkey,
1428
+
"record": {
1429
+
"$type": "app.bsky.graph.follow",
1430
+
"subject": followee_did,
1431
+
"createdAt": Utc::now().to_rfc3339()
1432
+
}
1433
+
}))
1434
+
.send()
1435
+
.await
1436
+
.expect("Follow failed");
1437
+
let status = res.status();
1438
+
let body: Value = res.json().await.unwrap_or_default();
1439
+
assert_eq!(status, StatusCode::OK, "Follow failed: {:?}", body);
1440
+
}
1441
+
}
1442
+
})
1443
+
})
1444
+
.collect();
1445
+
1446
+
join_all(follow_futures).await;
1447
+
1448
+
let expected_follows_per_user = user_count - 1;
1449
+
let verify_futures: Vec<_> = users
1450
+
.iter()
1451
+
.map(|(did, jwt)| {
1452
+
let client = client.clone();
1453
+
let base = base.to_string();
1454
+
let did = did.clone();
1455
+
let jwt = jwt.clone();
1456
+
async move {
1457
+
let res = client
1458
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
1459
+
.bearer_auth(&jwt)
1460
+
.query(&[
1461
+
("repo", did.as_str()),
1462
+
("collection", "app.bsky.graph.follow"),
1463
+
("limit", "100"),
1464
+
])
1465
+
.send()
1466
+
.await
1467
+
.unwrap();
1468
+
let body: Value = res.json().await.unwrap();
1469
+
body["records"].as_array().unwrap().len()
1470
+
}
1471
+
})
1472
+
.collect();
1473
+
1474
+
let follow_counts: Vec<usize> = join_all(verify_futures).await;
1475
+
let total_follows: usize = follow_counts.iter().sum();
1476
+
1477
+
assert_eq!(
1478
+
total_follows,
1479
+
user_count * expected_follows_per_user,
1480
+
"Each of {} users should follow {} others = {} total follows",
1481
+
user_count,
1482
+
expected_follows_per_user,
1483
+
user_count * expected_follows_per_user
1484
+
);
1485
+
1486
+
let (poster_did, poster_jwt) = &users[0];
1487
+
let (post_uri, post_cid) = create_post(&client, poster_did, poster_jwt, "Popular post").await;
1488
+
1489
+
let like_futures: Vec<_> = users
1490
+
.iter()
1491
+
.skip(1)
1492
+
.map(|(liker_did, liker_jwt)| {
1493
+
let client = client.clone();
1494
+
let base = base.to_string();
1495
+
let liker_did = liker_did.clone();
1496
+
let liker_jwt = liker_jwt.clone();
1497
+
let post_uri = post_uri.clone();
1498
+
let post_cid = post_cid.clone();
1499
+
async move {
1500
+
let rkey = format!("like_{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
1501
+
let res = client
1502
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base))
1503
+
.bearer_auth(&liker_jwt)
1504
+
.json(&json!({
1505
+
"repo": liker_did,
1506
+
"collection": "app.bsky.feed.like",
1507
+
"rkey": rkey,
1508
+
"record": {
1509
+
"$type": "app.bsky.feed.like",
1510
+
"subject": { "uri": post_uri, "cid": post_cid },
1511
+
"createdAt": Utc::now().to_rfc3339()
1512
+
}
1513
+
}))
1514
+
.send()
1515
+
.await
1516
+
.expect("Like failed");
1517
+
assert_eq!(res.status(), StatusCode::OK);
1518
+
}
1519
+
})
1520
+
.collect();
1521
+
1522
+
join_all(like_futures).await;
1523
+
}
1524
+
1525
+
#[tokio::test]
1526
+
async fn test_scale_many_blobs_in_repo() {
1527
+
let client = client();
1528
+
let base = base_url().await;
1529
+
let (did, jwt) = setup_new_user("scale-blobs").await;
1530
+
1531
+
let blob_count = 300;
1532
+
let blob_futures: Vec<_> = (0..blob_count)
1533
+
.map(|i| {
1534
+
let client = client.clone();
1535
+
let base = base.to_string();
1536
+
let jwt = jwt.clone();
1537
+
async move {
1538
+
let blob_data = format!("Blob data number {} with some padding to make it realistic size for testing purposes", i);
1539
+
let res = client
1540
+
.post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base))
1541
+
.header(header::CONTENT_TYPE, "text/plain")
1542
+
.bearer_auth(&jwt)
1543
+
.body(blob_data)
1544
+
.send()
1545
+
.await
1546
+
.expect("Blob upload failed");
1547
+
assert_eq!(res.status(), StatusCode::OK, "Failed to upload blob {}", i);
1548
+
let body: Value = res.json().await.unwrap();
1549
+
body["blob"].clone()
1550
+
}
1551
+
})
1552
+
.collect();
1553
+
1554
+
let blobs: Vec<Value> = join_all(blob_futures).await;
1555
+
1556
+
let post_futures: Vec<_> = blobs
1557
+
.chunks(3)
1558
+
.enumerate()
1559
+
.map(|(i, blob_chunk)| {
1560
+
let client = client.clone();
1561
+
let base = base.to_string();
1562
+
let did = did.clone();
1563
+
let jwt = jwt.clone();
1564
+
let images: Vec<Value> = blob_chunk
1565
+
.iter()
1566
+
.enumerate()
1567
+
.map(|(j, blob)| {
1568
+
json!({
1569
+
"alt": format!("Image {} in post {}", j, i),
1570
+
"image": blob
1571
+
})
1572
+
})
1573
+
.collect();
1574
+
async move {
1575
+
let res = client
1576
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
1577
+
.bearer_auth(&jwt)
1578
+
.json(&json!({
1579
+
"repo": did,
1580
+
"collection": "app.bsky.feed.post",
1581
+
"record": {
1582
+
"$type": "app.bsky.feed.post",
1583
+
"text": format!("Post {} with {} images", i, images.len()),
1584
+
"createdAt": Utc::now().to_rfc3339(),
1585
+
"embed": {
1586
+
"$type": "app.bsky.embed.images",
1587
+
"images": images
1588
+
}
1589
+
}
1590
+
}))
1591
+
.send()
1592
+
.await
1593
+
.expect("Post with blobs failed");
1594
+
let status = res.status();
1595
+
let body: Value = res.json().await.unwrap_or_default();
1596
+
assert_eq!(status, StatusCode::OK, "Post with blobs failed: {:?}", body);
1597
+
}
1598
+
})
1599
+
.collect();
1600
+
1601
+
join_all(post_futures).await;
1602
+
1603
+
let list_blobs_res = client
1604
+
.get(format!("{}/xrpc/com.atproto.sync.listBlobs", base))
1605
+
.query(&[("did", did.as_str())])
1606
+
.send()
1607
+
.await
1608
+
.expect("List blobs failed");
1609
+
assert_eq!(list_blobs_res.status(), StatusCode::OK);
1610
+
let blobs_body: Value = list_blobs_res.json().await.unwrap();
1611
+
let cids = blobs_body["cids"].as_array().unwrap();
1612
+
assert_eq!(
1613
+
cids.len(),
1614
+
blob_count,
1615
+
"Should have {} blobs in repo",
1616
+
blob_count
1617
+
);
1618
+
1619
+
let verify_futures: Vec<_> = cids
1620
+
.iter()
1621
+
.take(10)
1622
+
.map(|cid| {
1623
+
let client = client.clone();
1624
+
let base = base.to_string();
1625
+
let did = did.clone();
1626
+
let cid_str = cid.as_str().unwrap().to_string();
1627
+
async move {
1628
+
let res = client
1629
+
.get(format!("{}/xrpc/com.atproto.sync.getBlob", base))
1630
+
.query(&[("did", did.as_str()), ("cid", cid_str.as_str())])
1631
+
.send()
1632
+
.await
1633
+
.expect("Get blob failed");
1634
+
assert_eq!(
1635
+
res.status(),
1636
+
StatusCode::OK,
1637
+
"Failed to get blob {}",
1638
+
cid_str
1639
+
);
1640
+
let bytes = res.bytes().await.unwrap();
1641
+
assert!(bytes.len() > 50, "Blob should have content");
1642
+
}
1643
+
})
1644
+
.collect();
1645
+
1646
+
join_all(verify_futures).await;
1647
+
}
1648
+
1649
+
#[tokio::test]
1650
+
async fn test_scale_batch_operations() {
1651
+
let client = client();
1652
+
let base = base_url().await;
1653
+
let (did, jwt) = setup_new_user("scale-batch").await;
1654
+
1655
+
let batch_size = 200;
1656
+
let writes: Vec<Value> = (0..batch_size)
1657
+
.map(|i| {
1658
+
json!({
1659
+
"$type": "com.atproto.repo.applyWrites#create",
1660
+
"collection": "app.bsky.feed.post",
1661
+
"rkey": format!("batch_{:03}", i),
1662
+
"value": {
1663
+
"$type": "app.bsky.feed.post",
1664
+
"text": format!("Batch created post {}", i),
1665
+
"createdAt": Utc::now().to_rfc3339()
1666
+
}
1667
+
})
1668
+
})
1669
+
.collect();
1670
+
1671
+
let apply_res = client
1672
+
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base))
1673
+
.bearer_auth(&jwt)
1674
+
.json(&json!({
1675
+
"repo": did,
1676
+
"writes": writes
1677
+
}))
1678
+
.send()
1679
+
.await
1680
+
.expect("Batch create failed");
1681
+
assert_eq!(
1682
+
apply_res.status(),
1683
+
StatusCode::OK,
1684
+
"Batch create of {} posts should succeed",
1685
+
batch_size
1686
+
);
1687
+
1688
+
let batch_count = count_records(&client, base, &jwt, &did, "app.bsky.feed.post").await;
1689
+
assert_eq!(
1690
+
batch_count, batch_size,
1691
+
"Should have {} posts after batch create",
1692
+
batch_size
1693
+
);
1694
+
1695
+
let update_writes: Vec<Value> = (0..batch_size)
1696
+
.map(|i| {
1697
+
json!({
1698
+
"$type": "com.atproto.repo.applyWrites#update",
1699
+
"collection": "app.bsky.feed.post",
1700
+
"rkey": format!("batch_{:03}", i),
1701
+
"value": {
1702
+
"$type": "app.bsky.feed.post",
1703
+
"text": format!("UPDATED batch post {}", i),
1704
+
"createdAt": Utc::now().to_rfc3339()
1705
+
}
1706
+
})
1707
+
})
1708
+
.collect();
1709
+
1710
+
let update_res = client
1711
+
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base))
1712
+
.bearer_auth(&jwt)
1713
+
.json(&json!({
1714
+
"repo": did,
1715
+
"writes": update_writes
1716
+
}))
1717
+
.send()
1718
+
.await
1719
+
.expect("Batch update failed");
1720
+
assert_eq!(
1721
+
update_res.status(),
1722
+
StatusCode::OK,
1723
+
"Batch update of {} posts should succeed",
1724
+
batch_size
1725
+
);
1726
+
1727
+
let verify_res = client
1728
+
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base))
1729
+
.query(&[
1730
+
("repo", did.as_str()),
1731
+
("collection", "app.bsky.feed.post"),
1732
+
("rkey", "batch_000"),
1733
+
])
1734
+
.send()
1735
+
.await
1736
+
.unwrap();
1737
+
let verify_body: Value = verify_res.json().await.unwrap();
1738
+
assert!(
1739
+
verify_body["value"]["text"]
1740
+
.as_str()
1741
+
.unwrap()
1742
+
.starts_with("UPDATED"),
1743
+
"Post should be updated"
1744
+
);
1745
+
1746
+
let delete_writes: Vec<Value> = (0..batch_size)
1747
+
.map(|i| {
1748
+
json!({
1749
+
"$type": "com.atproto.repo.applyWrites#delete",
1750
+
"collection": "app.bsky.feed.post",
1751
+
"rkey": format!("batch_{:03}", i)
1752
+
})
1753
+
})
1754
+
.collect();
1755
+
1756
+
let delete_res = client
1757
+
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base))
1758
+
.bearer_auth(&jwt)
1759
+
.json(&json!({
1760
+
"repo": did,
1761
+
"writes": delete_writes
1762
+
}))
1763
+
.send()
1764
+
.await
1765
+
.expect("Batch delete failed");
1766
+
assert_eq!(
1767
+
delete_res.status(),
1768
+
StatusCode::OK,
1769
+
"Batch delete of {} posts should succeed",
1770
+
batch_size
1771
+
);
1772
+
1773
+
let final_res = client
1774
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
1775
+
.bearer_auth(&jwt)
1776
+
.query(&[
1777
+
("repo", did.as_str()),
1778
+
("collection", "app.bsky.feed.post"),
1779
+
("limit", "100"),
1780
+
])
1781
+
.send()
1782
+
.await
1783
+
.unwrap();
1784
+
let final_body: Value = final_res.json().await.unwrap();
1785
+
assert_eq!(
1786
+
final_body["records"].as_array().unwrap().len(),
1787
+
0,
1788
+
"Should have 0 posts after batch delete"
1789
+
);
1790
+
}
1791
+
1792
+
#[tokio::test]
1793
+
async fn test_scale_reply_thread_depth() {
1794
+
let client = client();
1795
+
let base = base_url().await;
1796
+
let (did, jwt) = setup_new_user("deep-thread").await;
1797
+
1798
+
let root_res = client
1799
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
1800
+
.bearer_auth(&jwt)
1801
+
.json(&json!({
1802
+
"repo": did,
1803
+
"collection": "app.bsky.feed.post",
1804
+
"record": {
1805
+
"$type": "app.bsky.feed.post",
1806
+
"text": "Root post of deep thread",
1807
+
"createdAt": Utc::now().to_rfc3339()
1808
+
}
1809
+
}))
1810
+
.send()
1811
+
.await
1812
+
.expect("Root post failed");
1813
+
assert_eq!(root_res.status(), StatusCode::OK);
1814
+
let root_body: Value = root_res.json().await.unwrap();
1815
+
let root_uri = root_body["uri"].as_str().unwrap().to_string();
1816
+
let root_cid = root_body["cid"].as_str().unwrap().to_string();
1817
+
1818
+
let thread_depth = 500;
1819
+
1820
+
struct ReplyState {
1821
+
parent_uri: String,
1822
+
parent_cid: String,
1823
+
depth: usize,
1824
+
}
1825
+
1826
+
let initial_state = ReplyState {
1827
+
parent_uri: root_uri.clone(),
1828
+
parent_cid: root_cid.clone(),
1829
+
depth: 1,
1830
+
};
1831
+
1832
+
let (parent_uri, reply_count) = futures::stream::unfold(initial_state, |state| {
1833
+
let client = client.clone();
1834
+
let base = base.to_string();
1835
+
let did = did.clone();
1836
+
let jwt = jwt.clone();
1837
+
let root_uri = root_uri.clone();
1838
+
let root_cid = root_cid.clone();
1839
+
async move {
1840
+
if state.depth > thread_depth {
1841
+
return None;
1842
+
}
1843
+
1844
+
let res = client
1845
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
1846
+
.bearer_auth(&jwt)
1847
+
.json(&json!({
1848
+
"repo": did,
1849
+
"collection": "app.bsky.feed.post",
1850
+
"record": {
1851
+
"$type": "app.bsky.feed.post",
1852
+
"text": format!("Reply at depth {}", state.depth),
1853
+
"createdAt": Utc::now().to_rfc3339(),
1854
+
"reply": {
1855
+
"root": { "uri": root_uri, "cid": root_cid },
1856
+
"parent": { "uri": &state.parent_uri, "cid": &state.parent_cid }
1857
+
}
1858
+
}
1859
+
}))
1860
+
.send()
1861
+
.await
1862
+
.expect("Reply failed");
1863
+
assert_eq!(
1864
+
res.status(),
1865
+
StatusCode::OK,
1866
+
"Reply at depth {} failed",
1867
+
state.depth
1868
+
);
1869
+
let body: Value = res.json().await.unwrap();
1870
+
let new_uri = body["uri"].as_str().unwrap().to_string();
1871
+
let new_cid = body["cid"].as_str().unwrap().to_string();
1872
+
1873
+
let next_state = ReplyState {
1874
+
parent_uri: new_uri.clone(),
1875
+
parent_cid: new_cid,
1876
+
depth: state.depth + 1,
1877
+
};
1878
+
1879
+
Some((new_uri, next_state))
1880
+
}
1881
+
})
1882
+
.fold((String::new(), 0usize), |(_, count), uri| async move {
1883
+
(uri, count + 1)
1884
+
})
1885
+
.await;
1886
+
1887
+
assert_eq!(
1888
+
reply_count, thread_depth,
1889
+
"Should have created {} replies",
1890
+
thread_depth
1891
+
);
1892
+
1893
+
let thread_count = count_records(&client, base, &jwt, &did, "app.bsky.feed.post").await;
1894
+
assert_eq!(
1895
+
thread_count,
1896
+
thread_depth + 1,
1897
+
"Should have root + {} replies",
1898
+
thread_depth
1899
+
);
1900
+
1901
+
let deepest_rkey = parent_uri.split('/').next_back().unwrap();
1902
+
let deep_res = client
1903
+
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base))
1904
+
.query(&[
1905
+
("repo", did.as_str()),
1906
+
("collection", "app.bsky.feed.post"),
1907
+
("rkey", deepest_rkey),
1908
+
])
1909
+
.send()
1910
+
.await
1911
+
.unwrap();
1912
+
assert_eq!(deep_res.status(), StatusCode::OK);
1913
+
let deep_body: Value = deep_res.json().await.unwrap();
1914
+
assert_eq!(
1915
+
deep_body["value"]["reply"]["root"]["uri"], root_uri,
1916
+
"Deepest reply should reference root"
1917
+
);
1918
+
}
1919
+
1920
+
#[tokio::test]
1921
+
async fn test_concurrent_import_and_writes() {
1922
+
let client = client();
1923
+
let base = base_url().await;
1924
+
let (did, jwt) = setup_new_user("import-conc").await;
1925
+
1926
+
let key_bytes = get_user_signing_key(&did)
1927
+
.await
1928
+
.expect("Failed to get user signing key");
1929
+
let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key");
1930
+
1931
+
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
1932
+
1933
+
let write_count = 10;
1934
+
1935
+
let import_future = {
1936
+
let client = client.clone();
1937
+
let base = base.to_string();
1938
+
let jwt = jwt.clone();
1939
+
let car_bytes = car_bytes.clone();
1940
+
async move {
1941
+
let res = client
1942
+
.post(format!("{}/xrpc/com.atproto.repo.importRepo", base))
1943
+
.bearer_auth(&jwt)
1944
+
.header("Content-Type", "application/vnd.ipld.car")
1945
+
.body(car_bytes)
1946
+
.send()
1947
+
.await
1948
+
.expect("Import request failed");
1949
+
let status = res.status();
1950
+
let body: Value = res.json().await.unwrap_or_default();
1951
+
assert_eq!(status, StatusCode::OK, "Import should succeed: {:?}", body);
1952
+
}
1953
+
};
1954
+
1955
+
let write_futures: Vec<_> = (0..write_count)
1956
+
.map(|i| {
1957
+
let client = client.clone();
1958
+
let base = base.to_string();
1959
+
let did = did.clone();
1960
+
let jwt = jwt.clone();
1961
+
async move {
1962
+
let res = client
1963
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
1964
+
.bearer_auth(&jwt)
1965
+
.json(&json!({
1966
+
"repo": did,
1967
+
"collection": "app.bsky.feed.post",
1968
+
"record": {
1969
+
"$type": "app.bsky.feed.post",
1970
+
"text": format!("Concurrent post {}", i),
1971
+
"createdAt": Utc::now().to_rfc3339()
1972
+
}
1973
+
}))
1974
+
.send()
1975
+
.await
1976
+
.expect("Write request failed");
1977
+
let status = res.status();
1978
+
let body: Value = res.json().await.unwrap_or_default();
1979
+
assert_eq!(
1980
+
status,
1981
+
StatusCode::OK,
1982
+
"Write {} should succeed: {:?}",
1983
+
i,
1984
+
body
1985
+
);
1986
+
}
1987
+
})
1988
+
.collect();
1989
+
1990
+
tokio::join!(import_future, join_all(write_futures));
1991
+
1992
+
let final_posts = client
1993
+
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
1994
+
.bearer_auth(&jwt)
1995
+
.query(&[
1996
+
("repo", did.as_str()),
1997
+
("collection", "app.bsky.feed.post"),
1998
+
("limit", "100"),
1999
+
])
2000
+
.send()
2001
+
.await
2002
+
.unwrap();
2003
+
let final_body: Value = final_posts.json().await.unwrap();
2004
+
let record_count = final_body["records"].as_array().unwrap().len();
2005
+
2006
+
let min_expected = write_count;
2007
+
assert!(
2008
+
record_count >= min_expected,
2009
+
"Expected at least {} records (from writes), got {} (import may also contribute records)",
2010
+
min_expected,
2011
+
record_count
2012
+
);
2013
+
}
+9
deploy/nginx/nginx-quadlet.conf
+9
deploy/nginx/nginx-quadlet.conf
···
92
92
proxy_set_header X-Forwarded-Proto $scheme;
93
93
}
94
94
95
+
location /webhook/ {
96
+
proxy_pass http://127.0.0.1:3000;
97
+
proxy_http_version 1.1;
98
+
proxy_set_header Host $host;
99
+
proxy_set_header X-Real-IP $remote_addr;
100
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
101
+
proxy_set_header X-Forwarded-Proto $scheme;
102
+
}
103
+
95
104
location = /metrics {
96
105
proxy_pass http://127.0.0.1:3000;
97
106
proxy_http_version 1.1;
+9
docs/install-debian.md
+9
docs/install-debian.md
···
203
203
proxy_set_header X-Forwarded-Proto $scheme;
204
204
}
205
205
206
+
location /webhook/ {
207
+
proxy_pass http://127.0.0.1:3000;
208
+
proxy_http_version 1.1;
209
+
proxy_set_header Host $host;
210
+
proxy_set_header X-Real-IP $remote_addr;
211
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
212
+
proxy_set_header X-Forwarded-Proto $scheme;
213
+
}
214
+
206
215
location = /metrics {
207
216
proxy_pass http://127.0.0.1:3000;
208
217
proxy_http_version 1.1;
+56
-22
frontend/src/components/dashboard/CommsContent.svelte
+56
-22
frontend/src/components/dashboard/CommsContent.svelte
···
17
17
let saving = $state(false)
18
18
let preferredChannel = $state('email')
19
19
let availableCommsChannels = $state<string[]>(['email'])
20
+
let telegramBotUsername = $state<string | undefined>(undefined)
20
21
let email = $state('')
21
22
let discordId = $state('')
22
23
let discordVerified = $state(false)
···
24
25
let telegramVerified = $state(false)
25
26
let signalNumber = $state('')
26
27
let signalVerified = $state(false)
28
+
let savedDiscordId = $state('')
29
+
let savedTelegramUsername = $state('')
30
+
let savedSignalNumber = $state('')
27
31
let verifyingChannel = $state<string | null>(null)
28
32
let verificationCode = $state('')
29
33
let historyLoading = $state(true)
···
59
63
telegramVerified = prefs.telegramVerified
60
64
signalNumber = prefs.signalNumber ?? ''
61
65
signalVerified = prefs.signalVerified
66
+
savedDiscordId = discordId
67
+
savedTelegramUsername = telegramUsername
68
+
savedSignalNumber = signalNumber
62
69
availableCommsChannels = serverInfo.availableCommsChannels ?? ['email']
70
+
telegramBotUsername = serverInfo.telegramBotUsername
63
71
} catch (e) {
64
72
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad'))
65
73
} finally {
···
71
79
e.preventDefault()
72
80
saving = true
73
81
try {
74
-
await api.updateNotificationPrefs(session.accessJwt, {
82
+
const result = await api.updateNotificationPrefs(session.accessJwt, {
75
83
preferredChannel,
76
-
discordId: discordId || undefined,
77
-
telegramUsername: telegramUsername || undefined,
78
-
signalNumber: signalNumber || undefined,
84
+
discordId: discordId !== savedDiscordId ? discordId : undefined,
85
+
telegramUsername: telegramUsername !== savedTelegramUsername ? telegramUsername : undefined,
86
+
signalNumber: signalNumber !== savedSignalNumber ? signalNumber : undefined,
79
87
})
80
88
await refreshSession()
81
89
toast.success($_('comms.preferencesSaved'))
82
-
await loadPrefs()
90
+
savedDiscordId = discordId
91
+
savedTelegramUsername = telegramUsername
92
+
savedSignalNumber = signalNumber
93
+
const channelToVerify = result.verificationRequired?.find(
94
+
(ch: string) => ch === 'discord' || ch === 'telegram' || ch === 'signal'
95
+
)
96
+
if (channelToVerify) {
97
+
verifyingChannel = channelToVerify
98
+
verificationCode = ''
99
+
}
83
100
} catch (e) {
84
101
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToSave'))
85
102
} finally {
···
218
235
<div class="config-item">
219
236
<div class="config-header">
220
237
<label for="email">{$_('register.email')}</label>
221
-
<span class="status verified">{$_('comms.primary')}</span>
238
+
<span class="status verified">
239
+
{preferredChannel === 'email' ? $_('comms.primary') : $_('comms.verified')}
240
+
</span>
222
241
</div>
223
242
<input id="email" type="email" value={email} disabled class="readonly" />
224
243
</div>
···
229
248
<label for="discord">{$_('register.discordId')}</label>
230
249
{#if discordId}
231
250
<span class="status" class:verified={discordVerified} class:unverified={!discordVerified}>
232
-
{discordVerified ? $_('comms.verified') : $_('comms.notVerified')}
251
+
{preferredChannel === 'discord' && discordVerified ? $_('comms.primary') : discordVerified ? $_('comms.verified') : $_('comms.notVerified')}
233
252
</span>
234
253
{/if}
235
254
</div>
···
242
261
placeholder={$_('register.discordIdPlaceholder')}
243
262
disabled={saving}
244
263
/>
245
-
{#if discordId && !discordVerified}
264
+
{#if discordId && discordId === savedDiscordId && !discordVerified}
246
265
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button>
247
266
{/if}
248
267
</div>
···
251
270
{/if}
252
271
{#if verifyingChannel === 'discord'}
253
272
<div class="verify-form">
254
-
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
273
+
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="128" />
255
274
<button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button>
256
275
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
257
276
</div>
···
265
284
<label for="telegram">{$_('register.telegramUsername')}</label>
266
285
{#if telegramUsername}
267
286
<span class="status" class:verified={telegramVerified} class:unverified={!telegramVerified}>
268
-
{telegramVerified ? $_('comms.verified') : $_('comms.notVerified')}
287
+
{preferredChannel === 'telegram' && telegramVerified ? $_('comms.primary') : telegramVerified ? $_('comms.verified') : $_('comms.notVerified')}
269
288
</span>
270
289
{/if}
271
290
</div>
···
278
297
placeholder={$_('register.telegramUsernamePlaceholder')}
279
298
disabled={saving}
280
299
/>
281
-
{#if telegramUsername && !telegramVerified}
282
-
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button>
283
-
{/if}
284
300
</div>
285
301
{#if telegramInUse}
286
302
<p class="hint warning">{$_('comms.telegramInUseWarning')}</p>
287
303
{/if}
288
-
{#if verifyingChannel === 'telegram'}
289
-
<div class="verify-form">
290
-
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
291
-
<button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button>
292
-
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
304
+
{#if telegramUsername && telegramUsername === savedTelegramUsername && !telegramVerified && telegramBotUsername}
305
+
{@const encodedHandle = session.handle.replaceAll('.', '_')}
306
+
<div class="telegram-verify-prompt">
307
+
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('comms.telegramOpenLink')}</a>
308
+
<span class="manual-hint">{$_('comms.telegramStartBot', { values: { botUsername: telegramBotUsername, handle: session.handle } })}</span>
293
309
</div>
294
310
{/if}
295
311
</div>
···
301
317
<label for="signal">{$_('register.signalNumber')}</label>
302
318
{#if signalNumber}
303
319
<span class="status" class:verified={signalVerified} class:unverified={!signalVerified}>
304
-
{signalVerified ? $_('comms.verified') : $_('comms.notVerified')}
320
+
{preferredChannel === 'signal' && signalVerified ? $_('comms.primary') : signalVerified ? $_('comms.verified') : $_('comms.notVerified')}
305
321
</span>
306
322
{/if}
307
323
</div>
···
314
330
placeholder={$_('register.signalNumberPlaceholder')}
315
331
disabled={saving}
316
332
/>
317
-
{#if signalNumber && !signalVerified}
333
+
{#if signalNumber && signalNumber === savedSignalNumber && !signalVerified}
318
334
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
319
335
{/if}
320
336
</div>
···
323
339
{/if}
324
340
{#if verifyingChannel === 'signal'}
325
341
<div class="verify-form">
326
-
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
342
+
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="128" />
327
343
<button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button>
328
344
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
329
345
</div>
···
505
521
color: var(--warning-text);
506
522
}
507
523
524
+
.telegram-verify-prompt {
525
+
display: flex;
526
+
flex-direction: column;
527
+
gap: var(--space-2);
528
+
padding: var(--space-3) var(--space-4);
529
+
background: var(--accent-bg, var(--bg-card));
530
+
border: 1px solid var(--accent, var(--border-color));
531
+
border-radius: var(--radius-md);
532
+
font-size: var(--text-sm);
533
+
color: var(--text-primary);
534
+
}
535
+
536
+
.manual-hint {
537
+
font-size: var(--text-xs);
538
+
color: var(--text-secondary);
539
+
}
540
+
508
541
.verify-btn {
509
542
padding: var(--space-2) var(--space-3);
510
543
font-size: var(--text-sm);
···
517
550
}
518
551
519
552
.verify-form input {
520
-
width: 120px;
553
+
flex: 1;
554
+
min-width: 0;
521
555
}
522
556
523
557
.verify-form button {
+13
-2
frontend/src/lib/api.ts
+13
-2
frontend/src/lib/api.ts
···
80
80
TotpStatus,
81
81
UpdateLegacyLoginResponse,
82
82
UpdateLocaleResponse,
83
+
UpdateNotificationPrefsResponse,
83
84
UploadBlobResponse,
84
85
VerificationChannel,
85
86
VerifyMigrationEmailResponse,
···
479
480
});
480
481
},
481
482
483
+
checkChannelVerified(
484
+
did: string,
485
+
channel: string,
486
+
): Promise<{ verified: boolean }> {
487
+
return xrpc("_checkChannelVerified", {
488
+
method: "POST",
489
+
body: { did, channel },
490
+
});
491
+
},
492
+
482
493
checkEmailInUse(email: string): Promise<{ inUse: boolean }> {
483
494
return xrpc("_account.checkEmailInUse", {
484
495
method: "POST",
···
648
659
discordId?: string;
649
660
telegramUsername?: string;
650
661
signalNumber?: string;
651
-
}): Promise<SuccessResponse> {
662
+
}): Promise<UpdateNotificationPrefsResponse> {
652
663
return xrpc("_account.updateNotificationPrefs", {
653
664
method: "POST",
654
665
token,
···
1847
1858
telegramUsername?: string;
1848
1859
signalNumber?: string;
1849
1860
},
1850
-
): Promise<Result<SuccessResponse, ApiError>> {
1861
+
): Promise<Result<UpdateNotificationPrefsResponse, ApiError>> {
1851
1862
return xrpcResult("_account.updateNotificationPrefs", {
1852
1863
method: "POST",
1853
1864
token,
+66
-32
frontend/src/lib/registration/VerificationStep.svelte
+66
-32
frontend/src/lib/registration/VerificationStep.svelte
···
1
1
<script lang="ts">
2
-
import { onDestroy } from 'svelte'
2
+
import { onDestroy, onMount } from 'svelte'
3
3
import { api, ApiError } from '../api'
4
4
import { resendVerification } from '../auth.svelte'
5
5
import type { RegistrationFlow } from './flow.svelte'
···
13
13
let verificationCode = $state('')
14
14
let resending = $state(false)
15
15
let resendMessage = $state<string | null>(null)
16
+
let telegramBotUsername = $state<string | undefined>(undefined)
16
17
17
18
let pollingInterval: ReturnType<typeof setInterval> | null = null
18
19
20
+
const isTelegram = $derived(flow.info.verificationChannel === 'telegram')
21
+
22
+
onMount(async () => {
23
+
if (isTelegram) {
24
+
try {
25
+
const serverInfo = await api.describeServer()
26
+
telegramBotUsername = serverInfo.telegramBotUsername
27
+
} catch {
28
+
}
29
+
}
30
+
})
31
+
19
32
$effect(() => {
20
-
if (flow.state.step === 'verify' && flow.account && !verificationCode.trim()) {
33
+
if (flow.state.step === 'verify' && flow.account && (isTelegram || !verificationCode.trim())) {
21
34
pollingInterval = setInterval(async () => {
22
-
if (verificationCode.trim()) return
35
+
if (!isTelegram && verificationCode.trim()) return
23
36
const advanced = await flow.checkAndAdvanceIfVerified()
24
37
if (advanced && pollingInterval) {
25
38
clearInterval(pollingInterval)
···
84
97
</script>
85
98
86
99
<div class="verification-step">
87
-
<p class="info-text">
88
-
We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}.
89
-
Enter it below to continue.
90
-
</p>
100
+
{#if isTelegram && telegramBotUsername}
101
+
{@const handle = flow.account?.handle ?? `${flow.info.handle.trim()}.${flow.state.pdsHostname}`}
102
+
{@const encodedHandle = handle.replaceAll('.', '_')}
103
+
<p class="info-text">
104
+
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">Open Telegram to verify</a>,
105
+
or send <code>/start {handle}</code> to <code>@{telegramBotUsername}</code> manually.
106
+
</p>
107
+
<p class="info-text waiting">Waiting for verification...</p>
108
+
{:else}
109
+
<p class="info-text">
110
+
We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}.
111
+
Enter it below to continue.
112
+
</p>
91
113
92
-
{#if resendMessage}
93
-
<div class="message success">{resendMessage}</div>
94
-
{/if}
114
+
{#if resendMessage}
115
+
<div class="message success">{resendMessage}</div>
116
+
{/if}
95
117
96
-
<form onsubmit={handleSubmit}>
97
-
<div class="field">
98
-
<label for="verification-code">Verification Code</label>
99
-
<input
100
-
id="verification-code"
101
-
type="text"
102
-
bind:value={verificationCode}
103
-
placeholder="XXXX-XXXX-XXXX-XXXX"
104
-
disabled={flow.state.submitting}
105
-
required
106
-
autocomplete="one-time-code"
107
-
class="code-input"
108
-
/>
109
-
<span class="hint">Copy the entire code from your message, including dashes.</span>
110
-
</div>
118
+
<form onsubmit={handleSubmit}>
119
+
<div class="field">
120
+
<label for="verification-code">Verification Code</label>
121
+
<input
122
+
id="verification-code"
123
+
type="text"
124
+
bind:value={verificationCode}
125
+
placeholder="XXXX-XXXX-XXXX-XXXX"
126
+
disabled={flow.state.submitting}
127
+
required
128
+
autocomplete="one-time-code"
129
+
class="code-input"
130
+
/>
131
+
<span class="hint">Copy the entire code from your message, including dashes.</span>
132
+
</div>
111
133
112
-
<button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}>
113
-
{flow.state.submitting ? 'Verifying...' : 'Verify'}
114
-
</button>
134
+
<button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}>
135
+
{flow.state.submitting ? 'Verifying...' : 'Verify'}
136
+
</button>
115
137
116
-
<button type="button" class="secondary" onclick={handleResend} disabled={resending}>
117
-
{resending ? 'Resending...' : 'Resend Code'}
118
-
</button>
119
-
</form>
138
+
<button type="button" class="secondary" onclick={handleResend} disabled={resending}>
139
+
{resending ? 'Resending...' : 'Resend Code'}
140
+
</button>
141
+
</form>
142
+
{/if}
120
143
</div>
121
144
122
145
<style>
···
129
152
.info-text {
130
153
color: var(--text-secondary);
131
154
margin: 0;
155
+
}
156
+
157
+
.info-text.waiting {
158
+
font-size: var(--text-sm);
159
+
}
160
+
161
+
.info-text code {
162
+
font-family: var(--font-mono, monospace);
163
+
background: var(--bg-secondary);
164
+
padding: 0.1em 0.3em;
165
+
border-radius: var(--radius-sm);
132
166
}
133
167
134
168
.code-input {
+4
-1
frontend/src/lib/registration/flow.svelte.ts
+4
-1
frontend/src/lib/registration/flow.svelte.ts
···
421
421
422
422
checkingVerification = true;
423
423
try {
424
-
const result = await api.checkEmailVerified(state.account.did);
424
+
const result = await api.checkChannelVerified(
425
+
state.account.did,
426
+
state.info.verificationChannel,
427
+
);
425
428
if (!result.verified) return false;
426
429
427
430
if (state.info.didType === "web-external") {
+6
frontend/src/lib/types/api.ts
+6
frontend/src/lib/types/api.ts
···
232
232
version?: string;
233
233
availableCommsChannels?: VerificationChannel[];
234
234
selfHostedDidWebEnabled?: boolean;
235
+
telegramBotUsername?: string;
236
+
}
237
+
238
+
export interface UpdateNotificationPrefsResponse {
239
+
success: boolean;
240
+
verificationRequired: string[];
235
241
}
236
242
237
243
export interface RepoInfo {
+3
-1
frontend/src/locales/en.json
+3
-1
frontend/src/locales/en.json
···
439
439
"noMessages": "No messages found.",
440
440
"discordInUseWarning": "This Discord ID is already associated with another account.",
441
441
"telegramInUseWarning": "This Telegram username is already associated with another account.",
442
-
"signalInUseWarning": "This Signal number is already associated with another account."
442
+
"signalInUseWarning": "This Signal number is already associated with another account.",
443
+
"telegramStartBot": "Or send /start {handle} to @{botUsername} manually",
444
+
"telegramOpenLink": "Open Telegram to verify"
443
445
},
444
446
"repoExplorer": {
445
447
"collections": "Collections",
+2
frontend/src/locales/fi.json
+2
frontend/src/locales/fi.json
···
436
436
"discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.",
437
437
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
438
438
"signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin.",
439
+
"telegramStartBot": "Tai lähetä /start {handle} käyttäjälle @{botUsername} manuaalisesti",
440
+
"telegramOpenLink": "Avaa Telegram vahvistaaksesi",
439
441
"failedToLoad": "Asetusten lataus epäonnistui",
440
442
"failedToSave": "Asetusten tallennus epäonnistui",
441
443
"failedToVerify": "Vahvistus epäonnistui",
+2
frontend/src/locales/ja.json
+2
frontend/src/locales/ja.json
···
436
436
"discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。",
437
437
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
438
438
"signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。",
439
+
"telegramStartBot": "または @{botUsername} に /start {handle} を手動で送信",
440
+
"telegramOpenLink": "Telegram で確認する",
439
441
"failedToLoad": "設定の読み込みに失敗しました",
440
442
"failedToSave": "設定の保存に失敗しました",
441
443
"failedToVerify": "確認に失敗しました",
+2
frontend/src/locales/ko.json
+2
frontend/src/locales/ko.json
···
436
436
"discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.",
437
437
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
438
438
"signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.",
439
+
"telegramStartBot": "또는 @{botUsername}에게 /start {handle}을 직접 보내세요",
440
+
"telegramOpenLink": "Telegram에서 인증하기",
439
441
"failedToLoad": "설정 로딩 실패",
440
442
"failedToSave": "설정 저장 실패",
441
443
"failedToVerify": "인증 실패",
+2
frontend/src/locales/sv.json
+2
frontend/src/locales/sv.json
···
436
436
"discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.",
437
437
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
438
438
"signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto.",
439
+
"telegramStartBot": "Eller skicka /start {handle} till @{botUsername} manuellt",
440
+
"telegramOpenLink": "Öppna Telegram för att verifiera",
439
441
"failedToLoad": "Kunde inte ladda inställningar",
440
442
"failedToSave": "Kunde inte spara inställningar",
441
443
"failedToVerify": "Verifiering misslyckades",
+2
frontend/src/locales/zh.json
+2
frontend/src/locales/zh.json
···
436
436
"discordInUseWarning": "此 Discord ID 已与另一个账户关联。",
437
437
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
438
438
"signalInUseWarning": "此 Signal 号码已与另一个账户关联。",
439
+
"telegramStartBot": "或手动向 @{botUsername} 发送 /start {handle}",
440
+
"telegramOpenLink": "打开 Telegram 验证",
439
441
"failedToLoad": "加载偏好设置失败",
440
442
"failedToSave": "保存偏好设置失败",
441
443
"failedToVerify": "验证失败",
+6
-7
frontend/src/routes/OAuthSsoRegister.svelte
+6
-7
frontend/src/routes/OAuthSsoRegister.svelte
···
99
99
inviteCodeRequired: data.inviteCodeRequired ?? false,
100
100
selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false,
101
101
}
102
-
if (data.commsChannels) {
103
-
commsChannels = {
104
-
email: data.commsChannels.email ?? true,
105
-
discord: data.commsChannels.discord ?? false,
106
-
telegram: data.commsChannels.telegram ?? false,
107
-
signal: data.commsChannels.signal ?? false,
108
-
}
102
+
const available: string[] = data.availableCommsChannels ?? ['email']
103
+
commsChannels = {
104
+
email: available.includes('email'),
105
+
discord: available.includes('discord'),
106
+
telegram: available.includes('telegram'),
107
+
signal: available.includes('signal'),
109
108
}
110
109
}
111
110
} catch {
+6
-7
frontend/src/routes/SsoRegisterComplete.svelte
+6
-7
frontend/src/routes/SsoRegisterComplete.svelte
···
114
114
inviteCodeRequired: data.inviteCodeRequired ?? false,
115
115
selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false,
116
116
}
117
-
if (data.commsChannels) {
118
-
commsChannels = {
119
-
email: data.commsChannels.email ?? true,
120
-
discord: data.commsChannels.discord ?? false,
121
-
telegram: data.commsChannels.telegram ?? false,
122
-
signal: data.commsChannels.signal ?? false,
123
-
}
117
+
const available: string[] = data.availableCommsChannels ?? ['email']
118
+
commsChannels = {
119
+
email: available.includes('email'),
120
+
discord: available.includes('discord'),
121
+
telegram: available.includes('telegram'),
122
+
signal: available.includes('signal'),
124
123
}
125
124
}
126
125
} catch {
+71
-26
frontend/src/routes/Verify.svelte
+71
-26
frontend/src/routes/Verify.svelte
···
32
32
let successChannel = $state<string | null>(null)
33
33
let tokenFromUrl = $state(false)
34
34
let oauthRequestUri = $state<string | null>(null)
35
+
let telegramBotUsername = $state<string | undefined>(undefined)
35
36
36
37
const auth = $derived(getAuthState())
37
38
···
40
41
}
41
42
42
43
const session = $derived(getSession())
44
+
const isTelegram = $derived(pendingVerification?.channel === 'telegram')
43
45
44
46
function parseQueryParams(): Record<string, string> {
45
47
return Object.fromEntries(new URLSearchParams(window.location.search))
···
99
101
channel: params.channel,
100
102
}))
101
103
}
104
+
105
+
if (pendingVerification?.channel === 'telegram') {
106
+
try {
107
+
const serverInfo = await api.describeServer()
108
+
telegramBotUsername = serverInfo.telegramBotUsername
109
+
} catch {
110
+
}
111
+
}
102
112
}
103
113
})
104
114
···
111
121
112
122
let pollingVerification = false
113
123
$effect(() => {
114
-
if (mode === 'signup' && pendingVerification && !verificationCode.trim()) {
124
+
if (mode === 'signup' && pendingVerification && (isTelegram || !verificationCode.trim())) {
115
125
const currentPending = pendingVerification
116
126
const interval = setInterval(async () => {
117
-
if (pollingVerification || verificationCode.trim()) return
127
+
if (pollingVerification || (!isTelegram && verificationCode.trim())) return
118
128
pollingVerification = true
119
129
try {
120
-
const result = await api.checkEmailVerified(currentPending.did)
130
+
const result = await api.checkChannelVerified(currentPending.did, currentPending.channel)
121
131
if (result.verified) {
122
132
clearInterval(interval)
123
133
clearPendingVerification()
···
435
445
<div class="message success">{resendMessage}</div>
436
446
{/if}
437
447
438
-
<form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}>
439
-
<div class="field">
440
-
<label for="verification-code">{$_('verify.codeLabel')}</label>
441
-
<input
442
-
id="verification-code"
443
-
type="text"
444
-
bind:value={verificationCode}
445
-
placeholder={$_('verify.codePlaceholder')}
446
-
disabled={submitting}
447
-
required
448
-
autocomplete="off"
449
-
class="token-input"
450
-
/>
451
-
<p class="field-help">{$_('verify.codeHelp')}</p>
448
+
{#if isTelegram && telegramBotUsername}
449
+
{@const encodedHandle = pendingVerification.handle.replaceAll('.', '_')}
450
+
<div class="telegram-hint">
451
+
<p>
452
+
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('comms.telegramOpenLink')}</a>
453
+
</p>
454
+
<p class="manual-text">
455
+
{$_('comms.telegramStartBot', { values: { botUsername: telegramBotUsername, handle: pendingVerification.handle } })}
456
+
</p>
457
+
<p class="waiting-text">{$_('verify.pleaseWait')}</p>
452
458
</div>
459
+
{:else}
460
+
<form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}>
461
+
<div class="field">
462
+
<label for="verification-code">{$_('verify.codeLabel')}</label>
463
+
<input
464
+
id="verification-code"
465
+
type="text"
466
+
bind:value={verificationCode}
467
+
placeholder={$_('verify.codePlaceholder')}
468
+
disabled={submitting}
469
+
required
470
+
autocomplete="off"
471
+
class="token-input"
472
+
/>
473
+
<p class="field-help">{$_('verify.codeHelp')}</p>
474
+
</div>
453
475
454
-
<div class="form-actions">
455
-
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
456
-
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
457
-
</button>
458
-
<button type="submit" disabled={submitting || !verificationCode.trim()}>
459
-
{submitting ? $_('common.verifying') : $_('common.verify')}
460
-
</button>
461
-
</div>
462
-
</form>
476
+
<div class="form-actions">
477
+
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
478
+
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
479
+
</button>
480
+
<button type="submit" disabled={submitting || !verificationCode.trim()}>
481
+
{submitting ? $_('common.verifying') : $_('common.verify')}
482
+
</button>
483
+
</div>
484
+
</form>
485
+
{/if}
463
486
464
487
<p class="link-text">
465
488
<a href="/app/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a>
···
585
608
.success-container .btn {
586
609
flex: none;
587
610
padding: var(--space-4) var(--space-8);
611
+
}
612
+
613
+
.telegram-hint {
614
+
padding: var(--space-4);
615
+
background: var(--bg-secondary);
616
+
border-radius: var(--radius-md);
617
+
}
618
+
619
+
.telegram-hint p {
620
+
margin: 0;
621
+
}
622
+
623
+
.telegram-hint .manual-text {
624
+
font-size: var(--text-sm);
625
+
color: var(--text-secondary);
626
+
margin-top: var(--space-1);
627
+
}
628
+
629
+
.telegram-hint .waiting-text {
630
+
font-size: var(--text-sm);
631
+
color: var(--text-secondary);
632
+
margin-top: var(--space-2);
588
633
}
589
634
</style>
+1
migrations/20260203_telegram_chat_id.sql
+1
migrations/20260203_telegram_chat_id.sql
···
1
+
ALTER TABLE users ADD COLUMN telegram_chat_id BIGINT;
+1
migrations/20260204_channel_verified_comms_type.sql
+1
migrations/20260204_channel_verified_comms_type.sql
···
1
+
ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'channel_verified';
+9
nginx.frontend.conf
+9
nginx.frontend.conf
···
122
122
proxy_set_header X-Forwarded-Proto $scheme;
123
123
}
124
124
125
+
location /webhook/ {
126
+
proxy_pass http://backend;
127
+
proxy_http_version 1.1;
128
+
proxy_set_header Host $host;
129
+
proxy_set_header X-Real-IP $remote_addr;
130
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
131
+
proxy_set_header X-Forwarded-Proto $scheme;
132
+
}
133
+
125
134
location = /metrics {
126
135
proxy_pass http://backend;
127
136
proxy_http_version 1.1;