mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-25 10:46:11 +00:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0caa93228 | ||
|
|
3ade3d10c1 | ||
|
|
0189aa9f96 | ||
|
|
d495d7d729 | ||
|
|
73cb89c9b7 | ||
|
|
ecb7934a20 | ||
|
|
9edc7dcdd8 | ||
|
|
479fa3ed22 | ||
|
|
aa815931e0 | ||
|
|
0ce725174d | ||
|
|
dae3cc7e08 | ||
|
|
b9e7955606 | ||
|
|
32c58b1d0b | ||
|
|
1b5a2b319c | ||
|
|
ed3d129594 | ||
|
|
8d0b6f8322 | ||
|
|
0fc577316e | ||
|
|
52d5236e89 | ||
|
|
0274f19d75 | ||
|
|
135912194d | ||
|
|
0b8787d1de | ||
|
|
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 |
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
@@ -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
+78
-76
@@ -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"
|
||||
@@ -1250,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"
|
||||
@@ -2486,7 +2522,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7630,9 +7665,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-api"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"backon",
|
||||
"base32",
|
||||
@@ -7666,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.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
"brotli",
|
||||
"chrono",
|
||||
"hmac",
|
||||
"k256",
|
||||
@@ -7698,13 +7730,14 @@ dependencies = [
|
||||
"totp-rs",
|
||||
"tranquil-config",
|
||||
"tranquil-crypto",
|
||||
"tranquil-types",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7719,7 +7752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7733,7 +7766,6 @@ dependencies = [
|
||||
"rsa",
|
||||
"secrecy",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -7745,15 +7777,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -7769,7 +7800,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7786,7 +7817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7802,18 +7833,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.6.5"
|
||||
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.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -7825,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.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7849,14 +7883,16 @@ dependencies = [
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tranquil-infra",
|
||||
"tranquil-types",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth-server"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
@@ -7879,7 +7915,9 @@ dependencies = [
|
||||
"tranquil-config",
|
||||
"tranquil-crypto",
|
||||
"tranquil-db-traits",
|
||||
"tranquil-infra",
|
||||
"tranquil-pds",
|
||||
"tranquil-scopes",
|
||||
"tranquil-types",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
@@ -7888,7 +7926,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -7900,7 +7938,6 @@ dependencies = [
|
||||
"base32",
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
"bs58",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"ciborium",
|
||||
@@ -7916,7 +7953,6 @@ dependencies = [
|
||||
"hmac",
|
||||
"http 1.4.0",
|
||||
"image",
|
||||
"infer",
|
||||
"ipld-core",
|
||||
"iroh-car",
|
||||
"jacquard-common",
|
||||
@@ -7958,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",
|
||||
@@ -7977,12 +8013,11 @@ dependencies = [
|
||||
"webauthn-rs",
|
||||
"webauthn-rs-proto",
|
||||
"wiremock",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -7994,7 +8029,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"backon",
|
||||
@@ -8023,7 +8058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -8034,12 +8069,13 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tranquil-types",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-server"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
@@ -8076,7 +8112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-signal"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -8084,7 +8120,6 @@ dependencies = [
|
||||
"futures",
|
||||
"presage",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
@@ -8092,14 +8127,13 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tranquil-signal",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -8116,7 +8150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-store"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -8156,7 +8190,6 @@ dependencies = [
|
||||
"tranquil-db",
|
||||
"tranquil-db-traits",
|
||||
"tranquil-oauth",
|
||||
"tranquil-repo",
|
||||
"tranquil-store",
|
||||
"tranquil-types",
|
||||
"uuid",
|
||||
@@ -8165,7 +8198,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-sync"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -8187,15 +8220,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.6.5"
|
||||
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]]
|
||||
@@ -8247,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"
|
||||
@@ -9454,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"
|
||||
@@ -9516,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"
|
||||
|
||||
+2
-3
@@ -26,7 +26,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.5"
|
||||
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" }
|
||||
@@ -138,11 +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"
|
||||
x509-parser = "0.18"
|
||||
zip = { version = "7.0", default-features = false, features = ["deflate"] }
|
||||
|
||||
ciborium = "0.2"
|
||||
ctor = "0.6"
|
||||
|
||||
@@ -10,6 +10,7 @@ dir = "/app/frontend/public"
|
||||
|
||||
[database]
|
||||
url = "postgres://postgres:postgres@db:5432/pds"
|
||||
max_connections = 20
|
||||
|
||||
[storage]
|
||||
path = "/var/lib/tranquil-pds/blobs"
|
||||
|
||||
@@ -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};
|
||||
@@ -200,7 +200,8 @@ pub fn create_self_hosted_did_web(handle: &str) -> Result<Did, ApiError> {
|
||||
return Err(ApiError::SelfHostedDidWebDisabled);
|
||||
}
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
Ok(Did::from(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 {
|
||||
|
||||
@@ -12,8 +12,8 @@ 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;
|
||||
@@ -65,16 +65,16 @@ 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)
|
||||
@@ -392,7 +392,7 @@ 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: CidLink::from(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,
|
||||
};
|
||||
@@ -477,7 +477,12 @@ pub async fn resolve_controller(
|
||||
|
||||
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,
|
||||
@@ -168,7 +172,6 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
|
||||
"Received /start from Discord user"
|
||||
);
|
||||
|
||||
let handle = handle.map(Handle::from);
|
||||
match state
|
||||
.repos
|
||||
.user
|
||||
|
||||
@@ -65,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
|
||||
@@ -147,12 +147,7 @@ async fn try_reactivate_migration(
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.clone(),
|
||||
did: did.clone(),
|
||||
did_doc: state
|
||||
.did_resolver
|
||||
.fetch_did_document(did)
|
||||
.await
|
||||
.ok()
|
||||
.map(|f| (*f).clone()),
|
||||
did_doc: state.did_resolver.fetch_did_document(did).await.ok(),
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
verification_required,
|
||||
@@ -202,7 +197,8 @@ pub async fn create_account(
|
||||
let token = extracted.token;
|
||||
if is_service_token(&token) {
|
||||
let verifier = ServiceTokenVerifier::new();
|
||||
let create_account_lxm = Nsid::from("com.atproto.server.createAccount".to_string());
|
||||
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(&create_account_lxm))
|
||||
.await
|
||||
@@ -448,7 +444,7 @@ pub async fn create_account(
|
||||
Ok(r) => r,
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
let commit_cid = CidLink::from(repo.commit_cid.to_string());
|
||||
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 {
|
||||
@@ -567,7 +563,7 @@ pub async fn create_account(
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.clone(),
|
||||
did,
|
||||
did_doc: did_doc.map(|f| (*f).clone()),
|
||||
did_doc,
|
||||
access_jwt: session.access_jwt,
|
||||
refresh_jwt: session.refresh_jwt,
|
||||
verification_required: !is_migration,
|
||||
|
||||
@@ -51,7 +51,9 @@ pub async fn resolve_handle(
|
||||
}
|
||||
};
|
||||
let cache_key = tranquil_pds::cache_keys::handle_key(&handle);
|
||||
if let Some(did) = state.cache.get(&cache_key).await {
|
||||
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;
|
||||
@@ -425,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(
|
||||
|
||||
@@ -109,7 +109,7 @@ pub async fn init_genesis_repo(
|
||||
encrypted_key_bytes,
|
||||
commit_cid,
|
||||
mst_root_cid: mst_root,
|
||||
repo_rev: Tid::from(rev.as_ref().to_string()),
|
||||
repo_rev: Tid::from(rev.clone()),
|
||||
genesis_block_cids: vec![mst_root.to_bytes(), commit_cid.to_bytes()],
|
||||
})
|
||||
}
|
||||
@@ -164,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,
|
||||
@@ -198,27 +205,31 @@ pub async fn sequence_new_account(
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event(
|
||||
state,
|
||||
did,
|
||||
&CidLink::from(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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -21,7 +28,7 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
let blob_body_limit =
|
||||
DefaultBodyLimit::max(tranquil_config::get().server.max_blob_size as usize);
|
||||
|
||||
axum::Router::new()
|
||||
let router = axum::Router::new()
|
||||
.route("/_health", get(server::health))
|
||||
.route(
|
||||
"/com.atproto.server.describeServer",
|
||||
@@ -373,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),
|
||||
@@ -438,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),
|
||||
@@ -446,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> {
|
||||
@@ -474,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,12 +6,16 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
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};
|
||||
use tranquil_pds::types::{Did, DidRef, 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 {
|
||||
@@ -145,11 +149,11 @@ async fn proxy_to_report_service(
|
||||
},
|
||||
};
|
||||
|
||||
let report_lxm = Nsid::from("com.atproto.moderation.createReport".to_string());
|
||||
let service_token = match tranquil_pds::auth::create_service_token(
|
||||
&auth_user.did,
|
||||
service_did,
|
||||
Some(&report_lxm),
|
||||
&DidRef::from(service_did),
|
||||
Some(&CREATE_REPORT_NSID),
|
||||
None,
|
||||
&key_bytes,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
|
||||
@@ -147,12 +147,14 @@ pub async fn request_channel_verification(
|
||||
match channel {
|
||||
CommsChannel::Email => {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let fallback_handle = Handle::from("user".to_string());
|
||||
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.unwrap_or(&fallback_handle),
|
||||
handle,
|
||||
&formatted_token,
|
||||
hostname,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ 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};
|
||||
@@ -19,6 +20,9 @@ use tranquil_pds::state::AppState;
|
||||
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(&Nsid::from("com.atproto.repo.uploadBlob".to_string()))?;
|
||||
service.require_lxm(&UPLOAD_BLOB_NSID)?;
|
||||
(service.did.clone(), None)
|
||||
}
|
||||
AuthAny::User(user) => {
|
||||
|
||||
@@ -233,7 +233,7 @@ pub async fn import_repo(
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let new_root_cid_link = CidLink::from(&new_root_cid);
|
||||
let new_rev_tid = tranquil_pds::types::Tid::from(new_rev_str.clone());
|
||||
let new_rev_tid = tranquil_pds::types::Tid::from(new_rev.clone());
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
@@ -243,17 +243,40 @@ pub async fn import_repo(
|
||||
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_tid)
|
||||
.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={}",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub use tranquil_pds::repo_ops::*;
|
||||
@@ -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,
|
||||
@@ -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};
|
||||
@@ -27,7 +27,8 @@ pub struct CheckAccountStatusOutput {
|
||||
pub activated: bool,
|
||||
pub valid_did: bool,
|
||||
pub repo_commit: String,
|
||||
pub repo_rev: Tid,
|
||||
#[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| Tid::from(c.rev().to_string()))
|
||||
.unwrap_or_else(|| Tid::from(String::new()))
|
||||
} else {
|
||||
Tid::from(String::new())
|
||||
}
|
||||
} else {
|
||||
Tid::from(String::new())
|
||||
}
|
||||
} else {
|
||||
Tid::from(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
|
||||
@@ -207,9 +212,10 @@ async fn assert_valid_did_document_for_service(
|
||||
if let Some(ref expected_rotation_key) = server_rotation_key
|
||||
&& !doc_rotation_keys.contains(&expected_rotation_key.as_str())
|
||||
{
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Server rotation key not included in PLC DID data".into(),
|
||||
));
|
||||
debug!(
|
||||
"DID {} rotation keys {:?} omit the PDS-managed server rotation key {}",
|
||||
did, doc_rotation_keys, expected_rotation_key
|
||||
);
|
||||
}
|
||||
|
||||
let doc_signing_key = doc_data
|
||||
@@ -246,13 +252,10 @@ async fn assert_valid_did_document_for_service(
|
||||
}
|
||||
|
||||
if !doc_rotation_keys.contains(&expected_did_key.as_str()) {
|
||||
warn!(
|
||||
debug!(
|
||||
"DID {} rotation keys {:?} omit the PDS-managed signing key {}",
|
||||
did, doc_rotation_keys, expected_did_key
|
||||
);
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"PLC rotation keys omit the PDS-managed signing key required to sign operations for this identity".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Some(host_and_path) = did.as_str().strip_prefix("did:web:") {
|
||||
@@ -449,7 +452,7 @@ pub async fn activate_account(
|
||||
if let Ok(Some(block)) = state.block_store.get(&cid).await {
|
||||
Commit::from_cbor(&block)
|
||||
.ok()
|
||||
.map(|c| Tid::from(c.rev().to_string()))
|
||||
.map(|c| Tid::from(c.rev().clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -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|_
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -43,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 })),
|
||||
@@ -115,7 +115,7 @@ pub async fn create_invite_codes(
|
||||
async move {
|
||||
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, codes })
|
||||
}
|
||||
@@ -188,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,
|
||||
@@ -219,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,
|
||||
|
||||
@@ -69,7 +69,8 @@ pub async fn create_passkey_account(
|
||||
let token = extracted.token;
|
||||
if is_service_token(&token) {
|
||||
let verifier = ServiceTokenVerifier::new();
|
||||
let create_account_lxm = Nsid::from("com.atproto.server.createAccount".to_string());
|
||||
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(&create_account_lxm))
|
||||
.await
|
||||
@@ -313,7 +314,7 @@ 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: tranquil_types::CidLink::from(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: invite_registration.into_invite_code(),
|
||||
|
||||
@@ -45,15 +45,23 @@ pub async fn request_password_reset(
|
||||
None
|
||||
};
|
||||
|
||||
let user_id = match state
|
||||
.repos
|
||||
.user
|
||||
.get_id_by_email_or_handle(
|
||||
normalized,
|
||||
&Handle::from(normalized_handle.as_str().to_string()),
|
||||
)
|
||||
.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");
|
||||
|
||||
@@ -11,7 +11,7 @@ use tracing::{error, info, warn};
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::auth::extractor::{Auth, Permissive};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::Did;
|
||||
use tranquil_pds::types::DidRef;
|
||||
use tranquil_types::Nsid;
|
||||
|
||||
static CREATE_ACCOUNT_NSID: LazyLock<Nsid> =
|
||||
@@ -45,7 +45,7 @@ static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetServiceAuthParams {
|
||||
pub aud: Did,
|
||||
pub aud: DidRef,
|
||||
pub lxm: Option<Nsid>,
|
||||
pub exp: Option<i64>,
|
||||
}
|
||||
@@ -169,14 +169,19 @@ pub async fn get_service_auth(
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
let service_token = match tranquil_pds::auth::create_service_token(
|
||||
&auth.did,
|
||||
¶ms.aud,
|
||||
lxm,
|
||||
params.exp,
|
||||
&key_bytes,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to create service token: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(GetServiceAuthOutput {
|
||||
|
||||
@@ -351,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,
|
||||
@@ -444,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)),
|
||||
@@ -800,7 +800,7 @@ async fn build_refresh_session_output(
|
||||
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),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ fn public_key_to_did_key(signing_key: &SigningKey) -> Did {
|
||||
multicodec_key.extend_from_slice(&SECP256K1_MULTICODEC_PREFIX);
|
||||
multicodec_key.extend_from_slice(&compressed_pubkey);
|
||||
let encoded = multibase::encode(multibase::Base::Base58Btc, &multicodec_key);
|
||||
Did::from(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)]
|
||||
|
||||
@@ -62,8 +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()).map(tranquil_types::Handle::from);
|
||||
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,
|
||||
|
||||
@@ -58,9 +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(
|
||||
&tranquil_types::CidLink::from(cid_str.to_string()),
|
||||
);
|
||||
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()) {
|
||||
@@ -70,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(),
|
||||
}
|
||||
}
|
||||
@@ -24,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;
|
||||
@@ -12,6 +13,8 @@ pub use 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,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
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::{DateTime, Duration, Utc};
|
||||
use hmac::{Hmac, Mac};
|
||||
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
|
||||
use sha2::Sha256;
|
||||
use tranquil_types::{Did, Jti, Nsid};
|
||||
use tranquil_types::{Did, DidRef, Jti, Nsid};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
@@ -125,16 +127,20 @@ pub fn create_refresh_token_with_jti(
|
||||
|
||||
pub fn create_service_token(
|
||||
did: &Did,
|
||||
aud: &Did,
|
||||
aud: &DidRef,
|
||||
lxm: Option<&Nsid>,
|
||||
exp: Option<i64>,
|
||||
key_bytes: &[u8],
|
||||
) -> Result<String> {
|
||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||
|
||||
let expiration = Utc::now()
|
||||
.checked_add_signed(Duration::seconds(60))
|
||||
.expect("valid timestamp")
|
||||
.timestamp();
|
||||
let expiration = match exp {
|
||||
Some(exp) => exp,
|
||||
None => Utc::now()
|
||||
.checked_add_signed(Duration::seconds(60))
|
||||
.expect("valid timestamp")
|
||||
.timestamp(),
|
||||
};
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.clone(),
|
||||
@@ -205,7 +211,7 @@ fn create_signed_token_pinned(
|
||||
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,
|
||||
@@ -328,7 +334,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,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::compress::decode_scope;
|
||||
|
||||
use super::types::{
|
||||
Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
|
||||
TokenVerifyError, UnsafeClaims,
|
||||
@@ -164,9 +166,15 @@ pub fn verify_token_es256k(
|
||||
.decode(claims_b64)
|
||||
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of claims failed"))?;
|
||||
|
||||
let claims: Claims = serde_json::from_slice(&claims_bytes)
|
||||
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(TokenVerifyError::Expired);
|
||||
@@ -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"));
|
||||
|
||||
@@ -5,12 +5,11 @@ 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 }
|
||||
|
||||
@@ -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;
|
||||
@@ -173,11 +175,10 @@ pub async fn create_cache(
|
||||
) -> 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}");
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,25 +1,41 @@
|
||||
use lettre::Message;
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::message::header::ContentType;
|
||||
use lettre::message::header::{Header, HeaderName, HeaderValue};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::types::EmailDomain;
|
||||
use crate::sender::SendError;
|
||||
use crate::types::QueuedComms;
|
||||
use crate::types::{CommsType, QueuedComms};
|
||||
|
||||
pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result<Message, SendError> {
|
||||
pub(super) fn build(
|
||||
from: &Mailbox,
|
||||
qc: &QueuedComms,
|
||||
apply_atmos_categories: bool,
|
||||
) -> Result<Message, SendError> {
|
||||
let to: Mailbox = qc
|
||||
.recipient
|
||||
.parse()
|
||||
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
|
||||
let subject = qc.subject.as_deref().unwrap_or("Notification");
|
||||
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
|
||||
Message::builder()
|
||||
let builder = Message::builder()
|
||||
.from(from.clone())
|
||||
.to(to)
|
||||
.subject(subject)
|
||||
.message_id(Some(message_id))
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.header(ContentType::TEXT_PLAIN);
|
||||
|
||||
let category = apply_atmos_categories
|
||||
.then(|| atmos_category(qc.comms_type))
|
||||
.flatten();
|
||||
|
||||
let builder = match category {
|
||||
Some(category) => builder.header(category),
|
||||
None => builder,
|
||||
};
|
||||
|
||||
builder
|
||||
.body(qc.body.clone())
|
||||
.map_err(|e| SendError::MessageBuild(e.to_string()))
|
||||
}
|
||||
@@ -34,10 +50,57 @@ pub(super) fn recipient_domain(message: &Message) -> Result<EmailDomain, SendErr
|
||||
.map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}")))
|
||||
}
|
||||
|
||||
// for use with comail.at
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
|
||||
enum AtmosCategory {
|
||||
PasswordReset,
|
||||
MfaOtp,
|
||||
Verification,
|
||||
}
|
||||
impl AtmosCategory {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::PasswordReset => "password-reset",
|
||||
Self::MfaOtp => "mfa-otp",
|
||||
Self::Verification => "verification",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Header for AtmosCategory {
|
||||
fn name() -> HeaderName {
|
||||
HeaderName::new_from_ascii_str("X-Atmos-Category")
|
||||
}
|
||||
fn parse(_s: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
//since we're never receiving email, we don't care about parsing
|
||||
Err("X-Atmos-Category is write-only".into())
|
||||
}
|
||||
fn display(&self) -> HeaderValue {
|
||||
HeaderValue::new(Self::name(), self.as_str().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn atmos_category(comms_type: CommsType) -> Option<AtmosCategory> {
|
||||
use CommsType::*;
|
||||
match comms_type {
|
||||
EmailVerification
|
||||
| ChannelVerification
|
||||
| ChannelVerified
|
||||
| MigrationVerification
|
||||
| LegacyLoginAlert
|
||||
| EmailUpdate
|
||||
| PlcOperation
|
||||
| AccountDeletion
|
||||
| Welcome => Some(AtmosCategory::Verification),
|
||||
PasswordReset | PasskeyRecovery => Some(AtmosCategory::PasswordReset),
|
||||
TwoFactorCode => Some(AtmosCategory::MfaOtp),
|
||||
AdminEmail => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{CommsChannel, CommsStatus, CommsType};
|
||||
use crate::types::{CommsChannel, CommsStatus};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -71,6 +134,7 @@ mod tests {
|
||||
let msg = build(
|
||||
&from_mailbox(),
|
||||
&fixture("user@nel.pet", Some("Welcome"), "Hello world."),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
@@ -87,6 +151,7 @@ mod tests {
|
||||
let msg = build(
|
||||
&from_mailbox(),
|
||||
&fixture("user@nel.pet", Some("héllo wörld"), "Body"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
@@ -99,6 +164,7 @@ mod tests {
|
||||
let result = build(
|
||||
&from_mailbox(),
|
||||
&fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"),
|
||||
false,
|
||||
);
|
||||
assert!(matches!(result, Err(SendError::InvalidRecipient(_))));
|
||||
}
|
||||
@@ -108,6 +174,7 @@ mod tests {
|
||||
let msg = build(
|
||||
&from_mailbox(),
|
||||
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
|
||||
false,
|
||||
)
|
||||
.expect("subject CRLF should be encoded, not rejected");
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
@@ -123,7 +190,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn message_id_uses_from_domain() {
|
||||
let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap();
|
||||
let msg = build(
|
||||
&from_mailbox(),
|
||||
&fixture("user@nel.pet", Some("s"), "b"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
let line = raw
|
||||
.lines()
|
||||
@@ -137,15 +209,58 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn missing_subject_uses_default() {
|
||||
let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap();
|
||||
let msg = build(
|
||||
&from_mailbox(),
|
||||
&fixture("user@nel.pet", None, "Body"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
assert!(raw.contains("Subject: Notification"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipient_domain_extracted() {
|
||||
let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap();
|
||||
let msg = build(
|
||||
&from_mailbox(),
|
||||
&fixture("user@Nel.PET", Some("s"), "b"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let d = recipient_domain(&msg).unwrap();
|
||||
assert_eq!(d.as_str(), "nel.pet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atmos_category_header_present_when_enabled_and_mapped() {
|
||||
let qc = QueuedComms {
|
||||
comms_type: CommsType::PasswordReset,
|
||||
..fixture("user@nel.pet", Some("s"), "b")
|
||||
};
|
||||
let msg = build(&from_mailbox(), &qc, true).unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
assert!(raw.contains("X-Atmos-Category: password-reset"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atmos_category_header_absent_when_disabled() {
|
||||
let qc = QueuedComms {
|
||||
comms_type: CommsType::PasswordReset,
|
||||
..fixture("user@nel.pet", Some("s"), "b")
|
||||
};
|
||||
let msg = build(&from_mailbox(), &qc, false).unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
assert!(!raw.contains("X-Atmos-Category"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atmos_category_header_absent_when_unmapped() {
|
||||
let qc = QueuedComms {
|
||||
comms_type: CommsType::AdminEmail,
|
||||
..fixture("user@nel.pet", Some("s"), "b")
|
||||
};
|
||||
let msg = build(&from_mailbox(), &qc, true).unwrap();
|
||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||
assert!(!raw.contains("X-Atmos-Category"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ fn build_smarthost(
|
||||
Ok(SendMode::Smarthost {
|
||||
transport: Box::new(builder.build()),
|
||||
total_timeout,
|
||||
apply_atmos_categories: cfg.email.smarthost.apply_atmos_categories,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -176,6 +177,16 @@ fn build_dkim(cfg: &tranquil_config::DkimConfig) -> Result<Option<DkimSigner>, S
|
||||
DkimSigner::load(selector, domain, path).map(Some)
|
||||
}
|
||||
|
||||
fn wants_atmos_categories(mode: &SendMode) -> bool {
|
||||
match mode {
|
||||
SendMode::Smarthost {
|
||||
apply_atmos_categories,
|
||||
..
|
||||
} => *apply_atmos_categories,
|
||||
SendMode::DirectMx { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommsSender for EmailSender {
|
||||
fn channel(&self) -> CommsChannel {
|
||||
@@ -183,7 +194,8 @@ impl CommsSender for EmailSender {
|
||||
}
|
||||
|
||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
||||
let mut message = message::build(&self.from, notification)?;
|
||||
let mut message =
|
||||
message::build(&self.from, notification, wants_atmos_categories(&self.mode))?;
|
||||
if let Some(signer) = &self.dkim {
|
||||
signer.sign(&mut message);
|
||||
}
|
||||
@@ -196,3 +208,45 @@ impl CommsSender for EmailSender {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lettre::Tokio1Executor;
|
||||
use std::time::Duration;
|
||||
|
||||
fn dummy_smarthost(apply_atmos_categories: bool) -> SendMode {
|
||||
let transport =
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous("localhost").build();
|
||||
SendMode::Smarthost {
|
||||
transport: Box::new(transport),
|
||||
total_timeout: Duration::from_secs(10),
|
||||
apply_atmos_categories,
|
||||
}
|
||||
}
|
||||
|
||||
fn dummy_direct_mx() -> SendMode {
|
||||
SendMode::DirectMx {
|
||||
resolver: Arc::new(TokioAsyncResolver::tokio(
|
||||
ResolverConfig::default(),
|
||||
ResolverOpts::default(),
|
||||
)),
|
||||
helo: HeloName::parse("mta.nel.pet").unwrap(),
|
||||
command_timeout: Duration::from_secs(5),
|
||||
total_timeout: Duration::from_secs(10),
|
||||
require_tls: false,
|
||||
inflight: Arc::new(Semaphore::new(1)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smarthost_reflects_its_own_flag() {
|
||||
assert!(wants_atmos_categories(&dummy_smarthost(true)));
|
||||
assert!(!wants_atmos_categories(&dummy_smarthost(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_mx_never_wants_atmos_categories() {
|
||||
assert!(!wants_atmos_categories(&dummy_direct_mx()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub enum SendMode {
|
||||
Smarthost {
|
||||
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
|
||||
total_timeout: Duration,
|
||||
apply_atmos_categories: bool,
|
||||
},
|
||||
DirectMx {
|
||||
resolver: Arc<TokioAsyncResolver>,
|
||||
@@ -33,8 +34,15 @@ pub enum SendMode {
|
||||
impl std::fmt::Debug for SendMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Smarthost { total_timeout, .. } => {
|
||||
write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})")
|
||||
Self::Smarthost {
|
||||
total_timeout,
|
||||
apply_atmos_categories,
|
||||
..
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"SendMode::Smarthost(total_timeout={total_timeout:?}, apply_atmos_categories={apply_atmos_categories:?})"
|
||||
)
|
||||
}
|
||||
Self::DirectMx {
|
||||
helo, require_tls, ..
|
||||
@@ -52,6 +60,7 @@ pub async fn dispatch(mode: &SendMode, message: Message) -> Result<(), SendError
|
||||
SendMode::Smarthost {
|
||||
transport,
|
||||
total_timeout,
|
||||
..
|
||||
} => with_total_timeout(*total_timeout, run_send(transport, message)).await,
|
||||
SendMode::DirectMx {
|
||||
resolver,
|
||||
|
||||
@@ -53,6 +53,7 @@ fn build_smarthost_sender_with_total_timeout(
|
||||
SendMode::Smarthost {
|
||||
transport: Box::new(transport),
|
||||
total_timeout,
|
||||
apply_atmos_categories: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -6,4 +6,3 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
confique = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -445,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,
|
||||
@@ -831,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,
|
||||
}
|
||||
@@ -1120,6 +1124,10 @@ pub struct SmarthostConfig {
|
||||
/// stuck relay cannot stall the comms queue.
|
||||
#[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)]
|
||||
pub total_timeout_secs: u64,
|
||||
|
||||
/// Apply Atmos/Comail.at categories for headers to be categorized appropriately.
|
||||
#[config(env = "MAIL_APPLY_ATMOS_CATEGORIES", default = false)]
|
||||
pub apply_atmos_categories: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
@@ -1981,6 +1989,7 @@ port = 587
|
||||
pool_size: 4,
|
||||
command_timeout_secs: 30,
|
||||
total_timeout_secs: 60,
|
||||
apply_atmos_categories: false,
|
||||
},
|
||||
direct_mx: DirectMxConfig {
|
||||
command_timeout_secs: 30,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ pub trait InfraRepository: Send + Sync {
|
||||
&self,
|
||||
code: &InviteCode,
|
||||
use_count: i32,
|
||||
for_account: Option<&Did>,
|
||||
for_account: &Did,
|
||||
) -> Result<bool, DbError>;
|
||||
|
||||
async fn create_invite_codes_batch(
|
||||
@@ -258,7 +258,7 @@ pub trait InfraRepository: Send + Sync {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -410,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,
|
||||
@@ -432,7 +439,7 @@ pub trait RepoRepository: Send + Sync {
|
||||
async fn get_user_block_cids_since_rev(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
since_rev: &Tid,
|
||||
since_rev: Option<&Tid>,
|
||||
) -> Result<Vec<Vec<u8>>, DbError>;
|
||||
|
||||
async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>;
|
||||
|
||||
@@ -670,7 +670,6 @@ pub struct UserForVerification {
|
||||
pub did: Did,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub handle: Handle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -1119,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> {
|
||||
|
||||
@@ -6,7 +6,9 @@ use tranquil_db_traits::{
|
||||
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,7 +115,7 @@ 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: &Tid) -> Result<Vec<CidLink>, DbError> {
|
||||
@@ -124,7 +130,7 @@ impl BlobRepository for PostgresBlobRepository {
|
||||
.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, InviteCode};
|
||||
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,
|
||||
@@ -153,9 +155,9 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
&self,
|
||||
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"#,
|
||||
@@ -175,9 +177,9 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
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)
|
||||
@@ -250,17 +252,19 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| InviteCodeInfo {
|
||||
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)),
|
||||
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: &InviteCode) -> Result<Vec<InviteCodeUse>, DbError> {
|
||||
@@ -278,11 +282,13 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|r| InviteCodeUse {
|
||||
code: code.clone(),
|
||||
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())
|
||||
}
|
||||
@@ -436,10 +442,10 @@ 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(
|
||||
@@ -459,15 +465,17 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| InviteCodeUse {
|
||||
code: InviteCode::from(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(
|
||||
@@ -485,17 +493,19 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| InviteCodeInfo {
|
||||
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)),
|
||||
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(
|
||||
@@ -513,14 +523,18 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(|r| InviteCodeInfo {
|
||||
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)),
|
||||
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(
|
||||
@@ -539,22 +553,22 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
(
|
||||
Ok((
|
||||
r.created_by_user,
|
||||
InviteCodeInfo {
|
||||
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(
|
||||
@@ -683,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> {
|
||||
@@ -1027,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(
|
||||
@@ -1058,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())
|
||||
}
|
||||
@@ -1105,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(
|
||||
@@ -1202,14 +1229,20 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.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: Did::from(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;
|
||||
|
||||
@@ -11,11 +11,12 @@ use tranquil_oauth::{
|
||||
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;
|
||||
@@ -98,7 +99,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
.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,
|
||||
@@ -115,7 +116,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.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),
|
||||
}
|
||||
@@ -144,7 +145,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
.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,
|
||||
@@ -161,7 +162,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.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),
|
||||
@@ -193,7 +194,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
.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,
|
||||
@@ -210,7 +211,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.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),
|
||||
@@ -326,7 +327,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
.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,
|
||||
@@ -343,7 +344,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.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()
|
||||
@@ -476,18 +477,14 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
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()))?,
|
||||
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::Other("Invalid controller DID in DB".into()))?,
|
||||
controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(
|
||||
|_| DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_CONTROLLER_DID),
|
||||
)?,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
@@ -570,18 +567,14 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
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()))?,
|
||||
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::Other("Invalid controller DID in DB".into()))?,
|
||||
controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(
|
||||
|_| DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_CONTROLLER_DID),
|
||||
)?,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
@@ -813,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(
|
||||
@@ -904,7 +898,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(TwoFactorChallenge {
|
||||
id: row.id,
|
||||
did: Did::from(row.did),
|
||||
did: column(row.did, col::OAUTH_2FA_CHALLENGE_DID)?,
|
||||
request_uri: RequestId::from(row.request_uri),
|
||||
code: row.code,
|
||||
attempts: row.attempts,
|
||||
@@ -928,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: RequestId::from(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> {
|
||||
|
||||
@@ -10,7 +10,9 @@ use tranquil_db_traits::{
|
||||
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,16 +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.into_iter().map(CidLink::from).collect(),
|
||||
)),
|
||||
_ => None,
|
||||
Some(cids) if !cids.is_empty() => Ok(Some(EventBlocks::LegacyCids(column_vec(
|
||||
cids,
|
||||
col::REPO_SEQ_BLOCKS_CIDS,
|
||||
)?))),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,24 +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
|
||||
.map(|blobs| blobs.into_iter().map(CidLink::from).collect()),
|
||||
.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.map(Tid::from),
|
||||
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;
|
||||
|
||||
@@ -266,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> {
|
||||
@@ -278,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.map(Tid::from),
|
||||
}))
|
||||
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> {
|
||||
@@ -294,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> {
|
||||
@@ -312,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(
|
||||
@@ -405,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(
|
||||
@@ -418,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();
|
||||
@@ -446,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!(
|
||||
@@ -462,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)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -486,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!(
|
||||
@@ -503,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)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -525,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!(
|
||||
@@ -541,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)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -563,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!(
|
||||
@@ -579,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)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -598,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!(
|
||||
@@ -613,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -629,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())
|
||||
}
|
||||
@@ -646,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> {
|
||||
@@ -688,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,
|
||||
@@ -760,22 +832,37 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
async fn get_user_block_cids_since_rev(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
since_rev: &Tid,
|
||||
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.as_str())
|
||||
.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> {
|
||||
@@ -1024,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(
|
||||
@@ -1054,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!(
|
||||
@@ -1069,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1092,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(
|
||||
@@ -1132,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(
|
||||
@@ -1155,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.map(Tid::from),
|
||||
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(
|
||||
@@ -1178,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(
|
||||
@@ -1520,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: repo_rev.map(Tid::from),
|
||||
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(
|
||||
@@ -1548,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(
|
||||
|
||||
@@ -10,7 +10,9 @@ use tranquil_db_traits::{
|
||||
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,
|
||||
@@ -69,21 +71,24 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| SessionToken {
|
||||
id: SessionId::new(r.id),
|
||||
did: Did::from(r.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: 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(
|
||||
@@ -104,14 +109,17 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.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 delete_session_by_access_jti(
|
||||
@@ -274,9 +282,9 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
let grace_cutoff = Utc::now() - Duration::seconds(REFRESH_GRACE_PERIOD_SECS);
|
||||
if r.used_at > grace_cutoff {
|
||||
Ok(RefreshGraceLookup::Replay(RefreshGraceReplay {
|
||||
did: Did::from(r.did),
|
||||
did: column(r.did, col::SESSION_TOKENS_DID)?,
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did.map(Did::from),
|
||||
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,
|
||||
@@ -286,7 +294,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
}))
|
||||
} else {
|
||||
Ok(RefreshGraceLookup::Compromised {
|
||||
did: Did::from(r.did),
|
||||
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),
|
||||
@@ -308,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: PasswordHash::new(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(
|
||||
@@ -341,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: PasswordHash::new(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(
|
||||
@@ -374,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: PasswordHash::new(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> {
|
||||
|
||||
@@ -4,6 +4,8 @@ use sqlx::PgPool;
|
||||
use tranquil_types::{AtIdentifier, Did, Handle, Jti, PasswordHash, TokenId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::{column, legacy_column, opt_column};
|
||||
use tranquil_db_traits::{
|
||||
AccountSearchResult, AccountType, ChannelVerificationStatus, CommsChannel, DbError,
|
||||
DidWebOverrides, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, SsoProviderType,
|
||||
@@ -87,17 +89,20 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserRow {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
inbound_migration: r.inbound_migration,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserRow {
|
||||
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,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
inbound_migration: r.inbound_migration,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_by_handle(&self, handle: &Handle) -> Result<Option<UserRow>, DbError> {
|
||||
@@ -110,17 +115,20 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserRow {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
inbound_migration: r.inbound_migration,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserRow {
|
||||
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,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
inbound_migration: r.inbound_migration,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_with_key_by_did(&self, did: &Did) -> Result<Option<UserWithKey>, DbError> {
|
||||
@@ -136,17 +144,20 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserWithKey {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserWithKey {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_status_by_did(&self, did: &Did) -> Result<Option<UserStatus>, DbError> {
|
||||
@@ -207,15 +218,18 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| OAuthTokenWithUser {
|
||||
did: Did::from(r.did),
|
||||
expires_at: r.expires_at,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(OAuthTokenWithUser {
|
||||
did: column(r.did, col::OAUTH_TOKEN_DID)?,
|
||||
expires_at: r.expires_at,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_user_info_by_did(&self, did: &Did) -> Result<Option<UserInfoForAuth>, DbError> {
|
||||
@@ -288,14 +302,16 @@ impl UserRepository for PostgresUserRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AccountSearchResult {
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
email_verified: r.email_verified,
|
||||
deactivated_at: r.deactivated_at,
|
||||
invites_disabled: r.invites_disabled,
|
||||
.filter_map(|r| {
|
||||
Some(AccountSearchResult {
|
||||
did: legacy_column(r.did, col::USERS_DID)?,
|
||||
handle: legacy_column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
email_verified: r.email_verified,
|
||||
deactivated_at: r.deactivated_at,
|
||||
invites_disabled: r.invites_disabled,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -311,24 +327,27 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserAuthInfo {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
password_hash: r.password_hash.map(PasswordHash::new),
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
r.email_verified,
|
||||
r.discord_verified,
|
||||
r.telegram_verified,
|
||||
r.signal_verified,
|
||||
),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserAuthInfo {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
password_hash: r.password_hash.map(PasswordHash::new),
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
r.email_verified,
|
||||
r.discord_verified,
|
||||
r.telegram_verified,
|
||||
r.signal_verified,
|
||||
),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_by_email(&self, email: &str) -> Result<Option<UserForVerification>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT id, did, email, email_verified, handle
|
||||
r#"SELECT id, did, email, email_verified
|
||||
FROM users
|
||||
WHERE LOWER(email) = $1"#,
|
||||
email
|
||||
@@ -336,13 +355,15 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserForVerification {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
email: r.email,
|
||||
email_verified: r.email_verified,
|
||||
handle: Handle::from(r.handle),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserForVerification {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
email: r.email,
|
||||
email_verified: r.email_verified,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> {
|
||||
@@ -354,15 +375,18 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserCommsPrefs {
|
||||
email: r.email,
|
||||
handle: Handle::from(r.handle),
|
||||
preferred_channel: r.preferred_channel,
|
||||
preferred_locale: r.preferred_locale,
|
||||
telegram_chat_id: r.telegram_chat_id,
|
||||
discord_id: r.discord_id,
|
||||
signal_username: r.signal_username,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserCommsPrefs {
|
||||
email: r.email,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
preferred_channel: r.preferred_channel,
|
||||
preferred_locale: r.preferred_locale,
|
||||
telegram_chat_id: r.telegram_chat_id,
|
||||
discord_id: r.discord_id,
|
||||
signal_username: r.signal_username,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_id_by_did(&self, did: &Did) -> Result<Option<Uuid>, DbError> {
|
||||
@@ -395,10 +419,13 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserIdAndHandle {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserIdAndHandle {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_did_web_info_by_handle(
|
||||
@@ -412,11 +439,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserDidWebInfo {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
migrated_to_pds: r.migrated_to_pds,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserDidWebInfo {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
migrated_to_pds: r.migrated_to_pds,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_did_web_overrides(
|
||||
@@ -441,7 +471,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(handle.map(Handle::from))
|
||||
opt_column(handle, col::USERS_HANDLE)
|
||||
}
|
||||
|
||||
async fn check_handle_exists(
|
||||
@@ -538,12 +568,15 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserEmailInfo {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
email_verified: r.email_verified,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserEmailInfo {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
email_verified: r.email_verified,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn check_email_exists(
|
||||
@@ -726,11 +759,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserIdHandleEmail {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserIdHandleEmail {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn update_preferred_comms_channel(
|
||||
@@ -794,17 +830,20 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserVerificationInfo {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
r.email_verified,
|
||||
r.discord_verified,
|
||||
r.telegram_verified,
|
||||
r.signal_verified,
|
||||
),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserVerificationInfo {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
r.email_verified,
|
||||
r.discord_verified,
|
||||
r.telegram_verified,
|
||||
r.signal_verified,
|
||||
),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn verify_email_channel(&self, user_id: Uuid, email: &str) -> Result<bool, DbError> {
|
||||
@@ -954,21 +993,22 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| StoredPasskey {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
credential_id: r.credential_id,
|
||||
public_key: r.public_key,
|
||||
sign_count: r.sign_count,
|
||||
created_at: r.created_at,
|
||||
last_used: r.last_used,
|
||||
friendly_name: r.friendly_name,
|
||||
aaguid: r.aaguid,
|
||||
transports: r.transports,
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(StoredPasskey {
|
||||
id: r.id,
|
||||
did: column(r.did, col::PASSKEYS_DID)?,
|
||||
credential_id: r.credential_id,
|
||||
public_key: r.public_key,
|
||||
sign_count: r.sign_count,
|
||||
created_at: r.created_at,
|
||||
last_used: r.last_used,
|
||||
friendly_name: r.friendly_name,
|
||||
aaguid: r.aaguid,
|
||||
transports: r.transports,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_passkey_by_credential_id(
|
||||
@@ -985,18 +1025,21 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| StoredPasskey {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
credential_id: r.credential_id,
|
||||
public_key: r.public_key,
|
||||
sign_count: r.sign_count,
|
||||
created_at: r.created_at,
|
||||
last_used: r.last_used,
|
||||
friendly_name: r.friendly_name,
|
||||
aaguid: r.aaguid,
|
||||
transports: r.transports,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(StoredPasskey {
|
||||
id: r.id,
|
||||
did: column(r.did, col::PASSKEYS_DID)?,
|
||||
credential_id: r.credential_id,
|
||||
public_key: r.public_key,
|
||||
sign_count: r.sign_count,
|
||||
created_at: r.created_at,
|
||||
last_used: r.last_used,
|
||||
friendly_name: r.friendly_name,
|
||||
aaguid: r.aaguid,
|
||||
transports: r.transports,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn save_passkey(
|
||||
@@ -1431,13 +1474,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|r| UserLoginCheck {
|
||||
did: Did::from(r.did),
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|r| {
|
||||
Ok(UserLoginCheck {
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
password_hash: r.password_hash.map(PasswordHash::new),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_login_info_by_identifier(
|
||||
@@ -1458,11 +1502,11 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserLoginInfo {
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserLoginInfo {
|
||||
id: row.id,
|
||||
did: Did::from(row.did),
|
||||
did: column(row.did, col::USERS_DID)?,
|
||||
email: row.email,
|
||||
password_hash: row.password_hash.map(PasswordHash::new),
|
||||
password_required: row.password_required,
|
||||
@@ -1479,6 +1523,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
account_type: row.account_type,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_2fa_status_by_did(&self, did: &Did) -> Result<Option<User2faStatus>, DbError> {
|
||||
@@ -1527,10 +1572,10 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserSessionInfo {
|
||||
handle: Handle::from(row.handle),
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserSessionInfo {
|
||||
handle: column(row.handle, col::USERS_HANDLE)?,
|
||||
email: row.email,
|
||||
is_admin: row.is_admin,
|
||||
deactivated_at: row.deactivated_at,
|
||||
@@ -1549,6 +1594,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
email_2fa_enabled: row.email_2fa_enabled,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_legacy_login_pref(
|
||||
@@ -1620,12 +1666,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserLoginFull {
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserLoginFull {
|
||||
id: row.id,
|
||||
did: Did::from(row.did),
|
||||
handle: Handle::from(row.handle),
|
||||
did: column(row.did, col::USERS_DID)?,
|
||||
handle: column(row.handle, col::USERS_HANDLE)?,
|
||||
password_hash: row.password_hash.map(PasswordHash::new),
|
||||
email: row.email,
|
||||
deactivated_at: row.deactivated_at,
|
||||
@@ -1645,6 +1691,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
email_2fa_enabled: row.email_2fa_enabled,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_confirm_signup_by_did(
|
||||
@@ -1664,12 +1711,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserConfirmSignup {
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserConfirmSignup {
|
||||
id: row.id,
|
||||
did: Did::from(row.did),
|
||||
handle: Handle::from(row.handle),
|
||||
did: column(row.did, col::USERS_DID)?,
|
||||
handle: column(row.handle, col::USERS_HANDLE)?,
|
||||
email: row.email,
|
||||
channel: row.channel,
|
||||
discord_username: row.discord_username,
|
||||
@@ -1679,6 +1726,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
encryption_version: row.encryption_version,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_resend_verification_by_did(
|
||||
@@ -1697,11 +1745,11 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserResendVerification {
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserResendVerification {
|
||||
id: row.id,
|
||||
handle: Handle::from(row.handle),
|
||||
handle: column(row.handle, col::USERS_HANDLE)?,
|
||||
email: row.email,
|
||||
channel: row.channel,
|
||||
discord_username: row.discord_username,
|
||||
@@ -1715,6 +1763,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn set_channel_verified(&self, did: &Did, channel: CommsChannel) -> Result<(), DbError> {
|
||||
@@ -1760,14 +1809,18 @@ impl UserRepository for PostgresUserRepository {
|
||||
}
|
||||
|
||||
async fn get_handles_by_email(&self, email: &str) -> Result<Vec<Handle>, DbError> {
|
||||
sqlx::query_scalar!(
|
||||
let handles = sqlx::query_scalar!(
|
||||
"SELECT handle FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL ORDER BY created_at DESC",
|
||||
email
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map(|handles| handles.into_iter().map(Handle::from).collect())
|
||||
.map_err(map_sqlx_error)
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(handles
|
||||
.into_iter()
|
||||
.filter_map(|h| legacy_column(h, col::USERS_HANDLE))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn set_password_reset_code(
|
||||
@@ -1798,15 +1851,16 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserResetCodeInfo {
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserResetCodeInfo {
|
||||
id: row.id,
|
||||
did: Did::from(row.did),
|
||||
did: column(row.did, col::USERS_DID)?,
|
||||
preferred_comms_channel: row.preferred_comms_channel,
|
||||
expires_at: row.password_reset_code_expires_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn clear_password_reset_code(&self, user_id: Uuid) -> Result<(), DbError> {
|
||||
@@ -1894,12 +1948,11 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
let did = column(user_did, col::USERS_DID)?;
|
||||
|
||||
tx.commit().await.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(PasswordResetResult {
|
||||
did: Did::from(user_did),
|
||||
session_jtis,
|
||||
})
|
||||
Ok(PasswordResetResult { did, session_jtis })
|
||||
}
|
||||
|
||||
async fn activate_account(&self, did: &Did) -> Result<bool, DbError> {
|
||||
@@ -2004,14 +2057,15 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserForDeletion {
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserForDeletion {
|
||||
id: row.id,
|
||||
password_hash: row.password_hash.map(PasswordHash::new),
|
||||
handle: Handle::from(row.handle),
|
||||
handle: column(row.handle, col::USERS_HANDLE)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_user_key_by_did(&self, did: &Did) -> Result<Option<UserKeyInfo>, DbError> {
|
||||
@@ -2154,11 +2208,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserForDidDoc {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
deactivated_at: r.deactivated_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserForDidDoc {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
deactivated_at: r.deactivated_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_user_for_did_doc_build(
|
||||
@@ -2173,11 +2230,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserForDidDocBuild {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
migrated_to_pds: r.migrated_to_pds,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserForDidDocBuild {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
migrated_to_pds: r.migrated_to_pds,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn upsert_did_web_overrides(
|
||||
@@ -2234,13 +2294,16 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserForPasskeySetup {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
recovery_token: r.recovery_token,
|
||||
recovery_token_expires_at: r.recovery_token_expires_at,
|
||||
password_required: r.password_required,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserForPasskeySetup {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
recovery_token: r.recovery_token,
|
||||
recovery_token_expires_at: r.recovery_token_expires_at,
|
||||
password_required: r.password_required,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_user_for_passkey_recovery(
|
||||
@@ -2257,12 +2320,15 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserForPasskeyRecovery {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
password_required: r.password_required,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserForPasskeyRecovery {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
password_required: r.password_required,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn set_recovery_token(
|
||||
@@ -2292,13 +2358,16 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserForRecovery {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
preferred_comms_channel: r.preferred_comms_channel,
|
||||
recovery_token: r.recovery_token,
|
||||
recovery_token_expires_at: r.recovery_token_expires_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserForRecovery {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
preferred_comms_channel: r.preferred_comms_channel,
|
||||
recovery_token: r.recovery_token,
|
||||
recovery_token_expires_at: r.recovery_token_expires_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_accounts_scheduled_for_deletion(
|
||||
@@ -2322,10 +2391,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| tranquil_db_traits::ScheduledDeletionAccount {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
.filter_map(|r| {
|
||||
Some(tranquil_db_traits::ScheduledDeletionAccount {
|
||||
id: r.id,
|
||||
did: legacy_column(r.did, col::USERS_DID)?,
|
||||
handle: legacy_column(r.handle, col::USERS_HANDLE)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -3029,13 +3100,15 @@ impl UserRepository for PostgresUserRepository {
|
||||
));
|
||||
}
|
||||
|
||||
let old_handle = legacy_column(old_handle, col::USERS_HANDLE);
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| tranquil_db_traits::MigrationReactivationError::Database(e.to_string()))?;
|
||||
|
||||
Ok(tranquil_db_traits::ReactivatedAccountInfo {
|
||||
user_id: account_id,
|
||||
old_handle: Handle::from(old_handle),
|
||||
old_handle,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,11 +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 = { path = "../tranquil-types", default-features = false }
|
||||
tranquil-types = { workspace = true }
|
||||
tranquil-infra = { workspace = true, optional = true, features = ["cache-keys"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -20,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,9 +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;
|
||||
@@ -17,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,
|
||||
@@ -44,6 +53,7 @@ pub struct DynamicRegistry {
|
||||
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> {
|
||||
@@ -70,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 =
|
||||
@@ -105,13 +124,17 @@ impl DynamicRegistry {
|
||||
}
|
||||
|
||||
pub fn is_negative_cached(&self, nsid: &Nsid) -> bool {
|
||||
let cache = self.negative_cache.read();
|
||||
cache
|
||||
.get(nsid)
|
||||
.is_some_and(|entry| entry.expires_at > Instant::now())
|
||||
self.negative_remaining(nsid).is_some()
|
||||
}
|
||||
|
||||
fn insert_negative(&self, nsid: &Nsid) {
|
||||
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();
|
||||
@@ -120,7 +143,7 @@ impl DynamicRegistry {
|
||||
cache.insert(
|
||||
nsid.clone(),
|
||||
NegativeEntry {
|
||||
expires_at: Instant::now() + NEGATIVE_CACHE_TTL,
|
||||
expires_at: Instant::now() + ttl,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -159,6 +182,44 @@ impl DynamicRegistry {
|
||||
arc
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -203,15 +264,23 @@ impl DynamicRegistry {
|
||||
|
||||
match self.acquire_leadership(nsid) {
|
||||
Some(_guard) => match resolver(nsid.clone()).await {
|
||||
Ok(doc) => Ok(self.insert_schema(doc)),
|
||||
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,
|
||||
error = %e,
|
||||
"lexicon refresh failed, serving stale cached entry"
|
||||
source,
|
||||
"lexicon refresh failed, serving cached entry"
|
||||
);
|
||||
Ok(stale)
|
||||
Ok(doc)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
@@ -230,34 +299,59 @@ impl DynamicRegistry {
|
||||
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.clone(),
|
||||
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
|
||||
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.clone()).await {
|
||||
Ok(doc) => Ok(self.insert_schema(doc)),
|
||||
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 {
|
||||
match (self.get_cached(nsid), self.negative_remaining(nsid)) {
|
||||
(Some(doc), _) => Ok(doc),
|
||||
(None, Some(remaining)) => Err(ResolveError::NegativelyCached {
|
||||
nsid: nsid.clone(),
|
||||
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
|
||||
ttl_secs: remaining.as_secs(),
|
||||
}),
|
||||
None => Err(ResolveError::LeaderAborted { nsid: nsid.clone() }),
|
||||
(None, None) => Err(ResolveError::LeaderAborted { nsid: nsid.clone() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +410,7 @@ impl Default for DynamicRegistry {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tranquil_infra::MemoryCache;
|
||||
|
||||
fn nsid(s: &str) -> Nsid {
|
||||
s.parse().unwrap()
|
||||
@@ -324,19 +419,19 @@ mod tests {
|
||||
#[test]
|
||||
fn test_negative_cache() {
|
||||
let registry = DynamicRegistry::new();
|
||||
assert!(!registry.is_negative_cached(&nsid("com.example.test")));
|
||||
assert!(!registry.is_negative_cached(&nsid("pet.nel.negative")));
|
||||
|
||||
registry.insert_negative(&nsid("com.example.test"));
|
||||
assert!(registry.is_negative_cached(&nsid("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(&nsid("com.example.cached"));
|
||||
registry.insert_negative(&nsid("pet.nel.cached"), NEGATIVE_CACHE_TTL);
|
||||
|
||||
let err = registry
|
||||
.resolve_and_cache(&nsid("com.example.cached"))
|
||||
.resolve_and_cache(&nsid("pet.nel.cached"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
@@ -383,17 +478,17 @@ mod tests {
|
||||
fn test_negative_cache_cleared_on_insert() {
|
||||
let registry = DynamicRegistry::new();
|
||||
|
||||
registry.insert_negative(&nsid("com.example.test"));
|
||||
assert!(registry.is_negative_cached(&nsid("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: nsid("com.example.test"),
|
||||
id: nsid("pet.nel.cleared"),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
|
||||
assert!(!registry.is_negative_cached(&nsid("com.example.test")));
|
||||
assert!(!registry.is_negative_cached(&nsid("pet.nel.cleared")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -692,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,11 @@ impl LexiconRegistry {
|
||||
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 {
|
||||
|
||||
@@ -4,7 +4,10 @@ use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
||||
use reqwest::Client;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use tranquil_types::{Did, Nsid};
|
||||
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();
|
||||
|
||||
@@ -17,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")
|
||||
})
|
||||
@@ -63,6 +67,8 @@ pub enum ResolveError {
|
||||
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")]
|
||||
@@ -73,6 +79,23 @@ pub enum ResolveError {
|
||||
LeaderAborted { nsid: Nsid },
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -123,7 +146,7 @@ pub async fn resolve_did_from_dns(authority: &str) -> Result<Did, ResolveError>
|
||||
pub async fn resolve_pds_endpoint(
|
||||
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
|
||||
@@ -131,7 +154,20 @@ 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.clone(),
|
||||
@@ -162,39 +198,29 @@ pub async fn resolve_pds_endpoint(
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
extract_pds_endpoint(&doc).ok_or_else(|| ResolveError::NoPdsEndpoint { did: did.clone() })
|
||||
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,
|
||||
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.as_str()),
|
||||
urlencoding::encode(nsid.as_str())
|
||||
);
|
||||
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 {
|
||||
@@ -204,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)
|
||||
@@ -292,6 +335,27 @@ mod tests {
|
||||
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!(
|
||||
@@ -316,57 +380,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tranquil_types::Nsid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LexiconDoc {
|
||||
pub lexicon: u32,
|
||||
pub id: Nsid,
|
||||
@@ -10,7 +10,7 @@ pub struct LexiconDoc {
|
||||
pub defs: HashMap<String, LexDef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum LexDef {
|
||||
#[serde(rename = "record")]
|
||||
@@ -35,14 +35,14 @@ pub enum LexDef {
|
||||
PermissionSet {},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LexRecord {
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
pub record: LexObject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LexObject {
|
||||
#[serde(default)]
|
||||
pub required: Vec<String>,
|
||||
@@ -52,7 +52,7 @@ pub struct LexObject {
|
||||
pub properties: HashMap<String, LexProperty>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum LexProperty {
|
||||
#[serde(rename = "string")]
|
||||
@@ -79,7 +79,7 @@ pub enum LexProperty {
|
||||
Object(LexObject),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexString {
|
||||
#[serde(default)]
|
||||
@@ -102,7 +102,7 @@ pub struct LexString {
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LexInteger {
|
||||
#[serde(default)]
|
||||
pub minimum: Option<i64>,
|
||||
@@ -116,7 +116,7 @@ pub struct LexInteger {
|
||||
pub const_value: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexBytes {
|
||||
#[serde(default)]
|
||||
@@ -125,7 +125,7 @@ pub struct LexBytes {
|
||||
pub min_length: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexBlob {
|
||||
#[serde(default)]
|
||||
@@ -134,7 +134,7 @@ pub struct LexBlob {
|
||||
pub max_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexArray {
|
||||
pub items: Box<LexProperty>,
|
||||
@@ -144,7 +144,7 @@ pub struct LexArray {
|
||||
pub max_length: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LexUnion {
|
||||
#[serde(default)]
|
||||
pub refs: Vec<String>,
|
||||
@@ -152,14 +152,14 @@ pub struct LexUnion {
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexRef {
|
||||
#[serde(rename = "ref")]
|
||||
pub reference: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum StringFormat {
|
||||
#[serde(rename = "did")]
|
||||
Did,
|
||||
@@ -204,6 +204,6 @@ pub fn parse_ref(reference: &str) -> ParsedRef<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexStringDef {}
|
||||
|
||||
@@ -74,7 +74,7 @@ async fn test_resolve_pds_endpoint_from_plc() {
|
||||
let endpoint = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(endpoint, "https://pds.example.com");
|
||||
assert_eq!(endpoint.as_str(), "https://pds.example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -130,14 +130,17 @@ async fn test_resolve_pds_endpoint_multiple_services_picks_pds() {
|
||||
"id": did,
|
||||
"service": [
|
||||
{
|
||||
"id": "#atproto_labeler",
|
||||
"type": "AtprotoLabeler",
|
||||
"serviceEndpoint": "https://labeler.example.com"
|
||||
},
|
||||
{
|
||||
"id": "#bsky_notif",
|
||||
"type": "BskyNotificationService",
|
||||
"serviceEndpoint": "https://notify.example.com"
|
||||
},
|
||||
{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": "https://pds.example.com"
|
||||
}
|
||||
@@ -149,7 +152,7 @@ async fn test_resolve_pds_endpoint_multiple_services_picks_pds() {
|
||||
let endpoint = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(endpoint, "https://pds.example.com");
|
||||
assert_eq!(endpoint.as_str(), "https://pds.example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -168,7 +171,7 @@ async fn test_fetch_schema_from_pds_success() {
|
||||
.await;
|
||||
|
||||
let doc = fetch_schema_from_pds(
|
||||
&pds_server.uri(),
|
||||
&pds_server.uri().parse().unwrap(),
|
||||
&did.parse().unwrap(),
|
||||
&nsid.parse().unwrap(),
|
||||
)
|
||||
@@ -195,7 +198,7 @@ async fn test_fetch_schema_missing_value_field() {
|
||||
.await;
|
||||
|
||||
let result = fetch_schema_from_pds(
|
||||
&pds_server.uri(),
|
||||
&pds_server.uri().parse().unwrap(),
|
||||
&did.parse().unwrap(),
|
||||
&nsid.parse().unwrap(),
|
||||
)
|
||||
@@ -222,7 +225,7 @@ async fn test_fetch_schema_invalid_lexicon_json() {
|
||||
.await;
|
||||
|
||||
let result = fetch_schema_from_pds(
|
||||
&pds_server.uri(),
|
||||
&pds_server.uri().parse().unwrap(),
|
||||
&did.parse().unwrap(),
|
||||
&nsid.parse().unwrap(),
|
||||
)
|
||||
@@ -352,7 +355,7 @@ async fn test_pds_trailing_slash_handled() {
|
||||
|
||||
let pds_url_with_slash = format!("{}/", pds_server.uri());
|
||||
let doc = fetch_schema_from_pds(
|
||||
&pds_url_with_slash,
|
||||
&pds_url_with_slash.parse().unwrap(),
|
||||
&did.parse().unwrap(),
|
||||
&nsid.parse().unwrap(),
|
||||
)
|
||||
@@ -377,7 +380,7 @@ async fn test_fetch_schema_error_status_gives_meaningful_error() {
|
||||
.await;
|
||||
|
||||
let result = fetch_schema_from_pds(
|
||||
&pds_server.uri(),
|
||||
&pds_server.uri().parse().unwrap(),
|
||||
&did.parse().unwrap(),
|
||||
&nsid.parse().unwrap(),
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ tranquil-types = { workspace = true }
|
||||
tranquil-config = { workspace = true }
|
||||
tranquil-crypto = { workspace = true }
|
||||
tranquil-db-traits = { workspace = true }
|
||||
tranquil-scopes = { workspace = true }
|
||||
|
||||
axum = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
@@ -33,3 +34,10 @@ tracing = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
webauthn-rs = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
tranquil-infra = { workspace = true, features = ["testing"] }
|
||||
|
||||
[features]
|
||||
bsky = []
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use tranquil_types::Nsid;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScopeInfo {
|
||||
@@ -8,6 +9,35 @@ pub struct ScopeInfo {
|
||||
pub description: String,
|
||||
pub display_name: String,
|
||||
pub granted: Option<bool>,
|
||||
pub restricted: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effective_scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PermissionSetInfo {
|
||||
pub nsid: Nsid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aud: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
pub include_scope: String,
|
||||
pub expanded: Vec<ScopeInfo>,
|
||||
pub granted: Option<bool>,
|
||||
pub restricted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FailedSetInfo {
|
||||
// `given_*` is the value as requested by the client.
|
||||
// Left as strings because the value from the client could be malformed.
|
||||
#[serde(rename = "nsid")]
|
||||
pub given_nsid: String,
|
||||
#[serde(rename = "aud", skip_serializing_if = "Option::is_none")]
|
||||
pub given_aud: Option<String>,
|
||||
pub reason: tranquil_scopes::ResolveFailure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -18,6 +48,8 @@ pub struct ConsentResponse {
|
||||
pub client_uri: Option<String>,
|
||||
pub logo_uri: Option<String>,
|
||||
pub scopes: Vec<ScopeInfo>,
|
||||
pub permission_sets: Vec<PermissionSetInfo>,
|
||||
pub failed_sets: Vec<FailedSetInfo>,
|
||||
pub show_consent: bool,
|
||||
pub did: Did,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -88,7 +120,7 @@ pub async fn consent_get(
|
||||
};
|
||||
|
||||
let did = flow_with_user.did().clone();
|
||||
let client_cache = ClientMetadataCache::new(3600);
|
||||
let client_cache = &state.client_metadata_cache;
|
||||
let client_metadata = client_cache
|
||||
.get(&request_data.parameters.client_id)
|
||||
.await
|
||||
@@ -116,26 +148,14 @@ pub async fn consent_get(
|
||||
None
|
||||
};
|
||||
|
||||
let effective_scope_str = if let Some(ref grant) = delegation_grant {
|
||||
tranquil_pds::delegation::intersect_scopes(
|
||||
requested_scope_str,
|
||||
grant.granted_scopes.as_str(),
|
||||
)
|
||||
} else {
|
||||
requested_scope_str.to_string()
|
||||
let authority = match delegation_grant.as_ref() {
|
||||
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
|
||||
None => scope_resolution::Authority::FullSelf,
|
||||
};
|
||||
|
||||
let expanded_scope_str = match expand_include_scopes(&effective_scope_str).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_scope",
|
||||
&format!("Failed to expand permission set: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
let requested_scopes: Vec<&str> = expanded_scope_str.split_whitespace().collect();
|
||||
let effective =
|
||||
scope_resolution::resolve_effective_scopes(&*state.cache, requested_scope_str, authority)
|
||||
.await;
|
||||
let requested_scopes: Vec<&str> = effective.permitted.split_whitespace().collect();
|
||||
let preferences = state
|
||||
.repos
|
||||
.oauth
|
||||
@@ -146,30 +166,51 @@ pub async fn consent_get(
|
||||
.iter()
|
||||
.map(|p| (p.scope.as_str(), p.granted))
|
||||
.collect();
|
||||
let requested_scope_strings: Vec<String> =
|
||||
requested_scopes.iter().map(|s| s.to_string()).collect();
|
||||
let presented_item_strings: Vec<String> = effective
|
||||
.outcome
|
||||
.passthrough
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(effective.outcome.sets.iter().map(|g| match &g.aud {
|
||||
Some(a) => format!("include:{}?aud={}", g.nsid, a),
|
||||
None => format!("include:{}", g.nsid),
|
||||
}))
|
||||
.collect();
|
||||
let show_consent = should_show_consent(
|
||||
state.repos.oauth.as_ref(),
|
||||
&did,
|
||||
&request_data.parameters.client_id,
|
||||
&requested_scope_strings,
|
||||
&presented_item_strings,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
|
||||
let scopes: Vec<ScopeInfo> = requested_scopes
|
||||
.iter()
|
||||
.map(|scope| {
|
||||
let (category, required, description, display_name) = if let Some(def) =
|
||||
tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(*scope)
|
||||
{
|
||||
let desc = if *scope == "atproto" && has_granular_scopes {
|
||||
|
||||
let grant_scope_str: Option<&str> =
|
||||
delegation_grant.as_ref().map(|g| g.granted_scopes.as_str());
|
||||
let coverage_of = |scope: &str| -> tranquil_pds::delegation::GrantCoverage {
|
||||
match grant_scope_str {
|
||||
Some(g) => tranquil_pds::delegation::grant_coverage(g, scope),
|
||||
None => tranquil_pds::delegation::GrantCoverage::Full,
|
||||
}
|
||||
};
|
||||
|
||||
let make_scope_info = |scope: &str| -> ScopeInfo {
|
||||
let (restricted, effective_scope) = match coverage_of(scope) {
|
||||
tranquil_pds::delegation::GrantCoverage::Full => (false, None),
|
||||
tranquil_pds::delegation::GrantCoverage::Narrowed(narrowed) => (false, Some(narrowed)),
|
||||
tranquil_pds::delegation::GrantCoverage::Withheld => (true, None),
|
||||
};
|
||||
let described = effective_scope.as_deref().unwrap_or(scope);
|
||||
let (category, required, description, display_name) =
|
||||
if let Some(def) = tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(described) {
|
||||
let desc = if described == "atproto" && has_granular_scopes {
|
||||
"AT Protocol baseline scope (permissions determined by selected options below)"
|
||||
.to_string()
|
||||
} else {
|
||||
def.description.to_string()
|
||||
};
|
||||
let name = if *scope == "atproto" && has_granular_scopes {
|
||||
let name = if described == "atproto" && has_granular_scopes {
|
||||
"AT Protocol Access".to_string()
|
||||
} else {
|
||||
def.display_name.to_string()
|
||||
@@ -180,33 +221,76 @@ pub async fn consent_get(
|
||||
desc,
|
||||
name,
|
||||
)
|
||||
} else if scope.starts_with("ref:") {
|
||||
} else if described.starts_with("ref:") {
|
||||
(
|
||||
"Reference".to_string(),
|
||||
false,
|
||||
"Referenced scope".to_string(),
|
||||
scope.to_string(),
|
||||
described.to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Other".to_string(),
|
||||
false,
|
||||
format!("Access to {}", scope),
|
||||
scope.to_string(),
|
||||
format!("Access to {}", described),
|
||||
described.to_string(),
|
||||
)
|
||||
};
|
||||
let granted = pref_map.get(*scope).copied();
|
||||
ScopeInfo {
|
||||
scope: scope.to_string(),
|
||||
category,
|
||||
required,
|
||||
description,
|
||||
display_name,
|
||||
granted,
|
||||
let granted = pref_map.get(scope).copied();
|
||||
ScopeInfo {
|
||||
scope: scope.to_string(),
|
||||
category,
|
||||
required,
|
||||
description,
|
||||
display_name,
|
||||
granted,
|
||||
restricted,
|
||||
effective_scope,
|
||||
}
|
||||
};
|
||||
|
||||
let scopes: Vec<ScopeInfo> = effective
|
||||
.outcome
|
||||
.passthrough
|
||||
.iter()
|
||||
.map(|s| make_scope_info(s))
|
||||
.collect();
|
||||
|
||||
let permission_sets: Vec<PermissionSetInfo> = effective
|
||||
.outcome
|
||||
.sets
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let include_scope = match &g.aud {
|
||||
Some(a) => format!("include:{}?aud={}", g.nsid, a),
|
||||
None => format!("include:{}", g.nsid),
|
||||
};
|
||||
let expanded: Vec<ScopeInfo> = g.expanded.iter().map(|s| make_scope_info(s)).collect();
|
||||
let restricted = !expanded.is_empty() && expanded.iter().all(|s| s.restricted);
|
||||
PermissionSetInfo {
|
||||
nsid: g.nsid.clone(),
|
||||
aud: g.aud.clone(),
|
||||
title: g.title.clone(),
|
||||
detail: g.detail.clone(),
|
||||
granted: pref_map.get(include_scope.as_str()).copied(),
|
||||
include_scope,
|
||||
expanded,
|
||||
restricted,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let failed_sets: Vec<FailedSetInfo> = effective
|
||||
.outcome
|
||||
.failures
|
||||
.iter()
|
||||
.map(|f| FailedSetInfo {
|
||||
given_nsid: f.given_nsid.clone(),
|
||||
given_aud: f.given_aud.clone(),
|
||||
reason: f.reason.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let account_handle = state
|
||||
.repos
|
||||
.user
|
||||
@@ -255,6 +339,8 @@ pub async fn consent_get(
|
||||
client_uri: client_metadata.as_ref().and_then(|m| m.client_uri.clone()),
|
||||
logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()),
|
||||
scopes,
|
||||
permission_sets,
|
||||
failed_sets,
|
||||
show_consent,
|
||||
did: did.clone(),
|
||||
handle: account_handle,
|
||||
@@ -342,16 +428,53 @@ pub async fn consent_post(
|
||||
None => None,
|
||||
};
|
||||
|
||||
let effective_scope_str = if let Some(ref grant) = delegation_grant {
|
||||
tranquil_pds::delegation::intersect_scopes(
|
||||
original_scope_str,
|
||||
grant.granted_scopes.as_str(),
|
||||
)
|
||||
} else {
|
||||
original_scope_str.to_string()
|
||||
let authority = match delegation_grant.as_ref() {
|
||||
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
|
||||
None => scope_resolution::Authority::FullSelf,
|
||||
};
|
||||
let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
|
||||
let atproto_was_requested = requested_scopes.contains(&"atproto");
|
||||
let effective =
|
||||
scope_resolution::resolve_effective_scopes(&*state.cache, original_scope_str, authority)
|
||||
.await;
|
||||
let include_token = |nsid: &str, aud: &Option<String>| -> String {
|
||||
match aud {
|
||||
Some(a) => format!("include:{}?aud={}", nsid, a),
|
||||
None => format!("include:{}", nsid),
|
||||
}
|
||||
};
|
||||
let approved_failed_sets: Vec<String> = effective
|
||||
.outcome
|
||||
.failures
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
form.approved_scopes
|
||||
.contains(&include_token(&f.given_nsid, &f.given_aud))
|
||||
})
|
||||
.map(|f| f.given_nsid.clone())
|
||||
.collect();
|
||||
if !approved_failed_sets.is_empty() {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_scope",
|
||||
&format!(
|
||||
"Could not resolve approved permission set(s): {}",
|
||||
approved_failed_sets.join(", ")
|
||||
),
|
||||
);
|
||||
}
|
||||
let presented_items: Vec<String> = effective
|
||||
.outcome
|
||||
.passthrough
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(
|
||||
effective
|
||||
.outcome
|
||||
.sets
|
||||
.iter()
|
||||
.map(|g| include_token(&g.nsid, &g.aud)),
|
||||
)
|
||||
.collect();
|
||||
let atproto_was_requested = presented_items.iter().any(|s| s == "atproto");
|
||||
if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -359,7 +482,8 @@ pub async fn consent_post(
|
||||
"The atproto scope was requested and must be approved",
|
||||
);
|
||||
}
|
||||
let final_approved: Vec<String> = form.approved_scopes.clone();
|
||||
let mut final_approved: Vec<String> = form.approved_scopes.clone();
|
||||
final_approved.retain(|s| presented_items.iter().any(|p| p == s) || s == "atproto");
|
||||
if final_approved.is_empty() {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -377,11 +501,11 @@ pub async fn consent_post(
|
||||
);
|
||||
}
|
||||
if form.remember {
|
||||
let preferences: Vec<ScopePreference> = requested_scopes
|
||||
let preferences: Vec<ScopePreference> = presented_items
|
||||
.iter()
|
||||
.map(|s| ScopePreference {
|
||||
scope: s.to_string(),
|
||||
granted: form.approved_scopes.contains(&s.to_string()),
|
||||
scope: s.clone(),
|
||||
granted: form.approved_scopes.contains(s),
|
||||
})
|
||||
.collect();
|
||||
let _ = state
|
||||
|
||||
@@ -80,7 +80,7 @@ pub async fn authorize_get(
|
||||
"Authorization request has expired. Please start a new request.",
|
||||
);
|
||||
}
|
||||
let client_cache = ClientMetadataCache::new(3600);
|
||||
let client_cache = &state.client_metadata_cache;
|
||||
let client_name = client_cache
|
||||
.get(&request_data.parameters.client_id)
|
||||
.await
|
||||
|
||||
@@ -14,8 +14,7 @@ use tranquil_db_traits::{ScopePreference, WebauthnChallengeType};
|
||||
use tranquil_pds::auth::{BareLoginIdentifier, NormalizedLoginIdentifier};
|
||||
use tranquil_pds::comms::comms_repo::enqueue_2fa_code;
|
||||
use tranquil_pds::oauth::{
|
||||
AuthFlow, ClientMetadataCache, DeviceData, DeviceId, OAuthError, Prompt, SessionId,
|
||||
db::should_show_consent, scopes::expand_include_scopes,
|
||||
AuthFlow, DeviceData, DeviceId, OAuthError, Prompt, SessionId, db::should_show_consent,
|
||||
};
|
||||
use tranquil_pds::rate_limit::{
|
||||
OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit,
|
||||
@@ -300,6 +299,7 @@ mod consent;
|
||||
mod login;
|
||||
mod passkey;
|
||||
mod registration;
|
||||
pub mod scope_resolution;
|
||||
mod two_factor;
|
||||
|
||||
pub use consent::*;
|
||||
|
||||
@@ -12,7 +12,16 @@ pub async fn register_complete(
|
||||
_rate_limit: OAuthRateLimited<OAuthRegisterCompleteLimit>,
|
||||
Json(form): Json<RegisterCompleteInput>,
|
||||
) -> Response {
|
||||
let did = Did::from(form.did.clone());
|
||||
let Ok(did) = Did::new(form.did.as_str()) else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid DID."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
@@ -97,12 +106,12 @@ pub async fn register_complete(
|
||||
}
|
||||
|
||||
if let Some(existing_did) = &request_data.did
|
||||
&& existing_did != &form.did
|
||||
&& existing_did != &did
|
||||
{
|
||||
tracing::warn!(
|
||||
request_uri = %form.request_uri,
|
||||
existing_did = %existing_did,
|
||||
attempted_did = %form.did,
|
||||
attempted_did = %did,
|
||||
"register_complete attempted with different DID than already bound"
|
||||
);
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
use tranquil_db_traits::DbScope;
|
||||
use tranquil_pds::cache::Cache;
|
||||
use tranquil_pds::delegation::intersect_scopes;
|
||||
use tranquil_pds::oauth::permission_set_resolver::expand_scopes;
|
||||
use tranquil_scopes::ExpansionOutcome;
|
||||
|
||||
pub enum Authority<'a> {
|
||||
FullSelf,
|
||||
Delegated(&'a DbScope),
|
||||
}
|
||||
|
||||
pub struct EffectiveScopes {
|
||||
// The expanded set of scopes, minus any denied by delegation
|
||||
pub permitted: String,
|
||||
// The expanded set of scopes, before delegation processing
|
||||
pub outcome: ExpansionOutcome,
|
||||
}
|
||||
|
||||
pub async fn resolve_effective_scopes(
|
||||
cache: &dyn Cache,
|
||||
requested: &str,
|
||||
authority: Authority<'_>,
|
||||
) -> EffectiveScopes {
|
||||
let outcome = expand_scopes(cache, requested).await;
|
||||
let expanded = outcome.to_scope_string();
|
||||
let permitted = match authority {
|
||||
Authority::FullSelf => expanded,
|
||||
Authority::Delegated(granted) => intersect_scopes(&expanded, granted.as_str()),
|
||||
};
|
||||
EffectiveScopes { permitted, outcome }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
use tranquil_infra::MemoryCache;
|
||||
use tranquil_pds::cache::Cache;
|
||||
|
||||
async fn cache_with(nsid: &str, scopes: &str) -> MemoryCache {
|
||||
let c = MemoryCache::new();
|
||||
let key = tranquil_pds::cache_keys::permission_set_key(
|
||||
&tranquil_types::Nsid::new(nsid).unwrap(),
|
||||
None,
|
||||
);
|
||||
let json = serde_json::json!({
|
||||
"scope": scopes,
|
||||
"title": null,
|
||||
"detail": null,
|
||||
"refreshed_at": chrono::Utc::now().timestamp(),
|
||||
})
|
||||
.to_string();
|
||||
let _ = c.set(&key, &json, Duration::from_secs(3600)).await;
|
||||
c
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_self_keeps_all_expanded() {
|
||||
let c = cache_with(
|
||||
"io.atcr.authFullApp",
|
||||
"repo:io.atcr.manifest?action=create identity:*",
|
||||
)
|
||||
.await;
|
||||
let eff = resolve_effective_scopes(
|
||||
&c,
|
||||
"atproto include:io.atcr.authFullApp",
|
||||
Authority::FullSelf,
|
||||
)
|
||||
.await;
|
||||
assert!(eff.permitted.contains("atproto"));
|
||||
assert!(
|
||||
eff.permitted
|
||||
.contains("repo:io.atcr.manifest?action=create")
|
||||
);
|
||||
assert!(eff.permitted.contains("identity:*"));
|
||||
assert!(eff.outcome.failures.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegated_intersects_expanded_against_grant() {
|
||||
let c = cache_with(
|
||||
"io.atcr.authFullApp",
|
||||
"repo:io.atcr.manifest?action=create identity:*",
|
||||
)
|
||||
.await;
|
||||
let granted = DbScope::new("atproto repo:* blob:*/* account:*?action=manage").unwrap();
|
||||
let eff = resolve_effective_scopes(
|
||||
&c,
|
||||
"atproto include:io.atcr.authFullApp",
|
||||
Authority::Delegated(&granted),
|
||||
)
|
||||
.await;
|
||||
assert!(eff.permitted.contains("atproto"));
|
||||
assert!(
|
||||
eff.permitted
|
||||
.contains("repo:io.atcr.manifest?action=create")
|
||||
);
|
||||
assert!(!eff.permitted.contains("identity"));
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ use tranquil_pds::rate_limit::{LoginLimit, OAuthRateLimited, TotpVerifyLimit};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::PlainPassword;
|
||||
use tranquil_pds::util::ClientIp;
|
||||
use tranquil_types::did_doc::{extract_handle, extract_pds_endpoint};
|
||||
use tranquil_types::did_doc::{PdsEndpointError, extract_handle, extract_pds_endpoint};
|
||||
use tranquil_types::url_kind;
|
||||
use tranquil_types::{Did, RequestId};
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
@@ -231,11 +232,17 @@ pub async fn delegation_auth(
|
||||
}
|
||||
};
|
||||
|
||||
let pds_url = match extract_pds_endpoint(&did_doc) {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
let pds_url = match extract_pds_endpoint::<url_kind::Pds>(&did_doc) {
|
||||
Ok(url) => url,
|
||||
Err(PdsEndpointError::Missing) => {
|
||||
return DelegationAuthResponse::err("Controller has no PDS endpoint");
|
||||
}
|
||||
Err(PdsEndpointError::Invalid(e)) => {
|
||||
tracing::warn!(controller = %controller_did, error = %e, "Controller PDS endpoint rejected");
|
||||
return DelegationAuthResponse::err(
|
||||
"Controller PDS endpoint isn't a usable https URL",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
@@ -447,7 +454,7 @@ pub async fn delegation_auth_token(
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CrossPdsCallbackParams {
|
||||
pub code: tranquil_types::AuthorizationCode,
|
||||
pub state: String,
|
||||
pub state: tranquil_types::CrossPdsState,
|
||||
pub iss: Option<String>,
|
||||
}
|
||||
|
||||
@@ -474,7 +481,7 @@ pub async fn delegation_callback(
|
||||
|
||||
if let Some(ref expected_issuer) = auth_state.expected_issuer {
|
||||
match ¶ms.iss {
|
||||
Some(iss) if iss != expected_issuer => {
|
||||
Some(iss) if iss.as_str() != expected_issuer.as_str() => {
|
||||
tracing::error!(
|
||||
"Cross-PDS issuer mismatch: expected {}, got {}",
|
||||
expected_issuer,
|
||||
|
||||
@@ -3,8 +3,8 @@ use axum::{Json, extract::State, http::HeaderMap};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tranquil_pds::oauth::{
|
||||
AuthorizationRequestParameters, ClientAuth, ClientMetadataCache, CodeChallengeMethod,
|
||||
OAuthError, Prompt, RequestData, RequestId, ResponseMode, ResponseType,
|
||||
AuthorizationRequestParameters, ClientAuth, CodeChallengeMethod, OAuthError, Prompt,
|
||||
RequestData, RequestId, ResponseMode, ResponseType,
|
||||
scopes::{ParsedScope, parse_scope},
|
||||
};
|
||||
use tranquil_pds::rate_limit::{OAuthParLimit, OAuthRateLimited};
|
||||
@@ -80,7 +80,7 @@ pub async fn pushed_authorization_request(
|
||||
.ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?;
|
||||
let code_challenge_method =
|
||||
parse_code_challenge_method(request.code_challenge_method.as_deref())?;
|
||||
let client_cache = ClientMetadataCache::new(3600);
|
||||
let client_cache = &state.client_metadata_cache;
|
||||
let client_metadata = client_cache.get(&request.client_id).await?;
|
||||
client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?;
|
||||
let client_auth = determine_client_auth(&request)?;
|
||||
|
||||
@@ -7,12 +7,9 @@ use axum::http::{HeaderMap, Method};
|
||||
use chrono::{Duration, Utc};
|
||||
use tranquil_db_traits::RefreshTokenLookup;
|
||||
use tranquil_pds::config::AuthConfig;
|
||||
use tranquil_pds::delegation::intersect_scopes;
|
||||
use tranquil_pds::oauth::{
|
||||
AuthFlow, ClientAuth, ClientMetadataCache, DPoPVerifier, OAuthError, RefreshToken, TokenData,
|
||||
TokenId,
|
||||
AuthFlow, ClientAuth, DPoPVerifier, OAuthError, RefreshToken, TokenData, TokenId,
|
||||
db::{enforce_token_limit_for_user, lookup_refresh_token},
|
||||
scopes::expand_include_scopes,
|
||||
verify_client_auth,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
@@ -65,7 +62,7 @@ pub async fn handle_authorization_code_grant(
|
||||
return Err(OAuthError::InvalidGrant("client_id mismatch".to_string()));
|
||||
}
|
||||
let did = authorized.did.clone();
|
||||
let client_metadata_cache = ClientMetadataCache::new(3600);
|
||||
let client_metadata_cache = &state.client_metadata_cache;
|
||||
let client_metadata = client_metadata_cache.get(&authorized.client_id).await?;
|
||||
let client_auth = match &request.client_auth {
|
||||
RequestClientAuth::PrivateKeyJwt {
|
||||
@@ -87,7 +84,7 @@ pub async fn handle_authorization_code_grant(
|
||||
},
|
||||
RequestClientAuth::None { .. } => ClientAuth::None,
|
||||
};
|
||||
verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?;
|
||||
verify_client_auth(client_metadata_cache, &client_metadata, &client_auth).await?;
|
||||
verify_pkce(&authorized.parameters.code_challenge, &code_verifier)?;
|
||||
if let Some(req_redirect_uri) = &redirect_uri
|
||||
&& req_redirect_uri != &authorized.parameters.redirect_uri
|
||||
@@ -132,46 +129,55 @@ pub async fn handle_authorization_code_grant(
|
||||
let refresh_token = RefreshToken::generate();
|
||||
let now = Utc::now();
|
||||
|
||||
let (raw_scope, controller_did) = if let Some(ref controller) = authorized.controller_did {
|
||||
let grant = state
|
||||
.repos
|
||||
.delegation
|
||||
.get_delegation(&did, controller)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let granted_scopes = match grant {
|
||||
Some(g) => g.granted_scopes,
|
||||
None => {
|
||||
return Err(OAuthError::InvalidGrant(
|
||||
"Delegation grant not found or revoked".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let requested = authorized.parameters.scope.as_deref().unwrap_or("atproto");
|
||||
let intersected = intersect_scopes(requested, granted_scopes.as_str());
|
||||
(Some(intersected), Some(controller.clone()))
|
||||
} else {
|
||||
(authorized.parameters.scope.clone(), None)
|
||||
};
|
||||
let controller_did = authorized.controller_did.clone();
|
||||
let requested_scope = authorized.parameters.scope.clone();
|
||||
|
||||
let final_scope = if let Some(ref scope) = raw_scope {
|
||||
if scope.contains("include:") {
|
||||
Some(expand_include_scopes(scope).await.map_err(|e| {
|
||||
OAuthError::InvalidScope(format!("Failed to expand permission set: {e}"))
|
||||
})?)
|
||||
let granted_scopes: Option<tranquil_db_traits::DbScope> =
|
||||
if let Some(ref controller) = controller_did {
|
||||
let grant = state
|
||||
.repos
|
||||
.delegation
|
||||
.get_delegation(&did, controller)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| {
|
||||
OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string())
|
||||
})?;
|
||||
Some(grant.granted_scopes.clone())
|
||||
} else {
|
||||
raw_scope
|
||||
}
|
||||
} else {
|
||||
raw_scope
|
||||
None
|
||||
};
|
||||
let authority = match granted_scopes.as_ref() {
|
||||
Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g),
|
||||
None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf,
|
||||
};
|
||||
let requested_for_resolve = requested_scope.as_deref().unwrap_or("atproto");
|
||||
let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes(
|
||||
&*state.cache,
|
||||
requested_for_resolve,
|
||||
authority,
|
||||
)
|
||||
.await;
|
||||
if !effective.outcome.failures.is_empty() {
|
||||
let names: Vec<String> = effective
|
||||
.outcome
|
||||
.failures
|
||||
.iter()
|
||||
.map(|f| f.given_nsid.clone())
|
||||
.collect();
|
||||
return Err(OAuthError::InvalidScope(format!(
|
||||
"Could not resolve permission set(s): {}",
|
||||
names.join(", ")
|
||||
)));
|
||||
}
|
||||
let resolved_scope = effective.permitted;
|
||||
|
||||
let access_token = create_access_token_with_delegation(
|
||||
&token_id,
|
||||
&did,
|
||||
dpop_jkt.as_ref(),
|
||||
final_scope.as_deref(),
|
||||
Some(resolved_scope.as_str()),
|
||||
controller_did.as_ref(),
|
||||
)?;
|
||||
let stored_client_auth = authorized.client_auth.unwrap_or(ClientAuth::None);
|
||||
@@ -195,7 +201,7 @@ pub async fn handle_authorization_code_grant(
|
||||
details: None,
|
||||
code: None,
|
||||
current_refresh_token: Some(refresh_token.clone()),
|
||||
scope: final_scope.clone(),
|
||||
scope: requested_scope.clone(),
|
||||
controller_did: controller_did.clone(),
|
||||
};
|
||||
state
|
||||
@@ -237,12 +243,58 @@ pub async fn handle_authorization_code_grant(
|
||||
},
|
||||
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
refresh_token: Some(refresh_token),
|
||||
scope: final_scope,
|
||||
scope: Some(resolved_scope.clone()),
|
||||
sub: Some(did),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn recompute_resolved_scope(
|
||||
state: &AppState,
|
||||
token_data: &TokenData,
|
||||
) -> Result<String, OAuthError> {
|
||||
let requested = token_data.scope.as_deref().unwrap_or("atproto");
|
||||
let granted_scopes: Option<tranquil_db_traits::DbScope> =
|
||||
if let Some(ref controller) = token_data.controller_did {
|
||||
let grant = state
|
||||
.repos
|
||||
.delegation
|
||||
.get_delegation(&token_data.did, controller)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| {
|
||||
OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string())
|
||||
})?;
|
||||
Some(grant.granted_scopes.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let authority = match granted_scopes.as_ref() {
|
||||
Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g),
|
||||
None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf,
|
||||
};
|
||||
let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes(
|
||||
&*state.cache,
|
||||
requested,
|
||||
authority,
|
||||
)
|
||||
.await;
|
||||
if !effective.outcome.failures.is_empty() {
|
||||
let names: Vec<String> = effective
|
||||
.outcome
|
||||
.failures
|
||||
.iter()
|
||||
.map(|f| f.given_nsid.clone())
|
||||
.collect();
|
||||
return Err(OAuthError::InvalidScope(format!(
|
||||
"Permission set(s) expired and unresolvable: {}",
|
||||
names.join(", ")
|
||||
)));
|
||||
}
|
||||
Ok(effective.permitted)
|
||||
}
|
||||
|
||||
pub async fn handle_refresh_token_grant(
|
||||
state: AppState,
|
||||
_headers: HeaderMap,
|
||||
@@ -282,11 +334,12 @@ pub async fn handle_refresh_token_grant(
|
||||
"Refresh token reuse within grace period, returning existing tokens"
|
||||
);
|
||||
let dpop_jkt = token_data.parameters.dpop_jkt.as_ref();
|
||||
let resolved = recompute_resolved_scope(&state, &token_data).await?;
|
||||
let access_token = create_access_token_with_delegation(
|
||||
&token_data.token_id,
|
||||
&token_data.did,
|
||||
dpop_jkt,
|
||||
token_data.scope.as_deref(),
|
||||
Some(resolved.as_str()),
|
||||
token_data.controller_did.as_ref(),
|
||||
)?;
|
||||
let mut response_headers = HeaderMap::new();
|
||||
@@ -307,7 +360,7 @@ pub async fn handle_refresh_token_grant(
|
||||
},
|
||||
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
refresh_token: token_data.current_refresh_token,
|
||||
scope: token_data.scope,
|
||||
scope: Some(resolved),
|
||||
sub: Some(token_data.did),
|
||||
}),
|
||||
));
|
||||
@@ -396,11 +449,12 @@ pub async fn handle_refresh_token_grant(
|
||||
new_expires_at = %new_expires_at,
|
||||
"Refresh token rotated successfully"
|
||||
);
|
||||
let resolved = recompute_resolved_scope(&state, &token_data).await?;
|
||||
let access_token = create_access_token_with_delegation(
|
||||
&token_data.token_id,
|
||||
&token_data.did,
|
||||
dpop_jkt.as_ref(),
|
||||
token_data.scope.as_deref(),
|
||||
Some(resolved.as_str()),
|
||||
token_data.controller_did.as_ref(),
|
||||
)?;
|
||||
let mut response_headers = HeaderMap::new();
|
||||
@@ -421,7 +475,7 @@ pub async fn handle_refresh_token_grant(
|
||||
},
|
||||
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
refresh_token: Some(new_refresh_token),
|
||||
scope: token_data.scope,
|
||||
scope: Some(resolved),
|
||||
sub: Some(token_data.did),
|
||||
}),
|
||||
))
|
||||
|
||||
@@ -43,7 +43,8 @@ pub fn create_access_token_with_delegation(
|
||||
let issuer = format!("https://{}", pds_hostname);
|
||||
let now = Utc::now().timestamp();
|
||||
let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS;
|
||||
let actual_scope = scope.unwrap_or("atproto");
|
||||
let actual_scope = tranquil_pds::auth::encode_scope(scope.unwrap_or("atproto"))
|
||||
.map_err(|_| OAuthError::InvalidScope("Scope too large".to_string()))?;
|
||||
let mut payload = json!({
|
||||
"iss": issuer,
|
||||
"sub": sub.as_str(),
|
||||
|
||||
@@ -106,6 +106,10 @@ pub async fn introspect_token(
|
||||
Ok(info) => info,
|
||||
Err(_) => return Ok(Json(inactive_response)),
|
||||
};
|
||||
let jwt_info = match tranquil_pds::oauth::verify::extract_oauth_token_info(&request.token) {
|
||||
Ok(info) => info,
|
||||
Err(_) => return Ok(Json(inactive_response)),
|
||||
};
|
||||
let token_id = TokenId::from(token_info.sid.clone());
|
||||
let token_data = match state.repos.oauth.get_token_by_id(&token_id).await {
|
||||
Ok(Some(data)) => data,
|
||||
@@ -118,7 +122,7 @@ pub async fn introspect_token(
|
||||
let issuer = format!("https://{}", pds_hostname);
|
||||
Ok(Json(IntrospectResponse {
|
||||
active: true,
|
||||
scope: token_data.scope,
|
||||
scope: jwt_info.scope,
|
||||
client_id: Some(token_data.client_id),
|
||||
username: None,
|
||||
token_type: if token_data.parameters.dpop_jkt.is_some() {
|
||||
@@ -129,7 +133,7 @@ pub async fn introspect_token(
|
||||
exp: Some(token_info.exp),
|
||||
iat: Some(token_info.iat),
|
||||
nbf: Some(token_info.iat),
|
||||
sub: Some(token_data.did.to_string()),
|
||||
sub: Some(jwt_info.did.to_string()),
|
||||
aud: Some(issuer.clone()),
|
||||
iss: Some(issuer),
|
||||
jti: Some(token_info.jti),
|
||||
|
||||
@@ -1023,7 +1023,9 @@ pub async fn complete_registration(
|
||||
}
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
let self_hosted_did =
|
||||
tranquil_pds::types::Did::from(format!("did:web:{}", encoded_handle));
|
||||
tranquil_pds::types::Did::new(format!("did:web:{}", encoded_handle)).map_err(
|
||||
|_| ApiError::InvalidHandle(Some("Handle is not a valid did:web".into())),
|
||||
)?;
|
||||
tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account");
|
||||
self_hosted_did
|
||||
}
|
||||
@@ -1151,8 +1153,8 @@ pub async fn complete_registration(
|
||||
.filter(|s| !s.is_empty()),
|
||||
encrypted_key_bytes: encrypted_key_bytes.clone(),
|
||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||
commit_cid: tranquil_types::CidLink::from(commit_cid.to_string()),
|
||||
repo_rev: tranquil_types::Tid::from(rev.as_ref().to_string()),
|
||||
commit_cid: tranquil_types::CidLink::from_cid(&commit_cid),
|
||||
repo_rev: tranquil_types::Tid::from(rev.clone()),
|
||||
genesis_block_cids,
|
||||
invite_code: invite_registration.into_invite_code(),
|
||||
birthdate_pref,
|
||||
@@ -1207,20 +1209,23 @@ pub async fn complete_registration(
|
||||
tracing::warn!("Failed to sequence account event for {}: {}", did, e);
|
||||
}
|
||||
|
||||
let profile_record = json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": handle.as_str()
|
||||
});
|
||||
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
|
||||
#[cfg(feature = "bsky")]
|
||||
{
|
||||
tracing::warn!("Failed to create default profile for {}: {}", did, e);
|
||||
let profile_record = json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": handle.as_str()
|
||||
});
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
let app_password = generate_app_password();
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tranquil-types = { workspace = true }
|
||||
tranquil-infra = { workspace = true, features = ["cache-keys"] }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::OAuthError;
|
||||
use crate::types::ClientAuth;
|
||||
use tranquil_types::ClientId;
|
||||
use tranquil_infra::cache_keys::{
|
||||
oauth_client_jwks_cooldown_key, oauth_client_jwks_key, oauth_client_meta_key,
|
||||
};
|
||||
use tranquil_infra::{Cache, cached_json, write_json};
|
||||
use tranquil_types::{
|
||||
ClientId, JwksUri, ReachPolicy, dns_guard, redirect_policy, url_reach_permits,
|
||||
};
|
||||
|
||||
const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientMetadata {
|
||||
@@ -30,8 +37,12 @@ pub struct ClientMetadata {
|
||||
pub dpop_bound_access_tokens: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jwks: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jwks_uri: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "tranquil_types::http_url::deserialize_optional"
|
||||
)]
|
||||
pub jwks_uri: Option<JwksUri>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub application_type: Option<String>,
|
||||
}
|
||||
@@ -58,33 +69,23 @@ impl Default for ClientMetadata {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClientMetadataCache {
|
||||
cache: Arc<RwLock<HashMap<String, CachedMetadata>>>,
|
||||
jwks_cache: Arc<RwLock<HashMap<String, CachedJwks>>>,
|
||||
cache: Arc<dyn Cache>,
|
||||
http_client: Client,
|
||||
cache_ttl_secs: u64,
|
||||
}
|
||||
|
||||
struct CachedMetadata {
|
||||
metadata: ClientMetadata,
|
||||
cached_at: std::time::Instant,
|
||||
}
|
||||
|
||||
struct CachedJwks {
|
||||
jwks: serde_json::Value,
|
||||
cached_at: std::time::Instant,
|
||||
cache_ttl: Duration,
|
||||
}
|
||||
|
||||
impl ClientMetadataCache {
|
||||
pub fn new(cache_ttl_secs: u64) -> Self {
|
||||
pub fn new(cache: Arc<dyn Cache>, cache_ttl: Duration) -> Self {
|
||||
Self {
|
||||
cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
jwks_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
cache,
|
||||
http_client: {
|
||||
let builder = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.pool_max_idle_per_host(10)
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(90))
|
||||
.redirect(redirect_policy(ReachPolicy::DEBUG_LOOPBACK))
|
||||
.dns_resolver(dns_guard(ReachPolicy::DEBUG_LOOPBACK))
|
||||
.user_agent(concat!(
|
||||
"Tranquil-PDS/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
@@ -92,9 +93,11 @@ impl ClientMetadataCache {
|
||||
));
|
||||
#[cfg(feature = "native-tls-roots")]
|
||||
let builder = builder.danger_accept_invalid_certs(true);
|
||||
builder.build().unwrap_or_else(|_| Client::new())
|
||||
builder
|
||||
.build()
|
||||
.expect("failed to build client metadata HTTP client")
|
||||
},
|
||||
cache_ttl_secs,
|
||||
cache_ttl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +142,7 @@ impl ClientMetadataCache {
|
||||
response_types: vec!["code".into()],
|
||||
scope,
|
||||
token_endpoint_auth_method: Some("none".into()),
|
||||
dpop_bound_access_tokens: Some(false),
|
||||
dpop_bound_access_tokens: Some(true),
|
||||
jwks: None,
|
||||
jwks_uri: None,
|
||||
application_type: Some("native".into()),
|
||||
@@ -150,26 +153,13 @@ impl ClientMetadataCache {
|
||||
if Self::is_loopback_client(client_id) {
|
||||
return Self::build_loopback_metadata(client_id);
|
||||
}
|
||||
{
|
||||
let cache = self.cache.read().await;
|
||||
if let Some(cached) = cache.get(client_id.as_str())
|
||||
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs
|
||||
{
|
||||
return Ok(cached.metadata.clone());
|
||||
}
|
||||
}
|
||||
let metadata = self.fetch_metadata(client_id).await?;
|
||||
{
|
||||
let mut cache = self.cache.write().await;
|
||||
cache.insert(
|
||||
client_id.to_string(),
|
||||
CachedMetadata {
|
||||
metadata: metadata.clone(),
|
||||
cached_at: std::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(metadata)
|
||||
cached_json(
|
||||
self.cache.as_ref(),
|
||||
&oauth_client_meta_key(client_id),
|
||||
self.cache_ttl,
|
||||
|| self.fetch_metadata(client_id),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_jwks(
|
||||
@@ -181,43 +171,57 @@ impl ClientMetadataCache {
|
||||
}
|
||||
let jwks_uri = metadata.jwks_uri.as_ref().ok_or_else(|| {
|
||||
OAuthError::InvalidClient(
|
||||
"Client using private_key_jwt must have jwks or jwks_uri".to_string(),
|
||||
"Client using private_key_jwt must have jwks or a usable jwks_uri".to_string(),
|
||||
)
|
||||
})?;
|
||||
{
|
||||
let cache = self.jwks_cache.read().await;
|
||||
if let Some(cached) = cache.get(jwks_uri)
|
||||
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs
|
||||
{
|
||||
return Ok(cached.jwks.clone());
|
||||
cached_json(
|
||||
self.cache.as_ref(),
|
||||
&oauth_client_jwks_key(jwks_uri),
|
||||
self.cache_ttl,
|
||||
|| self.fetch_jwks(jwks_uri),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn refresh_jwks(
|
||||
&self,
|
||||
metadata: &ClientMetadata,
|
||||
) -> Result<Option<serde_json::Value>, OAuthError> {
|
||||
match (&metadata.jwks, &metadata.jwks_uri) {
|
||||
(None, Some(jwks_uri)) => {
|
||||
let cooldown_key = oauth_client_jwks_cooldown_key(jwks_uri);
|
||||
if self.cache.get(&cooldown_key).await.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let _ = self
|
||||
.cache
|
||||
.set(&cooldown_key, "1", JWKS_REFRESH_COOLDOWN)
|
||||
.await;
|
||||
self.fetch_and_store_jwks(jwks_uri).await.map(Some)
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_and_store_jwks(
|
||||
&self,
|
||||
jwks_uri: &JwksUri,
|
||||
) -> Result<serde_json::Value, OAuthError> {
|
||||
let jwks = self.fetch_jwks(jwks_uri).await?;
|
||||
{
|
||||
let mut cache = self.jwks_cache.write().await;
|
||||
cache.insert(
|
||||
jwks_uri.clone(),
|
||||
CachedJwks {
|
||||
jwks: jwks.clone(),
|
||||
cached_at: std::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
write_json(
|
||||
self.cache.as_ref(),
|
||||
&oauth_client_jwks_key(jwks_uri),
|
||||
&jwks,
|
||||
self.cache_ttl,
|
||||
)
|
||||
.await;
|
||||
Ok(jwks)
|
||||
}
|
||||
|
||||
async fn fetch_jwks(&self, jwks_uri: &str) -> Result<serde_json::Value, OAuthError> {
|
||||
if !jwks_uri.starts_with("https://")
|
||||
&& (!jwks_uri.starts_with("http://")
|
||||
|| (!jwks_uri.contains("localhost") && !jwks_uri.contains("127.0.0.1")))
|
||||
{
|
||||
return Err(OAuthError::InvalidClient(
|
||||
"jwks_uri must use https (except for localhost)".to_string(),
|
||||
));
|
||||
}
|
||||
async fn fetch_jwks(&self, jwks_uri: &JwksUri) -> Result<serde_json::Value, OAuthError> {
|
||||
let response = self
|
||||
.http_client
|
||||
.get(jwks_uri)
|
||||
.get(jwks_uri.as_str())
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
@@ -243,22 +247,16 @@ impl ClientMetadataCache {
|
||||
}
|
||||
|
||||
async fn fetch_metadata(&self, client_id: &ClientId) -> Result<ClientMetadata, OAuthError> {
|
||||
if !client_id.starts_with("http://") && !client_id.starts_with("https://") {
|
||||
let url = reqwest::Url::parse(client_id)
|
||||
.map_err(|_| OAuthError::InvalidClient("client_id must be a URL".to_string()))?;
|
||||
if !url_reach_permits(&url, ReachPolicy::DEBUG_LOOPBACK) {
|
||||
return Err(OAuthError::InvalidClient(
|
||||
"client_id must be a URL".to_string(),
|
||||
));
|
||||
}
|
||||
if client_id.starts_with("http://")
|
||||
&& !client_id.contains("localhost")
|
||||
&& !client_id.contains("127.0.0.1")
|
||||
{
|
||||
return Err(OAuthError::InvalidClient(
|
||||
"Non-localhost client_id must use https".to_string(),
|
||||
"client_id must be an https URL inside the allowed host reach".to_string(),
|
||||
));
|
||||
}
|
||||
let response = self
|
||||
.http_client
|
||||
.get(client_id.as_str())
|
||||
.get(url)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
@@ -514,7 +512,29 @@ async fn verify_private_key_jwt_async(
|
||||
"client_assertion iat is in the future".to_string(),
|
||||
));
|
||||
}
|
||||
let signing_input = format!("{}.{}", parts[0], parts[1]);
|
||||
let signature_bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[2])
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid signature encoding".to_string()))?;
|
||||
let jwks = cache.get_jwks(metadata).await?;
|
||||
match verify_assertion_signature(&jwks, kid, alg, &signing_input, &signature_bytes) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(cached_failure) => match cache.refresh_jwks(metadata).await {
|
||||
Ok(Some(fresh)) => {
|
||||
verify_assertion_signature(&fresh, kid, alg, &signing_input, &signature_bytes)
|
||||
}
|
||||
Ok(None) | Err(_) => Err(cached_failure),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_assertion_signature(
|
||||
jwks: &serde_json::Value,
|
||||
kid: Option<&str>,
|
||||
alg: &str,
|
||||
signing_input: &str,
|
||||
signature: &[u8],
|
||||
) -> Result<(), OAuthError> {
|
||||
let keys = jwks
|
||||
.get("keys")
|
||||
.and_then(|k| k.as_array())
|
||||
@@ -531,10 +551,6 @@ async fn verify_private_key_jwt_async(
|
||||
"No matching key found in client JWKS".to_string(),
|
||||
));
|
||||
}
|
||||
let signing_input = format!("{}.{}", parts[0], parts[1]);
|
||||
let signature_bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[2])
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid signature encoding".to_string()))?;
|
||||
matching_keys
|
||||
.into_iter()
|
||||
.filter(|key| {
|
||||
@@ -544,12 +560,12 @@ async fn verify_private_key_jwt_async(
|
||||
.find_map(|key| {
|
||||
let kty = key.get("kty").and_then(|k| k.as_str()).unwrap_or("");
|
||||
match (alg, kty) {
|
||||
("ES256", "EC") => verify_es256(key, &signing_input, &signature_bytes).ok(),
|
||||
("ES384", "EC") => verify_es384(key, &signing_input, &signature_bytes).ok(),
|
||||
("ES256", "EC") => verify_es256(key, signing_input, signature).ok(),
|
||||
("ES384", "EC") => verify_es384(key, signing_input, signature).ok(),
|
||||
("RS256" | "RS384" | "RS512", "RSA") => {
|
||||
verify_rsa(alg, key, &signing_input, &signature_bytes).ok()
|
||||
verify_rsa(alg, key, signing_input, signature).ok()
|
||||
}
|
||||
("EdDSA", "OKP") => verify_eddsa(key, &signing_input, &signature_bytes).ok(),
|
||||
("EdDSA", "OKP") => verify_eddsa(key, signing_input, signature).ok(),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use tranquil_types::{ClientId, Did};
|
||||
use tranquil_types::{AuthServerEndpoint, ClientId, Did, Issuer};
|
||||
|
||||
pub use tranquil_types::{AuthorizationCode, DeviceId, RefreshToken, RequestId, TokenId};
|
||||
|
||||
@@ -195,9 +195,9 @@ pub struct ProtectedResourceMetadata {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthorizationServerMetadata {
|
||||
pub issuer: String,
|
||||
pub authorization_endpoint: String,
|
||||
pub token_endpoint: String,
|
||||
pub issuer: Issuer,
|
||||
pub authorization_endpoint: AuthServerEndpoint,
|
||||
pub token_endpoint: AuthServerEndpoint,
|
||||
pub jwks_uri: String,
|
||||
pub registration_endpoint: Option<String>,
|
||||
pub scopes_supported: Option<Vec<String>>,
|
||||
@@ -206,7 +206,7 @@ pub struct AuthorizationServerMetadata {
|
||||
pub grant_types_supported: Option<Vec<String>>,
|
||||
pub token_endpoint_auth_methods_supported: Option<Vec<String>>,
|
||||
pub code_challenge_methods_supported: Option<Vec<String>>,
|
||||
pub pushed_authorization_request_endpoint: Option<String>,
|
||||
pub pushed_authorization_request_endpoint: Option<AuthServerEndpoint>,
|
||||
pub require_pushed_authorization_requests: Option<bool>,
|
||||
pub dpop_signing_alg_values_supported: Option<Vec<String>>,
|
||||
pub authorization_response_iss_parameter_supported: Option<bool>,
|
||||
|
||||
@@ -7,7 +7,6 @@ license.workspace = true
|
||||
[dependencies]
|
||||
tranquil-types = { workspace = true }
|
||||
tranquil-config = { workspace = true }
|
||||
tranquil-crypto = { workspace = true }
|
||||
tranquil-storage = { workspace = true }
|
||||
tranquil-cache = { workspace = true }
|
||||
tranquil-repo = { workspace = true }
|
||||
@@ -16,7 +15,7 @@ tranquil-auth = { workspace = true }
|
||||
tranquil-oauth = { workspace = true }
|
||||
tranquil-comms = { workspace = true }
|
||||
tranquil-signal = { workspace = true }
|
||||
tranquil-db = { workspace = true }
|
||||
tranquil-db = { workspace = true, features = ["postgres"] }
|
||||
tranquil-db-traits = { workspace = true }
|
||||
tranquil-store = { workspace = true }
|
||||
tranquil-lexicon = { workspace = true, features = ["resolve"] }
|
||||
@@ -29,13 +28,11 @@ axum = { workspace = true }
|
||||
base32 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
bcrypt = { workspace = true }
|
||||
bs58 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
cid = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
governor = { workspace = true }
|
||||
hickory-resolver = { workspace = true }
|
||||
@@ -43,7 +40,6 @@ hkdf = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
http = { workspace = true }
|
||||
image = { workspace = true }
|
||||
infer = { workspace = true }
|
||||
ipld-core = { workspace = true }
|
||||
iroh-car = { workspace = true }
|
||||
jacquard-common = { workspace = true }
|
||||
@@ -57,7 +53,6 @@ multihash = { workspace = true }
|
||||
p256 = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
redis = { workspace = true, optional = true }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -70,7 +65,6 @@ sqlx = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
@@ -80,20 +74,19 @@ urlencoding = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
webauthn-rs = { workspace = true }
|
||||
webauthn-rs-proto = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
aws-config = { workspace = true, optional = true }
|
||||
aws-sdk-s3 = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["frontend", "s3", "valkey"]
|
||||
bsky = ["bsky-support"]
|
||||
bsky-support = []
|
||||
external-infra = []
|
||||
s3-storage = ["tranquil-storage/s3", "dep:aws-config", "dep:aws-sdk-s3"]
|
||||
s3 = ["s3-storage"]
|
||||
valkey = ["tranquil-cache/valkey", "dep:redis"]
|
||||
postgres = ["tranquil-db/postgres"]
|
||||
s3 = ["tranquil-storage/s3"]
|
||||
valkey = ["tranquil-cache/valkey"]
|
||||
frontend = []
|
||||
native-tls-roots = ["tranquil-oauth/native-tls-roots"]
|
||||
|
||||
[dev-dependencies]
|
||||
tranquil-infra = { workspace = true, features = ["testing"] }
|
||||
tempfile = "3"
|
||||
ciborium = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
@@ -105,3 +98,8 @@ tranquil-api = { workspace = true }
|
||||
tranquil-oauth-server = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
wiremock = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
aws-config = { workspace = true }
|
||||
aws-sdk-s3 = { workspace = true }
|
||||
redis = { workspace = true }
|
||||
|
||||
@@ -763,6 +763,8 @@ impl From<crate::api::validation::HandleValidationError> for ApiError {
|
||||
HandleValidationError::BannedWord => {
|
||||
Self::InvalidHandle(Some("Inappropriate language in handle".to_string()))
|
||||
}
|
||||
HandleValidationError::UnusableHandleDomain
|
||||
| HandleValidationError::NoHandleDomains => Self::InternalError(Some(e.to_string())),
|
||||
_ => Self::InvalidHandle(Some(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod error;
|
||||
pub mod invite;
|
||||
pub mod proxy;
|
||||
pub mod proxy_client;
|
||||
pub mod query;
|
||||
pub mod responses;
|
||||
pub mod validation;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::LazyLock;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::proxy_client::proxy_client;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, Nsid};
|
||||
use crate::types::{Did, DidRef, Nsid};
|
||||
use crate::util::get_header_str;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
@@ -19,9 +19,7 @@ use tower::{Service, util::BoxCloneSyncService};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
||||
[
|
||||
"app.bsky.actor.getPreferences",
|
||||
"app.bsky.actor.putPreferences",
|
||||
let mut methods: HashSet<&str> = [
|
||||
"com.atproto.admin.deleteAccount",
|
||||
"com.atproto.admin.disableAccountInvites",
|
||||
"com.atproto.admin.disableInviteCodes",
|
||||
@@ -103,7 +101,13 @@ static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
||||
"com.atproto.temp.dereferenceScope",
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
.collect();
|
||||
// BSKY: the Bluesky preferences API must be implemented by PDSs
|
||||
if cfg!(feature = "bsky-support") {
|
||||
methods.insert("app.bsky.actor.getPreferences");
|
||||
methods.insert("app.bsky.actor.putPreferences");
|
||||
};
|
||||
methods
|
||||
});
|
||||
|
||||
fn is_protected_method(method: &str) -> bool {
|
||||
@@ -111,6 +115,7 @@ fn is_protected_method(method: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Fetch the `feed` generator record from the AppView and return its `did`.
|
||||
#[cfg(feature = "bsky-support")]
|
||||
async fn resolve_feed_generator_did(appview_url: &str, query: Option<&str>) -> Option<Did> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GetFeedQuery {
|
||||
@@ -201,6 +206,7 @@ impl<S: Service<Request, Response = Response, Error = Infallible>> Service<Reque
|
||||
}
|
||||
|
||||
// If the age assurance override is set and this is an age assurance call then we dont want to proxy even if the client requests it
|
||||
#[cfg(feature = "bsky")]
|
||||
if tranquil_config::get().server.age_assurance_override
|
||||
&& (path.ends_with("app.bsky.ageassurance.getState")
|
||||
|| path.ends_with("app.bsky.unspecced.getAgeAssuranceState"))
|
||||
@@ -328,7 +334,8 @@ async fn proxy_handler(
|
||||
},
|
||||
};
|
||||
|
||||
// getFeed must be audienced to the feed generator, not the AppView.
|
||||
// BSKY: getFeed must be audienced to the feed generator, not the AppView.
|
||||
#[cfg(feature = "bsky-support")]
|
||||
let (token_aud, token_lxm) = if method == "app.bsky.feed.getFeed" {
|
||||
match resolve_feed_generator_did(&resolved.url, query.as_deref()).await {
|
||||
Some(feed_did) => (
|
||||
@@ -339,21 +346,24 @@ async fn proxy_handler(
|
||||
),
|
||||
None => {
|
||||
warn!(
|
||||
"getFeed proxy: could not resolve feed generator DID; refusing \
|
||||
to mint an AppView-audienced token"
|
||||
"getFeed proxy refuses to mint an AppView-audienced token \
|
||||
because feed generator DID resolution failed"
|
||||
);
|
||||
return ApiError::InvalidRequest("Could not resolve feed".into())
|
||||
return ApiError::InvalidRequest("Couldn't resolve feed".into())
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(resolved.did.clone(), method_nsid.clone())
|
||||
};
|
||||
#[cfg(not(feature = "bsky-support"))]
|
||||
let (token_aud, token_lxm) = (resolved.did.clone(), method_nsid.clone());
|
||||
|
||||
match crate::auth::create_service_token(
|
||||
&auth_user.did,
|
||||
&token_aud,
|
||||
&DidRef::from(token_aud),
|
||||
Some(&token_lxm),
|
||||
None,
|
||||
&key_bytes,
|
||||
) {
|
||||
Ok(new_token) => {
|
||||
|
||||
@@ -147,7 +147,9 @@ impl std::fmt::Display for SsrfError {
|
||||
}
|
||||
|
||||
impl std::error::Error for SsrfError {}
|
||||
|
||||
// TODO: update to match https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/pipethrough.ts
|
||||
// Currently spec says nothing at all!! about forwarding headers during proxying and https://github.com/bluesky-social/atproto/discussions/2350
|
||||
#[cfg(feature = "bsky-support")]
|
||||
pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| {
|
||||
[
|
||||
HeaderName::from_static("accept-language"),
|
||||
@@ -156,6 +158,14 @@ pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| {
|
||||
http::header::CONTENT_TYPE,
|
||||
]
|
||||
});
|
||||
#[cfg(not(feature = "bsky-support"))]
|
||||
pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 3]> = LazyLock::new(|| {
|
||||
[
|
||||
HeaderName::from_static("accept-language"),
|
||||
crate::util::HEADER_ATPROTO_ACCEPT_LABELERS,
|
||||
http::header::CONTENT_TYPE,
|
||||
]
|
||||
});
|
||||
pub static RESPONSE_HEADERS_TO_FORWARD: LazyLock<[HeaderName; 6]> = LazyLock::new(|| {
|
||||
[
|
||||
crate::util::HEADER_ATPROTO_REPO_REV,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use super::error::ApiError;
|
||||
|
||||
pub struct XrpcQuery<T>(pub T);
|
||||
|
||||
impl<T: DeserializeOwned, S: Send + Sync> FromRequestParts<S> for XrpcQuery<T> {
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let raw = parts.uri.query().unwrap_or_default();
|
||||
serde_urlencoded::from_str(raw)
|
||||
.map(Self)
|
||||
.map_err(|e| ApiError::InvalidRequest(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@ pub struct DidResponse {
|
||||
}
|
||||
|
||||
impl DidResponse {
|
||||
pub fn response(did: impl Into<Did>) -> impl IntoResponse {
|
||||
Json(Self { did: did.into() })
|
||||
pub fn response(did: Did) -> impl IntoResponse {
|
||||
Json(Self { did })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,13 +101,17 @@ fn validate_email_detailed(email: &str) -> Result<(), EmailValidationError> {
|
||||
pub enum HandleValidationError {
|
||||
Empty,
|
||||
TooShort,
|
||||
TooLong,
|
||||
TooLong { max: usize },
|
||||
InvalidCharacters,
|
||||
StartsWithInvalidChar,
|
||||
EndsWithInvalidChar,
|
||||
ContainsSpaces,
|
||||
BannedWord,
|
||||
Reserved,
|
||||
InvalidSyntax,
|
||||
DisallowedTld,
|
||||
UnusableHandleDomain,
|
||||
NoHandleDomains,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HandleValidationError {
|
||||
@@ -119,11 +123,9 @@ impl std::fmt::Display for HandleValidationError {
|
||||
"Handle must be at least {} characters",
|
||||
MIN_HANDLE_LENGTH
|
||||
),
|
||||
Self::TooLong => write!(
|
||||
f,
|
||||
"Handle exceeds maximum length of {} characters",
|
||||
MAX_SERVICE_HANDLE_LOCAL_PART
|
||||
),
|
||||
Self::TooLong { max } => {
|
||||
write!(f, "Handle exceeds maximum length of {} characters", max)
|
||||
}
|
||||
Self::InvalidCharacters => write!(
|
||||
f,
|
||||
"Handle contains invalid characters. Only alphanumeric characters and hyphens are allowed"
|
||||
@@ -135,6 +137,15 @@ impl std::fmt::Display for HandleValidationError {
|
||||
Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"),
|
||||
Self::BannedWord => write!(f, "Inappropriate language in handle"),
|
||||
Self::Reserved => write!(f, "Reserved handle"),
|
||||
Self::InvalidSyntax => write!(f, "Handle does not match atproto handle syntax"),
|
||||
Self::DisallowedTld => write!(f, "Handle uses a reserved TLD and cannot resolve"),
|
||||
Self::UnusableHandleDomain => write!(
|
||||
f,
|
||||
"This server's handle domain has a reserved TLD, so no handle under it is a valid atproto handle"
|
||||
),
|
||||
Self::NoHandleDomains => {
|
||||
write!(f, "No handle domains are configured on this server")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +170,9 @@ pub fn validate_full_domain_handle(handle: &str) -> Result<Handle, HandleValidat
|
||||
}
|
||||
|
||||
if handle.len() > MAX_HANDLE_LENGTH {
|
||||
return Err(HandleValidationError::TooLong);
|
||||
return Err(HandleValidationError::TooLong {
|
||||
max: MAX_HANDLE_LENGTH,
|
||||
});
|
||||
}
|
||||
|
||||
if handle
|
||||
@@ -190,7 +203,11 @@ pub fn validate_full_domain_handle(handle: &str) -> Result<Handle, HandleValidat
|
||||
return Err(HandleValidationError::BannedWord);
|
||||
}
|
||||
|
||||
Ok(Handle::from(handle_lower))
|
||||
let handle = Handle::new(handle_lower).map_err(|_| HandleValidationError::InvalidSyntax)?;
|
||||
match handle.has_disallowed_tld() {
|
||||
true => Err(HandleValidationError::DisallowedTld),
|
||||
false => Ok(handle),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
|
||||
@@ -210,16 +227,40 @@ pub fn resolve_handle_input(input: &str) -> Result<Handle, HandleValidationError
|
||||
None => input,
|
||||
};
|
||||
let validated = validate_short_handle(handle_to_validate)?;
|
||||
Ok(Handle::from(format!(
|
||||
"{}.{}",
|
||||
validated,
|
||||
matched_domain.unwrap_or(&available_domains[0])
|
||||
)))
|
||||
let domain = matched_domain
|
||||
.or_else(|| available_domains.first())
|
||||
.ok_or(HandleValidationError::NoHandleDomains)?;
|
||||
let handle = Handle::new(format!("{}.{}", validated, domain))
|
||||
.map_err(|_| HandleValidationError::InvalidSyntax)?;
|
||||
match handle.has_disallowed_tld() {
|
||||
true => Err(HandleValidationError::UnusableHandleDomain),
|
||||
false => Ok(handle),
|
||||
}
|
||||
} else {
|
||||
validate_full_domain_handle(input)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn domain_forms_valid_handles(domain: &str) -> bool {
|
||||
Handle::new(format!("whelk.{domain}")).is_ok_and(|h| !h.has_disallowed_tld())
|
||||
}
|
||||
|
||||
pub fn warn_unusable_handle_domains() {
|
||||
tranquil_config::get()
|
||||
.server
|
||||
.user_handle_domain_list()
|
||||
.iter()
|
||||
.filter(|domain| !domain_forms_valid_handles(domain))
|
||||
.for_each(|domain| {
|
||||
tracing::error!(
|
||||
domain = %domain,
|
||||
"configured handle domain can't form a valid atproto handle, so every account \
|
||||
creation under it will be rejected. Set server.user_handle_domains to a domain \
|
||||
whose TLD isn't reserved."
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn validate_service_handle(
|
||||
handle: &str,
|
||||
reserved_policy: ReservedHandlePolicy,
|
||||
@@ -239,7 +280,9 @@ pub fn validate_service_handle(
|
||||
}
|
||||
|
||||
if handle.len() > MAX_SERVICE_HANDLE_LOCAL_PART {
|
||||
return Err(HandleValidationError::TooLong);
|
||||
return Err(HandleValidationError::TooLong {
|
||||
max: MAX_SERVICE_HANDLE_LOCAL_PART,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(first_char) = handle.chars().next()
|
||||
@@ -314,6 +357,19 @@ mod tests {
|
||||
assert_eq!(validate_short_handle("abc"), Ok("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_domain_handles_with_reserved_tlds_are_rejected() {
|
||||
assert!(validate_full_domain_handle("whelk.oyster.cafe").is_ok());
|
||||
assert_eq!(
|
||||
validate_full_domain_handle("whelk.pds.internal"),
|
||||
Err(HandleValidationError::DisallowedTld)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_full_domain_handle("handle.invalid"),
|
||||
Err(HandleValidationError::DisallowedTld)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_handles() {
|
||||
assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty));
|
||||
@@ -388,11 +444,15 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("exactly19characters"),
|
||||
Err(HandleValidationError::TooLong)
|
||||
Err(HandleValidationError::TooLong {
|
||||
max: MAX_SERVICE_HANDLE_LOCAL_PART
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("waytoolongusername123456789"),
|
||||
Err(HandleValidationError::TooLong)
|
||||
Err(HandleValidationError::TooLong {
|
||||
max: MAX_SERVICE_HANDLE_LOCAL_PART
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,32 +2,14 @@ use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cache::Cache;
|
||||
use crate::cache_keys::email_token_key;
|
||||
use crate::types::Did;
|
||||
use crate::util::{generate_token_code, normalize_token_code};
|
||||
|
||||
pub use tranquil_types::EmailTokenPurpose;
|
||||
|
||||
const TOKEN_TTL_SECS: u64 = 900;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EmailTokenPurpose {
|
||||
UpdateEmail,
|
||||
ConfirmEmail,
|
||||
DeleteAccount,
|
||||
ResetPassword,
|
||||
PlcOperation,
|
||||
}
|
||||
|
||||
impl EmailTokenPurpose {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::UpdateEmail => "update_email",
|
||||
Self::ConfirmEmail => "confirm_email",
|
||||
Self::DeleteAccount => "delete_account",
|
||||
Self::ResetPassword => "reset_password",
|
||||
Self::PlcOperation => "plc_operation",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct TokenData {
|
||||
token: String,
|
||||
@@ -42,10 +24,6 @@ pub enum TokenError {
|
||||
ExpiredToken,
|
||||
}
|
||||
|
||||
fn cache_key(did: &Did, purpose: EmailTokenPurpose) -> String {
|
||||
format!("email_token:{}:{}", purpose.as_str(), did)
|
||||
}
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0)
|
||||
}
|
||||
@@ -69,7 +47,7 @@ pub async fn create_email_token(
|
||||
|
||||
cache
|
||||
.set(
|
||||
&cache_key(did, purpose),
|
||||
&email_token_key(did, purpose),
|
||||
&json,
|
||||
Duration::from_secs(TOKEN_TTL_SECS),
|
||||
)
|
||||
@@ -89,7 +67,7 @@ pub async fn validate_email_token(
|
||||
return Err(TokenError::CacheUnavailable);
|
||||
}
|
||||
|
||||
let key = cache_key(did, purpose);
|
||||
let key = email_token_key(did, purpose);
|
||||
let json = cache.get(&key).await.ok_or(TokenError::InvalidToken)?;
|
||||
|
||||
let data: TokenData = serde_json::from_str(&json).map_err(|_| TokenError::InvalidToken)?;
|
||||
@@ -112,7 +90,7 @@ pub async fn validate_email_token(
|
||||
}
|
||||
|
||||
pub async fn delete_email_token(cache: &dyn Cache, did: &Did, purpose: EmailTokenPurpose) {
|
||||
let _ = cache.delete(&cache_key(did, purpose)).await;
|
||||
let _ = cache.delete(&email_token_key(did, purpose)).await;
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
@@ -128,68 +106,12 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cache::CacheError;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MockCache {
|
||||
data: Mutex<HashMap<String, (String, u64)>>,
|
||||
}
|
||||
|
||||
impl MockCache {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
data: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Cache for MockCache {
|
||||
async fn get(&self, key: &str) -> Option<String> {
|
||||
let data = self.data.lock().unwrap();
|
||||
let now = current_timestamp();
|
||||
data.get(key)
|
||||
.filter(|(_, exp)| *exp > now)
|
||||
.map(|(v, _)| v.clone())
|
||||
}
|
||||
|
||||
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
let expires = current_timestamp() + ttl.as_secs();
|
||||
data.insert(key.to_string(), (value.to_string(), expires));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<(), CacheError> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
data.remove(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_bytes(&self, _key: &str) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn set_bytes(
|
||||
&self,
|
||||
_key: &str,
|
||||
_value: &[u8],
|
||||
_ttl: Duration,
|
||||
) -> Result<(), CacheError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
use tranquil_infra::MemoryCache;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_and_validate_token() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let cache = MemoryCache::new();
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
|
||||
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
@@ -205,8 +127,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_consumed_after_use() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let cache = MemoryCache::new();
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
|
||||
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
@@ -223,8 +145,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_token_rejected() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let cache = MemoryCache::new();
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
|
||||
let _token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
@@ -237,8 +159,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wrong_purpose_rejected() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let cache = MemoryCache::new();
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
|
||||
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
@@ -252,8 +174,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_token_format() {
|
||||
// The emitted token is the display form: uppercase `XXXXX-XXXXX`.
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let cache = MemoryCache::new();
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
(0..50).for_each(|_| {
|
||||
let token = futures::executor::block_on(create_email_token(
|
||||
&cache,
|
||||
@@ -269,8 +191,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_case_insensitive_validation() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let cache = MemoryCache::new();
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
|
||||
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
@@ -284,8 +206,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hyphen_insensitive_validation() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let cache = MemoryCache::new();
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
|
||||
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
@@ -300,7 +222,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_noop_cache_returns_unavailable() {
|
||||
let cache = crate::cache::NoOpCache;
|
||||
let did = Did::from("did:plc:whelk".to_string());
|
||||
let did = Did::new("did:plc:whelk").expect("valid DID");
|
||||
|
||||
let result = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail).await;
|
||||
assert_eq!(result.unwrap_err(), TokenError::CacheUnavailable);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user