mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-25 02:36:06 +00:00
Compare commits
155
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
726cbbfc53 | ||
|
|
e41f34746a | ||
|
|
fbfa15b0b4 | ||
|
|
eb1a89dc58 | ||
|
|
6ca6c45605 | ||
|
|
d238affd76 | ||
|
|
469255f5a9 | ||
|
|
8559764d31 | ||
|
|
a405d523ca | ||
|
|
3c46e5fc73 | ||
|
|
f330dcd366 | ||
|
|
1411506d8c | ||
|
|
1a9dcf86ba | ||
|
|
8ae9ce9c8f | ||
|
|
6ec4484cad | ||
|
|
f3af04e4ae | ||
|
|
aab1a945c2 | ||
|
|
9dc184ee33 | ||
|
|
ab4eba6dc4 | ||
|
|
a171518290 | ||
|
|
28f2e04019 | ||
|
|
39a2e40b35 | ||
|
|
05ab0b7423 | ||
|
|
221b32d66f | ||
|
|
1b489776c5 | ||
|
|
2ca15fe7a0 | ||
|
|
48ae1e8b7b | ||
|
|
6838976969 | ||
|
|
3045ee25c0 | ||
|
|
04a90b1563 | ||
|
|
e13ba7f4c7 | ||
|
|
a3f729c3cd | ||
|
|
637b817a33 | ||
|
|
562f970bc3 | ||
|
|
06fd6a1ce9 | ||
|
|
3b07cdfb24 | ||
|
|
4a8826b7a1 | ||
|
|
80afd764d3 | ||
|
|
5bbe2146ff | ||
|
|
b009ccdaf2 | ||
|
|
39f74b5adf | ||
|
|
a231d7da29 | ||
|
|
63d84d38fb | ||
|
|
fe9b88141c | ||
|
|
72f5dce32b | ||
|
|
cd7e01100e | ||
|
|
7c248be153 | ||
|
|
91999819c6 | ||
|
|
ffce1d5d05 | ||
|
|
8e6ace2fe2 | ||
|
|
3018a20843 | ||
|
|
37fc06fb39 | ||
|
|
728a8c4d3b | ||
|
|
3d49e99cc3 | ||
|
|
7e823673ca | ||
|
|
320933598c | ||
|
|
500dc2e0e6 | ||
|
|
a220611a8b | ||
|
|
ca7a4b4b73 | ||
|
|
8ff02610e4 | ||
|
|
22f82489d5 | ||
|
|
cee483e358 | ||
|
|
7f8e858137 | ||
|
|
44d73dac58 | ||
|
|
b8cae15c12 | ||
|
|
9b58961bba | ||
|
|
31ee12ecd3 | ||
|
|
ea106d5246 | ||
|
|
4015217a2e | ||
|
|
7e4c9e1dab | ||
|
|
e9dc57d6f4 | ||
|
|
4e2525b245 | ||
|
|
38508c2c6e | ||
|
|
7a54ccf6a3 | ||
|
|
e85394c314 | ||
|
|
411c6be108 | ||
|
|
28aa7ab7fc | ||
|
|
348b69d76b | ||
|
|
4b23ca2c36 | ||
|
|
a7052e878c | ||
|
|
fc6063dba8 | ||
|
|
44cb016762 | ||
|
|
e6eee18ace | ||
|
|
96c8375706 | ||
|
|
86c5995568 | ||
|
|
036c317fd6 | ||
|
|
4d2c7d4723 | ||
|
|
f24a9f8bc0 | ||
|
|
f6ef6ecbd9 | ||
|
|
a2567bdb1a | ||
|
|
56120d252d | ||
|
|
210b0f463c | ||
|
|
c80f504dc0 | ||
|
|
a3bd7c59ad | ||
|
|
9ebde27540 | ||
|
|
28a7834304 | ||
|
|
191da5b311 | ||
|
|
a5b4ba7d65 | ||
|
|
90dabd8840 | ||
|
|
19eaccea74 | ||
|
|
f620a6bc43 | ||
|
|
04f370aaa1 | ||
|
|
3fd8f7ebbf | ||
|
|
0afcb2ee28 | ||
|
|
8bd556f65b | ||
|
|
cc92594506 | ||
|
|
76f22b801b | ||
|
|
021b7dbec4 | ||
|
|
eb034cb8b3 | ||
|
|
bdaf510898 | ||
|
|
deb2502112 | ||
|
|
1815ddba9f | ||
|
|
a7517ed5c9 | ||
|
|
d07d702dd4 | ||
|
|
1901b0a630 | ||
|
|
58f8d327c1 | ||
|
|
a13343e1de | ||
|
|
60e10af4aa | ||
|
|
f176f55862 | ||
|
|
fac9520a16 | ||
|
|
eee6fb9ff4 | ||
|
|
2462d0ab3b | ||
|
|
85f87f7b28 | ||
|
|
b1d86caa78 | ||
|
|
9b2cfb3a7e | ||
|
|
efd499bb26 | ||
|
|
d4dfe838eb | ||
|
|
af3821514f | ||
|
|
8f7aad3756 | ||
|
|
75b9e3165f | ||
|
|
ccc9916109 | ||
|
|
bc8fd66a45 | ||
|
|
180de29984 | ||
|
|
0455dc20bd | ||
|
|
2770b9b14a | ||
|
|
d436597184 | ||
|
|
4cfca6d956 | ||
|
|
98b94fb170 | ||
|
|
4fe01cff72 | ||
|
|
00c9eb732f | ||
|
|
6d2d3b4be4 | ||
|
|
9d81e58803 | ||
|
|
2afd075496 | ||
|
|
c74bf967cf | ||
|
|
1285d5c675 | ||
|
|
c30d73cd4d | ||
|
|
0fab8f2eb9 | ||
|
|
57336fa124 | ||
|
|
ace105899f | ||
|
|
c80a525e0d | ||
|
|
7edb76507b | ||
|
|
a20e4e05e6 | ||
|
|
3e7a199736 | ||
|
|
7ed9c8ce59 | ||
|
|
b0c3402a82 |
@@ -25,6 +25,28 @@ fail-fast = false
|
||||
test-threads = "num-cpus"
|
||||
slow-timeout = { period = "300s", terminate-after = 2 }
|
||||
|
||||
[profile.gauntlet-pr]
|
||||
retries = 0
|
||||
fail-fast = true
|
||||
test-threads = "num-cpus"
|
||||
slow-timeout = { period = "60s", terminate-after = 5 }
|
||||
|
||||
[[profile.gauntlet-pr.overrides]]
|
||||
filter = "binary(gauntlet_smoke)"
|
||||
slow-timeout = { period = "300s", terminate-after = 2 }
|
||||
|
||||
[profile.gauntlet-nightly]
|
||||
retries = 0
|
||||
fail-fast = false
|
||||
test-threads = "num-cpus"
|
||||
slow-timeout = { period = "600s", terminate-after = 1 }
|
||||
|
||||
[profile.gauntlet-soak]
|
||||
retries = 0
|
||||
fail-fast = false
|
||||
test-threads = 1
|
||||
slow-timeout = { period = "5m", terminate-after = 1000 }
|
||||
|
||||
[test-groups]
|
||||
serial-env-tests = { max-threads = 1 }
|
||||
heavy-load-tests = { max-threads = 4 }
|
||||
@@ -46,6 +68,10 @@ test-group = "serial-env-tests"
|
||||
filter = "package(tranquil-signal)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "package(tranquil-config)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "binary(whole_story)"
|
||||
test-group = "heavy-load-tests"
|
||||
@@ -80,6 +106,16 @@ slow-timeout = { period = "300s", terminate-after = 8 }
|
||||
filter = "binary(compaction_restart) | binary(mst_refcount_integrity) | binary(gc_compaction_restart)"
|
||||
slow-timeout = { period = "120s", terminate-after = 4 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "test(/retention_time_travel_survives_many_seeds/)"
|
||||
slow-timeout = { period = "300s", terminate-after = 24 }
|
||||
test-group = "io-heavy-sim"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "binary(fd_lifecycle)"
|
||||
slow-timeout = { period = "300s", terminate-after = 4 }
|
||||
test-group = "io-heavy-sim"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "test(/import_with_verification/) | test(/plc_migration/)"
|
||||
test-group = "serial-env-tests"
|
||||
@@ -96,6 +132,10 @@ test-group = "serial-env-tests"
|
||||
filter = "package(tranquil-signal)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "package(tranquil-config)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "binary(whole_story)"
|
||||
test-group = "heavy-load-tests"
|
||||
@@ -107,3 +147,13 @@ test-group = "heavy-load-tests"
|
||||
[[profile.ci.overrides]]
|
||||
filter = "binary(repo_lifecycle)"
|
||||
test-group = "heavy-load-tests"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "test(/retention_time_travel_survives_many_seeds/)"
|
||||
slow-timeout = { period = "300s", terminate-after = 24 }
|
||||
test-group = "io-heavy-sim"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "binary(fd_lifecycle)"
|
||||
slow-timeout = { period = "300s", terminate-after = 4 }
|
||||
test-group = "io-heavy-sim"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
target/
|
||||
.git/
|
||||
.jj/
|
||||
**/node_modules/
|
||||
frontend/dist/
|
||||
frontend/coverage/
|
||||
frontend/.pnpm-store/
|
||||
.direnv/
|
||||
result
|
||||
.env
|
||||
*.output
|
||||
reference-pds-bsky/
|
||||
+4
-1
@@ -1,6 +1,9 @@
|
||||
/target
|
||||
target/
|
||||
.env
|
||||
.direnv
|
||||
result
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/coverage/
|
||||
frontend/.pnpm-store
|
||||
frontend/.npmrc
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT nextval('firehose_seq') as \"seq!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "seq!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "09142b5a6b91fe4fd795fb6c85af5461e2621f6c3545b6d26e4618d813904b75"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE repo_seq r\n SET seq = $1::bigint + (o.ord - 1)\n FROM unnest($2::bigint[]) WITH ORDINALITY AS o(id, ord)\n WHERE r.id = o.id",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0b895d105363a60040323c5c47bc897905af3da980d0dc1b3b60bbdcdd90ea7a"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
|
||||
"query": "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1 AND available_uses > 0 AND COALESCE(disabled, false) = false",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -10,5 +10,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1ee6eda3e44660e7f14fcfe56adc2d41c72901b9c701fc7b992314e5370b32dc"
|
||||
"hash": "0bb2cb6af37bff735b6b380697fc8e1fa2034ca0600e8c16e1e362b722192327"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM repo_seq\n WHERE did = $1\n AND id <> (SELECT id FROM repo_seq WHERE did = $1 ORDER BY id DESC LIMIT 1)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "122d79e006c0e11214e3cf11cfb98bdff6fbf156893b482572532ebcd8979e61"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin\n FROM users WHERE did = $1",
|
||||
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration\n FROM users WHERE handle = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -42,6 +42,11 @@
|
||||
"ordinal": 7,
|
||||
"name": "is_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inbound_migration",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -57,8 +62,9 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6b51995c40519a63f85c70f29ca8bd6ec1963c8562d78215d980785dc46a6384"
|
||||
"hash": "18bbda5582db1b32d02ab8a3eee970c9508b9bd67239c2f936639a9f863b30ff"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET deactivated_at = NULL WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "23201d4e26bc650939e30f69fb0bca00d351d057098afebc1017f70a84b4bd22"
|
||||
}
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO repo_seq (did, event_type, active, status)\n VALUES ($1, 'account', false, 'deleted')\n RETURNING seq\n ",
|
||||
"query": "\n INSERT INTO repo_seq (did, event_type, active, status)\n VALUES ($1, 'account', false, 'deleted')\n RETURNING id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "seq",
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
@@ -18,5 +18,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a0ea3630adb9e411d94d88084df6b05d7fc3ba8c306146fc932b736fe342eceb"
|
||||
"hash": "2c3c0740793f29b9bd9e0f81c2372d427dee091e20da8f71ae1e5f2769aa985d"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT pg_try_advisory_xact_lock($1) as \"locked!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "locked!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3cc7247d2cd7e85a6f21d783dd956f8fecc6f63c994014bb4e91504d498b5fd5"
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4dcee809896ead3de8ca0433856ed424211d79df201d08bbea0e4c576931a234"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO repo_seq (did, event_type, handle)\n VALUES ($1, 'identity', $2)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4f3ef746cb5fc0916d3df5f92494c538497d3b9c1cfcc6e44c9b80f2193094f6"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO repo_seq (did, event_type, handle)\n VALUES ($1, 'identity', $2)\n RETURNING seq\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "seq",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "50b2178cd174af1dc132a22048e52a6af492f4d8025ec512dd9c0c9ba62eb202"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET deactivated_at = NULL, inbound_migration = FALSE WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "51e029e572777e6a103fd7fd5550494de9d4cac7e3ff84e27ddec1a6aaefc047"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE comms_queue\n SET status = 'failed'::comms_status,\n attempts = max_attempts,\n last_error = $2,\n updated_at = NOW()\n WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5bee4ed5296667e4ca7e1a97aec28d30a470b8aee7b378ec9ca4e34de4faf349"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id as \"id!\" FROM repo_seq WHERE seq IS NULL ORDER BY id LIMIT $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "714a1c9a73e2fed8182d4ab2fe4a2c33bc10a52e27ed95d3a6641477ae42395e"
|
||||
}
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
|
||||
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "seq",
|
||||
"name": "seq!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
@@ -90,7 +90,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
@@ -108,5 +108,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "4702a94384ce938d3f5384bc2106a48500f8749134e6bfd1c619a9151f5d3772"
|
||||
"hash": "7cdf3b8136782a5b2e4aff91907a680566b4610e6cbf9d9396d31f9bbd99c3b8"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM session_tokens WHERE id = $1 AND did = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8003624cedbac8b094c83933578517abfb2eaf8e59d1d52c7ea59bf5d11cfcfe"
|
||||
}
|
||||
+4
-12
@@ -1,14 +1,8 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev)\n VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8)\n RETURNING seq\n ",
|
||||
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev)\n VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "seq",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
@@ -21,9 +15,7 @@
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a325862f897484bb1a8863fd7a1bc3fd17ab20b6e30498f9900daf80609c413d"
|
||||
"hash": "800b93a38eef70bafdf0a1b221994f3c19ab562f84d18265376296a592b4c767"
|
||||
}
|
||||
+4
-12
@@ -1,14 +1,8 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n RETURNING seq\n ",
|
||||
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "seq",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
@@ -23,9 +17,7 @@
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "97dd3cfe72b74b4dac686c030a07f6eb56dabb2df72815f3248760b4e3f7950e"
|
||||
"hash": "82276a34f473ff8b9790b78915ae3f72873d4b748cdb47bd2b81edc2f7a6e5a8"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM session_tokens WHERE access_jti = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "847ce3c34985d0957526c87e0a20c6b4e5daae08a338f7635def682ac0689cf6"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM repo_seq WHERE did = $1 AND seq != $2",
|
||||
"query": "DELETE FROM repo_seq WHERE did = $1 AND id <> $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -11,5 +11,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b6d6548acb89d6384cd226f6ed0d66de27fde3af24b4a7a3fce7e098812e38a5"
|
||||
"hash": "856e458c1c78f030545dc147f76d1b91fa4cc6e178eb51133c52358855ba3f02"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE status = 'pending'\n AND scheduled_for <= $1\n AND attempts < max_attempts\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at",
|
||||
"query": "UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE attempts < max_attempts\n AND scheduled_for <= $1\n AND (\n status = 'pending'\n OR (status = 'processing'\n AND updated_at < $1 - INTERVAL '10 minutes')\n )\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -154,5 +154,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "8047fda41bd94f819213decb8b3e0aba49a8dbdb10217eefd77e3567f8c9694a"
|
||||
"hash": "890aa92acdcb0fe2a3bf04d87e1f16a801d271da7cedc32fc42c2ef5b100faae"
|
||||
}
|
||||
+4
-12
@@ -1,14 +1,8 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO repo_seq (did, event_type, active, status)\n VALUES ($1, 'account', $2, $3)\n RETURNING seq\n ",
|
||||
"query": "\n INSERT INTO repo_seq (did, event_type, active, status)\n VALUES ($1, 'account', $2, $3)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "seq",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
@@ -16,9 +10,7 @@
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "623042c0b901e17ff37c5bcc1a4eefe56fc8b996ca8326e75796897d13c9e9ed"
|
||||
"hash": "8ccd42b5b1b75f45e094889ee26af8517de28ca687efa81fd9c4fce598338fe9"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY updated_at ASC\n OFFSET $2\n )\n ",
|
||||
"query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY created_at DESC\n OFFSET $2\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -11,5 +11,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "56cd24903171eddc2ededd9079ffe10937c34e99b0305f25c980ca754da44625"
|
||||
"hash": "8f4357f7a18ddcf6b686a4555f244d37c35917364b8f917ca6ee2d4030ace742"
|
||||
}
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
|
||||
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "seq",
|
||||
"name": "seq!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
@@ -90,7 +90,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
@@ -108,5 +108,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c47746c256fd8c9d386551f3bd270e9a96fecdb713642d07d60fb4aeb02c8426"
|
||||
"hash": "957bc81c63510ab502d2f8cea75dafcbdf2c8ebb448c86990d5e404ee5280438"
|
||||
}
|
||||
+4
-12
@@ -1,14 +1,8 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data)\n VALUES ($1, 'sync', $2, $3, $4, $5)\n RETURNING seq\n ",
|
||||
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data)\n VALUES ($1, 'sync', $2, $3, $4, $5)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "seq",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
@@ -18,9 +12,7 @@
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5996774d097a484f81353d49e4a99b07c30a003377c924add576bfb48ddde29b"
|
||||
"hash": "9ba7869f29286cfd6b811ffa1e26bda1d637b98b44be66cdc265b1af4cec2d4f"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT pg_advisory_xact_lock($1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "pg_advisory_xact_lock",
|
||||
"type_info": "Void"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a06e1d9f6f95e4c4c2b98310ebddcc9d963cc033582bf2e945e8bf3a301b4247"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM session_tokens WHERE access_jti = $1 AND did = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a27e93bc594babbada10afe5c3e33a65909ec69c579329916833e4b0fe2332d3"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "session_id",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b2e1736dbe2ab9114e373353bcc299176417f3c9220025f9521591ba62928bd7"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT setval('firehose_seq', $1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "setval",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "bd0407818c8bdae1f1024777951b08cb9c386fe751722e1caf6ad243e271697a"
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT u.used_at, st.id AS session_id, st.did, st.scope, st.controller_did,\n st.access_jti, st.refresh_jti, st.access_expires_at, st.refresh_expires_at,\n k.key_bytes, k.encryption_version\n FROM used_refresh_tokens u\n JOIN session_tokens st ON st.id = u.session_id\n JOIN users us ON st.did = us.did\n JOIN user_keys k ON us.id = k.user_id\n WHERE u.refresh_jti = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "used_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "session_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "scope",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "controller_did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "access_jti",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "refresh_jti",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "access_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "refresh_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "key_bytes",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "encryption_version",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c08e3760be75071ac5e281cb3b05b3a02dccbf35b96c3d50c652878a38c9f9e6"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO used_refresh_tokens (refresh_jti, session_id)\n VALUES ($1, $2)\n ON CONFLICT (refresh_jti) DO NOTHING\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c0a7b73070e6c5b9baa9caddca3c5d24427b48ed02b5cac913a0c0af1e5c9379"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "NOTIFY repo_updates",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d4eacb9bca838dd18dcfd8ca39b83ef50968bfaaabc213536c049cc222ffe9c8"
|
||||
}
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
|
||||
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "seq",
|
||||
"name": "seq!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
@@ -91,7 +91,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
@@ -109,5 +109,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "89c8ce80d8c52b4668c34f63e48b51cacc7e8ff300958a192378f1d72de04f9a"
|
||||
"hash": "da2770e44c0ec21756ce4b6b0c57abff4f38cd8c680c5ced7d3b35f3c1f58da7"
|
||||
}
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
|
||||
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "seq",
|
||||
"name": "seq!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
@@ -91,7 +91,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
@@ -109,5 +109,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0349b2af4d21105029ecc2bf81ad7be9987ec17ac088730596849d7a45558e57"
|
||||
"hash": "dfe011b2d2bafbbbbea508a4977fc34bb1be4cf2612296df23763e6e5841c33e"
|
||||
}
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
|
||||
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "seq",
|
||||
"name": "seq!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
@@ -91,7 +91,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
@@ -109,5 +109,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ad784aff9bb5c1f19f90a3b1c3b3694a3e1aa889b0fe3e55cfebb7af8c620256"
|
||||
"hash": "f16d41b41a5388997e6ed408da55056173340ed12b60b1a956de87f73f9d0824"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin\n FROM users WHERE handle = $1",
|
||||
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration\n FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -42,6 +42,11 @@
|
||||
"ordinal": 7,
|
||||
"name": "is_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inbound_migration",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -57,8 +62,9 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "584bceda60d8b6a02e7dc44d833e3fba13151f36ba9f75c64e33d6cb534cc939"
|
||||
"hash": "f1c4ec28b02d09ffce35aa8249c1747a70c12a3ecfc1ff6ca1847840f770db2f"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "session_id",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
when:
|
||||
- event: ["push", "manual"]
|
||||
branch: ["main"]
|
||||
|
||||
engine: nixery
|
||||
|
||||
dependencies:
|
||||
nixpkgs:
|
||||
- nix
|
||||
- cachix
|
||||
- gnugrep
|
||||
|
||||
steps:
|
||||
- name: Authenticate
|
||||
command: |
|
||||
cachix authtoken "$CACHIX_AUTH_TOKEN"
|
||||
|
||||
- name: Build and push aarch64
|
||||
command: |
|
||||
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
|
||||
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds-aarch64 $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
|
||||
@@ -0,0 +1,28 @@
|
||||
when:
|
||||
- event: ["push", "manual"]
|
||||
branch: ["main"]
|
||||
|
||||
engine: nixery
|
||||
|
||||
dependencies:
|
||||
nixpkgs:
|
||||
- nix
|
||||
- cachix
|
||||
- gnugrep
|
||||
|
||||
steps:
|
||||
- name: Authenticate
|
||||
command: |
|
||||
cachix authtoken "$CACHIX_AUTH_TOKEN"
|
||||
|
||||
- name: Build and push x86_64
|
||||
command: |
|
||||
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
|
||||
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
|
||||
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-frontend $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
|
||||
|
||||
- name: Build and push devShell
|
||||
command: |
|
||||
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
|
||||
nix develop $SUBS --profile dev-profile -c true
|
||||
cachix push tranquil dev-profile
|
||||
@@ -1,24 +1,36 @@
|
||||
when:
|
||||
- event: []
|
||||
branch: []
|
||||
- event: [ "manual" ]
|
||||
- event: [ "push" ]
|
||||
branch: [ "main" ]
|
||||
|
||||
engine: nixery
|
||||
|
||||
dependencies:
|
||||
nixpkgs:
|
||||
- podman
|
||||
- kaniko
|
||||
|
||||
environment:
|
||||
DOCKER_CONFIG: "/kaniko/.docker"
|
||||
|
||||
steps:
|
||||
- name: Create podman config
|
||||
- name: Configure Kaniko
|
||||
command: |
|
||||
mkdir -p ~/.config/containers
|
||||
echo "unqualified-search-registries = [\"docker.io\"]" >> ~/.config/containers/registries.conf
|
||||
mkdir -p /kaniko/.docker/
|
||||
echo "{
|
||||
\"auths\": {
|
||||
\"https://atcr.io/v1\":{
|
||||
\"auth\": \"$ATCR_CREDENTIALS\"
|
||||
}
|
||||
}
|
||||
}" > /kaniko/.docker/config.json
|
||||
|
||||
- name: Build image
|
||||
command: |
|
||||
podman build . -t tranquil-pds:latest -t "tranquil-pds:$TANGLED_COMMIT_SHA"
|
||||
|
||||
- name: Publish image
|
||||
command: |
|
||||
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil.farm/tranquil-pds:latest"
|
||||
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA"
|
||||
executor \
|
||||
--context=$(pwd) \
|
||||
--ignore-path=$(pwd) \
|
||||
--dockerfile=$(pwd)/Dockerfile \
|
||||
--destination="atcr.io/tranquil.farm/tranquil-pds:latest" \
|
||||
--destination="atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA" \
|
||||
--push-retry=3 \
|
||||
--skip-push-permission-check
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
# Contributing to Tranquil PDS
|
||||
|
||||
## When PRing
|
||||
|
||||
In order of importance:
|
||||
|
||||
- **You must run your change! Every contribution that says "here's xyz. untested." does not help the project.**
|
||||
- Relevant tests to your PR must pass. The whole suite doesn't have to be proven to have run, because there are a *ton* of tests and they're quite heavy, but hopefully there are existing tests for whatever you're PRing, and if there aren't, please add those too.
|
||||
- Run cargo fmt :P
|
||||
|
||||
> 🦪 Lewis
|
||||
>
|
||||
> Good CI fixes some of these. We should really get around to that.
|
||||
|
||||
Things that would also be nice but aren't like, a pain in our side:
|
||||
|
||||
- Big changes should be stacked PRs that are broken up into digestible pieces. Those stacked PRs should hopefully be able to be merged individually if necessary.
|
||||
|
||||
## Local Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
||||
- Add `pds.test` to your hosts file (one-time setup):
|
||||
|
||||
```
|
||||
127.0.0.1 pds.test
|
||||
```
|
||||
|
||||
- **macOS / Linux:** `/etc/hosts`
|
||||
- **Windows:** `C:\Windows\System32\drivers\etc\hosts`
|
||||
|
||||
### Starting the dev environment
|
||||
|
||||
```bash
|
||||
just run-dev
|
||||
```
|
||||
|
||||
This starts the following services via `docker-compose`:
|
||||
|
||||
- **Traefik** — HTTPS reverse proxy at `https://pds.test`
|
||||
- **Backend** — Rust server with `cargo-watch` (auto-rebuilds on file changes)
|
||||
- **Frontend** — Vite dev server with hot module replacement
|
||||
- **Postgres** — Database on port 5432
|
||||
- **PLC Directory** — Local [did-method-plc](https://github.com/did-method-plc/did-method-plc) server for DID registration
|
||||
- **Mailpit** — Local email server with web UI at [http://localhost:8025](http://localhost:8025)
|
||||
|
||||
Once all services are running, open **https://pds.test** in your browser.
|
||||
|
||||
### Trusting the self-signed certificate
|
||||
|
||||
Traefik generates a self-signed TLS certificate. Your browser will show a security warning on first visit. You can either click through it, or add the certificate to your system trust store for a seamless experience:
|
||||
|
||||
**macOS:**
|
||||
|
||||
```bash
|
||||
# Extract the cert from traefik and add it to the system keychain
|
||||
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 > /tmp/pds-test.pem
|
||||
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/pds-test.pem
|
||||
```
|
||||
|
||||
**Linux (Debian/Ubuntu):**
|
||||
|
||||
```bash
|
||||
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 | sudo tee /usr/local/share/ca-certificates/pds-test.crt
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
|
||||
**Linux (Fedora/RHEL):**
|
||||
|
||||
```bash
|
||||
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 | sudo tee /etc/pki/ca-trust/source/anchors/pds-test.pem
|
||||
sudo update-ca-trust
|
||||
```
|
||||
|
||||
**Windows (PowerShell as Administrator):**
|
||||
|
||||
```powershell
|
||||
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
|
||||
$cert.Import([System.Text.Encoding]::UTF8.GetBytes((echo | openssl s_client -connect localhost:443 -servername pds.test 2>$null | openssl x509)))
|
||||
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store("Root", "LocalMachine")
|
||||
$store.Open("ReadWrite")
|
||||
$store.Add($cert)
|
||||
$store.Close()
|
||||
```
|
||||
|
||||
Restart your browser after adding the certificate.
|
||||
|
||||
### Stopping the dev environment
|
||||
|
||||
```bash
|
||||
# Stop containers (preserves database + build cache)
|
||||
docker compose --profile dev down
|
||||
|
||||
# Stop and wipe all data (fresh start)
|
||||
docker compose --profile dev down -v
|
||||
```
|
||||
|
||||
### Direct database access
|
||||
|
||||
Postgres is exposed on port 5432:
|
||||
|
||||
```bash
|
||||
psql postgres://postgres:postgres@localhost:5432/pds
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
- **Source code** is bind-mounted into the containers so that changes made on the host will be immediately reflected in the application
|
||||
- **Backend** uses `cargo-watch` to recompile and restart when Rust files change
|
||||
- **Frontend** uses Vite's HMR for instant browser updates when frontend files change
|
||||
- **Build cache** (`target/` directory and cargo registry) are stored in Docker volumes, so incremental compilation persists across container restarts
|
||||
- **Traefik** routes `/`, `/xrpc`, `/oauth`, `/.well-known`, `/u`, and `/health` to the backend; everything else goes to the Vite dev server
|
||||
- **Mailpit** captures all outgoing email — open [http://localhost:8025](http://localhost:8025) to view verification emails during registration
|
||||
- **PLC Directory** runs locally so DID registration doesn't hit the real `plc.directory`
|
||||
|
||||
### Running the backend natively
|
||||
|
||||
If you prefer running the Rust backend outside Docker (faster incremental builds on host), you need:
|
||||
|
||||
- Rust toolchain (see `rust-toolchain.toml`)
|
||||
- `protoc` (`brew install protobuf` on macOS)
|
||||
- PostgreSQL (start with `docker compose up db`)
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
cargo run -p tranquil-server -- --config config.toml
|
||||
```
|
||||
|
||||
And start the frontend separately:
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm install && pnpm dev
|
||||
```
|
||||
Generated
+334
-45
@@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a"
|
||||
dependencies = [
|
||||
"abnf-core",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -18,7 +18,7 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d"
|
||||
dependencies = [
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -210,16 +210,32 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-derive 0.5.1",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||
dependencies = [
|
||||
"asn1-rs-derive 0.6.0",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.5.1"
|
||||
@@ -232,6 +248,18 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
@@ -1046,7 +1074,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1055,6 +1083,15 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
@@ -1558,7 +1595,7 @@ checksum = "06b4f5ec222421e22bb0a8cbaa36b1d2b50fd45cdd30c915ded34108da78b29f"
|
||||
dependencies = [
|
||||
"confique-macro",
|
||||
"serde",
|
||||
"toml",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1970,9 +2007,23 @@ version = "9.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"asn1-rs 0.6.2",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
dependencies = [
|
||||
"asn1-rs 0.7.2",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
@@ -2216,6 +2267,22 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
@@ -2818,6 +2885,34 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h3"
|
||||
version = "0.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h3-quinn"
|
||||
version = "0.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
"h3",
|
||||
"quinn",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
@@ -3780,6 +3875,37 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"httpdate",
|
||||
"idna",
|
||||
"mime",
|
||||
"nom 8.0.0",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rsa",
|
||||
"rustls 0.23.37",
|
||||
"sha2",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tracing",
|
||||
"url",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
@@ -4409,6 +4535,15 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
@@ -4558,7 +4693,16 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"asn1-rs 0.6.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
dependencies = [
|
||||
"asn1-rs 0.7.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4839,7 +4983,7 @@ checksum = "9114f9c1683dd09c5f4fa024c89fdad783eaae21d3d52dd23ddaaffa29ffb168"
|
||||
dependencies = [
|
||||
"either",
|
||||
"fnv",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"once_cell",
|
||||
"postcard",
|
||||
"quick-xml",
|
||||
@@ -5050,7 +5194,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
"toml_edit 0.25.5+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5128,7 +5272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bit-vec 0.8.0",
|
||||
"bitflags",
|
||||
"num-traits",
|
||||
"rand 0.9.2",
|
||||
@@ -5371,6 +5515,7 @@ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"futures-io",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
@@ -5427,6 +5572,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
@@ -5568,6 +5719,20 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
|
||||
dependencies = [
|
||||
"pem",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser 0.18.1",
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.1.0"
|
||||
@@ -5833,7 +5998,7 @@ version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6050,6 +6215,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secrecy"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -6201,6 +6376,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.4"
|
||||
@@ -7144,6 +7328,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
@@ -7152,13 +7348,22 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"serde_spanned 1.0.4",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
@@ -7177,6 +7382,20 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.5+spec-1.1.0"
|
||||
@@ -7198,6 +7417,12 @@ dependencies = [
|
||||
"winnow 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.0.7+spec-1.1.0"
|
||||
@@ -7405,7 +7630,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-api"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7456,7 +7681,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
@@ -7479,11 +7704,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"redis",
|
||||
"thiserror 2.0.18",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tranquil-config",
|
||||
@@ -7493,11 +7719,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"ed25519-dalek",
|
||||
"futures",
|
||||
"hickory-resolver",
|
||||
"lettre",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"rsa",
|
||||
"secrecy",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
@@ -7511,7 +7745,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
@@ -7519,7 +7753,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -7535,7 +7769,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7552,7 +7786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7568,7 +7802,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -7579,7 +7813,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -7598,7 +7832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7621,7 +7855,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth-server"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -7654,7 +7888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -7694,6 +7928,7 @@ dependencies = [
|
||||
"multibase",
|
||||
"multihash",
|
||||
"p256 0.13.2",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
"redis",
|
||||
"regex",
|
||||
@@ -7706,6 +7941,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"testcontainers",
|
||||
"testcontainers-modules",
|
||||
"thiserror 2.0.18",
|
||||
@@ -7746,7 +7982,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -7758,19 +7994,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"backon",
|
||||
"bincode 2.0.1",
|
||||
"bytes",
|
||||
"foca",
|
||||
"futures",
|
||||
"hkdf",
|
||||
"metrics",
|
||||
"parking_lot",
|
||||
"quinn",
|
||||
"rand 0.9.2",
|
||||
"rcgen",
|
||||
"rustls 0.23.37",
|
||||
"serde",
|
||||
"socket2 0.6.3",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -7779,11 +8018,12 @@ dependencies = [
|
||||
"tranquil-config",
|
||||
"tranquil-infra",
|
||||
"uuid",
|
||||
"x509-parser 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -7799,15 +8039,31 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-server"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
"bytes",
|
||||
"clap",
|
||||
"dotenvy",
|
||||
"ed25519-dalek",
|
||||
"futures-util",
|
||||
"h3",
|
||||
"h3-quinn",
|
||||
"hex",
|
||||
"http 1.4.0",
|
||||
"hyper 1.8.1",
|
||||
"hyper-util",
|
||||
"quinn",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"rustls 0.23.37",
|
||||
"rustls-pemfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tranquil-api",
|
||||
@@ -7820,7 +8076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-signal"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7843,7 +8099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -7860,12 +8116,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-store"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"cid",
|
||||
"clap",
|
||||
"dashmap",
|
||||
"fjall",
|
||||
"flume 0.11.1",
|
||||
@@ -7893,6 +8150,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tikv-jemallocator",
|
||||
"tokio",
|
||||
"toml 0.8.23",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tranquil-db",
|
||||
@@ -7907,7 +8165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-sync"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7929,7 +8187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cid",
|
||||
@@ -8442,9 +8700,9 @@ checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"base64urlsafedata",
|
||||
"der-parser",
|
||||
"der-parser 9.0.0",
|
||||
"hex",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"rand 0.9.2",
|
||||
@@ -8458,7 +8716,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"webauthn-attestation-ca",
|
||||
"webauthn-rs-proto",
|
||||
"x509-parser",
|
||||
"x509-parser 0.16.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8854,6 +9112,9 @@ name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
@@ -9009,17 +9270,35 @@ version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"asn1-rs 0.6.2",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"der-parser 9.0.0",
|
||||
"lazy_static",
|
||||
"nom",
|
||||
"oid-registry",
|
||||
"nom 7.1.3",
|
||||
"oid-registry 0.7.1",
|
||||
"rusticata-macros",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
dependencies = [
|
||||
"asn1-rs 0.7.2",
|
||||
"data-encoding",
|
||||
"der-parser 10.0.0",
|
||||
"lazy_static",
|
||||
"nom 7.1.3",
|
||||
"oid-registry 0.8.1",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
@@ -9048,6 +9327,16 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
|
||||
+27
-4
@@ -26,7 +26,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.5"
|
||||
version = "0.6.5"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -59,6 +59,7 @@ presage = { git = "https://github.com/whisperfish/presage", rev = "fe3ed54c4844a
|
||||
unicode-segmentation = "1"
|
||||
|
||||
aes-gcm = "0.10"
|
||||
arc-swap = "1"
|
||||
backon = "1"
|
||||
bincode = { version = "2", features = ["serde"] }
|
||||
anyhow = "1.0"
|
||||
@@ -81,11 +82,15 @@ foca = { version = "1", features = ["bincode-codec", "tracing"] }
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
governor = "0.10"
|
||||
h3 = "0.0.8"
|
||||
h3-quinn = "0.0.10"
|
||||
hex = "0.4"
|
||||
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
|
||||
hkdf = "0.12"
|
||||
hmac = "0.12"
|
||||
http = "1.4"
|
||||
hyper = { version = "1", features = ["server", "http1", "http2"] }
|
||||
hyper-util = { version = "0.1", features = ["server", "server-auto", "server-graceful", "service", "tokio"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
||||
qrcodegen = "1.8"
|
||||
infer = "0.19"
|
||||
@@ -93,6 +98,7 @@ ipld-core = "0.4"
|
||||
iroh-car = "0.5"
|
||||
jacquard-common = { version = "0.9", features = ["crypto-k256"] }
|
||||
jacquard-repo = "0.9"
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "pool", "dkim", "tracing"] }
|
||||
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] }
|
||||
k256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] }
|
||||
metrics = "0.24"
|
||||
@@ -102,9 +108,15 @@ parking_lot = "0.12"
|
||||
multihash = "0.19"
|
||||
p256 = { version = "0.13", features = ["ecdsa"] }
|
||||
p384 = { version = "0.13", features = ["ecdsa"] }
|
||||
quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-ring", "log"] }
|
||||
rand = "0.8"
|
||||
rcgen = { version = "0.14", default-features = false, features = ["ring", "pem"] }
|
||||
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
|
||||
regex = "1"
|
||||
rsa = "0.9"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls-pemfile = "2"
|
||||
secrecy = { version = "0.10", features = ["serde"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "http2", "charset", "macos-system-configuration"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_bytes = "0.11"
|
||||
@@ -116,8 +128,9 @@ sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"
|
||||
subtle = "2.5"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "time", "signal", "process", "io-util", "fs"] }
|
||||
tokio-util = "0.7.18"
|
||||
tokio-util = { version = "0.7.18", features = ["rt"] }
|
||||
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
|
||||
totp-rs = { version = "5", features = ["qr"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
||||
@@ -128,6 +141,7 @@ urlencoding = "2.1"
|
||||
uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng", "serde"] }
|
||||
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys", "conditional-ui"] }
|
||||
webauthn-rs-proto = "0.5"
|
||||
x509-parser = "0.18"
|
||||
zip = { version = "7.0", default-features = false, features = ["deflate"] }
|
||||
|
||||
ciborium = "0.2"
|
||||
@@ -140,11 +154,20 @@ wiremock = "0.6"
|
||||
curve25519-dalek = { git = "https://github.com/signalapp/curve25519-dalek", tag = "signal-curve25519-4.1.3" }
|
||||
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
codegen-units = 256
|
||||
|
||||
[profile.lewis]
|
||||
inherits = "release"
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
|
||||
[profile.bench]
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
debug = 1
|
||||
strip = false
|
||||
|
||||
+38
-16
@@ -1,13 +1,35 @@
|
||||
FROM node:24-alpine AS frontend
|
||||
ARG DISTROLESS_IMAGE=gcr.io/distroless/cc-debian13:latest@sha256:1e3c6d9c255be500eb680cdea0ad07554f52ae92dfcbdf07043a2a435b4c1fe3
|
||||
|
||||
FROM node:24-trixie-slim AS frontend
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY frontend/ ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM rust:1.92-alpine AS builder
|
||||
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang protoc
|
||||
FROM rust:1.96-slim-trixie AS builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates pkg-config libssl-dev mold clang protobuf-compiler curl xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ARG COMPRESS="true"
|
||||
RUN set -eux; \
|
||||
if [ "$COMPRESS" = "true" ]; then \
|
||||
arch="$(uname -m)"; \
|
||||
case "$arch" in \
|
||||
x86_64) upx_arch=amd64; upx_sha=ddc2654063fe4dc80d95b420788494e4db078ebb01a650692d623b5a9906e61e ;; \
|
||||
aarch64) upx_arch=arm64; upx_sha=100310f74eb6f67694d1d0377f1c729b6a49238ce8c4de21ea2e7d3406186f8b ;; \
|
||||
*) echo "upx: no prebuilt binary for $arch, skipping compression"; upx_arch="" ;; \
|
||||
esac; \
|
||||
if [ -n "$upx_arch" ]; then \
|
||||
curl -fsSL -o /tmp/upx.tar.xz "https://github.com/upx/upx/releases/download/v5.0.2/upx-5.0.2-${upx_arch}_linux.tar.xz"; \
|
||||
echo "${upx_sha} /tmp/upx.tar.xz" | sha256sum -c -; \
|
||||
tar -xJf /tmp/upx.tar.xz -C /tmp; \
|
||||
install -m0755 "/tmp/upx-5.0.2-${upx_arch}_linux/upx" /usr/local/bin/upx; \
|
||||
rm -rf /tmp/upx.tar.xz "/tmp/upx-5.0.2-${upx_arch}_linux"; \
|
||||
fi; \
|
||||
fi
|
||||
RUN mkdir -p /stage/var/lib/tranquil-pds/blobs /stage/var/lib/tranquil-pds/store
|
||||
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
|
||||
WORKDIR /app
|
||||
ARG SLIM="false"
|
||||
@@ -35,24 +57,24 @@ COPY crates/tranquil-oauth-server ./crates/tranquil-oauth-server
|
||||
COPY crates/tranquil-store ./crates/tranquil-store
|
||||
COPY crates/tranquil-signal ./crates/tranquil-signal
|
||||
COPY crates/tranquil-server ./crates/tranquil-server
|
||||
COPY migrations ./crates/tranquil-pds/migrations
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/app/target \
|
||||
COPY migrations ./migrations
|
||||
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,id=tranquil-target,target=/app/target,sharing=locked \
|
||||
if [ "$SLIM" = "true" ]; then \
|
||||
SQLX_OFFLINE=true cargo build --release -p tranquil-server --no-default-features; \
|
||||
else \
|
||||
SQLX_OFFLINE=true cargo build --release -p tranquil-server; \
|
||||
fi && \
|
||||
cp target/release/tranquil-server /tmp/tranquil-pds
|
||||
cp target/release/tranquil-server /tmp/tranquil-pds && \
|
||||
if [ "$COMPRESS" = "true" ] && command -v upx >/dev/null 2>&1; then upx --best --lzma /tmp/tranquil-pds; fi
|
||||
|
||||
FROM alpine:3.23
|
||||
RUN apk add --no-cache msmtp ca-certificates \
|
||||
&& ln -sf /usr/bin/msmtp /usr/sbin/sendmail
|
||||
FROM ${DISTROLESS_IMAGE}
|
||||
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
|
||||
COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend
|
||||
COPY migrations /app/migrations
|
||||
WORKDIR /app
|
||||
ENV SERVER_HOST=0.0.0.0
|
||||
COPY --from=builder --chown=65532:65532 /stage/var/lib/tranquil-pds /var/lib/tranquil-pds
|
||||
COPY --from=frontend --chown=65532:65532 /app/dist /var/lib/tranquil-pds/frontend
|
||||
WORKDIR /var/lib/tranquil-pds
|
||||
ENV SERVER_HOST=[::]
|
||||
ENV SERVER_PORT=3000
|
||||
EXPOSE 3000
|
||||
CMD ["tranquil-pds"]
|
||||
ENTRYPOINT ["/usr/local/bin/tranquil-pds"]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM rust:1.96-slim-trixie
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
mold \
|
||||
clang \
|
||||
protobuf-compiler \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN cargo install cargo-watch
|
||||
|
||||
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
|
||||
ENV SQLX_OFFLINE=true
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
CMD ["cargo", "watch", "-x", "run -p tranquil-server -- --config /app/config.dev.toml"]
|
||||
@@ -6,7 +6,7 @@ A Personal Data Server for the AT Protocol.
|
||||
|
||||
We came together to make this PDS to enable and empower our users to better host their data on this shared protocol. All of our decisions as a project are guided by their usefulness to the community: PDS hosters and end-users both.
|
||||
|
||||
Comparatively: Bluesky the company created a "reference PDS" that we can self-host quite easily, and that's great, but Bluesky has an incentive to make software for themselvess first & foremost, then secondly their software can be useful for us self-hosters. In contrast, Tranquil is not from a company, and will never be.
|
||||
Comparatively: Bluesky the company created a "reference PDS" that we can self-host quite easily, and that's great, but Bluesky has an incentive to make software for themselves first & foremost, then secondly their software can be useful for us self-hosters. In contrast, Tranquil is not from a company, and will never be.
|
||||
|
||||
## What's different about Tranquil PDS
|
||||
|
||||
@@ -20,7 +20,7 @@ It is a superset of the reference PDS, including:
|
||||
- account delegation: letting others manage an account with configurable permission levels
|
||||
- a built-in web UI for account management, repo browsing, and admin
|
||||
|
||||
Unlike the ref PDS, Tranquil itself is compiled to a single binary with no nodeJS runtime. However, at time of writing, Tranquil requires postgres running separately.
|
||||
Unlike the ref PDS, Tranquil is a single binary with no nodejs runtime. That said, at time of writing, Tranquil does require postgres running separately.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -46,22 +46,28 @@ just test
|
||||
just lint
|
||||
```
|
||||
|
||||
Nix users can enter a devshell with `nix develop`, or `direnv allow` to auto-enter via the bundled `.envrc`. Pre-built artifacts including the devshell are available from our [binary cache](docs/2_INSTALL_NIX.md#binary-cache).
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Quick Deploy (Docker/Podman Compose)
|
||||
|
||||
Edit `config.toml` with your values. Generate secrets with `openssl rand -base64 48`.
|
||||
`docker-compose.prod.yaml` pulls the prebuilt image `atcr.io/tranquil.farm/tranquil-pds:latest`. Sign in to the registry first with `podman login atcr.io`. The Containers guide covers building from source.
|
||||
|
||||
```bash
|
||||
cp example.toml config.toml
|
||||
```
|
||||
|
||||
Edit `config.toml` with your values and generate secrets with `openssl rand -base64 48`. Set the postgres password to match `docker-compose.prod.yaml`. nginx needs a TLS certificate before it starts, so follow the wildcard cert steps in the [Containers guide](docs/2_INSTALL_CONTAINERS.md).
|
||||
|
||||
```bash
|
||||
podman-compose -f docker-compose.prod.yaml up -d
|
||||
```
|
||||
|
||||
### Installation Guides
|
||||
|
||||
- [Debian](docs/install-debian.md)
|
||||
- [Containers](docs/install-containers.md)
|
||||
- [Kubernetes](docs/install-kubernetes.md)
|
||||
- [Nix](docs/2_INSTALL_NIX.md)
|
||||
- [Containers](docs/2_INSTALL_CONTAINERS.md)
|
||||
|
||||
## Community
|
||||
|
||||
@@ -74,7 +80,7 @@ We currently don't have a shared space to chat and organize Tranquil things, but
|
||||
- [@oyster.cafe](https://tangled.org/did:plc:3fwecdnvtcscjnrx2p4n7alz)
|
||||
- [@nel.pet](https://tangled.org/did:plc:h5wsnqetncv6lu2weom35lg2)
|
||||
|
||||
### Amazing contributers
|
||||
### Amazing contributors
|
||||
|
||||
- [@isabelroses.com](https://tangled.org/did:plc:qxichs7jsycphrsmbujwqbfb)
|
||||
- [@quilling.dev](https://tangled.org/did:plc:jrtgsidnmxaen4offglr5lsh)
|
||||
@@ -85,6 +91,8 @@ We currently don't have a shared space to chat and organize Tranquil things, but
|
||||
- [@a.starrysky.fyi](https://tangled.org/did:plc:uuyqs6y3pwtbteet4swt5i5y)
|
||||
- [@sans-self.org](https://tangled.org/did:plc:wydyrngmxbcsqdvhmd7whmye)
|
||||
- [@tachyonism.tngl.sh](https://tangled.org/did:plc:w6qiwij62bmdugsd3gemhpy2)
|
||||
- [@trezy.codes](https://tangled.org/did:plc:4jrld6fwpnwqehtce56qshzv)
|
||||
- [@ave.zone](https://tangled.org/did:plc:mchrltkrhuzpxleiwpmmvpar)
|
||||
- Could be your name here too!
|
||||
|
||||
### Tranquil PDS instances in the wild!
|
||||
@@ -99,4 +107,3 @@ This project is very grateful to [@nonbinary.computer](https://tangled.org/did:p
|
||||
## License
|
||||
|
||||
AGPL-3.0-or-later. Documentation is CC BY-SA 4.0. See [LICENSE](LICENSE) for details.
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
[server]
|
||||
hostname = "pds.test"
|
||||
allow_http_proxy = true
|
||||
invite_code_required = false
|
||||
disable_rate_limiting = true
|
||||
|
||||
[frontend]
|
||||
enabled = true
|
||||
dir = "/app/frontend/public"
|
||||
|
||||
[database]
|
||||
url = "postgres://postgres:postgres@db:5432/pds"
|
||||
|
||||
[storage]
|
||||
path = "/var/lib/tranquil-pds/blobs"
|
||||
|
||||
[plc]
|
||||
directory_url = "http://plc:2582"
|
||||
|
||||
[email]
|
||||
from_address = "noreply@pds.test"
|
||||
from_name = "Tranquil PDS (Dev)"
|
||||
|
||||
[email.smarthost]
|
||||
host = "mailpit"
|
||||
port = 1025
|
||||
tls = "none"
|
||||
|
||||
[secrets]
|
||||
allow_insecure = true
|
||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{Admin, Auth};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Handle};
|
||||
use tranquil_pds::types::{Did, Handle, InviteCode};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetAccountInfoParams {
|
||||
@@ -39,7 +39,7 @@ pub struct AccountInfo {
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteCodeInfo {
|
||||
pub code: String,
|
||||
pub code: InviteCode,
|
||||
pub available: i32,
|
||||
pub disabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -123,12 +123,12 @@ async fn get_invites_for_user(
|
||||
return None;
|
||||
}
|
||||
|
||||
let code_strings: Vec<String> = invite_codes.iter().map(|ic| ic.code.clone()).collect();
|
||||
let codes: Vec<InviteCode> = invite_codes.iter().map(|ic| ic.code.clone()).collect();
|
||||
|
||||
let uses = state
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_code_uses_batch(&code_strings)
|
||||
.get_invite_code_uses_batch(&codes)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
@@ -157,7 +157,7 @@ async fn get_invites_for_user(
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_invite_code_info(state: &AppState, code: &str) -> Option<InviteCodeInfo> {
|
||||
async fn get_invite_code_info(state: &AppState, code: &InviteCode) -> Option<InviteCodeInfo> {
|
||||
let info = state.repos.infra.get_invite_code_info(code).await.ok()??;
|
||||
|
||||
let uses = state
|
||||
@@ -200,11 +200,11 @@ pub async fn get_account_infos(
|
||||
return Err(ApiError::InvalidRequest("dids is required".into()));
|
||||
}
|
||||
|
||||
let dids_typed: Vec<Did> = dids.iter().filter_map(|d| d.parse().ok()).collect();
|
||||
let dids: Vec<Did> = dids.iter().filter_map(|d| d.parse().ok()).collect();
|
||||
let accounts = state
|
||||
.repos
|
||||
.infra
|
||||
.get_admin_account_infos_by_dids(&dids_typed)
|
||||
.get_admin_account_infos_by_dids(&dids)
|
||||
.await
|
||||
.log_db_err("fetching account infos")?;
|
||||
|
||||
@@ -217,7 +217,7 @@ pub async fn get_account_infos(
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let all_codes: Vec<String> = all_invite_codes
|
||||
let all_codes: Vec<InviteCode> = all_invite_codes
|
||||
.iter()
|
||||
.map(|(_, c)| c.code.clone())
|
||||
.collect();
|
||||
@@ -233,7 +233,7 @@ pub async fn get_account_infos(
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let invited_by_map: HashMap<uuid::Uuid, String> = state
|
||||
let invited_by_map: HashMap<uuid::Uuid, InviteCode> = state
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_code_uses_by_users(&user_ids)
|
||||
@@ -249,7 +249,7 @@ pub async fn get_account_infos(
|
||||
|
||||
let (codes_by_user, code_info_map): (
|
||||
HashMap<uuid::Uuid, Vec<InviteCodeInfo>>,
|
||||
HashMap<String, InviteCodeInfo>,
|
||||
HashMap<InviteCode, InviteCodeInfo>,
|
||||
) = all_invite_codes.into_iter().fold(
|
||||
(HashMap::new(), HashMap::new()),
|
||||
|(mut by_user, mut by_code), (user_id, ic)| {
|
||||
|
||||
@@ -106,7 +106,7 @@ pub async fn update_account_handle(
|
||||
}
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&tranquil_pds::cache_keys::handle_key(&handle))
|
||||
.delete(&tranquil_pds::cache_keys::handle_key(&handle_for_check))
|
||||
.await;
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_identity_event(
|
||||
&state,
|
||||
|
||||
@@ -10,12 +10,13 @@ use tranquil_pds::api::EmptyResponse;
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{Admin, Auth};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_types::{Did, InviteCode};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DisableInviteCodesInput {
|
||||
pub codes: Option<Vec<String>>,
|
||||
pub accounts: Option<Vec<String>>,
|
||||
pub codes: Option<Vec<InviteCode>>,
|
||||
pub accounts: Option<Vec<Did>>,
|
||||
}
|
||||
|
||||
pub async fn disable_invite_codes(
|
||||
@@ -28,17 +29,14 @@ pub async fn disable_invite_codes(
|
||||
{
|
||||
error!("DB error disabling invite codes: {:?}", e);
|
||||
}
|
||||
if let Some(accounts) = &input.accounts {
|
||||
let accounts_typed: Vec<tranquil_types::Did> =
|
||||
accounts.iter().filter_map(|a| a.parse().ok()).collect();
|
||||
if let Err(e) = state
|
||||
if let Some(accounts) = &input.accounts
|
||||
&& let Err(e) = state
|
||||
.repos
|
||||
.infra
|
||||
.disable_invite_codes_by_account(&accounts_typed)
|
||||
.disable_invite_codes_by_account(accounts)
|
||||
.await
|
||||
{
|
||||
error!("DB error disabling invite codes by account: {:?}", e);
|
||||
}
|
||||
{
|
||||
error!("DB error disabling invite codes by account: {:?}", e);
|
||||
}
|
||||
Ok(Json(EmptyResponse {}))
|
||||
}
|
||||
@@ -53,7 +51,7 @@ pub struct GetInviteCodesParams {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteCodeInfo {
|
||||
pub code: String,
|
||||
pub code: InviteCode,
|
||||
pub available: i32,
|
||||
pub disabled: bool,
|
||||
pub for_account: String,
|
||||
@@ -72,7 +70,7 @@ pub struct InviteCodeUseInfo {
|
||||
#[derive(Serialize)]
|
||||
pub struct GetInviteCodesOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
pub cursor: Option<InviteCode>,
|
||||
pub codes: Vec<InviteCodeInfo>,
|
||||
}
|
||||
|
||||
@@ -95,9 +93,9 @@ pub async fn get_invite_codes(
|
||||
.log_db_err("fetching invite codes")?;
|
||||
|
||||
let user_ids: Vec<uuid::Uuid> = codes_rows.iter().map(|r| r.created_by_user).collect();
|
||||
let code_strings: Vec<String> = codes_rows.iter().map(|r| r.code.clone()).collect();
|
||||
let code_values: Vec<InviteCode> = codes_rows.iter().map(|r| r.code.clone()).collect();
|
||||
|
||||
let creator_dids: std::collections::HashMap<uuid::Uuid, tranquil_types::Did> = state
|
||||
let creator_dids: std::collections::HashMap<uuid::Uuid, Did> = state
|
||||
.repos
|
||||
.infra
|
||||
.get_user_dids_by_ids(&user_ids)
|
||||
@@ -106,14 +104,14 @@ pub async fn get_invite_codes(
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let uses_by_code = if code_strings.is_empty() {
|
||||
let uses_by_code = if code_values.is_empty() {
|
||||
std::collections::HashMap::new()
|
||||
} else {
|
||||
common::group_invite_uses_by_code(
|
||||
state
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_code_uses_batch(&code_strings)
|
||||
.get_invite_code_uses_batch(&code_values)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
|u| InviteCodeUseInfo {
|
||||
@@ -167,7 +165,7 @@ pub async fn disable_account_invites(
|
||||
if account.is_empty() {
|
||||
return Err(ApiError::InvalidRequest("account is required".into()));
|
||||
}
|
||||
let account_did: tranquil_types::Did = account
|
||||
let account_did: Did = account
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
|
||||
|
||||
@@ -200,7 +198,7 @@ pub async fn enable_account_invites(
|
||||
if account.is_empty() {
|
||||
return Err(ApiError::InvalidRequest("account is required".into()));
|
||||
}
|
||||
let account_did: tranquil_types::Did = account
|
||||
let account_did: Did = account
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ use tranquil_pds::state::AppState;
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SignalStatusOutput {
|
||||
pub enabled: bool,
|
||||
pub linked: bool,
|
||||
}
|
||||
|
||||
@@ -23,13 +22,12 @@ pub async fn get_signal_status(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
) -> Result<Json<SignalStatusOutput>, ApiError> {
|
||||
let enabled = tranquil_config::get().signal.enabled;
|
||||
let linked = match &state.signal_sender {
|
||||
Some(slot) => slot.is_linked().await,
|
||||
None => false,
|
||||
};
|
||||
|
||||
Ok(Json(SignalStatusOutput { enabled, linked }))
|
||||
Ok(Json(SignalStatusOutput { linked }))
|
||||
}
|
||||
|
||||
pub async fn link_signal_device(
|
||||
@@ -76,7 +74,7 @@ pub async fn link_signal_device(
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
_ = shutdown.cancelled() => {
|
||||
tracing::info!("Signal linking aborted due to server shutdown");
|
||||
tracing::info!("server shutting down, aborting signal linking");
|
||||
return;
|
||||
}
|
||||
r = link_result.completion => r,
|
||||
@@ -84,10 +82,10 @@ pub async fn link_signal_device(
|
||||
match result {
|
||||
Ok(Ok(client)) => {
|
||||
if slot_for_task.complete_link(generation, client).await {
|
||||
tracing::info!("Signal device linked successfully");
|
||||
tracing::info!("signal device linked");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Signal link completed but generation mismatch or already linked; discarding"
|
||||
"discarding completed signal link, generation mismatch or already linked"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use tracing::error;
|
||||
use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository};
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::api::error::DbResultExt;
|
||||
use tranquil_pds::types::{AtIdentifier, Did, Handle};
|
||||
use tranquil_pds::types::{AtIdentifier, Did, Handle, PasswordHash};
|
||||
|
||||
pub struct ResolvedRepo {
|
||||
pub user_id: uuid::Uuid,
|
||||
@@ -76,7 +76,7 @@ pub async fn resolve_repo_user_id(
|
||||
pub fn group_invite_uses_by_code<U, F>(
|
||||
uses: Vec<tranquil_db_traits::InviteCodeUse>,
|
||||
map_use: F,
|
||||
) -> HashMap<String, Vec<U>>
|
||||
) -> HashMap<tranquil_types::InviteCode, Vec<U>>
|
||||
where
|
||||
F: Fn(tranquil_db_traits::InviteCodeUse) -> U,
|
||||
{
|
||||
@@ -195,12 +195,12 @@ pub fn extract_verification_recipient(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_self_hosted_did_web(handle: &str) -> Result<String, ApiError> {
|
||||
pub fn create_self_hosted_did_web(handle: &str) -> Result<Did, ApiError> {
|
||||
if !tranquil_pds::util::is_self_hosted_did_web_enabled() {
|
||||
return Err(ApiError::SelfHostedDidWebDisabled);
|
||||
}
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
Ok(format!("did:web:{}", encoded_handle))
|
||||
Ok(Did::from(format!("did:web:{}", encoded_handle)))
|
||||
}
|
||||
|
||||
pub enum CredentialMatch {
|
||||
@@ -216,10 +216,10 @@ pub async fn verify_credential(
|
||||
session_repo: &dyn SessionRepository,
|
||||
user_id: uuid::Uuid,
|
||||
password: &str,
|
||||
password_hash: Option<&str>,
|
||||
password_hash: Option<&PasswordHash>,
|
||||
) -> Option<CredentialMatch> {
|
||||
let main_valid = password_hash
|
||||
.map(|h| bcrypt::verify(password, h).unwrap_or(false))
|
||||
.map(|h| bcrypt::verify(password, h.as_str()).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
if main_valid {
|
||||
return Some(CredentialMatch::MainPassword);
|
||||
@@ -230,7 +230,7 @@ pub async fn verify_credential(
|
||||
.unwrap_or_default();
|
||||
app_passwords
|
||||
.into_iter()
|
||||
.find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false))
|
||||
.find(|app| bcrypt::verify(password, app.password_hash.as_str()).unwrap_or(false))
|
||||
.map(|app| {
|
||||
let scopes = app.scopes.unwrap_or_else(|| {
|
||||
if app.privilege.is_privileged() {
|
||||
@@ -247,14 +247,16 @@ pub async fn verify_credential(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hash_or_internal_error(value: &str) -> Result<String, ApiError> {
|
||||
bcrypt::hash(value, DEFAULT_COST).map_err(|e| {
|
||||
error!("Bcrypt hash error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})
|
||||
pub fn hash_or_internal_error(value: &str) -> Result<PasswordHash, ApiError> {
|
||||
bcrypt::hash(value, DEFAULT_COST)
|
||||
.map(PasswordHash::new)
|
||||
.map_err(|e| {
|
||||
error!("Bcrypt hash error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn hash_password_async(password: &str) -> Result<String, ApiError> {
|
||||
pub async fn hash_password_async(password: &str) -> Result<PasswordHash, ApiError> {
|
||||
let password = password.to_string();
|
||||
tokio::task::spawn_blocking(move || hash(password, DEFAULT_COST))
|
||||
.await
|
||||
@@ -262,6 +264,7 @@ pub async fn hash_password_async(password: &str) -> Result<String, ApiError> {
|
||||
error!("Failed to spawn blocking task: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.map(PasswordHash::new)
|
||||
.map_err(|e| {
|
||||
error!("Failed to hash password: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
|
||||
@@ -17,7 +17,7 @@ use tranquil_pds::delegation::{
|
||||
};
|
||||
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Handle};
|
||||
use tranquil_pds::types::{CidLink, Did, Handle};
|
||||
|
||||
pub async fn list_controllers(
|
||||
State(state): State<AppState>,
|
||||
@@ -38,11 +38,10 @@ pub async fn list_controllers(
|
||||
async move {
|
||||
if c.handle.is_none() {
|
||||
c.handle = did_resolver
|
||||
.fetch_did_document(c.did.as_str())
|
||||
.fetch_did_document(&c.did)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|doc| tranquil_types::did_doc::extract_handle(&doc))
|
||||
.map(Into::into);
|
||||
.and_then(|doc| tranquil_types::did_doc::extract_handle(&doc));
|
||||
}
|
||||
c
|
||||
}
|
||||
@@ -78,7 +77,7 @@ pub async fn add_controller(
|
||||
}
|
||||
match state
|
||||
.cross_pds_oauth
|
||||
.check_remote_is_delegated(pds_url, input.controller_did.as_str())
|
||||
.check_remote_is_delegated(pds_url, &input.controller_did)
|
||||
.await
|
||||
{
|
||||
Some(true) => {
|
||||
@@ -174,9 +173,7 @@ pub async fn remove_controller(
|
||||
.session
|
||||
.delete_app_passwords_by_controller(&auth.did, &input.controller_did)
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
.try_into()
|
||||
.unwrap_or(0usize);
|
||||
.unwrap_or(0);
|
||||
|
||||
let revoked_oauth_tokens = state
|
||||
.repos
|
||||
@@ -232,6 +229,20 @@ pub async fn update_controller_scopes(
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
let revoked_app_passwords = state
|
||||
.repos
|
||||
.session
|
||||
.delete_app_passwords_by_controller(&auth.did, &input.controller_did)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let revoked_oauth_tokens = state
|
||||
.repos
|
||||
.oauth
|
||||
.revoke_tokens_for_controller(&auth.did, &input.controller_did)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let _ = state
|
||||
.repos
|
||||
.delegation
|
||||
@@ -241,7 +252,9 @@ pub async fn update_controller_scopes(
|
||||
Some(&input.controller_did),
|
||||
DelegationActionType::ScopesModified,
|
||||
Some(json!({
|
||||
"new_scopes": input.granted_scopes.as_str()
|
||||
"new_scopes": input.granted_scopes.as_str(),
|
||||
"revoked_app_passwords": revoked_app_passwords,
|
||||
"revoked_oauth_tokens": revoked_oauth_tokens
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
@@ -330,7 +343,6 @@ pub struct CreateDelegatedAccountInput {
|
||||
pub handle: String,
|
||||
pub email: Option<String>,
|
||||
pub controller_scopes: ValidatedDelegationScope,
|
||||
pub invite_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -362,25 +374,11 @@ pub async fn create_delegated_account(
|
||||
return Err(ApiError::InvalidEmail);
|
||||
}
|
||||
|
||||
let validated_invite_code = if let Some(ref code) = input.invite_code {
|
||||
match state.repos.infra.validate_invite_code(code).await {
|
||||
Ok(validated) => Some(validated),
|
||||
Err(_) => return Err(ApiError::InvalidInviteCode),
|
||||
}
|
||||
} else {
|
||||
let invite_required = tranquil_config::get().server.invite_code_required;
|
||||
if invite_required {
|
||||
return Err(ApiError::InviteCodeRequired);
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let plc = create_plc_did(&state, &handle).await.map_err(|e| {
|
||||
tracing::error!("PLC DID creation failed: {:?}", e);
|
||||
e
|
||||
})?;
|
||||
let did = plc.did;
|
||||
let handle: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?;
|
||||
info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account");
|
||||
|
||||
let repo = init_genesis_repo(&state, &did, &plc.signing_key, &plc.signing_key_bytes).await?;
|
||||
@@ -394,19 +392,18 @@ pub async fn create_delegated_account(
|
||||
controller_scopes: input.controller_scopes.as_str().to_string(),
|
||||
encrypted_key_bytes: repo.encrypted_key_bytes,
|
||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||
commit_cid: repo.commit_cid.to_string(),
|
||||
commit_cid: CidLink::from(repo.commit_cid.to_string()),
|
||||
repo_rev: repo.repo_rev.clone(),
|
||||
genesis_block_cids: repo.genesis_block_cids,
|
||||
invite_code: input.invite_code.clone(),
|
||||
};
|
||||
|
||||
let user_id = match state
|
||||
match state
|
||||
.repos
|
||||
.user
|
||||
.create_delegated_account(&create_input)
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Ok(_) => {}
|
||||
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
|
||||
return Err(ApiError::HandleNotAvailable(None));
|
||||
}
|
||||
@@ -417,16 +414,6 @@ pub async fn create_delegated_account(
|
||||
error!("Error creating delegated account: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(validated) = validated_invite_code
|
||||
&& let Err(e) = state
|
||||
.repos
|
||||
.infra
|
||||
.record_invite_code_use(&validated, user_id)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to record invite code use for {}: {:?}", did, e);
|
||||
}
|
||||
|
||||
crate::identity::provision::sequence_new_account(
|
||||
@@ -476,17 +463,14 @@ pub async fn resolve_controller(
|
||||
.parse()
|
||||
.map_err(|_| ApiError::ControllerNotFound)?
|
||||
} else {
|
||||
let local_handle: Option<Handle> = identifier.parse().ok();
|
||||
let local_user = match local_handle {
|
||||
Some(ref h) => state.repos.user.get_by_handle(h).await.ok().flatten(),
|
||||
None => None,
|
||||
};
|
||||
let handle: Handle = identifier
|
||||
.parse()
|
||||
.map_err(|_| ApiError::ControllerNotFound)?;
|
||||
let local_user = state.repos.user.get_by_handle(&handle).await.ok().flatten();
|
||||
match local_user {
|
||||
Some(user) => user.did,
|
||||
None => tranquil_pds::handle::resolve_handle(identifier)
|
||||
None => tranquil_pds::handle::resolve_handle(&handle)
|
||||
.await
|
||||
.map_err(|_| ApiError::ControllerNotFound)?
|
||||
.parse()
|
||||
.map_err(|_| ApiError::ControllerNotFound)?,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,10 +168,11 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
|
||||
"Received /start from Discord user"
|
||||
);
|
||||
|
||||
let handle = handle.map(Handle::from);
|
||||
match state
|
||||
.repos
|
||||
.user
|
||||
.store_discord_user_id(&discord_username, &discord_user_id, handle.as_deref())
|
||||
.store_discord_user_id(&discord_username, &discord_user_id, handle.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(Some(user_id)) => {
|
||||
|
||||
@@ -10,10 +10,11 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{debug, error, info};
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::api::invite::check_registration_invite;
|
||||
use tranquil_pds::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token};
|
||||
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Handle, PlainPassword};
|
||||
use tranquil_pds::types::{CidLink, Did, Handle, Nsid, PlainPassword};
|
||||
use tranquil_pds::validation::validate_password;
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -24,7 +25,7 @@ pub struct CreateAccountInput {
|
||||
pub invite_code: Option<String>,
|
||||
pub did: Option<String>,
|
||||
pub did_type: Option<String>,
|
||||
pub signing_key: Option<String>,
|
||||
pub signing_key: Option<Did>,
|
||||
pub verification_channel: Option<tranquil_db_traits::CommsChannel>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
@@ -46,23 +47,15 @@ pub struct CreateAccountOutput {
|
||||
|
||||
async fn try_reactivate_migration(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
handle: &str,
|
||||
did: &Did,
|
||||
handle: &Handle,
|
||||
email: &Option<String>,
|
||||
verification_channel: tranquil_db_traits::CommsChannel,
|
||||
verification_recipient: Option<&str>,
|
||||
) -> Option<Response> {
|
||||
let did_typed: Did = match did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Some(ApiError::InternalError(Some("Invalid DID".into())).into_response()),
|
||||
};
|
||||
let handle_typed: Handle = match handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Some(ApiError::InvalidHandle(None).into_response()),
|
||||
};
|
||||
let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
|
||||
did: did_typed.clone(),
|
||||
new_handle: handle_typed.clone(),
|
||||
did: did.clone(),
|
||||
new_handle: handle.clone(),
|
||||
new_email: email.clone(),
|
||||
};
|
||||
match state
|
||||
@@ -119,7 +112,7 @@ async fn try_reactivate_migration(
|
||||
}
|
||||
};
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: did_typed.clone(),
|
||||
did: did.clone(),
|
||||
access_jti: access_meta.jti.clone(),
|
||||
refresh_jti: refresh_meta.jti.clone(),
|
||||
access_expires_at: access_meta.expires_at,
|
||||
@@ -139,7 +132,7 @@ async fn try_reactivate_migration(
|
||||
super::provision::enqueue_migration_verification(
|
||||
state,
|
||||
reactivated.user_id,
|
||||
&did_typed,
|
||||
did,
|
||||
verification_channel,
|
||||
recipient,
|
||||
)
|
||||
@@ -152,8 +145,8 @@ async fn try_reactivate_migration(
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.to_string().into(),
|
||||
did: did_typed.clone(),
|
||||
handle: handle.clone(),
|
||||
did: did.clone(),
|
||||
did_doc: state
|
||||
.did_resolver
|
||||
.fetch_did_document(did)
|
||||
@@ -209,8 +202,9 @@ pub async fn create_account(
|
||||
let token = extracted.token;
|
||||
if is_service_token(&token) {
|
||||
let verifier = ServiceTokenVerifier::new();
|
||||
let create_account_lxm = Nsid::from("com.atproto.server.createAccount".to_string());
|
||||
match verifier
|
||||
.verify_service_token(&token, Some("com.atproto.server.createAccount"))
|
||||
.verify_service_token(&token, Some(&create_account_lxm))
|
||||
.await
|
||||
{
|
||||
Ok(claims) => {
|
||||
@@ -307,7 +301,7 @@ pub async fn create_account(
|
||||
};
|
||||
let hostname = &cfg.server.hostname;
|
||||
let key_result =
|
||||
match super::provision::resolve_signing_key(&state, input.signing_key.as_deref()).await {
|
||||
match super::provision::resolve_signing_key(&state, input.signing_key.as_ref()).await {
|
||||
Ok(k) => k,
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
@@ -315,7 +309,7 @@ pub async fn create_account(
|
||||
let signing_key = key_result.signing_key;
|
||||
let reserved_key_id = key_result.reserved_key_id;
|
||||
let did_type = input.did_type.as_deref().unwrap_or("plc");
|
||||
let did = match did_type {
|
||||
let did: Did = match did_type {
|
||||
"web" => {
|
||||
let self_hosted_did = match common::create_self_hosted_did_web(&handle) {
|
||||
Ok(d) => d,
|
||||
@@ -340,27 +334,42 @@ pub async fn create_account(
|
||||
}
|
||||
if !is_did_web_byod
|
||||
&& let Err(e) =
|
||||
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref()).await
|
||||
verify_did_web(d, hostname, &input.handle, input.signing_key.as_ref()).await
|
||||
{
|
||||
return ApiError::InvalidDid(e.to_string()).into_response();
|
||||
}
|
||||
info!(did = %d, "Creating external did:web account");
|
||||
d.clone()
|
||||
match d.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InvalidDid("Invalid DID format".into()).into_response(),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(d) = &input.did {
|
||||
if d.starts_with("did:plc:") && is_migration {
|
||||
info!(did = %d, "Migration with existing did:plc");
|
||||
d.clone()
|
||||
match d.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return ApiError::InvalidDid("Invalid DID format".into())
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else if d.starts_with("did:web:") {
|
||||
if !is_did_web_byod
|
||||
&& let Err(e) =
|
||||
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref())
|
||||
verify_did_web(d, hostname, &input.handle, input.signing_key.as_ref())
|
||||
.await
|
||||
{
|
||||
return ApiError::InvalidDid(e.to_string()).into_response();
|
||||
}
|
||||
d.clone()
|
||||
match d.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return ApiError::InvalidDid("Invalid DID format".into())
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else if !d.trim().is_empty() {
|
||||
return ApiError::InvalidDid(
|
||||
"Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth.".into()
|
||||
@@ -395,14 +404,10 @@ pub async fn create_account(
|
||||
return response;
|
||||
}
|
||||
|
||||
let handle_typed: Handle = match handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return ApiError::InvalidHandle(None).into_response(),
|
||||
};
|
||||
let handle_available = match state
|
||||
.repos
|
||||
.user
|
||||
.check_handle_available_for_new_account(&handle_typed)
|
||||
.check_handle_available_for_new_account(&handle)
|
||||
.await
|
||||
{
|
||||
Ok(available) => available,
|
||||
@@ -415,40 +420,11 @@ pub async fn create_account(
|
||||
return ApiError::HandleTaken.into_response();
|
||||
}
|
||||
|
||||
let is_bootstrap = state.bootstrap_invite_code.is_some()
|
||||
&& state.repos.user.count_users().await.unwrap_or(1) == 0;
|
||||
|
||||
if is_bootstrap {
|
||||
match input.invite_code.as_deref() {
|
||||
Some(code) if Some(code) == state.bootstrap_invite_code.as_deref() => {}
|
||||
_ => return ApiError::InvalidInviteCode.into_response(),
|
||||
}
|
||||
} else {
|
||||
let invite_code_required = tranquil_config::get().server.invite_code_required;
|
||||
if invite_code_required
|
||||
&& input
|
||||
.invite_code
|
||||
.as_ref()
|
||||
.map(|c| c.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return ApiError::InviteCodeRequired.into_response();
|
||||
}
|
||||
if let Some(code) = &input.invite_code
|
||||
&& !code.trim().is_empty()
|
||||
{
|
||||
let valid = match state.repos.user.check_and_consume_invite_code(code).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("Error checking invite code: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if !valid {
|
||||
return ApiError::InvalidInviteCode.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
let invite_registration =
|
||||
match check_registration_invite(&state, input.invite_code.as_deref()).await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
|
||||
if let Err(e) = validate_password(&input.password) {
|
||||
return ApiError::InvalidRequest(e.to_string()).into_response();
|
||||
@@ -465,23 +441,15 @@ pub async fn create_account(
|
||||
None
|
||||
};
|
||||
|
||||
let did_for_commit: Did = match did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(),
|
||||
};
|
||||
let repo = match super::provision::init_genesis_repo(
|
||||
&state,
|
||||
&did_for_commit,
|
||||
&signing_key,
|
||||
&secret_key_bytes,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
let commit_cid_str = repo.commit_cid.to_string();
|
||||
let rev_str = repo.repo_rev.clone();
|
||||
let repo =
|
||||
match super::provision::init_genesis_repo(&state, &did, &signing_key, &secret_key_bytes)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
let commit_cid = CidLink::from(repo.commit_cid.to_string());
|
||||
let repo_rev = repo.repo_rev.clone();
|
||||
|
||||
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
|
||||
Some(json!({
|
||||
@@ -501,26 +469,23 @@ pub async fn create_account(
|
||||
let repo_for_seq = repo.clone();
|
||||
|
||||
let create_input = tranquil_db_traits::CreatePasswordAccountInput {
|
||||
handle: handle_typed.clone(),
|
||||
handle: handle.clone(),
|
||||
email: email.clone(),
|
||||
did: did_for_commit.clone(),
|
||||
did: did.clone(),
|
||||
password_hash,
|
||||
preferred_comms_channel,
|
||||
discord_username: comms.discord,
|
||||
telegram_username: comms.telegram,
|
||||
signal_username: comms.signal,
|
||||
deactivated_at,
|
||||
inbound_migration: is_migration || is_did_web_byod,
|
||||
encrypted_key_bytes: repo.encrypted_key_bytes,
|
||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||
reserved_key_id,
|
||||
commit_cid: commit_cid_str.clone(),
|
||||
repo_rev: rev_str.clone(),
|
||||
commit_cid: commit_cid.clone(),
|
||||
repo_rev: repo_rev.clone(),
|
||||
genesis_block_cids: repo.genesis_block_cids,
|
||||
invite_code: if is_bootstrap {
|
||||
None
|
||||
} else {
|
||||
input.invite_code.clone()
|
||||
},
|
||||
invite_code: invite_registration.into_invite_code(),
|
||||
birthdate_pref,
|
||||
};
|
||||
|
||||
@@ -540,6 +505,9 @@ pub async fn create_account(
|
||||
Err(tranquil_db_traits::CreateAccountError::DidExists) => {
|
||||
return ApiError::AccountAlreadyExists.into_response();
|
||||
}
|
||||
Err(tranquil_db_traits::CreateAccountError::InviteCodeUnavailable) => {
|
||||
return ApiError::InvalidInviteCode.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error creating password account: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
@@ -547,21 +515,15 @@ pub async fn create_account(
|
||||
};
|
||||
let user_id = create_result.user_id;
|
||||
if !is_migration && !is_did_web_byod {
|
||||
super::provision::sequence_new_account(
|
||||
&state,
|
||||
&did_for_commit,
|
||||
&handle_typed,
|
||||
&repo_for_seq,
|
||||
&input.handle,
|
||||
)
|
||||
.await;
|
||||
super::provision::sequence_new_account(&state, &did, &handle, &repo_for_seq, &input.handle)
|
||||
.await;
|
||||
}
|
||||
if !is_migration {
|
||||
if let Some(ref recipient) = verification_recipient {
|
||||
super::provision::enqueue_signup_verification(
|
||||
&state,
|
||||
user_id,
|
||||
&did_for_commit,
|
||||
&did,
|
||||
verification_channel,
|
||||
recipient,
|
||||
)
|
||||
@@ -571,7 +533,7 @@ pub async fn create_account(
|
||||
super::provision::enqueue_migration_verification(
|
||||
&state,
|
||||
user_id,
|
||||
&did_for_commit,
|
||||
&did,
|
||||
verification_channel,
|
||||
recipient,
|
||||
)
|
||||
@@ -581,7 +543,6 @@ pub async fn create_account(
|
||||
let session = match super::provision::create_and_store_session(
|
||||
&state,
|
||||
&did,
|
||||
&did_for_commit,
|
||||
&secret_key_bytes,
|
||||
"transition:generic transition:chat.bsky",
|
||||
None,
|
||||
@@ -604,8 +565,8 @@ pub async fn create_account(
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.clone().into(),
|
||||
did: did_for_commit,
|
||||
handle: handle.clone(),
|
||||
did,
|
||||
did_doc: did_doc.map(|f| (*f).clone()),
|
||||
access_jwt: session.access_jwt,
|
||||
refresh_jwt: session.refresh_jwt,
|
||||
|
||||
@@ -19,7 +19,7 @@ use tranquil_pds::rate_limit::{
|
||||
HandleUpdateDailyLimit, HandleUpdateLimit, check_user_rate_limit_with_message,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::Handle;
|
||||
use tranquil_pds::types::{Did, Handle};
|
||||
use tranquil_pds::util::get_header_str;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -44,16 +44,16 @@ pub async fn resolve_handle(
|
||||
if handle_str.is_empty() {
|
||||
return ApiError::InvalidRequest("handle is required".into()).into_response();
|
||||
}
|
||||
let cache_key = tranquil_pds::cache_keys::handle_key(handle_str);
|
||||
if let Some(did) = state.cache.get(&cache_key).await {
|
||||
return DidResponse::response(did).into_response();
|
||||
}
|
||||
let handle: Handle = match handle_str.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
|
||||
}
|
||||
};
|
||||
let cache_key = tranquil_pds::cache_keys::handle_key(&handle);
|
||||
if let Some(did) = state.cache.get(&cache_key).await {
|
||||
return DidResponse::response(did).into_response();
|
||||
}
|
||||
let user = state.repos.user.get_by_handle(&handle).await;
|
||||
match user {
|
||||
Ok(Some(row)) => {
|
||||
@@ -63,7 +63,7 @@ pub async fn resolve_handle(
|
||||
.await;
|
||||
DidResponse::response(row.did).into_response()
|
||||
}
|
||||
Ok(None) => match tranquil_pds::handle::resolve_handle(handle.as_str()).await {
|
||||
Ok(None) => match tranquil_pds::handle::resolve_handle(&handle).await {
|
||||
Ok(did) => {
|
||||
let _ = state
|
||||
.cache
|
||||
@@ -159,15 +159,15 @@ pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -
|
||||
|
||||
async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response {
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
let expected_did = format!("did:web:{}", encoded_handle);
|
||||
let expected_did_typed: tranquil_pds::types::Did = match expected_did.parse() {
|
||||
let expected_did: tranquil_pds::types::Did = match format!("did:web:{}", encoded_handle).parse()
|
||||
{
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InvalidRequest("Invalid DID format".into()).into_response(),
|
||||
};
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_user_for_did_doc_build(&expected_did_typed)
|
||||
.get_user_for_did_doc_build(&expected_did)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
@@ -212,8 +212,7 @@ async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) ->
|
||||
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let current_handle = format!("{}.{}", handle, hostname_for_handles);
|
||||
let current_handle_typed: Handle = match current_handle.parse() {
|
||||
let current_handle: Handle = match format!("{}.{}", handle, hostname_for_handles).parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
|
||||
@@ -222,7 +221,7 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_did_web_info_by_handle(¤t_handle_typed)
|
||||
.get_did_web_info_by_handle(¤t_handle)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
@@ -347,7 +346,7 @@ pub async fn verify_did_web(
|
||||
did: &str,
|
||||
hostname: &str,
|
||||
handle: &str,
|
||||
expected_signing_key: Option<&str>,
|
||||
expected_signing_key: Option<&Did>,
|
||||
) -> Result<(), DidWebVerifyError> {
|
||||
let hostname_for_handles = hostname.split(':').next().unwrap_or(hostname);
|
||||
let subdomain_host = format!("{}.{}", handle, hostname_for_handles);
|
||||
@@ -491,16 +490,10 @@ pub async fn get_recommended_did_credentials(
|
||||
let rotation_keys = if auth.did.starts_with("did:web:") {
|
||||
vec![]
|
||||
} else {
|
||||
let server_rotation_key = match &tranquil_config::get().secrets.plc_rotation_key {
|
||||
Some(key) => key.clone(),
|
||||
None => {
|
||||
warn!(
|
||||
"PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation"
|
||||
);
|
||||
did_key.clone()
|
||||
}
|
||||
};
|
||||
vec![server_rotation_key]
|
||||
tranquil_pds::plc::rotation_keys_for(
|
||||
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
|
||||
&signing_key,
|
||||
)
|
||||
};
|
||||
Ok(Json(GetRecommendedDidCredentialsOutput {
|
||||
rotation_keys,
|
||||
@@ -589,7 +582,7 @@ pub async fn update_handle(
|
||||
.max_by_key(|d| d.len())
|
||||
.cloned();
|
||||
let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle);
|
||||
let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some())
|
||||
let handle: Handle = if (!new_handle.contains('.') || matched_handle_domain.is_some())
|
||||
&& !is_domain_itself
|
||||
{
|
||||
let (short_part, full_handle) = match &matched_handle_domain {
|
||||
@@ -604,13 +597,12 @@ pub async fn update_handle(
|
||||
}
|
||||
};
|
||||
if full_handle == current_handle {
|
||||
let handle_typed: Handle = match full_handle.parse() {
|
||||
let handle: Handle = match full_handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||
};
|
||||
if let Err(e) =
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle_typed))
|
||||
.await
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for handle update: {}", e);
|
||||
}
|
||||
@@ -628,21 +620,21 @@ pub async fn update_handle(
|
||||
return Err(ApiError::InvalidHandle(Some("Handle too long".into())));
|
||||
}
|
||||
full_handle
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?
|
||||
} else {
|
||||
let handle: Handle = new_handle
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?;
|
||||
if new_handle == current_handle {
|
||||
let handle_typed: Handle = match new_handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||
};
|
||||
if let Err(e) =
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle_typed))
|
||||
.await
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for handle update: {}", e);
|
||||
}
|
||||
return Ok(Json(EmptyResponse {}));
|
||||
}
|
||||
match tranquil_pds::handle::verify_handle_ownership(&new_handle, &did).await {
|
||||
match tranquil_pds::handle::verify_handle_ownership(&handle, &did).await {
|
||||
Ok(()) => {}
|
||||
Err(tranquil_pds::handle::HandleResolutionError::NotFound) => {
|
||||
return Err(ApiError::HandleNotAvailable(None));
|
||||
@@ -661,15 +653,12 @@ pub async fn update_handle(
|
||||
))));
|
||||
}
|
||||
}
|
||||
new_handle.clone()
|
||||
handle
|
||||
};
|
||||
let handle_typed: Handle = handle
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?;
|
||||
let handle_exists = state
|
||||
.repos
|
||||
.user
|
||||
.check_handle_exists(&handle_typed, user_id)
|
||||
.check_handle_exists(&handle, user_id)
|
||||
.await
|
||||
.log_db_err("checking handle existence")?;
|
||||
if handle_exists {
|
||||
@@ -678,7 +667,7 @@ pub async fn update_handle(
|
||||
state
|
||||
.repos
|
||||
.user
|
||||
.update_handle(user_id, &handle_typed)
|
||||
.update_handle(user_id, &handle)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error updating handle: {:?}", e);
|
||||
@@ -696,11 +685,11 @@ pub async fn update_handle(
|
||||
.delete(&tranquil_pds::cache_keys::handle_key(&handle))
|
||||
.await;
|
||||
if let Err(e) =
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle_typed)).await
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for handle update: {}", e);
|
||||
}
|
||||
if let Err(e) = update_plc_handle(&state, &did, &handle_typed).await {
|
||||
if let Err(e) = update_plc_handle(&state, &did, &handle).await {
|
||||
warn!("Failed to update PLC handle: {}", e);
|
||||
}
|
||||
Ok(Json(EmptyResponse {}))
|
||||
|
||||
@@ -26,10 +26,9 @@ pub async fn verify_handle_ownership(
|
||||
_rate_limit: RateLimited<HandleVerificationLimit>,
|
||||
Json(input): Json<VerifyHandleOwnershipInput>,
|
||||
) -> Response {
|
||||
let handle_str = input.handle.as_str();
|
||||
let did_str = input.did.as_str();
|
||||
|
||||
let dns_mismatch = match tranquil_pds::handle::resolve_handle_dns(handle_str).await {
|
||||
let dns_mismatch = match tranquil_pds::handle::resolve_handle_dns(&input.handle).await {
|
||||
Ok(did) if did == did_str => {
|
||||
return Json(VerifyHandleOwnershipOutput {
|
||||
verified: true,
|
||||
@@ -45,7 +44,7 @@ pub async fn verify_handle_ownership(
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
match tranquil_pds::handle::resolve_handle_http(handle_str).await {
|
||||
match tranquil_pds::handle::resolve_handle_http(&input.handle).await {
|
||||
Ok(did) if did == did_str => Json(VerifyHandleOwnershipOutput {
|
||||
verified: true,
|
||||
method: Some("http".to_string()),
|
||||
|
||||
@@ -3,5 +3,5 @@ mod sign;
|
||||
mod submit;
|
||||
|
||||
pub use request::request_plc_operation_signature;
|
||||
pub use sign::{ServiceInput, SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation};
|
||||
pub use sign::{SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation};
|
||||
pub use submit::{SubmitPlcOperationInput, submit_plc_operation};
|
||||
|
||||
@@ -6,10 +6,6 @@ use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{Auth, Permissive};
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
fn generate_plc_token() -> String {
|
||||
tranquil_pds::util::generate_token_code()
|
||||
}
|
||||
|
||||
pub async fn request_plc_operation_signature(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Permissive>,
|
||||
@@ -28,12 +24,13 @@ pub async fn request_plc_operation_signature(
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let _ = state.repos.infra.delete_plc_tokens_for_user(user_id).await;
|
||||
let plc_token = generate_plc_token();
|
||||
let display_token = tranquil_pds::util::generate_token_code();
|
||||
let stored_token = tranquil_pds::util::normalize_token_code(&display_token);
|
||||
let expires_at = Utc::now() + Duration::minutes(10);
|
||||
state
|
||||
.repos
|
||||
.infra
|
||||
.insert_plc_token(user_id, &plc_token, expires_at)
|
||||
.insert_plc_token(user_id, &stored_token, expires_at)
|
||||
.await
|
||||
.log_db_err("creating PLC token")?;
|
||||
|
||||
@@ -42,7 +39,7 @@ pub async fn request_plc_operation_signature(
|
||||
state.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user_id,
|
||||
&plc_token,
|
||||
&display_token,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -9,7 +9,10 @@ use tranquil_pds::api::ApiError;
|
||||
use tranquil_pds::api::error::DbResultExt;
|
||||
use tranquil_pds::auth::{Auth, Permissive};
|
||||
use tranquil_pds::circuit_breaker::with_circuit_breaker;
|
||||
use tranquil_pds::plc::{PlcError, PlcService, ServiceType, create_update_op, sign_operation};
|
||||
use tranquil_pds::plc::{
|
||||
PlcError, PlcService, create_update_op, missing_required_rotation_key, sign_operation,
|
||||
signing_key_to_did_key,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -19,14 +22,7 @@ pub struct SignPlcOperationInput {
|
||||
pub rotation_keys: Option<Vec<String>>,
|
||||
pub also_known_as: Option<Vec<String>>,
|
||||
pub verification_methods: Option<HashMap<String, String>>,
|
||||
pub services: Option<HashMap<String, ServiceInput>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ServiceInput {
|
||||
#[serde(rename = "type")]
|
||||
pub service_type: ServiceType,
|
||||
pub endpoint: String,
|
||||
pub services: Option<HashMap<String, PlcService>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -50,9 +46,10 @@ pub async fn sign_plc_operation(
|
||||
"PLC operations are only valid for did:plc identities".into(),
|
||||
));
|
||||
}
|
||||
let token = input.token.as_ref().ok_or_else(|| {
|
||||
let raw_token = input.token.as_ref().ok_or_else(|| {
|
||||
ApiError::InvalidRequest("Email confirmation token required to sign PLC operations".into())
|
||||
})?;
|
||||
let token = &tranquil_pds::util::normalize_token_code(raw_token);
|
||||
|
||||
let user_id = state
|
||||
.repos
|
||||
@@ -106,25 +103,12 @@ pub async fn sign_plc_operation(
|
||||
if last_op.is_tombstone() {
|
||||
return Err(ApiError::from(PlcError::Tombstoned));
|
||||
}
|
||||
let services = input.services.map(|s| {
|
||||
s.into_iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k,
|
||||
PlcService {
|
||||
service_type: v.service_type,
|
||||
endpoint: v.endpoint,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let unsigned_op = create_update_op(
|
||||
&last_op,
|
||||
input.rotation_keys,
|
||||
input.verification_methods,
|
||||
input.also_known_as,
|
||||
services,
|
||||
input.services,
|
||||
)
|
||||
.map_err(|e| match e {
|
||||
PlcError::Tombstoned => ApiError::InvalidRequest("Cannot update tombstoned DID".into()),
|
||||
@@ -134,6 +118,18 @@ pub async fn sign_plc_operation(
|
||||
}
|
||||
})?;
|
||||
|
||||
let signing_did_key = signing_key_to_did_key(&signing_key);
|
||||
if let Some(rotation_keys) = unsigned_op.get("rotationKeys").and_then(Value::as_array) {
|
||||
let rotation_key_strs: Vec<&str> = rotation_keys.iter().filter_map(Value::as_str).collect();
|
||||
if let Some(missing) = missing_required_rotation_key(
|
||||
&rotation_key_strs,
|
||||
&signing_did_key,
|
||||
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
|
||||
) {
|
||||
return Err(ApiError::InvalidRequest(missing.message().into()));
|
||||
}
|
||||
}
|
||||
|
||||
let signed_op = sign_operation(&unsigned_op, &signing_key).map_err(|e| {
|
||||
error!("Failed to sign PLC operation: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
|
||||
@@ -67,19 +67,14 @@ pub async fn submit_plc_operation(
|
||||
})?;
|
||||
|
||||
let user_did_key = signing_key_to_did_key(&signing_key);
|
||||
let server_rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| user_did_key.clone());
|
||||
if let Some(rotation_keys) = op.get("rotationKeys").and_then(Value::as_array) {
|
||||
let has_server_key = rotation_keys
|
||||
.iter()
|
||||
.any(|k| k.as_str() == Some(&server_rotation_key));
|
||||
if !has_server_key {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Rotation keys do not include server's rotation key".into(),
|
||||
));
|
||||
let rotation_key_strs: Vec<&str> = rotation_keys.iter().filter_map(Value::as_str).collect();
|
||||
if let Some(missing) = tranquil_pds::plc::missing_required_rotation_key(
|
||||
&rotation_key_strs,
|
||||
&user_did_key,
|
||||
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
|
||||
) {
|
||||
return Err(ApiError::InvalidRequest(missing.message().into()));
|
||||
}
|
||||
}
|
||||
if let Some(services) = op.get("services").and_then(Value::as_object)
|
||||
@@ -129,20 +124,13 @@ pub async fn submit_plc_operation(
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
match state
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.repo
|
||||
.insert_identity_event(did, Some(&user.handle))
|
||||
.await
|
||||
{
|
||||
Ok(seq) => {
|
||||
if let Err(e) = state.repos.repo.notify_update(seq).await {
|
||||
warn!("Failed to notify identity event: {:?}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to sequence identity event: {:?}", e);
|
||||
}
|
||||
warn!("Failed to sequence identity event: {:?}", e);
|
||||
}
|
||||
let _ = state
|
||||
.cache
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use jacquard_common::types::{integer::LimitedU32, string::Tid};
|
||||
use jacquard_common::types::{integer::LimitedU32, string::Tid as JacquardTid};
|
||||
use jacquard_repo::{mst::Mst, storage::BlockStore};
|
||||
use k256::ecdsa::SigningKey;
|
||||
use std::sync::Arc;
|
||||
@@ -6,7 +6,7 @@ use tranquil_db_traits::CommsChannel;
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::repo_ops::create_signed_commit;
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Handle};
|
||||
use tranquil_pds::types::{CidLink, Did, Handle, Tid};
|
||||
|
||||
pub struct PlcDidResult {
|
||||
pub did: Did,
|
||||
@@ -14,7 +14,7 @@ pub struct PlcDidResult {
|
||||
pub signing_key: SigningKey,
|
||||
}
|
||||
|
||||
pub async fn create_plc_did(state: &AppState, handle: &str) -> Result<PlcDidResult, ApiError> {
|
||||
pub async fn create_plc_did(state: &AppState, handle: &Handle) -> Result<PlcDidResult, ApiError> {
|
||||
use k256::SecretKey;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
@@ -25,10 +25,7 @@ pub async fn create_plc_did(state: &AppState, handle: &str) -> Result<PlcDidResu
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let did_str = submit_plc_genesis(state, &signing_key, handle).await?;
|
||||
let did: Did = did_str
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InternalError(Some("PLC genesis returned invalid DID".into())))?;
|
||||
let did = submit_plc_genesis(state, &signing_key, handle).await?;
|
||||
|
||||
Ok(PlcDidResult {
|
||||
did,
|
||||
@@ -40,20 +37,14 @@ pub async fn create_plc_did(state: &AppState, handle: &str) -> Result<PlcDidResu
|
||||
pub async fn submit_plc_genesis(
|
||||
state: &AppState,
|
||||
signing_key: &SigningKey,
|
||||
handle: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
handle: &Handle,
|
||||
) -> Result<Did, ApiError> {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
|
||||
let rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(signing_key));
|
||||
|
||||
let genesis_result = tranquil_pds::plc::create_genesis_operation(
|
||||
signing_key,
|
||||
&rotation_key,
|
||||
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
|
||||
handle,
|
||||
&pds_endpoint,
|
||||
)
|
||||
@@ -80,7 +71,7 @@ pub struct GenesisRepo {
|
||||
pub encrypted_key_bytes: Vec<u8>,
|
||||
pub commit_cid: cid::Cid,
|
||||
pub mst_root_cid: cid::Cid,
|
||||
pub repo_rev: String,
|
||||
pub repo_rev: Tid,
|
||||
pub genesis_block_cids: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
@@ -102,8 +93,8 @@ pub async fn init_genesis_repo(
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let (commit_bytes, _sig) = create_signed_commit(did, mst_root, rev.as_ref(), None, signing_key)
|
||||
let rev = JacquardTid::now(LimitedU32::MIN);
|
||||
let (commit_bytes, _sig) = create_signed_commit(did, mst_root, &rev, None, signing_key)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error creating genesis commit: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
@@ -118,7 +109,7 @@ pub async fn init_genesis_repo(
|
||||
encrypted_key_bytes,
|
||||
commit_cid,
|
||||
mst_root_cid: mst_root,
|
||||
repo_rev: rev.as_ref().to_string(),
|
||||
repo_rev: Tid::from(rev.as_ref().to_string()),
|
||||
genesis_block_cids: vec![mst_root.to_bytes(), commit_cid.to_bytes()],
|
||||
})
|
||||
}
|
||||
@@ -131,7 +122,7 @@ pub struct SigningKeyResult {
|
||||
|
||||
pub async fn resolve_signing_key(
|
||||
state: &AppState,
|
||||
signing_key_did: Option<&str>,
|
||||
signing_key_did: Option<&Did>,
|
||||
) -> Result<SigningKeyResult, ApiError> {
|
||||
match signing_key_did {
|
||||
Some(key_did) => {
|
||||
@@ -207,7 +198,7 @@ pub async fn sequence_new_account(
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event(
|
||||
state,
|
||||
did,
|
||||
&repo.commit_cid.to_string(),
|
||||
&CidLink::from(repo.commit_cid.to_string()),
|
||||
Some(&repo.repo_rev),
|
||||
)
|
||||
.await
|
||||
@@ -264,25 +255,23 @@ pub struct SessionResult {
|
||||
|
||||
pub async fn create_and_store_session(
|
||||
state: &AppState,
|
||||
did_str: &str,
|
||||
did: &Did,
|
||||
signing_key_bytes: &[u8],
|
||||
scope: &str,
|
||||
controller_did: Option<&Did>,
|
||||
) -> Result<SessionResult, ApiError> {
|
||||
let access_meta =
|
||||
tranquil_pds::auth::create_access_token_with_metadata(did_str, signing_key_bytes).map_err(
|
||||
let access_meta = tranquil_pds::auth::create_access_token_with_metadata(did, signing_key_bytes)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error creating access token: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let refresh_meta =
|
||||
tranquil_pds::auth::create_refresh_token_with_metadata(did, signing_key_bytes).map_err(
|
||||
|e| {
|
||||
tracing::error!("Error creating access token: {:?}", e);
|
||||
tracing::error!("Error creating refresh token: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
},
|
||||
)?;
|
||||
let refresh_meta =
|
||||
tranquil_pds::auth::create_refresh_token_with_metadata(did_str, signing_key_bytes)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error creating refresh token: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: did.clone(),
|
||||
access_jti: access_meta.jti.clone(),
|
||||
|
||||
@@ -15,8 +15,12 @@ pub mod temp;
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
pub fn api_routes() -> axum::Router<AppState> {
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{get, post};
|
||||
|
||||
let blob_body_limit =
|
||||
DefaultBodyLimit::max(tranquil_config::get().server.max_blob_size as usize);
|
||||
|
||||
axum::Router::new()
|
||||
.route("/_health", get(server::health))
|
||||
.route(
|
||||
@@ -68,7 +72,10 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
.route("/com.atproto.repo.deleteRecord", post(repo::delete_record))
|
||||
.route("/com.atproto.repo.listRecords", get(repo::list_records))
|
||||
.route("/com.atproto.repo.describeRepo", get(repo::describe_repo))
|
||||
.route("/com.atproto.repo.uploadBlob", post(repo::upload_blob))
|
||||
.route(
|
||||
"/com.atproto.repo.uploadBlob",
|
||||
post(repo::upload_blob).layer(blob_body_limit),
|
||||
)
|
||||
.route("/com.atproto.repo.applyWrites", post(repo::apply_writes))
|
||||
.route(
|
||||
"/com.atproto.server.checkAccountStatus",
|
||||
@@ -247,7 +254,10 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
"/_identity.verifyHandleOwnership",
|
||||
post(identity::verify_handle_ownership),
|
||||
)
|
||||
.route("/com.atproto.repo.importRepo", post(repo::import_repo))
|
||||
.route(
|
||||
"/com.atproto.repo.importRepo",
|
||||
post(repo::import_repo).layer(blob_body_limit),
|
||||
)
|
||||
.route(
|
||||
"/com.atproto.admin.deleteAccount",
|
||||
post(admin::delete_account),
|
||||
|
||||
@@ -6,11 +6,12 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
use tranquil_pds::api::ApiError;
|
||||
use tranquil_pds::api::proxy_client::{is_ssrf_safe, proxy_client};
|
||||
use tranquil_pds::auth::{AnyUser, Auth};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Nsid};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReportReasonType {
|
||||
@@ -65,17 +66,26 @@ pub struct CreateReportOutput {
|
||||
|
||||
struct ReportServiceConfig {
|
||||
url: String,
|
||||
did: String,
|
||||
did: Did,
|
||||
}
|
||||
|
||||
fn get_report_service_config() -> Option<ReportServiceConfig> {
|
||||
let cfg = tranquil_config::get();
|
||||
let url = cfg.moderation.report_service_url.clone()?;
|
||||
let did = cfg.moderation.report_service_did.clone()?;
|
||||
if url.is_empty() || did.is_empty() {
|
||||
let did_str = cfg.moderation.report_service_did.as_deref()?;
|
||||
if url.is_empty() || did_str.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(ReportServiceConfig { url, did })
|
||||
match did_str.parse::<Did>() {
|
||||
Ok(did) => Some(ReportServiceConfig { url, did }),
|
||||
Err(_) => {
|
||||
warn!(
|
||||
report_service_did = did_str,
|
||||
"invalid report_service_did, handling reports locally"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_report(
|
||||
@@ -96,7 +106,7 @@ async fn proxy_to_report_service(
|
||||
state: &AppState,
|
||||
auth_user: &tranquil_pds::auth::AuthenticatedUser,
|
||||
service_url: &str,
|
||||
service_did: &str,
|
||||
service_did: &Did,
|
||||
input: &CreateReportInput,
|
||||
) -> Response {
|
||||
if let Err(e) = is_ssrf_safe(service_url) {
|
||||
@@ -135,10 +145,11 @@ async fn proxy_to_report_service(
|
||||
},
|
||||
};
|
||||
|
||||
let report_lxm = Nsid::from("com.atproto.moderation.createReport".to_string());
|
||||
let service_token = match tranquil_pds::auth::create_service_token(
|
||||
&auth_user.did,
|
||||
service_did,
|
||||
Some("com.atproto.moderation.createReport"),
|
||||
Some(&report_lxm),
|
||||
&key_bytes,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
|
||||
@@ -6,7 +6,7 @@ use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType};
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{Active, Auth};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_types::Did;
|
||||
use tranquil_types::{Did, Handle};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -137,7 +137,7 @@ pub async fn request_channel_verification(
|
||||
did: &Did,
|
||||
channel: CommsChannel,
|
||||
identifier: &str,
|
||||
handle: Option<&str>,
|
||||
handle: Option<&Handle>,
|
||||
) -> Result<String, ApiError> {
|
||||
let token = tranquil_pds::auth::verification_token::generate_channel_update_token(
|
||||
did, channel, identifier,
|
||||
@@ -147,12 +147,12 @@ pub async fn request_channel_verification(
|
||||
match channel {
|
||||
CommsChannel::Email => {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let handle_str = handle.unwrap_or("user");
|
||||
let fallback_handle = Handle::from("user".to_string());
|
||||
tranquil_pds::comms::comms_repo::enqueue_email_update(
|
||||
state.repos.infra.as_ref(),
|
||||
user_id,
|
||||
identifier,
|
||||
handle_str,
|
||||
handle.unwrap_or(&fallback_handle),
|
||||
&formatted_token,
|
||||
hostname,
|
||||
)
|
||||
@@ -282,7 +282,7 @@ async fn process_messaging_channel_update(
|
||||
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
|
||||
),
|
||||
CommsChannel::Signal => ApiError::InvalidRequest(
|
||||
"Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)".into(),
|
||||
"Invalid Signal username. Must be a 3-32 character nickname, a dot, then a 2-20 digit discriminator".into(),
|
||||
),
|
||||
CommsChannel::Email => ApiError::InvalidEmail,
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{Auth, AuthAny, NotTakendown, Permissive, VerifyScope};
|
||||
use tranquil_pds::delegation::DelegationActionType;
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{CidLink, Did};
|
||||
use tranquil_pds::types::{CidLink, Did, Nsid};
|
||||
use tranquil_pds::util::get_header_str;
|
||||
|
||||
fn detect_mime_type(data: &[u8], client_hint: &str) -> String {
|
||||
@@ -48,7 +48,7 @@ pub async fn upload_blob(
|
||||
) -> Result<Response, ApiError> {
|
||||
let (did, controller_did): (Did, Option<Did>) = match &auth {
|
||||
AuthAny::Service(service) => {
|
||||
service.require_lxm("com.atproto.repo.uploadBlob")?;
|
||||
service.require_lxm(&Nsid::from("com.atproto.repo.uploadBlob".to_string()))?;
|
||||
(service.did.clone(), None)
|
||||
}
|
||||
AuthAny::User(user) => {
|
||||
|
||||
@@ -14,6 +14,35 @@ use tranquil_pds::sync::verify::CarVerifier;
|
||||
use tranquil_pds::types::Did;
|
||||
use tranquil_types::{AtUri, CidLink};
|
||||
|
||||
fn map_car_verify_error(e: tranquil_pds::sync::verify::VerifyError) -> ApiError {
|
||||
use tranquil_pds::sync::verify::VerifyError;
|
||||
match e {
|
||||
VerifyError::DidMismatch {
|
||||
commit_did,
|
||||
expected_did,
|
||||
} => ApiError::InvalidRepo(format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, expected_did
|
||||
)),
|
||||
VerifyError::InvalidSignature => ApiError::InvalidRequest(
|
||||
"Repo commit signature does not match the DID document signing key".into(),
|
||||
),
|
||||
VerifyError::NoSigningKey => {
|
||||
ApiError::InvalidRequest("DID document has no atproto signing key".into())
|
||||
}
|
||||
VerifyError::DidResolutionFailed(msg) => {
|
||||
ApiError::InvalidRequest(format!("Could not resolve DID document: {}", msg))
|
||||
}
|
||||
VerifyError::MstValidationFailed(msg) => {
|
||||
ApiError::InvalidRequest(format!("MST validation failed: {}", msg))
|
||||
}
|
||||
other => {
|
||||
error!("CAR verification failed: {:?}", other);
|
||||
ApiError::InvalidRequest(format!("CAR verification failed: {}", other))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn import_repo(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<NotTakendown>,
|
||||
@@ -77,27 +106,6 @@ pub async fn import_repo(
|
||||
blocks.len(),
|
||||
root
|
||||
);
|
||||
let Some(root_block) = blocks.get(&root) else {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Root block not found in CAR file".into(),
|
||||
));
|
||||
};
|
||||
let commit_did: Did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
|
||||
Ok(commit) => commit
|
||||
.did()
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidRequest("Commit contains invalid DID".into()))?,
|
||||
Err(e) => {
|
||||
return Err(ApiError::InvalidRequest(format!("Invalid commit: {}", e)));
|
||||
}
|
||||
};
|
||||
if commit_did != *did {
|
||||
return Err(ApiError::InvalidRepo(format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, did
|
||||
)));
|
||||
}
|
||||
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
|
||||
.ok()
|
||||
.map(|v| v == "true" || v == "1")
|
||||
@@ -106,92 +114,26 @@ pub async fn import_repo(
|
||||
.map(|c| c.import.skip_verification)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let is_migration = user.deactivated_at.is_some();
|
||||
let is_migration = user.inbound_migration && user.deactivated_at.is_some();
|
||||
if skip_verification {
|
||||
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
|
||||
warn!("Skipping all CAR verification for repo import (SKIP_IMPORT_VERIFICATION=true)");
|
||||
} else if is_migration {
|
||||
debug!("Verifying CAR file structure for migration (skipping signature verification)");
|
||||
let verifier = CarVerifier::new();
|
||||
match verifier.verify_car_structure_only(did, &root, &blocks) {
|
||||
Ok(verified) => {
|
||||
debug!(
|
||||
"CAR structure verification successful: rev={}, data_cid={}",
|
||||
verified.rev, verified.data_cid
|
||||
);
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::DidMismatch {
|
||||
commit_did,
|
||||
expected_did,
|
||||
}) => {
|
||||
return Err(ApiError::InvalidRepo(format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, expected_did
|
||||
)));
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::MstValidationFailed(msg)) => {
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"MST validation failed: {}",
|
||||
msg
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("CAR structure verification error: {:?}", e);
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"CAR verification failed: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
let verified = CarVerifier::new()
|
||||
.verify_car_structure_only(&root, &blocks)
|
||||
.map_err(map_car_verify_error)?;
|
||||
debug!(
|
||||
"CAR structure verified for migration import: rev={}, data_cid={}",
|
||||
verified.rev, verified.data_cid
|
||||
);
|
||||
} else {
|
||||
debug!("Verifying CAR file signature and structure for DID {}", did);
|
||||
let verifier = CarVerifier::new();
|
||||
match verifier.verify_car(did, &root, &blocks).await {
|
||||
Ok(verified) => {
|
||||
debug!(
|
||||
"CAR verification successful: rev={}, data_cid={}",
|
||||
verified.rev, verified.data_cid
|
||||
);
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::DidMismatch {
|
||||
commit_did,
|
||||
expected_did,
|
||||
}) => {
|
||||
return Err(ApiError::InvalidRepo(format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, expected_did
|
||||
)));
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::InvalidSignature) => {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"CAR file commit signature verification failed".into(),
|
||||
));
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::DidResolutionFailed(msg)) => {
|
||||
warn!("DID resolution failed during import verification: {}", msg);
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"Failed to verify DID: {}",
|
||||
msg
|
||||
)));
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::NoSigningKey) => {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"DID document does not contain a signing key".into(),
|
||||
));
|
||||
}
|
||||
Err(tranquil_pds::sync::verify::VerifyError::MstValidationFailed(msg)) => {
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"MST validation failed: {}",
|
||||
msg
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("CAR verification error: {:?}", e);
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"CAR verification failed: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
let verified = CarVerifier::new()
|
||||
.verify_car(did, &root, &blocks)
|
||||
.await
|
||||
.map_err(map_car_verify_error)?;
|
||||
debug!(
|
||||
"CAR signature and structure verified: rev={}, data_cid={}",
|
||||
verified.rev, verified.data_cid
|
||||
);
|
||||
}
|
||||
let max_blocks = tranquil_config::get().import.max_blocks as usize;
|
||||
let _write_lock = state.repo_write_locks.lock(user_id).await;
|
||||
@@ -225,17 +167,11 @@ pub async fn import_repo(
|
||||
.records
|
||||
.iter()
|
||||
.flat_map(|record| {
|
||||
let record_uri =
|
||||
AtUri::from_parts(did.as_str(), &record.collection, &record.rkey);
|
||||
record.blob_refs.iter().filter_map(move |blob_ref| {
|
||||
match CidLink::new(&blob_ref.cid) {
|
||||
Ok(cid_link) => Some((record_uri.clone(), cid_link)),
|
||||
Err(_) => {
|
||||
tracing::warn!(cid = %blob_ref.cid, "skipping unparseable blob CID reference during import");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
let record_uri = AtUri::from_parts(did, &record.collection, &record.rkey);
|
||||
record
|
||||
.blob_refs
|
||||
.iter()
|
||||
.map(move |blob_ref| (record_uri.clone(), blob_ref.cid.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -285,27 +221,23 @@ pub async fn import_repo(
|
||||
})?;
|
||||
let new_rev = Tid::now(LimitedU32::MIN);
|
||||
let new_rev_str = new_rev.to_string();
|
||||
let (commit_bytes, _sig) = create_signed_commit(
|
||||
did,
|
||||
import_result.data_cid,
|
||||
&new_rev_str,
|
||||
None,
|
||||
&signing_key,
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("Failed to create new commit: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let (commit_bytes, _sig) =
|
||||
create_signed_commit(did, import_result.data_cid, &new_rev, None, &signing_key)
|
||||
.map_err(|e| {
|
||||
error!("Failed to create new commit: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let new_root_cid: cid::Cid =
|
||||
state.block_store.put(&commit_bytes).await.map_err(|e| {
|
||||
error!("Failed to store new commit block: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let new_root_cid_link = CidLink::from(&new_root_cid);
|
||||
let new_rev_tid = tranquil_pds::types::Tid::from(new_rev_str.clone());
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
.update_repo_root(user_id, &new_root_cid_link, &new_rev_str)
|
||||
.update_repo_root(user_id, &new_root_cid_link, &new_rev_tid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to update repo root: {:?}", e);
|
||||
@@ -316,7 +248,7 @@ pub async fn import_repo(
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
.insert_user_blocks(user_id, &all_block_cids, &new_rev_str)
|
||||
.insert_user_blocks(user_id, &all_block_cids, &new_rev_tid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to insert user_blocks: {:?}", e);
|
||||
@@ -417,7 +349,6 @@ async fn sequence_import_event(
|
||||
rev: None,
|
||||
};
|
||||
|
||||
let seq = state.repos.repo.insert_commit_event(&data).await?;
|
||||
state.repos.repo.notify_update(seq).await?;
|
||||
state.repos.repo.insert_commit_event(&data).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::validation::validate_record_with_status;
|
||||
use super::validation_mode::{ValidationMode, deserialize_validation_mode};
|
||||
use crate::repo::record::write::CommitInfo;
|
||||
use crate::repo::record::write::{CommitInfo, ensure_record_type};
|
||||
use axum::{Json, extract::State};
|
||||
use jacquard_repo::{mst::Mst, storage::BlockStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -14,11 +14,11 @@ use tranquil_pds::auth::{
|
||||
};
|
||||
use tranquil_pds::repo::TrackingBlockStore;
|
||||
use tranquil_pds::repo_ops::{
|
||||
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
|
||||
finalize_repo_write,
|
||||
CommitResult, FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
|
||||
finalize_repo_write, with_repair_retry,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, CidLink, Did, Nsid, Rkey};
|
||||
use tranquil_pds::validation::ValidationStatus;
|
||||
|
||||
const MAX_BATCH_WRITES: usize = 200;
|
||||
@@ -27,8 +27,7 @@ struct WriteAccumulator {
|
||||
mst: Mst<TrackingBlockStore>,
|
||||
results: Vec<WriteResult>,
|
||||
ops: Vec<RecordOp>,
|
||||
modified_keys: Vec<String>,
|
||||
all_blob_cids: Vec<String>,
|
||||
all_blob_cids: Vec<CidLink>,
|
||||
backlinks_to_add: Vec<Backlink>,
|
||||
backlinks_to_remove: Vec<AtUri>,
|
||||
}
|
||||
@@ -44,7 +43,6 @@ async fn process_single_write(
|
||||
mst,
|
||||
mut results,
|
||||
mut ops,
|
||||
mut modified_keys,
|
||||
mut all_blob_cids,
|
||||
mut backlinks_to_add,
|
||||
mut backlinks_to_remove,
|
||||
@@ -56,6 +54,8 @@ async fn process_single_write(
|
||||
rkey,
|
||||
value,
|
||||
} => {
|
||||
let value = ensure_record_type(value, collection);
|
||||
let value = &*value;
|
||||
let validation_status = if validate.should_skip() {
|
||||
None
|
||||
} else {
|
||||
@@ -69,8 +69,19 @@ async fn process_single_write(
|
||||
.await?,
|
||||
)
|
||||
};
|
||||
all_blob_cids.extend(extract_blob_cids(value));
|
||||
let rkey = rkey.clone().unwrap_or_else(Rkey::generate);
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
if mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::from_mst_error("read MST for applyWrites create", &e))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"Record already exists at {key}"
|
||||
)));
|
||||
}
|
||||
all_blob_cids.extend(extract_blob_cids(value));
|
||||
let record_ipld = tranquil_pds::util::json_to_ipld(value);
|
||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
||||
@@ -78,12 +89,10 @@ async fn process_single_write(
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
modified_keys.push(key.clone());
|
||||
let new_mst = mst
|
||||
.add(&key, record_cid)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
|
||||
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
|
||||
let uri = AtUri::from_parts(did, collection, &rkey);
|
||||
backlinks_to_add.extend(extract_backlinks(&uri, value));
|
||||
results.push(WriteResult::CreateResult {
|
||||
@@ -100,7 +109,6 @@ async fn process_single_write(
|
||||
mst: new_mst,
|
||||
results,
|
||||
ops,
|
||||
modified_keys,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
@@ -111,6 +119,8 @@ async fn process_single_write(
|
||||
rkey,
|
||||
value,
|
||||
} => {
|
||||
let value = ensure_record_type(value, collection);
|
||||
let value = &*value;
|
||||
let validation_status = if validate.should_skip() {
|
||||
None
|
||||
} else {
|
||||
@@ -124,6 +134,14 @@ async fn process_single_write(
|
||||
.await?,
|
||||
)
|
||||
};
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
let prev_record_cid = mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::from_mst_error("read update target from MST", &e))?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidRequest("Update target record does not exist".into())
|
||||
})?;
|
||||
all_blob_cids.extend(extract_blob_cids(value));
|
||||
let record_ipld = tranquil_pds::util::json_to_ipld(value);
|
||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||
@@ -132,21 +150,10 @@ async fn process_single_write(
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
modified_keys.push(key.clone());
|
||||
let prev_record_cid = mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidRequest("Update target record does not exist".into())
|
||||
})?;
|
||||
let new_mst = mst
|
||||
.update(&key, record_cid)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
|
||||
.map_err(|e| ApiError::from_mst_error("update record in MST", &e))?;
|
||||
let uri = AtUri::from_parts(did, collection, rkey);
|
||||
backlinks_to_remove.push(uri.clone());
|
||||
backlinks_to_add.extend(extract_backlinks(&uri, value));
|
||||
@@ -165,7 +172,6 @@ async fn process_single_write(
|
||||
mst: new_mst,
|
||||
results,
|
||||
ops,
|
||||
modified_keys,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
@@ -173,20 +179,17 @@ async fn process_single_write(
|
||||
}
|
||||
WriteOp::Delete { collection, rkey } => {
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
modified_keys.push(key.clone());
|
||||
let prev_record_cid = mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
|
||||
})?
|
||||
.map_err(|e| ApiError::from_mst_error("read delete target from MST", &e))?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidRequest("Delete target record does not exist".into())
|
||||
})?;
|
||||
let new_mst = mst
|
||||
.delete(&key)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to delete from MST".into())))?;
|
||||
.map_err(|e| ApiError::from_mst_error("delete record from MST", &e))?;
|
||||
backlinks_to_remove.push(AtUri::from_parts(did, collection, rkey));
|
||||
results.push(WriteResult::DeleteResult {});
|
||||
ops.push(RecordOp::Delete {
|
||||
@@ -198,7 +201,6 @@ async fn process_single_write(
|
||||
mst: new_mst,
|
||||
results,
|
||||
ops,
|
||||
modified_keys,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
@@ -219,7 +221,6 @@ async fn process_writes(
|
||||
mst: initial_mst,
|
||||
results: Vec::new(),
|
||||
ops: Vec::new(),
|
||||
modified_keys: Vec::new(),
|
||||
all_blob_cids: Vec::new(),
|
||||
backlinks_to_add: Vec::new(),
|
||||
backlinks_to_remove: Vec::new(),
|
||||
@@ -231,6 +232,45 @@ async fn process_writes(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute_apply_writes(
|
||||
state: &AppState,
|
||||
user_id: uuid::Uuid,
|
||||
did: &Did,
|
||||
input: &ApplyWritesInput,
|
||||
controller_did: Option<&Did>,
|
||||
write_summary: Option<serde_json::Value>,
|
||||
) -> Result<(CommitResult, Vec<WriteResult>), ApiError> {
|
||||
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
|
||||
|
||||
let WriteAccumulator {
|
||||
mst: final_mst,
|
||||
results,
|
||||
ops,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
} = process_writes(&input.writes, mst, did, input.validate, &ctx.tracking_store).await?;
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
state,
|
||||
ctx,
|
||||
final_mst,
|
||||
FinalizeParams {
|
||||
did,
|
||||
user_id,
|
||||
controller_did,
|
||||
delegation_detail: write_summary,
|
||||
ops,
|
||||
blob_cids: &all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((commit_result, results))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "$type")]
|
||||
pub enum WriteOp {
|
||||
@@ -313,9 +353,9 @@ pub async fn apply_writes(
|
||||
&auth,
|
||||
&input.writes,
|
||||
|w| match w {
|
||||
WriteOp::Create { collection, .. } => collection.as_str(),
|
||||
WriteOp::Update { collection, .. } => collection.as_str(),
|
||||
WriteOp::Delete { collection, .. } => collection.as_str(),
|
||||
WriteOp::Create { collection, .. } => collection,
|
||||
WriteOp::Update { collection, .. } => collection,
|
||||
WriteOp::Delete { collection, .. } => collection,
|
||||
},
|
||||
|w| match w {
|
||||
WriteOp::Create { .. } => WriteOpKind::Create,
|
||||
@@ -345,25 +385,6 @@ pub async fn apply_writes(
|
||||
.log_db_err("fetching user for batch write")?
|
||||
.ok_or(ApiError::InternalError(Some("User not found".into())))?;
|
||||
|
||||
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
|
||||
|
||||
let WriteAccumulator {
|
||||
mst: final_mst,
|
||||
results,
|
||||
ops,
|
||||
modified_keys,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
} = process_writes(
|
||||
&input.writes,
|
||||
mst,
|
||||
&did,
|
||||
input.validate,
|
||||
&ctx.tracking_store,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let write_summary: Option<serde_json::Value> = controller_did.as_ref().map(|_| {
|
||||
let writes: Vec<serde_json::Value> = input
|
||||
.writes
|
||||
@@ -397,22 +418,16 @@ pub async fn apply_writes(
|
||||
})
|
||||
});
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
&state,
|
||||
ctx,
|
||||
final_mst,
|
||||
FinalizeParams {
|
||||
did: &did,
|
||||
let (commit_result, results) = with_repair_retry(&state, user_id, || {
|
||||
execute_apply_writes(
|
||||
&state,
|
||||
user_id,
|
||||
controller_did: controller_did.as_ref(),
|
||||
delegation_detail: write_summary,
|
||||
ops,
|
||||
modified_keys: &modified_keys,
|
||||
blob_cids: &all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
},
|
||||
)
|
||||
&did,
|
||||
&input,
|
||||
controller_did.as_ref(),
|
||||
write_summary.clone(),
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApplyWritesOutput {
|
||||
|
||||
@@ -4,13 +4,15 @@ use cid::Cid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use tracing::error;
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::auth::{Active, Auth, VerifyScope};
|
||||
use tranquil_pds::cid_types::RecordCid;
|
||||
use tranquil_pds::repo_ops::{FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write};
|
||||
use tranquil_pds::repo_ops::{
|
||||
FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write, with_repair_retry,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, Nsid, Rkey};
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteRecordInput {
|
||||
@@ -41,13 +43,30 @@ pub async fn delete_record(
|
||||
let user_id = repo_auth.user_id;
|
||||
let controller_did = repo_auth.controller_did;
|
||||
|
||||
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
|
||||
let out = with_repair_retry(&state, user_id, || {
|
||||
delete_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
|
||||
})
|
||||
.await?;
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
async fn delete_record_inner(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
user_id: Uuid,
|
||||
controller_did: Option<&Did>,
|
||||
input: &DeleteRecordInput,
|
||||
) -> Result<DeleteRecordOutput, ApiError> {
|
||||
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
|
||||
|
||||
let key = format!("{}/{}", input.collection, input.rkey);
|
||||
|
||||
if let Some(swap_record_str) = &input.swap_record {
|
||||
let expected_cid = Cid::from_str(swap_record_str).ok();
|
||||
let actual_cid = mst.get(&key).await.ok().flatten();
|
||||
let actual_cid = mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::from_mst_error("read swap target from MST", &e))?;
|
||||
if expected_cid != actual_cid {
|
||||
return Err(ApiError::InvalidSwap(Some(
|
||||
"Record has been modified or does not exist".into(),
|
||||
@@ -55,18 +74,18 @@ pub async fn delete_record(
|
||||
}
|
||||
}
|
||||
|
||||
let prev_record_cid = mst.get(&key).await.map_err(|e| {
|
||||
error!("Failed to read prev record from MST: {}", e);
|
||||
ApiError::InternalError(Some("Failed to read MST".into()))
|
||||
})?;
|
||||
let prev_record_cid = mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::from_mst_error("read prev record from MST", &e))?;
|
||||
let Some(prev_record_cid) = prev_record_cid else {
|
||||
return Ok(Json(DeleteRecordOutput { commit: None }));
|
||||
return Ok(DeleteRecordOutput { commit: None });
|
||||
};
|
||||
|
||||
let new_mst = mst.delete(&key).await.map_err(|e| {
|
||||
error!("Failed to delete from MST: {}", e);
|
||||
ApiError::InternalError(Some("Failed to delete from MST".into()))
|
||||
})?;
|
||||
let new_mst = mst
|
||||
.delete(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::from_mst_error("delete record from MST", &e))?;
|
||||
|
||||
let op = RecordOp::Delete {
|
||||
collection: input.collection.clone(),
|
||||
@@ -74,18 +93,17 @@ pub async fn delete_record(
|
||||
prev: RecordCid::from(prev_record_cid),
|
||||
};
|
||||
|
||||
let modified_keys = [key];
|
||||
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
|
||||
let deleted_uri = AtUri::from_parts(did, &input.collection, &input.rkey);
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
&state,
|
||||
state,
|
||||
ctx,
|
||||
new_mst,
|
||||
FinalizeParams {
|
||||
did: &did,
|
||||
did,
|
||||
user_id,
|
||||
controller_did: controller_did.as_ref(),
|
||||
delegation_detail: controller_did.as_ref().map(|_| {
|
||||
controller_did,
|
||||
delegation_detail: controller_did.map(|_| {
|
||||
json!({
|
||||
"action": "delete",
|
||||
"collection": input.collection,
|
||||
@@ -93,7 +111,6 @@ pub async fn delete_record(
|
||||
})
|
||||
}),
|
||||
ops: vec![op],
|
||||
modified_keys: &modified_keys,
|
||||
blob_cids: &[],
|
||||
backlinks_to_add: vec![],
|
||||
backlinks_to_remove: vec![deleted_uri],
|
||||
@@ -101,10 +118,10 @@ pub async fn delete_record(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(DeleteRecordOutput {
|
||||
Ok(DeleteRecordOutput {
|
||||
commit: Some(CommitInfo {
|
||||
cid: commit_result.commit_cid.to_string(),
|
||||
rev: commit_result.rev,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ pub async fn validate_record_with_status(
|
||||
require_lexicon: bool,
|
||||
) -> Result<ValidationStatus, ApiError> {
|
||||
let registry = tranquil_lexicon::LexiconRegistry::global();
|
||||
if !registry.has_schema(collection.as_str()) {
|
||||
let _ = registry.resolve_dynamic(collection.as_str()).await;
|
||||
if !registry.has_schema(collection) {
|
||||
let _ = registry.resolve_dynamic(collection).await;
|
||||
}
|
||||
|
||||
let validator = RecordValidator::new().require_lexicon(require_lexicon);
|
||||
validator
|
||||
.validate_with_rkey(record, collection.as_str(), rkey.map(|v| v.as_str()))
|
||||
.validate_with_rkey(record, collection, rkey)
|
||||
.map_err(validation_error_to_api_error)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ use cid::Cid;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::borrow::Cow;
|
||||
use std::str::FromStr;
|
||||
use tracing::error;
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{
|
||||
Active, Auth, AuthSource, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
|
||||
@@ -14,10 +14,10 @@ use tranquil_pds::auth::{
|
||||
};
|
||||
use tranquil_pds::repo_ops::{
|
||||
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
|
||||
finalize_repo_write,
|
||||
finalize_repo_write, with_repair_retry,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey, Tid};
|
||||
use tranquil_pds::validation::ValidationStatus;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -62,6 +62,29 @@ pub async fn prepare_repo_write<A: RepoScopeAction>(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_record_type<'a>(
|
||||
record: &'a serde_json::Value,
|
||||
collection: &Nsid,
|
||||
) -> Cow<'a, serde_json::Value> {
|
||||
let serde_json::Value::Object(map) = record else {
|
||||
return Cow::Borrowed(record);
|
||||
};
|
||||
let needs_fill = match map.get("$type") {
|
||||
None | Some(serde_json::Value::Null) => true,
|
||||
Some(serde_json::Value::String(existing)) => existing.is_empty(),
|
||||
Some(_) => false,
|
||||
};
|
||||
if !needs_fill {
|
||||
return Cow::Borrowed(record);
|
||||
}
|
||||
let mut map = map.clone();
|
||||
map.insert(
|
||||
"$type".to_string(),
|
||||
serde_json::Value::String(collection.to_string()),
|
||||
);
|
||||
Cow::Owned(serde_json::Value::Object(map))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct CreateRecordInput {
|
||||
@@ -79,7 +102,7 @@ pub struct CreateRecordInput {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitInfo {
|
||||
pub cid: String,
|
||||
pub rev: String,
|
||||
pub rev: Tid,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -95,15 +118,32 @@ pub struct CreateRecordOutput {
|
||||
pub async fn create_record(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<CreateRecordInput>,
|
||||
Json(mut input): Json<CreateRecordInput>,
|
||||
) -> Result<Json<CreateRecordOutput>, ApiError> {
|
||||
if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) {
|
||||
input.record = record;
|
||||
}
|
||||
let scope_proof = auth.verify_repo_create(&input.collection)?;
|
||||
let repo_auth = prepare_repo_write(&state, &scope_proof, &input.repo).await?;
|
||||
let did = repo_auth.did;
|
||||
let user_id = repo_auth.user_id;
|
||||
let controller_did = repo_auth.controller_did;
|
||||
|
||||
let (ctx, mut mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
|
||||
let out = with_repair_retry(&state, user_id, || {
|
||||
create_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
|
||||
})
|
||||
.await?;
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
async fn create_record_inner(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
user_id: Uuid,
|
||||
controller_did: Option<&Did>,
|
||||
input: &CreateRecordInput,
|
||||
) -> Result<CreateRecordOutput, ApiError> {
|
||||
let (ctx, mut mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
|
||||
|
||||
let validation_status = if input.validate.should_skip() {
|
||||
None
|
||||
@@ -119,12 +159,12 @@ pub async fn create_record(
|
||||
)
|
||||
};
|
||||
|
||||
let rkey = input.rkey.unwrap_or_else(Rkey::generate);
|
||||
let rkey = input.rkey.clone().unwrap_or_else(Rkey::generate);
|
||||
let mut ops: Vec<RecordOp> = Vec::new();
|
||||
let mut conflict_uris_to_cleanup: Vec<AtUri> = Vec::new();
|
||||
|
||||
if !input.validate.should_skip() {
|
||||
let record_uri = AtUri::from_parts(&did, &input.collection, &rkey);
|
||||
let record_uri = AtUri::from_parts(did, &input.collection, &rkey);
|
||||
let backlinks = extract_backlinks(&record_uri, &input.record);
|
||||
|
||||
if !backlinks.is_empty() {
|
||||
@@ -149,24 +189,18 @@ pub async fn create_record(
|
||||
Ok(Some(cid)) => cid,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to read conflict record from MST {}: {:?}",
|
||||
conflict_uri, e
|
||||
);
|
||||
return Err(ApiError::InternalError(Some(
|
||||
"Failed to read conflicting record from MST".into(),
|
||||
)));
|
||||
return Err(ApiError::from_mst_error(
|
||||
&format!("read conflict record from MST {conflict_uri}"),
|
||||
&e,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
mst = mst.delete(&conflict_key).await.map_err(|e| {
|
||||
error!(
|
||||
"Failed to delete conflict from MST {}: {:?}",
|
||||
conflict_uri, e
|
||||
);
|
||||
ApiError::InternalError(Some(
|
||||
"Failed to delete conflicting record from MST".into(),
|
||||
))
|
||||
ApiError::from_mst_error(
|
||||
&format!("delete conflict from MST {conflict_uri}"),
|
||||
&e,
|
||||
)
|
||||
})?;
|
||||
|
||||
ops.push(RecordOp::Delete {
|
||||
@@ -179,6 +213,18 @@ pub async fn create_record(
|
||||
}
|
||||
}
|
||||
|
||||
let key = format!("{}/{}", input.collection, rkey);
|
||||
if mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::from_mst_error("read MST for create existence check", &e))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
"Record already exists at {key}"
|
||||
)));
|
||||
}
|
||||
|
||||
let record_ipld = tranquil_pds::util::json_to_ipld(&input.record);
|
||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
||||
@@ -187,12 +233,10 @@ pub async fn create_record(
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
|
||||
|
||||
let key = format!("{}/{}", input.collection, rkey);
|
||||
mst = mst
|
||||
.add(&key, record_cid)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
|
||||
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
|
||||
|
||||
ops.push(RecordOp::Create {
|
||||
collection: input.collection.clone(),
|
||||
@@ -200,34 +244,20 @@ pub async fn create_record(
|
||||
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
|
||||
});
|
||||
|
||||
let modified_keys: Vec<String> = ops
|
||||
.iter()
|
||||
.map(|op| match op {
|
||||
RecordOp::Create {
|
||||
collection, rkey, ..
|
||||
}
|
||||
| RecordOp::Update {
|
||||
collection, rkey, ..
|
||||
}
|
||||
| RecordOp::Delete {
|
||||
collection, rkey, ..
|
||||
} => format!("{}/{}", collection, rkey),
|
||||
})
|
||||
.collect();
|
||||
let blob_cids = extract_blob_cids(&input.record);
|
||||
|
||||
let created_uri = AtUri::from_parts(&did, &input.collection, &rkey);
|
||||
let created_uri = AtUri::from_parts(did, &input.collection, &rkey);
|
||||
let backlinks_to_add = extract_backlinks(&created_uri, &input.record);
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
&state,
|
||||
state,
|
||||
ctx,
|
||||
mst,
|
||||
FinalizeParams {
|
||||
did: &did,
|
||||
did,
|
||||
user_id,
|
||||
controller_did: controller_did.as_ref(),
|
||||
delegation_detail: controller_did.as_ref().map(|_| {
|
||||
controller_did,
|
||||
delegation_detail: controller_did.map(|_| {
|
||||
json!({
|
||||
"action": "create",
|
||||
"collection": input.collection,
|
||||
@@ -235,7 +265,6 @@ pub async fn create_record(
|
||||
})
|
||||
}),
|
||||
ops,
|
||||
modified_keys: &modified_keys,
|
||||
blob_cids: &blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove: conflict_uris_to_cleanup,
|
||||
@@ -243,7 +272,7 @@ pub async fn create_record(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(CreateRecordOutput {
|
||||
Ok(CreateRecordOutput {
|
||||
uri: created_uri,
|
||||
cid: record_cid.to_string(),
|
||||
commit: CommitInfo {
|
||||
@@ -251,7 +280,7 @@ pub async fn create_record(
|
||||
rev: commit_result.rev,
|
||||
},
|
||||
validation_status,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -283,15 +312,32 @@ pub struct PutRecordOutput {
|
||||
pub async fn put_record(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<PutRecordInput>,
|
||||
Json(mut input): Json<PutRecordInput>,
|
||||
) -> Result<Json<PutRecordOutput>, ApiError> {
|
||||
if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) {
|
||||
input.record = record;
|
||||
}
|
||||
let upsert_proof = auth.verify_repo_upsert(&input.collection)?;
|
||||
let repo_auth = prepare_repo_write(&state, &upsert_proof, &input.repo).await?;
|
||||
let did = repo_auth.did;
|
||||
let user_id = repo_auth.user_id;
|
||||
let controller_did = repo_auth.controller_did;
|
||||
|
||||
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
|
||||
let out = with_repair_retry(&state, user_id, || {
|
||||
put_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
|
||||
})
|
||||
.await?;
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
async fn put_record_inner(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
user_id: Uuid,
|
||||
controller_did: Option<&Did>,
|
||||
input: &PutRecordInput,
|
||||
) -> Result<PutRecordOutput, ApiError> {
|
||||
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
|
||||
|
||||
let validation_status = if input.validate.should_skip() {
|
||||
None
|
||||
@@ -309,9 +355,13 @@ pub async fn put_record(
|
||||
|
||||
let key = format!("{}/{}", input.collection, input.rkey);
|
||||
|
||||
let read_cid = |r: Result<Option<Cid>, jacquard_repo::error::RepoError>| {
|
||||
r.map_err(|e| ApiError::from_mst_error("read MST for put", &e))
|
||||
};
|
||||
|
||||
if let Some(swap_record_str) = &input.swap_record {
|
||||
let expected_cid = Cid::from_str(swap_record_str).ok();
|
||||
let actual_cid = mst.get(&key).await.ok().flatten();
|
||||
let actual_cid = read_cid(mst.get(&key).await)?;
|
||||
if expected_cid != actual_cid {
|
||||
return Err(ApiError::InvalidSwap(Some(
|
||||
"Record has been modified or does not exist".into(),
|
||||
@@ -319,7 +369,7 @@ pub async fn put_record(
|
||||
}
|
||||
}
|
||||
|
||||
let existing_cid = mst.get(&key).await.ok().flatten();
|
||||
let existing_cid = read_cid(mst.get(&key).await)?;
|
||||
let record_ipld = tranquil_pds::util::json_to_ipld(&input.record);
|
||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
||||
@@ -330,21 +380,21 @@ pub async fn put_record(
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
|
||||
|
||||
if existing_cid == Some(record_cid) {
|
||||
return Ok(Json(PutRecordOutput {
|
||||
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
|
||||
return Ok(PutRecordOutput {
|
||||
uri: AtUri::from_parts(did, &input.collection, &input.rkey),
|
||||
cid: record_cid.to_string(),
|
||||
commit: None,
|
||||
validation_status,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
let record_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
|
||||
let record_uri = AtUri::from_parts(did, &input.collection, &input.rkey);
|
||||
let (new_mst, op, is_update, backlinks_to_remove) = match existing_cid {
|
||||
Some(prev_cid) => {
|
||||
let new_mst = mst
|
||||
.update(&key, record_cid)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
|
||||
.map_err(|e| ApiError::from_mst_error("update record in MST", &e))?;
|
||||
let op = RecordOp::Update {
|
||||
collection: input.collection.clone(),
|
||||
rkey: input.rkey.clone(),
|
||||
@@ -357,7 +407,7 @@ pub async fn put_record(
|
||||
let new_mst = mst
|
||||
.add(&key, record_cid)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
|
||||
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
|
||||
let op = RecordOp::Create {
|
||||
collection: input.collection.clone(),
|
||||
rkey: input.rkey.clone(),
|
||||
@@ -367,19 +417,18 @@ pub async fn put_record(
|
||||
}
|
||||
};
|
||||
|
||||
let modified_keys = [key];
|
||||
let blob_cids = extract_blob_cids(&input.record);
|
||||
let backlinks_to_add = extract_backlinks(&record_uri, &input.record);
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
&state,
|
||||
state,
|
||||
ctx,
|
||||
new_mst,
|
||||
FinalizeParams {
|
||||
did: &did,
|
||||
did,
|
||||
user_id,
|
||||
controller_did: controller_did.as_ref(),
|
||||
delegation_detail: controller_did.as_ref().map(|_| {
|
||||
controller_did,
|
||||
delegation_detail: controller_did.map(|_| {
|
||||
json!({
|
||||
"action": if is_update { "update" } else { "create" },
|
||||
"collection": input.collection,
|
||||
@@ -387,7 +436,6 @@ pub async fn put_record(
|
||||
})
|
||||
}),
|
||||
ops: vec![op],
|
||||
modified_keys: &modified_keys,
|
||||
blob_cids: &blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
@@ -395,7 +443,7 @@ pub async fn put_record(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(PutRecordOutput {
|
||||
Ok(PutRecordOutput {
|
||||
uri: record_uri,
|
||||
cid: record_cid.to_string(),
|
||||
commit: Some(CommitInfo {
|
||||
@@ -403,5 +451,5 @@ pub async fn put_record(
|
||||
rev: commit_result.rev,
|
||||
}),
|
||||
validation_status,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use tranquil_pds::cache::Cache;
|
||||
use tranquil_pds::oauth::scopes::{AccountAction, AccountAttr};
|
||||
use tranquil_pds::plc::PlcClient;
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::PlainPassword;
|
||||
use tranquil_pds::types::{PlainPassword, Tid};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -27,7 +27,7 @@ pub struct CheckAccountStatusOutput {
|
||||
pub activated: bool,
|
||||
pub valid_did: bool,
|
||||
pub repo_commit: String,
|
||||
pub repo_rev: String,
|
||||
pub repo_rev: Tid,
|
||||
pub repo_blocks: i64,
|
||||
pub indexed_records: i64,
|
||||
pub private_state_values: i64,
|
||||
@@ -72,16 +72,16 @@ pub async fn check_account_status(
|
||||
if let Ok(Some(block)) = state.block_store.get(&cid).await {
|
||||
Commit::from_cbor(&block)
|
||||
.ok()
|
||||
.map(|c| c.rev().to_string())
|
||||
.unwrap_or_default()
|
||||
.map(|c| Tid::from(c.rev().to_string()))
|
||||
.unwrap_or_else(|| Tid::from(String::new()))
|
||||
} else {
|
||||
String::new()
|
||||
Tid::from(String::new())
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
Tid::from(String::new())
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
Tid::from(String::new())
|
||||
};
|
||||
let record_count: i64 = state.repos.repo.count_records(user_id).await.unwrap_or(0);
|
||||
let imported_blobs: i64 = state
|
||||
@@ -133,7 +133,7 @@ async fn assert_valid_did_document_for_service(
|
||||
if did.as_str().starts_with("did:plc:") {
|
||||
let max_attempts = if with_retry { 5 } else { 1 };
|
||||
let cache_for_retry = cache.clone();
|
||||
let did_owned = did.as_str().to_string();
|
||||
let did_owned = did.clone();
|
||||
let expected_owned = expected_endpoint.clone();
|
||||
let attempt_counter = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
@@ -197,18 +197,19 @@ async fn assert_valid_did_document_for_service(
|
||||
.await
|
||||
.map_err(ApiError::InvalidRequest)?;
|
||||
|
||||
let doc_rotation_keys = doc_data
|
||||
.get("rotationKeys")
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| arr.iter().filter_map(Value::as_str).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
|
||||
let server_rotation_key = tranquil_config::get().secrets.plc_rotation_key.clone();
|
||||
if let Some(ref expected_rotation_key) = server_rotation_key {
|
||||
let rotation_keys = doc_data
|
||||
.get("rotationKeys")
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| arr.iter().filter_map(Value::as_str).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
if !rotation_keys.contains(&expected_rotation_key.as_str()) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Server rotation key not included in PLC DID data".into(),
|
||||
));
|
||||
}
|
||||
if let Some(ref expected_rotation_key) = server_rotation_key
|
||||
&& !doc_rotation_keys.contains(&expected_rotation_key.as_str())
|
||||
{
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Server rotation key not included in PLC DID data".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let doc_signing_key = doc_data
|
||||
@@ -243,6 +244,16 @@ async fn assert_valid_did_document_for_service(
|
||||
"DID document verification method does not match expected signing key".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if !doc_rotation_keys.contains(&expected_did_key.as_str()) {
|
||||
warn!(
|
||||
"DID {} rotation keys {:?} omit the PDS-managed signing key {}",
|
||||
did, doc_rotation_keys, expected_did_key
|
||||
);
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"PLC rotation keys omit the PDS-managed signing key required to sign operations for this identity".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Some(host_and_path) = did.as_str().strip_prefix("did:web:") {
|
||||
let client = tranquil_pds::api::proxy_client::did_resolution_client();
|
||||
@@ -376,7 +387,7 @@ pub async fn activate_account(
|
||||
.cache
|
||||
.delete(&tranquil_pds::cache_keys::plc_data_key(&did))
|
||||
.await;
|
||||
if state.did_resolver.refresh_did(did.as_str()).await.is_err() {
|
||||
if state.did_resolver.refresh_did(&did).await.is_err() {
|
||||
warn!(
|
||||
"[MIGRATION] activateAccount: Failed to refresh DID cache for {}",
|
||||
did
|
||||
@@ -412,10 +423,8 @@ pub async fn activate_account(
|
||||
"[MIGRATION] activateAccount: Sequencing identity event for did={} handle={:?}",
|
||||
did, handle
|
||||
);
|
||||
let handle_typed = handle.clone();
|
||||
if let Err(e) =
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, handle_typed.as_ref())
|
||||
.await
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, handle.as_ref()).await
|
||||
{
|
||||
warn!(
|
||||
"[MIGRATION] activateAccount: Failed to sequence identity event for activation: {}",
|
||||
@@ -438,7 +447,9 @@ pub async fn activate_account(
|
||||
);
|
||||
let rev = if let Ok(cid) = Cid::from_str(root_cid_link.as_str()) {
|
||||
if let Ok(Some(block)) = state.block_store.get(&cid).await {
|
||||
Commit::from_cbor(&block).ok().map(|c| c.rev().to_string())
|
||||
Commit::from_cbor(&block)
|
||||
.ok()
|
||||
.map(|c| Tid::from(c.rev().to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -448,8 +459,8 @@ pub async fn activate_account(
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event(
|
||||
&state,
|
||||
&did,
|
||||
root_cid_link.as_str(),
|
||||
rev.as_deref(),
|
||||
&root_cid_link,
|
||||
rev.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -626,7 +637,7 @@ pub async fn delete_account(
|
||||
state.repos.session.as_ref(),
|
||||
user_id,
|
||||
password,
|
||||
password_hash.as_deref(),
|
||||
password_hash.as_ref(),
|
||||
)
|
||||
.await
|
||||
.is_none()
|
||||
@@ -665,27 +676,22 @@ pub async fn delete_account(
|
||||
error!("DB error deleting account: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let account_seq = tranquil_pds::repo_ops::sequence_account_event(
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_account_event(
|
||||
&state,
|
||||
did,
|
||||
tranquil_db_traits::AccountStatus::Deleted,
|
||||
)
|
||||
.await;
|
||||
match account_seq {
|
||||
Ok(seq) => {
|
||||
if let Err(e) = state.repos.repo.delete_sequences_except(did, seq).await {
|
||||
warn!(
|
||||
"Failed to cleanup sequences for deleted account {}: {}",
|
||||
did, e
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to sequence account deletion event for {}: {}",
|
||||
did, e
|
||||
);
|
||||
}
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Failed to sequence account deletion event for {}: {}",
|
||||
did, e
|
||||
);
|
||||
} else if let Err(e) = state.repos.repo.purge_did_events_keeping_latest(did).await {
|
||||
warn!(
|
||||
"Failed to cleanup sequences for deleted account {}: {}",
|
||||
did, e
|
||||
);
|
||||
}
|
||||
let _ = state
|
||||
.cache
|
||||
|
||||
@@ -116,7 +116,10 @@ pub async fn create_app_password(
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default();
|
||||
let granted_scopes = match grant {
|
||||
Some(g) => g.granted_scopes,
|
||||
None => return Err(ApiError::InsufficientScope(None)),
|
||||
};
|
||||
|
||||
let requested = input.scopes.as_deref().unwrap_or("atproto");
|
||||
let intersected = intersect_scopes(requested, granted_scopes.as_str());
|
||||
@@ -188,7 +191,7 @@ pub async fn create_app_password(
|
||||
}
|
||||
Ok(Json(CreateAppPasswordOutput {
|
||||
name: name.to_string(),
|
||||
password,
|
||||
password: password.into_inner(),
|
||||
created_at: created_at.to_rfc3339(),
|
||||
privileged: privilege.is_privileged(),
|
||||
scopes: final_scopes,
|
||||
|
||||
@@ -19,6 +19,7 @@ use tranquil_pds::auth::{Auth, NotTakendown};
|
||||
use tranquil_pds::oauth::scopes::{AccountAction, AccountAttr};
|
||||
use tranquil_pds::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AtIdentifier, Did};
|
||||
|
||||
const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
@@ -37,7 +38,7 @@ struct PendingEmailUpdate {
|
||||
|
||||
async fn get_pending_email_update(
|
||||
cache: &dyn tranquil_pds::cache::Cache,
|
||||
did: &str,
|
||||
did: &Did,
|
||||
) -> Option<PendingEmailUpdate> {
|
||||
cache
|
||||
.get(&tranquil_pds::cache_keys::email_update_key(did))
|
||||
@@ -79,7 +80,7 @@ pub async fn request_email_update(
|
||||
if token_required {
|
||||
let token = tranquil_pds::auth::email_token::create_email_token(
|
||||
state.cache.as_ref(),
|
||||
auth.did.as_str(),
|
||||
&auth.did,
|
||||
tranquil_pds::auth::email_token::EmailTokenPurpose::UpdateEmail,
|
||||
)
|
||||
.await
|
||||
@@ -249,7 +250,7 @@ pub async fn update_email(
|
||||
|
||||
tranquil_pds::auth::email_token::validate_email_token(
|
||||
state.cache.as_ref(),
|
||||
did.as_str(),
|
||||
did,
|
||||
tranquil_pds::auth::email_token::EmailTokenPurpose::UpdateEmail,
|
||||
token,
|
||||
)
|
||||
@@ -298,7 +299,7 @@ pub async fn update_email(
|
||||
|
||||
let short_token_result = tranquil_pds::auth::email_token::validate_email_token(
|
||||
state.cache.as_ref(),
|
||||
did.as_str(),
|
||||
did,
|
||||
tranquil_pds::auth::email_token::EmailTokenPurpose::UpdateEmail,
|
||||
token,
|
||||
)
|
||||
@@ -394,7 +395,7 @@ pub async fn update_email(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CheckEmailVerifiedInput {
|
||||
pub identifier: String,
|
||||
pub identifier: AtIdentifier,
|
||||
}
|
||||
|
||||
pub async fn check_email_verified(
|
||||
|
||||
@@ -7,6 +7,7 @@ use tranquil_pds::auth::{Admin, Auth, NotTakendown};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::Did;
|
||||
use tranquil_pds::util::gen_invite_code;
|
||||
use tranquil_types::InviteCode as InviteCodeValue;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -17,7 +18,7 @@ pub struct CreateInviteCodeInput {
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateInviteCodeOutput {
|
||||
pub code: String,
|
||||
pub code: InviteCodeValue,
|
||||
}
|
||||
|
||||
pub async fn create_invite_code(
|
||||
@@ -72,8 +73,8 @@ pub struct CreateInviteCodesOutput {
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AccountCodes {
|
||||
pub account: String,
|
||||
pub codes: Vec<String>,
|
||||
pub account: Did,
|
||||
pub codes: Vec<InviteCodeValue>,
|
||||
}
|
||||
|
||||
pub async fn create_invite_codes(
|
||||
@@ -112,14 +113,11 @@ pub async fn create_invite_codes(
|
||||
let infra_repo = state.repos.infra.clone();
|
||||
let use_count = input.use_count;
|
||||
async move {
|
||||
let codes: Vec<String> = (0..code_count).map(|_| gen_invite_code()).collect();
|
||||
let codes: Vec<InviteCodeValue> = (0..code_count).map(|_| gen_invite_code()).collect();
|
||||
infra_repo
|
||||
.create_invite_codes_batch(&codes, use_count, admin_user_id, Some(&account))
|
||||
.await
|
||||
.map(|_| AccountCodes {
|
||||
account: account.to_string(),
|
||||
codes,
|
||||
})
|
||||
.map(|_| AccountCodes { account, codes })
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
@@ -145,7 +143,7 @@ pub struct GetAccountInviteCodesParams {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteCode {
|
||||
pub code: String,
|
||||
pub code: InviteCodeValue,
|
||||
pub available: i32,
|
||||
pub disabled: bool,
|
||||
pub for_account: String,
|
||||
|
||||
@@ -5,7 +5,7 @@ use tranquil_pds::BUILD_VERSION;
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::util::{discord_app_id, discord_bot_username, telegram_bot_username};
|
||||
|
||||
fn get_available_comms_channels() -> Vec<CommsChannel> {
|
||||
async fn get_available_comms_channels(state: &AppState) -> Vec<CommsChannel> {
|
||||
let cfg = tranquil_config::get();
|
||||
let mut channels = vec![CommsChannel::Email];
|
||||
if cfg.discord.bot_token.is_some() {
|
||||
@@ -14,7 +14,9 @@ fn get_available_comms_channels() -> Vec<CommsChannel> {
|
||||
if cfg.telegram.bot_token.is_some() {
|
||||
channels.push(CommsChannel::Telegram);
|
||||
}
|
||||
if cfg.signal.enabled {
|
||||
if let Some(slot) = &state.signal_sender
|
||||
&& slot.is_linked().await
|
||||
{
|
||||
channels.push(CommsChannel::Signal);
|
||||
}
|
||||
channels
|
||||
@@ -66,7 +68,7 @@ pub struct DescribeServerOutput {
|
||||
pub telegram_bot_username: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn describe_server() -> Json<DescribeServerOutput> {
|
||||
pub async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerOutput> {
|
||||
let cfg = tranquil_config::get();
|
||||
let pds_hostname = &cfg.server.hostname;
|
||||
|
||||
@@ -82,7 +84,7 @@ pub async fn describe_server() -> Json<DescribeServerOutput> {
|
||||
email: cfg.server.contact_email.clone(),
|
||||
},
|
||||
version: BUILD_VERSION,
|
||||
available_comms_channels: get_available_comms_channels(),
|
||||
available_comms_channels: get_available_comms_channels(&state).await,
|
||||
self_hosted_did_web_enabled: is_self_hosted_did_web_enabled(),
|
||||
discord_bot_username: discord_bot_username().map(String::from),
|
||||
discord_app_id: discord_app_id().map(String::from),
|
||||
|
||||
@@ -51,6 +51,7 @@ pub use session::{
|
||||
auto_resend_verification, confirm_signup, create_session, delete_session,
|
||||
get_legacy_login_preference, get_session, list_sessions, refresh_session, resend_verification,
|
||||
revoke_all_sessions, revoke_session, update_legacy_login_preference, update_locale,
|
||||
verification_blocks_login,
|
||||
};
|
||||
pub use signing_key::reserve_signing_key;
|
||||
pub use totp::{
|
||||
|
||||
@@ -7,13 +7,14 @@ use serde_json::json;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tranquil_db_traits::WebauthnChallengeType;
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::api::invite::check_registration_invite;
|
||||
use tranquil_pds::api::{OptionsResponse, SuccessResponse};
|
||||
use tranquil_pds::auth::NormalizedLoginIdentifier;
|
||||
|
||||
use tranquil_pds::auth::{ServiceTokenVerifier, generate_app_password, is_service_token};
|
||||
use tranquil_pds::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Handle, PlainPassword};
|
||||
use tranquil_pds::types::{Did, Handle, Jti, Nsid, PlainPassword};
|
||||
use tranquil_pds::validation::validate_password;
|
||||
|
||||
fn generate_setup_token() -> String {
|
||||
@@ -38,7 +39,7 @@ pub struct CreatePasskeyAccountInput {
|
||||
pub invite_code: Option<String>,
|
||||
pub did: Option<String>,
|
||||
pub did_type: Option<String>,
|
||||
pub signing_key: Option<String>,
|
||||
pub signing_key: Option<Did>,
|
||||
pub verification_channel: Option<tranquil_db_traits::CommsChannel>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
@@ -68,8 +69,9 @@ pub async fn create_passkey_account(
|
||||
let token = extracted.token;
|
||||
if is_service_token(&token) {
|
||||
let verifier = ServiceTokenVerifier::new();
|
||||
let create_account_lxm = Nsid::from("com.atproto.server.createAccount".to_string());
|
||||
match verifier
|
||||
.verify_service_token(&token, Some("com.atproto.server.createAccount"))
|
||||
.verify_service_token(&token, Some(&create_account_lxm))
|
||||
.await
|
||||
{
|
||||
Ok(claims) => {
|
||||
@@ -119,26 +121,8 @@ pub async fn create_passkey_account(
|
||||
return Err(ApiError::InvalidEmail);
|
||||
}
|
||||
|
||||
let is_bootstrap = state.bootstrap_invite_code.is_some()
|
||||
&& state.repos.user.count_users().await.unwrap_or(1) == 0;
|
||||
|
||||
let _validated_invite_code = if is_bootstrap {
|
||||
match input.invite_code.as_deref() {
|
||||
Some(code) if Some(code) == state.bootstrap_invite_code.as_deref() => None,
|
||||
_ => return Err(ApiError::InvalidInviteCode),
|
||||
}
|
||||
} else if let Some(ref code) = input.invite_code {
|
||||
match state.repos.infra.validate_invite_code(code).await {
|
||||
Ok(validated) => Some(validated),
|
||||
Err(_) => return Err(ApiError::InvalidInviteCode),
|
||||
}
|
||||
} else {
|
||||
let invite_required = tranquil_config::get().server.invite_code_required;
|
||||
if invite_required {
|
||||
return Err(ApiError::InviteCodeRequired);
|
||||
}
|
||||
None
|
||||
};
|
||||
let invite_registration =
|
||||
check_registration_invite(&state, input.invite_code.as_deref()).await?;
|
||||
|
||||
let verification_channel = input
|
||||
.verification_channel
|
||||
@@ -160,7 +144,7 @@ pub async fn create_passkey_account(
|
||||
let did_type = input.did_type.as_deref().unwrap_or("plc");
|
||||
|
||||
let key_result =
|
||||
match crate::identity::provision::resolve_signing_key(&state, input.signing_key.as_deref())
|
||||
match crate::identity::provision::resolve_signing_key(&state, input.signing_key.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(k) => k,
|
||||
@@ -170,7 +154,7 @@ pub async fn create_passkey_account(
|
||||
let secret_key = key_result.signing_key;
|
||||
let reserved_key_id = key_result.reserved_key_id;
|
||||
|
||||
let did = match did_type {
|
||||
let did: Did = match did_type {
|
||||
"web" => {
|
||||
let self_hosted_did = match common::create_self_hosted_did_web(&handle) {
|
||||
Ok(d) => d,
|
||||
@@ -208,7 +192,7 @@ pub async fn create_passkey_account(
|
||||
d,
|
||||
hostname,
|
||||
&input.handle,
|
||||
input.signing_key.as_deref(),
|
||||
input.signing_key.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -216,7 +200,8 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
info!(did = %d, "Creating external did:web passkey account (reserved key)");
|
||||
}
|
||||
d.to_string()
|
||||
d.parse()
|
||||
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?
|
||||
}
|
||||
_ => {
|
||||
if let Some(ref auth_did) = byod_auth {
|
||||
@@ -229,7 +214,9 @@ pub async fn create_passkey_account(
|
||||
)));
|
||||
}
|
||||
info!(did = %provided_did, "Creating BYOD did:plc passkey account (migration)");
|
||||
provided_did.clone()
|
||||
provided_did
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"BYOD migration requires a did:plc or did:web DID".into(),
|
||||
@@ -241,15 +228,9 @@ pub async fn create_passkey_account(
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(&secret_key));
|
||||
|
||||
let genesis_result = match tranquil_pds::plc::create_genesis_operation(
|
||||
&secret_key,
|
||||
&rotation_key,
|
||||
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
|
||||
&handle,
|
||||
&pds_endpoint,
|
||||
) {
|
||||
@@ -291,13 +272,9 @@ pub async fn create_passkey_account(
|
||||
None
|
||||
};
|
||||
|
||||
let did_typed: Did = match did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Err(ApiError::InternalError(Some("Invalid DID".into()))),
|
||||
};
|
||||
let repo = match crate::identity::provision::init_genesis_repo(
|
||||
&state,
|
||||
&did_typed,
|
||||
&did,
|
||||
&secret_key,
|
||||
&secret_key_bytes,
|
||||
)
|
||||
@@ -316,10 +293,6 @@ pub async fn create_passkey_account(
|
||||
None
|
||||
};
|
||||
|
||||
let handle_typed: Handle = match handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||
};
|
||||
let repo_for_seq = repo.clone();
|
||||
let comms = crate::identity::provision::normalize_comms_usernames(
|
||||
input.discord_username.as_deref(),
|
||||
@@ -327,9 +300,9 @@ pub async fn create_passkey_account(
|
||||
input.signal_username.as_deref(),
|
||||
);
|
||||
let create_input = tranquil_db_traits::CreatePasskeyAccountInput {
|
||||
handle: handle_typed.clone(),
|
||||
handle: handle.clone(),
|
||||
email: email.clone().unwrap_or_default(),
|
||||
did: did_typed.clone(),
|
||||
did: did.clone(),
|
||||
preferred_comms_channel: verification_channel,
|
||||
discord_username: comms.discord,
|
||||
telegram_username: comms.telegram,
|
||||
@@ -340,14 +313,10 @@ pub async fn create_passkey_account(
|
||||
encrypted_key_bytes: repo.encrypted_key_bytes,
|
||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||
reserved_key_id,
|
||||
commit_cid: repo.commit_cid.to_string(),
|
||||
commit_cid: tranquil_types::CidLink::from(repo.commit_cid.to_string()),
|
||||
repo_rev: repo.repo_rev.clone(),
|
||||
genesis_block_cids: repo.genesis_block_cids,
|
||||
invite_code: if is_bootstrap {
|
||||
None
|
||||
} else {
|
||||
input.invite_code.clone()
|
||||
},
|
||||
invite_code: invite_registration.into_invite_code(),
|
||||
birthdate_pref,
|
||||
};
|
||||
|
||||
@@ -359,6 +328,9 @@ pub async fn create_passkey_account(
|
||||
Err(tranquil_db_traits::CreateAccountError::EmailTaken) => {
|
||||
return Err(ApiError::EmailTaken);
|
||||
}
|
||||
Err(tranquil_db_traits::CreateAccountError::InviteCodeUnavailable) => {
|
||||
return Err(ApiError::InvalidInviteCode);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error creating passkey account: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
@@ -369,10 +341,10 @@ pub async fn create_passkey_account(
|
||||
if !is_byod_did_web {
|
||||
crate::identity::provision::sequence_new_account(
|
||||
&state,
|
||||
&did_typed,
|
||||
&handle_typed,
|
||||
&repo_for_seq,
|
||||
&did,
|
||||
&handle,
|
||||
&repo_for_seq,
|
||||
handle.as_str(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -380,7 +352,7 @@ pub async fn create_passkey_account(
|
||||
crate::identity::provision::enqueue_signup_verification(
|
||||
&state,
|
||||
user_id,
|
||||
&did_typed,
|
||||
&did,
|
||||
verification_channel,
|
||||
&verification_recipient,
|
||||
)
|
||||
@@ -391,10 +363,10 @@ pub async fn create_passkey_account(
|
||||
let access_jwt = if byod_auth.is_some() {
|
||||
match tranquil_pds::auth::create_access_token_with_metadata(&did, &secret_key_bytes) {
|
||||
Ok(token_meta) => {
|
||||
let refresh_jti = uuid::Uuid::new_v4().to_string();
|
||||
let refresh_jti = Jti::from(uuid::Uuid::new_v4().to_string());
|
||||
let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(24);
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: did_typed.clone(),
|
||||
did: did.clone(),
|
||||
access_jti: token_meta.jti.clone(),
|
||||
refresh_jti,
|
||||
access_expires_at: token_meta.expires_at,
|
||||
@@ -421,8 +393,8 @@ pub async fn create_passkey_account(
|
||||
};
|
||||
|
||||
Ok(Json(CreatePasskeyAccountOutput {
|
||||
did: did.into(),
|
||||
handle: handle.into(),
|
||||
did,
|
||||
handle,
|
||||
setup_token,
|
||||
setup_expires_at,
|
||||
access_jwt,
|
||||
@@ -576,7 +548,7 @@ pub async fn complete_passkey_setup(
|
||||
Ok(Json(CompletePasskeySetupOutput {
|
||||
did: input.did.clone(),
|
||||
handle: user.handle,
|
||||
app_password,
|
||||
app_password: app_password.into_inner(),
|
||||
app_password_name,
|
||||
}))
|
||||
}
|
||||
@@ -715,7 +687,7 @@ pub async fn request_passkey_recovery(
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.user
|
||||
.set_recovery_token(&user.did, &recovery_token_hash, expires_at)
|
||||
.set_recovery_token(&user.did, recovery_token_hash.as_str(), expires_at)
|
||||
.await
|
||||
{
|
||||
error!("Error updating recovery token: {:?}", e);
|
||||
|
||||
@@ -10,13 +10,9 @@ use tranquil_pds::auth::{
|
||||
};
|
||||
use tranquil_pds::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::PlainPassword;
|
||||
use tranquil_pds::types::{Handle, PlainPassword};
|
||||
use tranquil_pds::validation::validate_password;
|
||||
|
||||
fn generate_reset_code() -> String {
|
||||
tranquil_pds::util::generate_token_code()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RequestPasswordResetInput {
|
||||
#[serde(alias = "identifier")]
|
||||
@@ -52,7 +48,10 @@ pub async fn request_password_reset(
|
||||
let user_id = match state
|
||||
.repos
|
||||
.user
|
||||
.get_id_by_email_or_handle(normalized, normalized_handle.as_str())
|
||||
.get_id_by_email_or_handle(
|
||||
normalized,
|
||||
&Handle::from(normalized_handle.as_str().to_string()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(id)) => id,
|
||||
@@ -70,12 +69,13 @@ pub async fn request_password_reset(
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
};
|
||||
let code = generate_reset_code();
|
||||
let display_code = tranquil_pds::util::generate_token_code();
|
||||
let stored_code = tranquil_pds::util::normalize_token_code(&display_code);
|
||||
let expires_at = Utc::now() + Duration::minutes(10);
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.user
|
||||
.set_password_reset_code(user_id, &code, expires_at)
|
||||
.set_password_reset_code(user_id, &stored_code, expires_at)
|
||||
.await
|
||||
{
|
||||
error!("DB error setting reset code: {:?}", e);
|
||||
@@ -86,7 +86,7 @@ pub async fn request_password_reset(
|
||||
state.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user_id,
|
||||
&code,
|
||||
&display_code,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
@@ -133,7 +133,13 @@ pub async fn reset_password(
|
||||
if let Err(e) = validate_password(password) {
|
||||
return Err(ApiError::InvalidRequest(e.to_string()));
|
||||
}
|
||||
let user = match state.repos.user.get_user_by_reset_code(token).await {
|
||||
let normalized_token = tranquil_pds::util::normalize_token_code(token);
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_user_by_reset_code(&normalized_token)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return Err(ApiError::InvalidToken(None));
|
||||
|
||||
@@ -74,7 +74,7 @@ pub async fn reauth_password(
|
||||
.log_db_err("fetching password hash")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
|
||||
let password_valid = bcrypt::verify(&input.password, password_hash.as_str()).unwrap_or(false);
|
||||
|
||||
if !password_valid {
|
||||
let app_password_hashes = state
|
||||
@@ -85,7 +85,7 @@ pub async fn reauth_password(
|
||||
.unwrap_or_default();
|
||||
|
||||
let app_password_valid = app_password_hashes.iter().fold(false, |acc, h| {
|
||||
acc | bcrypt::verify(&input.password, h).unwrap_or(false)
|
||||
acc | bcrypt::verify(&input.password, h.as_str()).unwrap_or(false)
|
||||
});
|
||||
|
||||
if !app_password_valid {
|
||||
|
||||
@@ -118,7 +118,7 @@ pub async fn get_service_auth(
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
params.aud.as_str(),
|
||||
method.as_str(),
|
||||
method,
|
||||
) {
|
||||
return e.into_response();
|
||||
}
|
||||
@@ -169,18 +169,14 @@ pub async fn get_service_auth(
|
||||
}
|
||||
}
|
||||
|
||||
let service_token = match tranquil_pds::auth::create_service_token(
|
||||
&auth.did,
|
||||
params.aud.as_str(),
|
||||
lxm.map(|v| v.as_str()),
|
||||
&key_bytes,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to create service token: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let service_token =
|
||||
match tranquil_pds::auth::create_service_token(&auth.did, ¶ms.aud, lxm, &key_bytes) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to create service token: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(GetServiceAuthOutput {
|
||||
|
||||
@@ -8,18 +8,28 @@ use bcrypt::verify;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
use tranquil_db_traits::{SessionId, TokenFamilyId};
|
||||
use tranquil_db_traits::{ChannelVerificationStatus, SessionId, TokenFamilyId};
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::api::{EmptyResponse, PreferredLocaleOutput, SuccessResponse};
|
||||
use tranquil_pds::auth::{
|
||||
Active, Auth, NormalizedLoginIdentifier, Permissive, require_legacy_session_mfa,
|
||||
require_reauth_window,
|
||||
};
|
||||
use tranquil_pds::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit};
|
||||
use tranquil_pds::rate_limit::{
|
||||
LoginLimit, RateLimited, RefreshSessionLimit, TotpVerifyLimit,
|
||||
check_user_rate_limit_with_message,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AccountState, Did, Handle, PlainPassword};
|
||||
use tranquil_pds::types::{AccountState, AtIdentifier, Did, Handle, PlainPassword};
|
||||
use tranquil_types::TokenId;
|
||||
|
||||
pub fn verification_blocks_login(channel_verification: &ChannelVerificationStatus) -> bool {
|
||||
!tranquil_config::get()
|
||||
.server
|
||||
.disable_account_verification_gate
|
||||
&& !channel_verification.has_any_verified()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSessionInput {
|
||||
@@ -68,10 +78,20 @@ pub async fn create_session(
|
||||
"Normalized identifier: {} -> {}",
|
||||
input.identifier, normalized_identifier
|
||||
);
|
||||
let Ok(login_identifier) = AtIdentifier::new(normalized_identifier.as_str()) else {
|
||||
let _ = verify(
|
||||
&input.password,
|
||||
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
|
||||
);
|
||||
warn!("Login identifier is not a valid handle or DID");
|
||||
return Err(ApiError::AuthenticationFailed(Some(
|
||||
"Invalid identifier or password".into(),
|
||||
)));
|
||||
};
|
||||
let row = match state
|
||||
.repos
|
||||
.user
|
||||
.get_login_full_by_identifier(normalized_identifier.as_str())
|
||||
.get_login_full_by_identifier(&login_identifier)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
@@ -102,7 +122,7 @@ pub async fn create_session(
|
||||
state.repos.session.as_ref(),
|
||||
row.id,
|
||||
&input.password,
|
||||
row.password_hash.as_deref(),
|
||||
row.password_hash.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let (app_password_name, app_password_scopes, app_password_controller) = match credential {
|
||||
@@ -129,14 +149,13 @@ pub async fn create_session(
|
||||
warn!("Login attempt for takendown account: {}", row.did);
|
||||
return Err(ApiError::AccountTakedown);
|
||||
}
|
||||
let is_verified = row.channel_verification.has_any_verified();
|
||||
let is_delegated = state
|
||||
.repos
|
||||
.delegation
|
||||
.is_delegated_account(&row.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !is_verified && !is_delegated {
|
||||
if verification_blocks_login(&row.channel_verification) && !is_delegated {
|
||||
warn!("Login attempt for unverified account: {}", row.did);
|
||||
let resend_info = auto_resend_verification(&state, &row.did).await;
|
||||
let handle = resend_info
|
||||
@@ -162,16 +181,35 @@ pub async fn create_session(
|
||||
let has_totp = row.totp_enabled;
|
||||
let email_2fa_enabled = row.email_2fa_enabled;
|
||||
let is_legacy_login = has_totp || email_2fa_enabled;
|
||||
let used_totp_factor = tranquil_pds::auth::legacy_2fa::used_totp_factor(
|
||||
has_totp,
|
||||
input.auth_factor_token.as_deref(),
|
||||
);
|
||||
let twofa_ctx = tranquil_pds::auth::legacy_2fa::Legacy2faContext {
|
||||
is_app_password: app_password_name.is_some(),
|
||||
email_2fa_enabled,
|
||||
has_totp,
|
||||
allow_legacy_login: row.allow_legacy_login,
|
||||
};
|
||||
if let Some(token) = input.auth_factor_token.as_deref()
|
||||
&& has_totp
|
||||
&& tranquil_pds::auth::legacy_2fa::looks_like_totp_token(token)
|
||||
{
|
||||
check_user_rate_limit_with_message::<TotpVerifyLimit>(
|
||||
&state,
|
||||
row.did.as_str(),
|
||||
"Too many verification attempts. Please try again in a few minutes.",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
match tranquil_pds::auth::legacy_2fa::process_legacy_2fa(
|
||||
state.cache.as_ref(),
|
||||
&row.did,
|
||||
&twofa_ctx,
|
||||
input.auth_factor_token.as_deref(),
|
||||
async |t: &str| {
|
||||
crate::server::totp::verify_totp_or_backup_for_user(&state, &row.did, t).await
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -239,7 +277,7 @@ pub async fn create_session(
|
||||
&row.did,
|
||||
&key_bytes,
|
||||
app_password_scopes.as_deref(),
|
||||
app_password_controller.as_deref(),
|
||||
app_password_controller.as_ref(),
|
||||
None,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
@@ -278,7 +316,7 @@ pub async fn create_session(
|
||||
error!("Failed to insert session: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
if is_legacy_login {
|
||||
if is_legacy_login && !used_totp_factor {
|
||||
warn!(
|
||||
did = %row.did,
|
||||
ip = %client_ip,
|
||||
@@ -424,7 +462,12 @@ pub async fn delete_session(
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
let jti = tranquil_pds::auth::extract_jti_from_headers(&headers)
|
||||
.ok_or(ApiError::AuthenticationRequired)?;
|
||||
match state.repos.session.delete_session_by_access_jti(&jti).await {
|
||||
match state
|
||||
.repos
|
||||
.session
|
||||
.delete_session_by_access_jti(&jti, &auth.did)
|
||||
.await
|
||||
{
|
||||
Ok(rows) if rows > 0 => {
|
||||
let session_cache_key = tranquil_pds::cache_keys::session_key(&auth.did, &jti);
|
||||
let _ = state.cache.delete(&session_cache_key).await;
|
||||
@@ -477,16 +520,15 @@ pub async fn refresh_session(
|
||||
)));
|
||||
}
|
||||
};
|
||||
if let Ok(Some(_)) = state
|
||||
.repos
|
||||
.session
|
||||
.check_refresh_token_used(&refresh_jti)
|
||||
.await
|
||||
if let Some(result) = dispatch_refresh_grace(
|
||||
&state,
|
||||
&refresh_token,
|
||||
&refresh_jti,
|
||||
state.repos.session.lookup_refresh_grace(&refresh_jti).await,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Refresh token reuse detected for jti: {}", refresh_jti);
|
||||
return Err(ApiError::AuthenticationFailed(Some(
|
||||
"Refresh token has been revoked due to suspected compromise".into(),
|
||||
)));
|
||||
return result;
|
||||
}
|
||||
let session_row = match state
|
||||
.repos
|
||||
@@ -496,9 +538,18 @@ pub async fn refresh_session(
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return Err(ApiError::AuthenticationFailed(Some(
|
||||
"Invalid refresh token".into(),
|
||||
)));
|
||||
return dispatch_refresh_grace(
|
||||
&state,
|
||||
&refresh_token,
|
||||
&refresh_jti,
|
||||
state.repos.session.lookup_refresh_grace(&refresh_jti).await,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| {
|
||||
Err(ApiError::AuthenticationFailed(Some(
|
||||
"Invalid refresh token".into(),
|
||||
)))
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Database error fetching session: {:?}", e);
|
||||
@@ -524,7 +575,7 @@ pub async fn refresh_session(
|
||||
&session_row.did,
|
||||
&key_bytes,
|
||||
session_row.scope.as_deref(),
|
||||
session_row.controller_did.as_deref(),
|
||||
session_row.controller_did.as_ref(),
|
||||
None,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
@@ -544,6 +595,7 @@ pub async fn refresh_session(
|
||||
}
|
||||
};
|
||||
let refresh_data = tranquil_db_traits::SessionRefreshData {
|
||||
did: session_row.did.clone(),
|
||||
old_refresh_jti: refresh_jti.clone(),
|
||||
session_id: session_row.id,
|
||||
new_access_jti: new_access_meta.jti.clone(),
|
||||
@@ -551,22 +603,27 @@ pub async fn refresh_session(
|
||||
new_access_expires_at: new_access_meta.expires_at,
|
||||
new_refresh_expires_at: new_refresh_meta.expires_at,
|
||||
};
|
||||
match state
|
||||
let (access_jwt, refresh_jwt) = match state
|
||||
.repos
|
||||
.session
|
||||
.refresh_session_atomic(&refresh_data)
|
||||
.await
|
||||
{
|
||||
Ok(tranquil_db_traits::RefreshSessionResult::Success) => {}
|
||||
Ok(tranquil_db_traits::RefreshSessionResult::TokenAlreadyUsed) => {
|
||||
warn!("Refresh token reuse detected during atomic operation");
|
||||
return Err(ApiError::AuthenticationFailed(Some(
|
||||
"Refresh token has been revoked due to suspected compromise".into(),
|
||||
)));
|
||||
Ok(tranquil_db_traits::RefreshSessionResult::Success) => {
|
||||
(new_access_meta.token, new_refresh_meta.token)
|
||||
}
|
||||
Ok(tranquil_db_traits::RefreshSessionResult::ConcurrentRefresh) => {
|
||||
Ok(tranquil_db_traits::RefreshSessionResult::GraceReplay(replay)) => {
|
||||
// Lost a benign concurrent rotation; re-mint the winner's tokens
|
||||
// using this session's signing key (same user, unchanged by rotation).
|
||||
info!(
|
||||
"Concurrent refresh within grace window for session_id: {}; replaying tokens",
|
||||
session_row.id
|
||||
);
|
||||
remint_grace_tokens(&replay, &key_bytes)?
|
||||
}
|
||||
Ok(tranquil_db_traits::RefreshSessionResult::Compromise) => {
|
||||
warn!(
|
||||
"Concurrent refresh detected for session_id: {}",
|
||||
"Refresh token reuse outside grace window or unreplayable rotation conflict for session_id: {}",
|
||||
session_row.id
|
||||
);
|
||||
return Err(ApiError::AuthenticationFailed(Some(
|
||||
@@ -577,12 +634,151 @@ pub async fn refresh_session(
|
||||
error!("Database error during session refresh: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
};
|
||||
build_refresh_session_output(&state, session_row.did, access_jwt, refresh_jwt).await
|
||||
}
|
||||
|
||||
async fn dispatch_refresh_grace(
|
||||
state: &AppState,
|
||||
refresh_token: &str,
|
||||
presented_jti: &str,
|
||||
lookup: Result<tranquil_db_traits::RefreshGraceLookup, tranquil_db_traits::DbError>,
|
||||
) -> Option<Result<Json<RefreshSessionOutput>, ApiError>> {
|
||||
match lookup {
|
||||
Ok(tranquil_db_traits::RefreshGraceLookup::NotUsed) => None,
|
||||
Ok(tranquil_db_traits::RefreshGraceLookup::Replay(replay)) => {
|
||||
Some(serve_refresh_grace_replay(state, refresh_token, presented_jti, replay).await)
|
||||
}
|
||||
Ok(tranquil_db_traits::RefreshGraceLookup::Compromised {
|
||||
did,
|
||||
session_id,
|
||||
key_bytes,
|
||||
encryption_version,
|
||||
}) => Some(Err(revoke_compromised_session(
|
||||
state,
|
||||
refresh_token,
|
||||
presented_jti,
|
||||
did,
|
||||
session_id,
|
||||
key_bytes,
|
||||
encryption_version,
|
||||
)
|
||||
.await)),
|
||||
Err(e) => {
|
||||
error!("Database error checking refresh token grace: {:?}", e);
|
||||
Some(Err(ApiError::InternalError(None)))
|
||||
}
|
||||
}
|
||||
let did_for_doc = session_row.did.clone();
|
||||
}
|
||||
|
||||
async fn serve_refresh_grace_replay(
|
||||
state: &AppState,
|
||||
refresh_token: &str,
|
||||
presented_jti: &str,
|
||||
replay: tranquil_db_traits::RefreshGraceReplay,
|
||||
) -> Result<Json<RefreshSessionOutput>, ApiError> {
|
||||
let key =
|
||||
match tranquil_pds::config::decrypt_key(&replay.key_bytes, Some(replay.encryption_version))
|
||||
{
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt user key for grace replay: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
};
|
||||
if tranquil_pds::auth::verify_refresh_token(refresh_token, &key).is_err() {
|
||||
return Err(ApiError::AuthenticationFailed(Some(
|
||||
"Invalid refresh token".into(),
|
||||
)));
|
||||
}
|
||||
info!("Refresh token reuse within grace window for jti: {presented_jti}; replaying tokens");
|
||||
let (access_jwt, refresh_jwt) = remint_grace_tokens(&replay, &key)?;
|
||||
build_refresh_session_output(state, replay.did, access_jwt, refresh_jwt).await
|
||||
}
|
||||
|
||||
async fn revoke_compromised_session(
|
||||
state: &AppState,
|
||||
refresh_token: &str,
|
||||
presented_jti: &str,
|
||||
did: Did,
|
||||
session_id: SessionId,
|
||||
key_bytes: Vec<u8>,
|
||||
encryption_version: i32,
|
||||
) -> ApiError {
|
||||
let key = match tranquil_pds::config::decrypt_key(&key_bytes, Some(encryption_version)) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt user key for grace check: {:?}", e);
|
||||
return ApiError::InternalError(None);
|
||||
}
|
||||
};
|
||||
if tranquil_pds::auth::verify_refresh_token(refresh_token, &key).is_err() {
|
||||
return ApiError::AuthenticationFailed(Some("Invalid refresh token".into()));
|
||||
}
|
||||
warn!("Refresh token reuse outside grace window for jti: {presented_jti}; revoking session");
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.session
|
||||
.delete_session_by_id(session_id, &did)
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
"Failed to revoke session {} for refresh token reuse: {:?}",
|
||||
session_id.as_i32(),
|
||||
e
|
||||
);
|
||||
return ApiError::InternalError(None);
|
||||
}
|
||||
ApiError::AuthenticationFailed(Some(
|
||||
"Refresh token has been revoked due to suspected compromise".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Re-mint the access/refresh JWTs for a grace-window replay from the session's
|
||||
/// current jtis and signing key. We never persist the signed JWTs; they are
|
||||
/// reconstructed on demand so a benignly-racing client converges on the same
|
||||
/// credentials the winning rotation produced. `key_bytes` is the owning user's
|
||||
/// already-decrypted signing key.
|
||||
fn remint_grace_tokens(
|
||||
replay: &tranquil_db_traits::RefreshGraceReplay,
|
||||
key_bytes: &[u8],
|
||||
) -> Result<(String, String), ApiError> {
|
||||
let access_jwt = tranquil_pds::auth::create_access_token_with_jti(
|
||||
&replay.did,
|
||||
key_bytes,
|
||||
replay.scope.as_deref(),
|
||||
replay.controller_did.as_ref(),
|
||||
None,
|
||||
&replay.access_jti,
|
||||
replay.access_expires_at,
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("Failed to re-mint access token for grace replay: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let refresh_jwt = tranquil_pds::auth::create_refresh_token_with_jti(
|
||||
&replay.did,
|
||||
key_bytes,
|
||||
&replay.refresh_jti,
|
||||
replay.refresh_expires_at,
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("Failed to re-mint refresh token for grace replay: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
Ok((access_jwt, refresh_jwt))
|
||||
}
|
||||
|
||||
async fn build_refresh_session_output(
|
||||
state: &AppState,
|
||||
did: Did,
|
||||
access_jwt: String,
|
||||
refresh_jwt: String,
|
||||
) -> Result<Json<RefreshSessionOutput>, ApiError> {
|
||||
let did_resolver = state.did_resolver.clone();
|
||||
let (db_result, did_doc) = tokio::join!(
|
||||
state.repos.user.get_session_info_by_did(&session_row.did),
|
||||
did_resolver.fetch_did_document(&did_for_doc)
|
||||
state.repos.user.get_session_info_by_did(&did),
|
||||
did_resolver.fetch_did_document(&did)
|
||||
);
|
||||
match db_result {
|
||||
Ok(Some(u)) => {
|
||||
@@ -593,10 +789,10 @@ pub async fn refresh_session(
|
||||
let account_state =
|
||||
AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None);
|
||||
Ok(Json(RefreshSessionOutput {
|
||||
access_jwt: new_access_meta.token,
|
||||
refresh_jwt: new_refresh_meta.token,
|
||||
access_jwt,
|
||||
refresh_jwt,
|
||||
handle,
|
||||
did: session_row.did,
|
||||
did,
|
||||
email: u.email,
|
||||
email_confirmed: u.channel_verification.email,
|
||||
preferred_channel: u.preferred_comms_channel.as_str().to_string(),
|
||||
@@ -609,7 +805,7 @@ pub async fn refresh_session(
|
||||
}))
|
||||
}
|
||||
Ok(None) => {
|
||||
error!("User not found for existing session: {}", session_row.did);
|
||||
error!("User not found for existing session: {}", did);
|
||||
Err(ApiError::InternalError(None))
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -719,7 +915,6 @@ pub async fn confirm_signup(
|
||||
let session = match crate::identity::provision::create_and_store_session(
|
||||
&state,
|
||||
&row.did,
|
||||
&row.did,
|
||||
&key_bytes,
|
||||
"transition:generic transition:chat.bsky",
|
||||
None,
|
||||
@@ -761,7 +956,7 @@ pub struct AutoResendResult {
|
||||
}
|
||||
|
||||
pub async fn auto_resend_verification(state: &AppState, did: &Did) -> Option<AutoResendResult> {
|
||||
let debounce_key = tranquil_pds::cache_keys::auto_verify_sent_key(did.as_str());
|
||||
let debounce_key = tranquil_pds::cache_keys::auto_verify_sent_key(did);
|
||||
let debounced = state.cache.get(&debounce_key).await.is_some();
|
||||
let row = match state.repos.user.get_resend_verification_by_did(did).await {
|
||||
Ok(Some(row)) => row,
|
||||
@@ -944,13 +1139,13 @@ pub async fn list_sessions(
|
||||
Ok(Json(ListSessionsOutput { sessions }))
|
||||
}
|
||||
|
||||
fn extract_client_name(client_id: &str) -> String {
|
||||
fn extract_client_name(client_id: &tranquil_types::ClientId) -> String {
|
||||
if client_id.starts_with("http://localhost") || client_id.starts_with("http://127.0.0.1") {
|
||||
"Localhost App".to_string()
|
||||
} else if let Ok(parsed) = reqwest::Url::parse(client_id) {
|
||||
} else if let Ok(parsed) = reqwest::Url::parse(client_id.as_str()) {
|
||||
parsed.host_str().unwrap_or("Unknown App").to_string()
|
||||
} else {
|
||||
client_id.to_string()
|
||||
client_id.as_str().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -980,7 +1175,7 @@ pub async fn revoke_session(
|
||||
state
|
||||
.repos
|
||||
.session
|
||||
.delete_session_by_id(session_id)
|
||||
.delete_session_by_id(session_id, &auth.did)
|
||||
.await
|
||||
.log_db_err("deleting session")?;
|
||||
let cache_key = tranquil_pds::cache_keys::session_key(&auth.did, &access_jti);
|
||||
@@ -1024,11 +1219,11 @@ pub async fn revoke_all_sessions(
|
||||
.delete_sessions_by_did(&auth.did)
|
||||
.await
|
||||
.log_db_err("revoking JWT sessions")?;
|
||||
let jti_typed = TokenId::from(jti.clone());
|
||||
let token_id = TokenId::from(jti.clone().into_inner());
|
||||
state
|
||||
.repos
|
||||
.oauth
|
||||
.delete_sessions_by_did_except(&auth.did, &jti_typed)
|
||||
.delete_sessions_by_did_except(&auth.did, &token_id)
|
||||
.await
|
||||
.log_db_err("revoking OAuth sessions")?;
|
||||
} else {
|
||||
|
||||
@@ -10,17 +10,18 @@ use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info};
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::Did;
|
||||
|
||||
const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01];
|
||||
|
||||
fn public_key_to_did_key(signing_key: &SigningKey) -> String {
|
||||
fn public_key_to_did_key(signing_key: &SigningKey) -> Did {
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let compressed_pubkey = verifying_key.to_sec1_bytes();
|
||||
let mut multicodec_key = Vec::with_capacity(2 + compressed_pubkey.len());
|
||||
multicodec_key.extend_from_slice(&SECP256K1_MULTICODEC_PREFIX);
|
||||
multicodec_key.extend_from_slice(&compressed_pubkey);
|
||||
let encoded = multibase::encode(multibase::Base::Base58Btc, &multicodec_key);
|
||||
format!("did:key:{}", encoded)
|
||||
Did::from(format!("did:key:{}", encoded))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -31,7 +32,7 @@ pub struct ReserveSigningKeyInput {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReserveSigningKeyOutput {
|
||||
pub signing_key: String,
|
||||
pub signing_key: Did,
|
||||
}
|
||||
|
||||
pub async fn reserve_signing_key(
|
||||
|
||||
@@ -52,7 +52,7 @@ impl DeviceTrustState {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrustedDevice {
|
||||
pub id: String,
|
||||
pub id: DeviceId,
|
||||
pub user_agent: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub trusted_at: Option<DateTime<Utc>>,
|
||||
|
||||
@@ -62,7 +62,8 @@ pub async fn handle_telegram_webhook(
|
||||
&& let Some(from) = message.from
|
||||
&& let Some(username) = from.username
|
||||
{
|
||||
let handle = parse_start_handle(message.text.as_deref());
|
||||
let handle =
|
||||
parse_start_handle(message.text.as_deref()).map(tranquil_types::Handle::from);
|
||||
|
||||
debug!(
|
||||
telegram_username = %username,
|
||||
@@ -73,7 +74,7 @@ pub async fn handle_telegram_webhook(
|
||||
match state
|
||||
.repos
|
||||
.user
|
||||
.store_telegram_chat_id(&username, from.id, handle.as_deref())
|
||||
.store_telegram_chat_id(&username, from.id, handle.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(Some(user_id)) => {
|
||||
|
||||
@@ -58,7 +58,9 @@ pub async fn dereference_scope(
|
||||
|
||||
for part in scope_parts {
|
||||
if let Some(cid_str) = part.strip_prefix("ref:") {
|
||||
let cache_key = tranquil_pds::cache_keys::scope_ref_key(cid_str);
|
||||
let cache_key = tranquil_pds::cache_keys::scope_ref_key(
|
||||
&tranquil_types::CidLink::from(cid_str.to_string()),
|
||||
);
|
||||
if let Some(cached) = state.cache.get(&cache_key).await {
|
||||
for s in cached.split_whitespace() {
|
||||
if !resolved_scopes.contains(&s.to_string()) {
|
||||
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
[dependencies]
|
||||
tranquil-config = { workspace = true }
|
||||
tranquil-crypto = { workspace = true }
|
||||
tranquil-types = { path = "../tranquil-types", default-features = false }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
base32 = { workspace = true }
|
||||
|
||||
@@ -5,10 +5,11 @@ mod verify;
|
||||
|
||||
pub use token::{
|
||||
create_access_token, create_access_token_hs256, create_access_token_hs256_with_metadata,
|
||||
create_access_token_with_delegation, create_access_token_with_metadata,
|
||||
create_access_token_with_scope_metadata, create_refresh_token, create_refresh_token_hs256,
|
||||
create_refresh_token_hs256_with_metadata, create_refresh_token_with_metadata,
|
||||
create_service_token, create_service_token_hs256,
|
||||
create_access_token_with_delegation, create_access_token_with_jti,
|
||||
create_access_token_with_metadata, create_access_token_with_scope_metadata,
|
||||
create_refresh_token, create_refresh_token_hs256, create_refresh_token_hs256_with_metadata,
|
||||
create_refresh_token_with_jti, create_refresh_token_with_metadata, create_service_token,
|
||||
create_service_token_hs256,
|
||||
};
|
||||
|
||||
pub use totp::{
|
||||
@@ -24,6 +25,6 @@ pub use types::{
|
||||
|
||||
pub use verify::{
|
||||
get_algorithm_from_token, get_did_from_token, get_jti_from_token, verify_access_token,
|
||||
verify_access_token_hs256, verify_access_token_typed, verify_refresh_token,
|
||||
verify_refresh_token_hs256, verify_token,
|
||||
verify_access_token_hs256, verify_refresh_token, verify_refresh_token_hs256, verify_token,
|
||||
verify_token_es256k,
|
||||
};
|
||||
|
||||
@@ -4,27 +4,28 @@ use super::types::{
|
||||
use anyhow::Result;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::{Duration, Utc};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use hmac::{Hmac, Mac};
|
||||
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
|
||||
use sha2::Sha256;
|
||||
use tranquil_types::{Did, Jti, Nsid};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub fn create_access_token(did: &str, key_bytes: &[u8]) -> Result<String> {
|
||||
pub fn create_access_token(did: &Did, key_bytes: &[u8]) -> Result<String> {
|
||||
Ok(create_access_token_with_metadata(did, key_bytes)?.token)
|
||||
}
|
||||
|
||||
pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result<String> {
|
||||
pub fn create_refresh_token(did: &Did, key_bytes: &[u8]) -> Result<String> {
|
||||
Ok(create_refresh_token_with_metadata(did, key_bytes)?.token)
|
||||
}
|
||||
|
||||
pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
|
||||
pub fn create_access_token_with_metadata(did: &Did, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
|
||||
create_access_token_with_scope_metadata(did, key_bytes, None, None)
|
||||
}
|
||||
|
||||
pub fn create_access_token_with_scope_metadata(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
key_bytes: &[u8],
|
||||
scopes: Option<&str>,
|
||||
hostname: Option<&str>,
|
||||
@@ -35,33 +36,33 @@ pub fn create_access_token_with_scope_metadata(
|
||||
scope,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
hostname,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_access_token_with_delegation(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
key_bytes: &[u8],
|
||||
scopes: Option<&str>,
|
||||
controller_did: Option<&str>,
|
||||
controller_did: Option<&Did>,
|
||||
hostname: Option<&str>,
|
||||
) -> Result<TokenWithMetadata> {
|
||||
let scope = scopes.unwrap_or(TokenScope::Access.as_str());
|
||||
let act = controller_did.map(|c| ActClaim { sub: c.to_string() });
|
||||
let act = controller_did.map(|c| ActClaim { sub: c.clone() });
|
||||
create_signed_token_with_act(
|
||||
did,
|
||||
scope,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
act,
|
||||
hostname,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_refresh_token_with_metadata(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
key_bytes: &[u8],
|
||||
) -> Result<TokenWithMetadata> {
|
||||
create_signed_token_with_metadata(
|
||||
@@ -69,15 +70,63 @@ pub fn create_refresh_token_with_metadata(
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
key_bytes,
|
||||
Duration::days(14),
|
||||
Duration::days(90),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Re-mint an access token carrying a specific `jti` and expiry. Used by the
|
||||
/// refresh grace window to reproduce a session's current access token without
|
||||
/// persisting the signed JWT itself.
|
||||
pub fn create_access_token_with_jti(
|
||||
did: &Did,
|
||||
key_bytes: &[u8],
|
||||
scopes: Option<&str>,
|
||||
controller_did: Option<&Did>,
|
||||
hostname: Option<&str>,
|
||||
jti: &Jti,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<String> {
|
||||
let scope = scopes.unwrap_or(TokenScope::Access.as_str());
|
||||
let act = controller_did.map(|c| ActClaim { sub: c.clone() });
|
||||
Ok(create_signed_token_pinned(
|
||||
did,
|
||||
scope,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
expires_at,
|
||||
jti.clone(),
|
||||
act,
|
||||
hostname,
|
||||
)?
|
||||
.token)
|
||||
}
|
||||
|
||||
/// Re-mint a refresh token carrying a specific `jti` and expiry. Counterpart to
|
||||
/// [`create_access_token_with_jti`] for the refresh grace window.
|
||||
pub fn create_refresh_token_with_jti(
|
||||
did: &Did,
|
||||
key_bytes: &[u8],
|
||||
jti: &Jti,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<String> {
|
||||
Ok(create_signed_token_pinned(
|
||||
did,
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
key_bytes,
|
||||
expires_at,
|
||||
jti.clone(),
|
||||
None,
|
||||
None,
|
||||
)?
|
||||
.token)
|
||||
}
|
||||
|
||||
pub fn create_service_token(
|
||||
did: &str,
|
||||
aud: &str,
|
||||
lxm: Option<&str>,
|
||||
did: &Did,
|
||||
aud: &Did,
|
||||
lxm: Option<&Nsid>,
|
||||
key_bytes: &[u8],
|
||||
) -> Result<String> {
|
||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||
@@ -88,14 +137,14 @@ pub fn create_service_token(
|
||||
.timestamp();
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
aud: aud.to_owned(),
|
||||
iss: did.clone(),
|
||||
sub: did.clone(),
|
||||
aud: aud.to_string(),
|
||||
exp: expiration,
|
||||
iat: Utc::now().timestamp(),
|
||||
scope: None,
|
||||
lxm: lxm.map(ToOwned::to_owned),
|
||||
jti: uuid::Uuid::new_v4().to_string(),
|
||||
lxm: lxm.cloned(),
|
||||
jti: Jti::new(uuid::Uuid::new_v4().to_string()),
|
||||
act: None,
|
||||
};
|
||||
|
||||
@@ -103,7 +152,7 @@ pub fn create_service_token(
|
||||
}
|
||||
|
||||
fn create_signed_token_with_metadata(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
scope: &str,
|
||||
typ: TokenType,
|
||||
key_bytes: &[u8],
|
||||
@@ -114,7 +163,7 @@ fn create_signed_token_with_metadata(
|
||||
}
|
||||
|
||||
fn create_signed_token_with_act(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
scope: &str,
|
||||
typ: TokenType,
|
||||
key_bytes: &[u8],
|
||||
@@ -122,14 +171,27 @@ fn create_signed_token_with_act(
|
||||
act: Option<ActClaim>,
|
||||
hostname: Option<&str>,
|
||||
) -> Result<TokenWithMetadata> {
|
||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||
|
||||
let expires_at = Utc::now()
|
||||
.checked_add_signed(duration)
|
||||
.expect("valid timestamp");
|
||||
let jti = Jti::new(uuid::Uuid::new_v4().to_string());
|
||||
create_signed_token_pinned(did, scope, typ, key_bytes, expires_at, jti, act, hostname)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn create_signed_token_pinned(
|
||||
did: &Did,
|
||||
scope: &str,
|
||||
typ: TokenType,
|
||||
key_bytes: &[u8],
|
||||
expires_at: DateTime<Utc>,
|
||||
jti: Jti,
|
||||
act: Option<ActClaim>,
|
||||
hostname: Option<&str>,
|
||||
) -> Result<TokenWithMetadata> {
|
||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||
|
||||
let expiration = expires_at.timestamp();
|
||||
let jti = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let aud_hostname = hostname.map(|h| h.to_string()).unwrap_or_else(|| {
|
||||
tranquil_config::try_get()
|
||||
@@ -138,8 +200,8 @@ fn create_signed_token_with_act(
|
||||
});
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
iss: did.clone(),
|
||||
sub: did.clone(),
|
||||
aud: format!("did:web:{}", aud_hostname),
|
||||
exp: expiration,
|
||||
iat: Utc::now().timestamp(),
|
||||
@@ -181,16 +243,16 @@ fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: TokenType) -> Re
|
||||
Ok(format!("{}.{}", message, signature_b64))
|
||||
}
|
||||
|
||||
pub fn create_access_token_hs256(did: &str, secret: &[u8]) -> Result<String> {
|
||||
pub fn create_access_token_hs256(did: &Did, secret: &[u8]) -> Result<String> {
|
||||
Ok(create_access_token_hs256_with_metadata(did, secret)?.token)
|
||||
}
|
||||
|
||||
pub fn create_refresh_token_hs256(did: &str, secret: &[u8]) -> Result<String> {
|
||||
pub fn create_refresh_token_hs256(did: &Did, secret: &[u8]) -> Result<String> {
|
||||
Ok(create_refresh_token_hs256_with_metadata(did, secret)?.token)
|
||||
}
|
||||
|
||||
pub fn create_access_token_hs256_with_metadata(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
secret: &[u8],
|
||||
) -> Result<TokenWithMetadata> {
|
||||
create_hs256_token_with_metadata(
|
||||
@@ -198,12 +260,12 @@ pub fn create_access_token_hs256_with_metadata(
|
||||
TokenScope::Access.as_str(),
|
||||
TokenType::Access,
|
||||
secret,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_refresh_token_hs256_with_metadata(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
secret: &[u8],
|
||||
) -> Result<TokenWithMetadata> {
|
||||
create_hs256_token_with_metadata(
|
||||
@@ -211,14 +273,14 @@ pub fn create_refresh_token_hs256_with_metadata(
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
secret,
|
||||
Duration::days(14),
|
||||
Duration::days(90),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_service_token_hs256(
|
||||
did: &str,
|
||||
aud: &str,
|
||||
lxm: &str,
|
||||
did: &Did,
|
||||
aud: &Did,
|
||||
lxm: &Nsid,
|
||||
secret: &[u8],
|
||||
) -> Result<String> {
|
||||
let expiration = Utc::now()
|
||||
@@ -227,14 +289,14 @@ pub fn create_service_token_hs256(
|
||||
.timestamp();
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
aud: aud.to_owned(),
|
||||
iss: did.clone(),
|
||||
sub: did.clone(),
|
||||
aud: aud.to_string(),
|
||||
exp: expiration,
|
||||
iat: Utc::now().timestamp(),
|
||||
scope: None,
|
||||
lxm: Some(lxm.to_string()),
|
||||
jti: uuid::Uuid::new_v4().to_string(),
|
||||
lxm: Some(lxm.clone()),
|
||||
jti: Jti::new(uuid::Uuid::new_v4().to_string()),
|
||||
act: None,
|
||||
};
|
||||
|
||||
@@ -242,7 +304,7 @@ pub fn create_service_token_hs256(
|
||||
}
|
||||
|
||||
fn create_hs256_token_with_metadata(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
scope: &str,
|
||||
typ: TokenType,
|
||||
secret: &[u8],
|
||||
@@ -253,11 +315,11 @@ fn create_hs256_token_with_metadata(
|
||||
.expect("valid timestamp");
|
||||
|
||||
let expiration = expires_at.timestamp();
|
||||
let jti = uuid::Uuid::new_v4().to_string();
|
||||
let jti = Jti::new(uuid::Uuid::new_v4().to_string());
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
iss: did.clone(),
|
||||
sub: did.clone(),
|
||||
aud: format!(
|
||||
"did:web:{}",
|
||||
tranquil_config::try_get()
|
||||
|
||||
@@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize, de, ser};
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
use tranquil_types::{Did, Jti, Nsid};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TokenType {
|
||||
@@ -15,7 +16,9 @@ impl TokenType {
|
||||
match self {
|
||||
Self::Access => "at+jwt",
|
||||
Self::Refresh => "refresh+jwt",
|
||||
Self::Service => "jwt",
|
||||
// RFC 7519 §5.1 recommends the uppercase "JWT".
|
||||
// and for atproto inter-service auth its a requirement.
|
||||
Self::Service => "JWT",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,21 +204,21 @@ impl std::error::Error for TokenDecodeError {}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActClaim {
|
||||
pub sub: String,
|
||||
pub sub: Did,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub iss: String,
|
||||
pub sub: String,
|
||||
pub iss: Did,
|
||||
pub sub: Did,
|
||||
pub aud: String,
|
||||
pub exp: i64,
|
||||
pub iat: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lxm: Option<String>,
|
||||
pub jti: String,
|
||||
pub lxm: Option<Nsid>,
|
||||
pub jti: Jti,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub act: Option<ActClaim>,
|
||||
}
|
||||
@@ -228,8 +231,8 @@ pub struct Header {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UnsafeClaims {
|
||||
pub iss: String,
|
||||
pub sub: Option<String>,
|
||||
pub iss: Did,
|
||||
pub sub: Option<Did>,
|
||||
}
|
||||
|
||||
pub struct TokenData<T> {
|
||||
@@ -238,21 +241,21 @@ pub struct TokenData<T> {
|
||||
|
||||
pub struct TokenWithMetadata {
|
||||
pub token: String,
|
||||
pub jti: String,
|
||||
pub jti: Jti,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TokenVerifyError {
|
||||
Expired,
|
||||
Invalid,
|
||||
Invalid(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for TokenVerifyError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Expired => write!(f, "Token expired"),
|
||||
Self::Invalid => write!(f, "Token invalid"),
|
||||
Self::Invalid(reason) => write!(f, "{}", reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +293,17 @@ mod tests {
|
||||
assert!(TokenType::from_str("bearer").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_token_header_serializes_typ_as_uppercase_jwt() {
|
||||
// RFC 7519 §5.1 recommends the JWT `typ` header value be uppercase "JWT".
|
||||
let header = Header {
|
||||
alg: SigningAlgorithm::ES256K,
|
||||
typ: TokenType::Service,
|
||||
};
|
||||
let json = serde_json::to_string(&header).expect("serialize header");
|
||||
assert!(json.contains(r#""typ":"JWT""#), "got {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_algorithm_case_insensitive() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -10,10 +10,11 @@ use hmac::{Hmac, Mac};
|
||||
use k256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Verifier};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
use tranquil_types::{Did, Jti};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub fn get_did_from_token(token: &str) -> Result<String, TokenDecodeError> {
|
||||
pub fn get_did_from_token(token: &str) -> Result<Did, TokenDecodeError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(TokenDecodeError::InvalidFormat);
|
||||
@@ -29,7 +30,7 @@ pub fn get_did_from_token(token: &str) -> Result<String, TokenDecodeError> {
|
||||
Ok(claims.sub.unwrap_or(claims.iss))
|
||||
}
|
||||
|
||||
pub fn get_jti_from_token(token: &str) -> Result<String, TokenDecodeError> {
|
||||
pub fn get_jti_from_token(token: &str) -> Result<Jti, TokenDecodeError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(TokenDecodeError::InvalidFormat);
|
||||
@@ -45,7 +46,7 @@ pub fn get_jti_from_token(token: &str) -> Result<String, TokenDecodeError> {
|
||||
claims
|
||||
.get("jti")
|
||||
.and_then(|j| j.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.map(Jti::new)
|
||||
.ok_or(TokenDecodeError::MissingClaim)
|
||||
}
|
||||
|
||||
@@ -66,11 +67,11 @@ pub fn get_algorithm_from_token(token: &str) -> Result<SigningAlgorithm, TokenDe
|
||||
}
|
||||
|
||||
pub fn verify_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
|
||||
verify_token_internal(token, key_bytes, None, None)
|
||||
verify_token_es256k(token, key_bytes, None, None).map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
|
||||
verify_token_internal(
|
||||
verify_token_es256k(
|
||||
token,
|
||||
key_bytes,
|
||||
Some(TokenType::Access),
|
||||
@@ -80,15 +81,17 @@ pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Cl
|
||||
TokenScope::AppPassPrivileged,
|
||||
]),
|
||||
)
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
pub fn verify_refresh_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
|
||||
verify_token_internal(
|
||||
verify_token_es256k(
|
||||
token,
|
||||
key_bytes,
|
||||
Some(TokenType::Refresh),
|
||||
Some(&[TokenScope::Refresh]),
|
||||
)
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
pub fn verify_access_token_hs256(token: &str, secret: &[u8]) -> Result<TokenData<Claims>> {
|
||||
@@ -113,15 +116,15 @@ pub fn verify_refresh_token_hs256(token: &str, secret: &[u8]) -> Result<TokenDat
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_token_internal(
|
||||
pub fn verify_token_es256k(
|
||||
token: &str,
|
||||
key_bytes: &[u8],
|
||||
expected_typ: Option<TokenType>,
|
||||
allowed_scopes: Option<&[TokenScope]>,
|
||||
) -> Result<TokenData<Claims>> {
|
||||
) -> Result<TokenData<Claims>, TokenVerifyError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(anyhow!("Invalid token format"));
|
||||
return Err(TokenVerifyError::Invalid("Invalid token format"));
|
||||
}
|
||||
|
||||
let header_b64 = parts[0];
|
||||
@@ -130,46 +133,43 @@ fn verify_token_internal(
|
||||
|
||||
let header_bytes = URL_SAFE_NO_PAD
|
||||
.decode(header_b64)
|
||||
.context("Base64 decode of header failed")?;
|
||||
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of header failed"))?;
|
||||
|
||||
let header: Header =
|
||||
serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?;
|
||||
let header: Header = serde_json::from_slice(&header_bytes)
|
||||
.map_err(|_| TokenVerifyError::Invalid("JSON decode of header failed"))?;
|
||||
|
||||
if let Some(expected) = expected_typ
|
||||
&& header.typ != expected
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Invalid token type: expected {}, got {}",
|
||||
expected,
|
||||
header.typ
|
||||
));
|
||||
return Err(TokenVerifyError::Invalid("Invalid token type"));
|
||||
}
|
||||
|
||||
let signature_bytes = URL_SAFE_NO_PAD
|
||||
.decode(signature_b64)
|
||||
.context("Base64 decode of signature failed")?;
|
||||
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of signature failed"))?;
|
||||
|
||||
let signature = Signature::from_slice(&signature_bytes)
|
||||
.map_err(|e| anyhow!("Invalid signature format: {}", e))?;
|
||||
.map_err(|_| TokenVerifyError::Invalid("Invalid signature format"))?;
|
||||
|
||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||
let signing_key = SigningKey::from_slice(key_bytes)
|
||||
.map_err(|_| TokenVerifyError::Invalid("Invalid signing key"))?;
|
||||
let verifying_key = VerifyingKey::from(&signing_key);
|
||||
|
||||
let message = format!("{}.{}", header_b64, claims_b64);
|
||||
verifying_key
|
||||
.verify(message.as_bytes(), &signature)
|
||||
.map_err(|e| anyhow!("Signature verification failed: {}", e))?;
|
||||
.map_err(|_| TokenVerifyError::Invalid("Signature verification failed"))?;
|
||||
|
||||
let claims_bytes = URL_SAFE_NO_PAD
|
||||
.decode(claims_b64)
|
||||
.context("Base64 decode of claims failed")?;
|
||||
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of claims failed"))?;
|
||||
|
||||
let claims: Claims =
|
||||
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
|
||||
let claims: Claims = serde_json::from_slice(&claims_bytes)
|
||||
.map_err(|_| TokenVerifyError::Invalid("JSON decode of claims failed"))?;
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
if claims.exp < now {
|
||||
return Err(anyhow!("Token expired"));
|
||||
return Err(TokenVerifyError::Expired);
|
||||
}
|
||||
|
||||
if let Some(scopes) = allowed_scopes {
|
||||
@@ -180,7 +180,7 @@ fn verify_token_internal(
|
||||
.parse()
|
||||
.unwrap_or_else(|e| match e {});
|
||||
if !scopes.contains(&token_scope) {
|
||||
return Err(anyhow!("Invalid token scope: {}", token_scope));
|
||||
return Err(TokenVerifyError::Invalid("Invalid token scope"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,88 +266,3 @@ fn verify_token_hs256_internal(
|
||||
|
||||
Ok(TokenData { claims })
|
||||
}
|
||||
|
||||
pub fn verify_access_token_typed(
|
||||
token: &str,
|
||||
key_bytes: &[u8],
|
||||
) -> Result<TokenData<Claims>, TokenVerifyError> {
|
||||
verify_token_typed_internal(token, key_bytes, Some(TokenType::Access), None)
|
||||
}
|
||||
|
||||
fn verify_token_typed_internal(
|
||||
token: &str,
|
||||
key_bytes: &[u8],
|
||||
expected_typ: Option<TokenType>,
|
||||
allowed_scopes: Option<&[TokenScope]>,
|
||||
) -> Result<TokenData<Claims>, TokenVerifyError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
}
|
||||
|
||||
let header_b64 = parts[0];
|
||||
let claims_b64 = parts[1];
|
||||
let signature_b64 = parts[2];
|
||||
|
||||
let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(header_b64) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
|
||||
let Ok(header) = serde_json::from_slice::<Header>(&header_bytes) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
|
||||
if let Some(expected) = expected_typ
|
||||
&& header.typ != expected
|
||||
{
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
}
|
||||
|
||||
let Ok(signature_bytes) = URL_SAFE_NO_PAD.decode(signature_b64) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
|
||||
let Ok(signature) = Signature::from_slice(&signature_bytes) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
|
||||
let Ok(signing_key) = SigningKey::from_slice(key_bytes) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
let verifying_key = VerifyingKey::from(&signing_key);
|
||||
|
||||
let message = format!("{}.{}", header_b64, claims_b64);
|
||||
if verifying_key
|
||||
.verify(message.as_bytes(), &signature)
|
||||
.is_err()
|
||||
{
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
}
|
||||
|
||||
let Ok(claims_bytes) = URL_SAFE_NO_PAD.decode(claims_b64) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
|
||||
let Ok(claims) = serde_json::from_slice::<Claims>(&claims_bytes) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
if claims.exp < now {
|
||||
return Err(TokenVerifyError::Expired);
|
||||
}
|
||||
|
||||
if let Some(scopes) = allowed_scopes {
|
||||
let token_scope: TokenScope = claims
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.parse()
|
||||
.unwrap_or_else(|e| match e {});
|
||||
if !scopes.contains(&token_scope) {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TokenData { claims })
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@ tranquil-ripple = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
redis = { workspace = true, optional = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -160,18 +160,17 @@ impl Cache for NoOpCache {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoOpRateLimiter;
|
||||
|
||||
#[async_trait]
|
||||
impl DistributedRateLimiter for NoOpRateLimiter {
|
||||
async fn check_rate_limit(&self, _key: &str, _limit: u32, _window_ms: u64) -> bool {
|
||||
true
|
||||
}
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CacheInitError {
|
||||
#[error("ripple config: {0}")]
|
||||
Config(#[from] tranquil_ripple::RippleConfigError),
|
||||
#[error("ripple start: {0}")]
|
||||
Start(#[from] tranquil_ripple::RippleStartError),
|
||||
}
|
||||
|
||||
pub async fn create_cache(
|
||||
shutdown: tokio_util::sync::CancellationToken,
|
||||
) -> (Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>) {
|
||||
) -> Result<(Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>), CacheInitError> {
|
||||
let cache_cfg = tranquil_config::try_get().map(|c| &c.cache);
|
||||
let backend = cache_cfg.map(|c| c.backend.as_str()).unwrap_or("ripple");
|
||||
let valkey_url = cache_cfg.and_then(|c| c.valkey_url.as_deref());
|
||||
@@ -183,7 +182,7 @@ pub async fn create_cache(
|
||||
Ok(cache) => {
|
||||
tracing::info!("using valkey cache at {url}");
|
||||
let rate_limiter = Arc::new(RedisRateLimiter::new(cache.connection()));
|
||||
return (Arc::new(cache), rate_limiter);
|
||||
return Ok((Arc::new(cache), rate_limiter));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to connect to valkey: {e}. falling back to ripple.");
|
||||
@@ -201,26 +200,13 @@ pub async fn create_cache(
|
||||
);
|
||||
}
|
||||
|
||||
match tranquil_ripple::RippleConfig::from_config() {
|
||||
Ok(config) => {
|
||||
let peer_count = config.seed_peers.len();
|
||||
match tranquil_ripple::RippleEngine::start(config, shutdown).await {
|
||||
Ok((cache, rate_limiter, _bound_addr)) => {
|
||||
match peer_count {
|
||||
0 => tracing::info!("ripple cache started (single-node)"),
|
||||
n => tracing::info!("ripple cache started ({n} seed peers)"),
|
||||
}
|
||||
(cache, rate_limiter)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("ripple engine failed to start: {e:#}. running without cache.");
|
||||
(Arc::new(NoOpCache), Arc::new(NoOpRateLimiter))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("ripple config error: {e:#}. running without cache.");
|
||||
(Arc::new(NoOpCache), Arc::new(NoOpRateLimiter))
|
||||
}
|
||||
let config = tranquil_ripple::RippleConfig::from_config()?;
|
||||
let peer_count = config.seed_peers.len();
|
||||
let (cache, rate_limiter, _bound_addr) =
|
||||
tranquil_ripple::RippleEngine::start(config, shutdown).await?;
|
||||
match peer_count {
|
||||
0 => tracing::info!("ripple cache started as a single node"),
|
||||
n => tracing::info!("ripple cache started with {n} seed peers"),
|
||||
}
|
||||
Ok((cache, rate_limiter))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,14 @@ tranquil-signal = { workspace = true }
|
||||
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
hickory-resolver = { workspace = true }
|
||||
lettre = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
rsa = { workspace = true }
|
||||
secrecy = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -18,3 +25,7 @@ tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tranquil-db-traits = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time", "io-util", "net"] }
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
use std::fs;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use ed25519_dalek::pkcs8::DecodePrivateKey as _;
|
||||
use lettre::Message;
|
||||
use lettre::message::dkim::{
|
||||
DkimCanonicalization, DkimCanonicalizationType, DkimConfig as LettreDkimConfig,
|
||||
DkimSigningAlgorithm, DkimSigningKey,
|
||||
};
|
||||
use lettre::message::header::HeaderName;
|
||||
use rsa::pkcs1::EncodeRsaPrivateKey;
|
||||
use rsa::pkcs8::LineEnding;
|
||||
|
||||
use super::types::{DkimKeyPath, DkimSelector, EmailDomain};
|
||||
use crate::sender::SendError;
|
||||
|
||||
const SIGNED_HEADERS: &[&str] = &[
|
||||
"From",
|
||||
"Sender",
|
||||
"Reply-To",
|
||||
"To",
|
||||
"Cc",
|
||||
"Subject",
|
||||
"Date",
|
||||
"In-Reply-To",
|
||||
"References",
|
||||
"MIME-Version",
|
||||
"Content-Type",
|
||||
"Content-Transfer-Encoding",
|
||||
];
|
||||
|
||||
pub struct DkimSigner {
|
||||
config: LettreDkimConfig,
|
||||
}
|
||||
|
||||
impl DkimSigner {
|
||||
pub fn load(
|
||||
selector: DkimSelector,
|
||||
domain: EmailDomain,
|
||||
path: DkimKeyPath,
|
||||
) -> Result<Self, SendError> {
|
||||
let pem = fs::read_to_string(path.as_path()).map_err(|e| {
|
||||
SendError::DkimSign(format!("read DKIM key {}: {e}", path.as_path().display()))
|
||||
})?;
|
||||
Self::from_pem(selector, domain, &pem)
|
||||
}
|
||||
|
||||
pub fn from_pem(
|
||||
selector: DkimSelector,
|
||||
domain: EmailDomain,
|
||||
pem: &str,
|
||||
) -> Result<Self, SendError> {
|
||||
let key = parse_key(pem)?;
|
||||
let canonicalization = DkimCanonicalization {
|
||||
header: DkimCanonicalizationType::Relaxed,
|
||||
body: DkimCanonicalizationType::Relaxed,
|
||||
};
|
||||
let headers = SIGNED_HEADERS
|
||||
.iter()
|
||||
.copied()
|
||||
.map(HeaderName::new_from_ascii_str)
|
||||
.collect();
|
||||
let config = LettreDkimConfig::new(
|
||||
selector.into_inner(),
|
||||
domain.into_inner(),
|
||||
key,
|
||||
headers,
|
||||
canonicalization,
|
||||
);
|
||||
Ok(Self { config })
|
||||
}
|
||||
|
||||
pub fn sign(&self, message: &mut Message) {
|
||||
message.sign(&self.config);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DkimSigner {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("DkimSigner")
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_key(input: &str) -> Result<DkimSigningKey, SendError> {
|
||||
let trimmed = input.trim_start();
|
||||
match trimmed {
|
||||
s if s.starts_with("-----BEGIN RSA PRIVATE KEY-----") => {
|
||||
DkimSigningKey::new(input, DkimSigningAlgorithm::Rsa)
|
||||
.map_err(|e| SendError::DkimSign(format!("RSA PKCS#1 PEM rejected: {e}")))
|
||||
}
|
||||
s if s.starts_with("-----BEGIN PRIVATE KEY-----") => parse_pkcs8(input),
|
||||
s if s.starts_with("-----BEGIN") => Err(SendError::DkimSign(
|
||||
"unrecognized PEM type; expected an RSA or Ed25519 private key".to_string(),
|
||||
)),
|
||||
_ => DkimSigningKey::new(input.trim(), DkimSigningAlgorithm::Ed25519).map_err(|e| {
|
||||
SendError::DkimSign(format!(
|
||||
"expected base64-encoded 32-byte Ed25519 seed or a PEM-wrapped key: {e}"
|
||||
))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pkcs8(pem: &str) -> Result<DkimSigningKey, SendError> {
|
||||
let ed25519_err = match ed25519_dalek::SigningKey::from_pkcs8_pem(pem) {
|
||||
Ok(key) => {
|
||||
let seed = BASE64_STANDARD.encode(key.to_bytes());
|
||||
return DkimSigningKey::new(&seed, DkimSigningAlgorithm::Ed25519)
|
||||
.map_err(|e| SendError::DkimSign(format!("re-import Ed25519 seed: {e}")));
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
let rsa_err = match rsa::RsaPrivateKey::from_pkcs8_pem(pem) {
|
||||
Ok(key) => {
|
||||
let pkcs1 = key
|
||||
.to_pkcs1_pem(LineEnding::LF)
|
||||
.map_err(|e| SendError::DkimSign(format!("re-encode RSA PKCS#8 as PKCS#1: {e}")))?;
|
||||
return DkimSigningKey::new(pkcs1.as_str(), DkimSigningAlgorithm::Rsa)
|
||||
.map_err(|e| SendError::DkimSign(format!("re-import RSA PKCS#1: {e}")));
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
Err(SendError::DkimSign(format!(
|
||||
"PKCS#8 PEM rejected by both parsers; ed25519: {ed25519_err}; rsa: {rsa_err}"
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::message::header::ContentType;
|
||||
use rsa::pkcs1::DecodeRsaPrivateKey as _;
|
||||
|
||||
const ED25519_RAW_SEED_B64: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||
|
||||
const RSA_PKCS1_PEM: &str = include_str!("test_fixtures/rsa2048-priv-pkcs1.pem");
|
||||
|
||||
fn ed25519_pkcs8_pem() -> String {
|
||||
let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
|
||||
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
|
||||
}
|
||||
|
||||
fn rsa_pkcs8_pem() -> String {
|
||||
let key = rsa::RsaPrivateKey::from_pkcs1_pem(RSA_PKCS1_PEM).unwrap();
|
||||
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
|
||||
}
|
||||
|
||||
fn signer(pem: &str) -> DkimSigner {
|
||||
DkimSigner::from_pem(
|
||||
DkimSelector::parse("default").unwrap(),
|
||||
EmailDomain::parse("nel.pet").unwrap(),
|
||||
pem,
|
||||
)
|
||||
.expect("key should load")
|
||||
}
|
||||
|
||||
fn signed_headers(signer: &DkimSigner) -> String {
|
||||
let from: Mailbox = "sender@nel.pet".parse().unwrap();
|
||||
let to: Mailbox = "recipient@nel.pet".parse().unwrap();
|
||||
let mut message = Message::builder()
|
||||
.from(from)
|
||||
.to(to)
|
||||
.subject("Roundtrip")
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body("Body".to_string())
|
||||
.unwrap();
|
||||
signer.sign(&mut message);
|
||||
String::from_utf8(message.formatted()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage() {
|
||||
assert!(matches!(
|
||||
parse_key("not a key"),
|
||||
Err(SendError::DkimSign(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_pem_type() {
|
||||
let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
match parse_key(pem) {
|
||||
Err(SendError::DkimSign(msg)) => assert!(msg.contains("unrecognized"), "msg: {msg}"),
|
||||
other => panic!("expected unrecognized PEM error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ed25519_raw_seed_signs() {
|
||||
let raw = signed_headers(&signer(ED25519_RAW_SEED_B64));
|
||||
assert_signed_with(&raw, "a=ed25519-sha256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ed25519_pkcs8_pem_signs() {
|
||||
let raw = signed_headers(&signer(&ed25519_pkcs8_pem()));
|
||||
assert_signed_with(&raw, "a=ed25519-sha256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsa_pkcs1_pem_signs() {
|
||||
let raw = signed_headers(&signer(RSA_PKCS1_PEM));
|
||||
assert_signed_with(&raw, "a=rsa-sha256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsa_pkcs8_pem_signs() {
|
||||
let raw = signed_headers(&signer(&rsa_pkcs8_pem()));
|
||||
assert_signed_with(&raw, "a=rsa-sha256");
|
||||
}
|
||||
|
||||
fn assert_signed_with(raw: &str, algorithm: &str) {
|
||||
assert!(
|
||||
raw.contains("DKIM-Signature:"),
|
||||
"no signature header: {raw}"
|
||||
);
|
||||
assert!(raw.contains(algorithm), "missing {algorithm}: {raw}");
|
||||
assert!(
|
||||
raw.contains("c=relaxed/relaxed"),
|
||||
"expected relaxed/relaxed canonicalization: {raw}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
use lettre::Message;
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::message::header::ContentType;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::types::EmailDomain;
|
||||
use crate::sender::SendError;
|
||||
use crate::types::QueuedComms;
|
||||
|
||||
pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result<Message, SendError> {
|
||||
let to: Mailbox = qc
|
||||
.recipient
|
||||
.parse()
|
||||
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
|
||||
let subject = qc.subject.as_deref().unwrap_or("Notification");
|
||||
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
|
||||
Message::builder()
|
||||
.from(from.clone())
|
||||
.to(to)
|
||||
.subject(subject)
|
||||
.message_id(Some(message_id))
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(qc.body.clone())
|
||||
.map_err(|e| SendError::MessageBuild(e.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn recipient_domain(message: &Message) -> Result<EmailDomain, SendError> {
|
||||
let envelope = message.envelope();
|
||||
let first = envelope
|
||||
.to()
|
||||
.first()
|
||||
.ok_or_else(|| SendError::MessageBuild("envelope has no recipients".to_string()))?;
|
||||
EmailDomain::parse(first.domain())
|
||||
.map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{CommsChannel, CommsStatus, CommsType};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn from_mailbox() -> Mailbox {
|
||||
"Test Sender <noreply@nel.pet>".parse().unwrap()
|
||||
}
|
||||
|
||||
fn fixture(recipient: &str, subject: Option<&str>, body: &str) -> QueuedComms {
|
||||
QueuedComms {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: None,
|
||||
channel: CommsChannel::Email,
|
||||
comms_type: CommsType::Welcome,
|
||||
status: CommsStatus::Pending,
|
||||
recipient: recipient.to_string(),
|
||||
subject: subject.map(String::from),
|
||||
body: body.to_string(),
|
||||
metadata: None,
|
||||
attempts: 0,
|
||||
max_attempts: 3,
|
||||
last_error: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
scheduled_for: Utc::now(),
|
||||
processed_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_basic_message() {
|
||||
let msg = build(
|
||||
&from_mailbox(),
|
||||
&fixture("user@nel.pet", Some("Welcome"), "Hello world."),
|
||||
)
|
||||
.unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
let lower = raw.to_lowercase();
|
||||
assert!(raw.contains("From: \"Test Sender\" <noreply@nel.pet>"));
|
||||
assert!(raw.contains("To: user@nel.pet"));
|
||||
assert!(raw.contains("Subject: Welcome"));
|
||||
assert!(lower.contains("content-type: text/plain"));
|
||||
assert!(raw.contains("Hello world."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_subject_is_encoded() {
|
||||
let msg = build(
|
||||
&from_mailbox(),
|
||||
&fixture("user@nel.pet", Some("héllo wörld"), "Body"),
|
||||
)
|
||||
.unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
assert!(raw.contains("=?utf-8?"));
|
||||
assert!(!raw.contains("héllo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_injection_rejected() {
|
||||
let result = build(
|
||||
&from_mailbox(),
|
||||
&fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"),
|
||||
);
|
||||
assert!(matches!(result, Err(SendError::InvalidRecipient(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_crlf_does_not_inject_headers() {
|
||||
let msg = build(
|
||||
&from_mailbox(),
|
||||
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
|
||||
)
|
||||
.expect("subject CRLF should be encoded, not rejected");
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
assert!(
|
||||
!raw.contains("Bcc:"),
|
||||
"CRLF in subject must not produce a Bcc header: {raw}"
|
||||
);
|
||||
assert!(
|
||||
raw.contains("Subject: ="),
|
||||
"subject with non-printable chars should be RFC 2047 encoded: {raw}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_id_uses_from_domain() {
|
||||
let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
let line = raw
|
||||
.lines()
|
||||
.find(|l| l.starts_with("Message-ID:") || l.starts_with("Message-Id:"))
|
||||
.expect("message-id header present");
|
||||
assert!(
|
||||
line.contains("@nel.pet>"),
|
||||
"message-id should use From domain: {line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_subject_uses_default() {
|
||||
let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
assert!(raw.contains("Subject: Notification"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipient_domain_extracted() {
|
||||
let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap();
|
||||
let d = recipient_domain(&msg).unwrap();
|
||||
assert_eq!(d.as_str(), "nel.pet");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
pub mod dkim;
|
||||
pub mod message;
|
||||
mod mx;
|
||||
pub mod transport;
|
||||
pub mod types;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::transport::smtp::AsyncSmtpTransport;
|
||||
use lettre::transport::smtp::PoolConfig;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::transport::smtp::extension::ClientId;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub use self::dkim::DkimSigner;
|
||||
pub use self::transport::SendMode;
|
||||
use self::types::{
|
||||
DkimKeyPath, DkimSelector, EmailDomain, HeloName, SmtpHost, SmtpPassword, SmtpPort,
|
||||
SmtpUsername, TlsMode,
|
||||
};
|
||||
use crate::sender::{CommsSender, SendError};
|
||||
use crate::types::{CommsChannel, QueuedComms};
|
||||
|
||||
pub struct EmailSender {
|
||||
from: Mailbox,
|
||||
mode: SendMode,
|
||||
dkim: Option<DkimSigner>,
|
||||
}
|
||||
|
||||
impl EmailSender {
|
||||
pub fn new(from: Mailbox, mode: SendMode, dkim: Option<DkimSigner>) -> Self {
|
||||
Self { from, mode, dkim }
|
||||
}
|
||||
|
||||
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Result<Option<Self>, SendError> {
|
||||
let Some(from_address) = cfg.email.from_address.as_deref().filter(|s| !s.is_empty()) else {
|
||||
info!("Email sender disabled: MAIL_FROM_ADDRESS unset");
|
||||
return Ok(None);
|
||||
};
|
||||
let from = build_from(&cfg.email.from_name, from_address)?;
|
||||
let dkim = build_dkim(&cfg.email.dkim)?;
|
||||
let mode = match cfg
|
||||
.email
|
||||
.smarthost
|
||||
.host
|
||||
.as_deref()
|
||||
.filter(|h| !h.is_empty())
|
||||
{
|
||||
Some(host) => build_smarthost(cfg, host)?,
|
||||
None => build_direct_mx(cfg)?,
|
||||
};
|
||||
info!(?mode, dkim = dkim.is_some(), "Email sender initialized");
|
||||
Ok(Some(Self { from, mode, dkim }))
|
||||
}
|
||||
}
|
||||
|
||||
fn config_invalid(field: &str, error: impl std::fmt::Display) -> SendError {
|
||||
SendError::ConfigInvalid(format!("{field}: {error}"))
|
||||
}
|
||||
|
||||
fn build_from(from_name: &str, from_address: &str) -> Result<Mailbox, SendError> {
|
||||
let raw = match from_name.is_empty() {
|
||||
true => from_address.to_string(),
|
||||
false => format!("\"{}\" <{}>", from_name.replace('"', "'"), from_address),
|
||||
};
|
||||
raw.parse::<Mailbox>()
|
||||
.map_err(|e| config_invalid("MAIL_FROM_ADDRESS / MAIL_FROM_NAME", e))
|
||||
}
|
||||
|
||||
fn build_smarthost(
|
||||
cfg: &tranquil_config::TranquilConfig,
|
||||
host_raw: &str,
|
||||
) -> Result<SendMode, SendError> {
|
||||
let host = SmtpHost::parse(host_raw).map_err(|e| config_invalid("MAIL_SMARTHOST_HOST", e))?;
|
||||
let port = SmtpPort::parse(cfg.email.smarthost.port)
|
||||
.map_err(|e| config_invalid("MAIL_SMARTHOST_PORT", e))?;
|
||||
let tls = TlsMode::parse(&cfg.email.smarthost.tls)
|
||||
.map_err(|e| config_invalid("MAIL_SMARTHOST_TLS", e))?;
|
||||
let helo = resolve_helo(cfg)?;
|
||||
let pool = PoolConfig::new()
|
||||
.max_size(cfg.email.smarthost.pool_size)
|
||||
.idle_timeout(Duration::from_secs(60));
|
||||
let command_timeout = Duration::from_secs(cfg.email.smarthost.command_timeout_secs);
|
||||
let total_timeout = Duration::from_secs(cfg.email.smarthost.total_timeout_secs);
|
||||
|
||||
let builder = match tls {
|
||||
TlsMode::Implicit => AsyncSmtpTransport::<lettre::Tokio1Executor>::relay(host.as_str())
|
||||
.map_err(|e| config_invalid("smarthost TLS setup", e))?,
|
||||
TlsMode::Starttls => {
|
||||
AsyncSmtpTransport::<lettre::Tokio1Executor>::starttls_relay(host.as_str())
|
||||
.map_err(|e| config_invalid("smarthost TLS setup", e))?
|
||||
}
|
||||
TlsMode::None => {
|
||||
AsyncSmtpTransport::<lettre::Tokio1Executor>::builder_dangerous(host.as_str())
|
||||
}
|
||||
};
|
||||
let builder = builder
|
||||
.port(port.as_u16())
|
||||
.hello_name(ClientId::Domain(helo.into_inner()))
|
||||
.timeout(Some(command_timeout))
|
||||
.pool_config(pool);
|
||||
let builder = match (
|
||||
cfg.email.smarthost.username.as_deref(),
|
||||
cfg.email.smarthost.password.as_deref(),
|
||||
) {
|
||||
(Some(u), Some(p)) => {
|
||||
let username =
|
||||
SmtpUsername::parse(u).map_err(|e| config_invalid("MAIL_SMARTHOST_USERNAME", e))?;
|
||||
let password =
|
||||
SmtpPassword::parse(p).map_err(|e| config_invalid("MAIL_SMARTHOST_PASSWORD", e))?;
|
||||
builder.credentials(Credentials::new(
|
||||
username.into_inner(),
|
||||
password.expose().to_string(),
|
||||
))
|
||||
}
|
||||
_ => builder,
|
||||
};
|
||||
Ok(SendMode::Smarthost {
|
||||
transport: Box::new(builder.build()),
|
||||
total_timeout,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_direct_mx(cfg: &tranquil_config::TranquilConfig) -> Result<SendMode, SendError> {
|
||||
let helo = resolve_helo(cfg)?;
|
||||
let resolver = Arc::new(
|
||||
TokioAsyncResolver::tokio_from_system_conf().unwrap_or_else(|e| {
|
||||
tracing::warn!("falling back to default DNS resolvers: {}", e);
|
||||
TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default())
|
||||
}),
|
||||
);
|
||||
let max_concurrent = cfg.email.direct_mx.max_concurrent_sends.max(1);
|
||||
Ok(SendMode::DirectMx {
|
||||
resolver,
|
||||
helo,
|
||||
command_timeout: Duration::from_secs(cfg.email.direct_mx.command_timeout_secs),
|
||||
total_timeout: Duration::from_secs(cfg.email.direct_mx.total_timeout_secs),
|
||||
require_tls: cfg.email.direct_mx.require_tls,
|
||||
inflight: Arc::new(Semaphore::new(max_concurrent)),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_helo(cfg: &tranquil_config::TranquilConfig) -> Result<HeloName, SendError> {
|
||||
let raw = cfg
|
||||
.email
|
||||
.helo_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| cfg.server.hostname_without_port().to_string());
|
||||
HeloName::parse(&raw).map_err(|e| config_invalid(&format!("HELO name {raw:?}"), e))
|
||||
}
|
||||
|
||||
fn build_dkim(cfg: &tranquil_config::DkimConfig) -> Result<Option<DkimSigner>, SendError> {
|
||||
let selector = match cfg.selector.as_deref() {
|
||||
Some(s) => s,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let domain = cfg
|
||||
.domain
|
||||
.as_deref()
|
||||
.ok_or_else(|| SendError::DkimSign("MAIL_DKIM_DOMAIN required when selector set".into()))?;
|
||||
let key_path = cfg.private_key_path.as_deref().ok_or_else(|| {
|
||||
SendError::DkimSign("MAIL_DKIM_KEY_PATH required when selector set".into())
|
||||
})?;
|
||||
let selector = DkimSelector::parse(selector)
|
||||
.map_err(|e| SendError::DkimSign(format!("invalid DKIM selector: {e}")))?;
|
||||
let domain = EmailDomain::parse(domain)
|
||||
.map_err(|e| SendError::DkimSign(format!("invalid DKIM domain: {e}")))?;
|
||||
let path = DkimKeyPath::parse(key_path)
|
||||
.map_err(|e| SendError::DkimSign(format!("DKIM key path invalid: {e}")))?;
|
||||
DkimSigner::load(selector, domain, path).map(Some)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommsSender for EmailSender {
|
||||
fn channel(&self) -> CommsChannel {
|
||||
CommsChannel::Email
|
||||
}
|
||||
|
||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
||||
let mut message = message::build(&self.from, notification)?;
|
||||
if let Some(signer) = &self.dkim {
|
||||
signer.sign(&mut message);
|
||||
}
|
||||
match transport::dispatch(&self.mode, message).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
warn!(comms_id = %notification.id, error = %e, "SMTP send failed");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user