mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-25 02:36:06 +00:00
Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b20f4cfb4 | ||
|
|
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 |
@@ -106,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"
|
||||
@@ -137,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,5 +4,6 @@ target/
|
||||
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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM session_tokens WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cf874abcb72017e775fe699a0b77ae9341355f30e4af84968ffeb9135dba745f"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
-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"
|
||||
}
|
||||
Generated
+178
-34
@@ -210,7 +210,7 @@ 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 7.1.3",
|
||||
@@ -220,6 +220,22 @@ dependencies = [
|
||||
"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"
|
||||
@@ -1970,7 +2007,21 @@ 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 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",
|
||||
@@ -2834,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"
|
||||
@@ -4614,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]]
|
||||
@@ -5184,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",
|
||||
@@ -5427,6 +5515,7 @@ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"futures-io",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
@@ -5630,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"
|
||||
@@ -7527,7 +7630,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-api"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7578,7 +7681,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
@@ -7601,11 +7704,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"redis",
|
||||
"thiserror 2.0.18",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tranquil-config",
|
||||
@@ -7615,7 +7719,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7641,7 +7745,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
@@ -7649,7 +7753,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -7665,7 +7769,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7682,7 +7786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7698,7 +7802,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -7709,7 +7813,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -7728,7 +7832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7751,7 +7855,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth-server"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -7784,7 +7888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -7837,6 +7941,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"testcontainers",
|
||||
"testcontainers-modules",
|
||||
"thiserror 2.0.18",
|
||||
@@ -7877,7 +7982,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -7889,19 +7994,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.6.3"
|
||||
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",
|
||||
@@ -7910,11 +8018,12 @@ dependencies = [
|
||||
"tranquil-config",
|
||||
"tranquil-infra",
|
||||
"uuid",
|
||||
"x509-parser 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -7930,17 +8039,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-server"
|
||||
version = "0.6.3"
|
||||
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",
|
||||
@@ -7960,7 +8076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-signal"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7983,7 +8099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -8000,7 +8116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-store"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -8049,7 +8165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-sync"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -8071,7 +8187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cid",
|
||||
@@ -8584,7 +8700,7 @@ checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"base64urlsafedata",
|
||||
"der-parser",
|
||||
"der-parser 9.0.0",
|
||||
"hex",
|
||||
"nom 7.1.3",
|
||||
"openssl",
|
||||
@@ -8600,7 +8716,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"webauthn-attestation-ca",
|
||||
"webauthn-rs-proto",
|
||||
"x509-parser",
|
||||
"x509-parser 0.16.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9154,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 7.1.3",
|
||||
"oid-registry",
|
||||
"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"
|
||||
@@ -9193,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"
|
||||
|
||||
+17
-3
@@ -26,7 +26,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.3"
|
||||
version = "0.6.5"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -82,6 +82,8 @@ 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"
|
||||
@@ -106,7 +108,9 @@ 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"
|
||||
@@ -137,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"
|
||||
@@ -149,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
|
||||
|
||||
+35
-11
@@ -1,4 +1,6 @@
|
||||
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 frontend/pnpm-workspace.yaml ./
|
||||
@@ -6,8 +8,28 @@ 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"
|
||||
@@ -36,21 +58,23 @@ COPY crates/tranquil-store ./crates/tranquil-store
|
||||
COPY crates/tranquil-signal ./crates/tranquil-signal
|
||||
COPY crates/tranquil-server ./crates/tranquil-server
|
||||
COPY migrations ./migrations
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/app/target \
|
||||
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 ca-certificates
|
||||
FROM ${DISTROLESS_IMAGE}
|
||||
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
|
||||
COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend
|
||||
WORKDIR /app
|
||||
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"]
|
||||
|
||||
+6
-7
@@ -1,14 +1,13 @@
|
||||
FROM rust:1.92-alpine
|
||||
FROM rust:1.96-slim-trixie
|
||||
|
||||
RUN apk add --no-cache \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
musl-dev \
|
||||
pkgconfig \
|
||||
openssl-dev \
|
||||
openssl-libs-static \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
mold \
|
||||
clang \
|
||||
protoc
|
||||
protobuf-compiler \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN cargo install cargo-watch
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ We currently don't have a shared space to chat and organize Tranquil things, but
|
||||
- [@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!
|
||||
|
||||
@@ -174,9 +174,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 +230,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 +253,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 +344,6 @@ pub struct CreateDelegatedAccountInput {
|
||||
pub handle: String,
|
||||
pub email: Option<String>,
|
||||
pub controller_scopes: ValidatedDelegationScope,
|
||||
pub invite_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -362,19 +375,6 @@ 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
|
||||
@@ -397,16 +397,15 @@ pub async fn create_delegated_account(
|
||||
commit_cid: 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 +416,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(
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -415,40 +416,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();
|
||||
@@ -517,11 +489,7 @@ pub async fn create_account(
|
||||
commit_cid: commit_cid_str.clone(),
|
||||
repo_rev: rev_str.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,
|
||||
};
|
||||
|
||||
@@ -541,6 +509,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();
|
||||
|
||||
@@ -491,16 +491,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,15 +45,9 @@ pub async fn submit_plc_genesis(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
@@ -108,90 +116,24 @@ pub async fn import_repo(
|
||||
});
|
||||
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;
|
||||
@@ -417,7 +359,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,8 +14,8 @@ 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};
|
||||
@@ -54,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 {
|
||||
@@ -72,7 +74,7 @@ async fn process_single_write(
|
||||
if mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
|
||||
.map_err(|e| ApiError::from_mst_error("read MST for applyWrites create", &e))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
@@ -90,7 +92,7 @@ async fn process_single_write(
|
||||
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 {
|
||||
@@ -117,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 {
|
||||
@@ -134,9 +138,7 @@ async fn process_single_write(
|
||||
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 update target from MST", &e))?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidRequest("Update target record does not exist".into())
|
||||
})?;
|
||||
@@ -151,7 +153,7 @@ async fn process_single_write(
|
||||
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));
|
||||
@@ -180,16 +182,14 @@ async fn process_single_write(
|
||||
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 {
|
||||
@@ -232,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 {
|
||||
@@ -346,24 +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,
|
||||
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,21 +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,
|
||||
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,17 +93,17 @@ pub async fn delete_record(
|
||||
prev: RecordCid::from(prev_record_cid),
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -99,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,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,7 +14,7 @@ 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};
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
@@ -183,7 +217,7 @@ pub async fn create_record(
|
||||
if mst
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
|
||||
.map_err(|e| ApiError::from_mst_error("read MST for create existence check", &e))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::InvalidRequest(format!(
|
||||
@@ -202,7 +236,7 @@ pub async fn create_record(
|
||||
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(),
|
||||
@@ -212,18 +246,18 @@ pub async fn create_record(
|
||||
|
||||
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,
|
||||
@@ -238,7 +272,7 @@ pub async fn create_record(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(CreateRecordOutput {
|
||||
Ok(CreateRecordOutput {
|
||||
uri: created_uri,
|
||||
cid: record_cid.to_string(),
|
||||
commit: CommitInfo {
|
||||
@@ -246,7 +280,7 @@ pub async fn create_record(
|
||||
rev: commit_result.rev,
|
||||
},
|
||||
validation_status,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -278,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
|
||||
@@ -304,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(),
|
||||
@@ -314,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()))?;
|
||||
@@ -325,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(),
|
||||
@@ -352,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(),
|
||||
@@ -366,14 +421,14 @@ pub async fn put_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,
|
||||
@@ -388,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 {
|
||||
@@ -396,5 +451,5 @@ pub async fn put_record(
|
||||
rev: commit_result.rev,
|
||||
}),
|
||||
validation_status,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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());
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
|
||||
@@ -119,26 +120,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
|
||||
@@ -241,15 +224,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,
|
||||
) {
|
||||
@@ -343,11 +320,7 @@ pub async fn create_passkey_account(
|
||||
commit_cid: 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 +332,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));
|
||||
|
||||
@@ -13,10 +13,6 @@ use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::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")]
|
||||
@@ -70,12 +66,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 +83,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 +130,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));
|
||||
|
||||
@@ -15,7 +15,10 @@ 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_types::TokenId;
|
||||
@@ -168,17 +171,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
|
||||
{
|
||||
@@ -285,7 +306,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,
|
||||
@@ -431,7 +452,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;
|
||||
@@ -484,16 +510,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
|
||||
@@ -503,9 +528,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);
|
||||
@@ -551,6 +585,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(),
|
||||
@@ -558,22 +593,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(
|
||||
@@ -584,12 +624,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_deref(),
|
||||
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)) => {
|
||||
@@ -600,10 +779,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(),
|
||||
@@ -616,7 +795,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) => {
|
||||
@@ -987,7 +1166,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);
|
||||
|
||||
@@ -6,8 +6,8 @@ use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{
|
||||
Active, Auth, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
|
||||
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
|
||||
is_backup_code_format, require_legacy_session_mfa, verify_backup_code, verify_password_mfa,
|
||||
verify_totp_code, verify_totp_mfa,
|
||||
is_backup_code_format, verify_backup_code, verify_password_mfa, verify_totp_code,
|
||||
verify_totp_mfa,
|
||||
};
|
||||
use tranquil_pds::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
|
||||
use tranquil_pds::state::AppState;
|
||||
@@ -163,11 +163,9 @@ pub async fn disable_totp(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<DisableTotpInput>,
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
let session_mfa = require_legacy_session_mfa(&state, &auth).await?;
|
||||
|
||||
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
|
||||
&state,
|
||||
session_mfa.did(),
|
||||
&auth.did,
|
||||
"Too many verification attempts. Please try again in a few minutes.",
|
||||
)
|
||||
.await?;
|
||||
@@ -184,7 +182,7 @@ pub async fn disable_totp(
|
||||
|
||||
tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &auth.did).await;
|
||||
|
||||
info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
|
||||
info!(did = %password_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
|
||||
|
||||
Ok(Json(EmptyResponse {}))
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -4,7 +4,7 @@ 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;
|
||||
@@ -35,7 +35,7 @@ pub fn create_access_token_with_scope_metadata(
|
||||
scope,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
hostname,
|
||||
)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ pub fn create_access_token_with_delegation(
|
||||
scope,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
act,
|
||||
hostname,
|
||||
)
|
||||
@@ -69,11 +69,59 @@ 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: &str,
|
||||
key_bytes: &[u8],
|
||||
scopes: Option<&str>,
|
||||
controller_did: Option<&str>,
|
||||
hostname: Option<&str>,
|
||||
jti: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<String> {
|
||||
let scope = scopes.unwrap_or(TokenScope::Access.as_str());
|
||||
let act = controller_did.map(|c| ActClaim { sub: c.to_string() });
|
||||
Ok(create_signed_token_pinned(
|
||||
did,
|
||||
scope,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
expires_at,
|
||||
jti.to_string(),
|
||||
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: &str,
|
||||
key_bytes: &[u8],
|
||||
jti: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<String> {
|
||||
Ok(create_signed_token_pinned(
|
||||
did,
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
key_bytes,
|
||||
expires_at,
|
||||
jti.to_string(),
|
||||
None,
|
||||
None,
|
||||
)?
|
||||
.token)
|
||||
}
|
||||
|
||||
pub fn create_service_token(
|
||||
did: &str,
|
||||
aud: &str,
|
||||
@@ -122,14 +170,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 = 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: &str,
|
||||
scope: &str,
|
||||
typ: TokenType,
|
||||
key_bytes: &[u8],
|
||||
expires_at: DateTime<Utc>,
|
||||
jti: String,
|
||||
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()
|
||||
@@ -198,7 +259,7 @@ pub fn create_access_token_hs256_with_metadata(
|
||||
TokenScope::Access.as_str(),
|
||||
TokenType::Access,
|
||||
secret,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -211,7 +272,7 @@ pub fn create_refresh_token_hs256_with_metadata(
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
secret,
|
||||
Duration::days(14),
|
||||
Duration::days(90),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +292,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!(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -128,9 +129,10 @@ fn build_smarthost(
|
||||
|
||||
fn build_direct_mx(cfg: &tranquil_config::TranquilConfig) -> Result<SendMode, SendError> {
|
||||
let helo = resolve_helo(cfg)?;
|
||||
let resolver = TokioAsyncResolver::tokio_from_system_conf()
|
||||
.map(Arc::new)
|
||||
.map_err(|e| config_invalid("system DNS configuration", e))?;
|
||||
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,
|
||||
|
||||
@@ -259,6 +259,9 @@ impl TranquilConfig {
|
||||
// -- tls --------------------------------------------------------------
|
||||
self.server.tls.validate(&mut errors);
|
||||
|
||||
// -- cache ------------------------------------------------------------
|
||||
self.cache.validate(&mut errors);
|
||||
|
||||
// -- SSO providers ----------------------------------------------------
|
||||
self.validate_sso_provider("sso.github", &self.sso.github, &mut errors);
|
||||
self.validate_sso_provider("sso.google", &self.sso.google, &mut errors);
|
||||
@@ -502,6 +505,7 @@ pub struct ServerConfig {
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
#[config(layer_attr(serde(deny_unknown_fields)))]
|
||||
pub struct TlsConfig {
|
||||
/// The path to the TLS cert chain.
|
||||
/// If you set both this and `key_path`, the server terminates TLS itself rather than expecting
|
||||
@@ -512,6 +516,11 @@ pub struct TlsConfig {
|
||||
/// Path to the TLS private key.
|
||||
#[config(env = "TLS_KEY_PATH")]
|
||||
pub key_path: Option<String>,
|
||||
|
||||
/// Serve HTTP/3 over QUIC on the same UDP port as the TCP listener.
|
||||
/// Requires cert_path and key_path.
|
||||
#[config(env = "TLS_HTTP3", default = false)]
|
||||
pub http3: bool,
|
||||
}
|
||||
|
||||
impl TlsConfig {
|
||||
@@ -531,6 +540,13 @@ impl TlsConfig {
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if self.http3 && self.material().is_none() {
|
||||
errors.push(
|
||||
"server.tls.http3 (TLS_HTTP3) requires server.tls.cert_path \
|
||||
and erver.tls.key_path"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,7 +632,9 @@ pub struct SecretsConfig {
|
||||
#[config(env = "MASTER_KEY")]
|
||||
pub master_key: Option<String>,
|
||||
|
||||
/// PLC rotation key (DID key). If not set, user-level keys are used.
|
||||
/// Optional operator-held PLC recovery key, as a public `did:key`. The PDS
|
||||
/// continues to sign PLC operations with the per-account signing key, which
|
||||
/// always remains in `rotationKeys`.
|
||||
#[config(env = "PLC_ROTATION_KEY")]
|
||||
pub plc_rotation_key: Option<String>,
|
||||
|
||||
@@ -773,6 +791,31 @@ pub struct CacheConfig {
|
||||
pub ripple: RippleCacheConfig,
|
||||
}
|
||||
|
||||
impl CacheConfig {
|
||||
pub fn validate(&self, errors: &mut Vec<String>) {
|
||||
let clustered = self
|
||||
.ripple
|
||||
.peers
|
||||
.as_deref()
|
||||
.unwrap_or(&[])
|
||||
.iter()
|
||||
.any(|p| !p.trim().is_empty());
|
||||
let keyed = self
|
||||
.ripple
|
||||
.cluster_key
|
||||
.as_deref()
|
||||
.is_some_and(|k| !k.trim().is_empty());
|
||||
if self.backend == "ripple" && clustered && !keyed && !self.ripple.allow_insecure {
|
||||
errors.push(
|
||||
"cache.ripple.peers (RIPPLE_PEERS) is set without cache.ripple.cluster_key \
|
||||
(RIPPLE_CLUSTER_KEY); set the cluster key to authenticate peers, or set \
|
||||
cache.ripple.allow_insecure (RIPPLE_ALLOW_INSECURE) for a trusted private network"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
#[config(layer_attr(serde(deny_unknown_fields)))]
|
||||
pub struct PlcConfig {
|
||||
@@ -1431,7 +1474,9 @@ fn split_comma_list(value: &str) -> Result<Vec<String>, std::convert::Infallible
|
||||
#[derive(Debug, Config)]
|
||||
#[config(layer_attr(serde(deny_unknown_fields)))]
|
||||
pub struct RippleCacheConfig {
|
||||
/// Address to bind the Ripple gossip protocol listener.
|
||||
/// Address to bind the Ripple gossip protocol listener. With the default
|
||||
/// value and no cluster_key or peers configured, the listener binds
|
||||
/// loopback instead and runs as a single node.
|
||||
#[config(env = "RIPPLE_BIND", default = "0.0.0.0:0")]
|
||||
pub bind_addr: String,
|
||||
|
||||
@@ -1450,6 +1495,16 @@ pub struct RippleCacheConfig {
|
||||
/// Maximum cache size in megabytes.
|
||||
#[config(env = "RIPPLE_CACHE_MAX_MB", default = 256)]
|
||||
pub cache_max_mb: usize,
|
||||
|
||||
/// Pre-shared cluster key authenticating ripple peers. Every node in the
|
||||
/// cluster must set the same value. When unset, peers are unauthenticated.
|
||||
#[config(env = "RIPPLE_CLUSTER_KEY")]
|
||||
pub cluster_key: Option<String>,
|
||||
|
||||
/// Allow ripple to bind a non-loopback address without a cluster key.
|
||||
/// Peers will be unauthenticated. Intended for trusted private networks.
|
||||
#[config(env = "RIPPLE_ALLOW_INSECURE", default = false)]
|
||||
pub allow_insecure: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
@@ -1638,10 +1693,8 @@ not_a_real_field = "oops"
|
||||
|
||||
#[test]
|
||||
fn load_accepts_known_keys() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"tranquil-config-known-keys-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("tranquil-config-known-keys-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("mkdir tempdir");
|
||||
let path = dir.join("config.toml");
|
||||
std::fs::write(
|
||||
@@ -1800,6 +1853,7 @@ port = 587
|
||||
TlsConfig {
|
||||
cert_path: None,
|
||||
key_path: None,
|
||||
http3: false,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
|
||||
@@ -1811,6 +1865,7 @@ port = 587
|
||||
TlsConfig {
|
||||
cert_path: Some("/etc/tranquil/cert.pem".to_string()),
|
||||
key_path: Some("/etc/tranquil/key.pem".to_string()),
|
||||
http3: false,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
|
||||
@@ -1822,6 +1877,7 @@ port = 587
|
||||
TlsConfig {
|
||||
cert_path: Some("/etc/tranquil/cert.pem".to_string()),
|
||||
key_path: None,
|
||||
http3: false,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(
|
||||
@@ -1830,6 +1886,67 @@ port = 587
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_validate_rejects_http3_without_material() {
|
||||
let mut errors = Vec::new();
|
||||
TlsConfig {
|
||||
cert_path: None,
|
||||
key_path: None,
|
||||
http3: true,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(
|
||||
errors.iter().any(|e| e.contains("http3")),
|
||||
"expected http3 error, got {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn cache_config_for_test(
|
||||
peers: Option<Vec<String>>,
|
||||
cluster_key: Option<&str>,
|
||||
allow_insecure: bool,
|
||||
) -> CacheConfig {
|
||||
CacheConfig {
|
||||
backend: "ripple".to_string(),
|
||||
valkey_url: None,
|
||||
ripple: RippleCacheConfig {
|
||||
bind_addr: "0.0.0.0:0".to_string(),
|
||||
peers,
|
||||
machine_id: None,
|
||||
gossip_interval_ms: 200,
|
||||
cache_max_mb: 256,
|
||||
cluster_key: cluster_key.map(str::to_string),
|
||||
allow_insecure,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_validate_rejects_clustered_keyless_ripple() {
|
||||
let mut errors = Vec::new();
|
||||
cache_config_for_test(Some(vec!["10.0.0.7:7000".to_string()]), None, false)
|
||||
.validate(&mut errors);
|
||||
assert!(
|
||||
errors.iter().any(|e| e.contains("RIPPLE_CLUSTER_KEY")),
|
||||
"expected cluster key error, got {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_validate_accepts_keyed_insecure_or_standalone() {
|
||||
let mut errors = Vec::new();
|
||||
cache_config_for_test(
|
||||
Some(vec!["10.0.0.7:7000".to_string()]),
|
||||
Some("nautilus-secret"),
|
||||
false,
|
||||
)
|
||||
.validate(&mut errors);
|
||||
cache_config_for_test(Some(vec!["10.0.0.7:7000".to_string()]), None, true)
|
||||
.validate(&mut errors);
|
||||
cache_config_for_test(None, None, false).validate(&mut errors);
|
||||
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EmailOverrides {
|
||||
from_address: Option<&'static str>,
|
||||
|
||||
@@ -268,17 +268,6 @@ pub trait InfraRepository: Send + Sync {
|
||||
code: &'a str,
|
||||
) -> Result<ValidatedInviteCode<'a>, InviteCodeError>;
|
||||
|
||||
async fn decrement_invite_code_uses(
|
||||
&self,
|
||||
code: &ValidatedInviteCode<'_>,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn record_invite_code_use(
|
||||
&self,
|
||||
code: &ValidatedInviteCode<'_>,
|
||||
used_by_user: Uuid,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn get_invite_codes_for_account(
|
||||
&self,
|
||||
for_account: &Did,
|
||||
|
||||
@@ -43,7 +43,8 @@ pub use repo::{
|
||||
pub use scope::{DbScope, InvalidScopeError};
|
||||
pub use sequence::{SequenceNumber, deserialize_optional_sequence};
|
||||
pub use session::{
|
||||
AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, LoginType, RefreshSessionResult,
|
||||
AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, LoginType,
|
||||
REFRESH_GRACE_PERIOD_SECS, RefreshGraceLookup, RefreshGraceReplay, RefreshSessionResult,
|
||||
SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, SessionRefreshData,
|
||||
SessionRepository, SessionToken, SessionTokenCreate,
|
||||
};
|
||||
|
||||
@@ -319,7 +319,6 @@ pub struct ApplyCommitInput {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApplyCommitResult {
|
||||
pub seq: i64,
|
||||
pub is_account_active: bool,
|
||||
}
|
||||
|
||||
@@ -445,19 +444,15 @@ pub trait RepoRepository: Send + Sync {
|
||||
|
||||
async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>;
|
||||
|
||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError>;
|
||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<(), DbError>;
|
||||
|
||||
async fn insert_identity_event(
|
||||
&self,
|
||||
did: &Did,
|
||||
handle: Option<&Handle>,
|
||||
) -> Result<SequenceNumber, DbError>;
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn insert_account_event(
|
||||
&self,
|
||||
did: &Did,
|
||||
status: AccountStatus,
|
||||
) -> Result<SequenceNumber, DbError>;
|
||||
async fn insert_account_event(&self, did: &Did, status: AccountStatus) -> Result<(), DbError>;
|
||||
|
||||
async fn insert_sync_event(
|
||||
&self,
|
||||
@@ -465,7 +460,7 @@ pub trait RepoRepository: Send + Sync {
|
||||
commit_cid: &CidLink,
|
||||
rev: Option<&str>,
|
||||
commit_bytes: &[u8],
|
||||
) -> Result<SequenceNumber, DbError>;
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn insert_genesis_commit_event(
|
||||
&self,
|
||||
@@ -475,14 +470,18 @@ pub trait RepoRepository: Send + Sync {
|
||||
rev: &str,
|
||||
commit_bytes: &[u8],
|
||||
mst_root_bytes: &[u8],
|
||||
) -> Result<SequenceNumber, DbError>;
|
||||
|
||||
async fn delete_sequences_except(
|
||||
&self,
|
||||
did: &Did,
|
||||
keep_seq: SequenceNumber,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn purge_did_events_keeping_latest(&self, did: &Did) -> Result<(), DbError>;
|
||||
|
||||
async fn assign_pending_sequences(&self) -> Result<u64, DbError> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn flush_pending_sequences(&self) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn prune_events_older_than(&self, cutoff: DateTime<Utc>) -> Result<PruneCount, DbError>;
|
||||
|
||||
async fn get_max_seq(&self) -> Result<SequenceNumber, DbError>;
|
||||
@@ -526,8 +525,6 @@ pub trait RepoRepository: Send + Sync {
|
||||
async fn get_repo_root_cid_by_user_id(&self, user_id: Uuid)
|
||||
-> Result<Option<CidLink>, DbError>;
|
||||
|
||||
async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError>;
|
||||
|
||||
async fn import_repo_data(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
@@ -563,5 +560,5 @@ pub trait RepoEventNotifier: Send + Sync {
|
||||
|
||||
#[async_trait]
|
||||
pub trait RepoEventReceiver: Send {
|
||||
async fn recv(&mut self) -> Option<i64>;
|
||||
async fn recv(&mut self) -> Option<()>;
|
||||
}
|
||||
|
||||
@@ -158,15 +158,53 @@ pub struct SessionMfaStatus {
|
||||
pub last_reauth_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Window during which a just-rotated refresh token may still be presented by a
|
||||
/// benignly-racing or retrying client without being treated as a compromise.
|
||||
/// The window is measured per rotated token from its own rotation time
|
||||
/// (`used_refresh_tokens.used_at` / the metastore marker's `rotated_at_ms`),
|
||||
/// matching the reference atproto PDS `REFRESH_GRACE_MS`
|
||||
/// (`account-manager.ts::rotateRefreshToken`), which shortens a rotated refresh
|
||||
/// token's lifetime to a 2-hour revocation grace period.
|
||||
pub const REFRESH_GRACE_PERIOD_SECS: i64 = 2 * 60 * 60;
|
||||
|
||||
/// The session's current token identity (plus its signing key) needed to re-mint
|
||||
/// the access/refresh JWTs during a grace-window replay. We carry the key so the
|
||||
/// caller can verify the presented token's signature before issuing anything.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefreshGraceReplay {
|
||||
pub did: Did,
|
||||
pub scope: Option<String>,
|
||||
pub controller_did: Option<Did>,
|
||||
pub access_jti: String,
|
||||
pub refresh_jti: String,
|
||||
pub access_expires_at: DateTime<Utc>,
|
||||
pub refresh_expires_at: DateTime<Utc>,
|
||||
pub key_bytes: Vec<u8>,
|
||||
pub encryption_version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RefreshSessionResult {
|
||||
Success,
|
||||
TokenAlreadyUsed,
|
||||
ConcurrentRefresh,
|
||||
GraceReplay(RefreshGraceReplay),
|
||||
Compromise,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RefreshGraceLookup {
|
||||
NotUsed,
|
||||
Replay(RefreshGraceReplay),
|
||||
Compromised {
|
||||
did: Did,
|
||||
session_id: SessionId,
|
||||
key_bytes: Vec<u8>,
|
||||
encryption_version: i32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRefreshData {
|
||||
pub did: Did,
|
||||
pub old_refresh_jti: String,
|
||||
pub session_id: SessionId,
|
||||
pub new_access_jti: String,
|
||||
@@ -189,18 +227,17 @@ pub trait SessionRepository: Send + Sync {
|
||||
refresh_jti: &str,
|
||||
) -> Result<Option<SessionForRefresh>, DbError>;
|
||||
|
||||
async fn update_session_tokens(
|
||||
async fn delete_session_by_access_jti(
|
||||
&self,
|
||||
access_jti: &str,
|
||||
did: &Did,
|
||||
) -> Result<u64, DbError>;
|
||||
|
||||
async fn delete_session_by_id(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
new_access_jti: &str,
|
||||
new_refresh_jti: &str,
|
||||
new_access_expires_at: DateTime<Utc>,
|
||||
new_refresh_expires_at: DateTime<Utc>,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn delete_session_by_access_jti(&self, access_jti: &str) -> Result<u64, DbError>;
|
||||
|
||||
async fn delete_session_by_id(&self, session_id: SessionId) -> Result<u64, DbError>;
|
||||
did: &Did,
|
||||
) -> Result<u64, DbError>;
|
||||
|
||||
async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>;
|
||||
|
||||
@@ -230,16 +267,7 @@ pub trait SessionRepository: Send + Sync {
|
||||
app_password_name: &str,
|
||||
) -> Result<Vec<String>, DbError>;
|
||||
|
||||
async fn check_refresh_token_used(
|
||||
&self,
|
||||
refresh_jti: &str,
|
||||
) -> Result<Option<SessionId>, DbError>;
|
||||
|
||||
async fn mark_refresh_token_used(
|
||||
&self,
|
||||
refresh_jti: &str,
|
||||
session_id: SessionId,
|
||||
) -> Result<bool, DbError>;
|
||||
async fn lookup_refresh_grace(&self, refresh_jti: &str) -> Result<RefreshGraceLookup, DbError>;
|
||||
|
||||
async fn list_app_passwords(&self, user_id: Uuid) -> Result<Vec<AppPasswordRecord>, DbError>;
|
||||
|
||||
|
||||
@@ -551,7 +551,7 @@ pub trait UserRepository: Send + Sync {
|
||||
limit: i64,
|
||||
) -> Result<Vec<ScheduledDeletionAccount>, DbError>;
|
||||
|
||||
async fn delete_account_with_firehose(&self, user_id: Uuid, did: &Did) -> Result<i64, DbError>;
|
||||
async fn delete_account_with_firehose(&self, user_id: Uuid, did: &Did) -> Result<(), DbError>;
|
||||
|
||||
async fn create_password_account(
|
||||
&self,
|
||||
@@ -589,8 +589,6 @@ pub trait UserRepository: Send + Sync {
|
||||
|
||||
async fn cleanup_expired_handle_reservations(&self) -> Result<u64, DbError>;
|
||||
|
||||
async fn check_and_consume_invite_code(&self, code: &str) -> Result<bool, DbError>;
|
||||
|
||||
async fn complete_passkey_setup(
|
||||
&self,
|
||||
input: &CompletePasskeySetupInput,
|
||||
@@ -1019,6 +1017,7 @@ pub enum CreateAccountError {
|
||||
EmailTaken,
|
||||
DidExists,
|
||||
InvalidToken,
|
||||
InviteCodeUnavailable,
|
||||
Database(String),
|
||||
}
|
||||
|
||||
@@ -1034,7 +1033,6 @@ pub struct CreateDelegatedAccountInput {
|
||||
pub commit_cid: String,
|
||||
pub repo_rev: String,
|
||||
pub genesis_block_cids: Vec<Vec<u8>>,
|
||||
pub invite_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -35,9 +35,9 @@ pub struct PostgresRepoEventReceiver {
|
||||
|
||||
#[async_trait]
|
||||
impl RepoEventReceiver for PostgresRepoEventReceiver {
|
||||
async fn recv(&mut self) -> Option<i64> {
|
||||
async fn recv(&mut self) -> Option<()> {
|
||||
match self.listener.recv().await {
|
||||
Ok(notification) => notification.payload().parse().ok(),
|
||||
Ok(_) => Some(()),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,38 +225,6 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async fn decrement_invite_code_uses(
|
||||
&self,
|
||||
code: &ValidatedInviteCode<'_>,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
|
||||
code.code()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_invite_code_use(
|
||||
&self,
|
||||
code: &ValidatedInviteCode<'_>,
|
||||
used_by_user: Uuid,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
|
||||
code.code(),
|
||||
used_by_user
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_invite_codes_for_account(
|
||||
&self,
|
||||
for_account: &Did,
|
||||
|
||||
@@ -111,6 +111,64 @@ fn map_sequenced_row(r: SequencedEventRow) -> Result<SequencedEvent, DbError> {
|
||||
})
|
||||
}
|
||||
|
||||
const SEQUENCER_LOCK_KEY: i64 = 0x0074_7261_6e73_6571;
|
||||
const SEQUENCER_BATCH_SIZE: i64 = 1000;
|
||||
|
||||
async fn notify_repo_pending(pool: &PgPool) {
|
||||
if let Err(e) = sqlx::query!("NOTIFY repo_updates").execute(pool).await {
|
||||
tracing::warn!(error = %e, "failed to notify repo_updates listeners");
|
||||
}
|
||||
}
|
||||
|
||||
async fn assign_one_batch(
|
||||
mut tx: sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
pool: &PgPool,
|
||||
) -> Result<i64, DbError> {
|
||||
let pending_ids: Vec<i64> = sqlx::query_scalar!(
|
||||
r#"SELECT id as "id!" FROM repo_seq WHERE seq IS NULL ORDER BY id LIMIT $1"#,
|
||||
SEQUENCER_BATCH_SIZE
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
let count = pending_ids.len() as i64;
|
||||
if count == 0 {
|
||||
tx.commit().await.map_err(map_sqlx_error)?;
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let start = sqlx::query_scalar!(r#"SELECT nextval('firehose_seq') as "seq!""#)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
if count > 1 {
|
||||
sqlx::query!("SELECT setval('firehose_seq', $1)", start + count - 1)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
}
|
||||
|
||||
let assigned = sqlx::query!(
|
||||
r#"UPDATE repo_seq r
|
||||
SET seq = $1::bigint + (o.ord - 1)
|
||||
FROM unnest($2::bigint[]) WITH ORDINALITY AS o(id, ord)
|
||||
WHERE r.id = o.id"#,
|
||||
start,
|
||||
&pending_ids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?
|
||||
.rows_affected();
|
||||
|
||||
tx.commit().await.map_err(map_sqlx_error)?;
|
||||
if assigned > 0 {
|
||||
notify_repo_pending(pool).await;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub struct PostgresRepoRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
@@ -716,13 +774,12 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
Ok(rows.into_iter().map(|(cid,)| cid).collect())
|
||||
}
|
||||
|
||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError> {
|
||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<(), DbError> {
|
||||
let (block_cids, block_data) = inline_to_paired_blocks(data.blocks.as_deref());
|
||||
let seq = sqlx::query_scalar!(
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING seq
|
||||
"#,
|
||||
data.did.as_str(),
|
||||
data.event_type.as_str(),
|
||||
@@ -735,67 +792,54 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
data.prev_data_cid.as_ref().map(|c| c.as_str()),
|
||||
data.rev
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(seq.into())
|
||||
notify_repo_pending(&self.pool).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_identity_event(
|
||||
&self,
|
||||
did: &Did,
|
||||
handle: Option<&Handle>,
|
||||
) -> Result<SequenceNumber, DbError> {
|
||||
) -> Result<(), DbError> {
|
||||
let handle_str = handle.map(|h| h.as_str());
|
||||
let seq = sqlx::query_scalar!(
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO repo_seq (did, event_type, handle)
|
||||
VALUES ($1, 'identity', $2)
|
||||
RETURNING seq
|
||||
"#,
|
||||
did.as_str(),
|
||||
handle_str
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(seq.into())
|
||||
notify_repo_pending(&self.pool).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_account_event(
|
||||
&self,
|
||||
did: &Did,
|
||||
status: AccountStatus,
|
||||
) -> Result<SequenceNumber, DbError> {
|
||||
async fn insert_account_event(&self, did: &Did, status: AccountStatus) -> Result<(), DbError> {
|
||||
let active = status.is_active();
|
||||
let status_str = status.for_firehose();
|
||||
let seq = sqlx::query_scalar!(
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO repo_seq (did, event_type, active, status)
|
||||
VALUES ($1, 'account', $2, $3)
|
||||
RETURNING seq
|
||||
"#,
|
||||
did.as_str(),
|
||||
active,
|
||||
status_str
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(seq.into())
|
||||
notify_repo_pending(&self.pool).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_sync_event(
|
||||
@@ -804,18 +848,17 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
commit_cid: &CidLink,
|
||||
rev: Option<&str>,
|
||||
commit_bytes: &[u8],
|
||||
) -> Result<SequenceNumber, DbError> {
|
||||
) -> Result<(), DbError> {
|
||||
let cid_bytes = commit_cid
|
||||
.to_cid()
|
||||
.map(|c| c.to_bytes())
|
||||
.unwrap_or_default();
|
||||
let block_cids: Vec<Vec<u8>> = vec![cid_bytes];
|
||||
let block_data: Vec<Vec<u8>> = vec![commit_bytes.to_vec()];
|
||||
let seq = sqlx::query_scalar!(
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data)
|
||||
VALUES ($1, 'sync', $2, $3, $4, $5)
|
||||
RETURNING seq
|
||||
"#,
|
||||
did.as_str(),
|
||||
commit_cid.as_str(),
|
||||
@@ -823,16 +866,12 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
&block_cids as &[Vec<u8>],
|
||||
&block_data as &[Vec<u8>]
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(seq.into())
|
||||
notify_repo_pending(&self.pool).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_genesis_commit_event(
|
||||
@@ -843,7 +882,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
rev: &str,
|
||||
commit_bytes: &[u8],
|
||||
mst_root_bytes: &[u8],
|
||||
) -> Result<SequenceNumber, DbError> {
|
||||
) -> Result<(), DbError> {
|
||||
let ops = serde_json::json!([]);
|
||||
let blobs: Vec<String> = vec![];
|
||||
let commit_cid_bytes = commit_cid
|
||||
@@ -858,11 +897,10 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
let block_data: Vec<Vec<u8>> = vec![commit_bytes.to_vec(), mst_root_bytes.to_vec()];
|
||||
let prev_cid: Option<&str> = None;
|
||||
|
||||
let seq = sqlx::query_scalar!(
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev)
|
||||
VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8)
|
||||
RETURNING seq
|
||||
"#,
|
||||
did.as_str(),
|
||||
commit_cid.as_str(),
|
||||
@@ -873,27 +911,22 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
&block_data as &[Vec<u8>],
|
||||
rev
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(seq.into())
|
||||
notify_repo_pending(&self.pool).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_sequences_except(
|
||||
&self,
|
||||
did: &Did,
|
||||
keep_seq: SequenceNumber,
|
||||
) -> Result<(), DbError> {
|
||||
async fn purge_did_events_keeping_latest(&self, did: &Did) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"DELETE FROM repo_seq WHERE did = $1 AND seq != $2",
|
||||
did.as_str(),
|
||||
keep_seq.as_i64()
|
||||
r#"
|
||||
DELETE FROM repo_seq
|
||||
WHERE did = $1
|
||||
AND id <> (SELECT id FROM repo_seq WHERE did = $1 ORDER BY id DESC LIMIT 1)
|
||||
"#,
|
||||
did.as_str()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -902,6 +935,42 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assign_pending_sequences(&self) -> Result<u64, DbError> {
|
||||
let mut total: u64 = 0;
|
||||
loop {
|
||||
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||
let acquired = sqlx::query_scalar!(
|
||||
r#"SELECT pg_try_advisory_xact_lock($1) as "locked!""#,
|
||||
SEQUENCER_LOCK_KEY
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
if !acquired {
|
||||
return Ok(total);
|
||||
}
|
||||
let count = assign_one_batch(tx, &self.pool).await?;
|
||||
total += count as u64;
|
||||
if count < SEQUENCER_BATCH_SIZE {
|
||||
return Ok(total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn flush_pending_sequences(&self) -> Result<(), DbError> {
|
||||
loop {
|
||||
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||
sqlx::query!("SELECT pg_advisory_xact_lock($1)", SEQUENCER_LOCK_KEY)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
let count = assign_one_batch(tx, &self.pool).await?;
|
||||
if count < SEQUENCER_BATCH_SIZE {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn prune_events_older_than(&self, cutoff: DateTime<Utc>) -> Result<PruneCount, DbError> {
|
||||
let result = sqlx::query!("DELETE FROM repo_seq WHERE created_at < $1", cutoff)
|
||||
.execute(&self.pool)
|
||||
@@ -965,7 +1034,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
Some(lim) => {
|
||||
let rows = sqlx::query_as!(
|
||||
SequencedEventRow,
|
||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
||||
FROM repo_seq
|
||||
WHERE seq > $1
|
||||
@@ -982,7 +1051,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
None => {
|
||||
let rows = sqlx::query_as!(
|
||||
SequencedEventRow,
|
||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
||||
FROM repo_seq
|
||||
WHERE seq > $1
|
||||
@@ -1004,7 +1073,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
) -> Result<Vec<SequencedEvent>, DbError> {
|
||||
let rows = sqlx::query_as!(
|
||||
SequencedEventRow,
|
||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
||||
FROM repo_seq
|
||||
WHERE seq > $1 AND seq < $2
|
||||
@@ -1024,7 +1093,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
) -> Result<Option<SequencedEvent>, DbError> {
|
||||
let row = sqlx::query_as!(
|
||||
SequencedEventRow,
|
||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
||||
FROM repo_seq
|
||||
WHERE seq = $1"#,
|
||||
@@ -1043,7 +1112,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
) -> Result<Vec<SequencedEvent>, DbError> {
|
||||
let rows = sqlx::query_as!(
|
||||
SequencedEventRow,
|
||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
||||
FROM repo_seq
|
||||
WHERE seq > $1
|
||||
@@ -1104,14 +1173,6 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
Ok(cid.map(CidLink::from))
|
||||
}
|
||||
|
||||
async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError> {
|
||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq.as_i64()))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn import_repo_data(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
@@ -1402,28 +1463,27 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
|
||||
let event = input.commit_event;
|
||||
let (event_block_cids, event_block_data) = inline_into_paired_blocks(event.blocks);
|
||||
let seq: i64 = sqlx::query_scalar(
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING seq
|
||||
"#,
|
||||
event.did.as_str(),
|
||||
event.event_type.as_str(),
|
||||
event.commit_cid.as_ref().map(|c| c.as_str()),
|
||||
event.prev_cid.as_ref().map(|c| c.as_str()),
|
||||
event.ops,
|
||||
event.blobs.as_deref(),
|
||||
&event_block_cids as &[Vec<u8>],
|
||||
&event_block_data as &[Vec<u8>],
|
||||
event.prev_data_cid.as_ref().map(|c| c.as_str()),
|
||||
event.rev
|
||||
)
|
||||
.bind(event.did.as_str())
|
||||
.bind(event.event_type.as_str())
|
||||
.bind(event.commit_cid.as_ref().map(|c| c.as_str()))
|
||||
.bind(event.prev_cid.as_ref().map(|c| c.as_str()))
|
||||
.bind(&event.ops)
|
||||
.bind(&event.blobs)
|
||||
.bind(&event_block_cids)
|
||||
.bind(&event_block_data)
|
||||
.bind(event.prev_data_cid.as_ref().map(|c| c.as_str()))
|
||||
.bind(&event.rev)
|
||||
.fetch_one(&mut *tx)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
|
||||
|
||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
|
||||
sqlx::query!("NOTIFY repo_updates")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
|
||||
@@ -1432,10 +1492,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
|
||||
|
||||
Ok(tranquil_db_traits::ApplyCommitResult {
|
||||
seq,
|
||||
is_account_active,
|
||||
})
|
||||
Ok(tranquil_db_traits::ApplyCommitResult { is_account_active })
|
||||
}
|
||||
|
||||
async fn get_users_without_blocks(&self) -> Result<Vec<UserWithoutBlocks>, DbError> {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use sqlx::PgPool;
|
||||
use tranquil_db_traits::{
|
||||
AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, DbError, LoginType,
|
||||
RefreshSessionResult, SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus,
|
||||
SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate,
|
||||
REFRESH_GRACE_PERIOD_SECS, RefreshGraceLookup, RefreshGraceReplay, RefreshSessionResult,
|
||||
SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, SessionRefreshData,
|
||||
SessionRepository, SessionToken, SessionTokenCreate,
|
||||
};
|
||||
use tranquil_types::Did;
|
||||
use uuid::Uuid;
|
||||
@@ -113,38 +114,15 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update_session_tokens(
|
||||
async fn delete_session_by_access_jti(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
new_access_jti: &str,
|
||||
new_refresh_jti: &str,
|
||||
new_access_expires_at: DateTime<Utc>,
|
||||
new_refresh_expires_at: DateTime<Utc>,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE session_tokens
|
||||
SET access_jti = $1, refresh_jti = $2, access_expires_at = $3,
|
||||
refresh_expires_at = $4, updated_at = NOW()
|
||||
WHERE id = $5
|
||||
"#,
|
||||
new_access_jti,
|
||||
new_refresh_jti,
|
||||
new_access_expires_at,
|
||||
new_refresh_expires_at,
|
||||
session_id.as_i32()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_session_by_access_jti(&self, access_jti: &str) -> Result<u64, DbError> {
|
||||
access_jti: &str,
|
||||
did: &Did,
|
||||
) -> Result<u64, DbError> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM session_tokens WHERE access_jti = $1",
|
||||
access_jti
|
||||
"DELETE FROM session_tokens WHERE access_jti = $1 AND did = $2",
|
||||
access_jti,
|
||||
did.as_str()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -153,10 +131,15 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn delete_session_by_id(&self, session_id: SessionId) -> Result<u64, DbError> {
|
||||
async fn delete_session_by_id(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
did: &Did,
|
||||
) -> Result<u64, DbError> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM session_tokens WHERE id = $1",
|
||||
session_id.as_i32()
|
||||
"DELETE FROM session_tokens WHERE id = $1 AND did = $2",
|
||||
session_id.as_i32(),
|
||||
did.as_str()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -267,40 +250,52 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn check_refresh_token_used(
|
||||
&self,
|
||||
refresh_jti: &str,
|
||||
) -> Result<Option<SessionId>, DbError> {
|
||||
let row = sqlx::query_scalar!(
|
||||
"SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1",
|
||||
async fn lookup_refresh_grace(&self, refresh_jti: &str) -> Result<RefreshGraceLookup, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT u.used_at, st.id AS session_id, st.did, st.scope, st.controller_did,
|
||||
st.access_jti, st.refresh_jti, st.access_expires_at, st.refresh_expires_at,
|
||||
k.key_bytes, k.encryption_version
|
||||
FROM used_refresh_tokens u
|
||||
JOIN session_tokens st ON st.id = u.session_id
|
||||
JOIN users us ON st.did = us.did
|
||||
JOIN user_keys k ON us.id = k.user_id
|
||||
WHERE u.refresh_jti = $1
|
||||
"#,
|
||||
refresh_jti
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(SessionId::new))
|
||||
}
|
||||
// No marker (or a missing users/user_keys join row) degrades to NotUsed.
|
||||
// That is safe: the normal refresh path then fails closed with "Invalid
|
||||
// refresh token" without mutating any state.
|
||||
let Some(r) = row else {
|
||||
return Ok(RefreshGraceLookup::NotUsed);
|
||||
};
|
||||
|
||||
async fn mark_refresh_token_used(
|
||||
&self,
|
||||
refresh_jti: &str,
|
||||
session_id: SessionId,
|
||||
) -> Result<bool, DbError> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO used_refresh_tokens (refresh_jti, session_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (refresh_jti) DO NOTHING
|
||||
"#,
|
||||
refresh_jti,
|
||||
session_id.as_i32()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
let grace_cutoff = Utc::now() - Duration::seconds(REFRESH_GRACE_PERIOD_SECS);
|
||||
if r.used_at > grace_cutoff {
|
||||
Ok(RefreshGraceLookup::Replay(RefreshGraceReplay {
|
||||
did: Did::from(r.did),
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did.map(Did::from),
|
||||
access_jti: r.access_jti,
|
||||
refresh_jti: r.refresh_jti,
|
||||
access_expires_at: r.access_expires_at,
|
||||
refresh_expires_at: r.refresh_expires_at,
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version.unwrap_or(0),
|
||||
}))
|
||||
} else {
|
||||
Ok(RefreshGraceLookup::Compromised {
|
||||
did: Did::from(r.did),
|
||||
session_id: SessionId::new(r.session_id),
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version.unwrap_or(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_app_passwords(&self, user_id: Uuid) -> Result<Vec<AppPasswordRecord>, DbError> {
|
||||
@@ -524,21 +519,10 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
) -> Result<RefreshSessionResult, DbError> {
|
||||
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||
|
||||
if let Ok(Some(session_id)) = sqlx::query_scalar!(
|
||||
"SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1 FOR UPDATE",
|
||||
data.old_refresh_jti
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
{
|
||||
let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
tx.commit().await.map_err(map_sqlx_error)?;
|
||||
return Ok(RefreshSessionResult::TokenAlreadyUsed);
|
||||
}
|
||||
|
||||
let result = sqlx::query!(
|
||||
// Atomically claim the old refresh jti. The INSERT serializes concurrent
|
||||
// rotations of the same token: exactly one request inserts the row, the
|
||||
// rest see `rows_affected == 0`.
|
||||
let claimed = sqlx::query!(
|
||||
"INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING",
|
||||
data.old_refresh_jti,
|
||||
data.session_id.as_i32()
|
||||
@@ -547,19 +531,42 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
let _ = sqlx::query!(
|
||||
"DELETE FROM session_tokens WHERE id = $1",
|
||||
data.session_id.as_i32()
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
tx.commit().await.map_err(map_sqlx_error)?;
|
||||
return Ok(RefreshSessionResult::ConcurrentRefresh);
|
||||
if claimed.rows_affected() == 0 {
|
||||
// Another request already rotated this token. Nothing to write, so
|
||||
// end our transaction before reading the winner's committed row.
|
||||
tx.rollback().await.map_err(map_sqlx_error)?;
|
||||
|
||||
// Within the grace window (measured from this token's own rotation
|
||||
// time) we replay the session's current tokens so a benignly-racing
|
||||
// client keeps a working session instead of being revoked.
|
||||
match self.lookup_refresh_grace(&data.old_refresh_jti).await? {
|
||||
RefreshGraceLookup::Replay(replay) => {
|
||||
return Ok(RefreshSessionResult::GraceReplay(replay));
|
||||
}
|
||||
RefreshGraceLookup::Compromised { .. } | RefreshGraceLookup::NotUsed => {
|
||||
// Outside the grace window, or the marker/session vanished
|
||||
// concurrently: genuine reuse. Revoke the session (delete is
|
||||
// idempotent).
|
||||
sqlx::query!(
|
||||
"DELETE FROM session_tokens WHERE id = $1",
|
||||
data.session_id.as_i32()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
return Ok(RefreshSessionResult::Compromise);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We won the rotation.
|
||||
sqlx::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",
|
||||
r#"
|
||||
UPDATE session_tokens
|
||||
SET access_jti = $1, refresh_jti = $2, access_expires_at = $3,
|
||||
refresh_expires_at = $4, updated_at = NOW()
|
||||
WHERE id = $5
|
||||
"#,
|
||||
data.new_access_jti,
|
||||
data.new_refresh_jti,
|
||||
data.new_access_expires_at,
|
||||
|
||||
@@ -43,6 +43,38 @@ pub(crate) fn map_sqlx_error(e: sqlx::Error) -> DbError {
|
||||
}
|
||||
}
|
||||
|
||||
async fn consume_invite_code(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
code: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), tranquil_db_traits::CreateAccountError> {
|
||||
let map_err = |e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string());
|
||||
|
||||
let decremented = sqlx::query!(
|
||||
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1 AND available_uses > 0 AND COALESCE(disabled, false) = false",
|
||||
code
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.rows_affected();
|
||||
|
||||
if decremented == 0 {
|
||||
return Err(tranquil_db_traits::CreateAccountError::InviteCodeUnavailable);
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
|
||||
code,
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserRepository for PostgresUserRepository {
|
||||
async fn get_by_did(&self, did: &Did) -> Result<Option<UserRow>, DbError> {
|
||||
@@ -2291,7 +2323,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn delete_account_with_firehose(&self, user_id: Uuid, did: &Did) -> Result<i64, DbError> {
|
||||
async fn delete_account_with_firehose(&self, user_id: Uuid, did: &Did) -> Result<(), DbError> {
|
||||
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||
|
||||
sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id)
|
||||
@@ -2370,11 +2402,11 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
let account_seq: i64 = sqlx::query_scalar!(
|
||||
let event_id: i64 = sqlx::query_scalar!(
|
||||
r#"
|
||||
INSERT INTO repo_seq (did, event_type, active, status)
|
||||
VALUES ($1, 'account', false, 'deleted')
|
||||
RETURNING seq
|
||||
RETURNING id
|
||||
"#,
|
||||
did.as_str()
|
||||
)
|
||||
@@ -2383,9 +2415,9 @@ impl UserRepository for PostgresUserRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM repo_seq WHERE did = $1 AND seq != $2",
|
||||
"DELETE FROM repo_seq WHERE did = $1 AND id <> $2",
|
||||
did.as_str(),
|
||||
account_seq
|
||||
event_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
@@ -2393,12 +2425,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
tx.commit().await.map_err(map_sqlx_error)?;
|
||||
|
||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", account_seq))
|
||||
sqlx::query!("NOTIFY repo_updates")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(account_seq)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_password_account(
|
||||
@@ -2521,20 +2553,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
})?;
|
||||
|
||||
if let Some(code) = &input.invite_code {
|
||||
let _ = sqlx::query!(
|
||||
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
|
||||
code
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
let _ = sqlx::query!(
|
||||
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
|
||||
code,
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
consume_invite_code(&mut tx, code, user_id).await?;
|
||||
}
|
||||
|
||||
if let Some(birthdate_pref) = &input.birthdate_pref {
|
||||
@@ -2650,23 +2669,6 @@ impl UserRepository for PostgresUserRepository {
|
||||
tranquil_db_traits::CreateAccountError::Database(e.to_string())
|
||||
})?;
|
||||
|
||||
if let Some(code) = &input.invite_code {
|
||||
let _ = sqlx::query!(
|
||||
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
|
||||
code
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
let _ = sqlx::query!(
|
||||
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
|
||||
code,
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(|e: sqlx::Error| {
|
||||
tranquil_db_traits::CreateAccountError::Database(e.to_string())
|
||||
})?;
|
||||
@@ -2784,20 +2786,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
})?;
|
||||
|
||||
if let Some(code) = &input.invite_code {
|
||||
let _ = sqlx::query!(
|
||||
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
|
||||
code
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
let _ = sqlx::query!(
|
||||
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
|
||||
code,
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
consume_invite_code(&mut tx, code, user_id).await?;
|
||||
}
|
||||
|
||||
if let Some(birthdate_pref) = &input.birthdate_pref {
|
||||
@@ -2932,20 +2921,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
})?;
|
||||
|
||||
if let Some(code) = &input.invite_code {
|
||||
let _ = sqlx::query!(
|
||||
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
|
||||
code
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
let _ = sqlx::query!(
|
||||
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
|
||||
code,
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
consume_invite_code(&mut tx, code, user_id).await?;
|
||||
}
|
||||
|
||||
if let Some(birthdate_pref) = &input.birthdate_pref {
|
||||
@@ -3124,38 +3100,6 @@ impl UserRepository for PostgresUserRepository {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn check_and_consume_invite_code(&self, code: &str) -> Result<bool, DbError> {
|
||||
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||
|
||||
let invite = sqlx::query!(
|
||||
"SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE",
|
||||
code
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
let Some(row) = invite else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if row.available_uses <= 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
|
||||
code
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
tx.commit().await.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn complete_passkey_setup(
|
||||
&self,
|
||||
input: &tranquil_db_traits::CompletePasskeySetupInput,
|
||||
|
||||
@@ -85,7 +85,10 @@ pub fn nsid_to_authority(nsid: &str) -> Result<String, ResolveError> {
|
||||
}
|
||||
|
||||
pub async fn resolve_did_from_dns(authority: &str) -> Result<String, ResolveError> {
|
||||
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
|
||||
let resolver = 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 extract_did = |lookup: hickory_resolver::lookup::TxtLookup| -> Option<String> {
|
||||
lookup
|
||||
|
||||
@@ -351,34 +351,16 @@ pub async fn consent_post(
|
||||
} else {
|
||||
original_scope_str.to_string()
|
||||
};
|
||||
|
||||
let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
|
||||
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
|
||||
let user_denied_some_granular = has_granular_scopes
|
||||
&& requested_scopes
|
||||
.iter()
|
||||
.filter(|s| is_granular_scope(s))
|
||||
.any(|s| !form.approved_scopes.contains(&s.to_string()));
|
||||
let atproto_was_requested = requested_scopes.contains(&"atproto");
|
||||
if atproto_was_requested
|
||||
&& !has_granular_scopes
|
||||
&& !form.approved_scopes.contains(&"atproto".to_string())
|
||||
{
|
||||
if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"The atproto scope was requested and must be approved",
|
||||
);
|
||||
}
|
||||
let final_approved: Vec<String> = if user_denied_some_granular {
|
||||
form.approved_scopes
|
||||
.iter()
|
||||
.filter(|s| *s != "atproto")
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
form.approved_scopes.clone()
|
||||
};
|
||||
let final_approved: Vec<String> = form.approved_scopes.clone();
|
||||
if final_approved.is_empty() {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
|
||||
@@ -148,7 +148,14 @@ pub async fn handle_authorization_code_grant(
|
||||
.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(OAuthError::InvalidGrant(
|
||||
"Delegation grant not found or revoked".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let requested = authorized.parameters.scope.as_deref().unwrap_or("atproto");
|
||||
let intersected = intersect_scopes(requested, granted_scopes.as_str());
|
||||
(Some(intersected), Some(controller.clone()))
|
||||
|
||||
@@ -10,6 +10,7 @@ use tranquil_db_traits::{SsoAction, SsoProviderType};
|
||||
use tranquil_types::RequestId;
|
||||
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::api::invite::check_registration_invite;
|
||||
use tranquil_pds::auth::extractor::extract_auth_token_from_header;
|
||||
use tranquil_pds::auth::{generate_app_password, validate_bearer_token_cached};
|
||||
use tranquil_pds::rate_limit::{
|
||||
@@ -985,18 +986,8 @@ pub async fn complete_registration(
|
||||
None => None,
|
||||
};
|
||||
|
||||
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 invite_registration =
|
||||
check_registration_invite(&state, input.invite_code.as_deref()).await?;
|
||||
|
||||
let handle_typed: tranquil_pds::types::Handle =
|
||||
handle.parse().map_err(|_| ApiError::InvalidHandle(None))?;
|
||||
@@ -1052,15 +1043,9 @@ pub async fn complete_registration(
|
||||
d.to_string()
|
||||
}
|
||||
_ => {
|
||||
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 = match tranquil_pds::plc::create_genesis_operation(
|
||||
&signing_key,
|
||||
&rotation_key,
|
||||
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
|
||||
&handle,
|
||||
&pds_endpoint,
|
||||
) {
|
||||
@@ -1169,7 +1154,7 @@ pub async fn complete_registration(
|
||||
commit_cid: commit_cid.to_string(),
|
||||
repo_rev: rev.as_ref().to_string(),
|
||||
genesis_block_cids,
|
||||
invite_code: input.invite_code.clone(),
|
||||
invite_code: invite_registration.into_invite_code(),
|
||||
birthdate_pref,
|
||||
sso_provider: pending_preview.provider,
|
||||
sso_provider_user_id: pending_preview.provider_user_id.clone().into_inner(),
|
||||
@@ -1196,6 +1181,9 @@ pub async fn complete_registration(
|
||||
Err(tranquil_db_traits::CreateAccountError::InvalidToken) => {
|
||||
return Err(ApiError::SsoSessionExpired);
|
||||
}
|
||||
Err(tranquil_db_traits::CreateAccountError::InviteCodeUnavailable) => {
|
||||
return Err(ApiError::InvalidInviteCode);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error creating SSO account: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
|
||||
@@ -94,6 +94,8 @@ frontend = []
|
||||
native-tls-roots = ["tranquil-oauth/native-tls-roots"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
totp-rs = { workspace = true }
|
||||
ciborium = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
testcontainers = { workspace = true }
|
||||
|
||||
@@ -15,6 +15,7 @@ struct ErrorBody<'a> {
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
InternalError(Option<String>),
|
||||
RepoCorruption,
|
||||
AuthenticationRequired,
|
||||
AuthenticationFailed(Option<String>),
|
||||
InvalidRequest(String),
|
||||
@@ -121,10 +122,34 @@ pub enum ApiError {
|
||||
},
|
||||
}
|
||||
|
||||
const MST_NODE_MISSING_MARKER: &str = "MST node not found";
|
||||
|
||||
impl ApiError {
|
||||
pub fn is_repo_corruption(&self) -> bool {
|
||||
matches!(self, Self::RepoCorruption)
|
||||
}
|
||||
|
||||
pub fn detail_is_repo_corruption(detail: &str) -> bool {
|
||||
detail.contains(tranquil_store::blockstore::BLOCK_CORRUPTION_MARKER)
|
||||
|| detail.contains(MST_NODE_MISSING_MARKER)
|
||||
}
|
||||
|
||||
pub fn from_mst_error(context: &str, e: &jacquard_repo::error::RepoError) -> Self {
|
||||
let detail = format!("{e:#}");
|
||||
if Self::detail_is_repo_corruption(&detail) {
|
||||
tracing::warn!("{context}: repairable MST damage: {detail}");
|
||||
Self::RepoCorruption
|
||||
} else {
|
||||
tracing::error!("{context}: {detail}");
|
||||
Self::InternalError(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::InternalError(_) | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::InternalError(_) | Self::RepoCorruption | Self::DatabaseError => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
|
||||
StatusCode::BAD_GATEWAY
|
||||
}
|
||||
@@ -223,7 +248,9 @@ impl ApiError {
|
||||
}
|
||||
fn error_name(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
Self::InternalError(_) | Self::DatabaseError => Cow::Borrowed("InternalServerError"),
|
||||
Self::InternalError(_) | Self::RepoCorruption | Self::DatabaseError => {
|
||||
Cow::Borrowed("InternalServerError")
|
||||
}
|
||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
|
||||
Cow::Borrowed("UpstreamError")
|
||||
}
|
||||
@@ -332,6 +359,7 @@ impl ApiError {
|
||||
Self::InternalError(msg) => msg
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Internal Server Error".into()),
|
||||
Self::RepoCorruption => "Internal Server Error".into(),
|
||||
Self::AuthenticationFailed(msg) => msg
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Authentication failed".into()),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
use tranquil_db_traits::InviteCodeError;
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InviteRegistration {
|
||||
Bootstrap,
|
||||
Standard(Option<String>),
|
||||
}
|
||||
|
||||
impl InviteRegistration {
|
||||
pub fn into_invite_code(self) -> Option<String> {
|
||||
match self {
|
||||
InviteRegistration::Bootstrap => None,
|
||||
InviteRegistration::Standard(code) => code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bootstrap_registration(
|
||||
expected_code: &str,
|
||||
user_count: i64,
|
||||
invite_code: Option<&str>,
|
||||
) -> Option<Result<InviteRegistration, ApiError>> {
|
||||
if user_count != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(match invite_code {
|
||||
Some(code) if code == expected_code => Ok(InviteRegistration::Bootstrap),
|
||||
_ => Err(ApiError::InvalidInviteCode),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_registration_invite(
|
||||
state: &AppState,
|
||||
invite_code: Option<&str>,
|
||||
) -> Result<InviteRegistration, ApiError> {
|
||||
if let Some(expected) = state.bootstrap_invite_code.as_deref() {
|
||||
let user_count = state.repos.user.count_users().await.unwrap_or(1);
|
||||
if let Some(decision) = bootstrap_registration(expected, user_count, invite_code) {
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
match invite_code.map(str::trim).filter(|code| !code.is_empty()) {
|
||||
Some(code) => match state.repos.infra.validate_invite_code(code).await {
|
||||
Ok(_) => Ok(InviteRegistration::Standard(Some(code.to_owned()))),
|
||||
Err(InviteCodeError::DatabaseError(e)) => {
|
||||
tracing::error!("failed to validate invite code: {e:?}");
|
||||
Err(ApiError::InternalError(None))
|
||||
}
|
||||
Err(_) => Err(ApiError::InvalidInviteCode),
|
||||
},
|
||||
None => match tranquil_config::get().server.invite_code_required {
|
||||
true => Err(ApiError::InviteCodeRequired),
|
||||
false => Ok(InviteRegistration::Standard(None)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bootstrap_taken_for_matching_code_on_empty_instance() {
|
||||
assert!(matches!(
|
||||
bootstrap_registration("squid-bootstrap", 0, Some("squid-bootstrap")),
|
||||
Some(Ok(InviteRegistration::Bootstrap))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_rejects_wrong_code_on_empty_instance() {
|
||||
assert!(matches!(
|
||||
bootstrap_registration("squid-bootstrap", 0, Some("whelk")),
|
||||
Some(Err(ApiError::InvalidInviteCode))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_rejects_missing_code_on_empty_instance() {
|
||||
assert!(matches!(
|
||||
bootstrap_registration("squid-bootstrap", 0, None),
|
||||
Some(Err(ApiError::InvalidInviteCode))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_falls_through_once_users_exist() {
|
||||
assert!(bootstrap_registration("squid-bootstrap", 1, Some("squid-bootstrap")).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod error;
|
||||
pub mod invite;
|
||||
pub mod proxy;
|
||||
pub mod proxy_client;
|
||||
pub mod responses;
|
||||
|
||||
@@ -109,6 +109,35 @@ fn is_protected_method(method: &str) -> bool {
|
||||
PROTECTED_METHODS.contains(method)
|
||||
}
|
||||
|
||||
/// Fetch the `feed` generator record from the AppView and return its `did`.
|
||||
async fn resolve_feed_generator_did(appview_url: &str, query: Option<&str>) -> Option<String> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GetFeedQuery {
|
||||
feed: String,
|
||||
}
|
||||
|
||||
let feed = serde_urlencoded::from_str::<GetFeedQuery>(query?)
|
||||
.ok()?
|
||||
.feed;
|
||||
let at_uri = crate::types::AtUri::new(feed).ok()?;
|
||||
let repo = at_uri.did()?;
|
||||
let collection = at_uri.collection()?;
|
||||
let rkey = at_uri.rkey()?;
|
||||
|
||||
let resp = proxy_client()
|
||||
.get(format!("{appview_url}/xrpc/com.atproto.repo.getRecord"))
|
||||
.query(&[("repo", repo), ("collection", collection), ("rkey", rkey)])
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
warn!(status = %resp.status(), "getFeed proxy: getRecord for feed generator failed");
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body.get("value")?.get("did")?.as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
pub struct XrpcProxyLayer {
|
||||
state: AppState,
|
||||
}
|
||||
@@ -287,10 +316,27 @@ async fn proxy_handler(
|
||||
},
|
||||
};
|
||||
|
||||
// getFeed must be audienced to the feed generator, not the AppView.
|
||||
let (token_aud, token_lxm) = if method == "app.bsky.feed.getFeed" {
|
||||
match resolve_feed_generator_did(&resolved.url, query.as_deref()).await {
|
||||
Some(feed_did) => (feed_did, "app.bsky.feed.getFeedSkeleton"),
|
||||
None => {
|
||||
warn!(
|
||||
"getFeed proxy: could not resolve feed generator DID; refusing \
|
||||
to mint an AppView-audienced token"
|
||||
);
|
||||
return ApiError::InvalidRequest("Could not resolve feed".into())
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(resolved.did.clone(), method)
|
||||
};
|
||||
|
||||
match crate::auth::create_service_token(
|
||||
&auth_user.did,
|
||||
&resolved.did,
|
||||
Some(method),
|
||||
&token_aud,
|
||||
Some(token_lxm),
|
||||
&key_bytes,
|
||||
) {
|
||||
Ok(new_token) => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cache::Cache;
|
||||
use crate::util::{generate_token_code, normalize_token_code};
|
||||
|
||||
const TOKEN_TTL_SECS: u64 = 900;
|
||||
const BASE32_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EmailTokenPurpose {
|
||||
@@ -46,14 +45,6 @@ fn cache_key(did: &str, purpose: EmailTokenPurpose) -> String {
|
||||
format!("email_token:{}:{}", purpose.as_str(), did)
|
||||
}
|
||||
|
||||
fn generate_short_token() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
let token: String = (0..10)
|
||||
.map(|_| BASE32_CHARS[rng.gen_range(0..BASE32_CHARS.len())] as char)
|
||||
.collect();
|
||||
format!("{}-{}", &token[0..5], &token[5..10])
|
||||
}
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0)
|
||||
}
|
||||
@@ -67,9 +58,9 @@ pub async fn create_email_token(
|
||||
return Err(TokenError::CacheUnavailable);
|
||||
}
|
||||
|
||||
let token = generate_short_token();
|
||||
let token = generate_token_code();
|
||||
let data = TokenData {
|
||||
token: token.clone(),
|
||||
token: normalize_token_code(&token),
|
||||
created_at: current_timestamp(),
|
||||
};
|
||||
|
||||
@@ -108,10 +99,9 @@ pub async fn validate_email_token(
|
||||
return Err(TokenError::ExpiredToken);
|
||||
}
|
||||
|
||||
let normalized_input = token.to_uppercase().replace('-', "");
|
||||
let normalized_stored = data.token.to_uppercase().replace('-', "");
|
||||
let normalized_input = normalize_token_code(token);
|
||||
|
||||
if !constant_time_eq(normalized_input.as_bytes(), normalized_stored.as_bytes()) {
|
||||
if !constant_time_eq(normalized_input.as_bytes(), data.token.as_bytes()) {
|
||||
return Err(TokenError::InvalidToken);
|
||||
}
|
||||
|
||||
@@ -260,20 +250,19 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_format() {
|
||||
(0..100).for_each(|_| {
|
||||
let token = generate_short_token();
|
||||
// The emitted token is the display form: uppercase `XXXXX-XXXXX`.
|
||||
let cache = MockCache::new();
|
||||
let did = "did:plc:test123";
|
||||
(0..50).for_each(|_| {
|
||||
let token = futures::executor::block_on(create_email_token(
|
||||
&cache,
|
||||
did,
|
||||
EmailTokenPurpose::UpdateEmail,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(token.len(), 11);
|
||||
assert_eq!(&token[5..6], "-");
|
||||
assert!(
|
||||
token[0..5]
|
||||
.chars()
|
||||
.all(|c| BASE32_CHARS.contains(&(c as u8)))
|
||||
);
|
||||
assert!(
|
||||
token[6..11]
|
||||
.chars()
|
||||
.all(|c| BASE32_CHARS.contains(&(c as u8)))
|
||||
);
|
||||
assert_eq!(token, token.to_uppercase());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,6 +281,21 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hyphen_insensitive_validation() {
|
||||
let cache = MockCache::new();
|
||||
let did = "did:plc:test123";
|
||||
|
||||
let token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let no_hyphen = token.replace('-', "");
|
||||
let result =
|
||||
validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, &no_hyphen).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_noop_cache_returns_unavailable() {
|
||||
let cache = crate::cache::NoOpCache;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
use chrono::Utc;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cache::Cache;
|
||||
use crate::types::Did;
|
||||
use crate::util::{generate_token_code, normalize_token_code};
|
||||
|
||||
const CHALLENGE_TTL_SECS: u64 = 300;
|
||||
const MIN_REMAINING_TTL_SECS: u64 = 10;
|
||||
const MAX_ATTEMPTS: u8 = 5;
|
||||
const CODE_LENGTH: usize = 8;
|
||||
const COOLDOWN_SECS: u64 = 60;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -94,7 +93,8 @@ async fn validate_challenge_internal(
|
||||
return Err(ValidationError::ChallengeExpired);
|
||||
}
|
||||
|
||||
if !constant_time_eq(code.as_bytes(), data.code.as_bytes()) {
|
||||
let normalized_input = normalize_token_code(code);
|
||||
if !constant_time_eq(normalized_input.as_bytes(), data.code.as_bytes()) {
|
||||
let updated = ChallengeData {
|
||||
code: data.code,
|
||||
attempts: data.attempts + 1,
|
||||
@@ -127,17 +127,19 @@ fn cooldown_key(did: &str) -> String {
|
||||
format!("legacy_2fa_cooldown:{}", did)
|
||||
}
|
||||
|
||||
fn generate_code() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..CODE_LENGTH)
|
||||
.map(|_| rng.gen_range(0..10).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
u64::try_from(Utc::now().timestamp()).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn looks_like_totp_token(code: &str) -> bool {
|
||||
let c = code.trim();
|
||||
(c.len() == 6 && c.bytes().all(|b| b.is_ascii_digit())) || crate::auth::is_backup_code_format(c)
|
||||
}
|
||||
|
||||
pub fn used_totp_factor(has_totp: bool, auth_factor_token: Option<&str>) -> bool {
|
||||
has_totp && auth_factor_token.is_some_and(looks_like_totp_token)
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
@@ -148,6 +150,7 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
== 0
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Legacy2faOutcome {
|
||||
NotRequired,
|
||||
Blocked,
|
||||
@@ -177,6 +180,7 @@ pub async fn process_legacy_2fa(
|
||||
did: &Did,
|
||||
ctx: &Legacy2faContext,
|
||||
auth_factor_token: Option<&str>,
|
||||
verify_totp: impl AsyncFnOnce(&str) -> bool,
|
||||
) -> Result<Legacy2faOutcome, Legacy2faFlowError> {
|
||||
if !ctx.requires_2fa() {
|
||||
return Ok(Legacy2faOutcome::NotRequired);
|
||||
@@ -192,8 +196,16 @@ pub async fn process_legacy_2fa(
|
||||
Ok(Legacy2faOutcome::ChallengeSent(code))
|
||||
}
|
||||
Some(token) => {
|
||||
validate_challenge(cache, did, token).await?;
|
||||
Ok(Legacy2faOutcome::Verified)
|
||||
if ctx.has_totp && looks_like_totp_token(token) {
|
||||
if verify_totp(token).await {
|
||||
Ok(Legacy2faOutcome::Verified)
|
||||
} else {
|
||||
Err(Legacy2faFlowError::Validation(ValidationError::InvalidCode))
|
||||
}
|
||||
} else {
|
||||
validate_challenge(cache, did, token).await?;
|
||||
Ok(Legacy2faOutcome::Verified)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,11 +231,11 @@ async fn create_challenge_code(
|
||||
return Err(ChallengeError::RateLimited);
|
||||
}
|
||||
|
||||
let code = generate_code();
|
||||
let display = generate_token_code();
|
||||
let now = current_timestamp();
|
||||
|
||||
let data = ChallengeData {
|
||||
code: code.clone(),
|
||||
code: normalize_token_code(&display),
|
||||
attempts: 0,
|
||||
created_at: now,
|
||||
};
|
||||
@@ -244,7 +256,7 @@ async fn create_challenge_code(
|
||||
.await
|
||||
.map_err(|_| ChallengeError::CacheError)?;
|
||||
|
||||
Ok(ChallengeCode(code))
|
||||
Ok(ChallengeCode(display))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -332,12 +344,46 @@ mod tests {
|
||||
let did = Did::new("did:plc:test123".to_string()).unwrap();
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
assert_eq!(code.as_str().len(), CODE_LENGTH);
|
||||
assert_eq!(code.as_str().len(), 11);
|
||||
|
||||
let result = validate_challenge(&cache, &did, code.as_str()).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_challenge_code_format() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test123".to_string()).unwrap();
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
let code = code.as_str();
|
||||
assert_eq!(code.len(), 11);
|
||||
assert_eq!(&code[5..6], "-");
|
||||
assert_eq!(code, code.to_uppercase());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_case_insensitive_validation() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test123".to_string()).unwrap();
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
let lowercase = code.as_str().to_lowercase();
|
||||
let result = validate_challenge(&cache, &did, &lowercase).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hyphen_insensitive_validation() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:test123".to_string()).unwrap();
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
let no_hyphen = code.as_str().replace('-', "");
|
||||
let result = validate_challenge(&cache, &did, &no_hyphen).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_code_rejected() {
|
||||
let cache = MockCache::new();
|
||||
@@ -396,15 +442,6 @@ mod tests {
|
||||
assert_eq!(result.unwrap_err(), ChallengeError::CacheUnavailable);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_code_generation_is_numeric() {
|
||||
(0..100).for_each(|_| {
|
||||
let code = generate_code();
|
||||
assert!(code.chars().all(|c| c.is_ascii_digit()));
|
||||
assert_eq!(code.len(), CODE_LENGTH);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_constant_time_eq() {
|
||||
assert!(constant_time_eq(b"12345678", b"12345678"));
|
||||
@@ -425,7 +462,9 @@ mod tests {
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
|
||||
}
|
||||
|
||||
@@ -440,7 +479,9 @@ mod tests {
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
|
||||
}
|
||||
|
||||
@@ -455,7 +496,9 @@ mod tests {
|
||||
allow_legacy_login: false,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Blocked));
|
||||
}
|
||||
|
||||
@@ -470,7 +513,9 @@ mod tests {
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_)));
|
||||
}
|
||||
|
||||
@@ -485,7 +530,9 @@ mod tests {
|
||||
allow_legacy_login: false,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_)));
|
||||
}
|
||||
|
||||
@@ -502,7 +549,7 @@ mod tests {
|
||||
|
||||
let code = create_challenge(&cache, &did).await.unwrap();
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some(code.as_str()))
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some(code.as_str()), reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
@@ -532,4 +579,153 @@ mod tests {
|
||||
let result = validate_challenge(&cache, &did, "12345678").await;
|
||||
assert_eq!(result.unwrap_err(), ValidationError::CacheUnavailable);
|
||||
}
|
||||
|
||||
async fn reject_totp(_code: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn accept_totp(_code: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_totp_shaped_token_accepted_via_verifier() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp1".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: false,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some("123456"), accept_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_totp_shaped_token_rejected_does_not_touch_email_challenge() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp2".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: true,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
// An email challenge exists for this user.
|
||||
let email_code = create_challenge(&cache, &did).await.unwrap();
|
||||
|
||||
// Five wrong TOTP-shaped attempts. If these incremented the email attempt
|
||||
// counter, the email challenge would be exhausted (MAX_ATTEMPTS = 5).
|
||||
for _ in 0..5 {
|
||||
let err = process_legacy_2fa(&cache, &did, &ctx, Some("000000"), reject_totp)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
Legacy2faFlowError::Validation(ValidationError::InvalidCode)
|
||||
));
|
||||
}
|
||||
|
||||
// The email challenge is still valid and consumable.
|
||||
let outcome =
|
||||
process_legacy_2fa(&cache, &did, &ctx, Some(email_code.as_str()), reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_email_shaped_token_routes_to_email_path_when_totp_present() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp3".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: true,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
let email_code = create_challenge(&cache, &did).await.unwrap();
|
||||
|
||||
// reject_totp would fail if this routed to the verifier; it must route to email.
|
||||
let outcome =
|
||||
process_legacy_2fa(&cache, &did, &ctx, Some(email_code.as_str()), reject_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_backup_code_shaped_token_routes_to_verifier() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp4".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: false,
|
||||
has_totp: true,
|
||||
allow_legacy_login: true,
|
||||
};
|
||||
|
||||
// No email challenge created. If this routed to email it would be
|
||||
// ChallengeNotFound; Verified proves it went to the verifier.
|
||||
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some("ABCD2345"), accept_totp)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Legacy2faOutcome::Verified));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_totp_shaped_token_ignored_when_no_totp() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::new("did:plc:totp5".to_string()).unwrap();
|
||||
let ctx = Legacy2faContext {
|
||||
is_app_password: false,
|
||||
email_2fa_enabled: true,
|
||||
has_totp: false,
|
||||
allow_legacy_login: false,
|
||||
};
|
||||
|
||||
// has_totp = false -> 6-digit token routes to email path; no challenge -> NotFound.
|
||||
let err = process_legacy_2fa(&cache, &did, &ctx, Some("123456"), reject_totp)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
Legacy2faFlowError::Validation(ValidationError::ChallengeNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_looks_like_totp_token() {
|
||||
// 6-digit TOTP codes
|
||||
assert!(looks_like_totp_token("123456"));
|
||||
assert!(looks_like_totp_token(" 000000 "));
|
||||
// backup-code format (8 chars, backup alphabet)
|
||||
assert!(looks_like_totp_token("ABCD2345"));
|
||||
// email challenge codes normalize to 10 alphanumeric chars -> not TOTP-shaped
|
||||
assert!(!looks_like_totp_token("ABCDEFGHIJ"));
|
||||
assert!(!looks_like_totp_token("ABCDE-FGHIJ"));
|
||||
// wrong lengths / non-digits
|
||||
assert!(!looks_like_totp_token("12345"));
|
||||
assert!(!looks_like_totp_token("1234567"));
|
||||
assert!(!looks_like_totp_token("12345A"));
|
||||
assert!(!looks_like_totp_token(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_used_totp_factor() {
|
||||
// strong MFA factors completed the login -> true
|
||||
assert!(used_totp_factor(true, Some("123456")));
|
||||
assert!(used_totp_factor(true, Some("ABCD2345")));
|
||||
// email-shaped code, or no token, or no TOTP on the account -> false
|
||||
assert!(!used_totp_factor(true, Some("ABCDEFGHIJ")));
|
||||
assert!(!used_totp_factor(true, None));
|
||||
assert!(!used_totp_factor(false, Some("123456")));
|
||||
assert!(!used_totp_factor(true, Some("")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,14 +46,15 @@ pub use tranquil_auth::{
|
||||
ActClaim, Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
|
||||
TokenVerifyError, TokenWithMetadata, TotpError, UnsafeClaims, 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, generate_backup_codes,
|
||||
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, get_algorithm_from_token,
|
||||
get_did_from_token, get_jti_from_token, hash_backup_code, is_backup_code_format,
|
||||
verify_access_token, verify_access_token_hs256, verify_access_token_typed, verify_backup_code,
|
||||
verify_refresh_token, verify_refresh_token_hs256, verify_token, verify_totp_code,
|
||||
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, generate_backup_codes, generate_qr_png_base64,
|
||||
generate_totp_secret, generate_totp_uri, get_algorithm_from_token, get_did_from_token,
|
||||
get_jti_from_token, hash_backup_code, is_backup_code_format, verify_access_token,
|
||||
verify_access_token_hs256, verify_access_token_typed, verify_backup_code, verify_refresh_token,
|
||||
verify_refresh_token_hs256, verify_token, verify_totp_code,
|
||||
};
|
||||
|
||||
pub fn lxm_permits(lxm: &str, expected: &str) -> bool {
|
||||
@@ -522,7 +523,7 @@ async fn validate_bearer_token_with_options_internal(
|
||||
auth_source: AuthSource::OAuth,
|
||||
});
|
||||
} else {
|
||||
return Err(TokenValidationError::TokenExpired);
|
||||
return Err(TokenValidationError::OAuthTokenExpired);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-3
@@ -1,6 +1,4 @@
|
||||
pub use tranquil_cache::{
|
||||
Cache, CacheError, DistributedRateLimiter, NoOpCache, NoOpRateLimiter, create_cache,
|
||||
};
|
||||
pub use tranquil_cache::{Cache, CacheError, DistributedRateLimiter, NoOpCache, create_cache};
|
||||
|
||||
#[cfg(feature = "valkey")]
|
||||
pub use tranquil_cache::{RedisRateLimiter, ValkeyCache};
|
||||
|
||||
@@ -5,8 +5,8 @@ pub use roles::{
|
||||
CanAddControllers, CanControlAccounts, verify_can_add_controllers, verify_can_control_accounts,
|
||||
};
|
||||
pub use scopes::{
|
||||
InvalidDelegationScopeError, SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope,
|
||||
intersect_scopes,
|
||||
EDITOR_FULL_SCOPES, InvalidDelegationScopeError, OWNER_FULL_SCOPES, SCOPE_PRESETS, ScopePreset,
|
||||
ValidatedDelegationScope, intersect_scopes,
|
||||
};
|
||||
pub use tranquil_db_traits::DelegationActionType;
|
||||
|
||||
|
||||
@@ -12,12 +12,17 @@ pub struct ScopePreset {
|
||||
pub scopes: &'static str,
|
||||
}
|
||||
|
||||
pub const OWNER_FULL_SCOPES: &str = "atproto repo:* blob:*/* identity:* account:*?action=manage";
|
||||
|
||||
pub const EDITOR_FULL_SCOPES: &str =
|
||||
"atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*";
|
||||
|
||||
pub const SCOPE_PRESETS: &[ScopePreset] = &[
|
||||
ScopePreset {
|
||||
name: "owner",
|
||||
label: "Owner",
|
||||
description: "Full control including delegation management",
|
||||
scopes: "atproto",
|
||||
scopes: OWNER_FULL_SCOPES,
|
||||
},
|
||||
ScopePreset {
|
||||
name: "admin",
|
||||
@@ -29,7 +34,7 @@ pub const SCOPE_PRESETS: &[ScopePreset] = &[
|
||||
name: "editor",
|
||||
label: "Editor",
|
||||
description: "Post content and upload media",
|
||||
scopes: "repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*",
|
||||
scopes: EDITOR_FULL_SCOPES,
|
||||
},
|
||||
ScopePreset {
|
||||
name: "viewer",
|
||||
@@ -40,36 +45,19 @@ pub const SCOPE_PRESETS: &[ScopePreset] = &[
|
||||
];
|
||||
|
||||
pub fn intersect_scopes(requested: &str, granted: &str) -> String {
|
||||
if granted.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let requested_set: HashSet<&str> = requested.split_whitespace().collect();
|
||||
let granted_set: HashSet<&str> = granted.split_whitespace().collect();
|
||||
|
||||
let granted_has_atproto = granted_set.contains("atproto");
|
||||
let requested_has_atproto = requested_set.contains("atproto");
|
||||
|
||||
if granted_has_atproto {
|
||||
let mut scopes: Vec<&str> = requested_set.into_iter().collect();
|
||||
scopes.sort();
|
||||
return scopes.join(" ");
|
||||
}
|
||||
|
||||
if requested_has_atproto {
|
||||
let mut scopes: Vec<&str> = granted_set.into_iter().collect();
|
||||
scopes.sort();
|
||||
return scopes.join(" ");
|
||||
}
|
||||
|
||||
let mut result: Vec<&str> = requested_set
|
||||
let mut scopes: Vec<&str> = requested_set
|
||||
.iter()
|
||||
.filter(|requested_scope| any_granted_covers(requested_scope, &granted_set))
|
||||
.filter(|requested_scope| {
|
||||
**requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_set)
|
||||
})
|
||||
.copied()
|
||||
.chain(requested_set.contains("atproto").then_some("atproto"))
|
||||
.collect();
|
||||
|
||||
result.sort();
|
||||
result.join(" ")
|
||||
scopes.sort();
|
||||
scopes.join(" ")
|
||||
}
|
||||
|
||||
fn any_granted_covers(requested: &str, granted: &HashSet<&str>) -> bool {
|
||||
@@ -159,17 +147,90 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_granted_atproto() {
|
||||
let result = intersect_scopes("repo:* blob:*/*", "atproto");
|
||||
fn test_intersect_owner_grant_covers_requested() {
|
||||
let result = intersect_scopes("repo:* blob:*/*", OWNER_FULL_SCOPES);
|
||||
assert!(result.contains("repo:*"));
|
||||
assert!(result.contains("blob:*/*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_requested_atproto() {
|
||||
let result = intersect_scopes("atproto", "repo:* blob:*/*");
|
||||
assert!(result.contains("repo:*"));
|
||||
fn test_intersect_bare_atproto_grant_is_auth_only() {
|
||||
let requested = "atproto repo:*?action=create blob:*/*";
|
||||
assert_eq!(intersect_scopes(requested, "atproto"), "atproto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_bare_atproto_request_is_auth_only() {
|
||||
assert_eq!(intersect_scopes("atproto", "repo:* blob:*/*"), "atproto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_downscoped_request_keeps_atproto() {
|
||||
let approved = "atproto repo:*?action=create blob:*/* account:*?action=manage";
|
||||
let result = intersect_scopes(approved, OWNER_FULL_SCOPES);
|
||||
assert!(result.split_whitespace().any(|s| s == "atproto"));
|
||||
assert!(result.contains("account:*?action=manage"));
|
||||
assert!(result.contains("repo:*?action=create"));
|
||||
assert!(result.contains("blob:*/*"));
|
||||
assert!(!result.contains("identity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_owner_passes_through_identity() {
|
||||
let requested = "atproto repo:*?action=create identity:* account:*?action=manage";
|
||||
let result = intersect_scopes(requested, OWNER_FULL_SCOPES);
|
||||
assert!(result.contains("identity:*"));
|
||||
assert!(result.contains("account:*?action=manage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_admin_excludes_identity() {
|
||||
let requested = "atproto repo:*?action=create identity:* account:*?action=manage";
|
||||
let granted = "atproto repo:* blob:*/* account:*?action=manage";
|
||||
let result = intersect_scopes(requested, granted);
|
||||
assert!(!result.contains("identity"));
|
||||
assert!(result.contains("account:*?action=manage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_admin_excludes_identity_coverage_path() {
|
||||
let requested = "repo:*?action=create identity:* account:*?action=manage";
|
||||
let granted = "atproto repo:* blob:*/* account:*?action=manage";
|
||||
let result = intersect_scopes(requested, granted);
|
||||
assert!(!result.contains("identity"));
|
||||
assert!(result.contains("account:*?action=manage"));
|
||||
assert!(result.contains("repo:*?action=create"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_editor_grant_keeps_atproto() {
|
||||
let editor = SCOPE_PRESETS
|
||||
.iter()
|
||||
.find(|p| p.name == "editor")
|
||||
.expect("editor preset")
|
||||
.scopes;
|
||||
let requested = "atproto repo:*?action=create identity:* account:*?action=manage blob:*/*";
|
||||
let result = intersect_scopes(requested, editor);
|
||||
assert!(result.split_whitespace().any(|s| s == "atproto"));
|
||||
assert!(result.contains("repo:*?action=create"));
|
||||
assert!(result.contains("blob:*/*"));
|
||||
assert!(!result.contains("identity"));
|
||||
assert!(!result.contains("account"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_guarantees_atproto_for_custom_grant() {
|
||||
let result = intersect_scopes(
|
||||
"atproto repo:*?action=create blob:*/*",
|
||||
"repo:*?action=create blob:*/*",
|
||||
);
|
||||
assert!(result.split_whitespace().any(|s| s == "atproto"));
|
||||
assert!(result.contains("blob:*/*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_no_atproto_request_stays_empty_when_uncovered() {
|
||||
assert_eq!(intersect_scopes("identity:*", "repo:* blob:*/*"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -181,8 +242,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_empty_granted() {
|
||||
assert_eq!(intersect_scopes("atproto", ""), "");
|
||||
fn test_intersect_viewer_grant_keeps_atproto() {
|
||||
let requested = "atproto repo:*?action=create blob:*/* identity:*";
|
||||
assert_eq!(intersect_scopes(requested, ""), "atproto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_empty_grant_without_atproto_request_is_empty() {
|
||||
assert_eq!(intersect_scopes("repo:*?action=create", ""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,7 +19,10 @@ pub enum HandleResolutionError {
|
||||
}
|
||||
|
||||
pub async fn resolve_handle_dns(handle: &str) -> Result<String, HandleResolutionError> {
|
||||
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
|
||||
let resolver = 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 query_name = format!("_atproto.{}", handle);
|
||||
let txt_lookup = resolver
|
||||
.txt_lookup(&query_name)
|
||||
|
||||
@@ -51,6 +51,8 @@ pub const BUILD_VERSION: &str = concat!(
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub const BUILD_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub const GENERAL_BODY_LIMIT: usize = 16 * 1024 * 1024;
|
||||
|
||||
pub struct ExternalRoutes {
|
||||
pub xrpc: Router<AppState>,
|
||||
pub oauth: Router<AppState>,
|
||||
@@ -97,9 +99,7 @@ pub fn app_with_routes(state: AppState, external: ExternalRoutes) -> Router {
|
||||
.nest("/.well-known", well_known_router)
|
||||
.route("/metrics", get(metrics::metrics_handler))
|
||||
.merge(external.extra)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
tranquil_config::get().server.max_blob_size as usize,
|
||||
))
|
||||
.layer(DefaultBodyLimit::max(GENERAL_BODY_LIMIT))
|
||||
.layer(axum::middleware::map_response(rewrite_extractor_errors))
|
||||
.layer(middleware::from_fn(metrics::metrics_middleware))
|
||||
.layer(
|
||||
@@ -111,6 +111,7 @@ pub fn app_with_routes(state: AppState, external: ExternalRoutes) -> Router {
|
||||
http::header::CONTENT_TYPE,
|
||||
http::header::CONTENT_ENCODING,
|
||||
http::header::ACCEPT_ENCODING,
|
||||
http::header::USER_AGENT,
|
||||
util::HEADER_DPOP,
|
||||
util::HEADER_ATPROTO_PROXY,
|
||||
util::HEADER_ATPROTO_ACCEPT_LABELERS,
|
||||
|
||||
@@ -39,31 +39,82 @@ pub enum PlcOpType {
|
||||
Tombstone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Error)]
|
||||
#[error("service type must not be empty")]
|
||||
pub struct EmptyServiceType;
|
||||
|
||||
mod custom_service_type {
|
||||
use super::EmptyServiceType;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CustomServiceType(String);
|
||||
|
||||
impl CustomServiceType {
|
||||
pub(super) fn new(name: String) -> Result<Self, EmptyServiceType> {
|
||||
match name.as_str() {
|
||||
"" => Err(EmptyServiceType),
|
||||
_ => Ok(Self(name)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use custom_service_type::CustomServiceType;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ServiceType {
|
||||
#[serde(rename = "AtprotoPersonalDataServer")]
|
||||
Pds,
|
||||
#[serde(rename = "AtprotoAppView")]
|
||||
AppView,
|
||||
#[serde(rename = "AtprotoLabeler")]
|
||||
Labeler,
|
||||
Other(CustomServiceType),
|
||||
}
|
||||
|
||||
impl ServiceType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Pds => "AtprotoPersonalDataServer",
|
||||
Self::AppView => "AtprotoAppView",
|
||||
Self::Labeler => "AtprotoLabeler",
|
||||
Self::Other(name) => name.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_pds(self) -> bool {
|
||||
matches!(self, Self::Pds)
|
||||
impl TryFrom<String> for ServiceType {
|
||||
type Error = EmptyServiceType;
|
||||
|
||||
fn try_from(name: String) -> Result<Self, Self::Error> {
|
||||
match name.as_str() {
|
||||
"AtprotoPersonalDataServer" => Ok(Self::Pds),
|
||||
"AtprotoLabeler" => Ok(Self::Labeler),
|
||||
_ => CustomServiceType::new(name).map(Self::Other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ServiceType {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ServiceType {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let name = String::deserialize(deserializer)?;
|
||||
Self::try_from(name).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
pub const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01];
|
||||
pub const P256_MULTICODEC_PREFIX: [u8; 2] = [0x80, 0x24];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlcOperation {
|
||||
@@ -365,6 +416,100 @@ pub fn signing_key_to_did_key(signing_key: &SigningKey) -> String {
|
||||
format!("did:key:{}", encoded)
|
||||
}
|
||||
|
||||
pub fn rotation_keys_for(
|
||||
configured_rotation_key: Option<&str>,
|
||||
signing_key: &SigningKey,
|
||||
) -> Vec<String> {
|
||||
let signing_did_key = signing_key_to_did_key(signing_key);
|
||||
match configured_rotation_key {
|
||||
Some(key) if key != signing_did_key.as_str() => vec![key.to_string(), signing_did_key],
|
||||
_ => vec![signing_did_key],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RequiredRotationKey {
|
||||
Signing,
|
||||
Operator,
|
||||
}
|
||||
|
||||
impl RequiredRotationKey {
|
||||
pub fn message(self) -> &'static str {
|
||||
match self {
|
||||
Self::Signing => {
|
||||
"Rotation keys must include the PDS-managed signing key so the server can sign future operations"
|
||||
}
|
||||
Self::Operator => {
|
||||
"Rotation keys must include the operator-held PLC recovery key configured for this server"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn missing_required_rotation_key(
|
||||
rotation_keys: &[&str],
|
||||
signing_did_key: &str,
|
||||
configured_rotation_key: Option<&str>,
|
||||
) -> Option<RequiredRotationKey> {
|
||||
if !rotation_keys.contains(&signing_did_key) {
|
||||
return Some(RequiredRotationKey::Signing);
|
||||
}
|
||||
match configured_rotation_key {
|
||||
Some(operator_key) if !rotation_keys.contains(&operator_key) => {
|
||||
Some(RequiredRotationKey::Operator)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_compressed_did_key<V, E>(
|
||||
key_bytes: &[u8],
|
||||
label: &str,
|
||||
did_key: &str,
|
||||
parse: impl Fn(&[u8]) -> Result<V, E>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
E: std::fmt::Display,
|
||||
{
|
||||
if key_bytes.len() != 33 {
|
||||
return Err(format!(
|
||||
"`{did_key}` must be a compressed {label} public key"
|
||||
));
|
||||
}
|
||||
parse(key_bytes)
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("`{did_key}` is not a valid {label} public key: {e}"))
|
||||
}
|
||||
|
||||
pub fn validate_rotation_did_key(did_key: &str) -> Result<(), String> {
|
||||
let multibase_part = did_key
|
||||
.strip_prefix("did:key:")
|
||||
.ok_or_else(|| format!("must be a did:key, got `{did_key}`"))?;
|
||||
let (base, decoded) = multibase::decode(multibase_part)
|
||||
.map_err(|e| format!("`{did_key}` is not valid multibase: {e}"))?;
|
||||
if base != multibase::Base::Base58Btc {
|
||||
return Err(format!("`{did_key}` must use base58btc multibase encoding"));
|
||||
}
|
||||
let (prefix, key_bytes) = decoded.split_at(decoded.len().min(2));
|
||||
if prefix == SECP256K1_MULTICODEC_PREFIX {
|
||||
validate_compressed_did_key(
|
||||
key_bytes,
|
||||
"secp256k1",
|
||||
did_key,
|
||||
k256::ecdsa::VerifyingKey::from_sec1_bytes,
|
||||
)
|
||||
} else if prefix == P256_MULTICODEC_PREFIX {
|
||||
validate_compressed_did_key(
|
||||
key_bytes,
|
||||
"p256",
|
||||
did_key,
|
||||
p256::ecdsa::VerifyingKey::from_sec1_bytes,
|
||||
)
|
||||
} else {
|
||||
Err(format!("`{did_key}` is not a secp256k1 or p256 did:key"))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GenesisResult {
|
||||
pub did: String,
|
||||
pub signed_operation: Value,
|
||||
@@ -372,13 +517,14 @@ pub struct GenesisResult {
|
||||
|
||||
pub fn create_genesis_operation(
|
||||
signing_key: &SigningKey,
|
||||
rotation_key: &str,
|
||||
configured_rotation_key: Option<&str>,
|
||||
handle: &str,
|
||||
pds_endpoint: &str,
|
||||
) -> Result<GenesisResult, PlcError> {
|
||||
let signing_did_key = signing_key_to_did_key(signing_key);
|
||||
let rotation_keys = rotation_keys_for(configured_rotation_key, signing_key);
|
||||
let mut verification_methods = HashMap::new();
|
||||
verification_methods.insert("atproto".to_string(), signing_did_key.clone());
|
||||
verification_methods.insert("atproto".to_string(), signing_did_key);
|
||||
let mut services = HashMap::new();
|
||||
services.insert(
|
||||
"atproto_pds".to_string(),
|
||||
@@ -389,7 +535,7 @@ pub fn create_genesis_operation(
|
||||
);
|
||||
let genesis_op = PlcOperation {
|
||||
op_type: PlcOpType::Operation,
|
||||
rotation_keys: vec![rotation_key.to_string()],
|
||||
rotation_keys,
|
||||
verification_methods,
|
||||
also_known_as: vec![format!("at://{}", handle)],
|
||||
services,
|
||||
@@ -601,6 +747,132 @@ mod tests {
|
||||
assert!(did_key.starts_with("did:key:z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rotation_keys_default_is_signing_key() {
|
||||
let key = SigningKey::random(&mut rand::thread_rng());
|
||||
let signing_did_key = signing_key_to_did_key(&key);
|
||||
assert_eq!(rotation_keys_for(None, &key), vec![signing_did_key]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rotation_keys_prepends_operator_key() {
|
||||
let key = SigningKey::random(&mut rand::thread_rng());
|
||||
let signing_did_key = signing_key_to_did_key(&key);
|
||||
let operator_key = "did:key:zQ3shScallopRecoveryKey";
|
||||
assert_eq!(
|
||||
rotation_keys_for(Some(operator_key), &key),
|
||||
vec![operator_key.to_string(), signing_did_key.clone()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rotation_keys_dedupes_when_operator_equals_signing() {
|
||||
let key = SigningKey::random(&mut rand::thread_rng());
|
||||
let signing_did_key = signing_key_to_did_key(&key);
|
||||
assert_eq!(
|
||||
rotation_keys_for(Some(&signing_did_key), &key),
|
||||
vec![signing_did_key]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_genesis_includes_signing_key_with_operator_rotation_key() {
|
||||
let key = SigningKey::random(&mut rand::thread_rng());
|
||||
let signing_did_key = signing_key_to_did_key(&key);
|
||||
let operator_key = "did:key:zQ3shWhelkOperatorKey";
|
||||
let result =
|
||||
create_genesis_operation(&key, Some(operator_key), "whelk.nel.pet", "https://nel.pet")
|
||||
.unwrap();
|
||||
let rotation_keys = result.signed_operation["rotationKeys"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(rotation_keys, vec![operator_key, signing_did_key.as_str()]);
|
||||
assert!(
|
||||
verify_operation_signature(
|
||||
&result.signed_operation,
|
||||
&[operator_key.to_string(), signing_did_key]
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
fn p256_did_key(key: &p256::ecdsa::SigningKey) -> String {
|
||||
let point = key.verifying_key().to_encoded_point(true);
|
||||
let mut prefixed = Vec::from(P256_MULTICODEC_PREFIX);
|
||||
prefixed.extend_from_slice(point.as_bytes());
|
||||
format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base58Btc, &prefixed)
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rotation_did_key_accepts_secp256k1() {
|
||||
let key = SigningKey::random(&mut rand::thread_rng());
|
||||
assert!(validate_rotation_did_key(&signing_key_to_did_key(&key)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rotation_did_key_accepts_p256() {
|
||||
let key = p256::ecdsa::SigningKey::random(&mut rand::thread_rng());
|
||||
assert!(validate_rotation_did_key(&p256_did_key(&key)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rotation_did_key_rejects_non_did_key() {
|
||||
assert!(validate_rotation_did_key("did:plc:squid").is_err());
|
||||
assert!(validate_rotation_did_key("zSomeMultibaseButNoPrefix").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rotation_did_key_rejects_unknown_multicodec() {
|
||||
let mut ed25519 = vec![0xed, 0x01];
|
||||
ed25519.extend_from_slice(&[0u8; 32]);
|
||||
let did_key = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base58Btc, &ed25519)
|
||||
);
|
||||
assert!(validate_rotation_did_key(&did_key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rotation_did_key_rejects_non_base58btc() {
|
||||
let key = SigningKey::random(&mut rand::thread_rng());
|
||||
let point = key.verifying_key().to_encoded_point(true);
|
||||
let mut prefixed = Vec::from(SECP256K1_MULTICODEC_PREFIX);
|
||||
prefixed.extend_from_slice(point.as_bytes());
|
||||
let hex_did_key = format!(
|
||||
"did:key:{}",
|
||||
multibase::encode(multibase::Base::Base16Lower, &prefixed)
|
||||
);
|
||||
assert!(validate_rotation_did_key(&hex_did_key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_required_rotation_key() {
|
||||
let signing = "did:key:zSigning";
|
||||
let operator = "did:key:zOperator";
|
||||
assert_eq!(
|
||||
missing_required_rotation_key(&[operator], signing, None),
|
||||
Some(RequiredRotationKey::Signing)
|
||||
);
|
||||
assert_eq!(
|
||||
missing_required_rotation_key(&[signing], signing, Some(operator)),
|
||||
Some(RequiredRotationKey::Operator)
|
||||
);
|
||||
assert_eq!(
|
||||
missing_required_rotation_key(&[operator, signing], signing, Some(operator)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
missing_required_rotation_key(&[signing], signing, None),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cid_for_cbor() {
|
||||
let value = json!({
|
||||
@@ -625,4 +897,80 @@ mod tests {
|
||||
let signed = sign_operation(&op, &key).unwrap();
|
||||
assert!(signed.get("sig").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_type_known_round_trip() {
|
||||
let cases = [
|
||||
(ServiceType::Pds, "\"AtprotoPersonalDataServer\""),
|
||||
(ServiceType::Labeler, "\"AtprotoLabeler\""),
|
||||
];
|
||||
cases.iter().for_each(|(variant, encoded)| {
|
||||
assert_eq!(serde_json::to_string(variant).unwrap(), *encoded);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ServiceType>(encoded).unwrap(),
|
||||
*variant
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_type_custom_round_trips() {
|
||||
let parsed: ServiceType = serde_json::from_str("\"ConchFeedGenerator\"").unwrap();
|
||||
assert_eq!(
|
||||
parsed,
|
||||
ServiceType::try_from("ConchFeedGenerator".to_string()).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&parsed).unwrap(),
|
||||
"\"ConchFeedGenerator\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_type_custom_normalizes_known_names() {
|
||||
assert_eq!(
|
||||
ServiceType::try_from("AtprotoPersonalDataServer".to_string()).unwrap(),
|
||||
ServiceType::Pds
|
||||
);
|
||||
assert_eq!(
|
||||
ServiceType::try_from("AtprotoLabeler".to_string()).unwrap(),
|
||||
ServiceType::Labeler
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_appview_is_not_a_named_type() {
|
||||
assert_eq!(
|
||||
ServiceType::try_from("AtprotoAppView".to_string()).unwrap(),
|
||||
ServiceType::Other(CustomServiceType::new("AtprotoAppView".to_string()).unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_type_rejects_empty() {
|
||||
assert!(ServiceType::try_from(String::new()).is_err());
|
||||
assert!(serde_json::from_str::<ServiceType>("\"\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plc_operation_with_custom_service_round_trips() {
|
||||
let op_json = json!({
|
||||
"type": "plc_operation",
|
||||
"rotationKeys": ["did:key:zScallop"],
|
||||
"verificationMethods": { "atproto": "did:key:zUni" },
|
||||
"alsoKnownAs": ["at://whelk.nel.pet"],
|
||||
"services": {
|
||||
"atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://nel.pet" },
|
||||
"custom_feedgen": { "type": "ConchFeedGenerator", "endpoint": "https://feed.nel.pet" }
|
||||
},
|
||||
"prev": null
|
||||
});
|
||||
let op: PlcOperation = serde_json::from_value(op_json.clone()).unwrap();
|
||||
assert_eq!(
|
||||
op.services["custom_feedgen"].service_type,
|
||||
ServiceType::try_from("ConchFeedGenerator".to_string()).unwrap()
|
||||
);
|
||||
assert_eq!(op.services["atproto_pds"].service_type, ServiceType::Pds);
|
||||
assert_eq!(serde_json::to_value(&op).unwrap(), op_json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,13 @@ use cid::Cid;
|
||||
use jacquard_repo::error::RepoError;
|
||||
use jacquard_repo::repo::CommitData;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use tranquil_store::blockstore::TranquilBlockStore;
|
||||
use tranquil_store::blockstore::{RepairOutcome, TranquilBlockStore};
|
||||
use tranquil_store::{RealIO, SystemClock};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum AnyBlockStore {
|
||||
Postgres(PostgresBlockStore),
|
||||
TranquilStore(TranquilBlockStore),
|
||||
TranquilStore(TranquilBlockStore<RealIO, SystemClock>),
|
||||
}
|
||||
|
||||
impl AnyBlockStore {
|
||||
@@ -23,7 +24,7 @@ impl AnyBlockStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_tranquil_store(&self) -> Option<&TranquilBlockStore> {
|
||||
pub fn as_tranquil_store(&self) -> Option<&TranquilBlockStore<RealIO, SystemClock>> {
|
||||
match self {
|
||||
Self::TranquilStore(s) => Some(s),
|
||||
Self::Postgres(_) => None,
|
||||
@@ -36,6 +37,38 @@ impl AnyBlockStore {
|
||||
Self::TranquilStore(s) => s.decrement_refs(cids).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn repair_structure(
|
||||
&self,
|
||||
entries: &[(String, Cid)],
|
||||
expected_root: Cid,
|
||||
) -> Result<RepairOutcome, RepoError> {
|
||||
match self {
|
||||
Self::Postgres(s) => {
|
||||
let nodes =
|
||||
tranquil_store::blockstore::rebuild_mst_nodes(entries, expected_root).await?;
|
||||
let nodes_total = nodes.len();
|
||||
let cids: Vec<Cid> = nodes.iter().map(|(cid, _)| *cid).collect();
|
||||
let present = s.get_many(&cids).await?;
|
||||
let missing: Vec<(Cid, Bytes)> = nodes
|
||||
.into_iter()
|
||||
.zip(present)
|
||||
.filter_map(|((cid, bytes), found)| found.is_none().then_some((cid, bytes)))
|
||||
.collect();
|
||||
let nodes_repaired = missing.len() as u64;
|
||||
if !missing.is_empty() {
|
||||
s.put_many(missing).await?;
|
||||
}
|
||||
Ok(RepairOutcome {
|
||||
nodes_total,
|
||||
nodes_repaired,
|
||||
})
|
||||
}
|
||||
Self::TranquilStore(s) => {
|
||||
tranquil_store::blockstore::rebuild_and_repair_mst(s, entries, expected_root).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockStore for AnyBlockStore {
|
||||
|
||||
@@ -16,10 +16,10 @@ use k256::ecdsa::SigningKey;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::OwnedMutexGuard;
|
||||
use tracing::{error, warn};
|
||||
use tranquil_db_traits::SequenceNumber;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -231,16 +231,247 @@ pub async fn begin_repo_write(
|
||||
Ok((ctx, mst))
|
||||
}
|
||||
|
||||
pub async fn repair_repo_structure(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
) -> Result<tranquil_store::blockstore::RepairOutcome, ApiError> {
|
||||
let _write_lock = state.repo_write_locks.lock(user_id).await;
|
||||
|
||||
let root_cid_str = state
|
||||
.repos
|
||||
.repo
|
||||
.get_repo_root_cid_by_user_id(user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: DB error fetching repo root: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())))?;
|
||||
let current_root_cid = Cid::from_str(root_cid_str.as_str())
|
||||
.map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())))?;
|
||||
|
||||
let commit_bytes = state
|
||||
.block_store
|
||||
.get(¤t_root_cid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: failed to load commit block: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.ok_or_else(|| ApiError::InternalError(Some("Commit block not found".into())))?;
|
||||
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| {
|
||||
error!("repair: failed to parse commit: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let data_root = commit.data;
|
||||
let repo_rev = commit.rev().to_string();
|
||||
|
||||
let records = state
|
||||
.repos
|
||||
.repo
|
||||
.get_all_records(user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: get_all_records failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let entries: Vec<(String, Cid)> = records
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
Cid::from_str(r.record_cid.as_str())
|
||||
.ok()
|
||||
.map(|cid| (format!("{}/{}", r.collection, r.rkey), cid))
|
||||
})
|
||||
.collect();
|
||||
|
||||
warn!(
|
||||
user_id = %user_id,
|
||||
records = entries.len(),
|
||||
"repair: rebuilding full MST from record set"
|
||||
);
|
||||
|
||||
let outcome = state
|
||||
.block_store
|
||||
.repair_structure(&entries, data_root)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: structural repair failed: {}", e);
|
||||
ApiError::InternalError(Some("Structural repair failed".into()))
|
||||
})?;
|
||||
|
||||
if outcome.nodes_repaired > 0 {
|
||||
let leaf_present = futures::future::try_join_all(
|
||||
entries.iter().map(|(_, cid)| state.block_store.has(cid)),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: leaf presence check failed: {e}");
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let missing: Vec<Cid> = entries
|
||||
.iter()
|
||||
.zip(leaf_present)
|
||||
.filter_map(|((_, cid), present)| (!present).then_some(*cid))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
error!(
|
||||
user_id = %user_id,
|
||||
missing = missing.len(),
|
||||
sample = ?missing.iter().take(5).map(|c| c.to_string()).collect::<Vec<_>>(),
|
||||
"repair: unrecoverable leaf data loss after structural repair"
|
||||
);
|
||||
return Err(ApiError::InternalError(Some(format!(
|
||||
"unrecoverable leaf data loss: {} record block(s) missing after structural repair",
|
||||
missing.len()
|
||||
))));
|
||||
}
|
||||
|
||||
let block_cids =
|
||||
crate::scheduled::collect_current_repo_blocks(&state.block_store, ¤t_root_cid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: re-walk for user_blocks backfill failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
.insert_user_blocks(user_id, &block_cids, &repo_rev)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: user_blocks backfill failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
warn!(
|
||||
user_id = %user_id,
|
||||
blocks = block_cids.len(),
|
||||
"repair: backfilled user_blocks from repaired DAG"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub async fn with_repair_retry<T, F, Fut>(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
mut attempt: F,
|
||||
) -> Result<T, ApiError>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T, ApiError>>,
|
||||
{
|
||||
match attempt().await {
|
||||
Err(e) if e.is_repo_corruption() => {
|
||||
warn!(
|
||||
"structural MST damage during repo write for user {user_id}, repairing and retrying"
|
||||
);
|
||||
match repair_repo_structure(state, user_id).await {
|
||||
Ok(outcome) if outcome.nodes_repaired > 0 => attempt().await,
|
||||
Ok(_) => {
|
||||
warn!(
|
||||
user_id = %user_id,
|
||||
"structural repair rewrote no nodes; damage is not in the MST structure, returning original error without retry"
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
Err(repair_err) => {
|
||||
error!(user_id = %user_id, "structural repair failed: {repair_err:?}");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
const REPAIR_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
const REPAIR_NOOP_COOLDOWN: Duration = Duration::from_secs(600);
|
||||
|
||||
struct RepairSlot {
|
||||
in_flight: bool,
|
||||
next_allowed: Instant,
|
||||
}
|
||||
|
||||
struct RepairGuard {
|
||||
slots: parking_lot::Mutex<HashMap<Uuid, RepairSlot>>,
|
||||
}
|
||||
|
||||
impl RepairGuard {
|
||||
fn try_claim(&self, user_id: Uuid, now: Instant) -> bool {
|
||||
let mut slots = self.slots.lock();
|
||||
let slot = slots.entry(user_id).or_insert(RepairSlot {
|
||||
in_flight: false,
|
||||
next_allowed: now,
|
||||
});
|
||||
if slot.in_flight || now < slot.next_allowed {
|
||||
return false;
|
||||
}
|
||||
slot.in_flight = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn release(&self, user_id: Uuid, now: Instant, cooldown: Duration) {
|
||||
if let Some(slot) = self.slots.lock().get_mut(&user_id) {
|
||||
slot.in_flight = false;
|
||||
slot.next_allowed = now + cooldown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static REPAIR_GUARD: LazyLock<RepairGuard> = LazyLock::new(|| RepairGuard {
|
||||
slots: parking_lot::Mutex::new(HashMap::new()),
|
||||
});
|
||||
|
||||
struct RepairLease {
|
||||
user_id: Uuid,
|
||||
cooldown: Duration,
|
||||
}
|
||||
|
||||
impl Drop for RepairLease {
|
||||
fn drop(&mut self) {
|
||||
REPAIR_GUARD.release(self.user_id, Instant::now(), self.cooldown);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schedule_repo_repair(state: &AppState, user_id: Uuid) {
|
||||
if !REPAIR_GUARD.try_claim(user_id, Instant::now()) {
|
||||
return;
|
||||
}
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lease = RepairLease {
|
||||
user_id,
|
||||
cooldown: REPAIR_COOLDOWN,
|
||||
};
|
||||
match repair_repo_structure(&state, user_id).await {
|
||||
Ok(outcome) => {
|
||||
if outcome.nodes_repaired == 0 {
|
||||
lease.cooldown = REPAIR_NOOP_COOLDOWN;
|
||||
}
|
||||
warn!(
|
||||
user_id = %user_id,
|
||||
nodes_repaired = outcome.nodes_repaired,
|
||||
nodes_total = outcome.nodes_total,
|
||||
"background MST repair complete"
|
||||
);
|
||||
}
|
||||
Err(e) => error!(user_id = %user_id, "background MST repair failed: {e:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn finalize_repo_write(
|
||||
state: &AppState,
|
||||
ctx: RepoWriteContext,
|
||||
mst: Mst<TrackingBlockStore>,
|
||||
params: FinalizeParams<'_>,
|
||||
) -> Result<CommitResult, ApiError> {
|
||||
let new_mst_root = mst.persist().await.map_err(|e| {
|
||||
error!("MST persist failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let new_mst_root = mst
|
||||
.persist()
|
||||
.await
|
||||
.map_err(|e| ApiError::from_mst_error("MST persist", &e))?;
|
||||
|
||||
let written_bytes = ctx.tracking_store.take_written_blocks();
|
||||
let new_tree_cids: Vec<Cid> = written_bytes.keys().copied().collect();
|
||||
@@ -776,7 +1007,7 @@ pub async fn sequence_identity_event(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
handle: Option<&Handle>,
|
||||
) -> Result<SequenceNumber, CommitError> {
|
||||
) -> Result<(), CommitError> {
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
@@ -788,7 +1019,7 @@ pub async fn sequence_account_event(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
status: tranquil_db_traits::AccountStatus,
|
||||
) -> Result<SequenceNumber, CommitError> {
|
||||
) -> Result<(), CommitError> {
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
@@ -801,7 +1032,7 @@ pub async fn sequence_sync_event(
|
||||
did: &Did,
|
||||
commit_cid: &str,
|
||||
rev: Option<&str>,
|
||||
) -> Result<SequenceNumber, CommitError> {
|
||||
) -> Result<(), CommitError> {
|
||||
let cid_link: crate::types::CidLink = commit_cid
|
||||
.parse()
|
||||
.map_err(|_| CommitError::InvalidCid(commit_cid.to_string()))?;
|
||||
@@ -829,7 +1060,7 @@ pub async fn sequence_genesis_commit(
|
||||
commit_cid: &Cid,
|
||||
mst_root_cid: &Cid,
|
||||
rev: &str,
|
||||
) -> Result<SequenceNumber, CommitError> {
|
||||
) -> Result<(), CommitError> {
|
||||
let commit_cid_link = crate::types::CidLink::from(commit_cid);
|
||||
let mst_root_cid_link = crate::types::CidLink::from(mst_root_cid);
|
||||
let commit_bytes = state
|
||||
@@ -862,3 +1093,117 @@ pub async fn sequence_genesis_commit(
|
||||
.await
|
||||
.map_err(|e| CommitError::DatabaseError(format!("genesis commit event: {}", e)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod repair_guard_tests {
|
||||
use super::*;
|
||||
|
||||
fn guard() -> RepairGuard {
|
||||
RepairGuard {
|
||||
slots: parking_lot::Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_dedups_in_flight_then_respects_cooldown() {
|
||||
let g = guard();
|
||||
let user = Uuid::from_u128(1);
|
||||
let t0 = Instant::now();
|
||||
|
||||
assert!(g.try_claim(user, t0), "first claim must succeed");
|
||||
assert!(
|
||||
!g.try_claim(user, t0),
|
||||
"second claim while a repair is in flight must be rejected"
|
||||
);
|
||||
|
||||
g.release(user, t0, REPAIR_COOLDOWN);
|
||||
assert!(
|
||||
!g.try_claim(user, t0 + Duration::from_secs(1)),
|
||||
"claim within the cooldown window must be rejected"
|
||||
);
|
||||
assert!(
|
||||
g.try_claim(user, t0 + REPAIR_COOLDOWN + Duration::from_millis(1)),
|
||||
"claim after the cooldown window must succeed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_users_do_not_block_each_other() {
|
||||
let g = guard();
|
||||
let t0 = Instant::now();
|
||||
assert!(g.try_claim(Uuid::from_u128(1), t0));
|
||||
assert!(g.try_claim(Uuid::from_u128(2), t0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_claims_for_one_user_admit_exactly_one() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Barrier};
|
||||
|
||||
let g = Arc::new(guard());
|
||||
let user = Uuid::from_u128(42);
|
||||
let now = Instant::now();
|
||||
let winners = Arc::new(AtomicUsize::new(0));
|
||||
let gate = Arc::new(Barrier::new(32));
|
||||
|
||||
let handles: Vec<_> = (0..32)
|
||||
.map(|_| {
|
||||
let g = Arc::clone(&g);
|
||||
let winners = Arc::clone(&winners);
|
||||
let gate = Arc::clone(&gate);
|
||||
std::thread::spawn(move || {
|
||||
gate.wait();
|
||||
if g.try_claim(user, now) {
|
||||
winners.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles
|
||||
.into_iter()
|
||||
.for_each(|h| h.join().expect("worker thread panicked"));
|
||||
|
||||
assert_eq!(
|
||||
winners.load(Ordering::Relaxed),
|
||||
1,
|
||||
"exactly one concurrent claim must win the dedup race"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_re_enables_claim_after_cooldown_for_recurring_corruption() {
|
||||
let g = guard();
|
||||
let user = Uuid::from_u128(7);
|
||||
let t0 = Instant::now();
|
||||
|
||||
assert!(g.try_claim(user, t0));
|
||||
g.release(user, t0, REPAIR_COOLDOWN);
|
||||
let after_cooldown = t0 + REPAIR_COOLDOWN + Duration::from_millis(1);
|
||||
assert!(
|
||||
g.try_claim(user, after_cooldown),
|
||||
"a fresh corruption after the cooldown must be repairable again"
|
||||
);
|
||||
assert!(
|
||||
!g.try_claim(user, after_cooldown),
|
||||
"the re-claimed repair must again dedup while in flight"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_repair_uses_longer_cooldown() {
|
||||
let g = guard();
|
||||
let user = Uuid::from_u128(9);
|
||||
let t0 = Instant::now();
|
||||
|
||||
assert!(g.try_claim(user, t0));
|
||||
g.release(user, t0, REPAIR_NOOP_COOLDOWN);
|
||||
assert!(
|
||||
!g.try_claim(user, t0 + REPAIR_COOLDOWN + Duration::from_millis(1)),
|
||||
"after a no-op repair the standard cooldown must not re-admit a claim"
|
||||
);
|
||||
assert!(
|
||||
g.try_claim(user, t0 + REPAIR_NOOP_COOLDOWN + Duration::from_millis(1)),
|
||||
"after the longer no-op cooldown a fresh claim must be admitted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,10 @@ pub async fn collect_current_repo_blocks(
|
||||
let block = match block_store.get(&cid).await {
|
||||
Ok(Some(b)) => b,
|
||||
Ok(None) => continue,
|
||||
Err(e) if crate::api::error::ApiError::detail_is_repo_corruption(&format!("{e:#}")) => {
|
||||
warn!(cid = %cid, error = %format!("{e:#}"), "skipping corrupt block during repo walk");
|
||||
continue;
|
||||
}
|
||||
Err(e) => anyhow::bail!("Failed to get block {}: {:?}", cid, e),
|
||||
};
|
||||
|
||||
@@ -574,7 +578,10 @@ impl CompactionBlocklist {
|
||||
}
|
||||
|
||||
fn run_compaction_pass(
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore,
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore<
|
||||
tranquil_store::RealIO,
|
||||
tranquil_store::SystemClock,
|
||||
>,
|
||||
liveness_threshold: f64,
|
||||
grace_period_ms: u64,
|
||||
blocklist: &parking_lot::Mutex<CompactionBlocklist>,
|
||||
@@ -711,7 +718,7 @@ async fn delete_account_data(
|
||||
warn!(storage_key = %key, error = %e, "Failed to delete blob from storage (continuing anyway)");
|
||||
});
|
||||
|
||||
let _account_seq = user_repo
|
||||
user_repo
|
||||
.delete_account_with_firehose(user_id, did)
|
||||
.await
|
||||
.context("Failed to delete account")?;
|
||||
@@ -727,10 +734,53 @@ async fn delete_account_data(
|
||||
|
||||
const CAR_BLOCK_BATCH_SIZE: usize = 500;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RepoCarError {
|
||||
MissingBlocks(Vec<Cid>),
|
||||
Source(anyhow::Error),
|
||||
}
|
||||
|
||||
impl RepoCarError {
|
||||
pub fn is_repairable(&self) -> bool {
|
||||
match self {
|
||||
Self::MissingBlocks(_) => true,
|
||||
Self::Source(e) => {
|
||||
crate::api::error::ApiError::detail_is_repo_corruption(&format!("{e:#}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RepoCarError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingBlocks(cids) => write!(
|
||||
f,
|
||||
"repo CAR is incomplete: {} block(s) referenced by the MST are missing from storage. First 5: {}",
|
||||
cids.len(),
|
||||
cids.iter()
|
||||
.take(5)
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
Self::Source(e) => write!(f, "{e:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RepoCarError {}
|
||||
|
||||
impl From<anyhow::Error> for RepoCarError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
Self::Source(e)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_repo_car(
|
||||
block_store: &AnyBlockStore,
|
||||
head_cid: &Cid,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
) -> Result<Vec<u8>, RepoCarError> {
|
||||
let block_cids_bytes = collect_current_repo_blocks(block_store, head_cid).await?;
|
||||
let block_cids: Vec<Cid> = block_cids_bytes
|
||||
.iter()
|
||||
@@ -757,16 +807,7 @@ pub async fn generate_repo_car(
|
||||
.filter_map(|(cid, block_opt)| block_opt.is_none().then_some(*cid))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
anyhow::bail!(
|
||||
"repo CAR is incomplete: {} block(s) referenced by the MST are missing from storage. First 5: {}",
|
||||
missing.len(),
|
||||
missing
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
return Err(RepoCarError::MissingBlocks(missing));
|
||||
}
|
||||
|
||||
chunk
|
||||
@@ -800,7 +841,7 @@ pub async fn generate_repo_car_from_user_blocks(
|
||||
block_store: &AnyBlockStore,
|
||||
user_id: uuid::Uuid,
|
||||
_head_cid: &Cid,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
) -> Result<Vec<u8>, RepoCarError> {
|
||||
use std::str::FromStr;
|
||||
|
||||
let repo_root_cid_str: String = repo_repo
|
||||
@@ -836,7 +877,10 @@ fn cid_to_bytes(cid: &Cid) -> anyhow::Result<CidBytes> {
|
||||
}
|
||||
|
||||
fn walk_repo_dag_sync(
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore,
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore<
|
||||
tranquil_store::RealIO,
|
||||
tranquil_store::SystemClock,
|
||||
>,
|
||||
head_cid: &Cid,
|
||||
reachable: &mut std::collections::HashSet<CidBytes>,
|
||||
phantom_files: &mut std::collections::HashSet<tranquil_store::blockstore::DataFileId>,
|
||||
@@ -960,7 +1004,10 @@ fn paginate_repos(
|
||||
}
|
||||
|
||||
pub fn run_reachability_walk(
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore,
|
||||
store: &tranquil_store::blockstore::TranquilBlockStore<
|
||||
tranquil_store::RealIO,
|
||||
tranquil_store::SystemClock,
|
||||
>,
|
||||
repo_repo: &dyn RepoRepository,
|
||||
) -> anyhow::Result<ReachabilityResult> {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
|
||||
@@ -359,7 +359,9 @@ impl AppState {
|
||||
let rate_limiters = Arc::new(RateLimiters::new());
|
||||
let repo_write_locks = Arc::new(RepoWriteLocks::new());
|
||||
let circuit_breakers = Arc::new(CircuitBreakers::new());
|
||||
let (cache, distributed_rate_limiter) = create_cache(shutdown.clone()).await;
|
||||
let (cache, distributed_rate_limiter) = create_cache(shutdown.clone())
|
||||
.await
|
||||
.expect("Failed to initialize cache and distributed rate limiter at startup");
|
||||
let did_resolver = Arc::new(DidResolver::new());
|
||||
let cross_pds_oauth = Arc::new(CrossPdsOAuthClient::new(cache.clone()));
|
||||
let sso_config = SsoConfig::init();
|
||||
@@ -471,12 +473,55 @@ impl AppState {
|
||||
}
|
||||
|
||||
struct TranquilStoreWiring {
|
||||
blockstore: tranquil_store::blockstore::TranquilBlockStore,
|
||||
blockstore: tranquil_store::blockstore::TranquilBlockStore<
|
||||
tranquil_store::RealIO,
|
||||
tranquil_store::SystemClock,
|
||||
>,
|
||||
signal_provider: Arc<dyn tranquil_signal::SignalStoreProvider>,
|
||||
repos: PostgresRepositories,
|
||||
segments_dir: PathBuf,
|
||||
}
|
||||
|
||||
fn migrate_delegation_preset_scopes(metastore: &tranquil_store::metastore::Metastore) {
|
||||
const MARKER_KEY: &str = "migration:delegation_preset_scopes_v1";
|
||||
const LEGACY_EDITOR_SCOPES: &str =
|
||||
"repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*";
|
||||
|
||||
let infra = metastore.infra_ops();
|
||||
if infra.get_server_config(MARKER_KEY).ok().flatten().is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let ops = metastore.delegation_ops();
|
||||
let owners = match ops.remap_grant_scopes("atproto", crate::delegation::OWNER_FULL_SCOPES) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::error!(error = ?e, "delegation owner-scope migration failed, will retry on next start");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let editors = match ops
|
||||
.remap_grant_scopes(LEGACY_EDITOR_SCOPES, crate::delegation::EDITOR_FULL_SCOPES)
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::error!(error = ?e, "delegation editor-scope migration failed, will retry on next start");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if owners + editors > 0 {
|
||||
tracing::info!(
|
||||
owners,
|
||||
editors,
|
||||
"upgraded legacy delegation grants to preset scopes"
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = infra.upsert_server_config(MARKER_KEY, "done") {
|
||||
tracing::error!(error = ?e, "failed to record delegation scope migration marker, will retry");
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_tranquil_store(
|
||||
store_cfg: &tranquil_config::TranquilStoreConfig,
|
||||
shutdown: CancellationToken,
|
||||
@@ -614,6 +659,8 @@ fn wire_tranquil_store(
|
||||
}
|
||||
}
|
||||
|
||||
migrate_delegation_preset_scopes(&metastore);
|
||||
|
||||
let notifier = bridge.notifier();
|
||||
let signal_db = metastore.database().clone();
|
||||
let signal_ks = metastore.signal_keyspace();
|
||||
|
||||
@@ -92,30 +92,20 @@ impl CarVerifier {
|
||||
|
||||
pub fn verify_car_structure_only(
|
||||
&self,
|
||||
expected_did: &Did,
|
||||
root_cid: &Cid,
|
||||
blocks: &HashMap<Cid, Bytes>,
|
||||
) -> Result<VerifiedCar, VerifyError> {
|
||||
) -> Result<StructureVerifiedCar, VerifyError> {
|
||||
let root_block = blocks
|
||||
.get(root_cid)
|
||||
.ok_or_else(|| VerifyError::BlockNotFound(root_cid.to_string()))?;
|
||||
let commit =
|
||||
Commit::from_cbor(root_block).map_err(|e| VerifyError::InvalidCommit(e.to_string()))?;
|
||||
let commit_did = commit.did().as_str();
|
||||
if commit_did != expected_did.as_str() {
|
||||
return Err(VerifyError::DidMismatch {
|
||||
commit_did: commit_did.to_string(),
|
||||
expected_did: expected_did.to_string(),
|
||||
});
|
||||
}
|
||||
let commit_did = commit.did().to_string().into();
|
||||
let data_cid = commit.data();
|
||||
self.verify_mst_structure(data_cid, blocks)?;
|
||||
debug!(
|
||||
"MST structure verified for DID {} (signature verification skipped for migration)",
|
||||
expected_did
|
||||
);
|
||||
Ok(VerifiedCar {
|
||||
did: expected_did.clone(),
|
||||
debug!("MST structure verified for commit: {:?}", commit);
|
||||
Ok(StructureVerifiedCar {
|
||||
did: commit_did,
|
||||
rev: commit.rev().to_string(),
|
||||
data_cid: *data_cid,
|
||||
prev: commit.prev().cloned(),
|
||||
@@ -289,6 +279,14 @@ impl CarVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StructureVerifiedCar {
|
||||
pub did: Did,
|
||||
pub rev: String,
|
||||
pub data_cid: Cid,
|
||||
pub prev: Option<Cid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VerifiedCar {
|
||||
pub did: Did,
|
||||
|
||||
@@ -33,7 +33,20 @@ pub fn generate_token_code() -> String {
|
||||
.map(|_| chars[rng.gen_range(0..chars.len())])
|
||||
.collect()
|
||||
};
|
||||
format!("{}-{}", gen_segment(&mut rng), gen_segment(&mut rng))
|
||||
// Human-entered short codes are displayed in uppercase; base32 digits are
|
||||
// unaffected by the conversion.
|
||||
format!("{}-{}", gen_segment(&mut rng), gen_segment(&mut rng)).to_uppercase()
|
||||
}
|
||||
|
||||
/// Normalize a user-entered short code so that codes are accepted
|
||||
/// case-insensitively and regardless of the separating hyphen or surrounding
|
||||
/// whitespace.
|
||||
pub fn normalize_token_code(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != '-')
|
||||
.collect::<String>()
|
||||
.to_uppercase()
|
||||
}
|
||||
|
||||
pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> {
|
||||
@@ -491,10 +504,44 @@ mod tests {
|
||||
assert!(
|
||||
code.chars()
|
||||
.filter(|&c| c != '-')
|
||||
.all(|c| BASE32_ALPHABET.contains(c))
|
||||
.all(|c| BASE32_ALPHABET.to_uppercase().contains(c))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_token_code_is_uppercase() {
|
||||
(0..100).for_each(|_| {
|
||||
let code = generate_token_code();
|
||||
assert_eq!(code, code.to_uppercase(), "code must be uppercase: {code}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_token_code_strips_hyphen_and_uppercases() {
|
||||
assert_eq!(normalize_token_code("k7m2p-q9rst"), "K7M2PQ9RST");
|
||||
assert_eq!(normalize_token_code("K7M2P-Q9RST"), "K7M2PQ9RST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_token_code_strips_whitespace() {
|
||||
assert_eq!(normalize_token_code(" k7m2p-q9rst \n"), "K7M2PQ9RST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_token_code_is_idempotent() {
|
||||
let once = normalize_token_code("k7m2p-q9rst");
|
||||
assert_eq!(normalize_token_code(&once), once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generated_code_round_trips_through_normalize() {
|
||||
let code = generate_token_code();
|
||||
// A user re-typing the displayed code lowercased and without the hyphen
|
||||
// must normalize to the same canonical form as the code itself.
|
||||
let retyped = code.to_lowercase().replace('-', "");
|
||||
assert_eq!(normalize_token_code(&code), normalize_token_code(&retyped));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_to_ipld_cid_link() {
|
||||
let json = serde_json::json!({
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use cid::Cid;
|
||||
use jacquard_repo::mst::Mst;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use tranquil_pds::repo::AnyBlockStore;
|
||||
use tranquil_pds::scheduled::{RepoCarError, generate_repo_car};
|
||||
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
|
||||
|
||||
const RECORD_COUNT: usize = 200;
|
||||
|
||||
fn open_store(dir: &std::path::Path) -> AnyBlockStore {
|
||||
let cfg = BlockStoreConfig {
|
||||
data_dir: dir.join("data"),
|
||||
index_dir: dir.join("index"),
|
||||
max_file_size: 64 * 1024,
|
||||
group_commit: GroupCommitConfig::default(),
|
||||
shard_count: 1,
|
||||
};
|
||||
AnyBlockStore::TranquilStore(TranquilBlockStore::open(cfg).expect("open block store"))
|
||||
}
|
||||
|
||||
async fn build_tree(any: &AnyBlockStore) -> Cid {
|
||||
let mut mst = Mst::new(Arc::new(any.clone()));
|
||||
for i in 0..RECORD_COUNT {
|
||||
let key = format!("app.bsky.feed.post/{i:0>6}");
|
||||
let cid = any
|
||||
.put(format!("record body {i}").as_bytes())
|
||||
.await
|
||||
.expect("put record");
|
||||
mst.add_mut(&key, cid).await.expect("mst add");
|
||||
}
|
||||
mst.persist().await.expect("persist mst")
|
||||
}
|
||||
|
||||
fn shred_data_files(data_dir: &std::path::Path) {
|
||||
let mut shredded = false;
|
||||
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
|
||||
continue;
|
||||
}
|
||||
let mut bytes = std::fs::read(&path).expect("read data file");
|
||||
let mut off = 5usize;
|
||||
while off + 48 < bytes.len() {
|
||||
bytes[off..off + 48].iter_mut().for_each(|b| *b = 0xFF);
|
||||
off += 192;
|
||||
shredded = true;
|
||||
}
|
||||
std::fs::write(&path, &bytes).expect("write corrupted data file");
|
||||
}
|
||||
assert!(shredded, "no .tqb data file was corrupted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn car_export_error_is_classified_as_repo_corruption() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let any = open_store(dir.path());
|
||||
let root = build_tree(&any).await;
|
||||
|
||||
generate_repo_car(&any, &root)
|
||||
.await
|
||||
.expect("pristine CAR must generate");
|
||||
|
||||
shred_data_files(&dir.path().join("data"));
|
||||
|
||||
let err = generate_repo_car(&any, &root)
|
||||
.await
|
||||
.expect_err("corrupt CAR export must error");
|
||||
assert!(
|
||||
err.is_repairable(),
|
||||
"{err} should classify as repairable so the sync path can self-heal"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn car_export_missing_block_is_repairable() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let source = open_store(dir.path());
|
||||
let root = build_tree(&source).await;
|
||||
|
||||
let pristine = open_store(&dir.path().join("pristine"));
|
||||
let head_block = source
|
||||
.get(&root)
|
||||
.await
|
||||
.expect("read root")
|
||||
.expect("root present");
|
||||
pristine.put(&head_block).await.expect("seed root only");
|
||||
|
||||
let err = generate_repo_car(&pristine, &root)
|
||||
.await
|
||||
.expect_err("CAR export over a store missing MST children must error");
|
||||
assert!(
|
||||
matches!(err, RepoCarError::MissingBlocks(ref cids) if !cids.is_empty()),
|
||||
"{err} should surface as MissingBlocks when referenced blocks are absent"
|
||||
);
|
||||
assert!(err.is_repairable());
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user