mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-24 18:26:06 +00:00
Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37234797b4 | ||
|
|
6297b1a451 | ||
|
|
107149f396 | ||
|
|
bd47cbdaa4 | ||
|
|
9840ac77cf | ||
|
|
420ce1e201 | ||
|
|
c723bc2164 | ||
|
|
18455f54f2 | ||
|
|
ce2f05b9d4 | ||
|
|
c88f69f31d | ||
|
|
b3c314ce66 | ||
|
|
434079a732 | ||
|
|
a5a2f30bbe | ||
|
|
dc2fbe6654 | ||
|
|
bc751b0ee2 | ||
|
|
9e78206cf4 | ||
|
|
779dc1b985 | ||
|
|
1dc0c40206 | ||
|
|
72fa88d79a | ||
|
|
596b9b15fd | ||
|
|
59934cc184 | ||
|
|
34a47e6e5a | ||
|
|
aca78bb8d3 | ||
|
|
25d7d24d4e | ||
|
|
b6274bb3c4 | ||
|
|
2a96a8f420 | ||
|
|
9ad70bda9e | ||
|
|
c3a8240154 | ||
|
|
1e2311f8fc | ||
|
|
17905115d8 | ||
|
|
6ed568dbfb | ||
|
|
d00d72895a | ||
|
|
2f1e22a950 | ||
|
|
946cb9740f | ||
|
|
0c7cccb14c | ||
|
|
4f37ac26cd | ||
|
|
bbe9f6f3b3 | ||
|
|
4e3b2f2af4 | ||
|
|
932b0c07d4 | ||
|
|
01a71ece7c | ||
|
|
8abb6cc741 | ||
|
|
2bfea64ffc | ||
|
|
e931268f7e | ||
|
|
14a086cb13 | ||
|
|
7244551ae1 | ||
|
|
01d93e44e7 | ||
|
|
fa50d7d54e | ||
|
|
19e7ec29f5 | ||
|
|
311fbfcb86 | ||
|
|
515c058006 | ||
|
|
aca6dd926e | ||
|
|
9c6730579e | ||
|
|
348ac887fc | ||
|
|
ecdda4c555 | ||
|
|
f17adc6f88 | ||
|
|
00ca223b5f | ||
|
|
9ccec274aa | ||
|
|
f2754efeb2 | ||
|
|
4416f50c87 | ||
|
|
e41f34746a | ||
|
|
fbfa15b0b4 | ||
|
|
eb1a89dc58 | ||
|
|
6ca6c45605 | ||
|
|
d238affd76 | ||
|
|
469255f5a9 | ||
|
|
8559764d31 | ||
|
|
a405d523ca | ||
|
|
3c46e5fc73 | ||
|
|
f330dcd366 | ||
|
|
1411506d8c | ||
|
|
1a9dcf86ba | ||
|
|
8ae9ce9c8f | ||
|
|
6ec4484cad | ||
|
|
f3af04e4ae | ||
|
|
aab1a945c2 | ||
|
|
9dc184ee33 | ||
|
|
ab4eba6dc4 | ||
|
|
a171518290 | ||
|
|
28f2e04019 | ||
|
|
39a2e40b35 | ||
|
|
05ab0b7423 | ||
|
|
221b32d66f | ||
|
|
1b489776c5 | ||
|
|
2ca15fe7a0 | ||
|
|
48ae1e8b7b | ||
|
|
6838976969 | ||
|
|
3045ee25c0 | ||
|
|
04a90b1563 | ||
|
|
e13ba7f4c7 | ||
|
|
a3f729c3cd | ||
|
|
637b817a33 | ||
|
|
562f970bc3 | ||
|
|
06fd6a1ce9 | ||
|
|
3b07cdfb24 | ||
|
|
4a8826b7a1 | ||
|
|
80afd764d3 | ||
|
|
5bbe2146ff | ||
|
|
b009ccdaf2 | ||
|
|
39f74b5adf | ||
|
|
a231d7da29 | ||
|
|
63d84d38fb | ||
|
|
fe9b88141c | ||
|
|
72f5dce32b | ||
|
|
cd7e01100e | ||
|
|
7c248be153 |
+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"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT r.record_cid AS \"record_cid!\"\n FROM records r\n WHERE r.repo_id = $1\n AND r.record_cid = ANY($2)\n AND NOT EXISTS (\n SELECT 1 FROM UNNEST($3::text[], $4::text[]) AS k(collection, rkey)\n WHERE k.collection = r.collection AND k.rkey = r.rkey\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "record_cid!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"TextArray",
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "105807a41c7337e7aa46bace29ab613030fd4fbf6845baddab9c0b2009972c02"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT block_cid AS \"block_cid!\" FROM user_blocks\n WHERE user_id = $1 AND repo_rev > $2\n ORDER BY repo_rev ASC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "block_cid!",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "30570ed3866840d1258c8768a5c8a23ade40700c05ddbbf7fc4f64bfa95b1ed4"
|
||||
}
|
||||
-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"
|
||||
}
|
||||
+2
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, did, email, email_verified, handle\n FROM users\n WHERE LOWER(email) = $1",
|
||||
"query": "SELECT id, did, email, email_verified\n FROM users\n WHERE LOWER(email) = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -22,11 +22,6 @@
|
||||
"ordinal": 3,
|
||||
"name": "email_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -38,9 +33,8 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b230a27fce54d4f79de4ffcc754bf6c7e5a889623e64cdd7aaec791b3553ed83"
|
||||
"hash": "55b5e5cb13e09c4261e0452ef7e21240b504096666692c8fbd5c7dcd58607bc7"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
-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"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT block_cid AS \"block_cid!\" FROM user_blocks\n WHERE user_id = $1\n ORDER BY repo_rev ASC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "block_cid!",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "85cc0cd1e62a30fa67d415b7a01164f962a422513e8f0737553321fd9987a56c"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
-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"
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
In order of importance:
|
||||
|
||||
- If your change involves how Tranquil implements atproto make sure its correct! See more below.
|
||||
- **You must run your change! Every contribution that says "here's xyz. untested." does not help the project.**
|
||||
- Relevant tests to your PR must pass. The whole suite doesn't have to be proven to have run, because there are a *ton* of tests and they're quite heavy, but hopefully there are existing tests for whatever you're PRing, and if there aren't, please add those too.
|
||||
- Run cargo fmt :P
|
||||
@@ -16,6 +17,60 @@ Things that would also be nice but aren't like, a pain in our side:
|
||||
|
||||
- Big changes should be stacked PRs that are broken up into digestible pieces. Those stacked PRs should hopefully be able to be merged individually if necessary.
|
||||
|
||||
### How we define a "correct" PDS implementation
|
||||
|
||||
The atproto specs are notoriously imprecise, ambiguious,
|
||||
lacks specifications for large parts of the protocol and network (even including what implementing a PDS entails!)
|
||||
and is generally none specific.
|
||||
This is bad.
|
||||
We won't waste time here describing all the ways in which that is problematic,
|
||||
the important thing for Tranquil is that this means that "follows spec" is not sufficient to describe a "correct" PDS implementation.
|
||||
Thus we need to come up with a description of "correct".
|
||||
In order of importance the following rules describe what "correct" means for Tranquil:
|
||||
|
||||
- The specs take precedence.
|
||||
If the spec *is* specific enough then follow it.
|
||||
Even if the reference implementation doesn't.
|
||||
- If the specs aren't sufficiently specific
|
||||
rely on the reference implementation, potential supporting documents or discussions,
|
||||
and/or community sentiment or common sense.
|
||||
If the matter is still debated and/or PBCs opinion differs from community sentiment we generally side with the community.
|
||||
- Examples here include what features and APIs to implement,
|
||||
here we look at what the reference implementation implements
|
||||
as well as https://github.com/bluesky-social/atproto/discussions/2350 as a supporting document.
|
||||
Another example is whether `include` scopes are allowed to use a `*` `aud` parameter.
|
||||
Discussion here has happened in https://github.com/bluesky-social/atproto/issues/4490.
|
||||
PBC has voiced an opinion that this should be disallowed,
|
||||
community sentiment seems to strongly lean to allowing it. Tranquil allows it.
|
||||
- Please mark locations like this with a `// SPECAMB: ...` comment explaining the ambiguity
|
||||
and what parts of the reference implementation and/or supporting documents have been used as reference.
|
||||
- If the reference implementation has behaviour that is only ever relevant for the Bluesky application.
|
||||
Implementions of such behaviour **must** be gated behind a `bsky-support` cargo feature of the implementing crate.
|
||||
- Examples here include bluesky feedgen specific service proxying behaviour,
|
||||
the `app.bsky.actor.getPreferences` and `app.bsky.actor.putPreferences` APIs,
|
||||
and special handling of the `X-BSKY-TOPICS` HTTP header during service proxying.
|
||||
- Please add a comment next to these implementations with an explanation of the behaviour.
|
||||
- Most of these behaviours are required for proper functioning of the official Bluesky client, though not all.
|
||||
If the behaviour isn't required for the official client consider not implementing it.
|
||||
- One such behaviour that we have a *hard rule* to never implement is default proxying to a configured Bluesky appview
|
||||
for `app.bsky.*` APIs and as fallback for `com.atproto.repo.getRecord`.
|
||||
Many third-party Bluesky clients rely on this behaviour, the official client used to do the same but does not anymore.
|
||||
Third-party clients breaking because they don't specify an `atproto-proxy` header is thus *not* a Tranquil bug but a bug in said clients.
|
||||
- Bluesky is the only application that will ever recieve application specific behaviour like this.
|
||||
It does so only because such a big section of atproto usage is Bluesky
|
||||
and because Bluesky is the only application that can practically rely on application specific behaviour.
|
||||
Application specific behaviour for other applications may still be added to Tranquil if such behaviour is a Tranquil feature,
|
||||
for example for Tranquils rudimentary banned content moderation feature,
|
||||
and not something said application relies on for proper functioning.
|
||||
|
||||
There is bound to be edge cases that these rules don't fully cover.
|
||||
Here common sense, community sentiment, furthering the goals of atproto itself, and ultimately maintainer opinion take precedence over support for any individual application.
|
||||
Even Bluesky.
|
||||
|
||||
The rules above are meant to capture Tranquils goals of being correct while being community oriented and avoiding as much "Bluesky-defaultism" as possible.
|
||||
Tranquil is a community atproto PDS, *not* a company-led Bluesky (or other atproto app) PDS.
|
||||
See also "Tranquil & the world" in docs/1_WELCOME_TO_TRANQUIL_PDS.md.
|
||||
|
||||
## Local Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Generated
+234
-88
@@ -105,6 +105,21 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -210,7 +225,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 +235,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 +263,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 +1089,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 +1098,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"
|
||||
@@ -1213,6 +1265,27 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
@@ -1970,7 +2043,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",
|
||||
@@ -2435,7 +2522,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2834,6 +2920,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 +4728,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 +5307,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 +5550,7 @@ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"futures-io",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
@@ -5630,6 +5754,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,9 +7665,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-api"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"backon",
|
||||
"base32",
|
||||
@@ -7563,27 +7700,25 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tranquil-config",
|
||||
"tranquil-db",
|
||||
"tranquil-db-traits",
|
||||
"tranquil-lexicon",
|
||||
"tranquil-pds",
|
||||
"tranquil-scopes",
|
||||
"tranquil-signal",
|
||||
"tranquil-types",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"webauthn-rs",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
"brotli",
|
||||
"chrono",
|
||||
"hmac",
|
||||
"k256",
|
||||
@@ -7595,17 +7730,19 @@ dependencies = [
|
||||
"totp-rs",
|
||||
"tranquil-config",
|
||||
"tranquil-crypto",
|
||||
"tranquil-types",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"redis",
|
||||
"thiserror 2.0.18",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tranquil-config",
|
||||
@@ -7615,7 +7752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7629,7 +7766,6 @@ dependencies = [
|
||||
"rsa",
|
||||
"secrecy",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -7641,15 +7777,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -7665,7 +7800,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7682,7 +7817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7698,18 +7833,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tranquil-config",
|
||||
"tranquil-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -7721,14 +7858,15 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tranquil-infra",
|
||||
"tranquil-types",
|
||||
"unicode-segmentation",
|
||||
"urlencoding",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7745,14 +7883,16 @@ dependencies = [
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tranquil-infra",
|
||||
"tranquil-types",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth-server"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
@@ -7775,7 +7915,9 @@ dependencies = [
|
||||
"tranquil-config",
|
||||
"tranquil-crypto",
|
||||
"tranquil-db-traits",
|
||||
"tranquil-infra",
|
||||
"tranquil-pds",
|
||||
"tranquil-scopes",
|
||||
"tranquil-types",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
@@ -7784,7 +7926,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -7796,7 +7938,6 @@ dependencies = [
|
||||
"base32",
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
"bs58",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"ciborium",
|
||||
@@ -7812,7 +7953,6 @@ dependencies = [
|
||||
"hmac",
|
||||
"http 1.4.0",
|
||||
"image",
|
||||
"infer",
|
||||
"ipld-core",
|
||||
"iroh-car",
|
||||
"jacquard-common",
|
||||
@@ -7837,6 +7977,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"testcontainers",
|
||||
"testcontainers-modules",
|
||||
"thiserror 2.0.18",
|
||||
@@ -7853,9 +7994,9 @@ dependencies = [
|
||||
"tranquil-cache",
|
||||
"tranquil-comms",
|
||||
"tranquil-config",
|
||||
"tranquil-crypto",
|
||||
"tranquil-db",
|
||||
"tranquil-db-traits",
|
||||
"tranquil-infra",
|
||||
"tranquil-lexicon",
|
||||
"tranquil-oauth",
|
||||
"tranquil-oauth-server",
|
||||
@@ -7872,12 +8013,11 @@ dependencies = [
|
||||
"webauthn-rs",
|
||||
"webauthn-rs-proto",
|
||||
"wiremock",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -7889,19 +8029,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
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 +8053,12 @@ dependencies = [
|
||||
"tranquil-config",
|
||||
"tranquil-infra",
|
||||
"uuid",
|
||||
"x509-parser 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -7925,22 +8069,30 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tranquil-types",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-server"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
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 +8112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-signal"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7968,7 +8120,6 @@ dependencies = [
|
||||
"futures",
|
||||
"presage",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
@@ -7976,14 +8127,13 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tranquil-signal",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -8000,7 +8150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-store"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -8040,7 +8190,6 @@ dependencies = [
|
||||
"tranquil-db",
|
||||
"tranquil-db-traits",
|
||||
"tranquil-oauth",
|
||||
"tranquil-repo",
|
||||
"tranquil-store",
|
||||
"tranquil-types",
|
||||
"uuid",
|
||||
@@ -8049,7 +8198,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-sync"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -8071,15 +8220,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"cid",
|
||||
"jacquard-common",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8131,12 +8287,6 @@ version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -8584,7 +8734,7 @@ checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"base64urlsafedata",
|
||||
"der-parser",
|
||||
"der-parser 9.0.0",
|
||||
"hex",
|
||||
"nom 7.1.3",
|
||||
"openssl",
|
||||
@@ -8600,7 +8750,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"webauthn-attestation-ca",
|
||||
"webauthn-rs-proto",
|
||||
"x509-parser",
|
||||
"x509-parser 0.16.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9154,17 +9304,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 +9361,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"
|
||||
@@ -9310,20 +9488,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "7.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
"typed-path",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zkcredential"
|
||||
version = "0.1.0"
|
||||
@@ -9372,30 +9536,12 @@ dependencies = [
|
||||
"zkcredential",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
|
||||
+18
-5
@@ -26,7 +26,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -47,7 +47,6 @@ tranquil-db = { path = "crates/tranquil-db" }
|
||||
tranquil-ripple = { path = "crates/tranquil-ripple" }
|
||||
tranquil-lexicon = { path = "crates/tranquil-lexicon" }
|
||||
tranquil-pds = { path = "crates/tranquil-pds" }
|
||||
tranquil-server = { path = "crates/tranquil-server" }
|
||||
tranquil-sync = { path = "crates/tranquil-sync" }
|
||||
tranquil-oauth-server = { path = "crates/tranquil-oauth-server" }
|
||||
tranquil-api = { path = "crates/tranquil-api" }
|
||||
@@ -82,6 +81,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 +107,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"
|
||||
@@ -134,10 +137,11 @@ tower-layer = "0.3"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
urlencoding = "2.1"
|
||||
url = "2.5"
|
||||
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"
|
||||
zip = { version = "7.0", default-features = false, features = ["deflate"] }
|
||||
x509-parser = "0.18"
|
||||
|
||||
ciborium = "0.2"
|
||||
ctor = "0.6"
|
||||
@@ -149,11 +153,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.96-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.96-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!
|
||||
|
||||
@@ -8,13 +8,10 @@ license.workspace = true
|
||||
tranquil-pds = { workspace = true }
|
||||
tranquil-types = { workspace = true }
|
||||
tranquil-config = { workspace = true }
|
||||
tranquil-db = { workspace = true }
|
||||
tranquil-db-traits = { workspace = true }
|
||||
tranquil-lexicon = { workspace = true, features = ["resolve"] }
|
||||
tranquil-scopes = { workspace = true }
|
||||
tranquil-signal = { workspace = true }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
backon = { workspace = true }
|
||||
base32 = { workspace = true }
|
||||
@@ -50,4 +47,7 @@ tracing = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
webauthn-rs = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[features]
|
||||
bsky = ["bsky-support"]
|
||||
bsky-support = []
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
mod preferences;
|
||||
|
||||
pub use preferences::{get_preferences, put_preferences};
|
||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{Admin, Auth};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Handle};
|
||||
use tranquil_pds::types::{Did, Handle, InviteCode};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetAccountInfoParams {
|
||||
@@ -39,7 +39,7 @@ pub struct AccountInfo {
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteCodeInfo {
|
||||
pub code: String,
|
||||
pub code: InviteCode,
|
||||
pub available: i32,
|
||||
pub disabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -123,12 +123,12 @@ async fn get_invites_for_user(
|
||||
return None;
|
||||
}
|
||||
|
||||
let code_strings: Vec<String> = invite_codes.iter().map(|ic| ic.code.clone()).collect();
|
||||
let codes: Vec<InviteCode> = invite_codes.iter().map(|ic| ic.code.clone()).collect();
|
||||
|
||||
let uses = state
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_code_uses_batch(&code_strings)
|
||||
.get_invite_code_uses_batch(&codes)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
@@ -157,7 +157,7 @@ async fn get_invites_for_user(
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_invite_code_info(state: &AppState, code: &str) -> Option<InviteCodeInfo> {
|
||||
async fn get_invite_code_info(state: &AppState, code: &InviteCode) -> Option<InviteCodeInfo> {
|
||||
let info = state.repos.infra.get_invite_code_info(code).await.ok()??;
|
||||
|
||||
let uses = state
|
||||
@@ -200,11 +200,11 @@ pub async fn get_account_infos(
|
||||
return Err(ApiError::InvalidRequest("dids is required".into()));
|
||||
}
|
||||
|
||||
let dids_typed: Vec<Did> = dids.iter().filter_map(|d| d.parse().ok()).collect();
|
||||
let dids: Vec<Did> = dids.iter().filter_map(|d| d.parse().ok()).collect();
|
||||
let accounts = state
|
||||
.repos
|
||||
.infra
|
||||
.get_admin_account_infos_by_dids(&dids_typed)
|
||||
.get_admin_account_infos_by_dids(&dids)
|
||||
.await
|
||||
.log_db_err("fetching account infos")?;
|
||||
|
||||
@@ -217,7 +217,7 @@ pub async fn get_account_infos(
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let all_codes: Vec<String> = all_invite_codes
|
||||
let all_codes: Vec<InviteCode> = all_invite_codes
|
||||
.iter()
|
||||
.map(|(_, c)| c.code.clone())
|
||||
.collect();
|
||||
@@ -233,7 +233,7 @@ pub async fn get_account_infos(
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let invited_by_map: HashMap<uuid::Uuid, String> = state
|
||||
let invited_by_map: HashMap<uuid::Uuid, InviteCode> = state
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_code_uses_by_users(&user_ids)
|
||||
@@ -249,7 +249,7 @@ pub async fn get_account_infos(
|
||||
|
||||
let (codes_by_user, code_info_map): (
|
||||
HashMap<uuid::Uuid, Vec<InviteCodeInfo>>,
|
||||
HashMap<String, InviteCodeInfo>,
|
||||
HashMap<InviteCode, InviteCodeInfo>,
|
||||
) = all_invite_codes.into_iter().fold(
|
||||
(HashMap::new(), HashMap::new()),
|
||||
|(mut by_user, mut by_code), (user_id, ic)| {
|
||||
|
||||
@@ -106,7 +106,7 @@ pub async fn update_account_handle(
|
||||
}
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&tranquil_pds::cache_keys::handle_key(&handle))
|
||||
.delete(&tranquil_pds::cache_keys::handle_key(&handle_for_check))
|
||||
.await;
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_identity_event(
|
||||
&state,
|
||||
|
||||
@@ -10,12 +10,13 @@ use tranquil_pds::api::EmptyResponse;
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{Admin, Auth};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_types::{Did, InviteCode};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DisableInviteCodesInput {
|
||||
pub codes: Option<Vec<String>>,
|
||||
pub accounts: Option<Vec<String>>,
|
||||
pub codes: Option<Vec<InviteCode>>,
|
||||
pub accounts: Option<Vec<Did>>,
|
||||
}
|
||||
|
||||
pub async fn disable_invite_codes(
|
||||
@@ -28,17 +29,14 @@ pub async fn disable_invite_codes(
|
||||
{
|
||||
error!("DB error disabling invite codes: {:?}", e);
|
||||
}
|
||||
if let Some(accounts) = &input.accounts {
|
||||
let accounts_typed: Vec<tranquil_types::Did> =
|
||||
accounts.iter().filter_map(|a| a.parse().ok()).collect();
|
||||
if let Err(e) = state
|
||||
if let Some(accounts) = &input.accounts
|
||||
&& let Err(e) = state
|
||||
.repos
|
||||
.infra
|
||||
.disable_invite_codes_by_account(&accounts_typed)
|
||||
.disable_invite_codes_by_account(accounts)
|
||||
.await
|
||||
{
|
||||
error!("DB error disabling invite codes by account: {:?}", e);
|
||||
}
|
||||
{
|
||||
error!("DB error disabling invite codes by account: {:?}", e);
|
||||
}
|
||||
Ok(Json(EmptyResponse {}))
|
||||
}
|
||||
@@ -53,7 +51,7 @@ pub struct GetInviteCodesParams {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteCodeInfo {
|
||||
pub code: String,
|
||||
pub code: InviteCode,
|
||||
pub available: i32,
|
||||
pub disabled: bool,
|
||||
pub for_account: String,
|
||||
@@ -72,7 +70,7 @@ pub struct InviteCodeUseInfo {
|
||||
#[derive(Serialize)]
|
||||
pub struct GetInviteCodesOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
pub cursor: Option<InviteCode>,
|
||||
pub codes: Vec<InviteCodeInfo>,
|
||||
}
|
||||
|
||||
@@ -95,9 +93,9 @@ pub async fn get_invite_codes(
|
||||
.log_db_err("fetching invite codes")?;
|
||||
|
||||
let user_ids: Vec<uuid::Uuid> = codes_rows.iter().map(|r| r.created_by_user).collect();
|
||||
let code_strings: Vec<String> = codes_rows.iter().map(|r| r.code.clone()).collect();
|
||||
let code_values: Vec<InviteCode> = codes_rows.iter().map(|r| r.code.clone()).collect();
|
||||
|
||||
let creator_dids: std::collections::HashMap<uuid::Uuid, tranquil_types::Did> = state
|
||||
let creator_dids: std::collections::HashMap<uuid::Uuid, Did> = state
|
||||
.repos
|
||||
.infra
|
||||
.get_user_dids_by_ids(&user_ids)
|
||||
@@ -106,14 +104,14 @@ pub async fn get_invite_codes(
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let uses_by_code = if code_strings.is_empty() {
|
||||
let uses_by_code = if code_values.is_empty() {
|
||||
std::collections::HashMap::new()
|
||||
} else {
|
||||
common::group_invite_uses_by_code(
|
||||
state
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_code_uses_batch(&code_strings)
|
||||
.get_invite_code_uses_batch(&code_values)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
|u| InviteCodeUseInfo {
|
||||
@@ -167,7 +165,7 @@ pub async fn disable_account_invites(
|
||||
if account.is_empty() {
|
||||
return Err(ApiError::InvalidRequest("account is required".into()));
|
||||
}
|
||||
let account_did: tranquil_types::Did = account
|
||||
let account_did: Did = account
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
|
||||
|
||||
@@ -200,7 +198,7 @@ pub async fn enable_account_invites(
|
||||
if account.is_empty() {
|
||||
return Err(ApiError::InvalidRequest("account is required".into()));
|
||||
}
|
||||
let account_did: tranquil_types::Did = account
|
||||
let account_did: Did = account
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use tracing::error;
|
||||
use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository};
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::api::error::DbResultExt;
|
||||
use tranquil_pds::types::{AtIdentifier, Did, Handle};
|
||||
use tranquil_pds::types::{AtIdentifier, Did, Handle, PasswordHash};
|
||||
|
||||
pub struct ResolvedRepo {
|
||||
pub user_id: uuid::Uuid,
|
||||
@@ -76,7 +76,7 @@ pub async fn resolve_repo_user_id(
|
||||
pub fn group_invite_uses_by_code<U, F>(
|
||||
uses: Vec<tranquil_db_traits::InviteCodeUse>,
|
||||
map_use: F,
|
||||
) -> HashMap<String, Vec<U>>
|
||||
) -> HashMap<tranquil_types::InviteCode, Vec<U>>
|
||||
where
|
||||
F: Fn(tranquil_db_traits::InviteCodeUse) -> U,
|
||||
{
|
||||
@@ -195,12 +195,13 @@ pub fn extract_verification_recipient(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_self_hosted_did_web(handle: &str) -> Result<String, ApiError> {
|
||||
pub fn create_self_hosted_did_web(handle: &str) -> Result<Did, ApiError> {
|
||||
if !tranquil_pds::util::is_self_hosted_did_web_enabled() {
|
||||
return Err(ApiError::SelfHostedDidWebDisabled);
|
||||
}
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
Ok(format!("did:web:{}", encoded_handle))
|
||||
Did::new(format!("did:web:{}", encoded_handle))
|
||||
.map_err(|_| ApiError::InvalidHandle(Some("Handle is not a valid did:web".into())))
|
||||
}
|
||||
|
||||
pub enum CredentialMatch {
|
||||
@@ -216,10 +217,10 @@ pub async fn verify_credential(
|
||||
session_repo: &dyn SessionRepository,
|
||||
user_id: uuid::Uuid,
|
||||
password: &str,
|
||||
password_hash: Option<&str>,
|
||||
password_hash: Option<&PasswordHash>,
|
||||
) -> Option<CredentialMatch> {
|
||||
let main_valid = password_hash
|
||||
.map(|h| bcrypt::verify(password, h).unwrap_or(false))
|
||||
.map(|h| bcrypt::verify(password, h.as_str()).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
if main_valid {
|
||||
return Some(CredentialMatch::MainPassword);
|
||||
@@ -230,7 +231,7 @@ pub async fn verify_credential(
|
||||
.unwrap_or_default();
|
||||
app_passwords
|
||||
.into_iter()
|
||||
.find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false))
|
||||
.find(|app| bcrypt::verify(password, app.password_hash.as_str()).unwrap_or(false))
|
||||
.map(|app| {
|
||||
let scopes = app.scopes.unwrap_or_else(|| {
|
||||
if app.privilege.is_privileged() {
|
||||
@@ -247,14 +248,16 @@ pub async fn verify_credential(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hash_or_internal_error(value: &str) -> Result<String, ApiError> {
|
||||
bcrypt::hash(value, DEFAULT_COST).map_err(|e| {
|
||||
error!("Bcrypt hash error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})
|
||||
pub fn hash_or_internal_error(value: &str) -> Result<PasswordHash, ApiError> {
|
||||
bcrypt::hash(value, DEFAULT_COST)
|
||||
.map(PasswordHash::new)
|
||||
.map_err(|e| {
|
||||
error!("Bcrypt hash error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn hash_password_async(password: &str) -> Result<String, ApiError> {
|
||||
pub async fn hash_password_async(password: &str) -> Result<PasswordHash, ApiError> {
|
||||
let password = password.to_string();
|
||||
tokio::task::spawn_blocking(move || hash(password, DEFAULT_COST))
|
||||
.await
|
||||
@@ -262,6 +265,7 @@ pub async fn hash_password_async(password: &str) -> Result<String, ApiError> {
|
||||
error!("Failed to spawn blocking task: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.map(PasswordHash::new)
|
||||
.map_err(|e| {
|
||||
error!("Failed to hash password: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
|
||||
@@ -12,12 +12,12 @@ use tranquil_pds::api::{
|
||||
};
|
||||
use tranquil_pds::auth::{Active, Auth};
|
||||
use tranquil_pds::delegation::{
|
||||
DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers,
|
||||
verify_can_control_accounts,
|
||||
DelegationActionType, IdentityResolutionError, SCOPE_PRESETS, ValidatedDelegationScope,
|
||||
verify_can_add_controllers, verify_can_control_accounts,
|
||||
};
|
||||
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Handle};
|
||||
use tranquil_pds::types::{CidLink, Did, Handle};
|
||||
|
||||
pub async fn list_controllers(
|
||||
State(state): State<AppState>,
|
||||
@@ -38,11 +38,10 @@ pub async fn list_controllers(
|
||||
async move {
|
||||
if c.handle.is_none() {
|
||||
c.handle = did_resolver
|
||||
.fetch_did_document(c.did.as_str())
|
||||
.fetch_did_document(&c.did)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|doc| tranquil_types::did_doc::extract_handle(&doc))
|
||||
.map(Into::into);
|
||||
.and_then(|doc| tranquil_types::did_doc::extract_handle(&doc));
|
||||
}
|
||||
c
|
||||
}
|
||||
@@ -66,19 +65,19 @@ pub async fn add_controller(
|
||||
) -> Result<Json<SuccessResponse>, ApiError> {
|
||||
let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did)
|
||||
.await
|
||||
.map_err(|_| ApiError::ControllerNotFound)?;
|
||||
.map_err(|e| match e {
|
||||
IdentityResolutionError::PdsEndpoint(_) => ApiError::InvalidDelegation(
|
||||
"Controller PDS endpoint isn't a usable https URL".into(),
|
||||
),
|
||||
IdentityResolutionError::DidResolution(_) => ApiError::ControllerNotFound,
|
||||
})?;
|
||||
|
||||
if !resolved.is_local
|
||||
&& let Some(ref pds_url) = resolved.pds_url
|
||||
{
|
||||
if !pds_url.starts_with("https://") {
|
||||
return Err(ApiError::InvalidDelegation(
|
||||
"Controller PDS must use HTTPS".into(),
|
||||
));
|
||||
}
|
||||
match state
|
||||
.cross_pds_oauth
|
||||
.check_remote_is_delegated(pds_url, input.controller_did.as_str())
|
||||
.check_remote_is_delegated(pds_url, &input.controller_did)
|
||||
.await
|
||||
{
|
||||
Some(true) => {
|
||||
@@ -174,9 +173,7 @@ pub async fn remove_controller(
|
||||
.session
|
||||
.delete_app_passwords_by_controller(&auth.did, &input.controller_did)
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
.try_into()
|
||||
.unwrap_or(0usize);
|
||||
.unwrap_or(0);
|
||||
|
||||
let revoked_oauth_tokens = state
|
||||
.repos
|
||||
@@ -232,6 +229,20 @@ pub async fn update_controller_scopes(
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
let revoked_app_passwords = state
|
||||
.repos
|
||||
.session
|
||||
.delete_app_passwords_by_controller(&auth.did, &input.controller_did)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let revoked_oauth_tokens = state
|
||||
.repos
|
||||
.oauth
|
||||
.revoke_tokens_for_controller(&auth.did, &input.controller_did)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let _ = state
|
||||
.repos
|
||||
.delegation
|
||||
@@ -241,7 +252,9 @@ pub async fn update_controller_scopes(
|
||||
Some(&input.controller_did),
|
||||
DelegationActionType::ScopesModified,
|
||||
Some(json!({
|
||||
"new_scopes": input.granted_scopes.as_str()
|
||||
"new_scopes": input.granted_scopes.as_str(),
|
||||
"revoked_app_passwords": revoked_app_passwords,
|
||||
"revoked_oauth_tokens": revoked_oauth_tokens
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
@@ -330,7 +343,6 @@ pub struct CreateDelegatedAccountInput {
|
||||
pub handle: String,
|
||||
pub email: Option<String>,
|
||||
pub controller_scopes: ValidatedDelegationScope,
|
||||
pub invite_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -362,25 +374,11 @@ pub async fn create_delegated_account(
|
||||
return Err(ApiError::InvalidEmail);
|
||||
}
|
||||
|
||||
let validated_invite_code = if let Some(ref code) = input.invite_code {
|
||||
match state.repos.infra.validate_invite_code(code).await {
|
||||
Ok(validated) => Some(validated),
|
||||
Err(_) => return Err(ApiError::InvalidInviteCode),
|
||||
}
|
||||
} else {
|
||||
let invite_required = tranquil_config::get().server.invite_code_required;
|
||||
if invite_required {
|
||||
return Err(ApiError::InviteCodeRequired);
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let plc = create_plc_did(&state, &handle).await.map_err(|e| {
|
||||
tracing::error!("PLC DID creation failed: {:?}", e);
|
||||
e
|
||||
})?;
|
||||
let did = plc.did;
|
||||
let handle: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?;
|
||||
info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account");
|
||||
|
||||
let repo = init_genesis_repo(&state, &did, &plc.signing_key, &plc.signing_key_bytes).await?;
|
||||
@@ -394,19 +392,18 @@ pub async fn create_delegated_account(
|
||||
controller_scopes: input.controller_scopes.as_str().to_string(),
|
||||
encrypted_key_bytes: repo.encrypted_key_bytes,
|
||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||
commit_cid: repo.commit_cid.to_string(),
|
||||
commit_cid: CidLink::from(&repo.commit_cid),
|
||||
repo_rev: repo.repo_rev.clone(),
|
||||
genesis_block_cids: repo.genesis_block_cids,
|
||||
invite_code: input.invite_code.clone(),
|
||||
};
|
||||
|
||||
let user_id = match state
|
||||
match state
|
||||
.repos
|
||||
.user
|
||||
.create_delegated_account(&create_input)
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Ok(_) => {}
|
||||
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
|
||||
return Err(ApiError::HandleNotAvailable(None));
|
||||
}
|
||||
@@ -417,16 +414,6 @@ pub async fn create_delegated_account(
|
||||
error!("Error creating delegated account: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(validated) = validated_invite_code
|
||||
&& let Err(e) = state
|
||||
.repos
|
||||
.infra
|
||||
.record_invite_code_use(&validated, user_id)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to record invite code use for {}: {:?}", did, e);
|
||||
}
|
||||
|
||||
crate::identity::provision::sequence_new_account(
|
||||
@@ -476,24 +463,26 @@ pub async fn resolve_controller(
|
||||
.parse()
|
||||
.map_err(|_| ApiError::ControllerNotFound)?
|
||||
} else {
|
||||
let local_handle: Option<Handle> = identifier.parse().ok();
|
||||
let local_user = match local_handle {
|
||||
Some(ref h) => state.repos.user.get_by_handle(h).await.ok().flatten(),
|
||||
None => None,
|
||||
};
|
||||
let handle: Handle = identifier
|
||||
.parse()
|
||||
.map_err(|_| ApiError::ControllerNotFound)?;
|
||||
let local_user = state.repos.user.get_by_handle(&handle).await.ok().flatten();
|
||||
match local_user {
|
||||
Some(user) => user.did,
|
||||
None => tranquil_pds::handle::resolve_handle(identifier)
|
||||
None => tranquil_pds::handle::resolve_handle(&handle)
|
||||
.await
|
||||
.map_err(|_| ApiError::ControllerNotFound)?
|
||||
.parse()
|
||||
.map_err(|_| ApiError::ControllerNotFound)?,
|
||||
}
|
||||
};
|
||||
|
||||
let resolved = tranquil_pds::delegation::resolve_identity(&state, &did)
|
||||
.await
|
||||
.map_err(|_| ApiError::ControllerNotFound)?;
|
||||
.map_err(|e| match e {
|
||||
IdentityResolutionError::PdsEndpoint(_) => ApiError::InvalidDelegation(
|
||||
"Controller PDS endpoint isn't a usable https URL".into(),
|
||||
),
|
||||
IdentityResolutionError::DidResolution(_) => ApiError::ControllerNotFound,
|
||||
})?;
|
||||
|
||||
Ok(Json(resolved))
|
||||
}
|
||||
|
||||
@@ -149,17 +149,21 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
|
||||
}
|
||||
};
|
||||
|
||||
let handle = parse_start_handle(interaction.data.as_ref().and_then(|d| d.options.as_deref()));
|
||||
|
||||
if let Some(ref h) = handle
|
||||
&& Handle::new(h).is_err()
|
||||
let handle = match parse_start_handle(
|
||||
interaction.data.as_ref().and_then(|d| d.options.as_deref()),
|
||||
)
|
||||
.map(Handle::new)
|
||||
.transpose()
|
||||
{
|
||||
return Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64}
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": "Invalid handle format. Handle should look like: nel.oyster.cafe", "flags": 64}
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
discord_username = %discord_username,
|
||||
@@ -171,7 +175,7 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
|
||||
match state
|
||||
.repos
|
||||
.user
|
||||
.store_discord_user_id(&discord_username, &discord_user_id, handle.as_deref())
|
||||
.store_discord_user_id(&discord_username, &discord_user_id, handle.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(Some(user_id)) => {
|
||||
|
||||
@@ -10,10 +10,11 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{debug, error, info};
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::api::invite::check_registration_invite;
|
||||
use tranquil_pds::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token};
|
||||
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Handle, PlainPassword};
|
||||
use tranquil_pds::types::{CidLink, Did, Handle, Nsid, PlainPassword};
|
||||
use tranquil_pds::validation::validate_password;
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -24,7 +25,7 @@ pub struct CreateAccountInput {
|
||||
pub invite_code: Option<String>,
|
||||
pub did: Option<String>,
|
||||
pub did_type: Option<String>,
|
||||
pub signing_key: Option<String>,
|
||||
pub signing_key: Option<Did>,
|
||||
pub verification_channel: Option<tranquil_db_traits::CommsChannel>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
@@ -46,23 +47,15 @@ pub struct CreateAccountOutput {
|
||||
|
||||
async fn try_reactivate_migration(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
handle: &str,
|
||||
did: &Did,
|
||||
handle: &Handle,
|
||||
email: &Option<String>,
|
||||
verification_channel: tranquil_db_traits::CommsChannel,
|
||||
verification_recipient: Option<&str>,
|
||||
) -> Option<Response> {
|
||||
let did_typed: Did = match did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Some(ApiError::InternalError(Some("Invalid DID".into())).into_response()),
|
||||
};
|
||||
let handle_typed: Handle = match handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Some(ApiError::InvalidHandle(None).into_response()),
|
||||
};
|
||||
let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
|
||||
did: did_typed.clone(),
|
||||
new_handle: handle_typed.clone(),
|
||||
did: did.clone(),
|
||||
new_handle: handle.clone(),
|
||||
new_email: email.clone(),
|
||||
};
|
||||
match state
|
||||
@@ -72,7 +65,7 @@ async fn try_reactivate_migration(
|
||||
.await
|
||||
{
|
||||
Ok(reactivated) => {
|
||||
info!(did = %did, old_handle = %reactivated.old_handle, new_handle = %handle, "Preparing existing account for inbound migration");
|
||||
info!(did = %did, old_handle = ?reactivated.old_handle, new_handle = %handle, "Preparing existing account for inbound migration");
|
||||
let secret_key_bytes = match state
|
||||
.repos
|
||||
.user
|
||||
@@ -119,7 +112,7 @@ async fn try_reactivate_migration(
|
||||
}
|
||||
};
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: did_typed.clone(),
|
||||
did: did.clone(),
|
||||
access_jti: access_meta.jti.clone(),
|
||||
refresh_jti: refresh_meta.jti.clone(),
|
||||
access_expires_at: access_meta.expires_at,
|
||||
@@ -139,7 +132,7 @@ async fn try_reactivate_migration(
|
||||
super::provision::enqueue_migration_verification(
|
||||
state,
|
||||
reactivated.user_id,
|
||||
&did_typed,
|
||||
did,
|
||||
verification_channel,
|
||||
recipient,
|
||||
)
|
||||
@@ -152,14 +145,9 @@ async fn try_reactivate_migration(
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.to_string().into(),
|
||||
did: did_typed.clone(),
|
||||
did_doc: state
|
||||
.did_resolver
|
||||
.fetch_did_document(did)
|
||||
.await
|
||||
.ok()
|
||||
.map(|f| (*f).clone()),
|
||||
handle: handle.clone(),
|
||||
did: did.clone(),
|
||||
did_doc: state.did_resolver.fetch_did_document(did).await.ok(),
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
verification_required,
|
||||
@@ -209,8 +197,10 @@ pub async fn create_account(
|
||||
let token = extracted.token;
|
||||
if is_service_token(&token) {
|
||||
let verifier = ServiceTokenVerifier::new();
|
||||
let create_account_lxm = Nsid::new("com.atproto.server.createAccount")
|
||||
.expect("com.atproto.server.createAccount is a valid NSID");
|
||||
match verifier
|
||||
.verify_service_token(&token, Some("com.atproto.server.createAccount"))
|
||||
.verify_service_token(&token, Some(&create_account_lxm))
|
||||
.await
|
||||
{
|
||||
Ok(claims) => {
|
||||
@@ -307,7 +297,7 @@ pub async fn create_account(
|
||||
};
|
||||
let hostname = &cfg.server.hostname;
|
||||
let key_result =
|
||||
match super::provision::resolve_signing_key(&state, input.signing_key.as_deref()).await {
|
||||
match super::provision::resolve_signing_key(&state, input.signing_key.as_ref()).await {
|
||||
Ok(k) => k,
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
@@ -315,7 +305,7 @@ pub async fn create_account(
|
||||
let signing_key = key_result.signing_key;
|
||||
let reserved_key_id = key_result.reserved_key_id;
|
||||
let did_type = input.did_type.as_deref().unwrap_or("plc");
|
||||
let did = match did_type {
|
||||
let did: Did = match did_type {
|
||||
"web" => {
|
||||
let self_hosted_did = match common::create_self_hosted_did_web(&handle) {
|
||||
Ok(d) => d,
|
||||
@@ -340,27 +330,42 @@ pub async fn create_account(
|
||||
}
|
||||
if !is_did_web_byod
|
||||
&& let Err(e) =
|
||||
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref()).await
|
||||
verify_did_web(d, hostname, &input.handle, input.signing_key.as_ref()).await
|
||||
{
|
||||
return ApiError::InvalidDid(e.to_string()).into_response();
|
||||
}
|
||||
info!(did = %d, "Creating external did:web account");
|
||||
d.clone()
|
||||
match d.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InvalidDid("Invalid DID format".into()).into_response(),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(d) = &input.did {
|
||||
if d.starts_with("did:plc:") && is_migration {
|
||||
info!(did = %d, "Migration with existing did:plc");
|
||||
d.clone()
|
||||
match d.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return ApiError::InvalidDid("Invalid DID format".into())
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else if d.starts_with("did:web:") {
|
||||
if !is_did_web_byod
|
||||
&& let Err(e) =
|
||||
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref())
|
||||
verify_did_web(d, hostname, &input.handle, input.signing_key.as_ref())
|
||||
.await
|
||||
{
|
||||
return ApiError::InvalidDid(e.to_string()).into_response();
|
||||
}
|
||||
d.clone()
|
||||
match d.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return ApiError::InvalidDid("Invalid DID format".into())
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else if !d.trim().is_empty() {
|
||||
return ApiError::InvalidDid(
|
||||
"Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth.".into()
|
||||
@@ -395,14 +400,10 @@ pub async fn create_account(
|
||||
return response;
|
||||
}
|
||||
|
||||
let handle_typed: Handle = match handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return ApiError::InvalidHandle(None).into_response(),
|
||||
};
|
||||
let handle_available = match state
|
||||
.repos
|
||||
.user
|
||||
.check_handle_available_for_new_account(&handle_typed)
|
||||
.check_handle_available_for_new_account(&handle)
|
||||
.await
|
||||
{
|
||||
Ok(available) => available,
|
||||
@@ -415,40 +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();
|
||||
@@ -465,23 +437,15 @@ pub async fn create_account(
|
||||
None
|
||||
};
|
||||
|
||||
let did_for_commit: Did = match did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(),
|
||||
};
|
||||
let repo = match super::provision::init_genesis_repo(
|
||||
&state,
|
||||
&did_for_commit,
|
||||
&signing_key,
|
||||
&secret_key_bytes,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
let commit_cid_str = repo.commit_cid.to_string();
|
||||
let rev_str = repo.repo_rev.clone();
|
||||
let repo =
|
||||
match super::provision::init_genesis_repo(&state, &did, &signing_key, &secret_key_bytes)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
let commit_cid = CidLink::from(&repo.commit_cid);
|
||||
let repo_rev = repo.repo_rev.clone();
|
||||
|
||||
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
|
||||
Some(json!({
|
||||
@@ -501,9 +465,9 @@ pub async fn create_account(
|
||||
let repo_for_seq = repo.clone();
|
||||
|
||||
let create_input = tranquil_db_traits::CreatePasswordAccountInput {
|
||||
handle: handle_typed.clone(),
|
||||
handle: handle.clone(),
|
||||
email: email.clone(),
|
||||
did: did_for_commit.clone(),
|
||||
did: did.clone(),
|
||||
password_hash,
|
||||
preferred_comms_channel,
|
||||
discord_username: comms.discord,
|
||||
@@ -514,14 +478,10 @@ pub async fn create_account(
|
||||
encrypted_key_bytes: repo.encrypted_key_bytes,
|
||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||
reserved_key_id,
|
||||
commit_cid: commit_cid_str.clone(),
|
||||
repo_rev: rev_str.clone(),
|
||||
commit_cid: commit_cid.clone(),
|
||||
repo_rev: repo_rev.clone(),
|
||||
genesis_block_cids: repo.genesis_block_cids,
|
||||
invite_code: if is_bootstrap {
|
||||
None
|
||||
} else {
|
||||
input.invite_code.clone()
|
||||
},
|
||||
invite_code: invite_registration.into_invite_code(),
|
||||
birthdate_pref,
|
||||
};
|
||||
|
||||
@@ -541,6 +501,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();
|
||||
@@ -548,21 +511,15 @@ pub async fn create_account(
|
||||
};
|
||||
let user_id = create_result.user_id;
|
||||
if !is_migration && !is_did_web_byod {
|
||||
super::provision::sequence_new_account(
|
||||
&state,
|
||||
&did_for_commit,
|
||||
&handle_typed,
|
||||
&repo_for_seq,
|
||||
&input.handle,
|
||||
)
|
||||
.await;
|
||||
super::provision::sequence_new_account(&state, &did, &handle, &repo_for_seq, &input.handle)
|
||||
.await;
|
||||
}
|
||||
if !is_migration {
|
||||
if let Some(ref recipient) = verification_recipient {
|
||||
super::provision::enqueue_signup_verification(
|
||||
&state,
|
||||
user_id,
|
||||
&did_for_commit,
|
||||
&did,
|
||||
verification_channel,
|
||||
recipient,
|
||||
)
|
||||
@@ -572,7 +529,7 @@ pub async fn create_account(
|
||||
super::provision::enqueue_migration_verification(
|
||||
&state,
|
||||
user_id,
|
||||
&did_for_commit,
|
||||
&did,
|
||||
verification_channel,
|
||||
recipient,
|
||||
)
|
||||
@@ -582,7 +539,6 @@ pub async fn create_account(
|
||||
let session = match super::provision::create_and_store_session(
|
||||
&state,
|
||||
&did,
|
||||
&did_for_commit,
|
||||
&secret_key_bytes,
|
||||
"transition:generic transition:chat.bsky",
|
||||
None,
|
||||
@@ -605,9 +561,9 @@ pub async fn create_account(
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.clone().into(),
|
||||
did: did_for_commit,
|
||||
did_doc: did_doc.map(|f| (*f).clone()),
|
||||
handle: handle.clone(),
|
||||
did,
|
||||
did_doc,
|
||||
access_jwt: session.access_jwt,
|
||||
refresh_jwt: session.refresh_jwt,
|
||||
verification_required: !is_migration,
|
||||
|
||||
@@ -19,7 +19,7 @@ use tranquil_pds::rate_limit::{
|
||||
HandleUpdateDailyLimit, HandleUpdateLimit, check_user_rate_limit_with_message,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::Handle;
|
||||
use tranquil_pds::types::{Did, Handle};
|
||||
use tranquil_pds::util::get_header_str;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -44,16 +44,18 @@ pub async fn resolve_handle(
|
||||
if handle_str.is_empty() {
|
||||
return ApiError::InvalidRequest("handle is required".into()).into_response();
|
||||
}
|
||||
let cache_key = tranquil_pds::cache_keys::handle_key(handle_str);
|
||||
if let Some(did) = state.cache.get(&cache_key).await {
|
||||
return DidResponse::response(did).into_response();
|
||||
}
|
||||
let handle: Handle = match handle_str.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
|
||||
}
|
||||
};
|
||||
let cache_key = tranquil_pds::cache_keys::handle_key(&handle);
|
||||
if let Some(cached) = state.cache.get(&cache_key).await
|
||||
&& let Ok(did) = tranquil_pds::Did::new(cached)
|
||||
{
|
||||
return DidResponse::response(did).into_response();
|
||||
}
|
||||
let user = state.repos.user.get_by_handle(&handle).await;
|
||||
match user {
|
||||
Ok(Some(row)) => {
|
||||
@@ -63,7 +65,7 @@ pub async fn resolve_handle(
|
||||
.await;
|
||||
DidResponse::response(row.did).into_response()
|
||||
}
|
||||
Ok(None) => match tranquil_pds::handle::resolve_handle(handle.as_str()).await {
|
||||
Ok(None) => match tranquil_pds::handle::resolve_handle(&handle).await {
|
||||
Ok(did) => {
|
||||
let _ = state
|
||||
.cache
|
||||
@@ -159,15 +161,15 @@ pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -
|
||||
|
||||
async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response {
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
let expected_did = format!("did:web:{}", encoded_handle);
|
||||
let expected_did_typed: tranquil_pds::types::Did = match expected_did.parse() {
|
||||
let expected_did: tranquil_pds::types::Did = match format!("did:web:{}", encoded_handle).parse()
|
||||
{
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InvalidRequest("Invalid DID format".into()).into_response(),
|
||||
};
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_user_for_did_doc_build(&expected_did_typed)
|
||||
.get_user_for_did_doc_build(&expected_did)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
@@ -212,8 +214,7 @@ async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) ->
|
||||
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let current_handle = format!("{}.{}", handle, hostname_for_handles);
|
||||
let current_handle_typed: Handle = match current_handle.parse() {
|
||||
let current_handle: Handle = match format!("{}.{}", handle, hostname_for_handles).parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
|
||||
@@ -222,7 +223,7 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_did_web_info_by_handle(¤t_handle_typed)
|
||||
.get_did_web_info_by_handle(¤t_handle)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
@@ -347,7 +348,7 @@ pub async fn verify_did_web(
|
||||
did: &str,
|
||||
hostname: &str,
|
||||
handle: &str,
|
||||
expected_signing_key: Option<&str>,
|
||||
expected_signing_key: Option<&Did>,
|
||||
) -> Result<(), DidWebVerifyError> {
|
||||
let hostname_for_handles = hostname.split(':').next().unwrap_or(hostname);
|
||||
let subdomain_host = format!("{}.{}", handle, hostname_for_handles);
|
||||
@@ -426,10 +427,15 @@ pub async fn verify_did_web(
|
||||
let expected_multibase = expected_signing_key
|
||||
.strip_prefix("did:key:")
|
||||
.ok_or(DidWebVerifyError::InvalidSigningKey)?;
|
||||
let did_prefixed_key_id = format!("{}#atproto", did);
|
||||
let has_matching_key = verification_methods.iter().any(|vm| {
|
||||
vm["publicKeyMultibase"]
|
||||
let is_atproto_method = vm["id"]
|
||||
.as_str()
|
||||
.is_some_and(|pk| pk == expected_multibase)
|
||||
.is_some_and(|id| id == "#atproto" || id == did_prefixed_key_id);
|
||||
is_atproto_method
|
||||
&& vm["publicKeyMultibase"]
|
||||
.as_str()
|
||||
.is_some_and(|pk| pk == expected_multibase)
|
||||
});
|
||||
if !has_matching_key {
|
||||
return Err(DidWebVerifyError::KeyMismatch(
|
||||
@@ -491,16 +497,10 @@ pub async fn get_recommended_did_credentials(
|
||||
let rotation_keys = if auth.did.starts_with("did:web:") {
|
||||
vec![]
|
||||
} else {
|
||||
let server_rotation_key = match &tranquil_config::get().secrets.plc_rotation_key {
|
||||
Some(key) => key.clone(),
|
||||
None => {
|
||||
warn!(
|
||||
"PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation"
|
||||
);
|
||||
did_key.clone()
|
||||
}
|
||||
};
|
||||
vec![server_rotation_key]
|
||||
tranquil_pds::plc::rotation_keys_for(
|
||||
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
|
||||
&signing_key,
|
||||
)
|
||||
};
|
||||
Ok(Json(GetRecommendedDidCredentialsOutput {
|
||||
rotation_keys,
|
||||
@@ -589,7 +589,7 @@ pub async fn update_handle(
|
||||
.max_by_key(|d| d.len())
|
||||
.cloned();
|
||||
let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle);
|
||||
let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some())
|
||||
let handle: Handle = if (!new_handle.contains('.') || matched_handle_domain.is_some())
|
||||
&& !is_domain_itself
|
||||
{
|
||||
let (short_part, full_handle) = match &matched_handle_domain {
|
||||
@@ -604,13 +604,12 @@ pub async fn update_handle(
|
||||
}
|
||||
};
|
||||
if full_handle == current_handle {
|
||||
let handle_typed: Handle = match full_handle.parse() {
|
||||
let handle: Handle = match full_handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||
};
|
||||
if let Err(e) =
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle_typed))
|
||||
.await
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for handle update: {}", e);
|
||||
}
|
||||
@@ -628,21 +627,21 @@ pub async fn update_handle(
|
||||
return Err(ApiError::InvalidHandle(Some("Handle too long".into())));
|
||||
}
|
||||
full_handle
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?
|
||||
} else {
|
||||
let handle: Handle = new_handle
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?;
|
||||
if new_handle == current_handle {
|
||||
let handle_typed: Handle = match new_handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||
};
|
||||
if let Err(e) =
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle_typed))
|
||||
.await
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for handle update: {}", e);
|
||||
}
|
||||
return Ok(Json(EmptyResponse {}));
|
||||
}
|
||||
match tranquil_pds::handle::verify_handle_ownership(&new_handle, &did).await {
|
||||
match tranquil_pds::handle::verify_handle_ownership(&handle, &did).await {
|
||||
Ok(()) => {}
|
||||
Err(tranquil_pds::handle::HandleResolutionError::NotFound) => {
|
||||
return Err(ApiError::HandleNotAvailable(None));
|
||||
@@ -661,15 +660,12 @@ pub async fn update_handle(
|
||||
))));
|
||||
}
|
||||
}
|
||||
new_handle.clone()
|
||||
handle
|
||||
};
|
||||
let handle_typed: Handle = handle
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?;
|
||||
let handle_exists = state
|
||||
.repos
|
||||
.user
|
||||
.check_handle_exists(&handle_typed, user_id)
|
||||
.check_handle_exists(&handle, user_id)
|
||||
.await
|
||||
.log_db_err("checking handle existence")?;
|
||||
if handle_exists {
|
||||
@@ -678,7 +674,7 @@ pub async fn update_handle(
|
||||
state
|
||||
.repos
|
||||
.user
|
||||
.update_handle(user_id, &handle_typed)
|
||||
.update_handle(user_id, &handle)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error updating handle: {:?}", e);
|
||||
@@ -696,11 +692,11 @@ pub async fn update_handle(
|
||||
.delete(&tranquil_pds::cache_keys::handle_key(&handle))
|
||||
.await;
|
||||
if let Err(e) =
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle_typed)).await
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for handle update: {}", e);
|
||||
}
|
||||
if let Err(e) = update_plc_handle(&state, &did, &handle_typed).await {
|
||||
if let Err(e) = update_plc_handle(&state, &did, &handle).await {
|
||||
warn!("Failed to update PLC handle: {}", e);
|
||||
}
|
||||
Ok(Json(EmptyResponse {}))
|
||||
|
||||
@@ -26,10 +26,9 @@ pub async fn verify_handle_ownership(
|
||||
_rate_limit: RateLimited<HandleVerificationLimit>,
|
||||
Json(input): Json<VerifyHandleOwnershipInput>,
|
||||
) -> Response {
|
||||
let handle_str = input.handle.as_str();
|
||||
let did_str = input.did.as_str();
|
||||
|
||||
let dns_mismatch = match tranquil_pds::handle::resolve_handle_dns(handle_str).await {
|
||||
let dns_mismatch = match tranquil_pds::handle::resolve_handle_dns(&input.handle).await {
|
||||
Ok(did) if did == did_str => {
|
||||
return Json(VerifyHandleOwnershipOutput {
|
||||
verified: true,
|
||||
@@ -45,7 +44,7 @@ pub async fn verify_handle_ownership(
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
match tranquil_pds::handle::resolve_handle_http(handle_str).await {
|
||||
match tranquil_pds::handle::resolve_handle_http(&input.handle).await {
|
||||
Ok(did) if did == did_str => Json(VerifyHandleOwnershipOutput {
|
||||
verified: true,
|
||||
method: Some("http".to_string()),
|
||||
|
||||
@@ -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, 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)]
|
||||
@@ -43,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
|
||||
@@ -114,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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use jacquard_common::types::{integer::LimitedU32, string::Tid};
|
||||
use jacquard_common::types::{integer::LimitedU32, string::Tid as JacquardTid};
|
||||
use jacquard_repo::{mst::Mst, storage::BlockStore};
|
||||
use k256::ecdsa::SigningKey;
|
||||
use std::sync::Arc;
|
||||
@@ -6,7 +6,7 @@ use tranquil_db_traits::CommsChannel;
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::repo_ops::create_signed_commit;
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Handle};
|
||||
use tranquil_pds::types::{CidLink, Did, Handle, Tid};
|
||||
|
||||
pub struct PlcDidResult {
|
||||
pub did: Did,
|
||||
@@ -14,7 +14,7 @@ pub struct PlcDidResult {
|
||||
pub signing_key: SigningKey,
|
||||
}
|
||||
|
||||
pub async fn create_plc_did(state: &AppState, handle: &str) -> Result<PlcDidResult, ApiError> {
|
||||
pub async fn create_plc_did(state: &AppState, handle: &Handle) -> Result<PlcDidResult, ApiError> {
|
||||
use k256::SecretKey;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
@@ -25,10 +25,7 @@ pub async fn create_plc_did(state: &AppState, handle: &str) -> Result<PlcDidResu
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let did_str = submit_plc_genesis(state, &signing_key, handle).await?;
|
||||
let did: Did = did_str
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InternalError(Some("PLC genesis returned invalid DID".into())))?;
|
||||
let did = submit_plc_genesis(state, &signing_key, handle).await?;
|
||||
|
||||
Ok(PlcDidResult {
|
||||
did,
|
||||
@@ -40,20 +37,14 @@ pub async fn create_plc_did(state: &AppState, handle: &str) -> Result<PlcDidResu
|
||||
pub async fn submit_plc_genesis(
|
||||
state: &AppState,
|
||||
signing_key: &SigningKey,
|
||||
handle: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
handle: &Handle,
|
||||
) -> Result<Did, ApiError> {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
|
||||
let rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(signing_key));
|
||||
|
||||
let genesis_result = tranquil_pds::plc::create_genesis_operation(
|
||||
signing_key,
|
||||
&rotation_key,
|
||||
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
|
||||
handle,
|
||||
&pds_endpoint,
|
||||
)
|
||||
@@ -80,7 +71,7 @@ pub struct GenesisRepo {
|
||||
pub encrypted_key_bytes: Vec<u8>,
|
||||
pub commit_cid: cid::Cid,
|
||||
pub mst_root_cid: cid::Cid,
|
||||
pub repo_rev: String,
|
||||
pub repo_rev: Tid,
|
||||
pub genesis_block_cids: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
@@ -102,8 +93,8 @@ pub async fn init_genesis_repo(
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let (commit_bytes, _sig) = create_signed_commit(did, mst_root, rev.as_ref(), None, signing_key)
|
||||
let rev = JacquardTid::now(LimitedU32::MIN);
|
||||
let (commit_bytes, _sig) = create_signed_commit(did, mst_root, &rev, None, signing_key)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error creating genesis commit: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
@@ -118,7 +109,7 @@ pub async fn init_genesis_repo(
|
||||
encrypted_key_bytes,
|
||||
commit_cid,
|
||||
mst_root_cid: mst_root,
|
||||
repo_rev: rev.as_ref().to_string(),
|
||||
repo_rev: Tid::from(rev.clone()),
|
||||
genesis_block_cids: vec![mst_root.to_bytes(), commit_cid.to_bytes()],
|
||||
})
|
||||
}
|
||||
@@ -131,7 +122,7 @@ pub struct SigningKeyResult {
|
||||
|
||||
pub async fn resolve_signing_key(
|
||||
state: &AppState,
|
||||
signing_key_did: Option<&str>,
|
||||
signing_key_did: Option<&Did>,
|
||||
) -> Result<SigningKeyResult, ApiError> {
|
||||
match signing_key_did {
|
||||
Some(key_did) => {
|
||||
@@ -173,6 +164,13 @@ pub async fn resolve_signing_key(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
not(feature = "bsky"),
|
||||
expect(
|
||||
unused_variables,
|
||||
reason = "only the bsky block writes display_name into the default profile record"
|
||||
)
|
||||
)]
|
||||
pub async fn sequence_new_account(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
@@ -207,27 +205,31 @@ pub async fn sequence_new_account(
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event(
|
||||
state,
|
||||
did,
|
||||
&repo.commit_cid.to_string(),
|
||||
&CidLink::from(&repo.commit_cid),
|
||||
Some(&repo.repo_rev),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to sequence sync event for {}: {}", did, e);
|
||||
}
|
||||
let profile_record = serde_json::json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": display_name
|
||||
});
|
||||
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
|
||||
state,
|
||||
did,
|
||||
&tranquil_pds::types::PROFILE_COLLECTION,
|
||||
&tranquil_pds::types::PROFILE_RKEY,
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
// TODO: make this configurable and also deduplicate with tranquil-oauth-server/src/sso_endpoints.rs:1210
|
||||
#[cfg(feature = "bsky")]
|
||||
{
|
||||
tracing::warn!("Failed to create default profile for {}: {}", did, e);
|
||||
let profile_record = serde_json::json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": display_name
|
||||
});
|
||||
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
|
||||
state,
|
||||
did,
|
||||
&tranquil_pds::types::PROFILE_COLLECTION,
|
||||
&tranquil_pds::types::PROFILE_RKEY,
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to create default profile for {}: {}", did, e);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,25 +266,23 @@ pub struct SessionResult {
|
||||
|
||||
pub async fn create_and_store_session(
|
||||
state: &AppState,
|
||||
did_str: &str,
|
||||
did: &Did,
|
||||
signing_key_bytes: &[u8],
|
||||
scope: &str,
|
||||
controller_did: Option<&Did>,
|
||||
) -> Result<SessionResult, ApiError> {
|
||||
let access_meta =
|
||||
tranquil_pds::auth::create_access_token_with_metadata(did_str, signing_key_bytes).map_err(
|
||||
let access_meta = tranquil_pds::auth::create_access_token_with_metadata(did, signing_key_bytes)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error creating access token: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let refresh_meta =
|
||||
tranquil_pds::auth::create_refresh_token_with_metadata(did, signing_key_bytes).map_err(
|
||||
|e| {
|
||||
tracing::error!("Error creating access token: {:?}", e);
|
||||
tracing::error!("Error creating refresh token: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
},
|
||||
)?;
|
||||
let refresh_meta =
|
||||
tranquil_pds::auth::create_refresh_token_with_metadata(did_str, signing_key_bytes)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error creating refresh token: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: did.clone(),
|
||||
access_jti: access_meta.jti.clone(),
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
pub mod actor;
|
||||
// BSKY: Bluesky requires PDSs to implement its private preferences API
|
||||
#[cfg(feature = "bsky-support")]
|
||||
pub mod actor {
|
||||
mod preferences;
|
||||
|
||||
pub use preferences::{get_preferences, put_preferences};
|
||||
}
|
||||
pub mod admin;
|
||||
#[cfg(feature = "bsky")]
|
||||
pub mod age_assurance;
|
||||
pub mod common;
|
||||
pub mod delegation;
|
||||
@@ -15,9 +22,13 @@ pub mod temp;
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
pub fn api_routes() -> axum::Router<AppState> {
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{get, post};
|
||||
|
||||
axum::Router::new()
|
||||
let blob_body_limit =
|
||||
DefaultBodyLimit::max(tranquil_config::get().server.max_blob_size as usize);
|
||||
|
||||
let router = axum::Router::new()
|
||||
.route("/_health", get(server::health))
|
||||
.route(
|
||||
"/com.atproto.server.describeServer",
|
||||
@@ -68,7 +79,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 +261,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),
|
||||
@@ -363,14 +380,6 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
post(admin::update_subject_status),
|
||||
)
|
||||
.route("/com.atproto.admin.sendEmail", post(admin::send_email))
|
||||
.route(
|
||||
"/app.bsky.actor.getPreferences",
|
||||
get(actor::get_preferences),
|
||||
)
|
||||
.route(
|
||||
"/app.bsky.actor.putPreferences",
|
||||
post(actor::put_preferences),
|
||||
)
|
||||
.route(
|
||||
"/com.atproto.temp.checkSignupQueue",
|
||||
get(temp::check_signup_queue),
|
||||
@@ -428,7 +437,21 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
.route(
|
||||
"/_delegation.resolveController",
|
||||
get(delegation::resolve_controller),
|
||||
);
|
||||
|
||||
#[cfg(feature = "bsky-support")]
|
||||
let router = router
|
||||
.route(
|
||||
"/app.bsky.actor.getPreferences",
|
||||
get(actor::get_preferences),
|
||||
)
|
||||
.route(
|
||||
"/app.bsky.actor.putPreferences",
|
||||
post(actor::put_preferences),
|
||||
);
|
||||
|
||||
#[cfg(feature = "bsky")]
|
||||
let router = router
|
||||
.route(
|
||||
"/app.bsky.ageassurance.getState",
|
||||
get(age_assurance::get_state),
|
||||
@@ -436,7 +459,9 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
.route(
|
||||
"/app.bsky.unspecced.getAgeAssuranceState",
|
||||
get(age_assurance::get_age_assurance_state),
|
||||
)
|
||||
);
|
||||
|
||||
router
|
||||
}
|
||||
|
||||
pub fn well_known_api_routes() -> axum::Router<AppState> {
|
||||
@@ -464,9 +489,15 @@ pub fn webhook_routes() -> axum::Router<AppState> {
|
||||
pub fn misc_routes() -> axum::Router<AppState> {
|
||||
use axum::routing::get;
|
||||
|
||||
axum::Router::new()
|
||||
let router = axum::Router::new()
|
||||
.route("/health", get(server::health))
|
||||
.route("/robots.txt", get(server::robots_txt))
|
||||
.route("/favicon.ico", get(server::get_logo))
|
||||
.route("/u/{handle}/did.json", get(identity::user_did_doc))
|
||||
.route("/u/{handle}/did.json", get(identity::user_did_doc));
|
||||
|
||||
if tranquil_config::get().server.rfc_moo_compliance {
|
||||
router.route("/cow.txt", get(server::cow_txt))
|
||||
} else {
|
||||
router
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,16 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tracing::{error, info};
|
||||
use std::sync::LazyLock;
|
||||
use tracing::{error, info, warn};
|
||||
use tranquil_pds::api::ApiError;
|
||||
use tranquil_pds::api::proxy_client::{is_ssrf_safe, proxy_client};
|
||||
use tranquil_pds::auth::{AnyUser, Auth};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Nsid};
|
||||
|
||||
static CREATE_REPORT_NSID: LazyLock<Nsid> =
|
||||
LazyLock::new(|| "com.atproto.moderation.createReport".parse().unwrap());
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReportReasonType {
|
||||
@@ -65,17 +70,26 @@ pub struct CreateReportOutput {
|
||||
|
||||
struct ReportServiceConfig {
|
||||
url: String,
|
||||
did: String,
|
||||
did: Did,
|
||||
}
|
||||
|
||||
fn get_report_service_config() -> Option<ReportServiceConfig> {
|
||||
let cfg = tranquil_config::get();
|
||||
let url = cfg.moderation.report_service_url.clone()?;
|
||||
let did = cfg.moderation.report_service_did.clone()?;
|
||||
if url.is_empty() || did.is_empty() {
|
||||
let did_str = cfg.moderation.report_service_did.as_deref()?;
|
||||
if url.is_empty() || did_str.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(ReportServiceConfig { url, did })
|
||||
match did_str.parse::<Did>() {
|
||||
Ok(did) => Some(ReportServiceConfig { url, did }),
|
||||
Err(_) => {
|
||||
warn!(
|
||||
report_service_did = did_str,
|
||||
"invalid report_service_did, handling reports locally"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_report(
|
||||
@@ -96,7 +110,7 @@ async fn proxy_to_report_service(
|
||||
state: &AppState,
|
||||
auth_user: &tranquil_pds::auth::AuthenticatedUser,
|
||||
service_url: &str,
|
||||
service_did: &str,
|
||||
service_did: &Did,
|
||||
input: &CreateReportInput,
|
||||
) -> Response {
|
||||
if let Err(e) = is_ssrf_safe(service_url) {
|
||||
@@ -138,7 +152,7 @@ async fn proxy_to_report_service(
|
||||
let service_token = match tranquil_pds::auth::create_service_token(
|
||||
&auth_user.did,
|
||||
service_did,
|
||||
Some("com.atproto.moderation.createReport"),
|
||||
Some(&CREATE_REPORT_NSID),
|
||||
&key_bytes,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
|
||||
@@ -6,7 +6,7 @@ use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType};
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{Active, Auth};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_types::Did;
|
||||
use tranquil_types::{Did, Handle};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -137,7 +137,7 @@ pub async fn request_channel_verification(
|
||||
did: &Did,
|
||||
channel: CommsChannel,
|
||||
identifier: &str,
|
||||
handle: Option<&str>,
|
||||
handle: Option<&Handle>,
|
||||
) -> Result<String, ApiError> {
|
||||
let token = tranquil_pds::auth::verification_token::generate_channel_update_token(
|
||||
did, channel, identifier,
|
||||
@@ -147,12 +147,14 @@ pub async fn request_channel_verification(
|
||||
match channel {
|
||||
CommsChannel::Email => {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let handle_str = handle.unwrap_or("user");
|
||||
let handle = handle.ok_or_else(|| {
|
||||
ApiError::InternalError(Some("Email verification requires a handle".into()))
|
||||
})?;
|
||||
tranquil_pds::comms::comms_repo::enqueue_email_update(
|
||||
state.repos.infra.as_ref(),
|
||||
user_id,
|
||||
identifier,
|
||||
handle_str,
|
||||
handle,
|
||||
&formatted_token,
|
||||
hostname,
|
||||
)
|
||||
|
||||
@@ -11,14 +11,18 @@ use multihash::Multihash;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::pin::Pin;
|
||||
use std::sync::LazyLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{Auth, AuthAny, NotTakendown, Permissive, VerifyScope};
|
||||
use tranquil_pds::delegation::DelegationActionType;
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{CidLink, Did};
|
||||
use tranquil_pds::types::{CidLink, Did, Nsid};
|
||||
use tranquil_pds::util::get_header_str;
|
||||
|
||||
static UPLOAD_BLOB_NSID: LazyLock<Nsid> =
|
||||
LazyLock::new(|| "com.atproto.repo.uploadBlob".parse().unwrap());
|
||||
|
||||
fn detect_mime_type(data: &[u8], client_hint: &str) -> String {
|
||||
if let Some(kind) = infer::get(data) {
|
||||
let detected = kind.mime_type().to_string();
|
||||
@@ -48,7 +52,7 @@ pub async fn upload_blob(
|
||||
) -> Result<Response, ApiError> {
|
||||
let (did, controller_did): (Did, Option<Did>) = match &auth {
|
||||
AuthAny::Service(service) => {
|
||||
service.require_lxm("com.atproto.repo.uploadBlob")?;
|
||||
service.require_lxm(&UPLOAD_BLOB_NSID)?;
|
||||
(service.did.clone(), None)
|
||||
}
|
||||
AuthAny::User(user) => {
|
||||
|
||||
@@ -14,6 +14,35 @@ use tranquil_pds::sync::verify::CarVerifier;
|
||||
use tranquil_pds::types::Did;
|
||||
use tranquil_types::{AtUri, CidLink};
|
||||
|
||||
fn map_car_verify_error(e: tranquil_pds::sync::verify::VerifyError) -> ApiError {
|
||||
use tranquil_pds::sync::verify::VerifyError;
|
||||
match e {
|
||||
VerifyError::DidMismatch {
|
||||
commit_did,
|
||||
expected_did,
|
||||
} => ApiError::InvalidRepo(format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, expected_did
|
||||
)),
|
||||
VerifyError::InvalidSignature => ApiError::InvalidRequest(
|
||||
"Repo commit signature does not match the DID document signing key".into(),
|
||||
),
|
||||
VerifyError::NoSigningKey => {
|
||||
ApiError::InvalidRequest("DID document has no atproto signing key".into())
|
||||
}
|
||||
VerifyError::DidResolutionFailed(msg) => {
|
||||
ApiError::InvalidRequest(format!("Could not resolve DID document: {}", msg))
|
||||
}
|
||||
VerifyError::MstValidationFailed(msg) => {
|
||||
ApiError::InvalidRequest(format!("MST validation failed: {}", msg))
|
||||
}
|
||||
other => {
|
||||
error!("CAR verification failed: {:?}", other);
|
||||
ApiError::InvalidRequest(format!("CAR verification failed: {}", other))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn import_repo(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<NotTakendown>,
|
||||
@@ -77,27 +106,6 @@ pub async fn import_repo(
|
||||
blocks.len(),
|
||||
root
|
||||
);
|
||||
let Some(root_block) = blocks.get(&root) else {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Root block not found in CAR file".into(),
|
||||
));
|
||||
};
|
||||
let commit_did: Did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
|
||||
Ok(commit) => commit
|
||||
.did()
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidRequest("Commit contains invalid DID".into()))?,
|
||||
Err(e) => {
|
||||
return Err(ApiError::InvalidRequest(format!("Invalid commit: {}", e)));
|
||||
}
|
||||
};
|
||||
if commit_did != *did {
|
||||
return Err(ApiError::InvalidRepo(format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, did
|
||||
)));
|
||||
}
|
||||
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
|
||||
.ok()
|
||||
.map(|v| v == "true" || v == "1")
|
||||
@@ -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;
|
||||
@@ -225,17 +167,11 @@ pub async fn import_repo(
|
||||
.records
|
||||
.iter()
|
||||
.flat_map(|record| {
|
||||
let record_uri =
|
||||
AtUri::from_parts(did.as_str(), &record.collection, &record.rkey);
|
||||
record.blob_refs.iter().filter_map(move |blob_ref| {
|
||||
match CidLink::new(&blob_ref.cid) {
|
||||
Ok(cid_link) => Some((record_uri.clone(), cid_link)),
|
||||
Err(_) => {
|
||||
tracing::warn!(cid = %blob_ref.cid, "skipping unparseable blob CID reference during import");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
let record_uri = AtUri::from_parts(did, &record.collection, &record.rkey);
|
||||
record
|
||||
.blob_refs
|
||||
.iter()
|
||||
.map(move |blob_ref| (record_uri.clone(), blob_ref.cid.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -285,43 +221,62 @@ pub async fn import_repo(
|
||||
})?;
|
||||
let new_rev = Tid::now(LimitedU32::MIN);
|
||||
let new_rev_str = new_rev.to_string();
|
||||
let (commit_bytes, _sig) = create_signed_commit(
|
||||
did,
|
||||
import_result.data_cid,
|
||||
&new_rev_str,
|
||||
None,
|
||||
&signing_key,
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("Failed to create new commit: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let (commit_bytes, _sig) =
|
||||
create_signed_commit(did, import_result.data_cid, &new_rev, None, &signing_key)
|
||||
.map_err(|e| {
|
||||
error!("Failed to create new commit: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let new_root_cid: cid::Cid =
|
||||
state.block_store.put(&commit_bytes).await.map_err(|e| {
|
||||
error!("Failed to store new commit block: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let new_root_cid_link = CidLink::from(&new_root_cid);
|
||||
let new_rev_tid = tranquil_pds::types::Tid::from(new_rev.clone());
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
.update_repo_root(user_id, &new_root_cid_link, &new_rev_str)
|
||||
.update_repo_root(user_id, &new_root_cid_link, &new_rev_tid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to update repo root: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let mut all_block_cids: Vec<Vec<u8>> = blocks.keys().map(|c| c.to_bytes()).collect();
|
||||
all_block_cids.push(new_root_cid.to_bytes());
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
.insert_user_blocks(user_id, &all_block_cids, &new_rev_str)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to insert user_blocks: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
match tranquil_pds::scheduled::collect_current_repo_blocks(
|
||||
&state.block_store,
|
||||
&new_root_cid,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reachable) => {
|
||||
if !reachable.is_complete() {
|
||||
error!(
|
||||
unreadable = reachable.unreadable,
|
||||
"scheduling a structural repair because the imported repo walk could \
|
||||
not read every block"
|
||||
);
|
||||
tranquil_pds::repo_ops::schedule_repo_repair(&state, user_id);
|
||||
}
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
.insert_user_blocks(user_id, &reachable.block_cids, &new_rev_tid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to insert user_blocks: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to walk the imported repo: {:?}. The root is already updated and \
|
||||
a scheduled structural repair will rebuild user_blocks",
|
||||
e
|
||||
);
|
||||
tranquil_pds::repo_ops::schedule_repo_repair(&state, user_id);
|
||||
}
|
||||
}
|
||||
let new_root_str = new_root_cid.to_string();
|
||||
info!(
|
||||
"Created new commit for imported repo: cid={}, rev={}",
|
||||
|
||||
@@ -18,7 +18,7 @@ use tranquil_pds::repo_ops::{
|
||||
finalize_repo_write, with_repair_retry,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, CidLink, Did, Nsid, Rkey};
|
||||
use tranquil_pds::validation::ValidationStatus;
|
||||
|
||||
const MAX_BATCH_WRITES: usize = 200;
|
||||
@@ -27,7 +27,7 @@ struct WriteAccumulator {
|
||||
mst: Mst<TrackingBlockStore>,
|
||||
results: Vec<WriteResult>,
|
||||
ops: Vec<RecordOp>,
|
||||
all_blob_cids: Vec<String>,
|
||||
all_blob_cids: Vec<CidLink>,
|
||||
backlinks_to_add: Vec<Backlink>,
|
||||
backlinks_to_remove: Vec<AtUri>,
|
||||
}
|
||||
@@ -353,9 +353,9 @@ pub async fn apply_writes(
|
||||
&auth,
|
||||
&input.writes,
|
||||
|w| match w {
|
||||
WriteOp::Create { collection, .. } => collection.as_str(),
|
||||
WriteOp::Update { collection, .. } => collection.as_str(),
|
||||
WriteOp::Delete { collection, .. } => collection.as_str(),
|
||||
WriteOp::Create { collection, .. } => collection,
|
||||
WriteOp::Update { collection, .. } => collection,
|
||||
WriteOp::Delete { collection, .. } => collection,
|
||||
},
|
||||
|w| match w {
|
||||
WriteOp::Create { .. } => WriteOpKind::Create,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub use tranquil_pds::repo_ops::*;
|
||||
@@ -9,13 +9,13 @@ pub async fn validate_record_with_status(
|
||||
require_lexicon: bool,
|
||||
) -> Result<ValidationStatus, ApiError> {
|
||||
let registry = tranquil_lexicon::LexiconRegistry::global();
|
||||
if !registry.has_schema(collection.as_str()) {
|
||||
let _ = registry.resolve_dynamic(collection.as_str()).await;
|
||||
if !registry.has_schema(collection) {
|
||||
let _ = registry.resolve_dynamic(collection).await;
|
||||
}
|
||||
|
||||
let validator = RecordValidator::new().require_lexicon(require_lexicon);
|
||||
validator
|
||||
.validate_with_rkey(record, collection.as_str(), rkey.map(|v| v.as_str()))
|
||||
.validate_with_rkey(record, collection, rkey)
|
||||
.map_err(validation_error_to_api_error)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::borrow::Cow;
|
||||
use std::str::FromStr;
|
||||
use tracing::warn;
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{
|
||||
Active, Auth, AuthSource, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
|
||||
@@ -17,7 +18,7 @@ use tranquil_pds::repo_ops::{
|
||||
finalize_repo_write, with_repair_retry,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey, Tid};
|
||||
use tranquil_pds::validation::ValidationStatus;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -102,7 +103,7 @@ pub struct CreateRecordInput {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitInfo {
|
||||
pub cid: String,
|
||||
pub rev: String,
|
||||
pub rev: Tid,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -181,8 +182,15 @@ async fn create_record_inner(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let conflict_rkey = Rkey::from(conflict_rkey_str.to_string());
|
||||
let conflict_collection = Nsid::from(conflict_col_str.to_string());
|
||||
let (Ok(conflict_rkey), Ok(conflict_collection)) =
|
||||
(Rkey::new(conflict_rkey_str), Nsid::new(conflict_col_str))
|
||||
else {
|
||||
warn!(
|
||||
uri = %conflict_uri,
|
||||
"skipping a backlink conflict whose stored URI doesn't parse"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let conflict_key = format!("{}/{}", conflict_collection, conflict_rkey);
|
||||
|
||||
let prev_cid = match mst.get(&conflict_key).await {
|
||||
@@ -373,11 +381,8 @@ async fn put_record_inner(
|
||||
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()))?;
|
||||
let record_cid = ctx
|
||||
.tracking_store
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
|
||||
let record_cid = jacquard_repo::mst::util::compute_cid(&record_bytes)
|
||||
.map_err(|_| ApiError::InvalidRecord("Failed to compute record CID".into()))?;
|
||||
|
||||
if existing_cid == Some(record_cid) {
|
||||
return Ok(PutRecordOutput {
|
||||
@@ -388,6 +393,11 @@ async fn put_record_inner(
|
||||
});
|
||||
}
|
||||
|
||||
ctx.tracking_store
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde_json::Value;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tranquil_pds::api::EmptyResponse;
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{Auth, NotTakendown, Permissive, require_legacy_session_mfa};
|
||||
@@ -18,7 +18,7 @@ use tranquil_pds::cache::Cache;
|
||||
use tranquil_pds::oauth::scopes::{AccountAction, AccountAttr};
|
||||
use tranquil_pds::plc::PlcClient;
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::PlainPassword;
|
||||
use tranquil_pds::types::{PlainPassword, Tid};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -27,7 +27,8 @@ pub struct CheckAccountStatusOutput {
|
||||
pub activated: bool,
|
||||
pub valid_did: bool,
|
||||
pub repo_commit: String,
|
||||
pub repo_rev: String,
|
||||
#[serde(serialize_with = "serialize_optional_rev")]
|
||||
pub repo_rev: Option<Tid>,
|
||||
pub repo_blocks: i64,
|
||||
pub indexed_records: i64,
|
||||
pub private_state_values: i64,
|
||||
@@ -35,6 +36,13 @@ pub struct CheckAccountStatusOutput {
|
||||
pub imported_blobs: i64,
|
||||
}
|
||||
|
||||
fn serialize_optional_rev<S: serde::Serializer>(
|
||||
rev: &Option<Tid>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(rev.as_ref().map_or("", Tid::as_str))
|
||||
}
|
||||
|
||||
pub async fn check_account_status(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Permissive>,
|
||||
@@ -65,23 +73,20 @@ pub async fn check_account_status(
|
||||
.count_user_blocks(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let repo_rev = if let Some(rev) = repo_rev_from_db {
|
||||
rev
|
||||
} else if !repo_commit.is_empty() {
|
||||
if let Ok(cid) = Cid::from_str(&repo_commit) {
|
||||
if let Ok(Some(block)) = state.block_store.get(&cid).await {
|
||||
let repo_rev = match (repo_rev_from_db, Cid::from_str(&repo_commit)) {
|
||||
(Some(rev), _) => Some(rev),
|
||||
(None, Ok(cid)) => state
|
||||
.block_store
|
||||
.get(&cid)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|block| {
|
||||
Commit::from_cbor(&block)
|
||||
.ok()
|
||||
.map(|c| c.rev().to_string())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
.map(|commit| Tid::from(commit.rev().clone()))
|
||||
}),
|
||||
(None, Err(_)) => None,
|
||||
};
|
||||
let record_count: i64 = state.repos.repo.count_records(user_id).await.unwrap_or(0);
|
||||
let imported_blobs: i64 = state
|
||||
@@ -133,7 +138,7 @@ async fn assert_valid_did_document_for_service(
|
||||
if did.as_str().starts_with("did:plc:") {
|
||||
let max_attempts = if with_retry { 5 } else { 1 };
|
||||
let cache_for_retry = cache.clone();
|
||||
let did_owned = did.as_str().to_string();
|
||||
let did_owned = did.clone();
|
||||
let expected_owned = expected_endpoint.clone();
|
||||
let attempt_counter = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
@@ -197,18 +202,20 @@ 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())
|
||||
{
|
||||
debug!(
|
||||
"DID {} rotation keys {:?} omit the PDS-managed server rotation key {}",
|
||||
did, doc_rotation_keys, expected_rotation_key
|
||||
);
|
||||
}
|
||||
|
||||
let doc_signing_key = doc_data
|
||||
@@ -243,6 +250,13 @@ 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()) {
|
||||
debug!(
|
||||
"DID {} rotation keys {:?} omit the PDS-managed signing key {}",
|
||||
did, doc_rotation_keys, expected_did_key
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if let Some(host_and_path) = did.as_str().strip_prefix("did:web:") {
|
||||
let client = tranquil_pds::api::proxy_client::did_resolution_client();
|
||||
@@ -376,7 +390,7 @@ pub async fn activate_account(
|
||||
.cache
|
||||
.delete(&tranquil_pds::cache_keys::plc_data_key(&did))
|
||||
.await;
|
||||
if state.did_resolver.refresh_did(did.as_str()).await.is_err() {
|
||||
if state.did_resolver.refresh_did(&did).await.is_err() {
|
||||
warn!(
|
||||
"[MIGRATION] activateAccount: Failed to refresh DID cache for {}",
|
||||
did
|
||||
@@ -412,10 +426,8 @@ pub async fn activate_account(
|
||||
"[MIGRATION] activateAccount: Sequencing identity event for did={} handle={:?}",
|
||||
did, handle
|
||||
);
|
||||
let handle_typed = handle.clone();
|
||||
if let Err(e) =
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, handle_typed.as_ref())
|
||||
.await
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, handle.as_ref()).await
|
||||
{
|
||||
warn!(
|
||||
"[MIGRATION] activateAccount: Failed to sequence identity event for activation: {}",
|
||||
@@ -438,7 +450,9 @@ pub async fn activate_account(
|
||||
);
|
||||
let rev = if let Ok(cid) = Cid::from_str(root_cid_link.as_str()) {
|
||||
if let Ok(Some(block)) = state.block_store.get(&cid).await {
|
||||
Commit::from_cbor(&block).ok().map(|c| c.rev().to_string())
|
||||
Commit::from_cbor(&block)
|
||||
.ok()
|
||||
.map(|c| Tid::from(c.rev().clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -448,8 +462,8 @@ pub async fn activate_account(
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event(
|
||||
&state,
|
||||
&did,
|
||||
root_cid_link.as_str(),
|
||||
rev.as_deref(),
|
||||
&root_cid_link,
|
||||
rev.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -626,7 +640,7 @@ pub async fn delete_account(
|
||||
state.repos.session.as_ref(),
|
||||
user_id,
|
||||
password,
|
||||
password_hash.as_deref(),
|
||||
password_hash.as_ref(),
|
||||
)
|
||||
.await
|
||||
.is_none()
|
||||
|
||||
@@ -116,7 +116,10 @@ pub async fn create_app_password(
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default();
|
||||
let granted_scopes = match grant {
|
||||
Some(g) => g.granted_scopes,
|
||||
None => return Err(ApiError::InsufficientScope(None)),
|
||||
};
|
||||
|
||||
let requested = input.scopes.as_deref().unwrap_or("atproto");
|
||||
let intersected = intersect_scopes(requested, granted_scopes.as_str());
|
||||
@@ -188,7 +191,7 @@ pub async fn create_app_password(
|
||||
}
|
||||
Ok(Json(CreateAppPasswordOutput {
|
||||
name: name.to_string(),
|
||||
password,
|
||||
password: password.into_inner(),
|
||||
created_at: created_at.to_rfc3339(),
|
||||
privileged: privilege.is_privileged(),
|
||||
scopes: final_scopes,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
..........................
|
||||
....*o|||||||8#@@@@@@@@@@@@@@@@@@@@@@@###&|o:_..
|
||||
..*:o|||&8##@###8888888######@#@###########################|*...
|
||||
.:o|||8#####8888|:::**. *&########################@@################&o_
|
||||
.*o&8###@#8&o*_. :###@##############@########################@@##&o_
|
||||
.*o8########& :##@#@##############@############################@###|_
|
||||
.*o|8##########8o .#######################################################&o_
|
||||
*&##|_ ..*&##8&o*|88888|_ _#######################################@##################|.
|
||||
*#####& *&######&o_..*o|o:_ .&##o _###########################################################&_
|
||||
_##8*##8 .|88|:::|#######8###8|*:_ .&#@@8 _##@@@########################################################&_
|
||||
_#@8_##8_ *8#8|*_ _:|#####&&####8 .&##############################################################|
|
||||
_#@8.|##8_ _::o###8&##8 .|##@############################8###########################@@#|_
|
||||
*###o.|88o ..*&####|..##& _|##########################8|_ .|#############################8
|
||||
*|###|_ ._&####8|*_ _*_ _::&8888888888888888|::*_ .|##@####@@@##################|
|
||||
*&###|_ _:_ .&88###8|*_ ..... .|#####@@@##################8
|
||||
.##@#& _##& .|##o _#@@#@#| .|#######&:_ _|###@####################8
|
||||
.:8##8o _o:*&##| *##8_.&@@##@#| _::o8#8|::|#####|_ _|#################88###8
|
||||
.&##&*_ *###o_###| .|##8*&##|*###o _###8####8|_ _:|###|_ .*o|||o:_ _:::&8888888888|_ _##8
|
||||
.###|_. _###o *###|*&#######8 *##8 .##8_ _:|###|_ _|###|_ .&########o _##&
|
||||
_|####8|&##8:_ _|#########88o .##8 *##& _|###o .|###o .#########| .o##o
|
||||
o#8|*:#@@###o _:::*__*_ _##8_ _##8_ _&##|_ *##8_ *8#####8|_ .*oo:_ o##|
|
||||
*###o.&#####& _oo* .8##& .8##8_ .|##& o##& _::::_.*o|8######|_ .##8.
|
||||
_###&o&##8_:*_ .###& .###|_&#####| _##8 :###o *ooo&#########@#@#& ....:##&
|
||||
.|8||###&. _**_ .###88##|*&###|*._&##& *|##8_ *o&####@@####@@######& .*o||||||&#######8_
|
||||
*&###o _|88##8_ _:8######|*:###|_ _##################88|_ *&#################&
|
||||
*#####o *&8o *##& _:::*_.&##|_ _#@##############8_ :##################8*
|
||||
.###&##8_.|88o *&8o _@@& .###| .&####@#########|_ .####@###@@########8*
|
||||
_##&.|###|_.... .|88o _##8* *&###|_ *###o _:&88######8|_ .*o|||##################o
|
||||
_##8_ _|########|_ .*o8####&#@@#@##o *#@8 _*:*. .&###@###################|
|
||||
.|##& _:::::&##& .*&##############@#8_ .###o _#@####################|_
|
||||
.&##|_ .&##8_ *o&####################8_ *##& .&#####@@############8*
|
||||
.|##8_.&###&####8_ _########################8**##& _##################|_
|
||||
.&###&##888888##8_ .|88######@###########|*######o _|8###############|_
|
||||
.&#####o *###|_ _::::::*o##8**o##8 .|###8o .&##@#############&*
|
||||
.|####o _|###|_ _##8.*&##& _*_ .#################|
|
||||
_*_ _|###|_.. .|#####8|_ *&#@#########8###&
|
||||
*#######&|o:_... ..*:::*. ......._:o&8####888&o:#####8_
|
||||
_###|&888#####@#####&|||o:_........................._:o||||8##@@@@####8|:*_ _:::*_
|
||||
.|#@###o _:::o#@#888######@@@@@@@@@@@@@@@@@@@@@@@@#####888|::::::**_
|
||||
_::*_ :##& *&8|_:::::::::::::::::::::::::**_
|
||||
.###8||&##8o
|
||||
_|888888|_
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use tranquil_pds::auth::{Auth, NotTakendown};
|
||||
use tranquil_pds::oauth::scopes::{AccountAction, AccountAttr};
|
||||
use tranquil_pds::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AtIdentifier, Did};
|
||||
|
||||
const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
@@ -37,7 +38,7 @@ struct PendingEmailUpdate {
|
||||
|
||||
async fn get_pending_email_update(
|
||||
cache: &dyn tranquil_pds::cache::Cache,
|
||||
did: &str,
|
||||
did: &Did,
|
||||
) -> Option<PendingEmailUpdate> {
|
||||
cache
|
||||
.get(&tranquil_pds::cache_keys::email_update_key(did))
|
||||
@@ -79,7 +80,7 @@ pub async fn request_email_update(
|
||||
if token_required {
|
||||
let token = tranquil_pds::auth::email_token::create_email_token(
|
||||
state.cache.as_ref(),
|
||||
auth.did.as_str(),
|
||||
&auth.did,
|
||||
tranquil_pds::auth::email_token::EmailTokenPurpose::UpdateEmail,
|
||||
)
|
||||
.await
|
||||
@@ -249,7 +250,7 @@ pub async fn update_email(
|
||||
|
||||
tranquil_pds::auth::email_token::validate_email_token(
|
||||
state.cache.as_ref(),
|
||||
did.as_str(),
|
||||
did,
|
||||
tranquil_pds::auth::email_token::EmailTokenPurpose::UpdateEmail,
|
||||
token,
|
||||
)
|
||||
@@ -298,7 +299,7 @@ pub async fn update_email(
|
||||
|
||||
let short_token_result = tranquil_pds::auth::email_token::validate_email_token(
|
||||
state.cache.as_ref(),
|
||||
did.as_str(),
|
||||
did,
|
||||
tranquil_pds::auth::email_token::EmailTokenPurpose::UpdateEmail,
|
||||
token,
|
||||
)
|
||||
@@ -394,7 +395,7 @@ pub async fn update_email(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CheckEmailVerifiedInput {
|
||||
pub identifier: String,
|
||||
pub identifier: AtIdentifier,
|
||||
}
|
||||
|
||||
pub async fn check_email_verified(
|
||||
|
||||
@@ -7,6 +7,7 @@ use tranquil_pds::auth::{Admin, Auth, NotTakendown};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::Did;
|
||||
use tranquil_pds::util::gen_invite_code;
|
||||
use tranquil_types::InviteCode as InviteCodeValue;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -17,7 +18,7 @@ pub struct CreateInviteCodeInput {
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateInviteCodeOutput {
|
||||
pub code: String,
|
||||
pub code: InviteCodeValue,
|
||||
}
|
||||
|
||||
pub async fn create_invite_code(
|
||||
@@ -42,7 +43,7 @@ pub async fn create_invite_code(
|
||||
match state
|
||||
.repos
|
||||
.infra
|
||||
.create_invite_code(&code, input.use_count, Some(&for_account))
|
||||
.create_invite_code(&code, input.use_count, &for_account)
|
||||
.await
|
||||
{
|
||||
Ok(true) => Ok(Json(CreateInviteCodeOutput { code })),
|
||||
@@ -72,8 +73,8 @@ pub struct CreateInviteCodesOutput {
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AccountCodes {
|
||||
pub account: String,
|
||||
pub codes: Vec<String>,
|
||||
pub account: Did,
|
||||
pub codes: Vec<InviteCodeValue>,
|
||||
}
|
||||
|
||||
pub async fn create_invite_codes(
|
||||
@@ -112,14 +113,11 @@ pub async fn create_invite_codes(
|
||||
let infra_repo = state.repos.infra.clone();
|
||||
let use_count = input.use_count;
|
||||
async move {
|
||||
let codes: Vec<String> = (0..code_count).map(|_| gen_invite_code()).collect();
|
||||
let codes: Vec<InviteCodeValue> = (0..code_count).map(|_| gen_invite_code()).collect();
|
||||
infra_repo
|
||||
.create_invite_codes_batch(&codes, use_count, admin_user_id, Some(&account))
|
||||
.create_invite_codes_batch(&codes, use_count, admin_user_id, &account)
|
||||
.await
|
||||
.map(|_| AccountCodes {
|
||||
account: account.to_string(),
|
||||
codes,
|
||||
})
|
||||
.map(|_| AccountCodes { account, codes })
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
@@ -145,7 +143,7 @@ pub struct GetAccountInviteCodesParams {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteCode {
|
||||
pub code: String,
|
||||
pub code: InviteCodeValue,
|
||||
pub available: i32,
|
||||
pub disabled: bool,
|
||||
pub for_account: String,
|
||||
@@ -190,27 +188,24 @@ pub async fn get_account_invite_codes(
|
||||
let codes = futures::future::join_all(filtered_codes.into_iter().map(|info| {
|
||||
let infra_repo = state.repos.infra.clone();
|
||||
async move {
|
||||
let uses = infra_repo
|
||||
let uses: Vec<InviteCodeUse> = infra_repo
|
||||
.get_invite_code_uses(&info.code)
|
||||
.await
|
||||
.map(|use_rows| {
|
||||
use_rows
|
||||
.into_iter()
|
||||
.map(|u| InviteCodeUse {
|
||||
used_by: u.used_by_did.to_string(),
|
||||
used_by_handle: u.used_by_handle.map(|h| h.to_string()),
|
||||
used_at: u.used_at.to_rfc3339(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.log_db_err("fetching invite code uses")?
|
||||
.into_iter()
|
||||
.map(|u| InviteCodeUse {
|
||||
used_by: u.used_by_did.to_string(),
|
||||
used_by_handle: u.used_by_handle.map(|h| h.to_string()),
|
||||
used_at: u.used_at.to_rfc3339(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
.collect();
|
||||
|
||||
let use_count = i32::try_from(uses.len()).unwrap_or(i32::MAX);
|
||||
if !include_used && use_count >= info.available_uses {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Some(InviteCode {
|
||||
Ok(Some(InviteCode {
|
||||
code: info.code,
|
||||
available: info.available_uses,
|
||||
disabled: false,
|
||||
@@ -221,11 +216,16 @@ pub async fn get_account_invite_codes(
|
||||
.unwrap_or_else(|| "admin".to_string()),
|
||||
created_at: info.created_at.to_rfc3339(),
|
||||
uses,
|
||||
})
|
||||
}))
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
|
||||
let codes: Vec<InviteCode> = codes.into_iter().flatten().collect();
|
||||
let codes: Vec<InviteCode> = codes
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<Option<InviteCode>>, ApiError>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
Ok(Json(GetAccountInviteCodesOutput { codes }))
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ pub async fn robots_txt() -> impl IntoResponse {
|
||||
"# Hello!\n\n# Crawling the public API is allowed\nUser-agent: *\nAllow: /\n",
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn cow_txt() -> &'static str {
|
||||
include_str!("cow.txt")
|
||||
}
|
||||
pub fn is_self_hosted_did_web_enabled() -> bool {
|
||||
tranquil_config::get().server.enable_pds_hosted_did_web
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ pub use email::{
|
||||
};
|
||||
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
|
||||
pub use logo::get_logo;
|
||||
pub use meta::{describe_server, health, robots_txt};
|
||||
pub use meta::{cow_txt, describe_server, health, robots_txt};
|
||||
pub use migration::{get_did_document, update_did_document};
|
||||
pub use passkey_account::{
|
||||
complete_passkey_setup, create_passkey_account, recover_passkey_account,
|
||||
|
||||
@@ -7,13 +7,14 @@ use serde_json::json;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tranquil_db_traits::WebauthnChallengeType;
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::api::invite::check_registration_invite;
|
||||
use tranquil_pds::api::{OptionsResponse, SuccessResponse};
|
||||
use tranquil_pds::auth::NormalizedLoginIdentifier;
|
||||
|
||||
use tranquil_pds::auth::{ServiceTokenVerifier, generate_app_password, is_service_token};
|
||||
use tranquil_pds::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Handle, PlainPassword};
|
||||
use tranquil_pds::types::{Did, Handle, Jti, Nsid, PlainPassword};
|
||||
use tranquil_pds::validation::validate_password;
|
||||
|
||||
fn generate_setup_token() -> String {
|
||||
@@ -38,7 +39,7 @@ pub struct CreatePasskeyAccountInput {
|
||||
pub invite_code: Option<String>,
|
||||
pub did: Option<String>,
|
||||
pub did_type: Option<String>,
|
||||
pub signing_key: Option<String>,
|
||||
pub signing_key: Option<Did>,
|
||||
pub verification_channel: Option<tranquil_db_traits::CommsChannel>,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
@@ -68,8 +69,10 @@ pub async fn create_passkey_account(
|
||||
let token = extracted.token;
|
||||
if is_service_token(&token) {
|
||||
let verifier = ServiceTokenVerifier::new();
|
||||
let create_account_lxm = Nsid::new("com.atproto.server.createAccount")
|
||||
.expect("com.atproto.server.createAccount is a valid NSID");
|
||||
match verifier
|
||||
.verify_service_token(&token, Some("com.atproto.server.createAccount"))
|
||||
.verify_service_token(&token, Some(&create_account_lxm))
|
||||
.await
|
||||
{
|
||||
Ok(claims) => {
|
||||
@@ -119,26 +122,8 @@ pub async fn create_passkey_account(
|
||||
return Err(ApiError::InvalidEmail);
|
||||
}
|
||||
|
||||
let is_bootstrap = state.bootstrap_invite_code.is_some()
|
||||
&& state.repos.user.count_users().await.unwrap_or(1) == 0;
|
||||
|
||||
let _validated_invite_code = if is_bootstrap {
|
||||
match input.invite_code.as_deref() {
|
||||
Some(code) if Some(code) == state.bootstrap_invite_code.as_deref() => None,
|
||||
_ => return Err(ApiError::InvalidInviteCode),
|
||||
}
|
||||
} else if let Some(ref code) = input.invite_code {
|
||||
match state.repos.infra.validate_invite_code(code).await {
|
||||
Ok(validated) => Some(validated),
|
||||
Err(_) => return Err(ApiError::InvalidInviteCode),
|
||||
}
|
||||
} else {
|
||||
let invite_required = tranquil_config::get().server.invite_code_required;
|
||||
if invite_required {
|
||||
return Err(ApiError::InviteCodeRequired);
|
||||
}
|
||||
None
|
||||
};
|
||||
let invite_registration =
|
||||
check_registration_invite(&state, input.invite_code.as_deref()).await?;
|
||||
|
||||
let verification_channel = input
|
||||
.verification_channel
|
||||
@@ -160,7 +145,7 @@ pub async fn create_passkey_account(
|
||||
let did_type = input.did_type.as_deref().unwrap_or("plc");
|
||||
|
||||
let key_result =
|
||||
match crate::identity::provision::resolve_signing_key(&state, input.signing_key.as_deref())
|
||||
match crate::identity::provision::resolve_signing_key(&state, input.signing_key.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(k) => k,
|
||||
@@ -170,7 +155,7 @@ pub async fn create_passkey_account(
|
||||
let secret_key = key_result.signing_key;
|
||||
let reserved_key_id = key_result.reserved_key_id;
|
||||
|
||||
let did = match did_type {
|
||||
let did: Did = match did_type {
|
||||
"web" => {
|
||||
let self_hosted_did = match common::create_self_hosted_did_web(&handle) {
|
||||
Ok(d) => d,
|
||||
@@ -208,7 +193,7 @@ pub async fn create_passkey_account(
|
||||
d,
|
||||
hostname,
|
||||
&input.handle,
|
||||
input.signing_key.as_deref(),
|
||||
input.signing_key.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -216,7 +201,8 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
info!(did = %d, "Creating external did:web passkey account (reserved key)");
|
||||
}
|
||||
d.to_string()
|
||||
d.parse()
|
||||
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?
|
||||
}
|
||||
_ => {
|
||||
if let Some(ref auth_did) = byod_auth {
|
||||
@@ -229,7 +215,9 @@ pub async fn create_passkey_account(
|
||||
)));
|
||||
}
|
||||
info!(did = %provided_did, "Creating BYOD did:plc passkey account (migration)");
|
||||
provided_did.clone()
|
||||
provided_did
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"BYOD migration requires a did:plc or did:web DID".into(),
|
||||
@@ -241,15 +229,9 @@ pub async fn create_passkey_account(
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let rotation_key = tranquil_config::get()
|
||||
.secrets
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(&secret_key));
|
||||
|
||||
let genesis_result = match tranquil_pds::plc::create_genesis_operation(
|
||||
&secret_key,
|
||||
&rotation_key,
|
||||
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
|
||||
&handle,
|
||||
&pds_endpoint,
|
||||
) {
|
||||
@@ -291,13 +273,9 @@ pub async fn create_passkey_account(
|
||||
None
|
||||
};
|
||||
|
||||
let did_typed: Did = match did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Err(ApiError::InternalError(Some("Invalid DID".into()))),
|
||||
};
|
||||
let repo = match crate::identity::provision::init_genesis_repo(
|
||||
&state,
|
||||
&did_typed,
|
||||
&did,
|
||||
&secret_key,
|
||||
&secret_key_bytes,
|
||||
)
|
||||
@@ -316,10 +294,6 @@ pub async fn create_passkey_account(
|
||||
None
|
||||
};
|
||||
|
||||
let handle_typed: Handle = match handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||
};
|
||||
let repo_for_seq = repo.clone();
|
||||
let comms = crate::identity::provision::normalize_comms_usernames(
|
||||
input.discord_username.as_deref(),
|
||||
@@ -327,9 +301,9 @@ pub async fn create_passkey_account(
|
||||
input.signal_username.as_deref(),
|
||||
);
|
||||
let create_input = tranquil_db_traits::CreatePasskeyAccountInput {
|
||||
handle: handle_typed.clone(),
|
||||
handle: handle.clone(),
|
||||
email: email.clone().unwrap_or_default(),
|
||||
did: did_typed.clone(),
|
||||
did: did.clone(),
|
||||
preferred_comms_channel: verification_channel,
|
||||
discord_username: comms.discord,
|
||||
telegram_username: comms.telegram,
|
||||
@@ -340,14 +314,10 @@ pub async fn create_passkey_account(
|
||||
encrypted_key_bytes: repo.encrypted_key_bytes,
|
||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||
reserved_key_id,
|
||||
commit_cid: repo.commit_cid.to_string(),
|
||||
commit_cid: tranquil_types::CidLink::from(&repo.commit_cid),
|
||||
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 +329,9 @@ pub async fn create_passkey_account(
|
||||
Err(tranquil_db_traits::CreateAccountError::EmailTaken) => {
|
||||
return Err(ApiError::EmailTaken);
|
||||
}
|
||||
Err(tranquil_db_traits::CreateAccountError::InviteCodeUnavailable) => {
|
||||
return Err(ApiError::InvalidInviteCode);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error creating passkey account: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
@@ -369,10 +342,10 @@ pub async fn create_passkey_account(
|
||||
if !is_byod_did_web {
|
||||
crate::identity::provision::sequence_new_account(
|
||||
&state,
|
||||
&did_typed,
|
||||
&handle_typed,
|
||||
&repo_for_seq,
|
||||
&did,
|
||||
&handle,
|
||||
&repo_for_seq,
|
||||
handle.as_str(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -380,7 +353,7 @@ pub async fn create_passkey_account(
|
||||
crate::identity::provision::enqueue_signup_verification(
|
||||
&state,
|
||||
user_id,
|
||||
&did_typed,
|
||||
&did,
|
||||
verification_channel,
|
||||
&verification_recipient,
|
||||
)
|
||||
@@ -391,10 +364,10 @@ pub async fn create_passkey_account(
|
||||
let access_jwt = if byod_auth.is_some() {
|
||||
match tranquil_pds::auth::create_access_token_with_metadata(&did, &secret_key_bytes) {
|
||||
Ok(token_meta) => {
|
||||
let refresh_jti = uuid::Uuid::new_v4().to_string();
|
||||
let refresh_jti = Jti::from(uuid::Uuid::new_v4().to_string());
|
||||
let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(24);
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: did_typed.clone(),
|
||||
did: did.clone(),
|
||||
access_jti: token_meta.jti.clone(),
|
||||
refresh_jti,
|
||||
access_expires_at: token_meta.expires_at,
|
||||
@@ -421,8 +394,8 @@ pub async fn create_passkey_account(
|
||||
};
|
||||
|
||||
Ok(Json(CreatePasskeyAccountOutput {
|
||||
did: did.into(),
|
||||
handle: handle.into(),
|
||||
did,
|
||||
handle,
|
||||
setup_token,
|
||||
setup_expires_at,
|
||||
access_jwt,
|
||||
@@ -576,7 +549,7 @@ pub async fn complete_passkey_setup(
|
||||
Ok(Json(CompletePasskeySetupOutput {
|
||||
did: input.did.clone(),
|
||||
handle: user.handle,
|
||||
app_password,
|
||||
app_password: app_password.into_inner(),
|
||||
app_password_name,
|
||||
}))
|
||||
}
|
||||
@@ -715,7 +688,7 @@ pub async fn request_passkey_recovery(
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.user
|
||||
.set_recovery_token(&user.did, &recovery_token_hash, expires_at)
|
||||
.set_recovery_token(&user.did, recovery_token_hash.as_str(), expires_at)
|
||||
.await
|
||||
{
|
||||
error!("Error updating recovery token: {:?}", e);
|
||||
|
||||
@@ -10,13 +10,9 @@ use tranquil_pds::auth::{
|
||||
};
|
||||
use tranquil_pds::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::PlainPassword;
|
||||
use tranquil_pds::types::{Handle, PlainPassword};
|
||||
use tranquil_pds::validation::validate_password;
|
||||
|
||||
fn generate_reset_code() -> String {
|
||||
tranquil_pds::util::generate_token_code()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RequestPasswordResetInput {
|
||||
#[serde(alias = "identifier")]
|
||||
@@ -49,12 +45,23 @@ pub async fn request_password_reset(
|
||||
None
|
||||
};
|
||||
|
||||
let user_id = match state
|
||||
.repos
|
||||
.user
|
||||
.get_id_by_email_or_handle(normalized, normalized_handle.as_str())
|
||||
.await
|
||||
{
|
||||
let lookup = match Handle::new(normalized_handle.as_str()) {
|
||||
Ok(handle) => {
|
||||
state
|
||||
.repos
|
||||
.user
|
||||
.get_id_by_email_or_handle(normalized, &handle)
|
||||
.await
|
||||
}
|
||||
Err(_) => state
|
||||
.repos
|
||||
.user
|
||||
.get_by_email(normalized)
|
||||
.await
|
||||
.map(|user| user.map(|user| user.id)),
|
||||
};
|
||||
|
||||
let user_id = match lookup {
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => {
|
||||
info!("Password reset requested for unknown identifier");
|
||||
@@ -70,12 +77,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 +94,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 +141,13 @@ pub async fn reset_password(
|
||||
if let Err(e) = validate_password(password) {
|
||||
return Err(ApiError::InvalidRequest(e.to_string()));
|
||||
}
|
||||
let user = match state.repos.user.get_user_by_reset_code(token).await {
|
||||
let normalized_token = tranquil_pds::util::normalize_token_code(token);
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_user_by_reset_code(&normalized_token)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return Err(ApiError::InvalidToken(None));
|
||||
|
||||
@@ -74,7 +74,7 @@ pub async fn reauth_password(
|
||||
.log_db_err("fetching password hash")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
|
||||
let password_valid = bcrypt::verify(&input.password, password_hash.as_str()).unwrap_or(false);
|
||||
|
||||
if !password_valid {
|
||||
let app_password_hashes = state
|
||||
@@ -85,7 +85,7 @@ pub async fn reauth_password(
|
||||
.unwrap_or_default();
|
||||
|
||||
let app_password_valid = app_password_hashes.iter().fold(false, |acc, h| {
|
||||
acc | bcrypt::verify(&input.password, h).unwrap_or(false)
|
||||
acc | bcrypt::verify(&input.password, h.as_str()).unwrap_or(false)
|
||||
});
|
||||
|
||||
if !app_password_valid {
|
||||
|
||||
@@ -118,7 +118,7 @@ pub async fn get_service_auth(
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
params.aud.as_str(),
|
||||
method.as_str(),
|
||||
method,
|
||||
) {
|
||||
return e.into_response();
|
||||
}
|
||||
@@ -169,18 +169,14 @@ pub async fn get_service_auth(
|
||||
}
|
||||
}
|
||||
|
||||
let service_token = match tranquil_pds::auth::create_service_token(
|
||||
&auth.did,
|
||||
params.aud.as_str(),
|
||||
lxm.map(|v| v.as_str()),
|
||||
&key_bytes,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to create service token: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let service_token =
|
||||
match tranquil_pds::auth::create_service_token(&auth.did, ¶ms.aud, lxm, &key_bytes) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to create service token: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(GetServiceAuthOutput {
|
||||
|
||||
@@ -15,9 +15,12 @@ use tranquil_pds::auth::{
|
||||
Active, Auth, NormalizedLoginIdentifier, Permissive, require_legacy_session_mfa,
|
||||
require_reauth_window,
|
||||
};
|
||||
use tranquil_pds::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit};
|
||||
use tranquil_pds::rate_limit::{
|
||||
LoginLimit, RateLimited, RefreshSessionLimit, TotpVerifyLimit,
|
||||
check_user_rate_limit_with_message,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AccountState, Did, Handle, PlainPassword};
|
||||
use tranquil_pds::types::{AccountState, AtIdentifier, Did, Handle, PlainPassword};
|
||||
use tranquil_types::TokenId;
|
||||
|
||||
pub fn verification_blocks_login(channel_verification: &ChannelVerificationStatus) -> bool {
|
||||
@@ -75,10 +78,20 @@ pub async fn create_session(
|
||||
"Normalized identifier: {} -> {}",
|
||||
input.identifier, normalized_identifier
|
||||
);
|
||||
let Ok(login_identifier) = AtIdentifier::new(normalized_identifier.as_str()) else {
|
||||
let _ = verify(
|
||||
&input.password,
|
||||
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
|
||||
);
|
||||
warn!("Login identifier is not a valid handle or DID");
|
||||
return Err(ApiError::AuthenticationFailed(Some(
|
||||
"Invalid identifier or password".into(),
|
||||
)));
|
||||
};
|
||||
let row = match state
|
||||
.repos
|
||||
.user
|
||||
.get_login_full_by_identifier(normalized_identifier.as_str())
|
||||
.get_login_full_by_identifier(&login_identifier)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
@@ -109,7 +122,7 @@ pub async fn create_session(
|
||||
state.repos.session.as_ref(),
|
||||
row.id,
|
||||
&input.password,
|
||||
row.password_hash.as_deref(),
|
||||
row.password_hash.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let (app_password_name, app_password_scopes, app_password_controller) = match credential {
|
||||
@@ -168,17 +181,35 @@ pub async fn create_session(
|
||||
let has_totp = row.totp_enabled;
|
||||
let email_2fa_enabled = row.email_2fa_enabled;
|
||||
let is_legacy_login = has_totp || email_2fa_enabled;
|
||||
let used_totp_factor = tranquil_pds::auth::legacy_2fa::used_totp_factor(
|
||||
has_totp,
|
||||
input.auth_factor_token.as_deref(),
|
||||
);
|
||||
let twofa_ctx = tranquil_pds::auth::legacy_2fa::Legacy2faContext {
|
||||
is_app_password: app_password_name.is_some(),
|
||||
email_2fa_enabled,
|
||||
has_totp,
|
||||
allow_legacy_login: row.allow_legacy_login,
|
||||
};
|
||||
if let Some(token) = input.auth_factor_token.as_deref()
|
||||
&& has_totp
|
||||
&& tranquil_pds::auth::legacy_2fa::looks_like_totp_token(token)
|
||||
{
|
||||
check_user_rate_limit_with_message::<TotpVerifyLimit>(
|
||||
&state,
|
||||
row.did.as_str(),
|
||||
"Too many verification attempts. Please try again in a few minutes.",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
match tranquil_pds::auth::legacy_2fa::process_legacy_2fa(
|
||||
state.cache.as_ref(),
|
||||
&row.did,
|
||||
&twofa_ctx,
|
||||
input.auth_factor_token.as_deref(),
|
||||
async |t: &str| {
|
||||
crate::server::totp::verify_totp_or_backup_for_user(&state, &row.did, t).await
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -246,7 +277,7 @@ pub async fn create_session(
|
||||
&row.did,
|
||||
&key_bytes,
|
||||
app_password_scopes.as_deref(),
|
||||
app_password_controller.as_deref(),
|
||||
app_password_controller.as_ref(),
|
||||
None,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
@@ -285,7 +316,7 @@ pub async fn create_session(
|
||||
error!("Failed to insert session: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
if is_legacy_login {
|
||||
if is_legacy_login && !used_totp_factor {
|
||||
warn!(
|
||||
did = %row.did,
|
||||
ip = %client_ip,
|
||||
@@ -320,7 +351,7 @@ pub async fn create_session(
|
||||
refresh_jwt: refresh_meta.token,
|
||||
handle,
|
||||
did: row.did,
|
||||
did_doc: did_doc.ok().map(|f| (*f).clone()),
|
||||
did_doc: did_doc.ok(),
|
||||
email: row.email,
|
||||
email_confirmed: Some(row.channel_verification.email),
|
||||
email_auth_factor: email_auth_factor_out,
|
||||
@@ -413,7 +444,7 @@ pub async fn get_session(
|
||||
status: account_state.status_for_session().map(String::from),
|
||||
migrated_to_pds,
|
||||
migrated_at,
|
||||
did_doc: did_doc.ok().map(|f| (*f).clone()),
|
||||
did_doc: did_doc.ok(),
|
||||
}))
|
||||
}
|
||||
Ok(None) => Err(ApiError::AuthenticationFailed(None)),
|
||||
@@ -431,7 +462,12 @@ pub async fn delete_session(
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
let jti = tranquil_pds::auth::extract_jti_from_headers(&headers)
|
||||
.ok_or(ApiError::AuthenticationRequired)?;
|
||||
match state.repos.session.delete_session_by_access_jti(&jti).await {
|
||||
match state
|
||||
.repos
|
||||
.session
|
||||
.delete_session_by_access_jti(&jti, &auth.did)
|
||||
.await
|
||||
{
|
||||
Ok(rows) if rows > 0 => {
|
||||
let session_cache_key = tranquil_pds::cache_keys::session_key(&auth.did, &jti);
|
||||
let _ = state.cache.delete(&session_cache_key).await;
|
||||
@@ -484,16 +520,15 @@ pub async fn refresh_session(
|
||||
)));
|
||||
}
|
||||
};
|
||||
if let Ok(Some(_)) = state
|
||||
.repos
|
||||
.session
|
||||
.check_refresh_token_used(&refresh_jti)
|
||||
.await
|
||||
if let Some(result) = dispatch_refresh_grace(
|
||||
&state,
|
||||
&refresh_token,
|
||||
&refresh_jti,
|
||||
state.repos.session.lookup_refresh_grace(&refresh_jti).await,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Refresh token reuse detected for jti: {}", refresh_jti);
|
||||
return Err(ApiError::AuthenticationFailed(Some(
|
||||
"Refresh token has been revoked due to suspected compromise".into(),
|
||||
)));
|
||||
return result;
|
||||
}
|
||||
let session_row = match state
|
||||
.repos
|
||||
@@ -503,9 +538,18 @@ pub async fn refresh_session(
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return Err(ApiError::AuthenticationFailed(Some(
|
||||
"Invalid refresh token".into(),
|
||||
)));
|
||||
return dispatch_refresh_grace(
|
||||
&state,
|
||||
&refresh_token,
|
||||
&refresh_jti,
|
||||
state.repos.session.lookup_refresh_grace(&refresh_jti).await,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| {
|
||||
Err(ApiError::AuthenticationFailed(Some(
|
||||
"Invalid refresh token".into(),
|
||||
)))
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Database error fetching session: {:?}", e);
|
||||
@@ -531,7 +575,7 @@ pub async fn refresh_session(
|
||||
&session_row.did,
|
||||
&key_bytes,
|
||||
session_row.scope.as_deref(),
|
||||
session_row.controller_did.as_deref(),
|
||||
session_row.controller_did.as_ref(),
|
||||
None,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
@@ -551,6 +595,7 @@ pub async fn refresh_session(
|
||||
}
|
||||
};
|
||||
let refresh_data = tranquil_db_traits::SessionRefreshData {
|
||||
did: session_row.did.clone(),
|
||||
old_refresh_jti: refresh_jti.clone(),
|
||||
session_id: session_row.id,
|
||||
new_access_jti: new_access_meta.jti.clone(),
|
||||
@@ -558,22 +603,27 @@ pub async fn refresh_session(
|
||||
new_access_expires_at: new_access_meta.expires_at,
|
||||
new_refresh_expires_at: new_refresh_meta.expires_at,
|
||||
};
|
||||
match state
|
||||
let (access_jwt, refresh_jwt) = match state
|
||||
.repos
|
||||
.session
|
||||
.refresh_session_atomic(&refresh_data)
|
||||
.await
|
||||
{
|
||||
Ok(tranquil_db_traits::RefreshSessionResult::Success) => {}
|
||||
Ok(tranquil_db_traits::RefreshSessionResult::TokenAlreadyUsed) => {
|
||||
warn!("Refresh token reuse detected during atomic operation");
|
||||
return Err(ApiError::AuthenticationFailed(Some(
|
||||
"Refresh token has been revoked due to suspected compromise".into(),
|
||||
)));
|
||||
Ok(tranquil_db_traits::RefreshSessionResult::Success) => {
|
||||
(new_access_meta.token, new_refresh_meta.token)
|
||||
}
|
||||
Ok(tranquil_db_traits::RefreshSessionResult::ConcurrentRefresh) => {
|
||||
Ok(tranquil_db_traits::RefreshSessionResult::GraceReplay(replay)) => {
|
||||
// Lost a benign concurrent rotation; re-mint the winner's tokens
|
||||
// using this session's signing key (same user, unchanged by rotation).
|
||||
info!(
|
||||
"Concurrent refresh within grace window for session_id: {}; replaying tokens",
|
||||
session_row.id
|
||||
);
|
||||
remint_grace_tokens(&replay, &key_bytes)?
|
||||
}
|
||||
Ok(tranquil_db_traits::RefreshSessionResult::Compromise) => {
|
||||
warn!(
|
||||
"Concurrent refresh detected for session_id: {}",
|
||||
"Refresh token reuse outside grace window or unreplayable rotation conflict for session_id: {}",
|
||||
session_row.id
|
||||
);
|
||||
return Err(ApiError::AuthenticationFailed(Some(
|
||||
@@ -584,12 +634,151 @@ pub async fn refresh_session(
|
||||
error!("Database error during session refresh: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
};
|
||||
build_refresh_session_output(&state, session_row.did, access_jwt, refresh_jwt).await
|
||||
}
|
||||
|
||||
async fn dispatch_refresh_grace(
|
||||
state: &AppState,
|
||||
refresh_token: &str,
|
||||
presented_jti: &str,
|
||||
lookup: Result<tranquil_db_traits::RefreshGraceLookup, tranquil_db_traits::DbError>,
|
||||
) -> Option<Result<Json<RefreshSessionOutput>, ApiError>> {
|
||||
match lookup {
|
||||
Ok(tranquil_db_traits::RefreshGraceLookup::NotUsed) => None,
|
||||
Ok(tranquil_db_traits::RefreshGraceLookup::Replay(replay)) => {
|
||||
Some(serve_refresh_grace_replay(state, refresh_token, presented_jti, replay).await)
|
||||
}
|
||||
Ok(tranquil_db_traits::RefreshGraceLookup::Compromised {
|
||||
did,
|
||||
session_id,
|
||||
key_bytes,
|
||||
encryption_version,
|
||||
}) => Some(Err(revoke_compromised_session(
|
||||
state,
|
||||
refresh_token,
|
||||
presented_jti,
|
||||
did,
|
||||
session_id,
|
||||
key_bytes,
|
||||
encryption_version,
|
||||
)
|
||||
.await)),
|
||||
Err(e) => {
|
||||
error!("Database error checking refresh token grace: {:?}", e);
|
||||
Some(Err(ApiError::InternalError(None)))
|
||||
}
|
||||
}
|
||||
let did_for_doc = session_row.did.clone();
|
||||
}
|
||||
|
||||
async fn serve_refresh_grace_replay(
|
||||
state: &AppState,
|
||||
refresh_token: &str,
|
||||
presented_jti: &str,
|
||||
replay: tranquil_db_traits::RefreshGraceReplay,
|
||||
) -> Result<Json<RefreshSessionOutput>, ApiError> {
|
||||
let key =
|
||||
match tranquil_pds::config::decrypt_key(&replay.key_bytes, Some(replay.encryption_version))
|
||||
{
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt user key for grace replay: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
};
|
||||
if tranquil_pds::auth::verify_refresh_token(refresh_token, &key).is_err() {
|
||||
return Err(ApiError::AuthenticationFailed(Some(
|
||||
"Invalid refresh token".into(),
|
||||
)));
|
||||
}
|
||||
info!("Refresh token reuse within grace window for jti: {presented_jti}; replaying tokens");
|
||||
let (access_jwt, refresh_jwt) = remint_grace_tokens(&replay, &key)?;
|
||||
build_refresh_session_output(state, replay.did, access_jwt, refresh_jwt).await
|
||||
}
|
||||
|
||||
async fn revoke_compromised_session(
|
||||
state: &AppState,
|
||||
refresh_token: &str,
|
||||
presented_jti: &str,
|
||||
did: Did,
|
||||
session_id: SessionId,
|
||||
key_bytes: Vec<u8>,
|
||||
encryption_version: i32,
|
||||
) -> ApiError {
|
||||
let key = match tranquil_pds::config::decrypt_key(&key_bytes, Some(encryption_version)) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt user key for grace check: {:?}", e);
|
||||
return ApiError::InternalError(None);
|
||||
}
|
||||
};
|
||||
if tranquil_pds::auth::verify_refresh_token(refresh_token, &key).is_err() {
|
||||
return ApiError::AuthenticationFailed(Some("Invalid refresh token".into()));
|
||||
}
|
||||
warn!("Refresh token reuse outside grace window for jti: {presented_jti}; revoking session");
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.session
|
||||
.delete_session_by_id(session_id, &did)
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
"Failed to revoke session {} for refresh token reuse: {:?}",
|
||||
session_id.as_i32(),
|
||||
e
|
||||
);
|
||||
return ApiError::InternalError(None);
|
||||
}
|
||||
ApiError::AuthenticationFailed(Some(
|
||||
"Refresh token has been revoked due to suspected compromise".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Re-mint the access/refresh JWTs for a grace-window replay from the session's
|
||||
/// current jtis and signing key. We never persist the signed JWTs; they are
|
||||
/// reconstructed on demand so a benignly-racing client converges on the same
|
||||
/// credentials the winning rotation produced. `key_bytes` is the owning user's
|
||||
/// already-decrypted signing key.
|
||||
fn remint_grace_tokens(
|
||||
replay: &tranquil_db_traits::RefreshGraceReplay,
|
||||
key_bytes: &[u8],
|
||||
) -> Result<(String, String), ApiError> {
|
||||
let access_jwt = tranquil_pds::auth::create_access_token_with_jti(
|
||||
&replay.did,
|
||||
key_bytes,
|
||||
replay.scope.as_deref(),
|
||||
replay.controller_did.as_ref(),
|
||||
None,
|
||||
&replay.access_jti,
|
||||
replay.access_expires_at,
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("Failed to re-mint access token for grace replay: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let refresh_jwt = tranquil_pds::auth::create_refresh_token_with_jti(
|
||||
&replay.did,
|
||||
key_bytes,
|
||||
&replay.refresh_jti,
|
||||
replay.refresh_expires_at,
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("Failed to re-mint refresh token for grace replay: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
Ok((access_jwt, refresh_jwt))
|
||||
}
|
||||
|
||||
async fn build_refresh_session_output(
|
||||
state: &AppState,
|
||||
did: Did,
|
||||
access_jwt: String,
|
||||
refresh_jwt: String,
|
||||
) -> Result<Json<RefreshSessionOutput>, ApiError> {
|
||||
let did_resolver = state.did_resolver.clone();
|
||||
let (db_result, did_doc) = tokio::join!(
|
||||
state.repos.user.get_session_info_by_did(&session_row.did),
|
||||
did_resolver.fetch_did_document(&did_for_doc)
|
||||
state.repos.user.get_session_info_by_did(&did),
|
||||
did_resolver.fetch_did_document(&did)
|
||||
);
|
||||
match db_result {
|
||||
Ok(Some(u)) => {
|
||||
@@ -600,10 +789,10 @@ pub async fn refresh_session(
|
||||
let account_state =
|
||||
AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None);
|
||||
Ok(Json(RefreshSessionOutput {
|
||||
access_jwt: new_access_meta.token,
|
||||
refresh_jwt: new_refresh_meta.token,
|
||||
access_jwt,
|
||||
refresh_jwt,
|
||||
handle,
|
||||
did: session_row.did,
|
||||
did,
|
||||
email: u.email,
|
||||
email_confirmed: u.channel_verification.email,
|
||||
preferred_channel: u.preferred_comms_channel.as_str().to_string(),
|
||||
@@ -611,12 +800,12 @@ pub async fn refresh_session(
|
||||
preferred_locale: u.preferred_locale,
|
||||
is_admin: u.is_admin,
|
||||
active: account_state.is_active(),
|
||||
did_doc: did_doc.ok().map(|f| (*f).clone()),
|
||||
did_doc: did_doc.ok(),
|
||||
status: account_state.status_for_session().map(String::from),
|
||||
}))
|
||||
}
|
||||
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) => {
|
||||
@@ -726,7 +915,6 @@ pub async fn confirm_signup(
|
||||
let session = match crate::identity::provision::create_and_store_session(
|
||||
&state,
|
||||
&row.did,
|
||||
&row.did,
|
||||
&key_bytes,
|
||||
"transition:generic transition:chat.bsky",
|
||||
None,
|
||||
@@ -768,7 +956,7 @@ pub struct AutoResendResult {
|
||||
}
|
||||
|
||||
pub async fn auto_resend_verification(state: &AppState, did: &Did) -> Option<AutoResendResult> {
|
||||
let debounce_key = tranquil_pds::cache_keys::auto_verify_sent_key(did.as_str());
|
||||
let debounce_key = tranquil_pds::cache_keys::auto_verify_sent_key(did);
|
||||
let debounced = state.cache.get(&debounce_key).await.is_some();
|
||||
let row = match state.repos.user.get_resend_verification_by_did(did).await {
|
||||
Ok(Some(row)) => row,
|
||||
@@ -951,13 +1139,13 @@ pub async fn list_sessions(
|
||||
Ok(Json(ListSessionsOutput { sessions }))
|
||||
}
|
||||
|
||||
fn extract_client_name(client_id: &str) -> String {
|
||||
fn extract_client_name(client_id: &tranquil_types::ClientId) -> String {
|
||||
if client_id.starts_with("http://localhost") || client_id.starts_with("http://127.0.0.1") {
|
||||
"Localhost App".to_string()
|
||||
} else if let Ok(parsed) = reqwest::Url::parse(client_id) {
|
||||
} else if let Ok(parsed) = reqwest::Url::parse(client_id.as_str()) {
|
||||
parsed.host_str().unwrap_or("Unknown App").to_string()
|
||||
} else {
|
||||
client_id.to_string()
|
||||
client_id.as_str().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,7 +1175,7 @@ pub async fn revoke_session(
|
||||
state
|
||||
.repos
|
||||
.session
|
||||
.delete_session_by_id(session_id)
|
||||
.delete_session_by_id(session_id, &auth.did)
|
||||
.await
|
||||
.log_db_err("deleting session")?;
|
||||
let cache_key = tranquil_pds::cache_keys::session_key(&auth.did, &access_jti);
|
||||
@@ -1031,11 +1219,11 @@ pub async fn revoke_all_sessions(
|
||||
.delete_sessions_by_did(&auth.did)
|
||||
.await
|
||||
.log_db_err("revoking JWT sessions")?;
|
||||
let jti_typed = TokenId::from(jti.clone());
|
||||
let token_id = TokenId::from(jti.clone().into_inner());
|
||||
state
|
||||
.repos
|
||||
.oauth
|
||||
.delete_sessions_by_did_except(&auth.did, &jti_typed)
|
||||
.delete_sessions_by_did_except(&auth.did, &token_id)
|
||||
.await
|
||||
.log_db_err("revoking OAuth sessions")?;
|
||||
} else {
|
||||
|
||||
@@ -10,17 +10,19 @@ use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info};
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::Did;
|
||||
|
||||
const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01];
|
||||
|
||||
fn public_key_to_did_key(signing_key: &SigningKey) -> String {
|
||||
fn public_key_to_did_key(signing_key: &SigningKey) -> Did {
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let compressed_pubkey = verifying_key.to_sec1_bytes();
|
||||
let mut multicodec_key = Vec::with_capacity(2 + compressed_pubkey.len());
|
||||
multicodec_key.extend_from_slice(&SECP256K1_MULTICODEC_PREFIX);
|
||||
multicodec_key.extend_from_slice(&compressed_pubkey);
|
||||
let encoded = multibase::encode(multibase::Base::Base58Btc, &multicodec_key);
|
||||
format!("did:key:{}", encoded)
|
||||
Did::new(format!("did:key:{}", encoded))
|
||||
.expect("did:key with a multibase-encoded secp256k1 key is a valid DID")
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -31,7 +33,7 @@ pub struct ReserveSigningKeyInput {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReserveSigningKeyOutput {
|
||||
pub signing_key: String,
|
||||
pub signing_key: Did,
|
||||
}
|
||||
|
||||
pub async fn reserve_signing_key(
|
||||
|
||||
@@ -52,7 +52,7 @@ impl DeviceTrustState {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrustedDevice {
|
||||
pub id: String,
|
||||
pub id: DeviceId,
|
||||
pub user_agent: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub trusted_at: Option<DateTime<Utc>>,
|
||||
|
||||
@@ -62,7 +62,20 @@ pub async fn handle_telegram_webhook(
|
||||
&& let Some(from) = message.from
|
||||
&& let Some(username) = from.username
|
||||
{
|
||||
let handle = parse_start_handle(message.text.as_deref());
|
||||
let handle = match parse_start_handle(message.text.as_deref())
|
||||
.map(tranquil_types::Handle::new)
|
||||
.transpose()
|
||||
{
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
telegram_username = %username,
|
||||
error = %e,
|
||||
"Ignoring /start with an invalid handle"
|
||||
);
|
||||
return StatusCode::OK;
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
telegram_username = %username,
|
||||
@@ -73,7 +86,7 @@ pub async fn handle_telegram_webhook(
|
||||
match state
|
||||
.repos
|
||||
.user
|
||||
.store_telegram_chat_id(&username, from.id, handle.as_deref())
|
||||
.store_telegram_chat_id(&username, from.id, handle.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(Some(user_id)) => {
|
||||
|
||||
@@ -58,7 +58,16 @@ pub async fn dereference_scope(
|
||||
|
||||
for part in scope_parts {
|
||||
if let Some(cid_str) = part.strip_prefix("ref:") {
|
||||
let cache_key = tranquil_pds::cache_keys::scope_ref_key(cid_str);
|
||||
let cid = match Cid::from_str(cid_str) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
tracing::warn!("Invalid CID in scope ref: {}", cid_str);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let cache_key =
|
||||
tranquil_pds::cache_keys::scope_ref_key(&tranquil_types::CidLink::from(&cid));
|
||||
if let Some(cached) = state.cache.get(&cache_key).await {
|
||||
for s in cached.split_whitespace() {
|
||||
if !resolved_scopes.contains(&s.to_string()) {
|
||||
@@ -68,14 +77,6 @@ pub async fn dereference_scope(
|
||||
continue;
|
||||
}
|
||||
|
||||
let cid = match Cid::from_str(cid_str) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
tracing::warn!("Invalid CID in scope ref: {}", cid_str);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let block_bytes = match state.block_store.get(&cid).await {
|
||||
Ok(Some(b)) => b,
|
||||
Ok(None) => {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tranquil_pds::api::SuccessResponse;
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmChannelVerificationInput {
|
||||
pub channel: tranquil_db_traits::CommsChannel,
|
||||
pub identifier: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
pub async fn confirm_channel_verification(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ConfirmChannelVerificationInput>,
|
||||
) -> Response {
|
||||
let token_input = crate::server::VerifyTokenInput {
|
||||
token: input.code,
|
||||
identifier: input.identifier,
|
||||
};
|
||||
|
||||
match crate::server::verify_token_internal(&state, token_input).await {
|
||||
Ok(_output) => SuccessResponse::ok().into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
[dependencies]
|
||||
tranquil-config = { workspace = true }
|
||||
tranquil-crypto = { workspace = true }
|
||||
tranquil-types = { path = "../tranquil-types", default-features = false }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
base32 = { workspace = true }
|
||||
@@ -23,3 +24,4 @@ subtle = { workspace = true }
|
||||
totp-rs = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
brotli = "8.0.4"
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use brotli::{CompressorWriter, Decompressor};
|
||||
use std::fmt;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
const COMPRESSED_PREFIX: &str = "$br$";
|
||||
const QUALITY: u32 = 9;
|
||||
const WINDOW_BITS: u32 = 16;
|
||||
const BUFFER_SIZE: usize = 4096;
|
||||
const MAX_SCOPE_LEN: u64 = 64 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScopeDecodeError {
|
||||
Base64DecodeFailed,
|
||||
DecompressFailed,
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
impl fmt::Display for ScopeDecodeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Base64DecodeFailed => write!(f, "Base64 decode of compressed scope failed"),
|
||||
Self::DecompressFailed => write!(f, "Brotli decompression of scope failed"),
|
||||
Self::TooLarge => write!(f, "Decompressed scope exceeds maximum length"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ScopeDecodeError {}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScopeEncodeError {
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
impl fmt::Display for ScopeEncodeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::TooLarge => write!(f, "Scope exceeds maximum length"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ScopeEncodeError {}
|
||||
|
||||
fn brotli_compress(input: &str) -> Vec<u8> {
|
||||
let mut writer = CompressorWriter::new(Vec::new(), BUFFER_SIZE, QUALITY, WINDOW_BITS);
|
||||
|
||||
writer
|
||||
.write_all(input.as_bytes())
|
||||
.expect("writing to a Vec cannot fail");
|
||||
|
||||
writer.into_inner()
|
||||
}
|
||||
|
||||
fn brotli_decompress(input: &[u8]) -> Result<String, ScopeDecodeError> {
|
||||
let mut output = String::new();
|
||||
|
||||
Decompressor::new(input, BUFFER_SIZE)
|
||||
.take(MAX_SCOPE_LEN + 1)
|
||||
.read_to_string(&mut output)
|
||||
.map_err(|_| ScopeDecodeError::DecompressFailed)?;
|
||||
|
||||
if output.len() as u64 > MAX_SCOPE_LEN {
|
||||
return Err(ScopeDecodeError::TooLarge);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub fn encode_scope(scope: &str) -> Result<String, ScopeEncodeError> {
|
||||
if scope.len() as u64 > MAX_SCOPE_LEN {
|
||||
return Err(ScopeEncodeError::TooLarge);
|
||||
}
|
||||
|
||||
let tagged = format!(
|
||||
"{COMPRESSED_PREFIX}{}",
|
||||
URL_SAFE_NO_PAD.encode(brotli_compress(scope))
|
||||
);
|
||||
|
||||
if tagged.len() < scope.len() || scope.starts_with(COMPRESSED_PREFIX) {
|
||||
Ok(tagged)
|
||||
} else {
|
||||
Ok(scope.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_scope(scope: &str) -> Result<String, ScopeDecodeError> {
|
||||
let Some(encoded) = scope.strip_prefix(COMPRESSED_PREFIX) else {
|
||||
return Ok(scope.to_owned());
|
||||
};
|
||||
|
||||
let compressed = URL_SAFE_NO_PAD
|
||||
.decode(encoded)
|
||||
.map_err(|_| ScopeDecodeError::Base64DecodeFailed)?;
|
||||
|
||||
brotli_decompress(&compressed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn long_scope() -> String {
|
||||
let mut scope = String::from("transition:generic transition:chat.bsky");
|
||||
for collection in [
|
||||
"social.colibri.message",
|
||||
"social.colibri.community",
|
||||
"social.colibri.reaction",
|
||||
"social.colibri.member",
|
||||
"social.colibri.channel.read",
|
||||
] {
|
||||
scope.push_str(&format!(" repo:{collection}?action=create&action=delete"));
|
||||
}
|
||||
scope
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_scope_roundtrips_through_compression() {
|
||||
let scope = long_scope();
|
||||
let encoded = encode_scope(&scope).unwrap();
|
||||
|
||||
assert!(encoded.starts_with(COMPRESSED_PREFIX));
|
||||
assert!(encoded.len() < scope.len());
|
||||
assert_eq!(decode_scope(&encoded).unwrap(), scope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_scope_stays_plaintext() {
|
||||
let encoded = encode_scope("com.atproto.access").unwrap();
|
||||
|
||||
assert_eq!(encoded, "com.atproto.access");
|
||||
assert_eq!(decode_scope(&encoded).unwrap(), "com.atproto.access");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untagged_scope_passes_through() {
|
||||
assert_eq!(
|
||||
decode_scope("com.atproto.refresh").unwrap(),
|
||||
"com.atproto.refresh"
|
||||
);
|
||||
assert_eq!(decode_scope("").unwrap(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_compressed_scope_errors_instead_of_panicking() {
|
||||
assert_eq!(
|
||||
decode_scope("$br$not valid base64!"),
|
||||
Err(ScopeDecodeError::Base64DecodeFailed)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_scope("$br$AAAAAAAAAAAAAAAA"),
|
||||
Err(ScopeDecodeError::DecompressFailed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_bomb_is_rejected() {
|
||||
let bomb = URL_SAFE_NO_PAD.encode(brotli_compress(&"a".repeat(MAX_SCOPE_LEN as usize * 2)));
|
||||
|
||||
assert_eq!(
|
||||
decode_scope(&format!("{COMPRESSED_PREFIX}{bomb}")),
|
||||
Err(ScopeDecodeError::TooLarge)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_that_looks_compressed_roundtrips() {
|
||||
let scope = "$br$repo:*";
|
||||
let encoded = encode_scope(scope).unwrap();
|
||||
|
||||
assert!(encoded.starts_with(COMPRESSED_PREFIX));
|
||||
assert_eq!(decode_scope(&encoded).unwrap(), scope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_rejects_oversized_scope() {
|
||||
let oversized = "a".repeat(MAX_SCOPE_LEN as usize + 1);
|
||||
|
||||
assert_eq!(encode_scope(&oversized), Err(ScopeEncodeError::TooLarge));
|
||||
assert!(encode_scope(&"a".repeat(MAX_SCOPE_LEN as usize)).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod compress;
|
||||
mod token;
|
||||
mod totp;
|
||||
mod types;
|
||||
@@ -5,12 +6,15 @@ 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 compress::{ScopeDecodeError, ScopeEncodeError, decode_scope, encode_scope};
|
||||
|
||||
pub use totp::{
|
||||
TotpError, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
|
||||
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
|
||||
@@ -24,6 +28,6 @@ pub use types::{
|
||||
|
||||
pub use verify::{
|
||||
get_algorithm_from_token, get_did_from_token, get_jti_from_token, verify_access_token,
|
||||
verify_access_token_hs256, verify_access_token_typed, verify_refresh_token,
|
||||
verify_refresh_token_hs256, verify_token,
|
||||
verify_access_token_hs256, verify_refresh_token, verify_refresh_token_hs256, verify_token,
|
||||
verify_token_es256k,
|
||||
};
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
use crate::compress::encode_scope;
|
||||
|
||||
use super::types::{
|
||||
ActClaim, Claims, Header, SigningAlgorithm, TokenScope, TokenType, TokenWithMetadata,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::{Duration, Utc};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use hmac::{Hmac, Mac};
|
||||
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
|
||||
use sha2::Sha256;
|
||||
use tranquil_types::{Did, Jti, Nsid};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub fn create_access_token(did: &str, key_bytes: &[u8]) -> Result<String> {
|
||||
pub fn create_access_token(did: &Did, key_bytes: &[u8]) -> Result<String> {
|
||||
Ok(create_access_token_with_metadata(did, key_bytes)?.token)
|
||||
}
|
||||
|
||||
pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result<String> {
|
||||
pub fn create_refresh_token(did: &Did, key_bytes: &[u8]) -> Result<String> {
|
||||
Ok(create_refresh_token_with_metadata(did, key_bytes)?.token)
|
||||
}
|
||||
|
||||
pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
|
||||
pub fn create_access_token_with_metadata(did: &Did, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
|
||||
create_access_token_with_scope_metadata(did, key_bytes, None, None)
|
||||
}
|
||||
|
||||
pub fn create_access_token_with_scope_metadata(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
key_bytes: &[u8],
|
||||
scopes: Option<&str>,
|
||||
hostname: Option<&str>,
|
||||
@@ -35,33 +38,33 @@ pub fn create_access_token_with_scope_metadata(
|
||||
scope,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
hostname,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_access_token_with_delegation(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
key_bytes: &[u8],
|
||||
scopes: Option<&str>,
|
||||
controller_did: Option<&str>,
|
||||
controller_did: Option<&Did>,
|
||||
hostname: Option<&str>,
|
||||
) -> Result<TokenWithMetadata> {
|
||||
let scope = scopes.unwrap_or(TokenScope::Access.as_str());
|
||||
let act = controller_did.map(|c| ActClaim { sub: c.to_string() });
|
||||
let act = controller_did.map(|c| ActClaim { sub: c.clone() });
|
||||
create_signed_token_with_act(
|
||||
did,
|
||||
scope,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
act,
|
||||
hostname,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_refresh_token_with_metadata(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
key_bytes: &[u8],
|
||||
) -> Result<TokenWithMetadata> {
|
||||
create_signed_token_with_metadata(
|
||||
@@ -69,15 +72,63 @@ pub fn create_refresh_token_with_metadata(
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
key_bytes,
|
||||
Duration::days(14),
|
||||
Duration::days(90),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Re-mint an access token carrying a specific `jti` and expiry. Used by the
|
||||
/// refresh grace window to reproduce a session's current access token without
|
||||
/// persisting the signed JWT itself.
|
||||
pub fn create_access_token_with_jti(
|
||||
did: &Did,
|
||||
key_bytes: &[u8],
|
||||
scopes: Option<&str>,
|
||||
controller_did: Option<&Did>,
|
||||
hostname: Option<&str>,
|
||||
jti: &Jti,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<String> {
|
||||
let scope = scopes.unwrap_or(TokenScope::Access.as_str());
|
||||
let act = controller_did.map(|c| ActClaim { sub: c.clone() });
|
||||
Ok(create_signed_token_pinned(
|
||||
did,
|
||||
scope,
|
||||
TokenType::Access,
|
||||
key_bytes,
|
||||
expires_at,
|
||||
jti.clone(),
|
||||
act,
|
||||
hostname,
|
||||
)?
|
||||
.token)
|
||||
}
|
||||
|
||||
/// Re-mint a refresh token carrying a specific `jti` and expiry. Counterpart to
|
||||
/// [`create_access_token_with_jti`] for the refresh grace window.
|
||||
pub fn create_refresh_token_with_jti(
|
||||
did: &Did,
|
||||
key_bytes: &[u8],
|
||||
jti: &Jti,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<String> {
|
||||
Ok(create_signed_token_pinned(
|
||||
did,
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
key_bytes,
|
||||
expires_at,
|
||||
jti.clone(),
|
||||
None,
|
||||
None,
|
||||
)?
|
||||
.token)
|
||||
}
|
||||
|
||||
pub fn create_service_token(
|
||||
did: &str,
|
||||
aud: &str,
|
||||
lxm: Option<&str>,
|
||||
did: &Did,
|
||||
aud: &Did,
|
||||
lxm: Option<&Nsid>,
|
||||
key_bytes: &[u8],
|
||||
) -> Result<String> {
|
||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||
@@ -88,14 +139,14 @@ pub fn create_service_token(
|
||||
.timestamp();
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
aud: aud.to_owned(),
|
||||
iss: did.clone(),
|
||||
sub: did.clone(),
|
||||
aud: aud.to_string(),
|
||||
exp: expiration,
|
||||
iat: Utc::now().timestamp(),
|
||||
scope: None,
|
||||
lxm: lxm.map(ToOwned::to_owned),
|
||||
jti: uuid::Uuid::new_v4().to_string(),
|
||||
lxm: lxm.cloned(),
|
||||
jti: Jti::new(uuid::Uuid::new_v4().to_string()),
|
||||
act: None,
|
||||
};
|
||||
|
||||
@@ -103,7 +154,7 @@ pub fn create_service_token(
|
||||
}
|
||||
|
||||
fn create_signed_token_with_metadata(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
scope: &str,
|
||||
typ: TokenType,
|
||||
key_bytes: &[u8],
|
||||
@@ -114,7 +165,7 @@ fn create_signed_token_with_metadata(
|
||||
}
|
||||
|
||||
fn create_signed_token_with_act(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
scope: &str,
|
||||
typ: TokenType,
|
||||
key_bytes: &[u8],
|
||||
@@ -122,14 +173,27 @@ fn create_signed_token_with_act(
|
||||
act: Option<ActClaim>,
|
||||
hostname: Option<&str>,
|
||||
) -> Result<TokenWithMetadata> {
|
||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||
|
||||
let expires_at = Utc::now()
|
||||
.checked_add_signed(duration)
|
||||
.expect("valid timestamp");
|
||||
let jti = Jti::new(uuid::Uuid::new_v4().to_string());
|
||||
create_signed_token_pinned(did, scope, typ, key_bytes, expires_at, jti, act, hostname)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn create_signed_token_pinned(
|
||||
did: &Did,
|
||||
scope: &str,
|
||||
typ: TokenType,
|
||||
key_bytes: &[u8],
|
||||
expires_at: DateTime<Utc>,
|
||||
jti: Jti,
|
||||
act: Option<ActClaim>,
|
||||
hostname: Option<&str>,
|
||||
) -> Result<TokenWithMetadata> {
|
||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||
|
||||
let expiration = expires_at.timestamp();
|
||||
let jti = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let aud_hostname = hostname.map(|h| h.to_string()).unwrap_or_else(|| {
|
||||
tranquil_config::try_get()
|
||||
@@ -138,12 +202,12 @@ fn create_signed_token_with_act(
|
||||
});
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
iss: did.clone(),
|
||||
sub: did.clone(),
|
||||
aud: format!("did:web:{}", aud_hostname),
|
||||
exp: expiration,
|
||||
iat: Utc::now().timestamp(),
|
||||
scope: Some(scope.to_string()),
|
||||
scope: Some(encode_scope(scope).context("Scope too large to encode")?),
|
||||
lxm: None,
|
||||
jti: jti.clone(),
|
||||
act,
|
||||
@@ -181,16 +245,16 @@ fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: TokenType) -> Re
|
||||
Ok(format!("{}.{}", message, signature_b64))
|
||||
}
|
||||
|
||||
pub fn create_access_token_hs256(did: &str, secret: &[u8]) -> Result<String> {
|
||||
pub fn create_access_token_hs256(did: &Did, secret: &[u8]) -> Result<String> {
|
||||
Ok(create_access_token_hs256_with_metadata(did, secret)?.token)
|
||||
}
|
||||
|
||||
pub fn create_refresh_token_hs256(did: &str, secret: &[u8]) -> Result<String> {
|
||||
pub fn create_refresh_token_hs256(did: &Did, secret: &[u8]) -> Result<String> {
|
||||
Ok(create_refresh_token_hs256_with_metadata(did, secret)?.token)
|
||||
}
|
||||
|
||||
pub fn create_access_token_hs256_with_metadata(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
secret: &[u8],
|
||||
) -> Result<TokenWithMetadata> {
|
||||
create_hs256_token_with_metadata(
|
||||
@@ -198,12 +262,12 @@ pub fn create_access_token_hs256_with_metadata(
|
||||
TokenScope::Access.as_str(),
|
||||
TokenType::Access,
|
||||
secret,
|
||||
Duration::minutes(15),
|
||||
Duration::minutes(120),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_refresh_token_hs256_with_metadata(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
secret: &[u8],
|
||||
) -> Result<TokenWithMetadata> {
|
||||
create_hs256_token_with_metadata(
|
||||
@@ -211,14 +275,14 @@ pub fn create_refresh_token_hs256_with_metadata(
|
||||
TokenScope::Refresh.as_str(),
|
||||
TokenType::Refresh,
|
||||
secret,
|
||||
Duration::days(14),
|
||||
Duration::days(90),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_service_token_hs256(
|
||||
did: &str,
|
||||
aud: &str,
|
||||
lxm: &str,
|
||||
did: &Did,
|
||||
aud: &Did,
|
||||
lxm: &Nsid,
|
||||
secret: &[u8],
|
||||
) -> Result<String> {
|
||||
let expiration = Utc::now()
|
||||
@@ -227,14 +291,14 @@ pub fn create_service_token_hs256(
|
||||
.timestamp();
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
aud: aud.to_owned(),
|
||||
iss: did.clone(),
|
||||
sub: did.clone(),
|
||||
aud: aud.to_string(),
|
||||
exp: expiration,
|
||||
iat: Utc::now().timestamp(),
|
||||
scope: None,
|
||||
lxm: Some(lxm.to_string()),
|
||||
jti: uuid::Uuid::new_v4().to_string(),
|
||||
lxm: Some(lxm.clone()),
|
||||
jti: Jti::new(uuid::Uuid::new_v4().to_string()),
|
||||
act: None,
|
||||
};
|
||||
|
||||
@@ -242,7 +306,7 @@ pub fn create_service_token_hs256(
|
||||
}
|
||||
|
||||
fn create_hs256_token_with_metadata(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
scope: &str,
|
||||
typ: TokenType,
|
||||
secret: &[u8],
|
||||
@@ -253,11 +317,11 @@ fn create_hs256_token_with_metadata(
|
||||
.expect("valid timestamp");
|
||||
|
||||
let expiration = expires_at.timestamp();
|
||||
let jti = uuid::Uuid::new_v4().to_string();
|
||||
let jti = Jti::new(uuid::Uuid::new_v4().to_string());
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
iss: did.clone(),
|
||||
sub: did.clone(),
|
||||
aud: format!(
|
||||
"did:web:{}",
|
||||
tranquil_config::try_get()
|
||||
@@ -266,7 +330,7 @@ fn create_hs256_token_with_metadata(
|
||||
),
|
||||
exp: expiration,
|
||||
iat: Utc::now().timestamp(),
|
||||
scope: Some(scope.to_string()),
|
||||
scope: Some(encode_scope(scope).context("Scope too large to encode")?),
|
||||
lxm: None,
|
||||
jti: jti.clone(),
|
||||
act: None,
|
||||
|
||||
@@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize, de, ser};
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
use tranquil_types::{Did, Jti, Nsid};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TokenType {
|
||||
@@ -15,7 +16,9 @@ impl TokenType {
|
||||
match self {
|
||||
Self::Access => "at+jwt",
|
||||
Self::Refresh => "refresh+jwt",
|
||||
Self::Service => "jwt",
|
||||
// RFC 7519 §5.1 recommends the uppercase "JWT".
|
||||
// and for atproto inter-service auth its a requirement.
|
||||
Self::Service => "JWT",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,21 +204,21 @@ impl std::error::Error for TokenDecodeError {}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActClaim {
|
||||
pub sub: String,
|
||||
pub sub: Did,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub iss: String,
|
||||
pub sub: String,
|
||||
pub iss: Did,
|
||||
pub sub: Did,
|
||||
pub aud: String,
|
||||
pub exp: i64,
|
||||
pub iat: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lxm: Option<String>,
|
||||
pub jti: String,
|
||||
pub lxm: Option<Nsid>,
|
||||
pub jti: Jti,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub act: Option<ActClaim>,
|
||||
}
|
||||
@@ -228,8 +231,8 @@ pub struct Header {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UnsafeClaims {
|
||||
pub iss: String,
|
||||
pub sub: Option<String>,
|
||||
pub iss: Did,
|
||||
pub sub: Option<Did>,
|
||||
}
|
||||
|
||||
pub struct TokenData<T> {
|
||||
@@ -238,21 +241,21 @@ pub struct TokenData<T> {
|
||||
|
||||
pub struct TokenWithMetadata {
|
||||
pub token: String,
|
||||
pub jti: String,
|
||||
pub jti: Jti,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TokenVerifyError {
|
||||
Expired,
|
||||
Invalid,
|
||||
Invalid(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for TokenVerifyError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Expired => write!(f, "Token expired"),
|
||||
Self::Invalid => write!(f, "Token invalid"),
|
||||
Self::Invalid(reason) => write!(f, "{}", reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +293,17 @@ mod tests {
|
||||
assert!(TokenType::from_str("bearer").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_token_header_serializes_typ_as_uppercase_jwt() {
|
||||
// RFC 7519 §5.1 recommends the JWT `typ` header value be uppercase "JWT".
|
||||
let header = Header {
|
||||
alg: SigningAlgorithm::ES256K,
|
||||
typ: TokenType::Service,
|
||||
};
|
||||
let json = serde_json::to_string(&header).expect("serialize header");
|
||||
assert!(json.contains(r#""typ":"JWT""#), "got {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_algorithm_case_insensitive() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::compress::decode_scope;
|
||||
|
||||
use super::types::{
|
||||
Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
|
||||
TokenVerifyError, UnsafeClaims,
|
||||
@@ -10,10 +12,11 @@ use hmac::{Hmac, Mac};
|
||||
use k256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Verifier};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
use tranquil_types::{Did, Jti};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub fn get_did_from_token(token: &str) -> Result<String, TokenDecodeError> {
|
||||
pub fn get_did_from_token(token: &str) -> Result<Did, TokenDecodeError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(TokenDecodeError::InvalidFormat);
|
||||
@@ -29,7 +32,7 @@ pub fn get_did_from_token(token: &str) -> Result<String, TokenDecodeError> {
|
||||
Ok(claims.sub.unwrap_or(claims.iss))
|
||||
}
|
||||
|
||||
pub fn get_jti_from_token(token: &str) -> Result<String, TokenDecodeError> {
|
||||
pub fn get_jti_from_token(token: &str) -> Result<Jti, TokenDecodeError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(TokenDecodeError::InvalidFormat);
|
||||
@@ -45,7 +48,7 @@ pub fn get_jti_from_token(token: &str) -> Result<String, TokenDecodeError> {
|
||||
claims
|
||||
.get("jti")
|
||||
.and_then(|j| j.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.map(Jti::new)
|
||||
.ok_or(TokenDecodeError::MissingClaim)
|
||||
}
|
||||
|
||||
@@ -66,11 +69,11 @@ pub fn get_algorithm_from_token(token: &str) -> Result<SigningAlgorithm, TokenDe
|
||||
}
|
||||
|
||||
pub fn verify_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
|
||||
verify_token_internal(token, key_bytes, None, None)
|
||||
verify_token_es256k(token, key_bytes, None, None).map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
|
||||
verify_token_internal(
|
||||
verify_token_es256k(
|
||||
token,
|
||||
key_bytes,
|
||||
Some(TokenType::Access),
|
||||
@@ -80,15 +83,17 @@ pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Cl
|
||||
TokenScope::AppPassPrivileged,
|
||||
]),
|
||||
)
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
pub fn verify_refresh_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
|
||||
verify_token_internal(
|
||||
verify_token_es256k(
|
||||
token,
|
||||
key_bytes,
|
||||
Some(TokenType::Refresh),
|
||||
Some(&[TokenScope::Refresh]),
|
||||
)
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
pub fn verify_access_token_hs256(token: &str, secret: &[u8]) -> Result<TokenData<Claims>> {
|
||||
@@ -113,15 +118,15 @@ pub fn verify_refresh_token_hs256(token: &str, secret: &[u8]) -> Result<TokenDat
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_token_internal(
|
||||
pub fn verify_token_es256k(
|
||||
token: &str,
|
||||
key_bytes: &[u8],
|
||||
expected_typ: Option<TokenType>,
|
||||
allowed_scopes: Option<&[TokenScope]>,
|
||||
) -> Result<TokenData<Claims>> {
|
||||
) -> Result<TokenData<Claims>, TokenVerifyError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(anyhow!("Invalid token format"));
|
||||
return Err(TokenVerifyError::Invalid("Invalid token format"));
|
||||
}
|
||||
|
||||
let header_b64 = parts[0];
|
||||
@@ -130,46 +135,49 @@ fn verify_token_internal(
|
||||
|
||||
let header_bytes = URL_SAFE_NO_PAD
|
||||
.decode(header_b64)
|
||||
.context("Base64 decode of header failed")?;
|
||||
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of header failed"))?;
|
||||
|
||||
let header: Header =
|
||||
serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?;
|
||||
let header: Header = serde_json::from_slice(&header_bytes)
|
||||
.map_err(|_| TokenVerifyError::Invalid("JSON decode of header failed"))?;
|
||||
|
||||
if let Some(expected) = expected_typ
|
||||
&& header.typ != expected
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Invalid token type: expected {}, got {}",
|
||||
expected,
|
||||
header.typ
|
||||
));
|
||||
return Err(TokenVerifyError::Invalid("Invalid token type"));
|
||||
}
|
||||
|
||||
let signature_bytes = URL_SAFE_NO_PAD
|
||||
.decode(signature_b64)
|
||||
.context("Base64 decode of signature failed")?;
|
||||
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of signature failed"))?;
|
||||
|
||||
let signature = Signature::from_slice(&signature_bytes)
|
||||
.map_err(|e| anyhow!("Invalid signature format: {}", e))?;
|
||||
.map_err(|_| TokenVerifyError::Invalid("Invalid signature format"))?;
|
||||
|
||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||
let signing_key = SigningKey::from_slice(key_bytes)
|
||||
.map_err(|_| TokenVerifyError::Invalid("Invalid signing key"))?;
|
||||
let verifying_key = VerifyingKey::from(&signing_key);
|
||||
|
||||
let message = format!("{}.{}", header_b64, claims_b64);
|
||||
verifying_key
|
||||
.verify(message.as_bytes(), &signature)
|
||||
.map_err(|e| anyhow!("Signature verification failed: {}", e))?;
|
||||
.map_err(|_| TokenVerifyError::Invalid("Signature verification failed"))?;
|
||||
|
||||
let claims_bytes = URL_SAFE_NO_PAD
|
||||
.decode(claims_b64)
|
||||
.context("Base64 decode of claims failed")?;
|
||||
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of claims failed"))?;
|
||||
|
||||
let claims: Claims =
|
||||
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
|
||||
let mut claims: Claims = serde_json::from_slice(&claims_bytes)
|
||||
.map_err(|_| TokenVerifyError::Invalid("JSON decode of claims failed"))?;
|
||||
|
||||
if let Some(scope) = &claims.scope {
|
||||
claims.scope = Some(
|
||||
decode_scope(scope).map_err(|_| TokenVerifyError::Invalid("Invalid token scope"))?,
|
||||
);
|
||||
}
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
if claims.exp < now {
|
||||
return Err(anyhow!("Token expired"));
|
||||
return Err(TokenVerifyError::Expired);
|
||||
}
|
||||
|
||||
if let Some(scopes) = allowed_scopes {
|
||||
@@ -180,7 +188,7 @@ fn verify_token_internal(
|
||||
.parse()
|
||||
.unwrap_or_else(|e| match e {});
|
||||
if !scopes.contains(&token_scope) {
|
||||
return Err(anyhow!("Invalid token scope: {}", token_scope));
|
||||
return Err(TokenVerifyError::Invalid("Invalid token scope"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,9 +252,13 @@ fn verify_token_hs256_internal(
|
||||
.decode(claims_b64)
|
||||
.context("Base64 decode of claims failed")?;
|
||||
|
||||
let claims: Claims =
|
||||
let mut claims: Claims =
|
||||
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
|
||||
|
||||
if let Some(scope) = &claims.scope {
|
||||
claims.scope = Some(decode_scope(scope).context("Invalid scope claim encoding")?);
|
||||
}
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
if claims.exp < now {
|
||||
return Err(anyhow!("Token expired"));
|
||||
@@ -266,88 +278,3 @@ fn verify_token_hs256_internal(
|
||||
|
||||
Ok(TokenData { claims })
|
||||
}
|
||||
|
||||
pub fn verify_access_token_typed(
|
||||
token: &str,
|
||||
key_bytes: &[u8],
|
||||
) -> Result<TokenData<Claims>, TokenVerifyError> {
|
||||
verify_token_typed_internal(token, key_bytes, Some(TokenType::Access), None)
|
||||
}
|
||||
|
||||
fn verify_token_typed_internal(
|
||||
token: &str,
|
||||
key_bytes: &[u8],
|
||||
expected_typ: Option<TokenType>,
|
||||
allowed_scopes: Option<&[TokenScope]>,
|
||||
) -> Result<TokenData<Claims>, TokenVerifyError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
}
|
||||
|
||||
let header_b64 = parts[0];
|
||||
let claims_b64 = parts[1];
|
||||
let signature_b64 = parts[2];
|
||||
|
||||
let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(header_b64) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
|
||||
let Ok(header) = serde_json::from_slice::<Header>(&header_bytes) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
|
||||
if let Some(expected) = expected_typ
|
||||
&& header.typ != expected
|
||||
{
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
}
|
||||
|
||||
let Ok(signature_bytes) = URL_SAFE_NO_PAD.decode(signature_b64) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
|
||||
let Ok(signature) = Signature::from_slice(&signature_bytes) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
|
||||
let Ok(signing_key) = SigningKey::from_slice(key_bytes) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
let verifying_key = VerifyingKey::from(&signing_key);
|
||||
|
||||
let message = format!("{}.{}", header_b64, claims_b64);
|
||||
if verifying_key
|
||||
.verify(message.as_bytes(), &signature)
|
||||
.is_err()
|
||||
{
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
}
|
||||
|
||||
let Ok(claims_bytes) = URL_SAFE_NO_PAD.decode(claims_b64) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
|
||||
let Ok(claims) = serde_json::from_slice::<Claims>(&claims_bytes) else {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
};
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
if claims.exp < now {
|
||||
return Err(TokenVerifyError::Expired);
|
||||
}
|
||||
|
||||
if let Some(scopes) = allowed_scopes {
|
||||
let token_scope: TokenScope = claims
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.parse()
|
||||
.unwrap_or_else(|e| match e {});
|
||||
if !scopes.contains(&token_scope) {
|
||||
return Err(TokenVerifyError::Invalid);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TokenData { claims })
|
||||
}
|
||||
|
||||
@@ -5,16 +5,16 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
valkey = ["dep:redis"]
|
||||
|
||||
[dependencies]
|
||||
tranquil-config = { workspace = true }
|
||||
tranquil-infra = { workspace = true }
|
||||
tranquil-infra = { workspace = true, features = ["cache-keys"] }
|
||||
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 }
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub use tranquil_infra::{Cache, CacheError, DistributedRateLimiter};
|
||||
pub use tranquil_infra::{
|
||||
Cache, CacheError, DistributedRateLimiter, cache_keys, cached_json, read_json, write_json,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
@@ -160,30 +162,28 @@ 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());
|
||||
|
||||
#[cfg(feature = "valkey")]
|
||||
if backend == "valkey" {
|
||||
if let Some(url) = valkey_url {
|
||||
if let Some(url) = cache_cfg.and_then(|c| c.valkey_url.as_deref()) {
|
||||
match ValkeyCache::new(url).await {
|
||||
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 +201,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))
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ reqwest = { workspace = true }
|
||||
rsa = { workspace = true }
|
||||
secrecy = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -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,12 @@ 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,
|
||||
|
||||
@@ -6,4 +6,3 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
confique = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -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);
|
||||
@@ -442,6 +445,10 @@ pub struct ServerConfig {
|
||||
#[config(env = "ENABLE_PDS_HOSTED_DID_WEB", default = false)]
|
||||
pub enable_pds_hosted_did_web: bool,
|
||||
|
||||
/// iykyk!
|
||||
#[config(env = "RFC_MOO_COMPLIANCE", default = false)]
|
||||
pub rfc_moo_compliance: bool,
|
||||
|
||||
/// When set to true, skip age-assurance birthday prompt for all accounts.
|
||||
#[config(env = "PDS_AGE_ASSURANCE_OVERRIDE", default = false)]
|
||||
pub age_assurance_override: bool,
|
||||
@@ -513,6 +520,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 {
|
||||
@@ -532,6 +544,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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,7 +636,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>,
|
||||
|
||||
@@ -774,6 +795,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 {
|
||||
@@ -789,7 +835,7 @@ pub struct PlcConfig {
|
||||
#[config(env = "PLC_CONNECT_TIMEOUT_SECS", default = 5)]
|
||||
pub connect_timeout_secs: u64,
|
||||
|
||||
/// Seconds to cache DID documents in memory.
|
||||
/// Seconds to cache DID documents.
|
||||
#[config(env = "DID_CACHE_TTL_SECS", default = 300)]
|
||||
pub did_cache_ttl_secs: u64,
|
||||
}
|
||||
@@ -811,18 +857,27 @@ pub struct FirehoseConfig {
|
||||
pub max_concurrent_repo_exports: usize,
|
||||
|
||||
/// List of relay / crawler notification URLs.
|
||||
#[config(env = "CRAWLERS", parse_env = split_comma_list)]
|
||||
pub crawlers: Option<Vec<String>>,
|
||||
}
|
||||
#[config(env = "CRAWLERS", parse_env = split_comma_list, default = [
|
||||
// If you know of more relays it makes sense to have here by all means make a PR!
|
||||
// All we request is that you only add "major" relays.
|
||||
// What exactly "major" means is up to interpretation and we will make the final call,
|
||||
// but a good rule of thumb is "most people are likely to add the relay if they know about it"
|
||||
|
||||
impl FirehoseConfig {
|
||||
/// Returns the list of crawler URLs, falling back to `["https://bsky.network"]`
|
||||
/// when none are configured.
|
||||
pub fn crawler_list(&self) -> Vec<String> {
|
||||
self.crawlers
|
||||
.clone()
|
||||
.unwrap_or_else(|| vec!["https://bsky.network".to_string()])
|
||||
}
|
||||
// Microcosm relays
|
||||
"https://relay.fire.hose.cam",
|
||||
"https://relay3.fr.hose.cam",
|
||||
// PBC relay
|
||||
"https://bsky.network",
|
||||
// firehose.network relays
|
||||
"https://northamerica.firehose.network",
|
||||
"https://europe.firehose.network",
|
||||
"https://asia.firehose.network",
|
||||
// Blacksky relay
|
||||
"https://atproto.africa",
|
||||
// UpCloud relay
|
||||
"https://relay.upcloud.world",
|
||||
])]
|
||||
pub crawlers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
@@ -1432,7 +1487,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,
|
||||
|
||||
@@ -1451,6 +1508,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)]
|
||||
@@ -1799,6 +1866,7 @@ port = 587
|
||||
TlsConfig {
|
||||
cert_path: None,
|
||||
key_path: None,
|
||||
http3: false,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
|
||||
@@ -1810,6 +1878,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:?}");
|
||||
@@ -1821,6 +1890,7 @@ port = 587
|
||||
TlsConfig {
|
||||
cert_path: Some("/etc/tranquil/cert.pem".to_string()),
|
||||
key_path: None,
|
||||
http3: false,
|
||||
}
|
||||
.validate(&mut errors);
|
||||
assert!(
|
||||
@@ -1829,6 +1899,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>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tranquil_types::{AtUri, CidLink, Did};
|
||||
use tranquil_types::{AtUri, CidLink, Did, Tid};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
@@ -58,7 +58,7 @@ pub trait BlobRepository: Send + Sync {
|
||||
limit: i64,
|
||||
) -> Result<Vec<CidLink>, DbError>;
|
||||
|
||||
async fn list_blobs_since_rev(&self, did: &Did, since: &str) -> Result<Vec<CidLink>, DbError>;
|
||||
async fn list_blobs_since_rev(&self, did: &Did, since: &Tid) -> Result<Vec<CidLink>, DbError>;
|
||||
|
||||
async fn count_blobs_by_user(&self, user_id: Uuid) -> Result<i64, DbError>;
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ pub struct DelegationGrant {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DelegatedAccountInfo {
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub handle: Option<Handle>,
|
||||
pub granted_scopes: DbScope,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -32,6 +33,7 @@ pub struct DelegatedAccountInfo {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ControllerInfo {
|
||||
pub did: Did,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub handle: Option<Handle>,
|
||||
pub granted_scopes: DbScope,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ColumnRef {
|
||||
table: &'static str,
|
||||
column: &'static str,
|
||||
}
|
||||
|
||||
impl ColumnRef {
|
||||
pub const fn new(table: &'static str, column: &'static str) -> Self {
|
||||
Self { table, column }
|
||||
}
|
||||
|
||||
pub const fn table(&self) -> &'static str {
|
||||
self.table
|
||||
}
|
||||
|
||||
pub const fn column(&self) -> &'static str {
|
||||
self.column
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ColumnRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}.{}", self.table, self.column)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DbError {
|
||||
#[error("Database query error: {0}")]
|
||||
@@ -29,6 +55,9 @@ pub enum DbError {
|
||||
#[error("Corrupt data in column: {0}")]
|
||||
CorruptData(&'static str),
|
||||
|
||||
#[error("Column {0} has a value that isn't valid for its type")]
|
||||
InvalidColumn(ColumnRef),
|
||||
|
||||
#[error("Other database error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tranquil_types::{CidLink, Did, Handle};
|
||||
use tranquil_types::{CidLink, Did, Handle, InviteCode};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
@@ -148,7 +148,7 @@ pub struct QueuedComms {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InviteCodeInfo {
|
||||
pub code: String,
|
||||
pub code: InviteCode,
|
||||
pub available_uses: i32,
|
||||
pub state: InviteCodeState,
|
||||
pub for_account: Option<Did>,
|
||||
@@ -158,7 +158,7 @@ pub struct InviteCodeInfo {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InviteCodeUse {
|
||||
pub code: String,
|
||||
pub code: InviteCode,
|
||||
pub used_by_did: Did,
|
||||
pub used_by_handle: Option<Handle>,
|
||||
pub used_at: DateTime<Utc>,
|
||||
@@ -166,7 +166,7 @@ pub struct InviteCodeUse {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InviteCodeRow {
|
||||
pub code: String,
|
||||
pub code: InviteCode,
|
||||
pub available_uses: i32,
|
||||
pub disabled: Option<bool>,
|
||||
pub created_by_user: Uuid,
|
||||
@@ -189,7 +189,7 @@ pub struct ReservedSigningKey {
|
||||
pub struct ReservedSigningKeyFull {
|
||||
pub id: Uuid,
|
||||
pub did: Option<Did>,
|
||||
pub public_key_did_key: String,
|
||||
pub public_key_did_key: Did,
|
||||
pub private_key_bytes: Vec<u8>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub used_at: Option<DateTime<Utc>>,
|
||||
@@ -248,45 +248,37 @@ pub trait InfraRepository: Send + Sync {
|
||||
|
||||
async fn create_invite_code(
|
||||
&self,
|
||||
code: &str,
|
||||
code: &InviteCode,
|
||||
use_count: i32,
|
||||
for_account: Option<&Did>,
|
||||
for_account: &Did,
|
||||
) -> Result<bool, DbError>;
|
||||
|
||||
async fn create_invite_codes_batch(
|
||||
&self,
|
||||
codes: &[String],
|
||||
codes: &[InviteCode],
|
||||
use_count: i32,
|
||||
created_by_user: Uuid,
|
||||
for_account: Option<&Did>,
|
||||
for_account: &Did,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn get_invite_code_available_uses(&self, code: &str) -> Result<Option<i32>, DbError>;
|
||||
async fn get_invite_code_available_uses(
|
||||
&self,
|
||||
code: &InviteCode,
|
||||
) -> Result<Option<i32>, DbError>;
|
||||
|
||||
async fn validate_invite_code<'a>(
|
||||
&self,
|
||||
code: &'a str,
|
||||
code: &'a InviteCode,
|
||||
) -> 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,
|
||||
) -> Result<Vec<InviteCodeInfo>, DbError>;
|
||||
|
||||
async fn get_invite_code_uses(&self, code: &str) -> Result<Vec<InviteCodeUse>, DbError>;
|
||||
async fn get_invite_code_uses(&self, code: &InviteCode) -> Result<Vec<InviteCodeUse>, DbError>;
|
||||
|
||||
async fn disable_invite_codes_by_code(&self, codes: &[String]) -> Result<(), DbError>;
|
||||
async fn disable_invite_codes_by_code(&self, codes: &[InviteCode]) -> Result<(), DbError>;
|
||||
|
||||
async fn disable_invite_codes_by_account(&self, accounts: &[Did]) -> Result<(), DbError>;
|
||||
|
||||
@@ -301,7 +293,7 @@ pub trait InfraRepository: Send + Sync {
|
||||
|
||||
async fn get_invite_code_uses_batch(
|
||||
&self,
|
||||
codes: &[String],
|
||||
codes: &[InviteCode],
|
||||
) -> Result<Vec<InviteCodeUse>, DbError>;
|
||||
|
||||
async fn get_invites_created_by_user(
|
||||
@@ -309,14 +301,20 @@ pub trait InfraRepository: Send + Sync {
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<InviteCodeInfo>, DbError>;
|
||||
|
||||
async fn get_invite_code_info(&self, code: &str) -> Result<Option<InviteCodeInfo>, DbError>;
|
||||
async fn get_invite_code_info(
|
||||
&self,
|
||||
code: &InviteCode,
|
||||
) -> Result<Option<InviteCodeInfo>, DbError>;
|
||||
|
||||
async fn get_invite_codes_by_users(
|
||||
&self,
|
||||
user_ids: &[Uuid],
|
||||
) -> Result<Vec<(Uuid, InviteCodeInfo)>, DbError>;
|
||||
|
||||
async fn get_invite_code_used_by_user(&self, user_id: Uuid) -> Result<Option<String>, DbError>;
|
||||
async fn get_invite_code_used_by_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<InviteCode>, DbError>;
|
||||
|
||||
async fn delete_invite_code_uses_by_user(&self, user_id: Uuid) -> Result<(), DbError>;
|
||||
|
||||
@@ -325,14 +323,14 @@ pub trait InfraRepository: Send + Sync {
|
||||
async fn reserve_signing_key(
|
||||
&self,
|
||||
did: Option<&Did>,
|
||||
public_key_did_key: &str,
|
||||
public_key_did_key: &Did,
|
||||
private_key_bytes: &[u8],
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<Uuid, DbError>;
|
||||
|
||||
async fn get_reserved_signing_key(
|
||||
&self,
|
||||
public_key_did_key: &str,
|
||||
public_key_did_key: &Did,
|
||||
) -> Result<Option<ReservedSigningKey>, DbError>;
|
||||
|
||||
async fn mark_signing_key_used(&self, key_id: Uuid) -> Result<(), DbError>;
|
||||
@@ -436,7 +434,7 @@ pub trait InfraRepository: Send + Sync {
|
||||
async fn get_invite_code_uses_by_users(
|
||||
&self,
|
||||
user_ids: &[Uuid],
|
||||
) -> Result<Vec<(Uuid, String)>, DbError>;
|
||||
) -> Result<Vec<(Uuid, InviteCode)>, DbError>;
|
||||
|
||||
async fn get_deletion_request_by_did(
|
||||
&self,
|
||||
@@ -466,7 +464,7 @@ pub trait InfraRepository: Send + Sync {
|
||||
|
||||
async fn get_reserved_signing_key_full(
|
||||
&self,
|
||||
public_key_did_key: &str,
|
||||
public_key_did_key: &Did,
|
||||
) -> Result<Option<ReservedSigningKeyFull>, DbError>;
|
||||
|
||||
async fn get_plc_tokens_by_did(&self, did: &Did) -> Result<Vec<PlcTokenInfo>, DbError>;
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
use std::marker::PhantomData;
|
||||
use tranquil_types::InviteCode;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ValidatedInviteCode<'a> {
|
||||
code: &'a str,
|
||||
_marker: PhantomData<&'a ()>,
|
||||
code: &'a InviteCode,
|
||||
}
|
||||
|
||||
impl<'a> ValidatedInviteCode<'a> {
|
||||
pub fn new_validated(code: &'a str) -> Self {
|
||||
Self {
|
||||
code,
|
||||
_marker: PhantomData,
|
||||
}
|
||||
pub fn new_validated(code: &'a InviteCode) -> Self {
|
||||
Self { code }
|
||||
}
|
||||
|
||||
pub fn code(&self) -> &str {
|
||||
pub fn code(&self) -> &'a InviteCode {
|
||||
self.code
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub use delegation::{
|
||||
AuditLogEntry, ControllerInfo, DelegatedAccountInfo, DelegationActionType, DelegationGrant,
|
||||
DelegationRepository,
|
||||
};
|
||||
pub use error::DbError;
|
||||
pub use error::{ColumnRef, DbError};
|
||||
pub use infra::{
|
||||
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest,
|
||||
DeletionRequestWithToken, InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder,
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ pub struct DeviceAccountRow {
|
||||
pub struct TwoFactorChallenge {
|
||||
pub id: Uuid,
|
||||
pub did: Did,
|
||||
pub request_uri: String,
|
||||
pub request_uri: RequestId,
|
||||
pub code: String,
|
||||
pub attempts: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
@@ -68,7 +68,7 @@ pub struct TwoFactorChallenge {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrustedDeviceRow {
|
||||
pub id: String,
|
||||
pub id: DeviceId,
|
||||
pub user_agent: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub trusted_at: Option<DateTime<Utc>>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey};
|
||||
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey, Tid};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
@@ -51,14 +51,7 @@ impl AccountStatus {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_firehose(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Active => None,
|
||||
other => Some(other.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_firehose_typed(&self) -> Option<Self> {
|
||||
pub fn for_firehose(&self) -> Option<Self> {
|
||||
match self {
|
||||
Self::Active => None,
|
||||
other => Some(*other),
|
||||
@@ -137,7 +130,7 @@ pub struct RepoAccountInfo {
|
||||
pub struct RepoInfo {
|
||||
pub user_id: Uuid,
|
||||
pub repo_root_cid: CidLink,
|
||||
pub repo_rev: Option<String>,
|
||||
pub repo_rev: Option<Tid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -169,7 +162,7 @@ pub struct RepoWithoutRev {
|
||||
pub struct UserWithoutBlocks {
|
||||
pub user_id: Uuid,
|
||||
pub repo_root_cid: CidLink,
|
||||
pub repo_rev: Option<String>,
|
||||
pub repo_rev: Option<Tid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -225,7 +218,7 @@ pub struct EventBlockInline {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EventBlocks {
|
||||
Inline(Vec<EventBlockInline>),
|
||||
LegacyCids(Vec<String>),
|
||||
LegacyCids(Vec<CidLink>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -238,12 +231,12 @@ pub struct SequencedEvent {
|
||||
pub prev_cid: Option<CidLink>,
|
||||
pub prev_data_cid: Option<CidLink>,
|
||||
pub ops: Option<serde_json::Value>,
|
||||
pub blobs: Option<Vec<String>>,
|
||||
pub blobs: Option<Vec<CidLink>>,
|
||||
pub blocks: Option<EventBlocks>,
|
||||
pub handle: Option<Handle>,
|
||||
pub active: Option<bool>,
|
||||
pub status: Option<AccountStatus>,
|
||||
pub rev: Option<String>,
|
||||
pub rev: Option<Tid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -253,10 +246,10 @@ pub struct CommitEventData {
|
||||
pub commit_cid: Option<CidLink>,
|
||||
pub prev_cid: Option<CidLink>,
|
||||
pub ops: Option<serde_json::Value>,
|
||||
pub blobs: Option<Vec<String>>,
|
||||
pub blobs: Option<Vec<CidLink>>,
|
||||
pub blocks: Option<Vec<EventBlockInline>>,
|
||||
pub prev_data_cid: Option<CidLink>,
|
||||
pub rev: Option<String>,
|
||||
pub rev: Option<Tid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -265,7 +258,7 @@ pub struct RepoListItem {
|
||||
pub deactivated_at: Option<DateTime<Utc>>,
|
||||
pub takedown_ref: Option<String>,
|
||||
pub repo_root_cid: CidLink,
|
||||
pub repo_rev: Option<String>,
|
||||
pub repo_rev: Option<Tid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -307,7 +300,7 @@ pub struct ApplyCommitInput {
|
||||
pub did: Did,
|
||||
pub expected_root_cid: Option<CidLink>,
|
||||
pub new_root_cid: CidLink,
|
||||
pub new_rev: String,
|
||||
pub new_rev: Tid,
|
||||
pub new_block_cids: Vec<Vec<u8>>,
|
||||
pub obsolete_block_cids: Vec<Vec<u8>>,
|
||||
pub record_upserts: Vec<RecordUpsert>,
|
||||
@@ -337,17 +330,17 @@ pub trait RepoRepository: Send + Sync {
|
||||
did: &Did,
|
||||
handle: &Handle,
|
||||
repo_root_cid: &CidLink,
|
||||
repo_rev: &str,
|
||||
repo_rev: &Tid,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn update_repo_root(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
repo_root_cid: &CidLink,
|
||||
repo_rev: &str,
|
||||
repo_rev: &Tid,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn update_repo_rev(&self, user_id: Uuid, repo_rev: &str) -> Result<(), DbError>;
|
||||
async fn update_repo_rev(&self, user_id: Uuid, repo_rev: &Tid) -> Result<(), DbError>;
|
||||
|
||||
async fn update_repo_status(
|
||||
&self,
|
||||
@@ -375,7 +368,7 @@ pub trait RepoRepository: Send + Sync {
|
||||
collections: &[Nsid],
|
||||
rkeys: &[Rkey],
|
||||
record_cids: &[CidLink],
|
||||
repo_rev: &str,
|
||||
repo_rev: &Tid,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn delete_records(
|
||||
@@ -417,6 +410,13 @@ pub trait RepoRepository: Send + Sync {
|
||||
async fn get_record_by_cid(&self, cid: &CidLink)
|
||||
-> Result<Option<RecordWithTakedown>, DbError>;
|
||||
|
||||
async fn referenced_record_cids(
|
||||
&self,
|
||||
repo_id: Uuid,
|
||||
cids: &[CidLink],
|
||||
excluded_keys: &[(&Nsid, &Rkey)],
|
||||
) -> Result<Vec<CidLink>, DbError>;
|
||||
|
||||
async fn set_record_takedown(
|
||||
&self,
|
||||
cid: &CidLink,
|
||||
@@ -427,7 +427,7 @@ pub trait RepoRepository: Send + Sync {
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
block_cids: &[Vec<u8>],
|
||||
repo_rev: &str,
|
||||
repo_rev: &Tid,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn delete_user_blocks(
|
||||
@@ -439,7 +439,7 @@ pub trait RepoRepository: Send + Sync {
|
||||
async fn get_user_block_cids_since_rev(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
since_rev: &str,
|
||||
since_rev: Option<&Tid>,
|
||||
) -> Result<Vec<Vec<u8>>, DbError>;
|
||||
|
||||
async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>;
|
||||
@@ -458,7 +458,7 @@ pub trait RepoRepository: Send + Sync {
|
||||
&self,
|
||||
did: &Did,
|
||||
commit_cid: &CidLink,
|
||||
rev: Option<&str>,
|
||||
rev: Option<&Tid>,
|
||||
commit_bytes: &[u8],
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
@@ -467,7 +467,7 @@ pub trait RepoRepository: Send + Sync {
|
||||
did: &Did,
|
||||
commit_cid: &CidLink,
|
||||
mst_root_cid: &CidLink,
|
||||
rev: &str,
|
||||
rev: &Tid,
|
||||
commit_bytes: &[u8],
|
||||
mst_root_bytes: &[u8],
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use tranquil_types::Did;
|
||||
use tranquil_types::{Did, Jti, PasswordHash};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
@@ -84,8 +84,8 @@ impl std::fmt::Display for SessionId {
|
||||
pub struct SessionToken {
|
||||
pub id: SessionId,
|
||||
pub did: Did,
|
||||
pub access_jti: String,
|
||||
pub refresh_jti: String,
|
||||
pub access_jti: Jti,
|
||||
pub refresh_jti: Jti,
|
||||
pub access_expires_at: DateTime<Utc>,
|
||||
pub refresh_expires_at: DateTime<Utc>,
|
||||
pub login_type: LoginType,
|
||||
@@ -100,8 +100,8 @@ pub struct SessionToken {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionTokenCreate {
|
||||
pub did: Did,
|
||||
pub access_jti: String,
|
||||
pub refresh_jti: String,
|
||||
pub access_jti: Jti,
|
||||
pub refresh_jti: Jti,
|
||||
pub access_expires_at: DateTime<Utc>,
|
||||
pub refresh_expires_at: DateTime<Utc>,
|
||||
pub login_type: LoginType,
|
||||
@@ -124,7 +124,7 @@ pub struct SessionForRefresh {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionListItem {
|
||||
pub id: SessionId,
|
||||
pub access_jti: String,
|
||||
pub access_jti: Jti,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub refresh_expires_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -134,7 +134,7 @@ pub struct AppPasswordRecord {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub name: String,
|
||||
pub password_hash: String,
|
||||
pub password_hash: PasswordHash,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub privilege: AppPasswordPrivilege,
|
||||
pub scopes: Option<String>,
|
||||
@@ -145,7 +145,7 @@ pub struct AppPasswordRecord {
|
||||
pub struct AppPasswordCreate {
|
||||
pub user_id: Uuid,
|
||||
pub name: String,
|
||||
pub password_hash: String,
|
||||
pub password_hash: PasswordHash,
|
||||
pub privilege: AppPasswordPrivilege,
|
||||
pub scopes: Option<String>,
|
||||
pub created_by_controller_did: Option<Did>,
|
||||
@@ -158,19 +158,57 @@ 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: Jti,
|
||||
pub refresh_jti: Jti,
|
||||
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 old_refresh_jti: String,
|
||||
pub did: Did,
|
||||
pub old_refresh_jti: Jti,
|
||||
pub session_id: SessionId,
|
||||
pub new_access_jti: String,
|
||||
pub new_refresh_jti: String,
|
||||
pub new_access_jti: Jti,
|
||||
pub new_refresh_jti: Jti,
|
||||
pub new_access_expires_at: DateTime<Utc>,
|
||||
pub new_refresh_expires_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -181,33 +219,28 @@ pub trait SessionRepository: Send + Sync {
|
||||
|
||||
async fn get_session_by_access_jti(
|
||||
&self,
|
||||
access_jti: &str,
|
||||
access_jti: &Jti,
|
||||
) -> Result<Option<SessionToken>, DbError>;
|
||||
|
||||
async fn get_session_for_refresh(
|
||||
&self,
|
||||
refresh_jti: &str,
|
||||
refresh_jti: &Jti,
|
||||
) -> Result<Option<SessionForRefresh>, DbError>;
|
||||
|
||||
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>;
|
||||
access_jti: &Jti,
|
||||
did: &Did,
|
||||
) -> Result<u64, 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>;
|
||||
async fn delete_session_by_id(&self, session_id: SessionId, did: &Did) -> Result<u64, DbError>;
|
||||
|
||||
async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>;
|
||||
|
||||
async fn delete_sessions_by_did_except_jti(
|
||||
&self,
|
||||
did: &Did,
|
||||
except_jti: &str,
|
||||
except_jti: &Jti,
|
||||
) -> Result<u64, DbError>;
|
||||
|
||||
async fn list_sessions_by_did(&self, did: &Did) -> Result<Vec<SessionListItem>, DbError>;
|
||||
@@ -216,7 +249,7 @@ pub trait SessionRepository: Send + Sync {
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
did: &Did,
|
||||
) -> Result<Option<String>, DbError>;
|
||||
) -> Result<Option<Jti>, DbError>;
|
||||
|
||||
async fn delete_sessions_by_app_password(
|
||||
&self,
|
||||
@@ -228,18 +261,9 @@ pub trait SessionRepository: Send + Sync {
|
||||
&self,
|
||||
did: &Did,
|
||||
app_password_name: &str,
|
||||
) -> Result<Vec<String>, DbError>;
|
||||
) -> Result<Vec<Jti>, 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: &Jti) -> Result<RefreshGraceLookup, DbError>;
|
||||
|
||||
async fn list_app_passwords(&self, user_id: Uuid) -> Result<Vec<AppPasswordRecord>, DbError>;
|
||||
|
||||
@@ -272,7 +296,8 @@ pub trait SessionRepository: Send + Sync {
|
||||
|
||||
async fn update_mfa_verified(&self, did: &Did) -> Result<(), DbError>;
|
||||
|
||||
async fn get_app_password_hashes_by_did(&self, did: &Did) -> Result<Vec<String>, DbError>;
|
||||
async fn get_app_password_hashes_by_did(&self, did: &Did)
|
||||
-> Result<Vec<PasswordHash>, DbError>;
|
||||
|
||||
async fn refresh_session_atomic(
|
||||
&self,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tranquil_types::{Did, Handle};
|
||||
use tranquil_types::{
|
||||
AtIdentifier, CidLink, Did, Handle, InviteCode, Jti, PasswordHash, Tid, TokenId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{ChannelVerificationStatus, CommsChannel, DbError, SsoProviderType};
|
||||
@@ -78,7 +80,7 @@ pub struct UserEmailInfo {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserLoginCheck {
|
||||
pub did: Did,
|
||||
pub password_hash: Option<String>,
|
||||
pub password_hash: Option<PasswordHash>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -86,7 +88,7 @@ pub struct UserLoginInfo {
|
||||
pub id: Uuid,
|
||||
pub did: Did,
|
||||
pub email: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
pub password_hash: Option<PasswordHash>,
|
||||
pub password_required: bool,
|
||||
pub two_factor_enabled: bool,
|
||||
pub preferred_comms_channel: CommsChannel,
|
||||
@@ -119,12 +121,12 @@ pub trait UserRepository: Send + Sync {
|
||||
async fn get_session_access_expiry(
|
||||
&self,
|
||||
did: &Did,
|
||||
access_jti: &str,
|
||||
access_jti: &Jti,
|
||||
) -> Result<Option<DateTime<Utc>>, DbError>;
|
||||
|
||||
async fn get_oauth_token_with_user(
|
||||
&self,
|
||||
token_id: &str,
|
||||
token_id: &TokenId,
|
||||
) -> Result<Option<OAuthTokenWithUser>, DbError>;
|
||||
|
||||
async fn get_user_info_by_did(&self, did: &Did) -> Result<Option<UserInfoForAuth>, DbError>;
|
||||
@@ -147,12 +149,12 @@ pub trait UserRepository: Send + Sync {
|
||||
|
||||
async fn get_login_check_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
identifier: &AtIdentifier,
|
||||
) -> Result<Option<UserLoginCheck>, DbError>;
|
||||
|
||||
async fn get_login_info_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
identifier: &AtIdentifier,
|
||||
) -> Result<Option<UserLoginInfo>, DbError>;
|
||||
|
||||
async fn get_2fa_status_by_did(&self, did: &Did) -> Result<Option<User2faStatus>, DbError>;
|
||||
@@ -209,7 +211,7 @@ pub trait UserRepository: Send + Sync {
|
||||
|
||||
async fn check_email_verified_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
identifier: &AtIdentifier,
|
||||
) -> Result<Option<bool>, DbError>;
|
||||
|
||||
async fn check_channel_verified_by_did(
|
||||
@@ -222,7 +224,11 @@ pub trait UserRepository: Send + Sync {
|
||||
|
||||
async fn admin_update_handle(&self, did: &Did, handle: &Handle) -> Result<u64, DbError>;
|
||||
|
||||
async fn admin_update_password(&self, did: &Did, password_hash: &str) -> Result<u64, DbError>;
|
||||
async fn admin_update_password(
|
||||
&self,
|
||||
did: &Did,
|
||||
password_hash: &PasswordHash,
|
||||
) -> Result<u64, DbError>;
|
||||
|
||||
async fn set_admin_status(&self, did: &Did, is_admin: bool) -> Result<(), DbError>;
|
||||
|
||||
@@ -262,7 +268,7 @@ pub trait UserRepository: Send + Sync {
|
||||
&self,
|
||||
telegram_username: &str,
|
||||
chat_id: i64,
|
||||
handle: Option<&str>,
|
||||
handle: Option<&Handle>,
|
||||
) -> Result<Option<Uuid>, DbError>;
|
||||
|
||||
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError>;
|
||||
@@ -277,7 +283,7 @@ pub trait UserRepository: Send + Sync {
|
||||
&self,
|
||||
discord_username: &str,
|
||||
discord_id: &str,
|
||||
handle: Option<&str>,
|
||||
handle: Option<&Handle>,
|
||||
) -> Result<Option<Uuid>, DbError>;
|
||||
|
||||
async fn get_verification_info(
|
||||
@@ -313,7 +319,7 @@ pub trait UserRepository: Send + Sync {
|
||||
|
||||
async fn has_passkeys(&self, did: &Did) -> Result<bool, DbError>;
|
||||
|
||||
async fn get_password_hash_by_did(&self, did: &Did) -> Result<Option<String>, DbError>;
|
||||
async fn get_password_hash_by_did(&self, did: &Did) -> Result<Option<PasswordHash>, DbError>;
|
||||
|
||||
async fn get_passkeys_for_user(&self, did: &Did) -> Result<Vec<StoredPasskey>, DbError>;
|
||||
|
||||
@@ -422,7 +428,7 @@ pub trait UserRepository: Send + Sync {
|
||||
|
||||
async fn get_login_full_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
identifier: &AtIdentifier,
|
||||
) -> Result<Option<UserLoginFull>, DbError>;
|
||||
|
||||
async fn get_confirm_signup_by_did(
|
||||
@@ -440,7 +446,7 @@ pub trait UserRepository: Send + Sync {
|
||||
async fn get_id_by_email_or_handle(
|
||||
&self,
|
||||
email: &str,
|
||||
handle: &str,
|
||||
handle: &Handle,
|
||||
) -> Result<Option<Uuid>, DbError>;
|
||||
|
||||
async fn count_accounts_by_email(&self, email: &str) -> Result<i64, DbError>;
|
||||
@@ -466,13 +472,16 @@ pub trait UserRepository: Send + Sync {
|
||||
did: &Did,
|
||||
) -> Result<Option<UserIdAndPasswordHash>, DbError>;
|
||||
|
||||
async fn update_password_hash(&self, user_id: Uuid, password_hash: &str)
|
||||
-> Result<(), DbError>;
|
||||
async fn update_password_hash(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
password_hash: &PasswordHash,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn reset_password_with_sessions(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
password_hash: &str,
|
||||
password_hash: &PasswordHash,
|
||||
) -> Result<PasswordResetResult, DbError>;
|
||||
|
||||
async fn activate_account(&self, did: &Did) -> Result<bool, DbError>;
|
||||
@@ -495,7 +504,7 @@ pub trait UserRepository: Send + Sync {
|
||||
async fn set_new_user_password(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
password_hash: &str,
|
||||
password_hash: &PasswordHash,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn get_user_key_by_did(&self, did: &Did) -> Result<Option<UserKeyInfo>, DbError>;
|
||||
@@ -589,8 +598,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,
|
||||
@@ -663,7 +670,6 @@ pub struct UserForVerification {
|
||||
pub did: Did,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub handle: Handle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -701,7 +707,7 @@ pub struct AccountSearchResult {
|
||||
pub struct UserAuthInfo {
|
||||
pub id: Uuid,
|
||||
pub did: Did,
|
||||
pub password_hash: Option<String>,
|
||||
pub password_hash: Option<PasswordHash>,
|
||||
pub deactivated_at: Option<DateTime<Utc>>,
|
||||
pub takedown_ref: Option<String>,
|
||||
pub channel_verification: ChannelVerificationStatus,
|
||||
@@ -865,7 +871,7 @@ pub struct UserLoginFull {
|
||||
pub id: Uuid,
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
pub password_hash: Option<String>,
|
||||
pub password_hash: Option<PasswordHash>,
|
||||
pub email: Option<String>,
|
||||
pub deactivated_at: Option<DateTime<Utc>>,
|
||||
pub takedown_ref: Option<String>,
|
||||
@@ -916,25 +922,25 @@ pub struct UserResetCodeInfo {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserPasswordInfo {
|
||||
pub id: Uuid,
|
||||
pub password_hash: Option<String>,
|
||||
pub password_hash: Option<PasswordHash>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserIdAndPasswordHash {
|
||||
pub id: Uuid,
|
||||
pub password_hash: String,
|
||||
pub password_hash: PasswordHash,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PasswordResetResult {
|
||||
pub did: Did,
|
||||
pub session_jtis: Vec<String>,
|
||||
pub session_jtis: Vec<Jti>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserForDeletion {
|
||||
pub id: Uuid,
|
||||
pub password_hash: Option<String>,
|
||||
pub password_hash: Option<PasswordHash>,
|
||||
pub handle: Handle,
|
||||
}
|
||||
|
||||
@@ -990,7 +996,7 @@ pub struct CreatePasswordAccountInput {
|
||||
pub handle: Handle,
|
||||
pub email: Option<String>,
|
||||
pub did: Did,
|
||||
pub password_hash: String,
|
||||
pub password_hash: PasswordHash,
|
||||
pub preferred_comms_channel: CommsChannel,
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
@@ -1000,10 +1006,10 @@ pub struct CreatePasswordAccountInput {
|
||||
pub encrypted_key_bytes: Vec<u8>,
|
||||
pub encryption_version: i32,
|
||||
pub reserved_key_id: Option<Uuid>,
|
||||
pub commit_cid: String,
|
||||
pub repo_rev: String,
|
||||
pub commit_cid: CidLink,
|
||||
pub repo_rev: Tid,
|
||||
pub genesis_block_cids: Vec<Vec<u8>>,
|
||||
pub invite_code: Option<String>,
|
||||
pub invite_code: Option<InviteCode>,
|
||||
pub birthdate_pref: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
@@ -1019,6 +1025,7 @@ pub enum CreateAccountError {
|
||||
EmailTaken,
|
||||
DidExists,
|
||||
InvalidToken,
|
||||
InviteCodeUnavailable,
|
||||
Database(String),
|
||||
}
|
||||
|
||||
@@ -1031,10 +1038,9 @@ pub struct CreateDelegatedAccountInput {
|
||||
pub controller_scopes: String,
|
||||
pub encrypted_key_bytes: Vec<u8>,
|
||||
pub encryption_version: i32,
|
||||
pub commit_cid: String,
|
||||
pub repo_rev: String,
|
||||
pub commit_cid: CidLink,
|
||||
pub repo_rev: Tid,
|
||||
pub genesis_block_cids: Vec<Vec<u8>>,
|
||||
pub invite_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -1046,16 +1052,16 @@ pub struct CreatePasskeyAccountInput {
|
||||
pub discord_username: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_username: Option<String>,
|
||||
pub setup_token_hash: String,
|
||||
pub setup_token_hash: PasswordHash,
|
||||
pub setup_expires_at: DateTime<Utc>,
|
||||
pub deactivated_at: Option<DateTime<Utc>>,
|
||||
pub encrypted_key_bytes: Vec<u8>,
|
||||
pub encryption_version: i32,
|
||||
pub reserved_key_id: Option<Uuid>,
|
||||
pub commit_cid: String,
|
||||
pub repo_rev: String,
|
||||
pub commit_cid: CidLink,
|
||||
pub repo_rev: Tid,
|
||||
pub genesis_block_cids: Vec<Vec<u8>>,
|
||||
pub invite_code: Option<String>,
|
||||
pub invite_code: Option<InviteCode>,
|
||||
pub birthdate_pref: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
@@ -1070,10 +1076,10 @@ pub struct CreateSsoAccountInput {
|
||||
pub signal_username: Option<String>,
|
||||
pub encrypted_key_bytes: Vec<u8>,
|
||||
pub encryption_version: i32,
|
||||
pub commit_cid: String,
|
||||
pub repo_rev: String,
|
||||
pub commit_cid: CidLink,
|
||||
pub repo_rev: Tid,
|
||||
pub genesis_block_cids: Vec<Vec<u8>>,
|
||||
pub invite_code: Option<String>,
|
||||
pub invite_code: Option<InviteCode>,
|
||||
pub birthdate_pref: Option<serde_json::Value>,
|
||||
pub sso_provider: SsoProviderType,
|
||||
pub sso_provider_user_id: String,
|
||||
@@ -1088,13 +1094,13 @@ pub struct CompletePasskeySetupInput {
|
||||
pub user_id: Uuid,
|
||||
pub did: Did,
|
||||
pub app_password_name: String,
|
||||
pub app_password_hash: String,
|
||||
pub app_password_hash: PasswordHash,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecoverPasskeyAccountInput {
|
||||
pub did: Did,
|
||||
pub password_hash: String,
|
||||
pub password_hash: PasswordHash,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -1112,7 +1118,7 @@ pub struct MigrationReactivationInput {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReactivatedAccountInfo {
|
||||
pub user_id: Uuid,
|
||||
pub old_handle: Handle,
|
||||
pub old_handle: Option<Handle>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -5,9 +5,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["postgres"]
|
||||
postgres = []
|
||||
sqlite = []
|
||||
|
||||
[dependencies]
|
||||
tranquil-db-traits = { workspace = true }
|
||||
|
||||
@@ -4,6 +4,8 @@ use tranquil_db_traits::{Backlink, BacklinkRepository, DbError};
|
||||
use tranquil_types::{AtUri, Nsid};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::column_vec;
|
||||
use super::user::map_sqlx_error;
|
||||
|
||||
pub struct PostgresBacklinkRepository {
|
||||
@@ -49,7 +51,7 @@ impl BacklinkRepository for PostgresBacklinkRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results.into_iter().map(Into::into).collect())
|
||||
column_vec(results, col::BACKLINKS_URI)
|
||||
}
|
||||
|
||||
async fn add_backlinks(&self, repo_id: Uuid, backlinks: &[Backlink]) -> Result<(), DbError> {
|
||||
|
||||
@@ -3,10 +3,12 @@ use sqlx::PgPool;
|
||||
use tranquil_db_traits::{
|
||||
BlobForExport, BlobMetadata, BlobRepository, BlobWithTakedown, DbError, MissingBlobInfo,
|
||||
};
|
||||
use tranquil_types::{AtUri, CidLink, Did};
|
||||
use tranquil_types::{AtUri, CidLink, Did, Tid};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::user::map_sqlx_error;
|
||||
use super::{column, column_vec, opt_column};
|
||||
|
||||
pub struct PostgresBlobRepository {
|
||||
pool: PgPool,
|
||||
@@ -42,7 +44,7 @@ impl BlobRepository for PostgresBlobRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(CidLink::from))
|
||||
opt_column(result, col::BLOBS_CID)
|
||||
}
|
||||
|
||||
async fn get_blob_metadata(&self, cid: &CidLink) -> Result<Option<BlobMetadata>, DbError> {
|
||||
@@ -73,10 +75,14 @@ impl BlobRepository for PostgresBlobRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(|r| BlobWithTakedown {
|
||||
cid: CidLink::from(r.cid),
|
||||
takedown_ref: r.takedown_ref,
|
||||
}))
|
||||
result
|
||||
.map(|r| {
|
||||
Ok(BlobWithTakedown {
|
||||
cid: column(r.cid, col::BLOBS_CID)?,
|
||||
takedown_ref: r.takedown_ref,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_blob_storage_key(&self, cid: &CidLink) -> Result<Option<String>, DbError> {
|
||||
@@ -109,22 +115,22 @@ impl BlobRepository for PostgresBlobRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results.into_iter().map(CidLink::from).collect())
|
||||
column_vec(results, col::BLOBS_CID)
|
||||
}
|
||||
|
||||
async fn list_blobs_since_rev(&self, did: &Did, since: &str) -> Result<Vec<CidLink>, DbError> {
|
||||
async fn list_blobs_since_rev(&self, did: &Did, since: &Tid) -> Result<Vec<CidLink>, DbError> {
|
||||
let results = sqlx::query_scalar!(
|
||||
r#"SELECT DISTINCT unnest(blobs) as "cid!"
|
||||
FROM repo_seq
|
||||
WHERE did = $1 AND rev > $2 AND blobs IS NOT NULL"#,
|
||||
did.as_str(),
|
||||
since
|
||||
since.as_str()
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results.into_iter().map(CidLink::from).collect())
|
||||
column_vec(results, col::REPO_SEQ_BLOBS)
|
||||
}
|
||||
|
||||
async fn count_blobs_by_user(&self, user_id: Uuid) -> Result<i64, DbError> {
|
||||
@@ -244,13 +250,15 @@ impl BlobRepository for PostgresBlobRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| MissingBlobInfo {
|
||||
blob_cid: CidLink::from(r.blob_cid),
|
||||
record_uri: AtUri::from(r.record_uri),
|
||||
.map(|r| {
|
||||
Ok(MissingBlobInfo {
|
||||
blob_cid: column(r.blob_cid, col::RECORD_BLOBS_BLOB_CID)?,
|
||||
record_uri: column(r.record_uri, col::RECORD_BLOBS_RECORD_URI)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn count_distinct_record_blobs(&self, repo_id: Uuid) -> Result<i64, DbError> {
|
||||
@@ -277,13 +285,15 @@ impl BlobRepository for PostgresBlobRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| BlobForExport {
|
||||
cid: CidLink::from(r.cid),
|
||||
storage_key: r.storage_key,
|
||||
mime_type: r.mime_type,
|
||||
.map(|r| {
|
||||
Ok(BlobForExport {
|
||||
cid: column(r.cid, col::BLOBS_CID)?,
|
||||
storage_key: r.storage_key,
|
||||
mime_type: r.mime_type,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ use tranquil_db_traits::{
|
||||
use tranquil_types::Did;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::user::map_sqlx_error;
|
||||
use super::{column, legacy_column, opt_column};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
|
||||
#[sqlx(type_name = "delegation_action_type", rename_all = "snake_case")]
|
||||
@@ -166,16 +168,19 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| DelegationGrant {
|
||||
id: r.id,
|
||||
delegated_did: r.delegated_did.into(),
|
||||
controller_did: r.controller_did.into(),
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
granted_by: r.granted_by.into(),
|
||||
revoked_at: r.revoked_at,
|
||||
revoked_by: r.revoked_by.map(Into::into),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(DelegationGrant {
|
||||
id: r.id,
|
||||
delegated_did: column(r.delegated_did, col::ACCOUNT_DELEGATIONS_DELEGATED_DID)?,
|
||||
controller_did: column(r.controller_did, col::ACCOUNT_DELEGATIONS_CONTROLLER_DID)?,
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
granted_by: column(r.granted_by, col::ACCOUNT_DELEGATIONS_GRANTED_BY)?,
|
||||
revoked_at: r.revoked_at,
|
||||
revoked_by: opt_column(r.revoked_by, col::ACCOUNT_DELEGATIONS_REVOKED_BY)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_delegations_for_account(
|
||||
@@ -205,17 +210,18 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| ControllerInfo {
|
||||
did: r.controller_did.into(),
|
||||
handle: r.handle.map(Into::into),
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
is_active: r.is_active,
|
||||
is_local: r.is_local,
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(ControllerInfo {
|
||||
did: column(r.controller_did, col::ACCOUNT_DELEGATIONS_CONTROLLER_DID)?,
|
||||
handle: r.handle.and_then(|h| legacy_column(h, col::USERS_HANDLE)),
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
is_active: r.is_active,
|
||||
is_local: r.is_local,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_accounts_controlled_by(
|
||||
@@ -243,15 +249,16 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| DelegatedAccountInfo {
|
||||
did: r.did.into(),
|
||||
handle: r.handle.into(),
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(DelegatedAccountInfo {
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
handle: legacy_column(r.handle, col::USERS_HANDLE),
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn count_active_controllers(&self, delegated_did: &Did) -> Result<i64, DbError> {
|
||||
@@ -353,20 +360,27 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AuditLogEntry {
|
||||
id: r.id,
|
||||
delegated_did: r.delegated_did.into(),
|
||||
actor_did: r.actor_did.into(),
|
||||
controller_did: r.controller_did.map(Into::into),
|
||||
action_type: r.action_type.into(),
|
||||
action_details: r.action_details,
|
||||
ip_address: r.ip_address,
|
||||
user_agent: r.user_agent,
|
||||
created_at: r.created_at,
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(AuditLogEntry {
|
||||
id: r.id,
|
||||
delegated_did: column(
|
||||
r.delegated_did,
|
||||
col::DELEGATION_AUDIT_LOG_DELEGATED_DID,
|
||||
)?,
|
||||
actor_did: column(r.actor_did, col::DELEGATION_AUDIT_LOG_ACTOR_DID)?,
|
||||
controller_did: opt_column(
|
||||
r.controller_did,
|
||||
col::DELEGATION_AUDIT_LOG_CONTROLLER_DID,
|
||||
)?,
|
||||
action_type: r.action_type.into(),
|
||||
action_details: r.action_details,
|
||||
ip_address: r.ip_address,
|
||||
user_agent: r.user_agent,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn count_audit_log_entries(&self, delegated_did: &Did) -> Result<i64, DbError> {
|
||||
|
||||
@@ -7,10 +7,12 @@ use tranquil_db_traits::{
|
||||
InviteCodeSortOrder, InviteCodeState, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo,
|
||||
QueuedComms, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode,
|
||||
};
|
||||
use tranquil_types::{CidLink, Did, Handle};
|
||||
use tranquil_types::{CidLink, Did, InviteCode};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::user::map_sqlx_error;
|
||||
use super::{column, legacy_column, opt_column};
|
||||
|
||||
pub struct PostgresInfraRepository {
|
||||
pool: PgPool,
|
||||
@@ -151,15 +153,15 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
|
||||
async fn create_invite_code(
|
||||
&self,
|
||||
code: &str,
|
||||
code: &InviteCode,
|
||||
use_count: i32,
|
||||
for_account: Option<&Did>,
|
||||
for_account: &Did,
|
||||
) -> Result<bool, DbError> {
|
||||
let for_account_str = for_account.map(|d| d.as_str());
|
||||
let for_account_str = for_account.as_str();
|
||||
let result = sqlx::query!(
|
||||
r#"INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)
|
||||
SELECT $1, $2, id, $3 FROM users WHERE is_admin = true LIMIT 1"#,
|
||||
code,
|
||||
code.as_str(),
|
||||
use_count,
|
||||
for_account_str
|
||||
)
|
||||
@@ -172,16 +174,17 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
|
||||
async fn create_invite_codes_batch(
|
||||
&self,
|
||||
codes: &[String],
|
||||
codes: &[InviteCode],
|
||||
use_count: i32,
|
||||
created_by_user: Uuid,
|
||||
for_account: Option<&Did>,
|
||||
for_account: &Did,
|
||||
) -> Result<(), DbError> {
|
||||
let for_account_str = for_account.map(|d| d.as_str());
|
||||
let for_account_str = for_account.as_str();
|
||||
let code_strs: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)
|
||||
SELECT code, $2, $3, $4 FROM UNNEST($1::text[]) AS t(code)"#,
|
||||
codes,
|
||||
&code_strs,
|
||||
use_count,
|
||||
created_by_user,
|
||||
for_account_str
|
||||
@@ -193,10 +196,13 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_invite_code_available_uses(&self, code: &str) -> Result<Option<i32>, DbError> {
|
||||
async fn get_invite_code_available_uses(
|
||||
&self,
|
||||
code: &InviteCode,
|
||||
) -> Result<Option<i32>, DbError> {
|
||||
let result = sqlx::query_scalar!(
|
||||
"SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE",
|
||||
code
|
||||
code.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
@@ -207,11 +213,11 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
|
||||
async fn validate_invite_code<'a>(
|
||||
&self,
|
||||
code: &'a str,
|
||||
code: &'a InviteCode,
|
||||
) -> Result<ValidatedInviteCode<'a>, InviteCodeError> {
|
||||
let result = sqlx::query!(
|
||||
r#"SELECT available_uses, COALESCE(disabled, false) as "disabled!" FROM invite_codes WHERE code = $1"#,
|
||||
code
|
||||
code.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
@@ -225,38 +231,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,
|
||||
@@ -278,27 +252,29 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| InviteCodeInfo {
|
||||
code: r.code,
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: None,
|
||||
.map(|r| {
|
||||
Ok(InviteCodeInfo {
|
||||
code: InviteCode::from(r.code),
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
|
||||
created_at: r.created_at,
|
||||
created_by: None,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_invite_code_uses(&self, code: &str) -> Result<Vec<InviteCodeUse>, DbError> {
|
||||
async fn get_invite_code_uses(&self, code: &InviteCode) -> Result<Vec<InviteCodeUse>, DbError> {
|
||||
let results = sqlx::query!(
|
||||
r#"SELECT u.did, u.handle, icu.used_at
|
||||
FROM invite_code_uses icu
|
||||
JOIN users u ON icu.used_by_user = u.id
|
||||
WHERE icu.code = $1
|
||||
ORDER BY icu.used_at DESC"#,
|
||||
code
|
||||
code.as_str()
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
@@ -306,19 +282,22 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|r| InviteCodeUse {
|
||||
code: code.to_string(),
|
||||
used_by_did: Did::from(r.did),
|
||||
used_by_handle: Some(Handle::from(r.handle)),
|
||||
used_at: r.used_at,
|
||||
.filter_map(|r| {
|
||||
Some(InviteCodeUse {
|
||||
code: code.clone(),
|
||||
used_by_did: legacy_column(r.did, col::USERS_DID)?,
|
||||
used_by_handle: legacy_column(r.handle, col::USERS_HANDLE),
|
||||
used_at: r.used_at,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn disable_invite_codes_by_code(&self, codes: &[String]) -> Result<(), DbError> {
|
||||
async fn disable_invite_codes_by_code(&self, codes: &[InviteCode]) -> Result<(), DbError> {
|
||||
let code_strs: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
|
||||
sqlx::query!(
|
||||
"UPDATE invite_codes SET disabled = TRUE WHERE code = ANY($1)",
|
||||
codes
|
||||
&code_strs
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -347,9 +326,24 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
limit: i64,
|
||||
sort: InviteCodeSortOrder,
|
||||
) -> Result<Vec<InviteCodeRow>, DbError> {
|
||||
fn to_row(
|
||||
code: String,
|
||||
available_uses: i32,
|
||||
disabled: Option<bool>,
|
||||
created_by_user: Uuid,
|
||||
created_at: DateTime<Utc>,
|
||||
) -> InviteCodeRow {
|
||||
InviteCodeRow {
|
||||
code: InviteCode::from(code),
|
||||
available_uses,
|
||||
disabled,
|
||||
created_by_user,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
let results = match (cursor, sort) {
|
||||
(Some(cursor_code), InviteCodeSortOrder::Recent) => sqlx::query_as!(
|
||||
InviteCodeRow,
|
||||
(Some(cursor_code), InviteCodeSortOrder::Recent) => sqlx::query!(
|
||||
r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
|
||||
FROM invite_codes ic
|
||||
WHERE ic.created_at < (SELECT created_at FROM invite_codes WHERE code = $1)
|
||||
@@ -360,9 +354,19 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?,
|
||||
(None, InviteCodeSortOrder::Recent) => sqlx::query_as!(
|
||||
InviteCodeRow,
|
||||
.map_err(map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
to_row(
|
||||
r.code,
|
||||
r.available_uses,
|
||||
r.disabled,
|
||||
r.created_by_user,
|
||||
r.created_at,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
(None, InviteCodeSortOrder::Recent) => sqlx::query!(
|
||||
r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
|
||||
FROM invite_codes ic
|
||||
ORDER BY created_at DESC
|
||||
@@ -371,9 +375,19 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?,
|
||||
(Some(cursor_code), InviteCodeSortOrder::Usage) => sqlx::query_as!(
|
||||
InviteCodeRow,
|
||||
.map_err(map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
to_row(
|
||||
r.code,
|
||||
r.available_uses,
|
||||
r.disabled,
|
||||
r.created_by_user,
|
||||
r.created_at,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
(Some(cursor_code), InviteCodeSortOrder::Usage) => sqlx::query!(
|
||||
r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
|
||||
FROM invite_codes ic
|
||||
WHERE ic.created_at < (SELECT created_at FROM invite_codes WHERE code = $1)
|
||||
@@ -384,9 +398,19 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?,
|
||||
(None, InviteCodeSortOrder::Usage) => sqlx::query_as!(
|
||||
InviteCodeRow,
|
||||
.map_err(map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
to_row(
|
||||
r.code,
|
||||
r.available_uses,
|
||||
r.disabled,
|
||||
r.created_by_user,
|
||||
r.created_at,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
(None, InviteCodeSortOrder::Usage) => sqlx::query!(
|
||||
r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
|
||||
FROM invite_codes ic
|
||||
ORDER BY available_uses DESC
|
||||
@@ -395,7 +419,18 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?,
|
||||
.map_err(map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
to_row(
|
||||
r.code,
|
||||
r.available_uses,
|
||||
r.disabled,
|
||||
r.created_by_user,
|
||||
r.created_at,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
Ok(results)
|
||||
@@ -407,37 +442,40 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| (r.id, Did::from(r.did)))
|
||||
.collect())
|
||||
.map(|r| Ok((r.id, column(r.did, col::USERS_DID)?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_invite_code_uses_batch(
|
||||
&self,
|
||||
codes: &[String],
|
||||
codes: &[InviteCode],
|
||||
) -> Result<Vec<InviteCodeUse>, DbError> {
|
||||
let code_strs: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
|
||||
let results = sqlx::query!(
|
||||
r#"SELECT icu.code, u.did, icu.used_at
|
||||
FROM invite_code_uses icu
|
||||
JOIN users u ON icu.used_by_user = u.id
|
||||
WHERE icu.code = ANY($1)
|
||||
ORDER BY icu.used_at DESC"#,
|
||||
codes
|
||||
&code_strs
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| InviteCodeUse {
|
||||
code: r.code,
|
||||
used_by_did: Did::from(r.did),
|
||||
used_by_handle: None,
|
||||
used_at: r.used_at,
|
||||
.map(|r| {
|
||||
Ok(InviteCodeUse {
|
||||
code: InviteCode::from(r.code),
|
||||
used_by_did: column(r.did, col::USERS_DID)?,
|
||||
used_by_handle: None,
|
||||
used_at: r.used_at,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_invites_created_by_user(
|
||||
@@ -455,39 +493,48 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| InviteCodeInfo {
|
||||
code: r.code,
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(Did::from(r.created_by)),
|
||||
.map(|r| {
|
||||
Ok(InviteCodeInfo {
|
||||
code: InviteCode::from(r.code),
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(column(r.created_by, col::USERS_DID)?),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_invite_code_info(&self, code: &str) -> Result<Option<InviteCodeInfo>, DbError> {
|
||||
async fn get_invite_code_info(
|
||||
&self,
|
||||
code: &InviteCode,
|
||||
) -> Result<Option<InviteCodeInfo>, DbError> {
|
||||
let result = sqlx::query!(
|
||||
r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.for_account, ic.created_at, u.did as created_by
|
||||
FROM invite_codes ic
|
||||
JOIN users u ON ic.created_by_user = u.id
|
||||
WHERE ic.code = $1"#,
|
||||
code
|
||||
code.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(|r| InviteCodeInfo {
|
||||
code: r.code,
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(Did::from(r.created_by)),
|
||||
}))
|
||||
result
|
||||
.map(|r| {
|
||||
Ok(InviteCodeInfo {
|
||||
code: InviteCode::from(r.code),
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(column(r.created_by, col::USERS_DID)?),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_invite_codes_by_users(
|
||||
@@ -506,25 +553,28 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
(
|
||||
Ok((
|
||||
r.created_by_user,
|
||||
InviteCodeInfo {
|
||||
code: r.code,
|
||||
code: InviteCode::from(r.code),
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(Did::from(r.created_by)),
|
||||
created_by: Some(column(r.created_by, col::USERS_DID)?),
|
||||
},
|
||||
)
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_invite_code_used_by_user(&self, user_id: Uuid) -> Result<Option<String>, DbError> {
|
||||
async fn get_invite_code_used_by_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<InviteCode>, DbError> {
|
||||
let result = sqlx::query_scalar!(
|
||||
"SELECT code FROM invite_code_uses WHERE used_by_user = $1",
|
||||
user_id
|
||||
@@ -533,7 +583,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result)
|
||||
Ok(result.map(InviteCode::from))
|
||||
}
|
||||
|
||||
async fn delete_invite_code_uses_by_user(&self, user_id: Uuid) -> Result<(), DbError> {
|
||||
@@ -563,7 +613,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
async fn reserve_signing_key(
|
||||
&self,
|
||||
did: Option<&Did>,
|
||||
public_key_did_key: &str,
|
||||
public_key_did_key: &Did,
|
||||
private_key_bytes: &[u8],
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<Uuid, DbError> {
|
||||
@@ -573,7 +623,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id"#,
|
||||
did_str,
|
||||
public_key_did_key,
|
||||
public_key_did_key.as_str(),
|
||||
private_key_bytes,
|
||||
expires_at
|
||||
)
|
||||
@@ -586,7 +636,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
|
||||
async fn get_reserved_signing_key(
|
||||
&self,
|
||||
public_key_did_key: &str,
|
||||
public_key_did_key: &Did,
|
||||
) -> Result<Option<ReservedSigningKey>, DbError> {
|
||||
let result = sqlx::query!(
|
||||
r#"SELECT id, private_key_bytes
|
||||
@@ -595,7 +645,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
AND used_at IS NULL
|
||||
AND expires_at > NOW()
|
||||
FOR UPDATE"#,
|
||||
public_key_did_key
|
||||
public_key_did_key.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
@@ -647,10 +697,14 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(|r| DeletionRequest {
|
||||
did: Did::from(r.did),
|
||||
expires_at: r.expires_at,
|
||||
}))
|
||||
result
|
||||
.map(|r| {
|
||||
Ok(DeletionRequest {
|
||||
did: column(r.did, col::ACCOUNT_DELETION_REQUESTS_DID)?,
|
||||
expires_at: r.expires_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn delete_deletion_request(&self, token: &str) -> Result<(), DbError> {
|
||||
@@ -991,16 +1045,20 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(|r| AdminAccountInfo {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
invites_disabled: r.invites_disabled.unwrap_or(false),
|
||||
email_verified: r.email_verified,
|
||||
deactivated_at: r.deactivated_at,
|
||||
}))
|
||||
result
|
||||
.map(|r| {
|
||||
Ok(AdminAccountInfo {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
invites_disabled: r.invites_disabled.unwrap_or(false),
|
||||
email_verified: r.email_verified,
|
||||
deactivated_at: r.deactivated_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_admin_account_infos_by_dids(
|
||||
@@ -1022,15 +1080,17 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|r| AdminAccountInfo {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
invites_disabled: r.invites_disabled.unwrap_or(false),
|
||||
email_verified: r.email_verified,
|
||||
deactivated_at: r.deactivated_at,
|
||||
.filter_map(|r| {
|
||||
Some(AdminAccountInfo {
|
||||
id: r.id,
|
||||
did: legacy_column(r.did, col::USERS_DID)?,
|
||||
handle: legacy_column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
invites_disabled: r.invites_disabled.unwrap_or(false),
|
||||
email_verified: r.email_verified,
|
||||
deactivated_at: r.deactivated_at,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -1038,7 +1098,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
async fn get_invite_code_uses_by_users(
|
||||
&self,
|
||||
user_ids: &[Uuid],
|
||||
) -> Result<Vec<(Uuid, String)>, DbError> {
|
||||
) -> Result<Vec<(Uuid, InviteCode)>, DbError> {
|
||||
let results = sqlx::query!(
|
||||
r#"
|
||||
SELECT used_by_user, code
|
||||
@@ -1053,7 +1113,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|r| (r.used_by_user, r.code))
|
||||
.map(|r| (r.used_by_user, InviteCode::from(r.code)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -1069,11 +1129,14 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| DeletionRequestWithToken {
|
||||
token: r.token,
|
||||
did: Did::new(r.did).expect("valid DID in database"),
|
||||
expires_at: r.expires_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(DeletionRequestWithToken {
|
||||
token: r.token,
|
||||
did: column(r.did, col::ACCOUNT_DELETION_REQUESTS_DID)?,
|
||||
expires_at: r.expires_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_latest_comms_for_user(
|
||||
@@ -1155,25 +1218,31 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
|
||||
async fn get_reserved_signing_key_full(
|
||||
&self,
|
||||
public_key_did_key: &str,
|
||||
public_key_did_key: &Did,
|
||||
) -> Result<Option<ReservedSigningKeyFull>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT id, did, public_key_did_key, private_key_bytes, expires_at, used_at
|
||||
FROM reserved_signing_keys WHERE public_key_did_key = $1"#,
|
||||
public_key_did_key
|
||||
public_key_did_key.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| ReservedSigningKeyFull {
|
||||
id: r.id,
|
||||
did: r.did.map(|d| Did::new(d).expect("valid DID in database")),
|
||||
public_key_did_key: r.public_key_did_key,
|
||||
private_key_bytes: r.private_key_bytes,
|
||||
expires_at: r.expires_at,
|
||||
used_at: r.used_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(ReservedSigningKeyFull {
|
||||
id: r.id,
|
||||
did: opt_column(r.did, col::RESERVED_SIGNING_KEYS_DID)?,
|
||||
public_key_did_key: column(
|
||||
r.public_key_did_key,
|
||||
col::RESERVED_SIGNING_KEYS_PUBLIC_KEY_DID_KEY,
|
||||
)?,
|
||||
private_key_bytes: r.private_key_bytes,
|
||||
expires_at: r.expires_at,
|
||||
used_at: r.used_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_plc_tokens_by_did(&self, did: &Did) -> Result<Vec<PlcTokenInfo>, DbError> {
|
||||
|
||||
@@ -10,7 +10,106 @@ mod sso;
|
||||
mod user;
|
||||
|
||||
use sqlx::PgPool;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tranquil_db_traits::{ColumnRef, DbError};
|
||||
|
||||
pub(crate) mod col {
|
||||
use tranquil_db_traits::ColumnRef;
|
||||
|
||||
pub const ACCOUNT_DELEGATIONS_CONTROLLER_DID: ColumnRef =
|
||||
ColumnRef::new("account_delegations", "controller_did");
|
||||
pub const ACCOUNT_DELEGATIONS_DELEGATED_DID: ColumnRef =
|
||||
ColumnRef::new("account_delegations", "delegated_did");
|
||||
pub const ACCOUNT_DELEGATIONS_GRANTED_BY: ColumnRef =
|
||||
ColumnRef::new("account_delegations", "granted_by");
|
||||
pub const ACCOUNT_DELEGATIONS_REVOKED_BY: ColumnRef =
|
||||
ColumnRef::new("account_delegations", "revoked_by");
|
||||
pub const ACCOUNT_DELETION_REQUESTS_DID: ColumnRef =
|
||||
ColumnRef::new("account_deletion_requests", "did");
|
||||
pub const APP_PASSWORDS_CREATED_BY_CONTROLLER_DID: ColumnRef =
|
||||
ColumnRef::new("app_passwords", "created_by_controller_did");
|
||||
pub const BACKLINKS_URI: ColumnRef = ColumnRef::new("backlinks", "uri");
|
||||
pub const BLOBS_CID: ColumnRef = ColumnRef::new("blobs", "cid");
|
||||
pub const DELEGATION_AUDIT_LOG_ACTOR_DID: ColumnRef =
|
||||
ColumnRef::new("delegation_audit_log", "actor_did");
|
||||
pub const DELEGATION_AUDIT_LOG_CONTROLLER_DID: ColumnRef =
|
||||
ColumnRef::new("delegation_audit_log", "controller_did");
|
||||
pub const DELEGATION_AUDIT_LOG_DELEGATED_DID: ColumnRef =
|
||||
ColumnRef::new("delegation_audit_log", "delegated_did");
|
||||
pub const INVITE_CODES_FOR_ACCOUNT: ColumnRef = ColumnRef::new("invite_codes", "for_account");
|
||||
pub const OAUTH_2FA_CHALLENGE_DID: ColumnRef = ColumnRef::new("oauth_2fa_challenge", "did");
|
||||
pub const OAUTH_AUTHORIZATION_REQUEST_CONTROLLER_DID: ColumnRef =
|
||||
ColumnRef::new("oauth_authorization_request", "controller_did");
|
||||
pub const OAUTH_AUTHORIZATION_REQUEST_DID: ColumnRef =
|
||||
ColumnRef::new("oauth_authorization_request", "did");
|
||||
pub const OAUTH_TOKEN_CONTROLLER_DID: ColumnRef =
|
||||
ColumnRef::new("oauth_token", "controller_did");
|
||||
pub const OAUTH_TOKEN_DID: ColumnRef = ColumnRef::new("oauth_token", "did");
|
||||
pub const PASSKEYS_DID: ColumnRef = ColumnRef::new("passkeys", "did");
|
||||
pub const RECORD_BLOBS_BLOB_CID: ColumnRef = ColumnRef::new("record_blobs", "blob_cid");
|
||||
pub const RECORD_BLOBS_RECORD_URI: ColumnRef = ColumnRef::new("record_blobs", "record_uri");
|
||||
pub const RECORDS_COLLECTION: ColumnRef = ColumnRef::new("records", "collection");
|
||||
pub const RECORDS_RECORD_CID: ColumnRef = ColumnRef::new("records", "record_cid");
|
||||
pub const RECORDS_RKEY: ColumnRef = ColumnRef::new("records", "rkey");
|
||||
pub const REPO_SEQ_BLOBS: ColumnRef = ColumnRef::new("repo_seq", "blobs");
|
||||
pub const REPO_SEQ_BLOCKS_CIDS: ColumnRef = ColumnRef::new("repo_seq", "blocks_cids");
|
||||
pub const REPO_SEQ_COMMIT_CID: ColumnRef = ColumnRef::new("repo_seq", "commit_cid");
|
||||
pub const REPO_SEQ_DID: ColumnRef = ColumnRef::new("repo_seq", "did");
|
||||
pub const REPO_SEQ_HANDLE: ColumnRef = ColumnRef::new("repo_seq", "handle");
|
||||
pub const REPO_SEQ_PREV_CID: ColumnRef = ColumnRef::new("repo_seq", "prev_cid");
|
||||
pub const REPO_SEQ_PREV_DATA_CID: ColumnRef = ColumnRef::new("repo_seq", "prev_data_cid");
|
||||
pub const REPO_SEQ_REV: ColumnRef = ColumnRef::new("repo_seq", "rev");
|
||||
pub const REPOS_REPO_REV: ColumnRef = ColumnRef::new("repos", "repo_rev");
|
||||
pub const REPOS_REPO_ROOT_CID: ColumnRef = ColumnRef::new("repos", "repo_root_cid");
|
||||
pub const RESERVED_SIGNING_KEYS_DID: ColumnRef = ColumnRef::new("reserved_signing_keys", "did");
|
||||
pub const RESERVED_SIGNING_KEYS_PUBLIC_KEY_DID_KEY: ColumnRef =
|
||||
ColumnRef::new("reserved_signing_keys", "public_key_did_key");
|
||||
pub const SESSION_TOKENS_CONTROLLER_DID: ColumnRef =
|
||||
ColumnRef::new("session_tokens", "controller_did");
|
||||
pub const SESSION_TOKENS_DID: ColumnRef = ColumnRef::new("session_tokens", "did");
|
||||
pub const USERS_DID: ColumnRef = ColumnRef::new("users", "did");
|
||||
pub const USERS_HANDLE: ColumnRef = ColumnRef::new("users", "handle");
|
||||
}
|
||||
|
||||
pub(crate) fn column<T: FromStr>(value: String, name: ColumnRef) -> Result<T, DbError> {
|
||||
T::from_str(&value).map_err(|_| {
|
||||
tracing::error!(
|
||||
column = %name,
|
||||
value = %value,
|
||||
"column has a value that isn't valid for its type"
|
||||
);
|
||||
DbError::InvalidColumn(name)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn opt_column<T: FromStr>(
|
||||
value: Option<String>,
|
||||
name: ColumnRef,
|
||||
) -> Result<Option<T>, DbError> {
|
||||
value.map(|v| column(v, name)).transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_column<T: FromStr>(value: String, name: ColumnRef) -> Option<T> {
|
||||
match T::from_str(&value) {
|
||||
Ok(v) => Some(v),
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
column = %name,
|
||||
value = %value,
|
||||
"ignoring a column value that isn't valid for its type"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn column_vec<T: FromStr>(
|
||||
values: Vec<String>,
|
||||
name: ColumnRef,
|
||||
) -> Result<Vec<T>, DbError> {
|
||||
values.into_iter().map(|v| column(v, name)).collect()
|
||||
}
|
||||
|
||||
pub use backlink::PostgresBacklinkRepository;
|
||||
pub use blob::PostgresBlobRepository;
|
||||
|
||||
@@ -7,16 +7,16 @@ use tranquil_db_traits::{
|
||||
ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge,
|
||||
};
|
||||
use tranquil_oauth::{
|
||||
AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, Code as OAuthCode,
|
||||
DeviceData, DeviceId as OAuthDeviceId, RefreshToken as OAuthRefreshToken, RequestData,
|
||||
SessionId as OAuthSessionId, TokenData, TokenId as OAuthTokenId,
|
||||
AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, DeviceData, RequestData,
|
||||
SessionId as OAuthSessionId, TokenData,
|
||||
};
|
||||
use tranquil_types::{
|
||||
AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, Handle, RefreshToken, RequestId,
|
||||
TokenId,
|
||||
AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, RefreshToken, RequestId, TokenId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::column;
|
||||
use super::user::map_sqlx_error;
|
||||
|
||||
const REGISTRATION_FLOW_EXTENDED_EXPIRY_SECS: i64 = 600;
|
||||
@@ -61,17 +61,17 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
RETURNING id
|
||||
"#,
|
||||
data.did.as_str(),
|
||||
&data.token_id.0,
|
||||
data.token_id.as_str(),
|
||||
data.created_at,
|
||||
data.updated_at,
|
||||
data.expires_at,
|
||||
data.client_id,
|
||||
data.client_id.as_str(),
|
||||
client_auth_json,
|
||||
data.device_id.as_ref().map(|d| d.0.as_str()),
|
||||
data.device_id.as_deref(),
|
||||
parameters_json,
|
||||
data.details,
|
||||
data.code.as_ref().map(|c| c.0.as_str()),
|
||||
data.current_refresh_token.as_ref().map(|r| r.0.as_str()),
|
||||
data.code.as_deref(),
|
||||
data.current_refresh_token.as_deref(),
|
||||
data.scope,
|
||||
data.controller_did.as_ref().map(|d| d.as_str()),
|
||||
)
|
||||
@@ -99,24 +99,24 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
token_id: OAuthTokenId(r.token_id),
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
|
||||
token_id: TokenId::from(r.token_id),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
expires_at: r.expires_at,
|
||||
client_id: r.client_id,
|
||||
client_id: ClientId::from(r.client_id),
|
||||
client_auth: from_json(r.client_auth)?,
|
||||
device_id: r.device_id.map(OAuthDeviceId),
|
||||
device_id: r.device_id.map(DeviceId::from),
|
||||
parameters: from_json(r.parameters)?,
|
||||
details: r.details,
|
||||
code: r.code.map(OAuthCode),
|
||||
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
|
||||
code: r.code.map(AuthorizationCode::from),
|
||||
current_refresh_token: r.current_refresh_token.map(RefreshToken::from),
|
||||
scope: r.scope,
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
|
||||
})),
|
||||
None => Ok(None),
|
||||
}
|
||||
@@ -145,24 +145,24 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
token_id: OAuthTokenId(r.token_id),
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
|
||||
token_id: TokenId::from(r.token_id),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
expires_at: r.expires_at,
|
||||
client_id: r.client_id,
|
||||
client_id: ClientId::from(r.client_id),
|
||||
client_auth: from_json(r.client_auth)?,
|
||||
device_id: r.device_id.map(OAuthDeviceId),
|
||||
device_id: r.device_id.map(DeviceId::from),
|
||||
parameters: from_json(r.parameters)?,
|
||||
details: r.details,
|
||||
code: r.code.map(OAuthCode),
|
||||
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
|
||||
code: r.code.map(AuthorizationCode::from),
|
||||
current_refresh_token: r.current_refresh_token.map(RefreshToken::from),
|
||||
scope: r.scope,
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
|
||||
},
|
||||
))),
|
||||
None => Ok(None),
|
||||
@@ -194,24 +194,24 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
token_id: OAuthTokenId(r.token_id),
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
|
||||
token_id: TokenId::from(r.token_id),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
expires_at: r.expires_at,
|
||||
client_id: r.client_id,
|
||||
client_id: ClientId::from(r.client_id),
|
||||
client_auth: from_json(r.client_auth)?,
|
||||
device_id: r.device_id.map(OAuthDeviceId),
|
||||
device_id: r.device_id.map(DeviceId::from),
|
||||
parameters: from_json(r.parameters)?,
|
||||
details: r.details,
|
||||
code: r.code.map(OAuthCode),
|
||||
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
|
||||
code: r.code.map(AuthorizationCode::from),
|
||||
current_refresh_token: r.current_refresh_token.map(RefreshToken::from),
|
||||
scope: r.scope,
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
|
||||
},
|
||||
))),
|
||||
None => Ok(None),
|
||||
@@ -327,24 +327,24 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
token_id: OAuthTokenId(r.token_id),
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
|
||||
token_id: TokenId::from(r.token_id),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
expires_at: r.expires_at,
|
||||
client_id: r.client_id,
|
||||
client_id: ClientId::from(r.client_id),
|
||||
client_auth: from_json(r.client_auth)?,
|
||||
device_id: r.device_id.map(OAuthDeviceId),
|
||||
device_id: r.device_id.map(DeviceId::from),
|
||||
parameters: from_json(r.parameters)?,
|
||||
details: r.details,
|
||||
code: r.code.map(OAuthCode),
|
||||
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
|
||||
code: r.code.map(AuthorizationCode::from),
|
||||
current_refresh_token: r.current_refresh_token.map(RefreshToken::from),
|
||||
scope: r.scope,
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -437,12 +437,12 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
"#,
|
||||
request_id.as_str(),
|
||||
data.did.as_ref().map(|d| d.as_str()),
|
||||
data.device_id.as_ref().map(|d| d.0.as_str()),
|
||||
data.client_id,
|
||||
data.device_id.as_deref(),
|
||||
data.client_id.as_str(),
|
||||
client_auth_json,
|
||||
parameters_json,
|
||||
data.expires_at,
|
||||
data.code.as_ref().map(|c| c.0.as_str()),
|
||||
data.code.as_deref(),
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -473,22 +473,18 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
};
|
||||
let parameters: AuthorizationRequestParameters = from_json(r.parameters)?;
|
||||
Ok(Some(RequestData {
|
||||
client_id: r.client_id,
|
||||
client_id: ClientId::from(r.client_id),
|
||||
client_auth,
|
||||
parameters,
|
||||
expires_at: r.expires_at,
|
||||
did: r
|
||||
.did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid DID in DB".into()))?,
|
||||
device_id: r.device_id.map(OAuthDeviceId),
|
||||
code: r.code.map(OAuthCode),
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?,
|
||||
did: r.did.map(|s| s.parse()).transpose().map_err(|_| {
|
||||
DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_DID)
|
||||
})?,
|
||||
device_id: r.device_id.map(DeviceId::from),
|
||||
code: r.code.map(AuthorizationCode::from),
|
||||
controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(
|
||||
|_| DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_CONTROLLER_DID),
|
||||
)?,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
@@ -567,22 +563,18 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
};
|
||||
let parameters: AuthorizationRequestParameters = from_json(r.parameters)?;
|
||||
Ok(Some(RequestData {
|
||||
client_id: r.client_id,
|
||||
client_id: ClientId::from(r.client_id),
|
||||
client_auth,
|
||||
parameters,
|
||||
expires_at: r.expires_at,
|
||||
did: r
|
||||
.did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid DID in DB".into()))?,
|
||||
device_id: r.device_id.map(OAuthDeviceId),
|
||||
code: r.code.map(OAuthCode),
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?,
|
||||
did: r.did.map(|s| s.parse()).transpose().map_err(|_| {
|
||||
DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_DID)
|
||||
})?,
|
||||
device_id: r.device_id.map(DeviceId::from),
|
||||
code: r.code.map(AuthorizationCode::from),
|
||||
controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(
|
||||
|_| DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_CONTROLLER_DID),
|
||||
)?,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
@@ -720,7 +712,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
"#,
|
||||
device_id.as_str(),
|
||||
&data.session_id.0,
|
||||
data.session_id.as_str(),
|
||||
data.user_agent,
|
||||
data.ip_address,
|
||||
data.last_seen_at,
|
||||
@@ -744,7 +736,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| DeviceData {
|
||||
session_id: OAuthSessionId(r.session_id),
|
||||
session_id: OAuthSessionId::from(r.session_id),
|
||||
user_agent: r.user_agent,
|
||||
ip_address: r.ip_address,
|
||||
last_seen_at: r.last_seen_at,
|
||||
@@ -814,15 +806,16 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| DeviceAccountRow {
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
last_used_at: r.last_used_at,
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(DeviceAccountRow {
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
last_used_at: r.last_used_at,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn verify_account_on_device(
|
||||
@@ -905,8 +898,8 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(TwoFactorChallenge {
|
||||
id: row.id,
|
||||
did: Did::from(row.did),
|
||||
request_uri: row.request_uri,
|
||||
did: column(row.did, col::OAUTH_2FA_CHALLENGE_DID)?,
|
||||
request_uri: RequestId::from(row.request_uri),
|
||||
code: row.code,
|
||||
attempts: row.attempts,
|
||||
created_at: row.created_at,
|
||||
@@ -929,15 +922,18 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| TwoFactorChallenge {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
request_uri: r.request_uri,
|
||||
code: r.code,
|
||||
attempts: r.attempts,
|
||||
created_at: r.created_at,
|
||||
expires_at: r.expires_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(TwoFactorChallenge {
|
||||
id: r.id,
|
||||
did: column(r.did, col::OAUTH_2FA_CHALLENGE_DID)?,
|
||||
request_uri: RequestId::from(r.request_uri),
|
||||
code: r.code,
|
||||
attempts: r.attempts,
|
||||
created_at: r.created_at,
|
||||
expires_at: r.expires_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn increment_2fa_attempts(&self, id: Uuid) -> Result<i32, DbError> {
|
||||
@@ -1143,7 +1139,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| TrustedDeviceRow {
|
||||
id: r.id,
|
||||
id: DeviceId::from(r.id),
|
||||
user_agent: r.user_agent,
|
||||
friendly_name: r.friendly_name,
|
||||
trusted_at: r.trusted_at,
|
||||
|
||||
@@ -7,10 +7,12 @@ use tranquil_db_traits::{
|
||||
RepoAccountInfo, RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoWithoutRev,
|
||||
SequenceNumber, SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks,
|
||||
};
|
||||
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey};
|
||||
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey, Tid};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::user::map_sqlx_error;
|
||||
use super::{column, column_vec, legacy_column, opt_column};
|
||||
|
||||
struct RecordRow {
|
||||
rkey: String,
|
||||
@@ -43,7 +45,7 @@ fn row_to_event_blocks(
|
||||
) -> Result<Option<EventBlocks>, DbError> {
|
||||
match (block_cids, block_data) {
|
||||
(Some(cids), Some(data)) if cids.len() == data.len() => match cids.is_empty() {
|
||||
true => Ok(legacy_fallback(legacy_blocks_cids)),
|
||||
true => legacy_fallback(legacy_blocks_cids),
|
||||
false => Ok(Some(EventBlocks::Inline(
|
||||
cids.into_iter()
|
||||
.zip(data)
|
||||
@@ -57,14 +59,19 @@ fn row_to_event_blocks(
|
||||
(Some(_), None) | (None, Some(_)) => Err(DbError::CorruptData(
|
||||
"repo_seq.block_cids/block_data partially populated",
|
||||
)),
|
||||
(None, None) => Ok(legacy_fallback(legacy_blocks_cids)),
|
||||
(None, None) => legacy_fallback(legacy_blocks_cids),
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_fallback(legacy_blocks_cids: Option<Vec<String>>) -> Option<EventBlocks> {
|
||||
fn legacy_fallback(
|
||||
legacy_blocks_cids: Option<Vec<String>>,
|
||||
) -> Result<Option<EventBlocks>, DbError> {
|
||||
match legacy_blocks_cids {
|
||||
Some(cids) if !cids.is_empty() => Some(EventBlocks::LegacyCids(cids)),
|
||||
_ => None,
|
||||
Some(cids) if !cids.is_empty() => Ok(Some(EventBlocks::LegacyCids(column_vec(
|
||||
cids,
|
||||
col::REPO_SEQ_BLOCKS_CIDS,
|
||||
)?))),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,22 +102,40 @@ fn map_sequenced_row(r: SequencedEventRow) -> Result<SequencedEvent, DbError> {
|
||||
let blocks = row_to_event_blocks(r.block_cids, r.block_data, r.blocks_cids)?;
|
||||
Ok(SequencedEvent {
|
||||
seq: r.seq.into(),
|
||||
did: Did::from(r.did),
|
||||
did: column(r.did, col::REPO_SEQ_DID)?,
|
||||
created_at: r.created_at,
|
||||
event_type: r.event_type,
|
||||
commit_cid: r.commit_cid.map(CidLink::from),
|
||||
prev_cid: r.prev_cid.map(CidLink::from),
|
||||
prev_data_cid: r.prev_data_cid.map(CidLink::from),
|
||||
commit_cid: opt_column(r.commit_cid, col::REPO_SEQ_COMMIT_CID)?,
|
||||
prev_cid: opt_column(r.prev_cid, col::REPO_SEQ_PREV_CID)?,
|
||||
prev_data_cid: opt_column(r.prev_data_cid, col::REPO_SEQ_PREV_DATA_CID)?,
|
||||
ops: r.ops,
|
||||
blobs: r.blobs,
|
||||
blobs: r
|
||||
.blobs
|
||||
.map(|blobs| column_vec(blobs, col::REPO_SEQ_BLOBS))
|
||||
.transpose()?,
|
||||
blocks,
|
||||
handle: r.handle.map(Handle::from),
|
||||
handle: r
|
||||
.handle
|
||||
.and_then(|h| legacy_column(h, col::REPO_SEQ_HANDLE)),
|
||||
active: r.active,
|
||||
status,
|
||||
rev: r.rev,
|
||||
rev: opt_column(r.rev, col::REPO_SEQ_REV)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_sequenced_rows(rows: Vec<SequencedEventRow>) -> Vec<SequencedEvent> {
|
||||
rows.into_iter()
|
||||
.filter_map(|r| {
|
||||
let seq = r.seq;
|
||||
map_sequenced_row(r)
|
||||
.inspect_err(|e| {
|
||||
tracing::error!(seq, error = %e, "skipping a repo_seq row that doesn't decode");
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
const SEQUENCER_LOCK_KEY: i64 = 0x0074_7261_6e73_6571;
|
||||
const SEQUENCER_BATCH_SIZE: i64 = 1000;
|
||||
|
||||
@@ -197,13 +222,13 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
_did: &Did,
|
||||
_handle: &Handle,
|
||||
repo_root_cid: &CidLink,
|
||||
repo_rev: &str,
|
||||
repo_rev: &Tid,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)",
|
||||
user_id,
|
||||
repo_root_cid.as_str(),
|
||||
repo_rev
|
||||
repo_rev.as_str()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -216,12 +241,12 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
repo_root_cid: &CidLink,
|
||||
repo_rev: &str,
|
||||
repo_rev: &Tid,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE repos SET repo_root_cid = $1, repo_rev = $2, updated_at = NOW() WHERE user_id = $3",
|
||||
repo_root_cid.as_str(),
|
||||
repo_rev,
|
||||
repo_rev.as_str(),
|
||||
user_id
|
||||
)
|
||||
.execute(&self.pool)
|
||||
@@ -231,10 +256,10 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_repo_rev(&self, user_id: Uuid, repo_rev: &str) -> Result<(), DbError> {
|
||||
async fn update_repo_rev(&self, user_id: Uuid, repo_rev: &Tid) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE repos SET repo_rev = $1 WHERE user_id = $2",
|
||||
repo_rev,
|
||||
repo_rev.as_str(),
|
||||
user_id
|
||||
)
|
||||
.execute(&self.pool)
|
||||
@@ -262,7 +287,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(CidLink::from))
|
||||
opt_column(result, col::REPOS_REPO_ROOT_CID)
|
||||
}
|
||||
|
||||
async fn get_repo(&self, user_id: Uuid) -> Result<Option<RepoInfo>, DbError> {
|
||||
@@ -274,11 +299,14 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| RepoInfo {
|
||||
user_id: r.user_id,
|
||||
repo_root_cid: CidLink::from(r.repo_root_cid),
|
||||
repo_rev: r.repo_rev,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(RepoInfo {
|
||||
user_id: r.user_id,
|
||||
repo_root_cid: column(r.repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
|
||||
repo_rev: opt_column(r.repo_rev, col::REPOS_REPO_REV)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_repo_root_by_did(&self, did: &Did) -> Result<Option<CidLink>, DbError> {
|
||||
@@ -290,7 +318,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(CidLink::from))
|
||||
opt_column(result, col::REPOS_REPO_ROOT_CID)
|
||||
}
|
||||
|
||||
async fn count_repos(&self) -> Result<i64, DbError> {
|
||||
@@ -308,13 +336,14 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| RepoWithoutRev {
|
||||
user_id: r.user_id,
|
||||
repo_root_cid: CidLink::from(r.repo_root_cid),
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(RepoWithoutRev {
|
||||
user_id: r.user_id,
|
||||
repo_root_cid: column(r.repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn upsert_records(
|
||||
@@ -323,7 +352,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
collections: &[Nsid],
|
||||
rkeys: &[Rkey],
|
||||
record_cids: &[CidLink],
|
||||
repo_rev: &str,
|
||||
repo_rev: &Tid,
|
||||
) -> Result<(), DbError> {
|
||||
let collections_str: Vec<&str> = collections.iter().map(|c| c.as_str()).collect();
|
||||
let rkeys_str: Vec<&str> = rkeys.iter().map(|r| r.as_str()).collect();
|
||||
@@ -341,7 +370,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
&collections_str as &[&str],
|
||||
&rkeys_str as &[&str],
|
||||
&cids_str as &[&str],
|
||||
repo_rev
|
||||
repo_rev.as_str()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -401,7 +430,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(CidLink::from))
|
||||
opt_column(result, col::RECORDS_RECORD_CID)
|
||||
}
|
||||
|
||||
async fn list_records(
|
||||
@@ -414,13 +443,16 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
rkey_start: Option<&Rkey>,
|
||||
rkey_end: Option<&Rkey>,
|
||||
) -> Result<Vec<RecordInfo>, DbError> {
|
||||
let to_record_info = |rows: Vec<RecordRow>| {
|
||||
rows.into_iter()
|
||||
.map(|r| RecordInfo {
|
||||
rkey: Rkey::from(r.rkey),
|
||||
record_cid: CidLink::from(r.record_cid),
|
||||
let to_record_info = |rows: Vec<RecordRow>| -> Result<Vec<RecordInfo>, DbError> {
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
Some(RecordInfo {
|
||||
rkey: legacy_column(r.rkey, col::RECORDS_RKEY)?,
|
||||
record_cid: legacy_column(r.record_cid, col::RECORDS_RECORD_CID)?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect())
|
||||
};
|
||||
|
||||
let collection_str = collection.as_str();
|
||||
@@ -442,7 +474,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
true => {
|
||||
let rows = sqlx::query_as!(
|
||||
@@ -458,7 +490,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -482,7 +514,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
true => {
|
||||
let rows = sqlx::query_as!(
|
||||
@@ -499,7 +531,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -521,7 +553,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
true => {
|
||||
let rows = sqlx::query_as!(
|
||||
@@ -537,7 +569,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -559,7 +591,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
true => {
|
||||
let rows = sqlx::query_as!(
|
||||
@@ -575,7 +607,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -594,7 +626,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
true => {
|
||||
let rows = sqlx::query_as!(
|
||||
@@ -609,7 +641,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -625,10 +657,12 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| FullRecordInfo {
|
||||
collection: Nsid::from(r.collection),
|
||||
rkey: Rkey::from(r.rkey),
|
||||
record_cid: CidLink::from(r.record_cid),
|
||||
.filter_map(|r| {
|
||||
Some(FullRecordInfo {
|
||||
collection: legacy_column(r.collection, col::RECORDS_COLLECTION)?,
|
||||
rkey: legacy_column(r.rkey, col::RECORDS_RKEY)?,
|
||||
record_cid: legacy_column(r.record_cid, col::RECORDS_RECORD_CID)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -642,7 +676,10 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows.into_iter().map(Nsid::from).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|c| legacy_column(c, col::RECORDS_COLLECTION))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_records(&self, repo_id: Uuid) -> Result<i64, DbError> {
|
||||
@@ -684,6 +721,45 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn referenced_record_cids(
|
||||
&self,
|
||||
repo_id: Uuid,
|
||||
cids: &[CidLink],
|
||||
excluded_keys: &[(&Nsid, &Rkey)],
|
||||
) -> Result<Vec<CidLink>, DbError> {
|
||||
if cids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let cid_strs: Vec<String> = cids.iter().map(|c| c.as_str().to_owned()).collect();
|
||||
let (excluded_collections, excluded_rkeys): (Vec<String>, Vec<String>) = excluded_keys
|
||||
.iter()
|
||||
.map(|(collection, rkey)| (collection.as_str().to_owned(), rkey.as_str().to_owned()))
|
||||
.unzip();
|
||||
|
||||
let rows = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT DISTINCT r.record_cid AS "record_cid!"
|
||||
FROM records r
|
||||
WHERE r.repo_id = $1
|
||||
AND r.record_cid = ANY($2)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM UNNEST($3::text[], $4::text[]) AS k(collection, rkey)
|
||||
WHERE k.collection = r.collection AND k.rkey = r.rkey
|
||||
)
|
||||
"#,
|
||||
repo_id,
|
||||
&cid_strs,
|
||||
&excluded_collections,
|
||||
&excluded_rkeys
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
column_vec(rows, col::RECORDS_RECORD_CID)
|
||||
}
|
||||
|
||||
async fn set_record_takedown(
|
||||
&self,
|
||||
cid: &CidLink,
|
||||
@@ -705,7 +781,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
block_cids: &[Vec<u8>],
|
||||
repo_rev: &str,
|
||||
repo_rev: &Tid,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
@@ -716,7 +792,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(block_cids)
|
||||
.bind(repo_rev)
|
||||
.bind(repo_rev.as_str())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
@@ -756,26 +832,45 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
async fn get_user_block_cids_since_rev(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
since_rev: &str,
|
||||
since_rev: Option<&Tid>,
|
||||
) -> Result<Vec<Vec<u8>>, DbError> {
|
||||
let rows: Vec<(Vec<u8>,)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT block_cid FROM user_blocks
|
||||
WHERE user_id = $1 AND repo_rev > $2
|
||||
ORDER BY repo_rev ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(since_rev)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
let rows = match since_rev {
|
||||
None => {
|
||||
sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT block_cid AS "block_cid!" FROM user_blocks
|
||||
WHERE user_id = $1
|
||||
ORDER BY repo_rev ASC
|
||||
"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
Some(rev) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT block_cid AS "block_cid!" FROM user_blocks
|
||||
WHERE user_id = $1 AND repo_rev > $2
|
||||
ORDER BY repo_rev ASC
|
||||
"#,
|
||||
user_id,
|
||||
rev.as_str()
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
Ok(rows.into_iter().map(|(cid,)| cid).collect())
|
||||
rows.map_err(map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<(), DbError> {
|
||||
let (block_cids, block_data) = inline_to_paired_blocks(data.blocks.as_deref());
|
||||
let blob_strs: Option<Vec<String>> = data
|
||||
.blobs
|
||||
.as_ref()
|
||||
.map(|blobs| blobs.iter().map(|c| c.to_string()).collect());
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)
|
||||
@@ -786,11 +881,11 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
data.commit_cid.as_ref().map(|c| c.as_str()),
|
||||
data.prev_cid.as_ref().map(|c| c.as_str()),
|
||||
data.ops,
|
||||
data.blobs.as_deref(),
|
||||
blob_strs.as_deref(),
|
||||
&block_cids as &[Vec<u8>],
|
||||
&block_data as &[Vec<u8>],
|
||||
data.prev_data_cid.as_ref().map(|c| c.as_str()),
|
||||
data.rev
|
||||
data.rev.as_deref()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -824,7 +919,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
|
||||
async fn insert_account_event(&self, did: &Did, status: AccountStatus) -> Result<(), DbError> {
|
||||
let active = status.is_active();
|
||||
let status_str = status.for_firehose();
|
||||
let status_str = status.for_firehose().map(|s| s.as_str());
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO repo_seq (did, event_type, active, status)
|
||||
@@ -846,7 +941,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
&self,
|
||||
did: &Did,
|
||||
commit_cid: &CidLink,
|
||||
rev: Option<&str>,
|
||||
rev: Option<&Tid>,
|
||||
commit_bytes: &[u8],
|
||||
) -> Result<(), DbError> {
|
||||
let cid_bytes = commit_cid
|
||||
@@ -862,7 +957,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
"#,
|
||||
did.as_str(),
|
||||
commit_cid.as_str(),
|
||||
rev,
|
||||
rev.map(|r| r.as_str()),
|
||||
&block_cids as &[Vec<u8>],
|
||||
&block_data as &[Vec<u8>]
|
||||
)
|
||||
@@ -879,7 +974,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
did: &Did,
|
||||
commit_cid: &CidLink,
|
||||
mst_root_cid: &CidLink,
|
||||
rev: &str,
|
||||
rev: &Tid,
|
||||
commit_bytes: &[u8],
|
||||
mst_root_bytes: &[u8],
|
||||
) -> Result<(), DbError> {
|
||||
@@ -909,7 +1004,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
&blobs,
|
||||
&block_cids as &[Vec<u8>],
|
||||
&block_data as &[Vec<u8>],
|
||||
rev
|
||||
rev.as_str()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -1016,13 +1111,16 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| RepoAccountInfo {
|
||||
user_id: r.id,
|
||||
did: Did::from(r.did),
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
repo_root_cid: r.repo_root_cid.map(CidLink::from),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(RepoAccountInfo {
|
||||
user_id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
repo_root_cid: opt_column(r.repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_events_since_seq(
|
||||
@@ -1046,7 +1144,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
rows.into_iter().map(map_sequenced_row).collect()
|
||||
Ok(collect_sequenced_rows(rows))
|
||||
}
|
||||
None => {
|
||||
let rows = sqlx::query_as!(
|
||||
@@ -1061,7 +1159,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
rows.into_iter().map(map_sequenced_row).collect()
|
||||
Ok(collect_sequenced_rows(rows))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1084,7 +1182,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
rows.into_iter().map(map_sequenced_row).collect()
|
||||
Ok(collect_sequenced_rows(rows))
|
||||
}
|
||||
|
||||
async fn get_event_by_seq(
|
||||
@@ -1124,7 +1222,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
rows.into_iter().map(map_sequenced_row).collect()
|
||||
Ok(collect_sequenced_rows(rows))
|
||||
}
|
||||
|
||||
async fn list_repos_paginated(
|
||||
@@ -1147,16 +1245,17 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| RepoListItem {
|
||||
did: Did::from(r.did),
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
repo_root_cid: CidLink::from(r.repo_root_cid),
|
||||
repo_rev: r.repo_rev,
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(RepoListItem {
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
repo_root_cid: column(r.repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
|
||||
repo_rev: opt_column(r.repo_rev, col::REPOS_REPO_REV)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_repo_root_cid_by_user_id(
|
||||
@@ -1170,7 +1269,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(cid.map(CidLink::from))
|
||||
opt_column(cid, col::REPOS_REPO_ROOT_CID)
|
||||
}
|
||||
|
||||
async fn import_repo_data(
|
||||
@@ -1463,6 +1562,10 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
|
||||
let event = input.commit_event;
|
||||
let (event_block_cids, event_block_data) = inline_into_paired_blocks(event.blocks);
|
||||
let event_blob_strs: Option<Vec<String>> = event
|
||||
.blobs
|
||||
.as_ref()
|
||||
.map(|blobs| blobs.iter().map(|c| c.to_string()).collect());
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)
|
||||
@@ -1473,11 +1576,11 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
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_blob_strs.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
|
||||
event.rev.as_deref()
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
@@ -1508,14 +1611,15 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(user_id, repo_root_cid, repo_rev)| UserWithoutBlocks {
|
||||
user_id,
|
||||
repo_root_cid: CidLink::from(repo_root_cid),
|
||||
repo_rev,
|
||||
rows.into_iter()
|
||||
.map(|(user_id, repo_root_cid, repo_rev)| {
|
||||
Ok(UserWithoutBlocks {
|
||||
user_id,
|
||||
repo_root_cid: column(repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
|
||||
repo_rev: opt_column(repo_rev, col::REPOS_REPO_REV)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_users_needing_record_blobs_backfill(
|
||||
@@ -1536,13 +1640,14 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| UserNeedingRecordBlobsBackfill {
|
||||
user_id: r.user_id,
|
||||
did: Did::from(r.did),
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(UserNeedingRecordBlobsBackfill {
|
||||
user_id: r.user_id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn insert_record_blobs(
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
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 tranquil_types::{Did, Jti, PasswordHash};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::user::map_sqlx_error;
|
||||
use super::{column, opt_column};
|
||||
|
||||
pub struct PostgresSessionRepository {
|
||||
pool: PgPool,
|
||||
@@ -33,8 +36,8 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
RETURNING id
|
||||
"#,
|
||||
data.did.as_str(),
|
||||
data.access_jti,
|
||||
data.refresh_jti,
|
||||
data.access_jti.as_str(),
|
||||
data.refresh_jti.as_str(),
|
||||
data.access_expires_at,
|
||||
data.refresh_expires_at,
|
||||
data.login_type.is_legacy(),
|
||||
@@ -52,7 +55,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
|
||||
async fn get_session_by_access_jti(
|
||||
&self,
|
||||
access_jti: &str,
|
||||
access_jti: &Jti,
|
||||
) -> Result<Option<SessionToken>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
@@ -62,32 +65,35 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
FROM session_tokens
|
||||
WHERE access_jti = $1
|
||||
"#,
|
||||
access_jti
|
||||
access_jti.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| SessionToken {
|
||||
id: SessionId::new(r.id),
|
||||
did: Did::from(r.did),
|
||||
access_jti: r.access_jti,
|
||||
refresh_jti: r.refresh_jti,
|
||||
access_expires_at: r.access_expires_at,
|
||||
refresh_expires_at: r.refresh_expires_at,
|
||||
login_type: LoginType::from_legacy_flag(r.legacy_login),
|
||||
mfa_verified: r.mfa_verified,
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did.map(Did::from),
|
||||
app_password_name: r.app_password_name,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(SessionToken {
|
||||
id: SessionId::new(r.id),
|
||||
did: column(r.did, col::SESSION_TOKENS_DID)?,
|
||||
access_jti: Jti::from(r.access_jti),
|
||||
refresh_jti: Jti::from(r.refresh_jti),
|
||||
access_expires_at: r.access_expires_at,
|
||||
refresh_expires_at: r.refresh_expires_at,
|
||||
login_type: LoginType::from_legacy_flag(r.legacy_login),
|
||||
mfa_verified: r.mfa_verified,
|
||||
scope: r.scope,
|
||||
controller_did: opt_column(r.controller_did, col::SESSION_TOKENS_CONTROLLER_DID)?,
|
||||
app_password_name: r.app_password_name,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_session_for_refresh(
|
||||
&self,
|
||||
refresh_jti: &str,
|
||||
refresh_jti: &Jti,
|
||||
) -> Result<Option<SessionForRefresh>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
@@ -97,54 +103,34 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()
|
||||
"#,
|
||||
refresh_jti
|
||||
refresh_jti.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| SessionForRefresh {
|
||||
id: SessionId::new(r.id),
|
||||
did: Did::from(r.did),
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did.map(Did::from),
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version.unwrap_or(0),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(SessionForRefresh {
|
||||
id: SessionId::new(r.id),
|
||||
did: column(r.did, col::SESSION_TOKENS_DID)?,
|
||||
scope: r.scope,
|
||||
controller_did: opt_column(r.controller_did, col::SESSION_TOKENS_CONTROLLER_DID)?,
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version.unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
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: &Jti,
|
||||
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.as_str(),
|
||||
did.as_str()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -153,10 +139,11 @@ 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
|
||||
@@ -177,12 +164,12 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
async fn delete_sessions_by_did_except_jti(
|
||||
&self,
|
||||
did: &Did,
|
||||
except_jti: &str,
|
||||
except_jti: &Jti,
|
||||
) -> Result<u64, DbError> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2",
|
||||
did.as_str(),
|
||||
except_jti
|
||||
except_jti.as_str()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -209,7 +196,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.into_iter()
|
||||
.map(|r| SessionListItem {
|
||||
id: SessionId::new(r.id),
|
||||
access_jti: r.access_jti,
|
||||
access_jti: Jti::from(r.access_jti),
|
||||
created_at: r.created_at,
|
||||
refresh_expires_at: r.refresh_expires_at,
|
||||
})
|
||||
@@ -220,7 +207,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
did: &Did,
|
||||
) -> Result<Option<String>, DbError> {
|
||||
) -> Result<Option<Jti>, DbError> {
|
||||
let row = sqlx::query_scalar!(
|
||||
"SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
|
||||
session_id.as_i32(),
|
||||
@@ -230,7 +217,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row)
|
||||
Ok(row.map(Jti::from))
|
||||
}
|
||||
|
||||
async fn delete_sessions_by_app_password(
|
||||
@@ -254,7 +241,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
&self,
|
||||
did: &Did,
|
||||
app_password_name: &str,
|
||||
) -> Result<Vec<String>, DbError> {
|
||||
) -> Result<Vec<Jti>, DbError> {
|
||||
let rows = sqlx::query_scalar!(
|
||||
"SELECT access_jti FROM session_tokens WHERE did = $1 AND app_password_name = $2",
|
||||
did.as_str(),
|
||||
@@ -264,43 +251,55 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows)
|
||||
Ok(rows.into_iter().map(Jti::from).collect())
|
||||
}
|
||||
|
||||
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",
|
||||
refresh_jti
|
||||
async fn lookup_refresh_grace(&self, refresh_jti: &Jti) -> 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.as_str()
|
||||
)
|
||||
.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: column(r.did, col::SESSION_TOKENS_DID)?,
|
||||
scope: r.scope,
|
||||
controller_did: opt_column(r.controller_did, col::SESSION_TOKENS_CONTROLLER_DID)?,
|
||||
access_jti: Jti::from(r.access_jti),
|
||||
refresh_jti: Jti::from(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: column(r.did, col::SESSION_TOKENS_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> {
|
||||
@@ -317,19 +316,23 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AppPasswordRecord {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
name: r.name,
|
||||
password_hash: r.password_hash,
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: r.created_by_controller_did.map(Did::from),
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(AppPasswordRecord {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
name: r.name,
|
||||
password_hash: PasswordHash::new(r.password_hash),
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: opt_column(
|
||||
r.created_by_controller_did,
|
||||
col::APP_PASSWORDS_CREATED_BY_CONTROLLER_DID,
|
||||
)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_app_passwords_for_login(
|
||||
@@ -350,19 +353,23 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AppPasswordRecord {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
name: r.name,
|
||||
password_hash: r.password_hash,
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: r.created_by_controller_did.map(Did::from),
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(AppPasswordRecord {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
name: r.name,
|
||||
password_hash: PasswordHash::new(r.password_hash),
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: opt_column(
|
||||
r.created_by_controller_did,
|
||||
col::APP_PASSWORDS_CREATED_BY_CONTROLLER_DID,
|
||||
)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_app_password_by_name(
|
||||
@@ -383,16 +390,22 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| AppPasswordRecord {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
name: r.name,
|
||||
password_hash: r.password_hash,
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: r.created_by_controller_did.map(Did::from),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(AppPasswordRecord {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
name: r.name,
|
||||
password_hash: PasswordHash::new(r.password_hash),
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: opt_column(
|
||||
r.created_by_controller_did,
|
||||
col::APP_PASSWORDS_CREATED_BY_CONTROLLER_DID,
|
||||
)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn create_app_password(&self, data: &AppPasswordCreate) -> Result<Uuid, DbError> {
|
||||
@@ -404,7 +417,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
"#,
|
||||
data.user_id,
|
||||
data.name,
|
||||
data.password_hash,
|
||||
data.password_hash.as_str(),
|
||||
data.privilege.is_privileged(),
|
||||
data.scopes,
|
||||
data.created_by_controller_did.as_ref().map(|d| d.as_str())
|
||||
@@ -504,7 +517,10 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_app_password_hashes_by_did(&self, did: &Did) -> Result<Vec<String>, DbError> {
|
||||
async fn get_app_password_hashes_by_did(
|
||||
&self,
|
||||
did: &Did,
|
||||
) -> Result<Vec<PasswordHash>, DbError> {
|
||||
let rows = sqlx::query_scalar!(
|
||||
r#"SELECT ap.password_hash FROM app_passwords ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
@@ -515,7 +531,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows)
|
||||
Ok(rows.into_iter().map(PasswordHash::new).collect())
|
||||
}
|
||||
|
||||
async fn refresh_session_atomic(
|
||||
@@ -524,44 +540,56 @@ 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.old_refresh_jti.as_str(),
|
||||
data.session_id.as_i32()
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.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",
|
||||
data.new_access_jti,
|
||||
data.new_refresh_jti,
|
||||
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.as_str(),
|
||||
data.new_refresh_jti.as_str(),
|
||||
data.new_access_expires_at,
|
||||
data.new_refresh_expires_at,
|
||||
data.session_id.as_i32()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,16 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
testing = []
|
||||
cache-keys = ["dep:tranquil-types"]
|
||||
|
||||
[dependencies]
|
||||
tranquil-config = { workspace = true }
|
||||
tranquil-types = { workspace = true, optional = true }
|
||||
|
||||
async-trait = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
use tranquil_types::{
|
||||
CidLink, ClientId, CrossPdsState, Did, EmailTokenPurpose, Handle, Jti, JwksUri, Nsid, PdsUrl,
|
||||
SsoIssuer, SsoJwksUri,
|
||||
};
|
||||
|
||||
pub fn session_key(did: &Did, jti: &Jti) -> String {
|
||||
format!("auth:session:{}:{}", did, jti)
|
||||
}
|
||||
|
||||
pub fn signing_key_key(did: &Did) -> String {
|
||||
format!("auth:key:{}", did)
|
||||
}
|
||||
|
||||
pub fn user_status_key(did: &Did) -> String {
|
||||
format!("auth:status:{}", did)
|
||||
}
|
||||
|
||||
pub fn handle_key(handle: &Handle) -> String {
|
||||
format!("handle:{}", handle)
|
||||
}
|
||||
|
||||
pub fn reauth_key(did: &Did) -> String {
|
||||
format!("reauth:{}", did)
|
||||
}
|
||||
|
||||
pub fn plc_doc_key(did: &Did) -> String {
|
||||
format!("plc:doc:{}", did)
|
||||
}
|
||||
|
||||
pub fn plc_data_key(did: &Did) -> String {
|
||||
format!("plc:data:{}", did)
|
||||
}
|
||||
|
||||
pub fn did_web_doc_key(did: &Did) -> String {
|
||||
format!("did:web:doc:{}", did)
|
||||
}
|
||||
|
||||
pub fn email_update_key(did: &Did) -> String {
|
||||
format!("email_update:{}", did)
|
||||
}
|
||||
|
||||
pub fn email_token_key(did: &Did, purpose: EmailTokenPurpose) -> String {
|
||||
format!("email_token:{}:{}", purpose, did)
|
||||
}
|
||||
|
||||
pub fn legacy_2fa_challenge_key(did: &Did) -> String {
|
||||
format!("legacy_2fa:{}", did)
|
||||
}
|
||||
|
||||
pub fn legacy_2fa_cooldown_key(did: &Did) -> String {
|
||||
format!("legacy_2fa_cooldown:{}", did)
|
||||
}
|
||||
|
||||
pub fn scope_ref_key(cid: &CidLink) -> String {
|
||||
format!("scope_ref:{}", cid)
|
||||
}
|
||||
|
||||
pub fn auto_verify_sent_key(did: &Did) -> String {
|
||||
format!("auto_verify_sent:{}", did)
|
||||
}
|
||||
|
||||
pub fn permission_set_key(nsid: &Nsid, aud: Option<&str>) -> String {
|
||||
match aud {
|
||||
Some(a) => format!("permset:{}:{}", nsid, a),
|
||||
None => format!("permset:{}", nsid),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn oauth_client_meta_key(client_id: &ClientId) -> String {
|
||||
format!("oauth:client_meta:{}", client_id)
|
||||
}
|
||||
|
||||
pub fn oauth_client_jwks_key(jwks_uri: &JwksUri) -> String {
|
||||
format!("oauth:jwks:{}", jwks_uri.canonical())
|
||||
}
|
||||
|
||||
pub fn oauth_client_jwks_cooldown_key(jwks_uri: &JwksUri) -> String {
|
||||
format!("oauth:jwks_cooldown:{}", jwks_uri.canonical())
|
||||
}
|
||||
|
||||
pub fn sso_jwks_key(jwks_uri: &SsoJwksUri) -> String {
|
||||
format!("sso:jwks:{}", jwks_uri.canonical())
|
||||
}
|
||||
|
||||
pub fn oidc_discovery_key(issuer: &SsoIssuer) -> String {
|
||||
format!("oidc:discovery:{}", issuer.canonical())
|
||||
}
|
||||
|
||||
pub fn cross_pds_state_key(state: &CrossPdsState) -> String {
|
||||
format!("cross_pds_state:{}", state)
|
||||
}
|
||||
|
||||
pub fn cross_pds_oauth_meta_key(pds_url: &PdsUrl) -> String {
|
||||
format!("cross_pds_oauth_meta:v2:{}", pds_url.canonical())
|
||||
}
|
||||
|
||||
pub fn lexicon_doc_key(nsid: &Nsid) -> String {
|
||||
format!("lexicon:doc:{}", nsid)
|
||||
}
|
||||
|
||||
pub fn lexicon_negative_key(nsid: &Nsid) -> String {
|
||||
format!("lexicon:neg:{}", nsid)
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
#[cfg(feature = "cache-keys")]
|
||||
pub mod cache_keys;
|
||||
|
||||
#[cfg(feature = "testing")]
|
||||
mod memory_cache;
|
||||
#[cfg(feature = "testing")]
|
||||
pub use memory_cache::MemoryCache;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -57,6 +66,42 @@ pub trait Cache: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read_json<T: serde::de::DeserializeOwned>(cache: &dyn Cache, key: &str) -> Option<T> {
|
||||
let json = cache.get(key).await?;
|
||||
serde_json::from_str(&json).ok()
|
||||
}
|
||||
|
||||
pub async fn write_json<T: serde::Serialize>(
|
||||
cache: &dyn Cache,
|
||||
key: &str,
|
||||
value: &T,
|
||||
ttl: Duration,
|
||||
) {
|
||||
if let Ok(json) = serde_json::to_string(value) {
|
||||
let _ = cache.set(key, &json, ttl).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cached_json<T, E, Fut>(
|
||||
cache: &dyn Cache,
|
||||
key: &str,
|
||||
ttl: Duration,
|
||||
fetch: impl FnOnce() -> Fut,
|
||||
) -> Result<T, E>
|
||||
where
|
||||
T: serde::Serialize + serde::de::DeserializeOwned,
|
||||
Fut: Future<Output = Result<T, E>>,
|
||||
{
|
||||
match read_json(cache, key).await {
|
||||
Some(value) => Ok(value),
|
||||
None => {
|
||||
let value = fetch().await?;
|
||||
write_json(cache, key, &value, ttl).await;
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DistributedRateLimiter: Send + Sync {
|
||||
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
use crate::{Cache, CacheError};
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct Entry {
|
||||
value: Vec<u8>,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MemoryCache {
|
||||
entries: Mutex<HashMap<String, Entry>>,
|
||||
}
|
||||
|
||||
impl MemoryCache {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn read(&self, key: &str) -> Option<Vec<u8>> {
|
||||
let now = Instant::now();
|
||||
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match entries.get(key) {
|
||||
Some(entry) if entry.expires_at > now => Some(entry.value.clone()),
|
||||
Some(_) => {
|
||||
entries.remove(key);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, key: &str, value: Vec<u8>, ttl: Duration) {
|
||||
let entry = Entry {
|
||||
value,
|
||||
expires_at: Instant::now() + ttl,
|
||||
};
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(key.to_string(), entry);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Cache for MemoryCache {
|
||||
async fn get(&self, key: &str) -> Option<String> {
|
||||
self.read(key).and_then(|v| String::from_utf8(v).ok())
|
||||
}
|
||||
|
||||
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
|
||||
self.write(key, value.as_bytes().to_vec(), ttl);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<(), CacheError> {
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_bytes(&self, key: &str) -> Option<Vec<u8>> {
|
||||
self.read(key)
|
||||
}
|
||||
|
||||
async fn set_bytes(&self, key: &str, value: &[u8], ttl: Duration) -> Result<(), CacheError> {
|
||||
self.write(key, value.to_vec(), ttl);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:urlencoding"]
|
||||
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:tranquil-infra"]
|
||||
|
||||
[dependencies]
|
||||
tranquil-types = { workspace = true }
|
||||
tranquil-infra = { workspace = true, optional = true, features = ["cache-keys"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -19,9 +20,9 @@ hickory-resolver = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
parking_lot = { workspace = true, optional = true }
|
||||
tracing = { workspace = true, optional = true }
|
||||
urlencoding = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tranquil-infra = { workspace = true, features = ["testing", "cache-keys"] }
|
||||
|
||||
@@ -6,8 +6,11 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Notify;
|
||||
use tranquil_infra::cache_keys::{lexicon_doc_key, lexicon_negative_key};
|
||||
use tranquil_infra::{Cache, read_json, write_json};
|
||||
use tranquil_types::Nsid;
|
||||
|
||||
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
|
||||
const POSITIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const REFRESH_FAILURE_BACKOFF: Duration = Duration::from_secs(60);
|
||||
const MAX_DYNAMIC_SCHEMAS: usize = 1024;
|
||||
@@ -16,6 +19,13 @@ struct NegativeEntry {
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
fn negative_ttl_for(error: &ResolveError) -> Duration {
|
||||
match error.is_definitive() {
|
||||
true => NEGATIVE_CACHE_TTL,
|
||||
false => REFRESH_FAILURE_BACKOFF,
|
||||
}
|
||||
}
|
||||
|
||||
struct PositiveEntry {
|
||||
doc: Arc<LexiconDoc>,
|
||||
expires_at: Instant,
|
||||
@@ -34,20 +44,21 @@ impl CacheEntry {
|
||||
}
|
||||
|
||||
struct SchemaStore {
|
||||
schemas: HashMap<String, PositiveEntry>,
|
||||
insertion_order: VecDeque<String>,
|
||||
schemas: HashMap<Nsid, PositiveEntry>,
|
||||
insertion_order: VecDeque<Nsid>,
|
||||
}
|
||||
|
||||
pub struct DynamicRegistry {
|
||||
store: RwLock<SchemaStore>,
|
||||
negative_cache: RwLock<HashMap<String, NegativeEntry>>,
|
||||
in_flight: RwLock<HashMap<String, Arc<Notify>>>,
|
||||
negative_cache: RwLock<HashMap<Nsid, NegativeEntry>>,
|
||||
in_flight: RwLock<HashMap<Nsid, Arc<Notify>>>,
|
||||
network_disabled: AtomicBool,
|
||||
shared: RwLock<Option<Arc<dyn Cache>>>,
|
||||
}
|
||||
|
||||
struct InFlightGuard<'a> {
|
||||
registry: &'a DynamicRegistry,
|
||||
nsid: String,
|
||||
nsid: Nsid,
|
||||
}
|
||||
|
||||
impl Drop for InFlightGuard<'_> {
|
||||
@@ -69,9 +80,18 @@ impl DynamicRegistry {
|
||||
negative_cache: RwLock::new(HashMap::new()),
|
||||
in_flight: RwLock::new(HashMap::new()),
|
||||
network_disabled: AtomicBool::new(false),
|
||||
shared: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_shared_cache(&self, cache: Arc<dyn Cache>) {
|
||||
*self.shared.write() = Some(cache);
|
||||
}
|
||||
|
||||
fn shared_cache(&self) -> Option<Arc<dyn Cache>> {
|
||||
self.shared.read().clone()
|
||||
}
|
||||
|
||||
pub fn from_env() -> Self {
|
||||
let registry = Self::new();
|
||||
let disabled =
|
||||
@@ -84,7 +104,7 @@ impl DynamicRegistry {
|
||||
self.network_disabled.store(disabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn get_cached(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
|
||||
pub fn get_cached(&self, nsid: &Nsid) -> Option<Arc<LexiconDoc>> {
|
||||
self.store
|
||||
.read()
|
||||
.schemas
|
||||
@@ -92,7 +112,7 @@ impl DynamicRegistry {
|
||||
.map(|e| Arc::clone(&e.doc))
|
||||
}
|
||||
|
||||
pub(crate) fn get_entry(&self, nsid: &str) -> Option<CacheEntry> {
|
||||
pub(crate) fn get_entry(&self, nsid: &Nsid) -> Option<CacheEntry> {
|
||||
let now = Instant::now();
|
||||
self.store.read().schemas.get(nsid).map(|e| {
|
||||
if e.expires_at > now {
|
||||
@@ -103,23 +123,27 @@ impl DynamicRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_negative_cached(&self, nsid: &str) -> bool {
|
||||
let cache = self.negative_cache.read();
|
||||
cache
|
||||
.get(nsid)
|
||||
.is_some_and(|entry| entry.expires_at > Instant::now())
|
||||
pub fn is_negative_cached(&self, nsid: &Nsid) -> bool {
|
||||
self.negative_remaining(nsid).is_some()
|
||||
}
|
||||
|
||||
fn insert_negative(&self, nsid: &str) {
|
||||
fn negative_remaining(&self, nsid: &Nsid) -> Option<Duration> {
|
||||
self.negative_cache
|
||||
.read()
|
||||
.get(nsid)
|
||||
.and_then(|entry| entry.expires_at.checked_duration_since(Instant::now()))
|
||||
}
|
||||
|
||||
fn insert_negative(&self, nsid: &Nsid, ttl: Duration) {
|
||||
let mut cache = self.negative_cache.write();
|
||||
if cache.len() >= MAX_DYNAMIC_SCHEMAS {
|
||||
let now = Instant::now();
|
||||
cache.retain(|_, entry| entry.expires_at > now);
|
||||
}
|
||||
cache.insert(
|
||||
nsid.to_string(),
|
||||
nsid.clone(),
|
||||
NegativeEntry {
|
||||
expires_at: Instant::now() + NEGATIVE_CACHE_TTL,
|
||||
expires_at: Instant::now() + ttl,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -158,25 +182,63 @@ impl DynamicRegistry {
|
||||
arc
|
||||
}
|
||||
|
||||
fn bump_expiry(&self, nsid: &str, duration: Duration) {
|
||||
async fn shared_get(&self, nsid: &Nsid) -> Option<Arc<LexiconDoc>> {
|
||||
let cache = self.shared_cache()?;
|
||||
let doc = read_json::<LexiconDoc>(cache.as_ref(), &lexicon_doc_key(nsid)).await?;
|
||||
Some(self.insert_schema(doc))
|
||||
}
|
||||
|
||||
async fn shared_put(&self, doc: &LexiconDoc) {
|
||||
let Some(cache) = self.shared_cache() else {
|
||||
return;
|
||||
};
|
||||
write_json(
|
||||
cache.as_ref(),
|
||||
&lexicon_doc_key(&doc.id),
|
||||
doc,
|
||||
POSITIVE_CACHE_TTL,
|
||||
)
|
||||
.await;
|
||||
let _ = cache.delete(&lexicon_negative_key(&doc.id)).await;
|
||||
}
|
||||
|
||||
async fn shared_is_negative(&self, nsid: &Nsid) -> bool {
|
||||
match self.shared_cache() {
|
||||
Some(cache) => cache.get(&lexicon_negative_key(nsid)).await.is_some(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn shared_put_negative(&self, nsid: &Nsid, error: &ResolveError) {
|
||||
if !error.is_definitive() {
|
||||
return;
|
||||
}
|
||||
if let Some(cache) = self.shared_cache() {
|
||||
let _ = cache
|
||||
.set(&lexicon_negative_key(nsid), "1", NEGATIVE_CACHE_TTL)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn bump_expiry(&self, nsid: &Nsid, duration: Duration) {
|
||||
let mut store = self.store.write();
|
||||
if let Some(entry) = store.schemas.get_mut(nsid) {
|
||||
entry.expires_at = Instant::now() + duration;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_and_cache(&self, nsid: &str) -> Result<Arc<LexiconDoc>, ResolveError> {
|
||||
pub async fn resolve_and_cache(&self, nsid: &Nsid) -> Result<Arc<LexiconDoc>, ResolveError> {
|
||||
self.resolve_and_cache_with(nsid, |n| async move { resolve_lexicon(&n).await })
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_and_cache_with<F, Fut>(
|
||||
&self,
|
||||
nsid: &str,
|
||||
nsid: &Nsid,
|
||||
resolver: F,
|
||||
) -> Result<Arc<LexiconDoc>, ResolveError>
|
||||
where
|
||||
F: FnOnce(String) -> Fut,
|
||||
F: FnOnce(Nsid) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
|
||||
{
|
||||
match self.get_entry(nsid) {
|
||||
@@ -188,12 +250,12 @@ impl DynamicRegistry {
|
||||
|
||||
async fn refresh_stale<F, Fut>(
|
||||
&self,
|
||||
nsid: &str,
|
||||
nsid: &Nsid,
|
||||
stale: Arc<LexiconDoc>,
|
||||
resolver: F,
|
||||
) -> Result<Arc<LexiconDoc>, ResolveError>
|
||||
where
|
||||
F: FnOnce(String) -> Fut,
|
||||
F: FnOnce(Nsid) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
|
||||
{
|
||||
if self.network_disabled.load(Ordering::Relaxed) {
|
||||
@@ -201,16 +263,24 @@ impl DynamicRegistry {
|
||||
}
|
||||
|
||||
match self.acquire_leadership(nsid) {
|
||||
Some(_guard) => match resolver(nsid.to_string()).await {
|
||||
Ok(doc) => Ok(self.insert_schema(doc)),
|
||||
Some(_guard) => match resolver(nsid.clone()).await {
|
||||
Ok(doc) => {
|
||||
self.shared_put(&doc).await;
|
||||
Ok(self.insert_schema(doc))
|
||||
}
|
||||
Err(e) => {
|
||||
let (doc, source) = match self.shared_get(nsid).await {
|
||||
Some(doc) => (doc, "shared"),
|
||||
None => (stale, "local"),
|
||||
};
|
||||
self.bump_expiry(nsid, REFRESH_FAILURE_BACKOFF);
|
||||
tracing::warn!(
|
||||
nsid = nsid,
|
||||
nsid = %nsid,
|
||||
error = %e,
|
||||
"lexicon refresh failed, serving stale cached entry"
|
||||
source,
|
||||
"lexicon refresh failed, serving cached entry"
|
||||
);
|
||||
Ok(stale)
|
||||
Ok(doc)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
@@ -222,62 +292,85 @@ impl DynamicRegistry {
|
||||
|
||||
async fn resolve_fresh<F, Fut>(
|
||||
&self,
|
||||
nsid: &str,
|
||||
nsid: &Nsid,
|
||||
resolver: F,
|
||||
) -> Result<Arc<LexiconDoc>, ResolveError>
|
||||
where
|
||||
F: FnOnce(String) -> Fut,
|
||||
F: FnOnce(Nsid) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
|
||||
{
|
||||
if self.network_disabled.load(Ordering::Relaxed) {
|
||||
return Err(ResolveError::NetworkDisabled);
|
||||
if let Some(doc) = self.shared_get(nsid).await {
|
||||
return Ok(doc);
|
||||
}
|
||||
if self.is_negative_cached(nsid) {
|
||||
|
||||
if let Some(remaining) = self.negative_remaining(nsid) {
|
||||
return Err(ResolveError::NegativelyCached {
|
||||
nsid: nsid.to_string(),
|
||||
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
|
||||
nsid: nsid.clone(),
|
||||
ttl_secs: remaining.as_secs(),
|
||||
});
|
||||
}
|
||||
|
||||
if self.shared_is_negative(nsid).await {
|
||||
// Cache reports 0 remaining TTL for shared negative hit,
|
||||
// so we mirror for the backoff rather than a full `NEGATIVE_CACHE_TTL`.
|
||||
self.insert_negative(nsid, REFRESH_FAILURE_BACKOFF);
|
||||
return Err(ResolveError::NegativelyCached {
|
||||
nsid: nsid.clone(),
|
||||
ttl_secs: REFRESH_FAILURE_BACKOFF.as_secs(),
|
||||
});
|
||||
}
|
||||
|
||||
if self.network_disabled.load(Ordering::Relaxed) {
|
||||
return Err(ResolveError::NetworkDisabled);
|
||||
}
|
||||
|
||||
match self.acquire_leadership(nsid) {
|
||||
Some(_guard) => match resolver(nsid.to_string()).await {
|
||||
Ok(doc) => Ok(self.insert_schema(doc)),
|
||||
Some(_guard) => match resolver(nsid.clone()).await {
|
||||
Ok(doc) => {
|
||||
self.shared_put(&doc).await;
|
||||
Ok(self.insert_schema(doc))
|
||||
}
|
||||
Err(e) => {
|
||||
self.insert_negative(nsid);
|
||||
tracing::debug!(nsid = nsid, error = %e, "caching negative resolution result");
|
||||
let ttl = negative_ttl_for(&e);
|
||||
self.insert_negative(nsid, ttl);
|
||||
self.shared_put_negative(nsid, &e).await;
|
||||
tracing::debug!(
|
||||
nsid = %nsid,
|
||||
error = %e,
|
||||
ttl_secs = ttl.as_secs(),
|
||||
"caching negative resolution result"
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
self.wait_for_leader(nsid).await;
|
||||
match self.get_cached(nsid) {
|
||||
Some(doc) => Ok(doc),
|
||||
None if self.is_negative_cached(nsid) => Err(ResolveError::NegativelyCached {
|
||||
nsid: nsid.to_string(),
|
||||
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
|
||||
}),
|
||||
None => Err(ResolveError::LeaderAborted {
|
||||
nsid: nsid.to_string(),
|
||||
match (self.get_cached(nsid), self.negative_remaining(nsid)) {
|
||||
(Some(doc), _) => Ok(doc),
|
||||
(None, Some(remaining)) => Err(ResolveError::NegativelyCached {
|
||||
nsid: nsid.clone(),
|
||||
ttl_secs: remaining.as_secs(),
|
||||
}),
|
||||
(None, None) => Err(ResolveError::LeaderAborted { nsid: nsid.clone() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire_leadership(&self, nsid: &str) -> Option<InFlightGuard<'_>> {
|
||||
fn acquire_leadership(&self, nsid: &Nsid) -> Option<InFlightGuard<'_>> {
|
||||
let mut map = self.in_flight.write();
|
||||
if map.contains_key(nsid) {
|
||||
None
|
||||
} else {
|
||||
map.insert(nsid.to_string(), Arc::new(Notify::new()));
|
||||
map.insert(nsid.clone(), Arc::new(Notify::new()));
|
||||
Some(InFlightGuard {
|
||||
registry: self,
|
||||
nsid: nsid.to_string(),
|
||||
nsid: nsid.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_leader(&self, nsid: &str) {
|
||||
async fn wait_for_leader(&self, nsid: &Nsid) {
|
||||
let notify = {
|
||||
let map = self.in_flight.read();
|
||||
match map.get(nsid) {
|
||||
@@ -300,7 +393,7 @@ impl DynamicRegistry {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn expire_now(&self, nsid: &str) {
|
||||
fn expire_now(&self, nsid: &Nsid) {
|
||||
let mut store = self.store.write();
|
||||
if let Some(entry) = store.schemas.get_mut(nsid) {
|
||||
entry.expires_at = Instant::now();
|
||||
@@ -317,29 +410,34 @@ impl Default for DynamicRegistry {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tranquil_infra::MemoryCache;
|
||||
|
||||
fn nsid(s: &str) -> Nsid {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negative_cache() {
|
||||
let registry = DynamicRegistry::new();
|
||||
assert!(!registry.is_negative_cached("com.example.test"));
|
||||
assert!(!registry.is_negative_cached(&nsid("pet.nel.negative")));
|
||||
|
||||
registry.insert_negative("com.example.test");
|
||||
assert!(registry.is_negative_cached("com.example.test"));
|
||||
registry.insert_negative(&nsid("pet.nel.negative"), NEGATIVE_CACHE_TTL);
|
||||
assert!(registry.is_negative_cached(&nsid("pet.nel.negative")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_negative_cache_returns_appropriate_error_variant() {
|
||||
let registry = DynamicRegistry::new();
|
||||
registry.insert_negative("com.example.cached");
|
||||
registry.insert_negative(&nsid("pet.nel.cached"), NEGATIVE_CACHE_TTL);
|
||||
|
||||
let err = registry
|
||||
.resolve_and_cache("com.example.cached")
|
||||
.resolve_and_cache(&nsid("pet.nel.cached"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
!matches!(err, ResolveError::InvalidNsid(_)),
|
||||
"negative cache hit should not return InvalidNsid - the NSID is valid, it just failed resolution recently. got: {}",
|
||||
matches!(err, ResolveError::NegativelyCached { .. }),
|
||||
"negative cache hit must surface as NegativelyCached, got: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
@@ -347,7 +445,11 @@ mod tests {
|
||||
#[test]
|
||||
fn test_empty_lookup() {
|
||||
let registry = DynamicRegistry::new();
|
||||
assert!(registry.get_cached("com.example.nonexistent").is_none());
|
||||
assert!(
|
||||
registry
|
||||
.get_cached(&nsid("com.example.nonexistent"))
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(registry.schema_count(), 0);
|
||||
}
|
||||
|
||||
@@ -356,7 +458,7 @@ mod tests {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.test".to_string(),
|
||||
id: nsid("com.example.test"),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
|
||||
@@ -364,11 +466,11 @@ mod tests {
|
||||
assert_eq!(arc.id, "com.example.test");
|
||||
assert_eq!(registry.schema_count(), 1);
|
||||
|
||||
let retrieved = registry.get_cached("com.example.test");
|
||||
let retrieved = registry.get_cached(&nsid("com.example.test"));
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().id, "com.example.test");
|
||||
|
||||
let entry = registry.get_entry("com.example.test").unwrap();
|
||||
let entry = registry.get_entry(&nsid("com.example.test")).unwrap();
|
||||
assert!(entry.is_fresh(), "freshly inserted entry must be fresh");
|
||||
}
|
||||
|
||||
@@ -376,17 +478,17 @@ mod tests {
|
||||
fn test_negative_cache_cleared_on_insert() {
|
||||
let registry = DynamicRegistry::new();
|
||||
|
||||
registry.insert_negative("com.example.test");
|
||||
assert!(registry.is_negative_cached("com.example.test"));
|
||||
registry.insert_negative(&nsid("pet.nel.cleared"), NEGATIVE_CACHE_TTL);
|
||||
assert!(registry.is_negative_cached(&nsid("pet.nel.cleared")));
|
||||
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.test".to_string(),
|
||||
id: nsid("pet.nel.cleared"),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
|
||||
assert!(!registry.is_negative_cached("com.example.test"));
|
||||
assert!(!registry.is_negative_cached(&nsid("pet.nel.cleared")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -394,17 +496,25 @@ mod tests {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "pet.nel.stale".to_string(),
|
||||
id: nsid("pet.nel.stale"),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
|
||||
assert!(registry.get_entry("pet.nel.stale").unwrap().is_fresh());
|
||||
assert!(
|
||||
registry
|
||||
.get_entry(&nsid("pet.nel.stale"))
|
||||
.unwrap()
|
||||
.is_fresh()
|
||||
);
|
||||
|
||||
registry.expire_now("pet.nel.stale");
|
||||
registry.expire_now(&nsid("pet.nel.stale"));
|
||||
|
||||
assert!(
|
||||
!registry.get_entry("pet.nel.stale").unwrap().is_fresh(),
|
||||
!registry
|
||||
.get_entry(&nsid("pet.nel.stale"))
|
||||
.unwrap()
|
||||
.is_fresh(),
|
||||
"entry past expiry must be reported stale"
|
||||
);
|
||||
}
|
||||
@@ -414,16 +524,16 @@ mod tests {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "pet.nel.flaky".to_string(),
|
||||
id: nsid("pet.nel.flaky"),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
registry.expire_now("pet.nel.flaky");
|
||||
registry.expire_now(&nsid("pet.nel.flaky"));
|
||||
|
||||
let result = registry
|
||||
.resolve_and_cache_with("pet.nel.flaky", |n| async move {
|
||||
.resolve_and_cache_with(&nsid("pet.nel.flaky"), |n| async move {
|
||||
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
|
||||
domain: n,
|
||||
domain: n.into_inner(),
|
||||
reason: "simulated failure".to_string(),
|
||||
})
|
||||
})
|
||||
@@ -432,11 +542,14 @@ mod tests {
|
||||
let served = result.expect("stale entry must be served when refresh fails");
|
||||
assert_eq!(served.id, "pet.nel.flaky");
|
||||
assert!(
|
||||
registry.get_entry("pet.nel.flaky").unwrap().is_fresh(),
|
||||
registry
|
||||
.get_entry(&nsid("pet.nel.flaky"))
|
||||
.unwrap()
|
||||
.is_fresh(),
|
||||
"failed refresh must bump expiry so subsequent lookups skip the resolver"
|
||||
);
|
||||
assert!(
|
||||
!registry.is_negative_cached("pet.nel.flaky"),
|
||||
!registry.is_negative_cached(&nsid("pet.nel.flaky")),
|
||||
"stale refresh failure must not poison negative cache"
|
||||
);
|
||||
}
|
||||
@@ -446,13 +559,13 @@ mod tests {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "pet.nel.fresh".to_string(),
|
||||
id: nsid("pet.nel.fresh"),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
|
||||
let result = registry
|
||||
.resolve_and_cache_with("pet.nel.fresh", |_| async move {
|
||||
.resolve_and_cache_with(&nsid("pet.nel.fresh"), |_| async move {
|
||||
panic!("resolver must not run on fresh hit")
|
||||
})
|
||||
.await;
|
||||
@@ -465,15 +578,15 @@ mod tests {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "pet.nel.offline".to_string(),
|
||||
id: nsid("pet.nel.offline"),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
registry.expire_now("pet.nel.offline");
|
||||
registry.expire_now(&nsid("pet.nel.offline"));
|
||||
registry.set_network_disabled(true);
|
||||
|
||||
let result = registry
|
||||
.resolve_and_cache_with("pet.nel.offline", |_| async move {
|
||||
.resolve_and_cache_with(&nsid("pet.nel.offline"), |_| async move {
|
||||
panic!("resolver must not run when network disabled")
|
||||
})
|
||||
.await;
|
||||
@@ -486,16 +599,21 @@ mod tests {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "pet.nel.refresh".to_string(),
|
||||
id: nsid("pet.nel.refresh"),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
registry.expire_now("pet.nel.refresh");
|
||||
registry.expire_now(&nsid("pet.nel.refresh"));
|
||||
|
||||
assert!(!registry.get_entry("pet.nel.refresh").unwrap().is_fresh());
|
||||
assert!(
|
||||
!registry
|
||||
.get_entry(&nsid("pet.nel.refresh"))
|
||||
.unwrap()
|
||||
.is_fresh()
|
||||
);
|
||||
|
||||
let refreshed = registry
|
||||
.resolve_and_cache_with("pet.nel.refresh", |n| async move {
|
||||
.resolve_and_cache_with(&nsid("pet.nel.refresh"), |n| async move {
|
||||
Ok(LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: n,
|
||||
@@ -507,7 +625,10 @@ mod tests {
|
||||
|
||||
assert_eq!(refreshed.id, "pet.nel.refresh");
|
||||
assert!(
|
||||
registry.get_entry("pet.nel.refresh").unwrap().is_fresh(),
|
||||
registry
|
||||
.get_entry(&nsid("pet.nel.refresh"))
|
||||
.unwrap()
|
||||
.is_fresh(),
|
||||
"refresh must restore freshness"
|
||||
);
|
||||
}
|
||||
@@ -524,7 +645,7 @@ mod tests {
|
||||
let calls = Arc::clone(&calls);
|
||||
tokio::spawn(async move {
|
||||
registry
|
||||
.resolve_and_cache_with("pet.nel.herd", |n| {
|
||||
.resolve_and_cache_with(&nsid("pet.nel.herd"), |n| {
|
||||
let calls = Arc::clone(&calls);
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
@@ -565,13 +686,13 @@ mod tests {
|
||||
let calls = Arc::clone(&calls);
|
||||
tokio::spawn(async move {
|
||||
registry
|
||||
.resolve_and_cache_with("pet.nel.failHerd", |n| {
|
||||
.resolve_and_cache_with(&nsid("pet.nel.failHerd"), |n| {
|
||||
let calls = Arc::clone(&calls);
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
|
||||
domain: n,
|
||||
domain: n.into_inner(),
|
||||
reason: "simulated".to_string(),
|
||||
})
|
||||
}
|
||||
@@ -590,7 +711,7 @@ mod tests {
|
||||
1,
|
||||
"single-flight must coalesce failing resolves too"
|
||||
);
|
||||
assert!(registry.is_negative_cached("pet.nel.failHerd"));
|
||||
assert!(registry.is_negative_cached(&nsid("pet.nel.failHerd")));
|
||||
}
|
||||
|
||||
async fn futures_collect<T>(handles: Vec<tokio::task::JoinHandle<T>>) -> Vec<T> {
|
||||
@@ -608,7 +729,7 @@ mod tests {
|
||||
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: format!("pet.nel.schema{}", i),
|
||||
id: nsid(&format!("pet.nel.schema{}", i)),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
@@ -617,23 +738,23 @@ mod tests {
|
||||
|
||||
let trigger = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "pet.nel.trigger".to_string(),
|
||||
id: nsid("pet.nel.trigger"),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(trigger);
|
||||
|
||||
assert!(
|
||||
registry.get_cached("pet.nel.schema0").is_none(),
|
||||
registry.get_cached(&nsid("pet.nel.schema0")).is_none(),
|
||||
"oldest entry should be evicted"
|
||||
);
|
||||
assert!(
|
||||
registry.get_cached("pet.nel.trigger").is_some(),
|
||||
registry.get_cached(&nsid("pet.nel.trigger")).is_some(),
|
||||
"newly inserted entry should exist"
|
||||
);
|
||||
let evict_count = MAX_DYNAMIC_SCHEMAS / 4;
|
||||
assert!(
|
||||
registry
|
||||
.get_cached(&format!("pet.nel.schema{}", evict_count))
|
||||
.get_cached(&nsid(&format!("pet.nel.schema{}", evict_count)))
|
||||
.is_some(),
|
||||
"entry after eviction window should survive"
|
||||
);
|
||||
@@ -644,7 +765,7 @@ mod tests {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "pet.nel.tracked".to_string(),
|
||||
id: nsid("pet.nel.tracked"),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
let arc = registry.insert_schema(doc);
|
||||
@@ -656,7 +777,7 @@ mod tests {
|
||||
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
|
||||
registry.insert_schema(LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: format!("pet.nel.filler{}", i),
|
||||
id: nsid(&format!("pet.nel.filler{}", i)),
|
||||
defs: HashMap::new(),
|
||||
});
|
||||
});
|
||||
@@ -666,4 +787,95 @@ mod tests {
|
||||
"evicted Arc should be freed when no external references remain"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_shared_positive_hit_skips_resolver() {
|
||||
let registry = DynamicRegistry::new();
|
||||
let cache = Arc::new(MemoryCache::new());
|
||||
registry.set_shared_cache(cache.clone());
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: nsid("pet.nel.sharedDoc"),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
cache
|
||||
.set(
|
||||
&lexicon_doc_key(&nsid("pet.nel.sharedDoc")),
|
||||
&serde_json::to_string(&doc).unwrap(),
|
||||
POSITIVE_CACHE_TTL,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resolved = registry
|
||||
.resolve_and_cache_with(&nsid("pet.nel.sharedDoc"), |_| async move {
|
||||
panic!("resolver mustn't run on a shared positive hit")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.id, "pet.nel.sharedDoc");
|
||||
assert!(registry.get_cached(&nsid("pet.nel.sharedDoc")).is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_definitive_failure_writes_shared_negative_and_peers_mirror_it() {
|
||||
let cache = Arc::new(MemoryCache::new());
|
||||
let registry = DynamicRegistry::new();
|
||||
registry.set_shared_cache(cache.clone());
|
||||
|
||||
let _ = registry
|
||||
.resolve_and_cache_with(&nsid("pet.nel.gone"), |n| async move {
|
||||
Err::<LexiconDoc, _>(ResolveError::SchemaNotFound {
|
||||
nsid: n,
|
||||
url: "https://oyster.cafe".to_string(),
|
||||
})
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
cache
|
||||
.get(&lexicon_negative_key(&nsid("pet.nel.gone")))
|
||||
.await
|
||||
.is_some(),
|
||||
"definitive failure must write the shared negative key"
|
||||
);
|
||||
|
||||
let _ = registry
|
||||
.resolve_and_cache_with(&nsid("pet.nel.transient"), |n| async move {
|
||||
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
|
||||
domain: n.into_inner(),
|
||||
reason: "simulated".to_string(),
|
||||
})
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
cache
|
||||
.get(&lexicon_negative_key(&nsid("pet.nel.transient")))
|
||||
.await
|
||||
.is_none(),
|
||||
"transient failure must stay out of the shared negative key"
|
||||
);
|
||||
|
||||
let peer = DynamicRegistry::new();
|
||||
peer.set_shared_cache(cache);
|
||||
let err = peer
|
||||
.resolve_and_cache_with(&nsid("pet.nel.gone"), |_| async move {
|
||||
panic!("resolver mustn't run on a shared negative hit")
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
ResolveError::NegativelyCached { ttl_secs, .. } => assert!(
|
||||
ttl_secs <= REFRESH_FAILURE_BACKOFF.as_secs(),
|
||||
"local mirror must use the backoff TTL, got {}s",
|
||||
ttl_secs
|
||||
),
|
||||
other => panic!("expected NegativelyCached, got: {}", other),
|
||||
}
|
||||
assert!(
|
||||
peer.negative_remaining(&nsid("pet.nel.gone"))
|
||||
.expect("local mirror exists")
|
||||
<= REFRESH_FAILURE_BACKOFF
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use crate::schema::{LexDef, LexObject, LexiconDoc, ParsedRef, parse_ref};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tranquil_types::Nsid;
|
||||
|
||||
static REGISTRY: OnceLock<LexiconRegistry> = OnceLock::new();
|
||||
|
||||
pub struct LexiconRegistry {
|
||||
schemas: HashMap<String, Arc<LexiconDoc>>,
|
||||
schemas: HashMap<Nsid, Arc<LexiconDoc>>,
|
||||
#[cfg(feature = "resolve")]
|
||||
dynamic: crate::dynamic::DynamicRegistry,
|
||||
}
|
||||
@@ -39,11 +40,17 @@ impl LexiconRegistry {
|
||||
self.dynamic.insert_schema(doc);
|
||||
}
|
||||
|
||||
pub fn get_doc(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
|
||||
self.schemas.get(nsid).cloned().or_else(|| {
|
||||
pub fn get_doc(&self, nsid: &Nsid) -> Option<Arc<LexiconDoc>> {
|
||||
self.get_doc_by_key(nsid.as_str())
|
||||
}
|
||||
|
||||
fn get_doc_by_key(&self, key: &str) -> Option<Arc<LexiconDoc>> {
|
||||
self.schemas.get(key).cloned().or_else(|| {
|
||||
#[cfg(feature = "resolve")]
|
||||
{
|
||||
self.dynamic.get_cached(nsid)
|
||||
Nsid::new(key)
|
||||
.ok()
|
||||
.and_then(|nsid| self.dynamic.get_cached(&nsid))
|
||||
}
|
||||
#[cfg(not(feature = "resolve"))]
|
||||
{
|
||||
@@ -52,7 +59,7 @@ impl LexiconRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_record_def(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
|
||||
pub fn get_record_def(&self, nsid: &Nsid) -> Option<Arc<LexiconDoc>> {
|
||||
let doc = self.get_doc(nsid)?;
|
||||
match doc.defs.get("main")? {
|
||||
LexDef::Record(_) => Some(doc),
|
||||
@@ -63,15 +70,15 @@ impl LexiconRegistry {
|
||||
pub fn resolve_ref(&self, reference: &str, context_nsid: &str) -> Option<ResolvedRef> {
|
||||
match parse_ref(reference) {
|
||||
ParsedRef::Local(local) => {
|
||||
let doc = self.get_doc(context_nsid)?;
|
||||
let doc = self.get_doc_by_key(context_nsid)?;
|
||||
Self::def_to_resolved(&doc, local)
|
||||
}
|
||||
ParsedRef::Qualified { nsid, fragment } => {
|
||||
let doc = self.get_doc(nsid)?;
|
||||
let doc = self.get_doc_by_key(nsid)?;
|
||||
Self::def_to_resolved(&doc, fragment)
|
||||
}
|
||||
ParsedRef::Bare(nsid) => {
|
||||
let doc = self.get_doc(nsid)?;
|
||||
let doc = self.get_doc_by_key(nsid)?;
|
||||
Self::def_to_resolved(&doc, "main")
|
||||
}
|
||||
}
|
||||
@@ -90,7 +97,7 @@ impl LexiconRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_schema(&self, nsid: &str) -> bool {
|
||||
pub fn has_schema(&self, nsid: &Nsid) -> bool {
|
||||
self.get_doc(nsid).is_some()
|
||||
}
|
||||
|
||||
@@ -109,15 +116,20 @@ impl LexiconRegistry {
|
||||
#[cfg(feature = "resolve")]
|
||||
pub async fn resolve_dynamic(
|
||||
&self,
|
||||
nsid: &str,
|
||||
nsid: &Nsid,
|
||||
) -> Result<Arc<LexiconDoc>, crate::resolve::ResolveError> {
|
||||
self.dynamic.resolve_and_cache(nsid).await
|
||||
}
|
||||
|
||||
#[cfg(feature = "resolve")]
|
||||
pub fn is_negative_cached(&self, nsid: &str) -> bool {
|
||||
pub fn is_negative_cached(&self, nsid: &Nsid) -> bool {
|
||||
self.dynamic.is_negative_cached(nsid)
|
||||
}
|
||||
|
||||
#[cfg(feature = "resolve")]
|
||||
pub fn set_shared_cache(&self, cache: Arc<dyn tranquil_infra::Cache>) {
|
||||
self.dynamic.set_shared_cache(cache);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResolvedRef {
|
||||
@@ -146,11 +158,15 @@ impl ResolvedRef {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn nsid(s: &str) -> Nsid {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_registry() {
|
||||
let registry = LexiconRegistry::new();
|
||||
assert_eq!(registry.schema_count(), 0);
|
||||
assert!(!registry.has_schema("app.bsky.feed.post"));
|
||||
assert!(!registry.has_schema(&nsid("app.bsky.feed.post")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -158,19 +174,19 @@ mod tests {
|
||||
let mut registry = LexiconRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.test".to_string(),
|
||||
id: nsid("com.example.test"),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.register(doc);
|
||||
assert_eq!(registry.schema_count(), 1);
|
||||
assert!(registry.has_schema("com.example.test"));
|
||||
assert!(!registry.has_schema("com.example.other"));
|
||||
assert!(registry.has_schema(&nsid("com.example.test")));
|
||||
assert!(!registry.has_schema(&nsid("com.example.other")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_record_def() {
|
||||
let registry = crate::test_schemas::test_registry();
|
||||
let doc = registry.get_record_def("com.test.basic");
|
||||
let doc = registry.get_record_def(&nsid("com.test.basic"));
|
||||
assert!(doc.is_some());
|
||||
let doc = doc.unwrap();
|
||||
match doc.defs.get("main").unwrap() {
|
||||
@@ -185,7 +201,11 @@ mod tests {
|
||||
#[test]
|
||||
fn test_get_record_def_unknown() {
|
||||
let registry = LexiconRegistry::new();
|
||||
assert!(registry.get_record_def("com.example.nonexistent").is_none());
|
||||
assert!(
|
||||
registry
|
||||
.get_record_def(&nsid("com.example.nonexistent"))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -205,7 +225,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_has_schema() {
|
||||
let registry = crate::test_schemas::test_registry();
|
||||
assert!(registry.has_schema("com.test.basic"));
|
||||
assert!(!registry.has_schema("com.example.nonexistent"));
|
||||
assert!(registry.has_schema(&nsid("com.test.basic")));
|
||||
assert!(!registry.has_schema(&nsid("com.example.nonexistent")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
||||
use reqwest::Client;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use tranquil_types::did_doc::extract_pds_endpoint;
|
||||
use tranquil_types::{
|
||||
Did, Nsid, SchemaHostUrl, UrlKind, dns_guard, redirect_policy, url_kind, url_reach_permits,
|
||||
};
|
||||
|
||||
static RESOLVER_CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
|
||||
@@ -16,7 +20,8 @@ fn client() -> &'static Client {
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.pool_max_idle_per_host(4)
|
||||
.pool_idle_timeout(Duration::from_secs(60))
|
||||
.redirect(reqwest::redirect::Policy::limited(3))
|
||||
.redirect(redirect_policy(url_kind::SchemaHost::REACH_POLICY))
|
||||
.dns_resolver(dns_guard(url_kind::SchemaHost::REACH_POLICY))
|
||||
.build()
|
||||
.expect("failed to build lexicon resolver HTTP client")
|
||||
})
|
||||
@@ -52,51 +57,67 @@ async fn read_body_limited(resp: reqwest::Response, max_bytes: usize) -> Result<
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ResolveError {
|
||||
#[error("failed to derive authority from NSID: {0}")]
|
||||
InvalidNsid(String),
|
||||
#[error("DNS lookup failed for {domain}: {reason}")]
|
||||
DnsLookup { domain: String, reason: String },
|
||||
#[error("no DID found in DNS TXT records for {domain}")]
|
||||
NoDid { domain: String },
|
||||
#[error("DID document fetch failed for {did}: {reason}")]
|
||||
DidResolution { did: String, reason: String },
|
||||
DidResolution { did: Did, reason: String },
|
||||
#[error("no PDS endpoint found in DID document for {did}")]
|
||||
NoPdsEndpoint { did: String },
|
||||
NoPdsEndpoint { did: Did },
|
||||
#[error("schema fetch failed from {url}: {reason}")]
|
||||
SchemaFetch { url: String, reason: String },
|
||||
#[error("no schema record for {nsid} at {url}")]
|
||||
SchemaNotFound { nsid: Nsid, url: String },
|
||||
#[error("schema deserialization failed: {0}")]
|
||||
InvalidSchema(String),
|
||||
#[error("schema resolution recently failed for {nsid}, cached for {ttl_secs}s")]
|
||||
NegativelyCached { nsid: String, ttl_secs: u64 },
|
||||
NegativelyCached { nsid: Nsid, ttl_secs: u64 },
|
||||
#[error("network resolution disabled")]
|
||||
NetworkDisabled,
|
||||
#[error("leader task for {nsid} aborted before completion")]
|
||||
LeaderAborted { nsid: String },
|
||||
LeaderAborted { nsid: Nsid },
|
||||
}
|
||||
|
||||
pub fn nsid_to_authority(nsid: &str) -> Result<String, ResolveError> {
|
||||
let mut segments: Vec<&str> = nsid.split('.').collect();
|
||||
if segments.len() < 3 {
|
||||
return Err(ResolveError::InvalidNsid(nsid.to_string()));
|
||||
impl ResolveError {
|
||||
pub fn is_definitive(&self) -> bool {
|
||||
match self {
|
||||
Self::NoDid { .. }
|
||||
| Self::NoPdsEndpoint { .. }
|
||||
| Self::InvalidSchema(_)
|
||||
| Self::SchemaNotFound { .. } => true,
|
||||
Self::DnsLookup { .. }
|
||||
| Self::DidResolution { .. }
|
||||
| Self::SchemaFetch { .. }
|
||||
| Self::NegativelyCached { .. }
|
||||
| Self::NetworkDisabled
|
||||
| Self::LeaderAborted { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn nsid_to_authority(nsid: &Nsid) -> String {
|
||||
let mut segments: Vec<&str> = nsid.split('.').collect();
|
||||
segments.pop();
|
||||
segments.reverse();
|
||||
Ok(segments.join("."))
|
||||
segments.join(".")
|
||||
}
|
||||
|
||||
pub async fn resolve_did_from_dns(authority: &str) -> Result<String, ResolveError> {
|
||||
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
|
||||
pub async fn resolve_did_from_dns(authority: &str) -> Result<Did, ResolveError> {
|
||||
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> {
|
||||
let extract_did = |lookup: hickory_resolver::lookup::TxtLookup| -> Option<Did> {
|
||||
lookup
|
||||
.iter()
|
||||
.flat_map(|record| record.txt_data())
|
||||
.find_map(|txt| {
|
||||
let txt_str = String::from_utf8_lossy(txt);
|
||||
txt_str.strip_prefix("did=").and_then(|did| {
|
||||
let did = did.trim();
|
||||
did.starts_with("did:").then(|| did.to_string())
|
||||
})
|
||||
txt_str
|
||||
.strip_prefix("did=")
|
||||
.and_then(|did| Did::new(did.trim()).ok())
|
||||
})
|
||||
};
|
||||
|
||||
@@ -123,9 +144,9 @@ pub async fn resolve_did_from_dns(authority: &str) -> Result<String, ResolveErro
|
||||
}
|
||||
|
||||
pub async fn resolve_pds_endpoint(
|
||||
did: &str,
|
||||
did: &Did,
|
||||
plc_directory_url: Option<&str>,
|
||||
) -> Result<String, ResolveError> {
|
||||
) -> Result<SchemaHostUrl, ResolveError> {
|
||||
let plc_base = plc_directory_url.unwrap_or(DEFAULT_PLC_DIRECTORY);
|
||||
|
||||
let url = match did
|
||||
@@ -133,10 +154,23 @@ pub async fn resolve_pds_endpoint(
|
||||
.and_then(|(_, rest)| rest.split_once(':'))
|
||||
{
|
||||
Some(("plc", _)) => format!("{}/{}", plc_base.trim_end_matches('/'), did),
|
||||
Some(("web", domain)) => format!("https://{}/.well-known/did.json", domain),
|
||||
Some(("web", domain)) => {
|
||||
let url = format!("https://{}/.well-known/did.json", domain);
|
||||
let permitted = reqwest::Url::parse(&url)
|
||||
.is_ok_and(|u| url_reach_permits(&u, url_kind::SchemaHost::REACH_POLICY));
|
||||
match permitted {
|
||||
true => url,
|
||||
false => {
|
||||
return Err(ResolveError::DidResolution {
|
||||
did: did.clone(),
|
||||
reason: "did:web host is outside the allowed host reach".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(ResolveError::DidResolution {
|
||||
did: did.to_string(),
|
||||
did: did.clone(),
|
||||
reason: "unsupported DID method".to_string(),
|
||||
});
|
||||
}
|
||||
@@ -147,58 +181,46 @@ pub async fn resolve_pds_endpoint(
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ResolveError::DidResolution {
|
||||
did: did.to_string(),
|
||||
did: did.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
|
||||
.await
|
||||
.map_err(|reason| ResolveError::DidResolution {
|
||||
did: did.to_string(),
|
||||
did: did.clone(),
|
||||
reason,
|
||||
})?;
|
||||
|
||||
let doc: serde_json::Value =
|
||||
serde_json::from_slice(&body).map_err(|e| ResolveError::DidResolution {
|
||||
did: did.to_string(),
|
||||
did: did.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
extract_pds_endpoint(&doc).ok_or(ResolveError::NoPdsEndpoint {
|
||||
did: did.to_string(),
|
||||
})
|
||||
extract_pds_endpoint(&doc).map_err(|_| ResolveError::NoPdsEndpoint { did: did.clone() })
|
||||
}
|
||||
|
||||
fn extract_pds_endpoint(doc: &serde_json::Value) -> Option<String> {
|
||||
doc.get("service")
|
||||
.and_then(|s| s.as_array())
|
||||
.and_then(|services| {
|
||||
services.iter().find_map(|svc| {
|
||||
let is_pds = svc
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.is_some_and(|t| t == "AtprotoPersonalDataServer");
|
||||
is_pds
|
||||
.then(|| svc.get("serviceEndpoint").and_then(|ep| ep.as_str()))?
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
})
|
||||
fn is_record_absent(xrpc_error: &str, xrpc_message: &str) -> bool {
|
||||
xrpc_error == "RecordNotFound"
|
||||
|| xrpc_error == "InvalidRequest" && xrpc_message.starts_with("Could not locate record")
|
||||
}
|
||||
|
||||
pub async fn fetch_schema_from_pds(
|
||||
pds_endpoint: &str,
|
||||
did: &str,
|
||||
nsid: &str,
|
||||
pds_endpoint: &SchemaHostUrl,
|
||||
did: &Did,
|
||||
nsid: &Nsid,
|
||||
) -> Result<LexiconDoc, ResolveError> {
|
||||
let url = format!(
|
||||
"{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=com.atproto.lexicon.schema&rkey={}",
|
||||
pds_endpoint.trim_end_matches('/'),
|
||||
urlencoding::encode(did),
|
||||
urlencoding::encode(nsid)
|
||||
);
|
||||
let mut request_url = pds_endpoint.endpoint("xrpc/com.atproto.repo.getRecord");
|
||||
request_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("repo", did.as_str())
|
||||
.append_pair("collection", "com.atproto.lexicon.schema")
|
||||
.append_pair("rkey", nsid.as_str());
|
||||
let url = request_url.to_string();
|
||||
|
||||
let resp = client()
|
||||
.get(&url)
|
||||
.get(request_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ResolveError::SchemaFetch {
|
||||
@@ -208,10 +230,27 @@ pub async fn fetch_schema_from_pds(
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(ResolveError::SchemaFetch {
|
||||
url,
|
||||
reason: format!("HTTP {}", status),
|
||||
});
|
||||
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let field = |name: &str| {
|
||||
body.get(name)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
};
|
||||
return match is_record_absent(&field("error"), &field("message")) {
|
||||
true => Err(ResolveError::SchemaNotFound {
|
||||
nsid: nsid.clone(),
|
||||
url,
|
||||
}),
|
||||
false => Err(ResolveError::SchemaFetch {
|
||||
url,
|
||||
reason: format!("HTTP {}", status),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
|
||||
@@ -238,8 +277,8 @@ pub async fn fetch_schema_from_pds(
|
||||
.map_err(|e| ResolveError::InvalidSchema(e.to_string()))
|
||||
}
|
||||
|
||||
fn validate_fetched_schema(doc: &LexiconDoc, nsid: &str) -> Result<(), ResolveError> {
|
||||
if doc.id != nsid {
|
||||
fn validate_fetched_schema(doc: &LexiconDoc, nsid: &Nsid) -> Result<(), ResolveError> {
|
||||
if doc.id != *nsid {
|
||||
return Err(ResolveError::InvalidSchema(format!(
|
||||
"schema id '{}' does not match requested NSID '{}'",
|
||||
doc.id, nsid
|
||||
@@ -254,22 +293,22 @@ fn validate_fetched_schema(doc: &LexiconDoc, nsid: &str) -> Result<(), ResolveEr
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resolve_lexicon(nsid: &str) -> Result<LexiconDoc, ResolveError> {
|
||||
pub async fn resolve_lexicon(nsid: &Nsid) -> Result<LexiconDoc, ResolveError> {
|
||||
resolve_lexicon_with_config(nsid, None).await
|
||||
}
|
||||
|
||||
pub async fn resolve_lexicon_with_config(
|
||||
nsid: &str,
|
||||
nsid: &Nsid,
|
||||
plc_directory_url: Option<&str>,
|
||||
) -> Result<LexiconDoc, ResolveError> {
|
||||
let authority = nsid_to_authority(nsid)?;
|
||||
tracing::debug!(nsid = nsid, authority = %authority, "resolving lexicon schema");
|
||||
let authority = nsid_to_authority(nsid);
|
||||
tracing::debug!(nsid = %nsid, authority = %authority, "resolving lexicon schema");
|
||||
|
||||
let did = resolve_did_from_dns(&authority).await?;
|
||||
tracing::debug!(nsid = nsid, did = %did, "resolved authority DID");
|
||||
tracing::debug!(nsid = %nsid, did = %did, "resolved authority DID");
|
||||
|
||||
let pds_endpoint = resolve_pds_endpoint(&did, plc_directory_url).await?;
|
||||
tracing::debug!(nsid = nsid, pds = %pds_endpoint, "resolved PDS endpoint");
|
||||
tracing::debug!(nsid = %nsid, pds = %pds_endpoint, "resolved PDS endpoint");
|
||||
|
||||
let doc = fetch_schema_from_pds(&pds_endpoint, &did, nsid).await?;
|
||||
validate_fetched_schema(&doc, nsid)?;
|
||||
@@ -278,8 +317,8 @@ pub async fn resolve_lexicon_with_config(
|
||||
}
|
||||
|
||||
pub async fn resolve_lexicon_from_did(
|
||||
nsid: &str,
|
||||
did: &str,
|
||||
nsid: &Nsid,
|
||||
did: &Did,
|
||||
plc_directory_url: Option<&str>,
|
||||
) -> Result<LexiconDoc, ResolveError> {
|
||||
let pds_endpoint = resolve_pds_endpoint(did, plc_directory_url).await?;
|
||||
@@ -292,100 +331,73 @@ pub async fn resolve_lexicon_from_did(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn nsid(s: &str) -> Nsid {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_record_absent_recognizes_only_the_reference_pds_absence_shapes() {
|
||||
assert!(is_record_absent(
|
||||
"RecordNotFound",
|
||||
"Could not locate record: at://did:plc:nel/com.atproto.lexicon.schema/x"
|
||||
));
|
||||
assert!(is_record_absent("RecordNotFound", ""));
|
||||
assert!(is_record_absent(
|
||||
"InvalidRequest",
|
||||
"Could not locate record"
|
||||
));
|
||||
assert!(!is_record_absent(
|
||||
"InvalidRequest",
|
||||
"Error: rkey must be a valid record key"
|
||||
));
|
||||
assert!(!is_record_absent("InvalidRequest", ""));
|
||||
assert!(!is_record_absent("InternalServerError", ""));
|
||||
assert!(!is_record_absent("RateLimitExceeded", ""));
|
||||
assert!(!is_record_absent("", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nsid_to_authority() {
|
||||
assert_eq!(
|
||||
nsid_to_authority("app.bsky.feed.post").unwrap(),
|
||||
nsid_to_authority(&nsid("app.bsky.feed.post")),
|
||||
"feed.bsky.app"
|
||||
);
|
||||
assert_eq!(
|
||||
nsid_to_authority("com.atproto.repo.strongRef").unwrap(),
|
||||
nsid_to_authority(&nsid("com.atproto.repo.strongRef")),
|
||||
"repo.atproto.com"
|
||||
);
|
||||
assert_eq!(
|
||||
nsid_to_authority("com.germnetwork.social.post").unwrap(),
|
||||
nsid_to_authority(&nsid("com.germnetwork.social.post")),
|
||||
"social.germnetwork.com"
|
||||
);
|
||||
assert!(nsid_to_authority("tooShort").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nsid_to_authority_three_segments() {
|
||||
assert_eq!(
|
||||
nsid_to_authority("org.example.record").unwrap(),
|
||||
nsid_to_authority(&nsid("org.example.record")),
|
||||
"example.org"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pds_endpoint_valid() {
|
||||
let doc = serde_json::json!({
|
||||
"service": [{
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": "https://pds.example.com"
|
||||
}]
|
||||
});
|
||||
assert_eq!(
|
||||
extract_pds_endpoint(&doc),
|
||||
Some("https://pds.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pds_endpoint_multiple_services() {
|
||||
let doc = serde_json::json!({
|
||||
"service": [
|
||||
{
|
||||
"type": "AtprotoLabeler",
|
||||
"serviceEndpoint": "https://labeler.example.com"
|
||||
},
|
||||
{
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": "https://pds.example.com"
|
||||
}
|
||||
]
|
||||
});
|
||||
assert_eq!(
|
||||
extract_pds_endpoint(&doc),
|
||||
Some("https://pds.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pds_endpoint_missing() {
|
||||
let doc = serde_json::json!({
|
||||
"service": [{
|
||||
"type": "AtprotoLabeler",
|
||||
"serviceEndpoint": "https://labeler.example.com"
|
||||
}]
|
||||
});
|
||||
assert_eq!(extract_pds_endpoint(&doc), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pds_endpoint_no_services() {
|
||||
let doc = serde_json::json!({});
|
||||
assert_eq!(extract_pds_endpoint(&doc), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_fetched_schema_ok() {
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.thing".to_string(),
|
||||
id: nsid("com.example.thing"),
|
||||
defs: Default::default(),
|
||||
};
|
||||
assert!(validate_fetched_schema(&doc, "com.example.thing").is_ok());
|
||||
assert!(validate_fetched_schema(&doc, &nsid("com.example.thing")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_fetched_schema_id_mismatch() {
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.other".to_string(),
|
||||
id: nsid("com.example.other"),
|
||||
defs: Default::default(),
|
||||
};
|
||||
let err = validate_fetched_schema(&doc, "com.example.thing").unwrap_err();
|
||||
let err = validate_fetched_schema(&doc, &nsid("com.example.thing")).unwrap_err();
|
||||
assert!(matches!(err, ResolveError::InvalidSchema(_)));
|
||||
}
|
||||
|
||||
@@ -393,10 +405,10 @@ mod tests {
|
||||
fn test_validate_fetched_schema_bad_version() {
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 99,
|
||||
id: "com.example.thing".to_string(),
|
||||
id: nsid("com.example.thing"),
|
||||
defs: Default::default(),
|
||||
};
|
||||
let err = validate_fetched_schema(&doc, "com.example.thing").unwrap_err();
|
||||
let err = validate_fetched_schema(&doc, &nsid("com.example.thing")).unwrap_err();
|
||||
assert!(matches!(err, ResolveError::InvalidSchema(_)));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user