Compare commits

..
Author SHA1 Message Date
Lewis 7b20f4cfb4 totp: let legacy sessions disable totp
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-28 10:50:30 +03:00
263 changed files with 5677 additions and 13693 deletions
@@ -1,25 +0,0 @@
{
"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"
}
@@ -1,23 +0,0 @@
{
"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"
}
@@ -1,22 +0,0 @@
{
"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"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, email, email_verified\n FROM users\n WHERE LOWER(email) = $1",
"query": "SELECT id, did, email, email_verified, handle\n FROM users\n WHERE LOWER(email) = $1",
"describe": {
"columns": [
{
@@ -22,6 +22,11 @@
"ordinal": 3,
"name": "email_verified",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "handle",
"type_info": "Text"
}
],
"parameters": {
@@ -33,8 +38,9 @@
false,
false,
true,
false,
false
]
},
"hash": "55b5e5cb13e09c4261e0452ef7e21240b504096666692c8fbd5c7dcd58607bc7"
"hash": "b230a27fce54d4f79de4ffcc754bf6c7e5a889623e64cdd7aaec791b3553ed83"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM session_tokens WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "cf874abcb72017e775fe699a0b77ae9341355f30e4af84968ffeb9135dba745f"
}
-55
View File
@@ -4,7 +4,6 @@
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
@@ -17,60 +16,6 @@ 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
+76 -78
View File
@@ -105,21 +105,6 @@ 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"
@@ -1265,27 +1250,6 @@ 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"
@@ -2522,6 +2486,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@@ -7665,8 +7630,9 @@ dependencies = [
[[package]]
name = "tranquil-api"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"anyhow",
"axum",
"backon",
"base32",
@@ -7700,25 +7666,27 @@ 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.6"
version = "0.6.5"
dependencies = [
"anyhow",
"base32",
"base64 0.22.1",
"bcrypt",
"brotli",
"chrono",
"hmac",
"k256",
@@ -7730,14 +7698,13 @@ dependencies = [
"totp-rs",
"tranquil-config",
"tranquil-crypto",
"tranquil-types",
"urlencoding",
"uuid",
]
[[package]]
name = "tranquil-cache"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7752,7 +7719,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7766,6 +7733,7 @@ dependencies = [
"rsa",
"secrecy",
"serde_json",
"sqlx",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -7777,14 +7745,15 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"confique",
"serde",
]
[[package]]
name = "tranquil-crypto"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -7800,7 +7769,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"async-trait",
"chrono",
@@ -7817,7 +7786,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7833,20 +7802,18 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"async-trait",
"bytes",
"futures",
"serde",
"serde_json",
"thiserror 2.0.18",
"tranquil-types",
"tranquil-config",
]
[[package]]
name = "tranquil-lexicon"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"chrono",
"futures",
@@ -7858,15 +7825,14 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
"tranquil-infra",
"tranquil-types",
"unicode-segmentation",
"urlencoding",
"wiremock",
]
[[package]]
name = "tranquil-oauth"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"anyhow",
"axum",
@@ -7883,16 +7849,14 @@ dependencies = [
"sqlx",
"tokio",
"tracing",
"tranquil-infra",
"tranquil-types",
"uuid",
]
[[package]]
name = "tranquil-oauth-server"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bcrypt",
@@ -7915,9 +7879,7 @@ dependencies = [
"tranquil-config",
"tranquil-crypto",
"tranquil-db-traits",
"tranquil-infra",
"tranquil-pds",
"tranquil-scopes",
"tranquil-types",
"urlencoding",
"uuid",
@@ -7926,7 +7888,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"aes-gcm",
"anyhow",
@@ -7938,6 +7900,7 @@ dependencies = [
"base32",
"base64 0.22.1",
"bcrypt",
"bs58",
"bytes",
"chrono",
"ciborium",
@@ -7953,6 +7916,7 @@ dependencies = [
"hmac",
"http 1.4.0",
"image",
"infer",
"ipld-core",
"iroh-car",
"jacquard-common",
@@ -7994,9 +7958,9 @@ dependencies = [
"tranquil-cache",
"tranquil-comms",
"tranquil-config",
"tranquil-crypto",
"tranquil-db",
"tranquil-db-traits",
"tranquil-infra",
"tranquil-lexicon",
"tranquil-oauth",
"tranquil-oauth-server",
@@ -8013,11 +7977,12 @@ dependencies = [
"webauthn-rs",
"webauthn-rs-proto",
"wiremock",
"zip",
]
[[package]]
name = "tranquil-repo"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"bytes",
"cid",
@@ -8029,7 +7994,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"async-trait",
"backon",
@@ -8058,7 +8023,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"axum",
"futures",
@@ -8069,13 +8034,12 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
"tranquil-types",
"urlencoding",
]
[[package]]
name = "tranquil-server"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"arc-swap",
"axum",
@@ -8112,7 +8076,7 @@ dependencies = [
[[package]]
name = "tranquil-signal"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"async-trait",
"chrono",
@@ -8120,6 +8084,7 @@ dependencies = [
"futures",
"presage",
"rand 0.9.2",
"serde",
"serde_json",
"sqlx",
"tempfile",
@@ -8127,13 +8092,14 @@ dependencies = [
"tokio",
"tokio-util",
"tracing",
"tranquil-signal",
"url",
"uuid",
]
[[package]]
name = "tranquil-storage"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"async-trait",
"aws-config",
@@ -8150,7 +8116,7 @@ dependencies = [
[[package]]
name = "tranquil-store"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"async-trait",
"bytes",
@@ -8190,6 +8156,7 @@ dependencies = [
"tranquil-db",
"tranquil-db-traits",
"tranquil-oauth",
"tranquil-repo",
"tranquil-store",
"tranquil-types",
"uuid",
@@ -8198,7 +8165,7 @@ dependencies = [
[[package]]
name = "tranquil-sync"
version = "0.6.6"
version = "0.6.5"
dependencies = [
"anyhow",
"axum",
@@ -8220,22 +8187,15 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.6.6"
version = "0.6.5"
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]]
@@ -8287,6 +8247,12 @@ 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"
@@ -9488,6 +9454,20 @@ 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"
@@ -9536,12 +9516,30 @@ 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"
+3 -2
View File
@@ -26,7 +26,7 @@ members = [
]
[workspace.package]
version = "0.6.6"
version = "0.6.5"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -47,6 +47,7 @@ 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" }
@@ -137,11 +138,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"
-1
View File
@@ -10,7 +10,6 @@ dir = "/app/frontend/public"
[database]
url = "postgres://postgres:postgres@db:5432/pds"
max_connections = 20
[storage]
path = "/var/lib/tranquil-pds/blobs"
+4 -4
View File
@@ -8,10 +8,13 @@ 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 }
@@ -47,7 +50,4 @@ tracing = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
webauthn-rs = { workspace = true }
[features]
bsky = ["bsky-support"]
bsky-support = []
zip = { workspace = true }
+3
View File
@@ -0,0 +1,3 @@
mod preferences;
pub use preferences::{get_preferences, put_preferences};
+10 -10
View File
@@ -8,7 +8,7 @@ use std::collections::HashMap;
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{Admin, Auth};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Handle, InviteCode};
use tranquil_pds::types::{Did, Handle};
#[derive(Deserialize)]
pub struct GetAccountInfoParams {
@@ -39,7 +39,7 @@ pub struct AccountInfo {
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct InviteCodeInfo {
pub code: InviteCode,
pub code: String,
pub available: i32,
pub disabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -123,12 +123,12 @@ async fn get_invites_for_user(
return None;
}
let codes: Vec<InviteCode> = invite_codes.iter().map(|ic| ic.code.clone()).collect();
let code_strings: Vec<String> = invite_codes.iter().map(|ic| ic.code.clone()).collect();
let uses = state
.repos
.infra
.get_invite_code_uses_batch(&codes)
.get_invite_code_uses_batch(&code_strings)
.await
.ok()?;
@@ -157,7 +157,7 @@ async fn get_invites_for_user(
}
}
async fn get_invite_code_info(state: &AppState, code: &InviteCode) -> Option<InviteCodeInfo> {
async fn get_invite_code_info(state: &AppState, code: &str) -> Option<InviteCodeInfo> {
let info = state.repos.infra.get_invite_code_info(code).await.ok()??;
let uses = state
@@ -200,11 +200,11 @@ pub async fn get_account_infos(
return Err(ApiError::InvalidRequest("dids is required".into()));
}
let dids: Vec<Did> = dids.iter().filter_map(|d| d.parse().ok()).collect();
let dids_typed: Vec<Did> = dids.iter().filter_map(|d| d.parse().ok()).collect();
let accounts = state
.repos
.infra
.get_admin_account_infos_by_dids(&dids)
.get_admin_account_infos_by_dids(&dids_typed)
.await
.log_db_err("fetching account infos")?;
@@ -217,7 +217,7 @@ pub async fn get_account_infos(
.await
.unwrap_or_default();
let all_codes: Vec<InviteCode> = all_invite_codes
let all_codes: Vec<String> = all_invite_codes
.iter()
.map(|(_, c)| c.code.clone())
.collect();
@@ -233,7 +233,7 @@ pub async fn get_account_infos(
Vec::new()
};
let invited_by_map: HashMap<uuid::Uuid, InviteCode> = state
let invited_by_map: HashMap<uuid::Uuid, String> = state
.repos
.infra
.get_invite_code_uses_by_users(&user_ids)
@@ -249,7 +249,7 @@ pub async fn get_account_infos(
let (codes_by_user, code_info_map): (
HashMap<uuid::Uuid, Vec<InviteCodeInfo>>,
HashMap<InviteCode, InviteCodeInfo>,
HashMap<String, InviteCodeInfo>,
) = all_invite_codes.into_iter().fold(
(HashMap::new(), HashMap::new()),
|(mut by_user, mut by_code), (user_id, ic)| {
@@ -106,7 +106,7 @@ pub async fn update_account_handle(
}
let _ = state
.cache
.delete(&tranquil_pds::cache_keys::handle_key(&handle_for_check))
.delete(&tranquil_pds::cache_keys::handle_key(&handle))
.await;
if let Err(e) = tranquil_pds::repo_ops::sequence_identity_event(
&state,
+18 -16
View File
@@ -10,13 +10,12 @@ use tranquil_pds::api::EmptyResponse;
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{Admin, Auth};
use tranquil_pds::state::AppState;
use tranquil_types::{Did, InviteCode};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DisableInviteCodesInput {
pub codes: Option<Vec<InviteCode>>,
pub accounts: Option<Vec<Did>>,
pub codes: Option<Vec<String>>,
pub accounts: Option<Vec<String>>,
}
pub async fn disable_invite_codes(
@@ -29,14 +28,17 @@ pub async fn disable_invite_codes(
{
error!("DB error disabling invite codes: {:?}", e);
}
if let Some(accounts) = &input.accounts
&& let Err(e) = state
if let Some(accounts) = &input.accounts {
let accounts_typed: Vec<tranquil_types::Did> =
accounts.iter().filter_map(|a| a.parse().ok()).collect();
if let Err(e) = state
.repos
.infra
.disable_invite_codes_by_account(accounts)
.disable_invite_codes_by_account(&accounts_typed)
.await
{
error!("DB error disabling invite codes by account: {:?}", e);
{
error!("DB error disabling invite codes by account: {:?}", e);
}
}
Ok(Json(EmptyResponse {}))
}
@@ -51,7 +53,7 @@ pub struct GetInviteCodesParams {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteCodeInfo {
pub code: InviteCode,
pub code: String,
pub available: i32,
pub disabled: bool,
pub for_account: String,
@@ -70,7 +72,7 @@ pub struct InviteCodeUseInfo {
#[derive(Serialize)]
pub struct GetInviteCodesOutput {
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<InviteCode>,
pub cursor: Option<String>,
pub codes: Vec<InviteCodeInfo>,
}
@@ -93,9 +95,9 @@ pub async fn get_invite_codes(
.log_db_err("fetching invite codes")?;
let user_ids: Vec<uuid::Uuid> = codes_rows.iter().map(|r| r.created_by_user).collect();
let code_values: Vec<InviteCode> = codes_rows.iter().map(|r| r.code.clone()).collect();
let code_strings: Vec<String> = codes_rows.iter().map(|r| r.code.clone()).collect();
let creator_dids: std::collections::HashMap<uuid::Uuid, Did> = state
let creator_dids: std::collections::HashMap<uuid::Uuid, tranquil_types::Did> = state
.repos
.infra
.get_user_dids_by_ids(&user_ids)
@@ -104,14 +106,14 @@ pub async fn get_invite_codes(
.into_iter()
.collect();
let uses_by_code = if code_values.is_empty() {
let uses_by_code = if code_strings.is_empty() {
std::collections::HashMap::new()
} else {
common::group_invite_uses_by_code(
state
.repos
.infra
.get_invite_code_uses_batch(&code_values)
.get_invite_code_uses_batch(&code_strings)
.await
.unwrap_or_default(),
|u| InviteCodeUseInfo {
@@ -165,7 +167,7 @@ pub async fn disable_account_invites(
if account.is_empty() {
return Err(ApiError::InvalidRequest("account is required".into()));
}
let account_did: Did = account
let account_did: tranquil_types::Did = account
.parse()
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
@@ -198,7 +200,7 @@ pub async fn enable_account_invites(
if account.is_empty() {
return Err(ApiError::InvalidRequest("account is required".into()));
}
let account_did: Did = account
let account_did: tranquil_types::Did = account
.parse()
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
+13 -17
View File
@@ -5,7 +5,7 @@ use tracing::error;
use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::api::error::DbResultExt;
use tranquil_pds::types::{AtIdentifier, Did, Handle, PasswordHash};
use tranquil_pds::types::{AtIdentifier, Did, Handle};
pub struct ResolvedRepo {
pub user_id: uuid::Uuid,
@@ -76,7 +76,7 @@ pub async fn resolve_repo_user_id(
pub fn group_invite_uses_by_code<U, F>(
uses: Vec<tranquil_db_traits::InviteCodeUse>,
map_use: F,
) -> HashMap<tranquil_types::InviteCode, Vec<U>>
) -> HashMap<String, Vec<U>>
where
F: Fn(tranquil_db_traits::InviteCodeUse) -> U,
{
@@ -195,13 +195,12 @@ pub fn extract_verification_recipient(
}
}
pub fn create_self_hosted_did_web(handle: &str) -> Result<Did, ApiError> {
pub fn create_self_hosted_did_web(handle: &str) -> Result<String, ApiError> {
if !tranquil_pds::util::is_self_hosted_did_web_enabled() {
return Err(ApiError::SelfHostedDidWebDisabled);
}
let encoded_handle = handle.replace(':', "%3A");
Did::new(format!("did:web:{}", encoded_handle))
.map_err(|_| ApiError::InvalidHandle(Some("Handle is not a valid did:web".into())))
Ok(format!("did:web:{}", encoded_handle))
}
pub enum CredentialMatch {
@@ -217,10 +216,10 @@ pub async fn verify_credential(
session_repo: &dyn SessionRepository,
user_id: uuid::Uuid,
password: &str,
password_hash: Option<&PasswordHash>,
password_hash: Option<&str>,
) -> Option<CredentialMatch> {
let main_valid = password_hash
.map(|h| bcrypt::verify(password, h.as_str()).unwrap_or(false))
.map(|h| bcrypt::verify(password, h).unwrap_or(false))
.unwrap_or(false);
if main_valid {
return Some(CredentialMatch::MainPassword);
@@ -231,7 +230,7 @@ pub async fn verify_credential(
.unwrap_or_default();
app_passwords
.into_iter()
.find(|app| bcrypt::verify(password, app.password_hash.as_str()).unwrap_or(false))
.find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false))
.map(|app| {
let scopes = app.scopes.unwrap_or_else(|| {
if app.privilege.is_privileged() {
@@ -248,16 +247,14 @@ pub async fn verify_credential(
})
}
pub fn hash_or_internal_error(value: &str) -> Result<PasswordHash, ApiError> {
bcrypt::hash(value, DEFAULT_COST)
.map(PasswordHash::new)
.map_err(|e| {
error!("Bcrypt hash error: {:?}", e);
ApiError::InternalError(None)
})
pub fn hash_or_internal_error(value: &str) -> Result<String, ApiError> {
bcrypt::hash(value, DEFAULT_COST).map_err(|e| {
error!("Bcrypt hash error: {:?}", e);
ApiError::InternalError(None)
})
}
pub async fn hash_password_async(password: &str) -> Result<PasswordHash, ApiError> {
pub async fn hash_password_async(password: &str) -> Result<String, ApiError> {
let password = password.to_string();
tokio::task::spawn_blocking(move || hash(password, DEFAULT_COST))
.await
@@ -265,7 +262,6 @@ pub async fn hash_password_async(password: &str) -> Result<PasswordHash, ApiErro
error!("Failed to spawn blocking task: {:?}", e);
ApiError::InternalError(None)
})?
.map(PasswordHash::new)
.map_err(|e| {
error!("Failed to hash password: {:?}", e);
ApiError::InternalError(None)
+24 -24
View File
@@ -12,12 +12,12 @@ use tranquil_pds::api::{
};
use tranquil_pds::auth::{Active, Auth};
use tranquil_pds::delegation::{
DelegationActionType, IdentityResolutionError, SCOPE_PRESETS, ValidatedDelegationScope,
verify_can_add_controllers, verify_can_control_accounts,
DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers,
verify_can_control_accounts,
};
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{CidLink, Did, Handle};
use tranquil_pds::types::{Did, Handle};
pub async fn list_controllers(
State(state): State<AppState>,
@@ -38,10 +38,11 @@ pub async fn list_controllers(
async move {
if c.handle.is_none() {
c.handle = did_resolver
.fetch_did_document(&c.did)
.fetch_did_document(c.did.as_str())
.await
.ok()
.and_then(|doc| tranquil_types::did_doc::extract_handle(&doc));
.and_then(|doc| tranquil_types::did_doc::extract_handle(&doc))
.map(Into::into);
}
c
}
@@ -65,19 +66,19 @@ pub async fn add_controller(
) -> Result<Json<SuccessResponse>, ApiError> {
let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did)
.await
.map_err(|e| match e {
IdentityResolutionError::PdsEndpoint(_) => ApiError::InvalidDelegation(
"Controller PDS endpoint isn't a usable https URL".into(),
),
IdentityResolutionError::DidResolution(_) => ApiError::ControllerNotFound,
})?;
.map_err(|_| 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)
.check_remote_is_delegated(pds_url, input.controller_did.as_str())
.await
{
Some(true) => {
@@ -379,6 +380,7 @@ pub async fn create_delegated_account(
e
})?;
let did = plc.did;
let handle: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?;
info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account");
let repo = init_genesis_repo(&state, &did, &plc.signing_key, &plc.signing_key_bytes).await?;
@@ -392,7 +394,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),
commit_cid: repo.commit_cid.to_string(),
repo_rev: repo.repo_rev.clone(),
genesis_block_cids: repo.genesis_block_cids,
};
@@ -463,26 +465,24 @@ pub async fn resolve_controller(
.parse()
.map_err(|_| ApiError::ControllerNotFound)?
} else {
let handle: Handle = identifier
.parse()
.map_err(|_| ApiError::ControllerNotFound)?;
let local_user = state.repos.user.get_by_handle(&handle).await.ok().flatten();
let local_handle: Option<Handle> = identifier.parse().ok();
let local_user = match local_handle {
Some(ref h) => state.repos.user.get_by_handle(h).await.ok().flatten(),
None => None,
};
match local_user {
Some(user) => user.did,
None => tranquil_pds::handle::resolve_handle(&handle)
None => tranquil_pds::handle::resolve_handle(identifier)
.await
.map_err(|_| ApiError::ControllerNotFound)?
.parse()
.map_err(|_| ApiError::ControllerNotFound)?,
}
};
let resolved = tranquil_pds::delegation::resolve_identity(&state, &did)
.await
.map_err(|e| match e {
IdentityResolutionError::PdsEndpoint(_) => ApiError::InvalidDelegation(
"Controller PDS endpoint isn't a usable https URL".into(),
),
IdentityResolutionError::DidResolution(_) => ApiError::ControllerNotFound,
})?;
.map_err(|_| ApiError::ControllerNotFound)?;
Ok(Json(resolved))
}
+11 -15
View File
@@ -149,21 +149,17 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
}
};
let handle = match parse_start_handle(
interaction.data.as_ref().and_then(|d| d.options.as_deref()),
)
.map(Handle::new)
.transpose()
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()
{
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();
}
};
return Json(json!({
"type": 4,
"data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64}
}))
.into_response();
}
debug!(
discord_username = %discord_username,
@@ -175,7 +171,7 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
match state
.repos
.user
.store_discord_user_id(&discord_username, &discord_user_id, handle.as_ref())
.store_discord_user_id(&discord_username, &discord_user_id, handle.as_deref())
.await
{
Ok(Some(user_id)) => {
+73 -58
View File
@@ -14,7 +14,7 @@ use tranquil_pds::api::invite::check_registration_invite;
use tranquil_pds::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token};
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{CidLink, Did, Handle, Nsid, PlainPassword};
use tranquil_pds::types::{Did, Handle, PlainPassword};
use tranquil_pds::validation::validate_password;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -25,7 +25,7 @@ pub struct CreateAccountInput {
pub invite_code: Option<String>,
pub did: Option<String>,
pub did_type: Option<String>,
pub signing_key: Option<Did>,
pub signing_key: Option<String>,
pub verification_channel: Option<tranquil_db_traits::CommsChannel>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
@@ -47,15 +47,23 @@ pub struct CreateAccountOutput {
async fn try_reactivate_migration(
state: &AppState,
did: &Did,
handle: &Handle,
did: &str,
handle: &str,
email: &Option<String>,
verification_channel: tranquil_db_traits::CommsChannel,
verification_recipient: Option<&str>,
) -> Option<Response> {
let did_typed: Did = match did.parse() {
Ok(d) => d,
Err(_) => return Some(ApiError::InternalError(Some("Invalid DID".into())).into_response()),
};
let handle_typed: Handle = match handle.parse() {
Ok(h) => h,
Err(_) => return Some(ApiError::InvalidHandle(None).into_response()),
};
let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
did: did.clone(),
new_handle: handle.clone(),
did: did_typed.clone(),
new_handle: handle_typed.clone(),
new_email: email.clone(),
};
match state
@@ -65,7 +73,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
@@ -112,7 +120,7 @@ async fn try_reactivate_migration(
}
};
let session_data = tranquil_db_traits::SessionTokenCreate {
did: did.clone(),
did: did_typed.clone(),
access_jti: access_meta.jti.clone(),
refresh_jti: refresh_meta.jti.clone(),
access_expires_at: access_meta.expires_at,
@@ -132,7 +140,7 @@ async fn try_reactivate_migration(
super::provision::enqueue_migration_verification(
state,
reactivated.user_id,
did,
&did_typed,
verification_channel,
recipient,
)
@@ -145,9 +153,14 @@ async fn try_reactivate_migration(
(
StatusCode::OK,
Json(CreateAccountOutput {
handle: handle.clone(),
did: did.clone(),
did_doc: state.did_resolver.fetch_did_document(did).await.ok(),
handle: handle.to_string().into(),
did: did_typed.clone(),
did_doc: state
.did_resolver
.fetch_did_document(did)
.await
.ok()
.map(|f| (*f).clone()),
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
verification_required,
@@ -197,10 +210,8 @@ pub async fn create_account(
let token = extracted.token;
if is_service_token(&token) {
let verifier = ServiceTokenVerifier::new();
let create_account_lxm = Nsid::new("com.atproto.server.createAccount")
.expect("com.atproto.server.createAccount is a valid NSID");
match verifier
.verify_service_token(&token, Some(&create_account_lxm))
.verify_service_token(&token, Some("com.atproto.server.createAccount"))
.await
{
Ok(claims) => {
@@ -297,7 +308,7 @@ pub async fn create_account(
};
let hostname = &cfg.server.hostname;
let key_result =
match super::provision::resolve_signing_key(&state, input.signing_key.as_ref()).await {
match super::provision::resolve_signing_key(&state, input.signing_key.as_deref()).await {
Ok(k) => k,
Err(e) => return e.into_response(),
};
@@ -305,7 +316,7 @@ pub async fn create_account(
let signing_key = key_result.signing_key;
let reserved_key_id = key_result.reserved_key_id;
let did_type = input.did_type.as_deref().unwrap_or("plc");
let did: Did = match did_type {
let did = match did_type {
"web" => {
let self_hosted_did = match common::create_self_hosted_did_web(&handle) {
Ok(d) => d,
@@ -330,42 +341,27 @@ pub async fn create_account(
}
if !is_did_web_byod
&& let Err(e) =
verify_did_web(d, hostname, &input.handle, input.signing_key.as_ref()).await
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref()).await
{
return ApiError::InvalidDid(e.to_string()).into_response();
}
info!(did = %d, "Creating external did:web account");
match d.parse() {
Ok(d) => d,
Err(_) => return ApiError::InvalidDid("Invalid DID format".into()).into_response(),
}
d.clone()
}
_ => {
if let Some(d) = &input.did {
if d.starts_with("did:plc:") && is_migration {
info!(did = %d, "Migration with existing did:plc");
match d.parse() {
Ok(d) => d,
Err(_) => {
return ApiError::InvalidDid("Invalid DID format".into())
.into_response();
}
}
d.clone()
} else if d.starts_with("did:web:") {
if !is_did_web_byod
&& let Err(e) =
verify_did_web(d, hostname, &input.handle, input.signing_key.as_ref())
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref())
.await
{
return ApiError::InvalidDid(e.to_string()).into_response();
}
match d.parse() {
Ok(d) => d,
Err(_) => {
return ApiError::InvalidDid("Invalid DID format".into())
.into_response();
}
}
d.clone()
} else if !d.trim().is_empty() {
return ApiError::InvalidDid(
"Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth.".into()
@@ -400,10 +396,14 @@ pub async fn create_account(
return response;
}
let handle_typed: Handle = match handle.parse() {
Ok(h) => h,
Err(_) => return ApiError::InvalidHandle(None).into_response(),
};
let handle_available = match state
.repos
.user
.check_handle_available_for_new_account(&handle)
.check_handle_available_for_new_account(&handle_typed)
.await
{
Ok(available) => available,
@@ -437,15 +437,23 @@ pub async fn create_account(
None
};
let repo =
match super::provision::init_genesis_repo(&state, &did, &signing_key, &secret_key_bytes)
.await
{
Ok(r) => r,
Err(e) => return e.into_response(),
};
let commit_cid = CidLink::from(&repo.commit_cid);
let repo_rev = repo.repo_rev.clone();
let did_for_commit: Did = match did.parse() {
Ok(d) => d,
Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(),
};
let repo = match super::provision::init_genesis_repo(
&state,
&did_for_commit,
&signing_key,
&secret_key_bytes,
)
.await
{
Ok(r) => r,
Err(e) => return e.into_response(),
};
let commit_cid_str = repo.commit_cid.to_string();
let rev_str = repo.repo_rev.clone();
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
Some(json!({
@@ -465,9 +473,9 @@ pub async fn create_account(
let repo_for_seq = repo.clone();
let create_input = tranquil_db_traits::CreatePasswordAccountInput {
handle: handle.clone(),
handle: handle_typed.clone(),
email: email.clone(),
did: did.clone(),
did: did_for_commit.clone(),
password_hash,
preferred_comms_channel,
discord_username: comms.discord,
@@ -478,8 +486,8 @@ pub async fn create_account(
encrypted_key_bytes: repo.encrypted_key_bytes,
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
reserved_key_id,
commit_cid: commit_cid.clone(),
repo_rev: repo_rev.clone(),
commit_cid: commit_cid_str.clone(),
repo_rev: rev_str.clone(),
genesis_block_cids: repo.genesis_block_cids,
invite_code: invite_registration.into_invite_code(),
birthdate_pref,
@@ -511,15 +519,21 @@ pub async fn create_account(
};
let user_id = create_result.user_id;
if !is_migration && !is_did_web_byod {
super::provision::sequence_new_account(&state, &did, &handle, &repo_for_seq, &input.handle)
.await;
super::provision::sequence_new_account(
&state,
&did_for_commit,
&handle_typed,
&repo_for_seq,
&input.handle,
)
.await;
}
if !is_migration {
if let Some(ref recipient) = verification_recipient {
super::provision::enqueue_signup_verification(
&state,
user_id,
&did,
&did_for_commit,
verification_channel,
recipient,
)
@@ -529,7 +543,7 @@ pub async fn create_account(
super::provision::enqueue_migration_verification(
&state,
user_id,
&did,
&did_for_commit,
verification_channel,
recipient,
)
@@ -539,6 +553,7 @@ pub async fn create_account(
let session = match super::provision::create_and_store_session(
&state,
&did,
&did_for_commit,
&secret_key_bytes,
"transition:generic transition:chat.bsky",
None,
@@ -561,9 +576,9 @@ pub async fn create_account(
(
StatusCode::OK,
Json(CreateAccountOutput {
handle: handle.clone(),
did,
did_doc,
handle: handle.clone().into(),
did: did_for_commit,
did_doc: did_doc.map(|f| (*f).clone()),
access_jwt: session.access_jwt,
refresh_jwt: session.refresh_jwt,
verification_required: !is_migration,
+34 -36
View File
@@ -19,7 +19,7 @@ use tranquil_pds::rate_limit::{
HandleUpdateDailyLimit, HandleUpdateLimit, check_user_rate_limit_with_message,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Handle};
use tranquil_pds::types::Handle;
use tranquil_pds::util::get_header_str;
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -44,18 +44,16 @@ pub async fn resolve_handle(
if handle_str.is_empty() {
return ApiError::InvalidRequest("handle is required".into()).into_response();
}
let cache_key = tranquil_pds::cache_keys::handle_key(handle_str);
if let Some(did) = state.cache.get(&cache_key).await {
return DidResponse::response(did).into_response();
}
let handle: Handle = match handle_str.parse() {
Ok(h) => h,
Err(_) => {
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
}
};
let cache_key = tranquil_pds::cache_keys::handle_key(&handle);
if let Some(cached) = state.cache.get(&cache_key).await
&& let Ok(did) = tranquil_pds::Did::new(cached)
{
return DidResponse::response(did).into_response();
}
let user = state.repos.user.get_by_handle(&handle).await;
match user {
Ok(Some(row)) => {
@@ -65,7 +63,7 @@ pub async fn resolve_handle(
.await;
DidResponse::response(row.did).into_response()
}
Ok(None) => match tranquil_pds::handle::resolve_handle(&handle).await {
Ok(None) => match tranquil_pds::handle::resolve_handle(handle.as_str()).await {
Ok(did) => {
let _ = state
.cache
@@ -161,15 +159,15 @@ pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -
async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response {
let encoded_handle = handle.replace(':', "%3A");
let expected_did: tranquil_pds::types::Did = match format!("did:web:{}", encoded_handle).parse()
{
let expected_did = format!("did:web:{}", encoded_handle);
let expected_did_typed: tranquil_pds::types::Did = match expected_did.parse() {
Ok(d) => d,
Err(_) => return ApiError::InvalidRequest("Invalid DID format".into()).into_response(),
};
let user = match state
.repos
.user
.get_user_for_did_doc_build(&expected_did)
.get_user_for_did_doc_build(&expected_did_typed)
.await
{
Ok(Some(u)) => u,
@@ -214,7 +212,8 @@ async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) ->
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
let hostname = &tranquil_config::get().server.hostname;
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let current_handle: Handle = match format!("{}.{}", handle, hostname_for_handles).parse() {
let current_handle = format!("{}.{}", handle, hostname_for_handles);
let current_handle_typed: Handle = match current_handle.parse() {
Ok(h) => h,
Err(_) => {
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
@@ -223,7 +222,7 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
let user = match state
.repos
.user
.get_did_web_info_by_handle(&current_handle)
.get_did_web_info_by_handle(&current_handle_typed)
.await
{
Ok(Some(u)) => u,
@@ -348,7 +347,7 @@ pub async fn verify_did_web(
did: &str,
hostname: &str,
handle: &str,
expected_signing_key: Option<&Did>,
expected_signing_key: Option<&str>,
) -> Result<(), DidWebVerifyError> {
let hostname_for_handles = hostname.split(':').next().unwrap_or(hostname);
let subdomain_host = format!("{}.{}", handle, hostname_for_handles);
@@ -427,15 +426,10 @@ 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| {
let is_atproto_method = vm["id"]
vm["publicKeyMultibase"]
.as_str()
.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)
.is_some_and(|pk| pk == expected_multibase)
});
if !has_matching_key {
return Err(DidWebVerifyError::KeyMismatch(
@@ -589,7 +583,7 @@ pub async fn update_handle(
.max_by_key(|d| d.len())
.cloned();
let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle);
let handle: Handle = if (!new_handle.contains('.') || matched_handle_domain.is_some())
let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some())
&& !is_domain_itself
{
let (short_part, full_handle) = match &matched_handle_domain {
@@ -604,12 +598,13 @@ pub async fn update_handle(
}
};
if full_handle == current_handle {
let handle: Handle = match full_handle.parse() {
let handle_typed: Handle = match full_handle.parse() {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
};
if let Err(e) =
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle)).await
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle_typed))
.await
{
warn!("Failed to sequence identity event for handle update: {}", e);
}
@@ -627,21 +622,21 @@ pub async fn update_handle(
return Err(ApiError::InvalidHandle(Some("Handle too long".into())));
}
full_handle
.parse()
.map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?
} else {
let handle: Handle = new_handle
.parse()
.map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?;
if new_handle == current_handle {
let handle_typed: Handle = match new_handle.parse() {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
};
if let Err(e) =
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle)).await
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle_typed))
.await
{
warn!("Failed to sequence identity event for handle update: {}", e);
}
return Ok(Json(EmptyResponse {}));
}
match tranquil_pds::handle::verify_handle_ownership(&handle, &did).await {
match tranquil_pds::handle::verify_handle_ownership(&new_handle, &did).await {
Ok(()) => {}
Err(tranquil_pds::handle::HandleResolutionError::NotFound) => {
return Err(ApiError::HandleNotAvailable(None));
@@ -660,12 +655,15 @@ pub async fn update_handle(
))));
}
}
handle
new_handle.clone()
};
let handle_typed: Handle = handle
.parse()
.map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?;
let handle_exists = state
.repos
.user
.check_handle_exists(&handle, user_id)
.check_handle_exists(&handle_typed, user_id)
.await
.log_db_err("checking handle existence")?;
if handle_exists {
@@ -674,7 +672,7 @@ pub async fn update_handle(
state
.repos
.user
.update_handle(user_id, &handle)
.update_handle(user_id, &handle_typed)
.await
.map_err(|e| {
error!("DB error updating handle: {:?}", e);
@@ -692,11 +690,11 @@ pub async fn update_handle(
.delete(&tranquil_pds::cache_keys::handle_key(&handle))
.await;
if let Err(e) =
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle)).await
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle_typed)).await
{
warn!("Failed to sequence identity event for handle update: {}", e);
}
if let Err(e) = update_plc_handle(&state, &did, &handle).await {
if let Err(e) = update_plc_handle(&state, &did, &handle_typed).await {
warn!("Failed to update PLC handle: {}", e);
}
Ok(Json(EmptyResponse {}))
+3 -2
View File
@@ -26,9 +26,10 @@ pub async fn verify_handle_ownership(
_rate_limit: RateLimited<HandleVerificationLimit>,
Json(input): Json<VerifyHandleOwnershipInput>,
) -> Response {
let handle_str = input.handle.as_str();
let did_str = input.did.as_str();
let dns_mismatch = match tranquil_pds::handle::resolve_handle_dns(&input.handle).await {
let dns_mismatch = match tranquil_pds::handle::resolve_handle_dns(handle_str).await {
Ok(did) if did == did_str => {
return Json(VerifyHandleOwnershipOutput {
verified: true,
@@ -44,7 +45,7 @@ pub async fn verify_handle_ownership(
Err(_) => None,
};
match tranquil_pds::handle::resolve_handle_http(&input.handle).await {
match tranquil_pds::handle::resolve_handle_http(handle_str).await {
Ok(did) if did == did_str => Json(VerifyHandleOwnershipOutput {
verified: true,
method: Some("http".to_string()),
+38 -44
View File
@@ -1,4 +1,4 @@
use jacquard_common::types::{integer::LimitedU32, string::Tid as JacquardTid};
use jacquard_common::types::{integer::LimitedU32, string::Tid};
use jacquard_repo::{mst::Mst, storage::BlockStore};
use k256::ecdsa::SigningKey;
use std::sync::Arc;
@@ -6,7 +6,7 @@ use tranquil_db_traits::CommsChannel;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::repo_ops::create_signed_commit;
use tranquil_pds::state::AppState;
use tranquil_pds::types::{CidLink, Did, Handle, Tid};
use tranquil_pds::types::{Did, Handle};
pub struct PlcDidResult {
pub did: Did,
@@ -14,7 +14,7 @@ pub struct PlcDidResult {
pub signing_key: SigningKey,
}
pub async fn create_plc_did(state: &AppState, handle: &Handle) -> Result<PlcDidResult, ApiError> {
pub async fn create_plc_did(state: &AppState, handle: &str) -> Result<PlcDidResult, ApiError> {
use k256::SecretKey;
use rand::rngs::OsRng;
@@ -25,7 +25,10 @@ pub async fn create_plc_did(state: &AppState, handle: &Handle) -> Result<PlcDidR
ApiError::InternalError(None)
})?;
let did = submit_plc_genesis(state, &signing_key, handle).await?;
let did_str = submit_plc_genesis(state, &signing_key, handle).await?;
let did: Did = did_str
.parse()
.map_err(|_| ApiError::InternalError(Some("PLC genesis returned invalid DID".into())))?;
Ok(PlcDidResult {
did,
@@ -37,8 +40,8 @@ pub async fn create_plc_did(state: &AppState, handle: &Handle) -> Result<PlcDidR
pub async fn submit_plc_genesis(
state: &AppState,
signing_key: &SigningKey,
handle: &Handle,
) -> Result<Did, ApiError> {
handle: &str,
) -> Result<String, ApiError> {
let hostname = &tranquil_config::get().server.hostname;
let pds_endpoint = format!("https://{}", hostname);
@@ -71,7 +74,7 @@ pub struct GenesisRepo {
pub encrypted_key_bytes: Vec<u8>,
pub commit_cid: cid::Cid,
pub mst_root_cid: cid::Cid,
pub repo_rev: Tid,
pub repo_rev: String,
pub genesis_block_cids: Vec<Vec<u8>>,
}
@@ -93,8 +96,8 @@ pub async fn init_genesis_repo(
ApiError::InternalError(None)
})?;
let rev = JacquardTid::now(LimitedU32::MIN);
let (commit_bytes, _sig) = create_signed_commit(did, mst_root, &rev, None, signing_key)
let rev = Tid::now(LimitedU32::MIN);
let (commit_bytes, _sig) = create_signed_commit(did, mst_root, rev.as_ref(), None, signing_key)
.map_err(|e| {
tracing::error!("Error creating genesis commit: {:?}", e);
ApiError::InternalError(None)
@@ -109,7 +112,7 @@ pub async fn init_genesis_repo(
encrypted_key_bytes,
commit_cid,
mst_root_cid: mst_root,
repo_rev: Tid::from(rev.clone()),
repo_rev: rev.as_ref().to_string(),
genesis_block_cids: vec![mst_root.to_bytes(), commit_cid.to_bytes()],
})
}
@@ -122,7 +125,7 @@ pub struct SigningKeyResult {
pub async fn resolve_signing_key(
state: &AppState,
signing_key_did: Option<&Did>,
signing_key_did: Option<&str>,
) -> Result<SigningKeyResult, ApiError> {
match signing_key_did {
Some(key_did) => {
@@ -164,13 +167,6 @@ 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,
@@ -205,31 +201,27 @@ pub async fn sequence_new_account(
if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event(
state,
did,
&CidLink::from(&repo.commit_cid),
&repo.commit_cid.to_string(),
Some(&repo.repo_rev),
)
.await
{
tracing::warn!("Failed to sequence sync event for {}: {}", did, e);
}
// TODO: make this configurable and also deduplicate with tranquil-oauth-server/src/sso_endpoints.rs:1210
#[cfg(feature = "bsky")]
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
{
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);
};
tracing::warn!("Failed to create default profile for {}: {}", did, e);
}
}
@@ -266,23 +258,25 @@ pub struct SessionResult {
pub async fn create_and_store_session(
state: &AppState,
did_str: &str,
did: &Did,
signing_key_bytes: &[u8],
scope: &str,
controller_did: Option<&Did>,
) -> Result<SessionResult, ApiError> {
let access_meta = tranquil_pds::auth::create_access_token_with_metadata(did, signing_key_bytes)
.map_err(|e| {
tracing::error!("Error creating access token: {:?}", e);
ApiError::InternalError(None)
})?;
let refresh_meta =
tranquil_pds::auth::create_refresh_token_with_metadata(did, signing_key_bytes).map_err(
let access_meta =
tranquil_pds::auth::create_access_token_with_metadata(did_str, signing_key_bytes).map_err(
|e| {
tracing::error!("Error creating refresh token: {:?}", e);
tracing::error!("Error creating access token: {:?}", e);
ApiError::InternalError(None)
},
)?;
let refresh_meta =
tranquil_pds::auth::create_refresh_token_with_metadata(did_str, signing_key_bytes)
.map_err(|e| {
tracing::error!("Error creating refresh token: {:?}", e);
ApiError::InternalError(None)
})?;
let session_data = tranquil_db_traits::SessionTokenCreate {
did: did.clone(),
access_jti: access_meta.jti.clone(),
+13 -34
View File
@@ -1,12 +1,5 @@
// 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 actor;
pub mod admin;
#[cfg(feature = "bsky")]
pub mod age_assurance;
pub mod common;
pub mod delegation;
@@ -28,7 +21,7 @@ pub fn api_routes() -> axum::Router<AppState> {
let blob_body_limit =
DefaultBodyLimit::max(tranquil_config::get().server.max_blob_size as usize);
let router = axum::Router::new()
axum::Router::new()
.route("/_health", get(server::health))
.route(
"/com.atproto.server.describeServer",
@@ -380,6 +373,14 @@ 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),
@@ -437,21 +438,7 @@ 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),
@@ -459,9 +446,7 @@ 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> {
@@ -489,15 +474,9 @@ pub fn webhook_routes() -> axum::Router<AppState> {
pub fn misc_routes() -> axum::Router<AppState> {
use axum::routing::get;
let router = axum::Router::new()
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));
if tranquil_config::get().server.rfc_moo_compliance {
router.route("/cow.txt", get(server::cow_txt))
} else {
router
}
.route("/u/{handle}/did.json", get(identity::user_did_doc))
}
+8 -23
View File
@@ -6,16 +6,11 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::LazyLock;
use tracing::{error, info, warn};
use tracing::{error, info};
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, 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 {
@@ -70,26 +65,17 @@ pub struct CreateReportOutput {
struct ReportServiceConfig {
url: String,
did: Did,
did: String,
}
fn get_report_service_config() -> Option<ReportServiceConfig> {
let cfg = tranquil_config::get();
let url = cfg.moderation.report_service_url.clone()?;
let did_str = cfg.moderation.report_service_did.as_deref()?;
if url.is_empty() || did_str.is_empty() {
let did = cfg.moderation.report_service_did.clone()?;
if url.is_empty() || did.is_empty() {
return None;
}
match did_str.parse::<Did>() {
Ok(did) => Some(ReportServiceConfig { url, did }),
Err(_) => {
warn!(
report_service_did = did_str,
"invalid report_service_did, handling reports locally"
);
None
}
}
Some(ReportServiceConfig { url, did })
}
pub async fn create_report(
@@ -110,7 +96,7 @@ async fn proxy_to_report_service(
state: &AppState,
auth_user: &tranquil_pds::auth::AuthenticatedUser,
service_url: &str,
service_did: &Did,
service_did: &str,
input: &CreateReportInput,
) -> Response {
if let Err(e) = is_ssrf_safe(service_url) {
@@ -151,9 +137,8 @@ async fn proxy_to_report_service(
let service_token = match tranquil_pds::auth::create_service_token(
&auth_user.did,
&DidRef::from(service_did),
Some(&CREATE_REPORT_NSID),
None,
service_did,
Some("com.atproto.moderation.createReport"),
&key_bytes,
) {
Ok(t) => t,
@@ -6,7 +6,7 @@ use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType};
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{Active, Auth};
use tranquil_pds::state::AppState;
use tranquil_types::{Did, Handle};
use tranquil_types::Did;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
@@ -137,7 +137,7 @@ pub async fn request_channel_verification(
did: &Did,
channel: CommsChannel,
identifier: &str,
handle: Option<&Handle>,
handle: Option<&str>,
) -> Result<String, ApiError> {
let token = tranquil_pds::auth::verification_token::generate_channel_update_token(
did, channel, identifier,
@@ -147,14 +147,12 @@ pub async fn request_channel_verification(
match channel {
CommsChannel::Email => {
let hostname = &tranquil_config::get().server.hostname;
let handle = handle.ok_or_else(|| {
ApiError::InternalError(Some("Email verification requires a handle".into()))
})?;
let handle_str = handle.unwrap_or("user");
tranquil_pds::comms::comms_repo::enqueue_email_update(
state.repos.infra.as_ref(),
user_id,
identifier,
handle,
handle_str,
&formatted_token,
hostname,
)
+2 -6
View File
@@ -11,18 +11,14 @@ use multihash::Multihash;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::pin::Pin;
use std::sync::LazyLock;
use tracing::{debug, error, info, warn};
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{Auth, AuthAny, NotTakendown, Permissive, VerifyScope};
use tranquil_pds::delegation::DelegationActionType;
use tranquil_pds::state::AppState;
use tranquil_pds::types::{CidLink, Did, Nsid};
use tranquil_pds::types::{CidLink, Did};
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();
@@ -52,7 +48,7 @@ pub async fn upload_blob(
) -> Result<Response, ApiError> {
let (did, controller_did): (Did, Option<Did>) = match &auth {
AuthAny::Service(service) => {
service.require_lxm(&UPLOAD_BLOB_NSID)?;
service.require_lxm("com.atproto.repo.uploadBlob")?;
(service.did.clone(), None)
}
AuthAny::User(user) => {
+34 -47
View File
@@ -167,11 +167,17 @@ pub async fn import_repo(
.records
.iter()
.flat_map(|record| {
let record_uri = AtUri::from_parts(did, &record.collection, &record.rkey);
record
.blob_refs
.iter()
.map(move |blob_ref| (record_uri.clone(), blob_ref.cid.clone()))
let record_uri =
AtUri::from_parts(did.as_str(), &record.collection, &record.rkey);
record.blob_refs.iter().filter_map(move |blob_ref| {
match CidLink::new(&blob_ref.cid) {
Ok(cid_link) => Some((record_uri.clone(), cid_link)),
Err(_) => {
tracing::warn!(cid = %blob_ref.cid, "skipping unparseable blob CID reference during import");
None
}
}
})
})
.collect();
@@ -221,62 +227,43 @@ pub async fn import_repo(
})?;
let new_rev = Tid::now(LimitedU32::MIN);
let new_rev_str = new_rev.to_string();
let (commit_bytes, _sig) =
create_signed_commit(did, import_result.data_cid, &new_rev, None, &signing_key)
.map_err(|e| {
error!("Failed to create new commit: {}", e);
ApiError::InternalError(None)
})?;
let (commit_bytes, _sig) = create_signed_commit(
did,
import_result.data_cid,
&new_rev_str,
None,
&signing_key,
)
.map_err(|e| {
error!("Failed to create new commit: {}", e);
ApiError::InternalError(None)
})?;
let new_root_cid: cid::Cid =
state.block_store.put(&commit_bytes).await.map_err(|e| {
error!("Failed to store new commit block: {:?}", e);
ApiError::InternalError(None)
})?;
let new_root_cid_link = CidLink::from(&new_root_cid);
let new_rev_tid = tranquil_pds::types::Tid::from(new_rev.clone());
state
.repos
.repo
.update_repo_root(user_id, &new_root_cid_link, &new_rev_tid)
.update_repo_root(user_id, &new_root_cid_link, &new_rev_str)
.await
.map_err(|e| {
error!("Failed to update repo root: {:?}", 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 mut all_block_cids: Vec<Vec<u8>> = blocks.keys().map(|c| c.to_bytes()).collect();
all_block_cids.push(new_root_cid.to_bytes());
state
.repos
.repo
.insert_user_blocks(user_id, &all_block_cids, &new_rev_str)
.await
.map_err(|e| {
error!("Failed to insert user_blocks: {:?}", e);
ApiError::InternalError(None)
})?;
let new_root_str = new_root_cid.to_string();
info!(
"Created new commit for imported repo: cid={}, rev={}",
+5 -5
View File
@@ -18,7 +18,7 @@ use tranquil_pds::repo_ops::{
finalize_repo_write, with_repair_retry,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AtIdentifier, AtUri, CidLink, Did, Nsid, Rkey};
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
use tranquil_pds::validation::ValidationStatus;
const MAX_BATCH_WRITES: usize = 200;
@@ -27,7 +27,7 @@ struct WriteAccumulator {
mst: Mst<TrackingBlockStore>,
results: Vec<WriteResult>,
ops: Vec<RecordOp>,
all_blob_cids: Vec<CidLink>,
all_blob_cids: Vec<String>,
backlinks_to_add: Vec<Backlink>,
backlinks_to_remove: Vec<AtUri>,
}
@@ -353,9 +353,9 @@ pub async fn apply_writes(
&auth,
&input.writes,
|w| match w {
WriteOp::Create { collection, .. } => collection,
WriteOp::Update { collection, .. } => collection,
WriteOp::Delete { collection, .. } => collection,
WriteOp::Create { collection, .. } => collection.as_str(),
WriteOp::Update { collection, .. } => collection.as_str(),
WriteOp::Delete { collection, .. } => collection.as_str(),
},
|w| match w {
WriteOp::Create { .. } => WriteOpKind::Create,
@@ -0,0 +1 @@
pub use tranquil_pds::repo_ops::*;
@@ -9,13 +9,13 @@ pub async fn validate_record_with_status(
require_lexicon: bool,
) -> Result<ValidationStatus, ApiError> {
let registry = tranquil_lexicon::LexiconRegistry::global();
if !registry.has_schema(collection) {
let _ = registry.resolve_dynamic(collection).await;
if !registry.has_schema(collection.as_str()) {
let _ = registry.resolve_dynamic(collection.as_str()).await;
}
let validator = RecordValidator::new().require_lexicon(require_lexicon);
validator
.validate_with_rkey(record, collection, rkey)
.validate_with_rkey(record, collection.as_str(), rkey.map(|v| v.as_str()))
.map_err(validation_error_to_api_error)
}
+9 -19
View File
@@ -7,7 +7,6 @@ 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,
@@ -18,7 +17,7 @@ use tranquil_pds::repo_ops::{
finalize_repo_write, with_repair_retry,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey, Tid};
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
use tranquil_pds::validation::ValidationStatus;
use uuid::Uuid;
@@ -103,7 +102,7 @@ pub struct CreateRecordInput {
#[serde(rename_all = "camelCase")]
pub struct CommitInfo {
pub cid: String,
pub rev: Tid,
pub rev: String,
}
#[derive(Serialize)]
@@ -182,15 +181,8 @@ async fn create_record_inner(
else {
continue;
};
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_rkey = Rkey::from(conflict_rkey_str.to_string());
let conflict_collection = Nsid::from(conflict_col_str.to_string());
let conflict_key = format!("{}/{}", conflict_collection, conflict_rkey);
let prev_cid = match mst.get(&conflict_key).await {
@@ -381,8 +373,11 @@ 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 = jacquard_repo::mst::util::compute_cid(&record_bytes)
.map_err(|_| ApiError::InvalidRecord("Failed to compute record CID".into()))?;
let record_cid = ctx
.tracking_store
.put(&record_bytes)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
if existing_cid == Some(record_cid) {
return Ok(PutRecordOutput {
@@ -393,11 +388,6 @@ 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::{debug, error, info, warn};
use tracing::{error, info, warn};
use tranquil_pds::api::EmptyResponse;
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{Auth, NotTakendown, Permissive, require_legacy_session_mfa};
@@ -18,7 +18,7 @@ use tranquil_pds::cache::Cache;
use tranquil_pds::oauth::scopes::{AccountAction, AccountAttr};
use tranquil_pds::plc::PlcClient;
use tranquil_pds::state::AppState;
use tranquil_pds::types::{PlainPassword, Tid};
use tranquil_pds::types::PlainPassword;
use uuid::Uuid;
#[derive(Serialize)]
@@ -27,8 +27,7 @@ pub struct CheckAccountStatusOutput {
pub activated: bool,
pub valid_did: bool,
pub repo_commit: String,
#[serde(serialize_with = "serialize_optional_rev")]
pub repo_rev: Option<Tid>,
pub repo_rev: String,
pub repo_blocks: i64,
pub indexed_records: i64,
pub private_state_values: i64,
@@ -36,13 +35,6 @@ 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>,
@@ -73,20 +65,23 @@ pub async fn check_account_status(
.count_user_blocks(user_id)
.await
.unwrap_or(0);
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| {
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 {
Commit::from_cbor(&block)
.ok()
.map(|commit| Tid::from(commit.rev().clone()))
}),
(None, Err(_)) => None,
.map(|c| c.rev().to_string())
.unwrap_or_default()
} else {
String::new()
}
} else {
String::new()
}
} else {
String::new()
};
let record_count: i64 = state.repos.repo.count_records(user_id).await.unwrap_or(0);
let imported_blobs: i64 = state
@@ -138,7 +133,7 @@ async fn assert_valid_did_document_for_service(
if did.as_str().starts_with("did:plc:") {
let max_attempts = if with_retry { 5 } else { 1 };
let cache_for_retry = cache.clone();
let did_owned = did.clone();
let did_owned = did.as_str().to_string();
let expected_owned = expected_endpoint.clone();
let attempt_counter = Arc::new(AtomicUsize::new(0));
@@ -212,10 +207,9 @@ 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())
{
debug!(
"DID {} rotation keys {:?} omit the PDS-managed server rotation key {}",
did, doc_rotation_keys, expected_rotation_key
);
return Err(ApiError::InvalidRequest(
"Server rotation key not included in PLC DID data".into(),
));
}
let doc_signing_key = doc_data
@@ -252,10 +246,13 @@ async fn assert_valid_did_document_for_service(
}
if !doc_rotation_keys.contains(&expected_did_key.as_str()) {
debug!(
warn!(
"DID {} rotation keys {:?} omit the PDS-managed signing key {}",
did, doc_rotation_keys, expected_did_key
);
return Err(ApiError::InvalidRequest(
"PLC rotation keys omit the PDS-managed signing key required to sign operations for this identity".into(),
));
}
}
} else if let Some(host_and_path) = did.as_str().strip_prefix("did:web:") {
@@ -390,7 +387,7 @@ pub async fn activate_account(
.cache
.delete(&tranquil_pds::cache_keys::plc_data_key(&did))
.await;
if state.did_resolver.refresh_did(&did).await.is_err() {
if state.did_resolver.refresh_did(did.as_str()).await.is_err() {
warn!(
"[MIGRATION] activateAccount: Failed to refresh DID cache for {}",
did
@@ -426,8 +423,10 @@ pub async fn activate_account(
"[MIGRATION] activateAccount: Sequencing identity event for did={} handle={:?}",
did, handle
);
let handle_typed = handle.clone();
if let Err(e) =
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, handle.as_ref()).await
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, handle_typed.as_ref())
.await
{
warn!(
"[MIGRATION] activateAccount: Failed to sequence identity event for activation: {}",
@@ -450,9 +449,7 @@ pub async fn activate_account(
);
let rev = if let Ok(cid) = Cid::from_str(root_cid_link.as_str()) {
if let Ok(Some(block)) = state.block_store.get(&cid).await {
Commit::from_cbor(&block)
.ok()
.map(|c| Tid::from(c.rev().clone()))
Commit::from_cbor(&block).ok().map(|c| c.rev().to_string())
} else {
None
}
@@ -462,8 +459,8 @@ pub async fn activate_account(
if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event(
&state,
&did,
&root_cid_link,
rev.as_ref(),
root_cid_link.as_str(),
rev.as_deref(),
)
.await
{
@@ -640,7 +637,7 @@ pub async fn delete_account(
state.repos.session.as_ref(),
user_id,
password,
password_hash.as_ref(),
password_hash.as_deref(),
)
.await
.is_none()
@@ -191,7 +191,7 @@ pub async fn create_app_password(
}
Ok(Json(CreateAppPasswordOutput {
name: name.to_string(),
password: password.into_inner(),
password,
created_at: created_at.to_rfc3339(),
privileged: privilege.is_privileged(),
scopes: final_scopes,
-57
View File
@@ -1,57 +0,0 @@
..........................
....*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|_
+5 -6
View File
@@ -19,7 +19,6 @@ use tranquil_pds::auth::{Auth, NotTakendown};
use tranquil_pds::oauth::scopes::{AccountAction, AccountAttr};
use tranquil_pds::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AtIdentifier, Did};
const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60);
@@ -38,7 +37,7 @@ struct PendingEmailUpdate {
async fn get_pending_email_update(
cache: &dyn tranquil_pds::cache::Cache,
did: &Did,
did: &str,
) -> Option<PendingEmailUpdate> {
cache
.get(&tranquil_pds::cache_keys::email_update_key(did))
@@ -80,7 +79,7 @@ pub async fn request_email_update(
if token_required {
let token = tranquil_pds::auth::email_token::create_email_token(
state.cache.as_ref(),
&auth.did,
auth.did.as_str(),
tranquil_pds::auth::email_token::EmailTokenPurpose::UpdateEmail,
)
.await
@@ -250,7 +249,7 @@ pub async fn update_email(
tranquil_pds::auth::email_token::validate_email_token(
state.cache.as_ref(),
did,
did.as_str(),
tranquil_pds::auth::email_token::EmailTokenPurpose::UpdateEmail,
token,
)
@@ -299,7 +298,7 @@ pub async fn update_email(
let short_token_result = tranquil_pds::auth::email_token::validate_email_token(
state.cache.as_ref(),
did,
did.as_str(),
tranquil_pds::auth::email_token::EmailTokenPurpose::UpdateEmail,
token,
)
@@ -395,7 +394,7 @@ pub async fn update_email(
#[derive(Deserialize)]
pub struct CheckEmailVerifiedInput {
pub identifier: AtIdentifier,
pub identifier: String,
}
pub async fn check_email_verified(
+26 -26
View File
@@ -7,7 +7,6 @@ use tranquil_pds::auth::{Admin, Auth, NotTakendown};
use tranquil_pds::state::AppState;
use tranquil_pds::types::Did;
use tranquil_pds::util::gen_invite_code;
use tranquil_types::InviteCode as InviteCodeValue;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -18,7 +17,7 @@ pub struct CreateInviteCodeInput {
#[derive(Serialize)]
pub struct CreateInviteCodeOutput {
pub code: InviteCodeValue,
pub code: String,
}
pub async fn create_invite_code(
@@ -43,7 +42,7 @@ pub async fn create_invite_code(
match state
.repos
.infra
.create_invite_code(&code, input.use_count, &for_account)
.create_invite_code(&code, input.use_count, Some(&for_account))
.await
{
Ok(true) => Ok(Json(CreateInviteCodeOutput { code })),
@@ -73,8 +72,8 @@ pub struct CreateInviteCodesOutput {
#[derive(Serialize)]
pub struct AccountCodes {
pub account: Did,
pub codes: Vec<InviteCodeValue>,
pub account: String,
pub codes: Vec<String>,
}
pub async fn create_invite_codes(
@@ -113,11 +112,14 @@ pub async fn create_invite_codes(
let infra_repo = state.repos.infra.clone();
let use_count = input.use_count;
async move {
let codes: Vec<InviteCodeValue> = (0..code_count).map(|_| gen_invite_code()).collect();
let codes: Vec<String> = (0..code_count).map(|_| gen_invite_code()).collect();
infra_repo
.create_invite_codes_batch(&codes, use_count, admin_user_id, &account)
.create_invite_codes_batch(&codes, use_count, admin_user_id, Some(&account))
.await
.map(|_| AccountCodes { account, codes })
.map(|_| AccountCodes {
account: account.to_string(),
codes,
})
}
}))
.await;
@@ -143,7 +145,7 @@ pub struct GetAccountInviteCodesParams {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteCode {
pub code: InviteCodeValue,
pub code: String,
pub available: i32,
pub disabled: bool,
pub for_account: String,
@@ -188,24 +190,27 @@ 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: Vec<InviteCodeUse> = infra_repo
let uses = infra_repo
.get_invite_code_uses(&info.code)
.await
.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(),
.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<_>>()
})
.collect();
.unwrap_or_default();
let use_count = i32::try_from(uses.len()).unwrap_or(i32::MAX);
if !include_used && use_count >= info.available_uses {
return Ok(None);
return None;
}
Ok(Some(InviteCode {
Some(InviteCode {
code: info.code,
available: info.available_uses,
disabled: false,
@@ -216,16 +221,11 @@ 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()
.collect::<Result<Vec<Option<InviteCode>>, ApiError>>()?
.into_iter()
.flatten()
.collect();
let codes: Vec<InviteCode> = codes.into_iter().flatten().collect();
Ok(Json(GetAccountInviteCodesOutput { codes }))
}
-4
View File
@@ -29,10 +29,6 @@ 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
}
+1 -1
View File
@@ -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::{cow_txt, describe_server, health, robots_txt};
pub use meta::{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,
@@ -14,7 +14,7 @@ use tranquil_pds::auth::NormalizedLoginIdentifier;
use tranquil_pds::auth::{ServiceTokenVerifier, generate_app_password, is_service_token};
use tranquil_pds::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Handle, Jti, Nsid, PlainPassword};
use tranquil_pds::types::{Did, Handle, PlainPassword};
use tranquil_pds::validation::validate_password;
fn generate_setup_token() -> String {
@@ -39,7 +39,7 @@ pub struct CreatePasskeyAccountInput {
pub invite_code: Option<String>,
pub did: Option<String>,
pub did_type: Option<String>,
pub signing_key: Option<Did>,
pub signing_key: Option<String>,
pub verification_channel: Option<tranquil_db_traits::CommsChannel>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
@@ -69,10 +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::new("com.atproto.server.createAccount")
.expect("com.atproto.server.createAccount is a valid NSID");
match verifier
.verify_service_token(&token, Some(&create_account_lxm))
.verify_service_token(&token, Some("com.atproto.server.createAccount"))
.await
{
Ok(claims) => {
@@ -145,7 +143,7 @@ pub async fn create_passkey_account(
let did_type = input.did_type.as_deref().unwrap_or("plc");
let key_result =
match crate::identity::provision::resolve_signing_key(&state, input.signing_key.as_ref())
match crate::identity::provision::resolve_signing_key(&state, input.signing_key.as_deref())
.await
{
Ok(k) => k,
@@ -155,7 +153,7 @@ pub async fn create_passkey_account(
let secret_key = key_result.signing_key;
let reserved_key_id = key_result.reserved_key_id;
let did: Did = match did_type {
let did = match did_type {
"web" => {
let self_hosted_did = match common::create_self_hosted_did_web(&handle) {
Ok(d) => d,
@@ -193,7 +191,7 @@ pub async fn create_passkey_account(
d,
hostname,
&input.handle,
input.signing_key.as_ref(),
input.signing_key.as_deref(),
)
.await
{
@@ -201,8 +199,7 @@ pub async fn create_passkey_account(
}
info!(did = %d, "Creating external did:web passkey account (reserved key)");
}
d.parse()
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?
d.to_string()
}
_ => {
if let Some(ref auth_did) = byod_auth {
@@ -215,9 +212,7 @@ pub async fn create_passkey_account(
)));
}
info!(did = %provided_did, "Creating BYOD did:plc passkey account (migration)");
provided_did
.parse()
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?
provided_did.clone()
} else {
return Err(ApiError::InvalidRequest(
"BYOD migration requires a did:plc or did:web DID".into(),
@@ -273,9 +268,13 @@ pub async fn create_passkey_account(
None
};
let did_typed: Did = match did.parse() {
Ok(d) => d,
Err(_) => return Err(ApiError::InternalError(Some("Invalid DID".into()))),
};
let repo = match crate::identity::provision::init_genesis_repo(
&state,
&did,
&did_typed,
&secret_key,
&secret_key_bytes,
)
@@ -294,6 +293,10 @@ pub async fn create_passkey_account(
None
};
let handle_typed: Handle = match handle.parse() {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
};
let repo_for_seq = repo.clone();
let comms = crate::identity::provision::normalize_comms_usernames(
input.discord_username.as_deref(),
@@ -301,9 +304,9 @@ pub async fn create_passkey_account(
input.signal_username.as_deref(),
);
let create_input = tranquil_db_traits::CreatePasskeyAccountInput {
handle: handle.clone(),
handle: handle_typed.clone(),
email: email.clone().unwrap_or_default(),
did: did.clone(),
did: did_typed.clone(),
preferred_comms_channel: verification_channel,
discord_username: comms.discord,
telegram_username: comms.telegram,
@@ -314,7 +317,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),
commit_cid: repo.commit_cid.to_string(),
repo_rev: repo.repo_rev.clone(),
genesis_block_cids: repo.genesis_block_cids,
invite_code: invite_registration.into_invite_code(),
@@ -342,10 +345,10 @@ pub async fn create_passkey_account(
if !is_byod_did_web {
crate::identity::provision::sequence_new_account(
&state,
&did,
&handle,
&did_typed,
&handle_typed,
&repo_for_seq,
handle.as_str(),
&handle,
)
.await;
}
@@ -353,7 +356,7 @@ pub async fn create_passkey_account(
crate::identity::provision::enqueue_signup_verification(
&state,
user_id,
&did,
&did_typed,
verification_channel,
&verification_recipient,
)
@@ -364,10 +367,10 @@ pub async fn create_passkey_account(
let access_jwt = if byod_auth.is_some() {
match tranquil_pds::auth::create_access_token_with_metadata(&did, &secret_key_bytes) {
Ok(token_meta) => {
let refresh_jti = Jti::from(uuid::Uuid::new_v4().to_string());
let refresh_jti = uuid::Uuid::new_v4().to_string();
let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(24);
let session_data = tranquil_db_traits::SessionTokenCreate {
did: did.clone(),
did: did_typed.clone(),
access_jti: token_meta.jti.clone(),
refresh_jti,
access_expires_at: token_meta.expires_at,
@@ -394,8 +397,8 @@ pub async fn create_passkey_account(
};
Ok(Json(CreatePasskeyAccountOutput {
did,
handle,
did: did.into(),
handle: handle.into(),
setup_token,
setup_expires_at,
access_jwt,
@@ -549,7 +552,7 @@ pub async fn complete_passkey_setup(
Ok(Json(CompletePasskeySetupOutput {
did: input.did.clone(),
handle: user.handle,
app_password: app_password.into_inner(),
app_password,
app_password_name,
}))
}
@@ -688,7 +691,7 @@ pub async fn request_passkey_recovery(
if let Err(e) = state
.repos
.user
.set_recovery_token(&user.did, recovery_token_hash.as_str(), expires_at)
.set_recovery_token(&user.did, &recovery_token_hash, expires_at)
.await
{
error!("Error updating recovery token: {:?}", e);
+7 -18
View File
@@ -10,7 +10,7 @@ use tranquil_pds::auth::{
};
use tranquil_pds::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Handle, PlainPassword};
use tranquil_pds::types::PlainPassword;
use tranquil_pds::validation::validate_password;
#[derive(Deserialize)]
@@ -45,23 +45,12 @@ pub async fn request_password_reset(
None
};
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 {
let user_id = match state
.repos
.user
.get_id_by_email_or_handle(normalized, normalized_handle.as_str())
.await
{
Ok(Some(id)) => id,
Ok(None) => {
info!("Password reset requested for unknown identifier");
+2 -2
View File
@@ -74,7 +74,7 @@ pub async fn reauth_password(
.log_db_err("fetching password hash")?
.ok_or(ApiError::AccountNotFound)?;
let password_valid = bcrypt::verify(&input.password, password_hash.as_str()).unwrap_or(false);
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
if !password_valid {
let app_password_hashes = state
@@ -85,7 +85,7 @@ pub async fn reauth_password(
.unwrap_or_default();
let app_password_valid = app_password_hashes.iter().fold(false, |acc, h| {
acc | bcrypt::verify(&input.password, h.as_str()).unwrap_or(false)
acc | bcrypt::verify(&input.password, h).unwrap_or(false)
});
if !app_password_valid {
@@ -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::DidRef;
use tranquil_pds::types::Did;
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: DidRef,
pub aud: Did,
pub lxm: Option<Nsid>,
pub exp: Option<i64>,
}
@@ -118,7 +118,7 @@ pub async fn get_service_auth(
&auth.auth_source,
auth.scope.as_deref(),
params.aud.as_str(),
method,
method.as_str(),
) {
return e.into_response();
}
@@ -171,9 +171,8 @@ pub async fn get_service_auth(
let service_token = match tranquil_pds::auth::create_service_token(
&auth.did,
&params.aud,
lxm,
params.exp,
params.aud.as_str(),
lxm.map(|v| v.as_str()),
&key_bytes,
) {
Ok(t) => t,
+16 -25
View File
@@ -20,7 +20,7 @@ use tranquil_pds::rate_limit::{
check_user_rate_limit_with_message,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AccountState, AtIdentifier, Did, Handle, PlainPassword};
use tranquil_pds::types::{AccountState, Did, Handle, PlainPassword};
use tranquil_types::TokenId;
pub fn verification_blocks_login(channel_verification: &ChannelVerificationStatus) -> bool {
@@ -78,20 +78,10 @@ pub async fn create_session(
"Normalized identifier: {} -> {}",
input.identifier, normalized_identifier
);
let Ok(login_identifier) = AtIdentifier::new(normalized_identifier.as_str()) else {
let _ = verify(
&input.password,
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
);
warn!("Login identifier is not a valid handle or DID");
return Err(ApiError::AuthenticationFailed(Some(
"Invalid identifier or password".into(),
)));
};
let row = match state
.repos
.user
.get_login_full_by_identifier(&login_identifier)
.get_login_full_by_identifier(normalized_identifier.as_str())
.await
{
Ok(Some(row)) => row,
@@ -122,7 +112,7 @@ pub async fn create_session(
state.repos.session.as_ref(),
row.id,
&input.password,
row.password_hash.as_ref(),
row.password_hash.as_deref(),
)
.await;
let (app_password_name, app_password_scopes, app_password_controller) = match credential {
@@ -277,7 +267,7 @@ pub async fn create_session(
&row.did,
&key_bytes,
app_password_scopes.as_deref(),
app_password_controller.as_ref(),
app_password_controller.as_deref(),
None,
) {
Ok(m) => m,
@@ -351,7 +341,7 @@ pub async fn create_session(
refresh_jwt: refresh_meta.token,
handle,
did: row.did,
did_doc: did_doc.ok(),
did_doc: did_doc.ok().map(|f| (*f).clone()),
email: row.email,
email_confirmed: Some(row.channel_verification.email),
email_auth_factor: email_auth_factor_out,
@@ -444,7 +434,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(),
did_doc: did_doc.ok().map(|f| (*f).clone()),
}))
}
Ok(None) => Err(ApiError::AuthenticationFailed(None)),
@@ -575,7 +565,7 @@ pub async fn refresh_session(
&session_row.did,
&key_bytes,
session_row.scope.as_deref(),
session_row.controller_did.as_ref(),
session_row.controller_did.as_deref(),
None,
) {
Ok(m) => m,
@@ -747,7 +737,7 @@ fn remint_grace_tokens(
&replay.did,
key_bytes,
replay.scope.as_deref(),
replay.controller_did.as_ref(),
replay.controller_did.as_deref(),
None,
&replay.access_jti,
replay.access_expires_at,
@@ -800,7 +790,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(),
did_doc: did_doc.ok().map(|f| (*f).clone()),
status: account_state.status_for_session().map(String::from),
}))
}
@@ -915,6 +905,7 @@ pub async fn confirm_signup(
let session = match crate::identity::provision::create_and_store_session(
&state,
&row.did,
&row.did,
&key_bytes,
"transition:generic transition:chat.bsky",
None,
@@ -956,7 +947,7 @@ pub struct AutoResendResult {
}
pub async fn auto_resend_verification(state: &AppState, did: &Did) -> Option<AutoResendResult> {
let debounce_key = tranquil_pds::cache_keys::auto_verify_sent_key(did);
let debounce_key = tranquil_pds::cache_keys::auto_verify_sent_key(did.as_str());
let debounced = state.cache.get(&debounce_key).await.is_some();
let row = match state.repos.user.get_resend_verification_by_did(did).await {
Ok(Some(row)) => row,
@@ -1139,13 +1130,13 @@ pub async fn list_sessions(
Ok(Json(ListSessionsOutput { sessions }))
}
fn extract_client_name(client_id: &tranquil_types::ClientId) -> String {
fn extract_client_name(client_id: &str) -> String {
if client_id.starts_with("http://localhost") || client_id.starts_with("http://127.0.0.1") {
"Localhost App".to_string()
} else if let Ok(parsed) = reqwest::Url::parse(client_id.as_str()) {
} else if let Ok(parsed) = reqwest::Url::parse(client_id) {
parsed.host_str().unwrap_or("Unknown App").to_string()
} else {
client_id.as_str().to_string()
client_id.to_string()
}
}
@@ -1219,11 +1210,11 @@ pub async fn revoke_all_sessions(
.delete_sessions_by_did(&auth.did)
.await
.log_db_err("revoking JWT sessions")?;
let token_id = TokenId::from(jti.clone().into_inner());
let jti_typed = TokenId::from(jti.clone());
state
.repos
.oauth
.delete_sessions_by_did_except(&auth.did, &token_id)
.delete_sessions_by_did_except(&auth.did, &jti_typed)
.await
.log_db_err("revoking OAuth sessions")?;
} else {
@@ -10,19 +10,17 @@ use serde::{Deserialize, Serialize};
use tracing::{error, info};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::state::AppState;
use tranquil_pds::types::Did;
const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01];
fn public_key_to_did_key(signing_key: &SigningKey) -> Did {
fn public_key_to_did_key(signing_key: &SigningKey) -> String {
let verifying_key = signing_key.verifying_key();
let compressed_pubkey = verifying_key.to_sec1_bytes();
let mut multicodec_key = Vec::with_capacity(2 + compressed_pubkey.len());
multicodec_key.extend_from_slice(&SECP256K1_MULTICODEC_PREFIX);
multicodec_key.extend_from_slice(&compressed_pubkey);
let encoded = multibase::encode(multibase::Base::Base58Btc, &multicodec_key);
Did::new(format!("did:key:{}", encoded))
.expect("did:key with a multibase-encoded secp256k1 key is a valid DID")
format!("did:key:{}", encoded)
}
#[derive(Deserialize)]
@@ -33,7 +31,7 @@ pub struct ReserveSigningKeyInput {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReserveSigningKeyOutput {
pub signing_key: Did,
pub signing_key: String,
}
pub async fn reserve_signing_key(
+4 -6
View File
@@ -6,8 +6,8 @@ use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{
Active, Auth, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
is_backup_code_format, require_legacy_session_mfa, verify_backup_code, verify_password_mfa,
verify_totp_code, verify_totp_mfa,
is_backup_code_format, verify_backup_code, verify_password_mfa, verify_totp_code,
verify_totp_mfa,
};
use tranquil_pds::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
use tranquil_pds::state::AppState;
@@ -163,11 +163,9 @@ pub async fn disable_totp(
auth: Auth<Active>,
Json(input): Json<DisableTotpInput>,
) -> Result<Json<EmptyResponse>, ApiError> {
let session_mfa = require_legacy_session_mfa(&state, &auth).await?;
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
&state,
session_mfa.did(),
&auth.did,
"Too many verification attempts. Please try again in a few minutes.",
)
.await?;
@@ -184,7 +182,7 @@ pub async fn disable_totp(
tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &auth.did).await;
info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
info!(did = %password_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
Ok(Json(EmptyResponse {}))
}
@@ -52,7 +52,7 @@ impl DeviceTrustState {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TrustedDevice {
pub id: DeviceId,
pub id: String,
pub user_agent: Option<String>,
pub friendly_name: Option<String>,
pub trusted_at: Option<DateTime<Utc>>,
+2 -15
View File
@@ -62,20 +62,7 @@ pub async fn handle_telegram_webhook(
&& let Some(from) = message.from
&& let Some(username) = from.username
{
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;
}
};
let handle = parse_start_handle(message.text.as_deref());
debug!(
telegram_username = %username,
@@ -86,7 +73,7 @@ pub async fn handle_telegram_webhook(
match state
.repos
.user
.store_telegram_chat_id(&username, from.id, handle.as_ref())
.store_telegram_chat_id(&username, from.id, handle.as_deref())
.await
{
Ok(Some(user_id)) => {
+9 -10
View File
@@ -58,16 +58,7 @@ pub async fn dereference_scope(
for part in scope_parts {
if let Some(cid_str) = part.strip_prefix("ref:") {
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));
let cache_key = tranquil_pds::cache_keys::scope_ref_key(cid_str);
if let Some(cached) = state.cache.get(&cache_key).await {
for s in cached.split_whitespace() {
if !resolved_scopes.contains(&s.to_string()) {
@@ -77,6 +68,14 @@ 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) => {
+31
View File
@@ -0,0 +1,31 @@
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(),
}
}
-2
View File
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
tranquil-config = { workspace = true }
tranquil-crypto = { workspace = true }
tranquil-types = { path = "../tranquil-types", default-features = false }
anyhow = { workspace = true }
base32 = { workspace = true }
@@ -24,4 +23,3 @@ subtle = { workspace = true }
totp-rs = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
brotli = "8.0.4"
-183
View File
@@ -1,183 +0,0 @@
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());
}
}
+2 -5
View File
@@ -1,4 +1,3 @@
mod compress;
mod token;
mod totp;
mod types;
@@ -13,8 +12,6 @@ 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,
@@ -28,6 +25,6 @@ pub use types::{
pub use verify::{
get_algorithm_from_token, get_did_from_token, get_jti_from_token, verify_access_token,
verify_access_token_hs256, verify_refresh_token, verify_refresh_token_hs256, verify_token,
verify_token_es256k,
verify_access_token_hs256, verify_access_token_typed, verify_refresh_token,
verify_refresh_token_hs256, verify_token,
};
+54 -61
View File
@@ -1,33 +1,30 @@
use crate::compress::encode_scope;
use super::types::{
ActClaim, Claims, Header, SigningAlgorithm, TokenScope, TokenType, TokenWithMetadata,
};
use anyhow::{Context, Result};
use anyhow::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, DidRef, Jti, Nsid};
type HmacSha256 = Hmac<Sha256>;
pub fn create_access_token(did: &Did, key_bytes: &[u8]) -> Result<String> {
pub fn create_access_token(did: &str, key_bytes: &[u8]) -> Result<String> {
Ok(create_access_token_with_metadata(did, key_bytes)?.token)
}
pub fn create_refresh_token(did: &Did, key_bytes: &[u8]) -> Result<String> {
pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result<String> {
Ok(create_refresh_token_with_metadata(did, key_bytes)?.token)
}
pub fn create_access_token_with_metadata(did: &Did, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
create_access_token_with_scope_metadata(did, key_bytes, None, None)
}
pub fn create_access_token_with_scope_metadata(
did: &Did,
did: &str,
key_bytes: &[u8],
scopes: Option<&str>,
hostname: Option<&str>,
@@ -44,14 +41,14 @@ pub fn create_access_token_with_scope_metadata(
}
pub fn create_access_token_with_delegation(
did: &Did,
did: &str,
key_bytes: &[u8],
scopes: Option<&str>,
controller_did: Option<&Did>,
controller_did: Option<&str>,
hostname: Option<&str>,
) -> Result<TokenWithMetadata> {
let scope = scopes.unwrap_or(TokenScope::Access.as_str());
let act = controller_did.map(|c| ActClaim { sub: c.clone() });
let act = controller_did.map(|c| ActClaim { sub: c.to_string() });
create_signed_token_with_act(
did,
scope,
@@ -64,7 +61,7 @@ pub fn create_access_token_with_delegation(
}
pub fn create_refresh_token_with_metadata(
did: &Did,
did: &str,
key_bytes: &[u8],
) -> Result<TokenWithMetadata> {
create_signed_token_with_metadata(
@@ -81,23 +78,23 @@ pub fn create_refresh_token_with_metadata(
/// refresh grace window to reproduce a session's current access token without
/// persisting the signed JWT itself.
pub fn create_access_token_with_jti(
did: &Did,
did: &str,
key_bytes: &[u8],
scopes: Option<&str>,
controller_did: Option<&Did>,
controller_did: Option<&str>,
hostname: Option<&str>,
jti: &Jti,
jti: &str,
expires_at: DateTime<Utc>,
) -> Result<String> {
let scope = scopes.unwrap_or(TokenScope::Access.as_str());
let act = controller_did.map(|c| ActClaim { sub: c.clone() });
let act = controller_did.map(|c| ActClaim { sub: c.to_string() });
Ok(create_signed_token_pinned(
did,
scope,
TokenType::Access,
key_bytes,
expires_at,
jti.clone(),
jti.to_string(),
act,
hostname,
)?
@@ -107,9 +104,9 @@ pub fn create_access_token_with_jti(
/// Re-mint a refresh token carrying a specific `jti` and expiry. Counterpart to
/// [`create_access_token_with_jti`] for the refresh grace window.
pub fn create_refresh_token_with_jti(
did: &Did,
did: &str,
key_bytes: &[u8],
jti: &Jti,
jti: &str,
expires_at: DateTime<Utc>,
) -> Result<String> {
Ok(create_signed_token_pinned(
@@ -118,7 +115,7 @@ pub fn create_refresh_token_with_jti(
TokenType::Refresh,
key_bytes,
expires_at,
jti.clone(),
jti.to_string(),
None,
None,
)?
@@ -126,31 +123,27 @@ pub fn create_refresh_token_with_jti(
}
pub fn create_service_token(
did: &Did,
aud: &DidRef,
lxm: Option<&Nsid>,
exp: Option<i64>,
did: &str,
aud: &str,
lxm: Option<&str>,
key_bytes: &[u8],
) -> Result<String> {
let signing_key = SigningKey::from_slice(key_bytes)?;
let expiration = match exp {
Some(exp) => exp,
None => Utc::now()
.checked_add_signed(Duration::seconds(60))
.expect("valid timestamp")
.timestamp(),
};
let expiration = Utc::now()
.checked_add_signed(Duration::seconds(60))
.expect("valid timestamp")
.timestamp();
let claims = Claims {
iss: did.clone(),
sub: did.clone(),
aud: aud.to_string(),
iss: did.to_owned(),
sub: did.to_owned(),
aud: aud.to_owned(),
exp: expiration,
iat: Utc::now().timestamp(),
scope: None,
lxm: lxm.cloned(),
jti: Jti::new(uuid::Uuid::new_v4().to_string()),
lxm: lxm.map(ToOwned::to_owned),
jti: uuid::Uuid::new_v4().to_string(),
act: None,
};
@@ -158,7 +151,7 @@ pub fn create_service_token(
}
fn create_signed_token_with_metadata(
did: &Did,
did: &str,
scope: &str,
typ: TokenType,
key_bytes: &[u8],
@@ -169,7 +162,7 @@ fn create_signed_token_with_metadata(
}
fn create_signed_token_with_act(
did: &Did,
did: &str,
scope: &str,
typ: TokenType,
key_bytes: &[u8],
@@ -180,18 +173,18 @@ fn create_signed_token_with_act(
let expires_at = Utc::now()
.checked_add_signed(duration)
.expect("valid timestamp");
let jti = Jti::new(uuid::Uuid::new_v4().to_string());
let jti = uuid::Uuid::new_v4().to_string();
create_signed_token_pinned(did, scope, typ, key_bytes, expires_at, jti, act, hostname)
}
#[allow(clippy::too_many_arguments)]
fn create_signed_token_pinned(
did: &Did,
did: &str,
scope: &str,
typ: TokenType,
key_bytes: &[u8],
expires_at: DateTime<Utc>,
jti: Jti,
jti: String,
act: Option<ActClaim>,
hostname: Option<&str>,
) -> Result<TokenWithMetadata> {
@@ -206,12 +199,12 @@ fn create_signed_token_pinned(
});
let claims = Claims {
iss: did.clone(),
sub: did.clone(),
iss: did.to_owned(),
sub: did.to_owned(),
aud: format!("did:web:{}", aud_hostname),
exp: expiration,
iat: Utc::now().timestamp(),
scope: Some(encode_scope(scope).context("Scope too large to encode")?),
scope: Some(scope.to_string()),
lxm: None,
jti: jti.clone(),
act,
@@ -249,16 +242,16 @@ fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: TokenType) -> Re
Ok(format!("{}.{}", message, signature_b64))
}
pub fn create_access_token_hs256(did: &Did, secret: &[u8]) -> Result<String> {
pub fn create_access_token_hs256(did: &str, secret: &[u8]) -> Result<String> {
Ok(create_access_token_hs256_with_metadata(did, secret)?.token)
}
pub fn create_refresh_token_hs256(did: &Did, secret: &[u8]) -> Result<String> {
pub fn create_refresh_token_hs256(did: &str, secret: &[u8]) -> Result<String> {
Ok(create_refresh_token_hs256_with_metadata(did, secret)?.token)
}
pub fn create_access_token_hs256_with_metadata(
did: &Did,
did: &str,
secret: &[u8],
) -> Result<TokenWithMetadata> {
create_hs256_token_with_metadata(
@@ -271,7 +264,7 @@ pub fn create_access_token_hs256_with_metadata(
}
pub fn create_refresh_token_hs256_with_metadata(
did: &Did,
did: &str,
secret: &[u8],
) -> Result<TokenWithMetadata> {
create_hs256_token_with_metadata(
@@ -284,9 +277,9 @@ pub fn create_refresh_token_hs256_with_metadata(
}
pub fn create_service_token_hs256(
did: &Did,
aud: &Did,
lxm: &Nsid,
did: &str,
aud: &str,
lxm: &str,
secret: &[u8],
) -> Result<String> {
let expiration = Utc::now()
@@ -295,14 +288,14 @@ pub fn create_service_token_hs256(
.timestamp();
let claims = Claims {
iss: did.clone(),
sub: did.clone(),
aud: aud.to_string(),
iss: did.to_owned(),
sub: did.to_owned(),
aud: aud.to_owned(),
exp: expiration,
iat: Utc::now().timestamp(),
scope: None,
lxm: Some(lxm.clone()),
jti: Jti::new(uuid::Uuid::new_v4().to_string()),
lxm: Some(lxm.to_string()),
jti: uuid::Uuid::new_v4().to_string(),
act: None,
};
@@ -310,7 +303,7 @@ pub fn create_service_token_hs256(
}
fn create_hs256_token_with_metadata(
did: &Did,
did: &str,
scope: &str,
typ: TokenType,
secret: &[u8],
@@ -321,11 +314,11 @@ fn create_hs256_token_with_metadata(
.expect("valid timestamp");
let expiration = expires_at.timestamp();
let jti = Jti::new(uuid::Uuid::new_v4().to_string());
let jti = uuid::Uuid::new_v4().to_string();
let claims = Claims {
iss: did.clone(),
sub: did.clone(),
iss: did.to_owned(),
sub: did.to_owned(),
aud: format!(
"did:web:{}",
tranquil_config::try_get()
@@ -334,7 +327,7 @@ fn create_hs256_token_with_metadata(
),
exp: expiration,
iat: Utc::now().timestamp(),
scope: Some(encode_scope(scope).context("Scope too large to encode")?),
scope: Some(scope.to_string()),
lxm: None,
jti: jti.clone(),
act: None,
+10 -11
View File
@@ -2,7 +2,6 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize, de, ser};
use std::fmt;
use std::str::FromStr;
use tranquil_types::{Did, Jti, Nsid};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenType {
@@ -204,21 +203,21 @@ impl std::error::Error for TokenDecodeError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActClaim {
pub sub: Did,
pub sub: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub iss: Did,
pub sub: Did,
pub iss: String,
pub sub: String,
pub aud: String,
pub exp: i64,
pub iat: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lxm: Option<Nsid>,
pub jti: Jti,
pub lxm: Option<String>,
pub jti: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub act: Option<ActClaim>,
}
@@ -231,8 +230,8 @@ pub struct Header {
#[derive(Debug, Serialize, Deserialize)]
pub struct UnsafeClaims {
pub iss: Did,
pub sub: Option<Did>,
pub iss: String,
pub sub: Option<String>,
}
pub struct TokenData<T> {
@@ -241,21 +240,21 @@ pub struct TokenData<T> {
pub struct TokenWithMetadata {
pub token: String,
pub jti: Jti,
pub jti: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenVerifyError {
Expired,
Invalid(&'static str),
Invalid,
}
impl fmt::Display for TokenVerifyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Expired => write!(f, "Token expired"),
Self::Invalid(reason) => write!(f, "{}", reason),
Self::Invalid => write!(f, "Token invalid"),
}
}
}
+112 -39
View File
@@ -1,5 +1,3 @@
use crate::compress::decode_scope;
use super::types::{
Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
TokenVerifyError, UnsafeClaims,
@@ -12,11 +10,10 @@ use hmac::{Hmac, Mac};
use k256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Verifier};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use tranquil_types::{Did, Jti};
type HmacSha256 = Hmac<Sha256>;
pub fn get_did_from_token(token: &str) -> Result<Did, TokenDecodeError> {
pub fn get_did_from_token(token: &str) -> Result<String, TokenDecodeError> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err(TokenDecodeError::InvalidFormat);
@@ -32,7 +29,7 @@ pub fn get_did_from_token(token: &str) -> Result<Did, TokenDecodeError> {
Ok(claims.sub.unwrap_or(claims.iss))
}
pub fn get_jti_from_token(token: &str) -> Result<Jti, TokenDecodeError> {
pub fn get_jti_from_token(token: &str) -> Result<String, TokenDecodeError> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err(TokenDecodeError::InvalidFormat);
@@ -48,7 +45,7 @@ pub fn get_jti_from_token(token: &str) -> Result<Jti, TokenDecodeError> {
claims
.get("jti")
.and_then(|j| j.as_str())
.map(Jti::new)
.map(|s| s.to_string())
.ok_or(TokenDecodeError::MissingClaim)
}
@@ -69,11 +66,11 @@ pub fn get_algorithm_from_token(token: &str) -> Result<SigningAlgorithm, TokenDe
}
pub fn verify_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
verify_token_es256k(token, key_bytes, None, None).map_err(anyhow::Error::from)
verify_token_internal(token, key_bytes, None, None)
}
pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
verify_token_es256k(
verify_token_internal(
token,
key_bytes,
Some(TokenType::Access),
@@ -83,17 +80,15 @@ pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Cl
TokenScope::AppPassPrivileged,
]),
)
.map_err(anyhow::Error::from)
}
pub fn verify_refresh_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
verify_token_es256k(
verify_token_internal(
token,
key_bytes,
Some(TokenType::Refresh),
Some(&[TokenScope::Refresh]),
)
.map_err(anyhow::Error::from)
}
pub fn verify_access_token_hs256(token: &str, secret: &[u8]) -> Result<TokenData<Claims>> {
@@ -118,15 +113,15 @@ pub fn verify_refresh_token_hs256(token: &str, secret: &[u8]) -> Result<TokenDat
)
}
pub fn verify_token_es256k(
fn verify_token_internal(
token: &str,
key_bytes: &[u8],
expected_typ: Option<TokenType>,
allowed_scopes: Option<&[TokenScope]>,
) -> Result<TokenData<Claims>, TokenVerifyError> {
) -> Result<TokenData<Claims>> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err(TokenVerifyError::Invalid("Invalid token format"));
return Err(anyhow!("Invalid token format"));
}
let header_b64 = parts[0];
@@ -135,49 +130,46 @@ pub fn verify_token_es256k(
let header_bytes = URL_SAFE_NO_PAD
.decode(header_b64)
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of header failed"))?;
.context("Base64 decode of header failed")?;
let header: Header = serde_json::from_slice(&header_bytes)
.map_err(|_| TokenVerifyError::Invalid("JSON decode of header failed"))?;
let header: Header =
serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?;
if let Some(expected) = expected_typ
&& header.typ != expected
{
return Err(TokenVerifyError::Invalid("Invalid token type"));
return Err(anyhow!(
"Invalid token type: expected {}, got {}",
expected,
header.typ
));
}
let signature_bytes = URL_SAFE_NO_PAD
.decode(signature_b64)
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of signature failed"))?;
.context("Base64 decode of signature failed")?;
let signature = Signature::from_slice(&signature_bytes)
.map_err(|_| TokenVerifyError::Invalid("Invalid signature format"))?;
.map_err(|e| anyhow!("Invalid signature format: {}", e))?;
let signing_key = SigningKey::from_slice(key_bytes)
.map_err(|_| TokenVerifyError::Invalid("Invalid signing key"))?;
let signing_key = SigningKey::from_slice(key_bytes)?;
let verifying_key = VerifyingKey::from(&signing_key);
let message = format!("{}.{}", header_b64, claims_b64);
verifying_key
.verify(message.as_bytes(), &signature)
.map_err(|_| TokenVerifyError::Invalid("Signature verification failed"))?;
.map_err(|e| anyhow!("Signature verification failed: {}", e))?;
let claims_bytes = URL_SAFE_NO_PAD
.decode(claims_b64)
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of claims failed"))?;
.context("Base64 decode of claims failed")?;
let mut claims: Claims = serde_json::from_slice(&claims_bytes)
.map_err(|_| TokenVerifyError::Invalid("JSON decode of claims failed"))?;
if let Some(scope) = &claims.scope {
claims.scope = Some(
decode_scope(scope).map_err(|_| TokenVerifyError::Invalid("Invalid token scope"))?,
);
}
let claims: Claims =
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
let now = Utc::now().timestamp();
if claims.exp < now {
return Err(TokenVerifyError::Expired);
return Err(anyhow!("Token expired"));
}
if let Some(scopes) = allowed_scopes {
@@ -188,7 +180,7 @@ pub fn verify_token_es256k(
.parse()
.unwrap_or_else(|e| match e {});
if !scopes.contains(&token_scope) {
return Err(TokenVerifyError::Invalid("Invalid token scope"));
return Err(anyhow!("Invalid token scope: {}", token_scope));
}
}
@@ -252,13 +244,9 @@ fn verify_token_hs256_internal(
.decode(claims_b64)
.context("Base64 decode of claims failed")?;
let mut claims: Claims =
let 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"));
@@ -278,3 +266,88 @@ fn verify_token_hs256_internal(
Ok(TokenData { claims })
}
pub fn verify_access_token_typed(
token: &str,
key_bytes: &[u8],
) -> Result<TokenData<Claims>, TokenVerifyError> {
verify_token_typed_internal(token, key_bytes, Some(TokenType::Access), None)
}
fn verify_token_typed_internal(
token: &str,
key_bytes: &[u8],
expected_typ: Option<TokenType>,
allowed_scopes: Option<&[TokenScope]>,
) -> Result<TokenData<Claims>, TokenVerifyError> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err(TokenVerifyError::Invalid);
}
let header_b64 = parts[0];
let claims_b64 = parts[1];
let signature_b64 = parts[2];
let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(header_b64) else {
return Err(TokenVerifyError::Invalid);
};
let Ok(header) = serde_json::from_slice::<Header>(&header_bytes) else {
return Err(TokenVerifyError::Invalid);
};
if let Some(expected) = expected_typ
&& header.typ != expected
{
return Err(TokenVerifyError::Invalid);
}
let Ok(signature_bytes) = URL_SAFE_NO_PAD.decode(signature_b64) else {
return Err(TokenVerifyError::Invalid);
};
let Ok(signature) = Signature::from_slice(&signature_bytes) else {
return Err(TokenVerifyError::Invalid);
};
let Ok(signing_key) = SigningKey::from_slice(key_bytes) else {
return Err(TokenVerifyError::Invalid);
};
let verifying_key = VerifyingKey::from(&signing_key);
let message = format!("{}.{}", header_b64, claims_b64);
if verifying_key
.verify(message.as_bytes(), &signature)
.is_err()
{
return Err(TokenVerifyError::Invalid);
}
let Ok(claims_bytes) = URL_SAFE_NO_PAD.decode(claims_b64) else {
return Err(TokenVerifyError::Invalid);
};
let Ok(claims) = serde_json::from_slice::<Claims>(&claims_bytes) else {
return Err(TokenVerifyError::Invalid);
};
let now = Utc::now().timestamp();
if claims.exp < now {
return Err(TokenVerifyError::Expired);
}
if let Some(scopes) = allowed_scopes {
let token_scope: TokenScope = claims
.scope
.as_deref()
.unwrap_or("")
.parse()
.unwrap_or_else(|e| match e {});
if !scopes.contains(&token_scope) {
return Err(TokenVerifyError::Invalid);
}
}
Ok(TokenData { claims })
}
+2 -1
View File
@@ -5,11 +5,12 @@ edition.workspace = true
license.workspace = true
[features]
default = []
valkey = ["dep:redis"]
[dependencies]
tranquil-config = { workspace = true }
tranquil-infra = { workspace = true, features = ["cache-keys"] }
tranquil-infra = { workspace = true }
tranquil-ripple = { workspace = true }
async-trait = { workspace = true }
+3 -4
View File
@@ -1,6 +1,4 @@
pub use tranquil_infra::{
Cache, CacheError, DistributedRateLimiter, cache_keys, cached_json, read_json, write_json,
};
pub use tranquil_infra::{Cache, CacheError, DistributedRateLimiter};
use async_trait::async_trait;
use std::sync::Arc;
@@ -175,10 +173,11 @@ 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) = cache_cfg.and_then(|c| c.valkey_url.as_deref()) {
if let Some(url) = valkey_url {
match ValkeyCache::new(url).await {
Ok(cache) => {
tracing::info!("using valkey cache at {url}");
+1
View File
@@ -19,6 +19,7 @@ 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 }
+8 -123
View File
@@ -1,41 +1,25 @@
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::{CommsType, QueuedComms};
use crate::types::QueuedComms;
pub(super) fn build(
from: &Mailbox,
qc: &QueuedComms,
apply_atmos_categories: bool,
) -> Result<Message, SendError> {
pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result<Message, SendError> {
let to: Mailbox = qc
.recipient
.parse()
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
let subject = qc.subject.as_deref().unwrap_or("Notification");
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
let builder = Message::builder()
Message::builder()
.from(from.clone())
.to(to)
.subject(subject)
.message_id(Some(message_id))
.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
.header(ContentType::TEXT_PLAIN)
.body(qc.body.clone())
.map_err(|e| SendError::MessageBuild(e.to_string()))
}
@@ -50,57 +34,10 @@ 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};
use crate::types::{CommsChannel, CommsStatus, CommsType};
use chrono::Utc;
use uuid::Uuid;
@@ -134,7 +71,6 @@ 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();
@@ -151,7 +87,6 @@ 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();
@@ -164,7 +99,6 @@ 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(_))));
}
@@ -174,7 +108,6 @@ 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();
@@ -190,12 +123,7 @@ mod tests {
#[test]
fn message_id_uses_from_domain() {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("s"), "b"),
false,
)
.unwrap();
let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
let line = raw
.lines()
@@ -209,58 +137,15 @@ mod tests {
#[test]
fn missing_subject_uses_default() {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", None, "Body"),
false,
)
.unwrap();
let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("Subject: Notification"));
}
#[test]
fn recipient_domain_extracted() {
let msg = build(
&from_mailbox(),
&fixture("user@Nel.PET", Some("s"), "b"),
false,
)
.unwrap();
let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap();
let d = recipient_domain(&msg).unwrap();
assert_eq!(d.as_str(), "nel.pet");
}
#[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"));
}
}
+5 -61
View File
@@ -124,18 +124,15 @@ fn build_smarthost(
Ok(SendMode::Smarthost {
transport: Box::new(builder.build()),
total_timeout,
apply_atmos_categories: cfg.email.smarthost.apply_atmos_categories,
})
}
fn build_direct_mx(cfg: &tranquil_config::TranquilConfig) -> Result<SendMode, SendError> {
let helo = resolve_helo(cfg)?;
let resolver = Arc::new(
TokioAsyncResolver::tokio_from_system_conf().unwrap_or_else(|e| {
tracing::warn!("falling back to default DNS resolvers: {}", e);
TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default())
}),
);
let resolver = Arc::new(TokioAsyncResolver::tokio_from_system_conf().unwrap_or_else(|e| {
tracing::warn!("falling back to default DNS resolvers: {}", e);
TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default())
}));
let max_concurrent = cfg.email.direct_mx.max_concurrent_sends.max(1);
Ok(SendMode::DirectMx {
resolver,
@@ -177,16 +174,6 @@ 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 {
@@ -194,8 +181,7 @@ impl CommsSender for EmailSender {
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let mut message =
message::build(&self.from, notification, wants_atmos_categories(&self.mode))?;
let mut message = message::build(&self.from, notification)?;
if let Some(signer) = &self.dkim {
signer.sign(&mut message);
}
@@ -208,45 +194,3 @@ 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()));
}
}
+2 -11
View File
@@ -19,7 +19,6 @@ pub enum SendMode {
Smarthost {
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
total_timeout: Duration,
apply_atmos_categories: bool,
},
DirectMx {
resolver: Arc<TokioAsyncResolver>,
@@ -34,15 +33,8 @@ 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,
apply_atmos_categories,
..
} => {
write!(
f,
"SendMode::Smarthost(total_timeout={total_timeout:?}, apply_atmos_categories={apply_atmos_categories:?})"
)
Self::Smarthost { total_timeout, .. } => {
write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})")
}
Self::DirectMx {
helo, require_tls, ..
@@ -60,7 +52,6 @@ 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,7 +53,6 @@ fn build_smarthost_sender_with_total_timeout(
SendMode::Smarthost {
transport: Box::new(transport),
total_timeout,
apply_atmos_categories: false,
},
None,
)
+1
View File
@@ -6,3 +6,4 @@ license.workspace = true
[dependencies]
confique = { workspace = true }
serde = { workspace = true }
+12 -30
View File
@@ -445,10 +445,6 @@ 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,
@@ -835,7 +831,7 @@ pub struct PlcConfig {
#[config(env = "PLC_CONNECT_TIMEOUT_SECS", default = 5)]
pub connect_timeout_secs: u64,
/// Seconds to cache DID documents.
/// Seconds to cache DID documents in memory.
#[config(env = "DID_CACHE_TTL_SECS", default = 300)]
pub did_cache_ttl_secs: u64,
}
@@ -857,27 +853,18 @@ pub struct FirehoseConfig {
pub max_concurrent_repo_exports: usize,
/// List of relay / crawler notification URLs.
#[config(env = "CRAWLERS", parse_env = split_comma_list, default = [
// If you know of more relays it makes sense to have here by all means make a PR!
// All we request is that you only add "major" relays.
// What exactly "major" means is up to interpretation and we will make the final call,
// but a good rule of thumb is "most people are likely to add the relay if they know about it"
#[config(env = "CRAWLERS", parse_env = split_comma_list)]
pub crawlers: Option<Vec<String>>,
}
// Microcosm relays
"https://relay.fire.hose.cam",
"https://relay3.fr.hose.cam",
// PBC relay
"https://bsky.network",
// firehose.network relays
"https://northamerica.firehose.network",
"https://europe.firehose.network",
"https://asia.firehose.network",
// Blacksky relay
"https://atproto.africa",
// UpCloud relay
"https://relay.upcloud.world",
])]
pub crawlers: Vec<String>,
impl FirehoseConfig {
/// Returns the list of crawler URLs, falling back to `["https://bsky.network"]`
/// when none are configured.
pub fn crawler_list(&self) -> Vec<String> {
self.crawlers
.clone()
.unwrap_or_else(|| vec!["https://bsky.network".to_string()])
}
}
#[derive(Debug, Config)]
@@ -1124,10 +1111,6 @@ 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)]
@@ -1989,7 +1972,6 @@ port = 587
pool_size: 4,
command_timeout_secs: 30,
total_timeout_secs: 60,
apply_atmos_categories: false,
},
direct_mx: DirectMxConfig {
command_timeout_secs: 30,
+2 -2
View File
@@ -1,6 +1,6 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tranquil_types::{AtUri, CidLink, Did, Tid};
use tranquil_types::{AtUri, CidLink, Did};
use uuid::Uuid;
use crate::DbError;
@@ -58,7 +58,7 @@ pub trait BlobRepository: Send + Sync {
limit: i64,
) -> Result<Vec<CidLink>, DbError>;
async fn list_blobs_since_rev(&self, did: &Did, since: &Tid) -> Result<Vec<CidLink>, DbError>;
async fn list_blobs_since_rev(&self, did: &Did, since: &str) -> Result<Vec<CidLink>, DbError>;
async fn count_blobs_by_user(&self, user_id: Uuid) -> Result<i64, DbError>;
+1 -3
View File
@@ -23,8 +23,7 @@ pub struct DelegationGrant {
#[serde(rename_all = "camelCase")]
pub struct DelegatedAccountInfo {
pub did: Did,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handle: Option<Handle>,
pub handle: Handle,
pub granted_scopes: DbScope,
pub granted_at: DateTime<Utc>,
}
@@ -33,7 +32,6 @@ 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>,
-29
View File
@@ -1,31 +1,5 @@
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}")]
@@ -55,9 +29,6 @@ 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),
}
+20 -29
View File
@@ -1,7 +1,7 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tranquil_types::{CidLink, Did, Handle, InviteCode};
use tranquil_types::{CidLink, Did, Handle};
use uuid::Uuid;
use crate::DbError;
@@ -148,7 +148,7 @@ pub struct QueuedComms {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InviteCodeInfo {
pub code: InviteCode,
pub code: String,
pub available_uses: i32,
pub state: InviteCodeState,
pub for_account: Option<Did>,
@@ -158,7 +158,7 @@ pub struct InviteCodeInfo {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InviteCodeUse {
pub code: InviteCode,
pub code: String,
pub used_by_did: Did,
pub used_by_handle: Option<Handle>,
pub used_at: DateTime<Utc>,
@@ -166,7 +166,7 @@ pub struct InviteCodeUse {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InviteCodeRow {
pub code: InviteCode,
pub code: String,
pub available_uses: i32,
pub disabled: Option<bool>,
pub created_by_user: Uuid,
@@ -189,7 +189,7 @@ pub struct ReservedSigningKey {
pub struct ReservedSigningKeyFull {
pub id: Uuid,
pub did: Option<Did>,
pub public_key_did_key: Did,
pub public_key_did_key: String,
pub private_key_bytes: Vec<u8>,
pub expires_at: DateTime<Utc>,
pub used_at: Option<DateTime<Utc>>,
@@ -248,27 +248,24 @@ pub trait InfraRepository: Send + Sync {
async fn create_invite_code(
&self,
code: &InviteCode,
code: &str,
use_count: i32,
for_account: &Did,
for_account: Option<&Did>,
) -> Result<bool, DbError>;
async fn create_invite_codes_batch(
&self,
codes: &[InviteCode],
codes: &[String],
use_count: i32,
created_by_user: Uuid,
for_account: &Did,
for_account: Option<&Did>,
) -> Result<(), DbError>;
async fn get_invite_code_available_uses(
&self,
code: &InviteCode,
) -> Result<Option<i32>, DbError>;
async fn get_invite_code_available_uses(&self, code: &str) -> Result<Option<i32>, DbError>;
async fn validate_invite_code<'a>(
&self,
code: &'a InviteCode,
code: &'a str,
) -> Result<ValidatedInviteCode<'a>, InviteCodeError>;
async fn get_invite_codes_for_account(
@@ -276,9 +273,9 @@ pub trait InfraRepository: Send + Sync {
for_account: &Did,
) -> Result<Vec<InviteCodeInfo>, DbError>;
async fn get_invite_code_uses(&self, code: &InviteCode) -> Result<Vec<InviteCodeUse>, DbError>;
async fn get_invite_code_uses(&self, code: &str) -> Result<Vec<InviteCodeUse>, DbError>;
async fn disable_invite_codes_by_code(&self, codes: &[InviteCode]) -> Result<(), DbError>;
async fn disable_invite_codes_by_code(&self, codes: &[String]) -> Result<(), DbError>;
async fn disable_invite_codes_by_account(&self, accounts: &[Did]) -> Result<(), DbError>;
@@ -293,7 +290,7 @@ pub trait InfraRepository: Send + Sync {
async fn get_invite_code_uses_batch(
&self,
codes: &[InviteCode],
codes: &[String],
) -> Result<Vec<InviteCodeUse>, DbError>;
async fn get_invites_created_by_user(
@@ -301,20 +298,14 @@ pub trait InfraRepository: Send + Sync {
user_id: Uuid,
) -> Result<Vec<InviteCodeInfo>, DbError>;
async fn get_invite_code_info(
&self,
code: &InviteCode,
) -> Result<Option<InviteCodeInfo>, DbError>;
async fn get_invite_code_info(&self, code: &str) -> Result<Option<InviteCodeInfo>, DbError>;
async fn get_invite_codes_by_users(
&self,
user_ids: &[Uuid],
) -> Result<Vec<(Uuid, InviteCodeInfo)>, DbError>;
async fn get_invite_code_used_by_user(
&self,
user_id: Uuid,
) -> Result<Option<InviteCode>, DbError>;
async fn get_invite_code_used_by_user(&self, user_id: Uuid) -> Result<Option<String>, DbError>;
async fn delete_invite_code_uses_by_user(&self, user_id: Uuid) -> Result<(), DbError>;
@@ -323,14 +314,14 @@ pub trait InfraRepository: Send + Sync {
async fn reserve_signing_key(
&self,
did: Option<&Did>,
public_key_did_key: &Did,
public_key_did_key: &str,
private_key_bytes: &[u8],
expires_at: DateTime<Utc>,
) -> Result<Uuid, DbError>;
async fn get_reserved_signing_key(
&self,
public_key_did_key: &Did,
public_key_did_key: &str,
) -> Result<Option<ReservedSigningKey>, DbError>;
async fn mark_signing_key_used(&self, key_id: Uuid) -> Result<(), DbError>;
@@ -434,7 +425,7 @@ pub trait InfraRepository: Send + Sync {
async fn get_invite_code_uses_by_users(
&self,
user_ids: &[Uuid],
) -> Result<Vec<(Uuid, InviteCode)>, DbError>;
) -> Result<Vec<(Uuid, String)>, DbError>;
async fn get_deletion_request_by_did(
&self,
@@ -464,7 +455,7 @@ pub trait InfraRepository: Send + Sync {
async fn get_reserved_signing_key_full(
&self,
public_key_did_key: &Did,
public_key_did_key: &str,
) -> Result<Option<ReservedSigningKeyFull>, DbError>;
async fn get_plc_tokens_by_did(&self, did: &Did) -> Result<Vec<PlcTokenInfo>, DbError>;
+9 -5
View File
@@ -1,18 +1,22 @@
use tranquil_types::InviteCode;
use std::marker::PhantomData;
use crate::DbError;
#[derive(Debug)]
pub struct ValidatedInviteCode<'a> {
code: &'a InviteCode,
code: &'a str,
_marker: PhantomData<&'a ()>,
}
impl<'a> ValidatedInviteCode<'a> {
pub fn new_validated(code: &'a InviteCode) -> Self {
Self { code }
pub fn new_validated(code: &'a str) -> Self {
Self {
code,
_marker: PhantomData,
}
}
pub fn code(&self) -> &'a InviteCode {
pub fn code(&self) -> &str {
self.code
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ pub use delegation::{
AuditLogEntry, ControllerInfo, DelegatedAccountInfo, DelegationActionType, DelegationGrant,
DelegationRepository,
};
pub use error::{ColumnRef, DbError};
pub use error::DbError;
pub use infra::{
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest,
DeletionRequestWithToken, InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder,
+2 -2
View File
@@ -59,7 +59,7 @@ pub struct DeviceAccountRow {
pub struct TwoFactorChallenge {
pub id: Uuid,
pub did: Did,
pub request_uri: RequestId,
pub request_uri: String,
pub code: String,
pub attempts: i32,
pub created_at: DateTime<Utc>,
@@ -68,7 +68,7 @@ pub struct TwoFactorChallenge {
#[derive(Debug, Clone)]
pub struct TrustedDeviceRow {
pub id: DeviceId,
pub id: String,
pub user_agent: Option<String>,
pub friendly_name: Option<String>,
pub trusted_at: Option<DateTime<Utc>>,
+26 -26
View File
@@ -1,7 +1,7 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey, Tid};
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey};
use uuid::Uuid;
use crate::DbError;
@@ -51,7 +51,14 @@ impl AccountStatus {
}
}
pub fn for_firehose(&self) -> Option<Self> {
pub fn for_firehose(&self) -> Option<&'static str> {
match self {
Self::Active => None,
other => Some(other.as_str()),
}
}
pub fn for_firehose_typed(&self) -> Option<Self> {
match self {
Self::Active => None,
other => Some(*other),
@@ -130,7 +137,7 @@ pub struct RepoAccountInfo {
pub struct RepoInfo {
pub user_id: Uuid,
pub repo_root_cid: CidLink,
pub repo_rev: Option<Tid>,
pub repo_rev: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -162,7 +169,7 @@ pub struct RepoWithoutRev {
pub struct UserWithoutBlocks {
pub user_id: Uuid,
pub repo_root_cid: CidLink,
pub repo_rev: Option<Tid>,
pub repo_rev: Option<String>,
}
#[derive(Debug, Clone)]
@@ -218,7 +225,7 @@ pub struct EventBlockInline {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EventBlocks {
Inline(Vec<EventBlockInline>),
LegacyCids(Vec<CidLink>),
LegacyCids(Vec<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -231,12 +238,12 @@ pub struct SequencedEvent {
pub prev_cid: Option<CidLink>,
pub prev_data_cid: Option<CidLink>,
pub ops: Option<serde_json::Value>,
pub blobs: Option<Vec<CidLink>>,
pub blobs: Option<Vec<String>>,
pub blocks: Option<EventBlocks>,
pub handle: Option<Handle>,
pub active: Option<bool>,
pub status: Option<AccountStatus>,
pub rev: Option<Tid>,
pub rev: Option<String>,
}
#[derive(Debug, Clone)]
@@ -246,10 +253,10 @@ pub struct CommitEventData {
pub commit_cid: Option<CidLink>,
pub prev_cid: Option<CidLink>,
pub ops: Option<serde_json::Value>,
pub blobs: Option<Vec<CidLink>>,
pub blobs: Option<Vec<String>>,
pub blocks: Option<Vec<EventBlockInline>>,
pub prev_data_cid: Option<CidLink>,
pub rev: Option<Tid>,
pub rev: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -258,7 +265,7 @@ pub struct RepoListItem {
pub deactivated_at: Option<DateTime<Utc>>,
pub takedown_ref: Option<String>,
pub repo_root_cid: CidLink,
pub repo_rev: Option<Tid>,
pub repo_rev: Option<String>,
}
#[derive(Debug, Clone)]
@@ -300,7 +307,7 @@ pub struct ApplyCommitInput {
pub did: Did,
pub expected_root_cid: Option<CidLink>,
pub new_root_cid: CidLink,
pub new_rev: Tid,
pub new_rev: String,
pub new_block_cids: Vec<Vec<u8>>,
pub obsolete_block_cids: Vec<Vec<u8>>,
pub record_upserts: Vec<RecordUpsert>,
@@ -330,17 +337,17 @@ pub trait RepoRepository: Send + Sync {
did: &Did,
handle: &Handle,
repo_root_cid: &CidLink,
repo_rev: &Tid,
repo_rev: &str,
) -> Result<(), DbError>;
async fn update_repo_root(
&self,
user_id: Uuid,
repo_root_cid: &CidLink,
repo_rev: &Tid,
repo_rev: &str,
) -> Result<(), DbError>;
async fn update_repo_rev(&self, user_id: Uuid, repo_rev: &Tid) -> Result<(), DbError>;
async fn update_repo_rev(&self, user_id: Uuid, repo_rev: &str) -> Result<(), DbError>;
async fn update_repo_status(
&self,
@@ -368,7 +375,7 @@ pub trait RepoRepository: Send + Sync {
collections: &[Nsid],
rkeys: &[Rkey],
record_cids: &[CidLink],
repo_rev: &Tid,
repo_rev: &str,
) -> Result<(), DbError>;
async fn delete_records(
@@ -410,13 +417,6 @@ pub trait RepoRepository: Send + Sync {
async fn get_record_by_cid(&self, cid: &CidLink)
-> Result<Option<RecordWithTakedown>, DbError>;
async fn referenced_record_cids(
&self,
repo_id: Uuid,
cids: &[CidLink],
excluded_keys: &[(&Nsid, &Rkey)],
) -> Result<Vec<CidLink>, DbError>;
async fn set_record_takedown(
&self,
cid: &CidLink,
@@ -427,7 +427,7 @@ pub trait RepoRepository: Send + Sync {
&self,
user_id: Uuid,
block_cids: &[Vec<u8>],
repo_rev: &Tid,
repo_rev: &str,
) -> Result<(), DbError>;
async fn delete_user_blocks(
@@ -439,7 +439,7 @@ pub trait RepoRepository: Send + Sync {
async fn get_user_block_cids_since_rev(
&self,
user_id: Uuid,
since_rev: Option<&Tid>,
since_rev: &str,
) -> Result<Vec<Vec<u8>>, DbError>;
async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>;
@@ -458,7 +458,7 @@ pub trait RepoRepository: Send + Sync {
&self,
did: &Did,
commit_cid: &CidLink,
rev: Option<&Tid>,
rev: Option<&str>,
commit_bytes: &[u8],
) -> Result<(), DbError>;
@@ -467,7 +467,7 @@ pub trait RepoRepository: Send + Sync {
did: &Did,
commit_cid: &CidLink,
mst_root_cid: &CidLink,
rev: &Tid,
rev: &str,
commit_bytes: &[u8],
mst_root_bytes: &[u8],
) -> Result<(), DbError>;
+26 -23
View File
@@ -1,6 +1,6 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use tranquil_types::{Did, Jti, PasswordHash};
use tranquil_types::Did;
use uuid::Uuid;
use crate::DbError;
@@ -84,8 +84,8 @@ impl std::fmt::Display for SessionId {
pub struct SessionToken {
pub id: SessionId,
pub did: Did,
pub access_jti: Jti,
pub refresh_jti: Jti,
pub access_jti: String,
pub refresh_jti: String,
pub access_expires_at: DateTime<Utc>,
pub refresh_expires_at: DateTime<Utc>,
pub login_type: LoginType,
@@ -100,8 +100,8 @@ pub struct SessionToken {
#[derive(Debug, Clone)]
pub struct SessionTokenCreate {
pub did: Did,
pub access_jti: Jti,
pub refresh_jti: Jti,
pub access_jti: String,
pub refresh_jti: String,
pub access_expires_at: DateTime<Utc>,
pub refresh_expires_at: DateTime<Utc>,
pub login_type: LoginType,
@@ -124,7 +124,7 @@ pub struct SessionForRefresh {
#[derive(Debug, Clone)]
pub struct SessionListItem {
pub id: SessionId,
pub access_jti: Jti,
pub access_jti: String,
pub created_at: DateTime<Utc>,
pub refresh_expires_at: DateTime<Utc>,
}
@@ -134,7 +134,7 @@ pub struct AppPasswordRecord {
pub id: Uuid,
pub user_id: Uuid,
pub name: String,
pub password_hash: PasswordHash,
pub password_hash: String,
pub created_at: DateTime<Utc>,
pub privilege: AppPasswordPrivilege,
pub scopes: Option<String>,
@@ -145,7 +145,7 @@ pub struct AppPasswordRecord {
pub struct AppPasswordCreate {
pub user_id: Uuid,
pub name: String,
pub password_hash: PasswordHash,
pub password_hash: String,
pub privilege: AppPasswordPrivilege,
pub scopes: Option<String>,
pub created_by_controller_did: Option<Did>,
@@ -175,8 +175,8 @@ pub struct RefreshGraceReplay {
pub did: Did,
pub scope: Option<String>,
pub controller_did: Option<Did>,
pub access_jti: Jti,
pub refresh_jti: Jti,
pub access_jti: String,
pub refresh_jti: String,
pub access_expires_at: DateTime<Utc>,
pub refresh_expires_at: DateTime<Utc>,
pub key_bytes: Vec<u8>,
@@ -205,10 +205,10 @@ pub enum RefreshGraceLookup {
#[derive(Debug, Clone)]
pub struct SessionRefreshData {
pub did: Did,
pub old_refresh_jti: Jti,
pub old_refresh_jti: String,
pub session_id: SessionId,
pub new_access_jti: Jti,
pub new_refresh_jti: Jti,
pub new_access_jti: String,
pub new_refresh_jti: String,
pub new_access_expires_at: DateTime<Utc>,
pub new_refresh_expires_at: DateTime<Utc>,
}
@@ -219,28 +219,32 @@ pub trait SessionRepository: Send + Sync {
async fn get_session_by_access_jti(
&self,
access_jti: &Jti,
access_jti: &str,
) -> Result<Option<SessionToken>, DbError>;
async fn get_session_for_refresh(
&self,
refresh_jti: &Jti,
refresh_jti: &str,
) -> Result<Option<SessionForRefresh>, DbError>;
async fn delete_session_by_access_jti(
&self,
access_jti: &Jti,
access_jti: &str,
did: &Did,
) -> Result<u64, DbError>;
async fn delete_session_by_id(&self, session_id: SessionId, did: &Did) -> Result<u64, DbError>;
async fn delete_session_by_id(
&self,
session_id: SessionId,
did: &Did,
) -> Result<u64, DbError>;
async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>;
async fn delete_sessions_by_did_except_jti(
&self,
did: &Did,
except_jti: &Jti,
except_jti: &str,
) -> Result<u64, DbError>;
async fn list_sessions_by_did(&self, did: &Did) -> Result<Vec<SessionListItem>, DbError>;
@@ -249,7 +253,7 @@ pub trait SessionRepository: Send + Sync {
&self,
session_id: SessionId,
did: &Did,
) -> Result<Option<Jti>, DbError>;
) -> Result<Option<String>, DbError>;
async fn delete_sessions_by_app_password(
&self,
@@ -261,9 +265,9 @@ pub trait SessionRepository: Send + Sync {
&self,
did: &Did,
app_password_name: &str,
) -> Result<Vec<Jti>, DbError>;
) -> Result<Vec<String>, DbError>;
async fn lookup_refresh_grace(&self, refresh_jti: &Jti) -> Result<RefreshGraceLookup, DbError>;
async fn lookup_refresh_grace(&self, refresh_jti: &str) -> Result<RefreshGraceLookup, DbError>;
async fn list_app_passwords(&self, user_id: Uuid) -> Result<Vec<AppPasswordRecord>, DbError>;
@@ -296,8 +300,7 @@ pub trait SessionRepository: Send + Sync {
async fn update_mfa_verified(&self, did: &Did) -> Result<(), DbError>;
async fn get_app_password_hashes_by_did(&self, did: &Did)
-> Result<Vec<PasswordHash>, DbError>;
async fn get_app_password_hashes_by_did(&self, did: &Did) -> Result<Vec<String>, DbError>;
async fn refresh_session_atomic(
&self,
+41 -49
View File
@@ -1,9 +1,7 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tranquil_types::{
AtIdentifier, CidLink, Did, Handle, InviteCode, Jti, PasswordHash, Tid, TokenId,
};
use tranquil_types::{Did, Handle};
use uuid::Uuid;
use crate::{ChannelVerificationStatus, CommsChannel, DbError, SsoProviderType};
@@ -80,7 +78,7 @@ pub struct UserEmailInfo {
#[derive(Debug, Clone)]
pub struct UserLoginCheck {
pub did: Did,
pub password_hash: Option<PasswordHash>,
pub password_hash: Option<String>,
}
#[derive(Debug, Clone)]
@@ -88,7 +86,7 @@ pub struct UserLoginInfo {
pub id: Uuid,
pub did: Did,
pub email: Option<String>,
pub password_hash: Option<PasswordHash>,
pub password_hash: Option<String>,
pub password_required: bool,
pub two_factor_enabled: bool,
pub preferred_comms_channel: CommsChannel,
@@ -121,12 +119,12 @@ pub trait UserRepository: Send + Sync {
async fn get_session_access_expiry(
&self,
did: &Did,
access_jti: &Jti,
access_jti: &str,
) -> Result<Option<DateTime<Utc>>, DbError>;
async fn get_oauth_token_with_user(
&self,
token_id: &TokenId,
token_id: &str,
) -> Result<Option<OAuthTokenWithUser>, DbError>;
async fn get_user_info_by_did(&self, did: &Did) -> Result<Option<UserInfoForAuth>, DbError>;
@@ -149,12 +147,12 @@ pub trait UserRepository: Send + Sync {
async fn get_login_check_by_identifier(
&self,
identifier: &AtIdentifier,
identifier: &str,
) -> Result<Option<UserLoginCheck>, DbError>;
async fn get_login_info_by_identifier(
&self,
identifier: &AtIdentifier,
identifier: &str,
) -> Result<Option<UserLoginInfo>, DbError>;
async fn get_2fa_status_by_did(&self, did: &Did) -> Result<Option<User2faStatus>, DbError>;
@@ -211,7 +209,7 @@ pub trait UserRepository: Send + Sync {
async fn check_email_verified_by_identifier(
&self,
identifier: &AtIdentifier,
identifier: &str,
) -> Result<Option<bool>, DbError>;
async fn check_channel_verified_by_did(
@@ -224,11 +222,7 @@ pub trait UserRepository: Send + Sync {
async fn admin_update_handle(&self, did: &Did, handle: &Handle) -> Result<u64, DbError>;
async fn admin_update_password(
&self,
did: &Did,
password_hash: &PasswordHash,
) -> Result<u64, DbError>;
async fn admin_update_password(&self, did: &Did, password_hash: &str) -> Result<u64, DbError>;
async fn set_admin_status(&self, did: &Did, is_admin: bool) -> Result<(), DbError>;
@@ -268,7 +262,7 @@ pub trait UserRepository: Send + Sync {
&self,
telegram_username: &str,
chat_id: i64,
handle: Option<&Handle>,
handle: Option<&str>,
) -> Result<Option<Uuid>, DbError>;
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError>;
@@ -283,7 +277,7 @@ pub trait UserRepository: Send + Sync {
&self,
discord_username: &str,
discord_id: &str,
handle: Option<&Handle>,
handle: Option<&str>,
) -> Result<Option<Uuid>, DbError>;
async fn get_verification_info(
@@ -319,7 +313,7 @@ pub trait UserRepository: Send + Sync {
async fn has_passkeys(&self, did: &Did) -> Result<bool, DbError>;
async fn get_password_hash_by_did(&self, did: &Did) -> Result<Option<PasswordHash>, DbError>;
async fn get_password_hash_by_did(&self, did: &Did) -> Result<Option<String>, DbError>;
async fn get_passkeys_for_user(&self, did: &Did) -> Result<Vec<StoredPasskey>, DbError>;
@@ -428,7 +422,7 @@ pub trait UserRepository: Send + Sync {
async fn get_login_full_by_identifier(
&self,
identifier: &AtIdentifier,
identifier: &str,
) -> Result<Option<UserLoginFull>, DbError>;
async fn get_confirm_signup_by_did(
@@ -446,7 +440,7 @@ pub trait UserRepository: Send + Sync {
async fn get_id_by_email_or_handle(
&self,
email: &str,
handle: &Handle,
handle: &str,
) -> Result<Option<Uuid>, DbError>;
async fn count_accounts_by_email(&self, email: &str) -> Result<i64, DbError>;
@@ -472,16 +466,13 @@ pub trait UserRepository: Send + Sync {
did: &Did,
) -> Result<Option<UserIdAndPasswordHash>, DbError>;
async fn update_password_hash(
&self,
user_id: Uuid,
password_hash: &PasswordHash,
) -> Result<(), DbError>;
async fn update_password_hash(&self, user_id: Uuid, password_hash: &str)
-> Result<(), DbError>;
async fn reset_password_with_sessions(
&self,
user_id: Uuid,
password_hash: &PasswordHash,
password_hash: &str,
) -> Result<PasswordResetResult, DbError>;
async fn activate_account(&self, did: &Did) -> Result<bool, DbError>;
@@ -504,7 +495,7 @@ pub trait UserRepository: Send + Sync {
async fn set_new_user_password(
&self,
user_id: Uuid,
password_hash: &PasswordHash,
password_hash: &str,
) -> Result<(), DbError>;
async fn get_user_key_by_did(&self, did: &Did) -> Result<Option<UserKeyInfo>, DbError>;
@@ -670,6 +661,7 @@ pub struct UserForVerification {
pub did: Did,
pub email: Option<String>,
pub email_verified: bool,
pub handle: Handle,
}
#[derive(Debug, Clone)]
@@ -707,7 +699,7 @@ pub struct AccountSearchResult {
pub struct UserAuthInfo {
pub id: Uuid,
pub did: Did,
pub password_hash: Option<PasswordHash>,
pub password_hash: Option<String>,
pub deactivated_at: Option<DateTime<Utc>>,
pub takedown_ref: Option<String>,
pub channel_verification: ChannelVerificationStatus,
@@ -871,7 +863,7 @@ pub struct UserLoginFull {
pub id: Uuid,
pub did: Did,
pub handle: Handle,
pub password_hash: Option<PasswordHash>,
pub password_hash: Option<String>,
pub email: Option<String>,
pub deactivated_at: Option<DateTime<Utc>>,
pub takedown_ref: Option<String>,
@@ -922,25 +914,25 @@ pub struct UserResetCodeInfo {
#[derive(Debug, Clone)]
pub struct UserPasswordInfo {
pub id: Uuid,
pub password_hash: Option<PasswordHash>,
pub password_hash: Option<String>,
}
#[derive(Debug, Clone)]
pub struct UserIdAndPasswordHash {
pub id: Uuid,
pub password_hash: PasswordHash,
pub password_hash: String,
}
#[derive(Debug, Clone)]
pub struct PasswordResetResult {
pub did: Did,
pub session_jtis: Vec<Jti>,
pub session_jtis: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct UserForDeletion {
pub id: Uuid,
pub password_hash: Option<PasswordHash>,
pub password_hash: Option<String>,
pub handle: Handle,
}
@@ -996,7 +988,7 @@ pub struct CreatePasswordAccountInput {
pub handle: Handle,
pub email: Option<String>,
pub did: Did,
pub password_hash: PasswordHash,
pub password_hash: String,
pub preferred_comms_channel: CommsChannel,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
@@ -1006,10 +998,10 @@ pub struct CreatePasswordAccountInput {
pub encrypted_key_bytes: Vec<u8>,
pub encryption_version: i32,
pub reserved_key_id: Option<Uuid>,
pub commit_cid: CidLink,
pub repo_rev: Tid,
pub commit_cid: String,
pub repo_rev: String,
pub genesis_block_cids: Vec<Vec<u8>>,
pub invite_code: Option<InviteCode>,
pub invite_code: Option<String>,
pub birthdate_pref: Option<serde_json::Value>,
}
@@ -1038,8 +1030,8 @@ pub struct CreateDelegatedAccountInput {
pub controller_scopes: String,
pub encrypted_key_bytes: Vec<u8>,
pub encryption_version: i32,
pub commit_cid: CidLink,
pub repo_rev: Tid,
pub commit_cid: String,
pub repo_rev: String,
pub genesis_block_cids: Vec<Vec<u8>>,
}
@@ -1052,16 +1044,16 @@ pub struct CreatePasskeyAccountInput {
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_username: Option<String>,
pub setup_token_hash: PasswordHash,
pub setup_token_hash: String,
pub setup_expires_at: DateTime<Utc>,
pub deactivated_at: Option<DateTime<Utc>>,
pub encrypted_key_bytes: Vec<u8>,
pub encryption_version: i32,
pub reserved_key_id: Option<Uuid>,
pub commit_cid: CidLink,
pub repo_rev: Tid,
pub commit_cid: String,
pub repo_rev: String,
pub genesis_block_cids: Vec<Vec<u8>>,
pub invite_code: Option<InviteCode>,
pub invite_code: Option<String>,
pub birthdate_pref: Option<serde_json::Value>,
}
@@ -1076,10 +1068,10 @@ pub struct CreateSsoAccountInput {
pub signal_username: Option<String>,
pub encrypted_key_bytes: Vec<u8>,
pub encryption_version: i32,
pub commit_cid: CidLink,
pub repo_rev: Tid,
pub commit_cid: String,
pub repo_rev: String,
pub genesis_block_cids: Vec<Vec<u8>>,
pub invite_code: Option<InviteCode>,
pub invite_code: Option<String>,
pub birthdate_pref: Option<serde_json::Value>,
pub sso_provider: SsoProviderType,
pub sso_provider_user_id: String,
@@ -1094,13 +1086,13 @@ pub struct CompletePasskeySetupInput {
pub user_id: Uuid,
pub did: Did,
pub app_password_name: String,
pub app_password_hash: PasswordHash,
pub app_password_hash: String,
}
#[derive(Debug, Clone)]
pub struct RecoverPasskeyAccountInput {
pub did: Did,
pub password_hash: PasswordHash,
pub password_hash: String,
}
#[derive(Debug, Clone)]
@@ -1118,7 +1110,7 @@ pub struct MigrationReactivationInput {
#[derive(Debug, Clone)]
pub struct ReactivatedAccountInfo {
pub user_id: Uuid,
pub old_handle: Option<Handle>,
pub old_handle: Handle,
}
#[derive(Debug, Clone)]
+2
View File
@@ -5,7 +5,9 @@ edition.workspace = true
license.workspace = true
[features]
default = ["postgres"]
postgres = []
sqlite = []
[dependencies]
tranquil-db-traits = { workspace = true }
+1 -3
View File
@@ -4,8 +4,6 @@ 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 {
@@ -51,7 +49,7 @@ impl BacklinkRepository for PostgresBacklinkRepository {
.await
.map_err(map_sqlx_error)?;
column_vec(results, col::BACKLINKS_URI)
Ok(results.into_iter().map(Into::into).collect())
}
async fn add_backlinks(&self, repo_id: Uuid, backlinks: &[Backlink]) -> Result<(), DbError> {
+21 -31
View File
@@ -3,12 +3,10 @@ use sqlx::PgPool;
use tranquil_db_traits::{
BlobForExport, BlobMetadata, BlobRepository, BlobWithTakedown, DbError, MissingBlobInfo,
};
use tranquil_types::{AtUri, CidLink, Did, Tid};
use tranquil_types::{AtUri, CidLink, Did};
use uuid::Uuid;
use super::col;
use super::user::map_sqlx_error;
use super::{column, column_vec, opt_column};
pub struct PostgresBlobRepository {
pool: PgPool,
@@ -44,7 +42,7 @@ impl BlobRepository for PostgresBlobRepository {
.await
.map_err(map_sqlx_error)?;
opt_column(result, col::BLOBS_CID)
Ok(result.map(CidLink::from))
}
async fn get_blob_metadata(&self, cid: &CidLink) -> Result<Option<BlobMetadata>, DbError> {
@@ -75,14 +73,10 @@ impl BlobRepository for PostgresBlobRepository {
.await
.map_err(map_sqlx_error)?;
result
.map(|r| {
Ok(BlobWithTakedown {
cid: column(r.cid, col::BLOBS_CID)?,
takedown_ref: r.takedown_ref,
})
})
.transpose()
Ok(result.map(|r| BlobWithTakedown {
cid: CidLink::from(r.cid),
takedown_ref: r.takedown_ref,
}))
}
async fn get_blob_storage_key(&self, cid: &CidLink) -> Result<Option<String>, DbError> {
@@ -115,22 +109,22 @@ impl BlobRepository for PostgresBlobRepository {
.await
.map_err(map_sqlx_error)?;
column_vec(results, col::BLOBS_CID)
Ok(results.into_iter().map(CidLink::from).collect())
}
async fn list_blobs_since_rev(&self, did: &Did, since: &Tid) -> Result<Vec<CidLink>, DbError> {
async fn list_blobs_since_rev(&self, did: &Did, since: &str) -> Result<Vec<CidLink>, DbError> {
let results = sqlx::query_scalar!(
r#"SELECT DISTINCT unnest(blobs) as "cid!"
FROM repo_seq
WHERE did = $1 AND rev > $2 AND blobs IS NOT NULL"#,
did.as_str(),
since.as_str()
since
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
column_vec(results, col::REPO_SEQ_BLOBS)
Ok(results.into_iter().map(CidLink::from).collect())
}
async fn count_blobs_by_user(&self, user_id: Uuid) -> Result<i64, DbError> {
@@ -250,15 +244,13 @@ impl BlobRepository for PostgresBlobRepository {
.await
.map_err(map_sqlx_error)?;
results
Ok(results
.into_iter()
.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)?,
})
.map(|r| MissingBlobInfo {
blob_cid: CidLink::from(r.blob_cid),
record_uri: AtUri::from(r.record_uri),
})
.collect()
.collect())
}
async fn count_distinct_record_blobs(&self, repo_id: Uuid) -> Result<i64, DbError> {
@@ -285,15 +277,13 @@ impl BlobRepository for PostgresBlobRepository {
.await
.map_err(map_sqlx_error)?;
results
Ok(results
.into_iter()
.map(|r| {
Ok(BlobForExport {
cid: column(r.cid, col::BLOBS_CID)?,
storage_key: r.storage_key,
mime_type: r.mime_type,
})
.map(|r| BlobForExport {
cid: CidLink::from(r.cid),
storage_key: r.storage_key,
mime_type: r.mime_type,
})
.collect()
.collect())
}
}
+41 -55
View File
@@ -7,9 +7,7 @@ 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")]
@@ -168,19 +166,16 @@ impl DelegationRepository for PostgresDelegationRepository {
.await
.map_err(map_sqlx_error)?;
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()
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),
}))
}
async fn get_delegations_for_account(
@@ -210,18 +205,17 @@ impl DelegationRepository for PostgresDelegationRepository {
.await
.map_err(map_sqlx_error)?;
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,
})
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,
})
.collect()
.collect())
}
async fn get_accounts_controlled_by(
@@ -249,16 +243,15 @@ impl DelegationRepository for PostgresDelegationRepository {
.await
.map_err(map_sqlx_error)?;
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,
})
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,
})
.collect()
.collect())
}
async fn count_active_controllers(&self, delegated_did: &Did) -> Result<i64, DbError> {
@@ -360,27 +353,20 @@ impl DelegationRepository for PostgresDelegationRepository {
.await
.map_err(map_sqlx_error)?;
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,
})
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,
})
.collect()
.collect())
}
async fn count_audit_log_entries(&self, delegated_did: &Did) -> Result<i64, DbError> {
+127 -228
View File
@@ -7,12 +7,10 @@ use tranquil_db_traits::{
InviteCodeSortOrder, InviteCodeState, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo,
QueuedComms, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode,
};
use tranquil_types::{CidLink, Did, InviteCode};
use tranquil_types::{CidLink, Did, Handle};
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,15 +151,15 @@ impl InfraRepository for PostgresInfraRepository {
async fn create_invite_code(
&self,
code: &InviteCode,
code: &str,
use_count: i32,
for_account: &Did,
for_account: Option<&Did>,
) -> Result<bool, DbError> {
let for_account_str = for_account.as_str();
let for_account_str = for_account.map(|d| d.as_str());
let result = sqlx::query!(
r#"INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)
SELECT $1, $2, id, $3 FROM users WHERE is_admin = true LIMIT 1"#,
code.as_str(),
code,
use_count,
for_account_str
)
@@ -174,17 +172,16 @@ impl InfraRepository for PostgresInfraRepository {
async fn create_invite_codes_batch(
&self,
codes: &[InviteCode],
codes: &[String],
use_count: i32,
created_by_user: Uuid,
for_account: &Did,
for_account: Option<&Did>,
) -> Result<(), DbError> {
let for_account_str = for_account.as_str();
let code_strs: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
let for_account_str = for_account.map(|d| d.as_str());
sqlx::query!(
r#"INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)
SELECT code, $2, $3, $4 FROM UNNEST($1::text[]) AS t(code)"#,
&code_strs,
codes,
use_count,
created_by_user,
for_account_str
@@ -196,13 +193,10 @@ impl InfraRepository for PostgresInfraRepository {
Ok(())
}
async fn get_invite_code_available_uses(
&self,
code: &InviteCode,
) -> Result<Option<i32>, DbError> {
async fn get_invite_code_available_uses(&self, code: &str) -> Result<Option<i32>, DbError> {
let result = sqlx::query_scalar!(
"SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE",
code.as_str()
code
)
.fetch_optional(&self.pool)
.await
@@ -213,11 +207,11 @@ impl InfraRepository for PostgresInfraRepository {
async fn validate_invite_code<'a>(
&self,
code: &'a InviteCode,
code: &'a str,
) -> Result<ValidatedInviteCode<'a>, InviteCodeError> {
let result = sqlx::query!(
r#"SELECT available_uses, COALESCE(disabled, false) as "disabled!" FROM invite_codes WHERE code = $1"#,
code.as_str()
code
)
.fetch_optional(&self.pool)
.await
@@ -252,29 +246,27 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
results
Ok(results
.into_iter()
.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,
})
.map(|r| InviteCodeInfo {
code: r.code,
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: None,
})
.collect()
.collect())
}
async fn get_invite_code_uses(&self, code: &InviteCode) -> Result<Vec<InviteCodeUse>, DbError> {
async fn get_invite_code_uses(&self, code: &str) -> Result<Vec<InviteCodeUse>, DbError> {
let results = sqlx::query!(
r#"SELECT u.did, u.handle, icu.used_at
FROM invite_code_uses icu
JOIN users u ON icu.used_by_user = u.id
WHERE icu.code = $1
ORDER BY icu.used_at DESC"#,
code.as_str()
code
)
.fetch_all(&self.pool)
.await
@@ -282,22 +274,19 @@ impl InfraRepository for PostgresInfraRepository {
Ok(results
.into_iter()
.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,
})
.map(|r| InviteCodeUse {
code: code.to_string(),
used_by_did: Did::from(r.did),
used_by_handle: Some(Handle::from(r.handle)),
used_at: r.used_at,
})
.collect())
}
async fn disable_invite_codes_by_code(&self, codes: &[InviteCode]) -> Result<(), DbError> {
let code_strs: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
async fn disable_invite_codes_by_code(&self, codes: &[String]) -> Result<(), DbError> {
sqlx::query!(
"UPDATE invite_codes SET disabled = TRUE WHERE code = ANY($1)",
&code_strs
codes
)
.execute(&self.pool)
.await
@@ -326,24 +315,9 @@ impl InfraRepository for PostgresInfraRepository {
limit: i64,
sort: InviteCodeSortOrder,
) -> Result<Vec<InviteCodeRow>, DbError> {
fn to_row(
code: String,
available_uses: i32,
disabled: Option<bool>,
created_by_user: Uuid,
created_at: DateTime<Utc>,
) -> InviteCodeRow {
InviteCodeRow {
code: InviteCode::from(code),
available_uses,
disabled,
created_by_user,
created_at,
}
}
let results = match (cursor, sort) {
(Some(cursor_code), InviteCodeSortOrder::Recent) => sqlx::query!(
(Some(cursor_code), InviteCodeSortOrder::Recent) => sqlx::query_as!(
InviteCodeRow,
r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
FROM invite_codes ic
WHERE ic.created_at < (SELECT created_at FROM invite_codes WHERE code = $1)
@@ -354,19 +328,9 @@ impl InfraRepository for PostgresInfraRepository {
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?
.into_iter()
.map(|r| {
to_row(
r.code,
r.available_uses,
r.disabled,
r.created_by_user,
r.created_at,
)
})
.collect(),
(None, InviteCodeSortOrder::Recent) => sqlx::query!(
.map_err(map_sqlx_error)?,
(None, InviteCodeSortOrder::Recent) => sqlx::query_as!(
InviteCodeRow,
r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
FROM invite_codes ic
ORDER BY created_at DESC
@@ -375,19 +339,9 @@ impl InfraRepository for PostgresInfraRepository {
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?
.into_iter()
.map(|r| {
to_row(
r.code,
r.available_uses,
r.disabled,
r.created_by_user,
r.created_at,
)
})
.collect(),
(Some(cursor_code), InviteCodeSortOrder::Usage) => sqlx::query!(
.map_err(map_sqlx_error)?,
(Some(cursor_code), InviteCodeSortOrder::Usage) => sqlx::query_as!(
InviteCodeRow,
r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
FROM invite_codes ic
WHERE ic.created_at < (SELECT created_at FROM invite_codes WHERE code = $1)
@@ -398,19 +352,9 @@ impl InfraRepository for PostgresInfraRepository {
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?
.into_iter()
.map(|r| {
to_row(
r.code,
r.available_uses,
r.disabled,
r.created_by_user,
r.created_at,
)
})
.collect(),
(None, InviteCodeSortOrder::Usage) => sqlx::query!(
.map_err(map_sqlx_error)?,
(None, InviteCodeSortOrder::Usage) => sqlx::query_as!(
InviteCodeRow,
r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
FROM invite_codes ic
ORDER BY available_uses DESC
@@ -419,18 +363,7 @@ impl InfraRepository for PostgresInfraRepository {
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?
.into_iter()
.map(|r| {
to_row(
r.code,
r.available_uses,
r.disabled,
r.created_by_user,
r.created_at,
)
})
.collect(),
.map_err(map_sqlx_error)?,
};
Ok(results)
@@ -442,40 +375,37 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
results
Ok(results
.into_iter()
.map(|r| Ok((r.id, column(r.did, col::USERS_DID)?)))
.collect()
.map(|r| (r.id, Did::from(r.did)))
.collect())
}
async fn get_invite_code_uses_batch(
&self,
codes: &[InviteCode],
codes: &[String],
) -> Result<Vec<InviteCodeUse>, DbError> {
let code_strs: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
let results = sqlx::query!(
r#"SELECT icu.code, u.did, icu.used_at
FROM invite_code_uses icu
JOIN users u ON icu.used_by_user = u.id
WHERE icu.code = ANY($1)
ORDER BY icu.used_at DESC"#,
&code_strs
codes
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
results
Ok(results
.into_iter()
.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,
})
.map(|r| InviteCodeUse {
code: r.code,
used_by_did: Did::from(r.did),
used_by_handle: None,
used_at: r.used_at,
})
.collect()
.collect())
}
async fn get_invites_created_by_user(
@@ -493,48 +423,39 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
results
Ok(results
.into_iter()
.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)?),
})
.map(|r| InviteCodeInfo {
code: r.code,
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
})
.collect()
.collect())
}
async fn get_invite_code_info(
&self,
code: &InviteCode,
) -> Result<Option<InviteCodeInfo>, DbError> {
async fn get_invite_code_info(&self, code: &str) -> Result<Option<InviteCodeInfo>, DbError> {
let result = sqlx::query!(
r#"SELECT ic.code, ic.available_uses, ic.disabled, ic.for_account, ic.created_at, u.did as created_by
FROM invite_codes ic
JOIN users u ON ic.created_by_user = u.id
WHERE ic.code = $1"#,
code.as_str()
code
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
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()
Ok(result.map(|r| InviteCodeInfo {
code: r.code,
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
}))
}
async fn get_invite_codes_by_users(
@@ -553,28 +474,25 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
results
Ok(results
.into_iter()
.map(|r| {
Ok((
(
r.created_by_user,
InviteCodeInfo {
code: InviteCode::from(r.code),
code: 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),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: Some(column(r.created_by, col::USERS_DID)?),
created_by: Some(Did::from(r.created_by)),
},
))
)
})
.collect()
.collect())
}
async fn get_invite_code_used_by_user(
&self,
user_id: Uuid,
) -> Result<Option<InviteCode>, DbError> {
async fn get_invite_code_used_by_user(&self, user_id: Uuid) -> Result<Option<String>, DbError> {
let result = sqlx::query_scalar!(
"SELECT code FROM invite_code_uses WHERE used_by_user = $1",
user_id
@@ -583,7 +501,7 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(result.map(InviteCode::from))
Ok(result)
}
async fn delete_invite_code_uses_by_user(&self, user_id: Uuid) -> Result<(), DbError> {
@@ -613,7 +531,7 @@ impl InfraRepository for PostgresInfraRepository {
async fn reserve_signing_key(
&self,
did: Option<&Did>,
public_key_did_key: &Did,
public_key_did_key: &str,
private_key_bytes: &[u8],
expires_at: DateTime<Utc>,
) -> Result<Uuid, DbError> {
@@ -623,7 +541,7 @@ impl InfraRepository for PostgresInfraRepository {
VALUES ($1, $2, $3, $4)
RETURNING id"#,
did_str,
public_key_did_key.as_str(),
public_key_did_key,
private_key_bytes,
expires_at
)
@@ -636,7 +554,7 @@ impl InfraRepository for PostgresInfraRepository {
async fn get_reserved_signing_key(
&self,
public_key_did_key: &Did,
public_key_did_key: &str,
) -> Result<Option<ReservedSigningKey>, DbError> {
let result = sqlx::query!(
r#"SELECT id, private_key_bytes
@@ -645,7 +563,7 @@ impl InfraRepository for PostgresInfraRepository {
AND used_at IS NULL
AND expires_at > NOW()
FOR UPDATE"#,
public_key_did_key.as_str()
public_key_did_key
)
.fetch_optional(&self.pool)
.await
@@ -697,14 +615,10 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
result
.map(|r| {
Ok(DeletionRequest {
did: column(r.did, col::ACCOUNT_DELETION_REQUESTS_DID)?,
expires_at: r.expires_at,
})
})
.transpose()
Ok(result.map(|r| DeletionRequest {
did: Did::from(r.did),
expires_at: r.expires_at,
}))
}
async fn delete_deletion_request(&self, token: &str) -> Result<(), DbError> {
@@ -1045,20 +959,16 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
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()
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,
}))
}
async fn get_admin_account_infos_by_dids(
@@ -1080,17 +990,15 @@ impl InfraRepository for PostgresInfraRepository {
Ok(results
.into_iter()
.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,
})
.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,
})
.collect())
}
@@ -1098,7 +1006,7 @@ impl InfraRepository for PostgresInfraRepository {
async fn get_invite_code_uses_by_users(
&self,
user_ids: &[Uuid],
) -> Result<Vec<(Uuid, InviteCode)>, DbError> {
) -> Result<Vec<(Uuid, String)>, DbError> {
let results = sqlx::query!(
r#"
SELECT used_by_user, code
@@ -1113,7 +1021,7 @@ impl InfraRepository for PostgresInfraRepository {
Ok(results
.into_iter()
.map(|r| (r.used_by_user, InviteCode::from(r.code)))
.map(|r| (r.used_by_user, r.code))
.collect())
}
@@ -1129,14 +1037,11 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
row.map(|r| {
Ok(DeletionRequestWithToken {
token: r.token,
did: column(r.did, col::ACCOUNT_DELETION_REQUESTS_DID)?,
expires_at: r.expires_at,
})
})
.transpose()
Ok(row.map(|r| DeletionRequestWithToken {
token: r.token,
did: Did::new(r.did).expect("valid DID in database"),
expires_at: r.expires_at,
}))
}
async fn get_latest_comms_for_user(
@@ -1218,31 +1123,25 @@ impl InfraRepository for PostgresInfraRepository {
async fn get_reserved_signing_key_full(
&self,
public_key_did_key: &Did,
public_key_did_key: &str,
) -> Result<Option<ReservedSigningKeyFull>, DbError> {
let row = sqlx::query!(
r#"SELECT id, did, public_key_did_key, private_key_bytes, expires_at, used_at
FROM reserved_signing_keys WHERE public_key_did_key = $1"#,
public_key_did_key.as_str()
public_key_did_key
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
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()
Ok(row.map(|r| ReservedSigningKeyFull {
id: r.id,
did: r.did.map(|d| Did::new(d).expect("valid DID in database")),
public_key_did_key: r.public_key_did_key,
private_key_bytes: r.private_key_bytes,
expires_at: r.expires_at,
used_at: r.used_at,
}))
}
async fn get_plc_tokens_by_did(&self, did: &Did) -> Result<Vec<PlcTokenInfo>, DbError> {
-99
View File
@@ -10,106 +10,7 @@ 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;
+89 -85
View File
@@ -7,16 +7,16 @@ use tranquil_db_traits::{
ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge,
};
use tranquil_oauth::{
AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, DeviceData, RequestData,
SessionId as OAuthSessionId, TokenData,
AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, Code as OAuthCode,
DeviceData, DeviceId as OAuthDeviceId, RefreshToken as OAuthRefreshToken, RequestData,
SessionId as OAuthSessionId, TokenData, TokenId as OAuthTokenId,
};
use tranquil_types::{
AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, RefreshToken, RequestId, TokenId,
AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, Handle, RefreshToken, RequestId,
TokenId,
};
use uuid::Uuid;
use super::col;
use super::column;
use super::user::map_sqlx_error;
const REGISTRATION_FLOW_EXTENDED_EXPIRY_SECS: i64 = 600;
@@ -61,17 +61,17 @@ impl OAuthRepository for PostgresOAuthRepository {
RETURNING id
"#,
data.did.as_str(),
data.token_id.as_str(),
&data.token_id.0,
data.created_at,
data.updated_at,
data.expires_at,
data.client_id.as_str(),
data.client_id,
client_auth_json,
data.device_id.as_deref(),
data.device_id.as_ref().map(|d| d.0.as_str()),
parameters_json,
data.details,
data.code.as_deref(),
data.current_refresh_token.as_deref(),
data.code.as_ref().map(|c| c.0.as_str()),
data.current_refresh_token.as_ref().map(|r| r.0.as_str()),
data.scope,
data.controller_did.as_ref().map(|d| d.as_str()),
)
@@ -99,24 +99,24 @@ impl OAuthRepository for PostgresOAuthRepository {
did: r
.did
.parse()
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
token_id: TokenId::from(r.token_id),
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
token_id: OAuthTokenId(r.token_id),
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
client_id: ClientId::from(r.client_id),
client_id: r.client_id,
client_auth: from_json(r.client_auth)?,
device_id: r.device_id.map(DeviceId::from),
device_id: r.device_id.map(OAuthDeviceId),
parameters: from_json(r.parameters)?,
details: r.details,
code: r.code.map(AuthorizationCode::from),
current_refresh_token: r.current_refresh_token.map(RefreshToken::from),
code: r.code.map(OAuthCode),
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
scope: r.scope,
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
})),
None => Ok(None),
}
@@ -145,24 +145,24 @@ impl OAuthRepository for PostgresOAuthRepository {
did: r
.did
.parse()
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
token_id: TokenId::from(r.token_id),
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
token_id: OAuthTokenId(r.token_id),
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
client_id: ClientId::from(r.client_id),
client_id: r.client_id,
client_auth: from_json(r.client_auth)?,
device_id: r.device_id.map(DeviceId::from),
device_id: r.device_id.map(OAuthDeviceId),
parameters: from_json(r.parameters)?,
details: r.details,
code: r.code.map(AuthorizationCode::from),
current_refresh_token: r.current_refresh_token.map(RefreshToken::from),
code: r.code.map(OAuthCode),
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
scope: r.scope,
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
},
))),
None => Ok(None),
@@ -194,24 +194,24 @@ impl OAuthRepository for PostgresOAuthRepository {
did: r
.did
.parse()
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
token_id: TokenId::from(r.token_id),
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
token_id: OAuthTokenId(r.token_id),
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
client_id: ClientId::from(r.client_id),
client_id: r.client_id,
client_auth: from_json(r.client_auth)?,
device_id: r.device_id.map(DeviceId::from),
device_id: r.device_id.map(OAuthDeviceId),
parameters: from_json(r.parameters)?,
details: r.details,
code: r.code.map(AuthorizationCode::from),
current_refresh_token: r.current_refresh_token.map(RefreshToken::from),
code: r.code.map(OAuthCode),
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
scope: r.scope,
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
},
))),
None => Ok(None),
@@ -327,24 +327,24 @@ impl OAuthRepository for PostgresOAuthRepository {
did: r
.did
.parse()
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
token_id: TokenId::from(r.token_id),
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
token_id: OAuthTokenId(r.token_id),
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
client_id: ClientId::from(r.client_id),
client_id: r.client_id,
client_auth: from_json(r.client_auth)?,
device_id: r.device_id.map(DeviceId::from),
device_id: r.device_id.map(OAuthDeviceId),
parameters: from_json(r.parameters)?,
details: r.details,
code: r.code.map(AuthorizationCode::from),
current_refresh_token: r.current_refresh_token.map(RefreshToken::from),
code: r.code.map(OAuthCode),
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
scope: r.scope,
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
})
})
.collect()
@@ -437,12 +437,12 @@ impl OAuthRepository for PostgresOAuthRepository {
"#,
request_id.as_str(),
data.did.as_ref().map(|d| d.as_str()),
data.device_id.as_deref(),
data.client_id.as_str(),
data.device_id.as_ref().map(|d| d.0.as_str()),
data.client_id,
client_auth_json,
parameters_json,
data.expires_at,
data.code.as_deref(),
data.code.as_ref().map(|c| c.0.as_str()),
)
.execute(&self.pool)
.await
@@ -473,18 +473,22 @@ impl OAuthRepository for PostgresOAuthRepository {
};
let parameters: AuthorizationRequestParameters = from_json(r.parameters)?;
Ok(Some(RequestData {
client_id: ClientId::from(r.client_id),
client_id: r.client_id,
client_auth,
parameters,
expires_at: r.expires_at,
did: r.did.map(|s| s.parse()).transpose().map_err(|_| {
DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_DID)
})?,
device_id: r.device_id.map(DeviceId::from),
code: r.code.map(AuthorizationCode::from),
controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(
|_| DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_CONTROLLER_DID),
)?,
did: r
.did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid DID in DB".into()))?,
device_id: r.device_id.map(OAuthDeviceId),
code: r.code.map(OAuthCode),
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?,
}))
}
None => Ok(None),
@@ -563,18 +567,22 @@ impl OAuthRepository for PostgresOAuthRepository {
};
let parameters: AuthorizationRequestParameters = from_json(r.parameters)?;
Ok(Some(RequestData {
client_id: ClientId::from(r.client_id),
client_id: r.client_id,
client_auth,
parameters,
expires_at: r.expires_at,
did: r.did.map(|s| s.parse()).transpose().map_err(|_| {
DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_DID)
})?,
device_id: r.device_id.map(DeviceId::from),
code: r.code.map(AuthorizationCode::from),
controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(
|_| DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_CONTROLLER_DID),
)?,
did: r
.did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid DID in DB".into()))?,
device_id: r.device_id.map(OAuthDeviceId),
code: r.code.map(OAuthCode),
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?,
}))
}
None => Ok(None),
@@ -712,7 +720,7 @@ impl OAuthRepository for PostgresOAuthRepository {
VALUES ($1, $2, $3, $4, $5)
"#,
device_id.as_str(),
data.session_id.as_str(),
&data.session_id.0,
data.user_agent,
data.ip_address,
data.last_seen_at,
@@ -736,7 +744,7 @@ impl OAuthRepository for PostgresOAuthRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| DeviceData {
session_id: OAuthSessionId::from(r.session_id),
session_id: OAuthSessionId(r.session_id),
user_agent: r.user_agent,
ip_address: r.ip_address,
last_seen_at: r.last_seen_at,
@@ -806,16 +814,15 @@ impl OAuthRepository for PostgresOAuthRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
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,
})
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,
})
.collect()
.collect())
}
async fn verify_account_on_device(
@@ -898,8 +905,8 @@ impl OAuthRepository for PostgresOAuthRepository {
.map_err(map_sqlx_error)?;
Ok(TwoFactorChallenge {
id: row.id,
did: column(row.did, col::OAUTH_2FA_CHALLENGE_DID)?,
request_uri: RequestId::from(row.request_uri),
did: Did::from(row.did),
request_uri: row.request_uri,
code: row.code,
attempts: row.attempts,
created_at: row.created_at,
@@ -922,18 +929,15 @@ impl OAuthRepository for PostgresOAuthRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
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()
Ok(row.map(|r| TwoFactorChallenge {
id: r.id,
did: Did::from(r.did),
request_uri: r.request_uri,
code: r.code,
attempts: r.attempts,
created_at: r.created_at,
expires_at: r.expires_at,
}))
}
async fn increment_2fa_attempts(&self, id: Uuid) -> Result<i32, DbError> {
@@ -1139,7 +1143,7 @@ impl OAuthRepository for PostgresOAuthRepository {
Ok(rows
.into_iter()
.map(|r| TrustedDeviceRow {
id: DeviceId::from(r.id),
id: r.id,
user_agent: r.user_agent,
friendly_name: r.friendly_name,
trusted_at: r.trusted_at,
+115 -220
View File
@@ -7,12 +7,10 @@ use tranquil_db_traits::{
RepoAccountInfo, RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoWithoutRev,
SequenceNumber, SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks,
};
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey, Tid};
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey};
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,
@@ -45,7 +43,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 => legacy_fallback(legacy_blocks_cids),
true => Ok(legacy_fallback(legacy_blocks_cids)),
false => Ok(Some(EventBlocks::Inline(
cids.into_iter()
.zip(data)
@@ -59,19 +57,14 @@ fn row_to_event_blocks(
(Some(_), None) | (None, Some(_)) => Err(DbError::CorruptData(
"repo_seq.block_cids/block_data partially populated",
)),
(None, None) => legacy_fallback(legacy_blocks_cids),
(None, None) => Ok(legacy_fallback(legacy_blocks_cids)),
}
}
fn legacy_fallback(
legacy_blocks_cids: Option<Vec<String>>,
) -> Result<Option<EventBlocks>, DbError> {
fn legacy_fallback(legacy_blocks_cids: Option<Vec<String>>) -> Option<EventBlocks> {
match legacy_blocks_cids {
Some(cids) if !cids.is_empty() => Ok(Some(EventBlocks::LegacyCids(column_vec(
cids,
col::REPO_SEQ_BLOCKS_CIDS,
)?))),
_ => Ok(None),
Some(cids) if !cids.is_empty() => Some(EventBlocks::LegacyCids(cids)),
_ => None,
}
}
@@ -102,40 +95,22 @@ 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: column(r.did, col::REPO_SEQ_DID)?,
did: Did::from(r.did),
created_at: r.created_at,
event_type: r.event_type,
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)?,
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),
ops: r.ops,
blobs: r
.blobs
.map(|blobs| column_vec(blobs, col::REPO_SEQ_BLOBS))
.transpose()?,
blobs: r.blobs,
blocks,
handle: r
.handle
.and_then(|h| legacy_column(h, col::REPO_SEQ_HANDLE)),
handle: r.handle.map(Handle::from),
active: r.active,
status,
rev: opt_column(r.rev, col::REPO_SEQ_REV)?,
rev: r.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;
@@ -222,13 +197,13 @@ impl RepoRepository for PostgresRepoRepository {
_did: &Did,
_handle: &Handle,
repo_root_cid: &CidLink,
repo_rev: &Tid,
repo_rev: &str,
) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)",
user_id,
repo_root_cid.as_str(),
repo_rev.as_str()
repo_rev
)
.execute(&self.pool)
.await
@@ -241,12 +216,12 @@ impl RepoRepository for PostgresRepoRepository {
&self,
user_id: Uuid,
repo_root_cid: &CidLink,
repo_rev: &Tid,
repo_rev: &str,
) -> Result<(), DbError> {
sqlx::query!(
"UPDATE repos SET repo_root_cid = $1, repo_rev = $2, updated_at = NOW() WHERE user_id = $3",
repo_root_cid.as_str(),
repo_rev.as_str(),
repo_rev,
user_id
)
.execute(&self.pool)
@@ -256,10 +231,10 @@ impl RepoRepository for PostgresRepoRepository {
Ok(())
}
async fn update_repo_rev(&self, user_id: Uuid, repo_rev: &Tid) -> Result<(), DbError> {
async fn update_repo_rev(&self, user_id: Uuid, repo_rev: &str) -> Result<(), DbError> {
sqlx::query!(
"UPDATE repos SET repo_rev = $1 WHERE user_id = $2",
repo_rev.as_str(),
repo_rev,
user_id
)
.execute(&self.pool)
@@ -287,7 +262,7 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
opt_column(result, col::REPOS_REPO_ROOT_CID)
Ok(result.map(CidLink::from))
}
async fn get_repo(&self, user_id: Uuid) -> Result<Option<RepoInfo>, DbError> {
@@ -299,14 +274,11 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
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()
Ok(row.map(|r| RepoInfo {
user_id: r.user_id,
repo_root_cid: CidLink::from(r.repo_root_cid),
repo_rev: r.repo_rev,
}))
}
async fn get_repo_root_by_did(&self, did: &Did) -> Result<Option<CidLink>, DbError> {
@@ -318,7 +290,7 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
opt_column(result, col::REPOS_REPO_ROOT_CID)
Ok(result.map(CidLink::from))
}
async fn count_repos(&self) -> Result<i64, DbError> {
@@ -336,14 +308,13 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
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)?,
})
Ok(rows
.into_iter()
.map(|r| RepoWithoutRev {
user_id: r.user_id,
repo_root_cid: CidLink::from(r.repo_root_cid),
})
.collect()
.collect())
}
async fn upsert_records(
@@ -352,7 +323,7 @@ impl RepoRepository for PostgresRepoRepository {
collections: &[Nsid],
rkeys: &[Rkey],
record_cids: &[CidLink],
repo_rev: &Tid,
repo_rev: &str,
) -> Result<(), DbError> {
let collections_str: Vec<&str> = collections.iter().map(|c| c.as_str()).collect();
let rkeys_str: Vec<&str> = rkeys.iter().map(|r| r.as_str()).collect();
@@ -370,7 +341,7 @@ impl RepoRepository for PostgresRepoRepository {
&collections_str as &[&str],
&rkeys_str as &[&str],
&cids_str as &[&str],
repo_rev.as_str()
repo_rev
)
.execute(&self.pool)
.await
@@ -430,7 +401,7 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
opt_column(result, col::RECORDS_RECORD_CID)
Ok(result.map(CidLink::from))
}
async fn list_records(
@@ -443,16 +414,13 @@ impl RepoRepository for PostgresRepoRepository {
rkey_start: Option<&Rkey>,
rkey_end: Option<&Rkey>,
) -> Result<Vec<RecordInfo>, DbError> {
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)?,
})
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),
})
.collect())
.collect()
};
let collection_str = collection.as_str();
@@ -474,7 +442,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
to_record_info(rows)
Ok(to_record_info(rows))
}
true => {
let rows = sqlx::query_as!(
@@ -490,7 +458,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
to_record_info(rows)
Ok(to_record_info(rows))
}
};
}
@@ -514,7 +482,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
to_record_info(rows)
Ok(to_record_info(rows))
}
true => {
let rows = sqlx::query_as!(
@@ -531,7 +499,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
to_record_info(rows)
Ok(to_record_info(rows))
}
};
}
@@ -553,7 +521,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
to_record_info(rows)
Ok(to_record_info(rows))
}
true => {
let rows = sqlx::query_as!(
@@ -569,7 +537,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
to_record_info(rows)
Ok(to_record_info(rows))
}
};
}
@@ -591,7 +559,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
to_record_info(rows)
Ok(to_record_info(rows))
}
true => {
let rows = sqlx::query_as!(
@@ -607,7 +575,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
to_record_info(rows)
Ok(to_record_info(rows))
}
};
}
@@ -626,7 +594,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
to_record_info(rows)
Ok(to_record_info(rows))
}
true => {
let rows = sqlx::query_as!(
@@ -641,7 +609,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
to_record_info(rows)
Ok(to_record_info(rows))
}
}
}
@@ -657,12 +625,10 @@ impl RepoRepository for PostgresRepoRepository {
Ok(rows
.into_iter()
.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)?,
})
.map(|r| FullRecordInfo {
collection: Nsid::from(r.collection),
rkey: Rkey::from(r.rkey),
record_cid: CidLink::from(r.record_cid),
})
.collect())
}
@@ -676,10 +642,7 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.filter_map(|c| legacy_column(c, col::RECORDS_COLLECTION))
.collect())
Ok(rows.into_iter().map(Nsid::from).collect())
}
async fn count_records(&self, repo_id: Uuid) -> Result<i64, DbError> {
@@ -721,45 +684,6 @@ 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,
@@ -781,7 +705,7 @@ impl RepoRepository for PostgresRepoRepository {
&self,
user_id: Uuid,
block_cids: &[Vec<u8>],
repo_rev: &Tid,
repo_rev: &str,
) -> Result<(), DbError> {
sqlx::query(
r#"
@@ -792,7 +716,7 @@ impl RepoRepository for PostgresRepoRepository {
)
.bind(user_id)
.bind(block_cids)
.bind(repo_rev.as_str())
.bind(repo_rev)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
@@ -832,45 +756,26 @@ impl RepoRepository for PostgresRepoRepository {
async fn get_user_block_cids_since_rev(
&self,
user_id: Uuid,
since_rev: Option<&Tid>,
since_rev: &str,
) -> Result<Vec<Vec<u8>>, DbError> {
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
}
};
let rows: Vec<(Vec<u8>,)> = sqlx::query_as(
r#"
SELECT block_cid FROM user_blocks
WHERE user_id = $1 AND repo_rev > $2
ORDER BY repo_rev ASC
"#,
)
.bind(user_id)
.bind(since_rev)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
rows.map_err(map_sqlx_error)
Ok(rows.into_iter().map(|(cid,)| cid).collect())
}
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<(), DbError> {
let (block_cids, block_data) = inline_to_paired_blocks(data.blocks.as_deref());
let blob_strs: Option<Vec<String>> = data
.blobs
.as_ref()
.map(|blobs| blobs.iter().map(|c| c.to_string()).collect());
sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)
@@ -881,11 +786,11 @@ impl RepoRepository for PostgresRepoRepository {
data.commit_cid.as_ref().map(|c| c.as_str()),
data.prev_cid.as_ref().map(|c| c.as_str()),
data.ops,
blob_strs.as_deref(),
data.blobs.as_deref(),
&block_cids as &[Vec<u8>],
&block_data as &[Vec<u8>],
data.prev_data_cid.as_ref().map(|c| c.as_str()),
data.rev.as_deref()
data.rev
)
.execute(&self.pool)
.await
@@ -919,7 +824,7 @@ impl RepoRepository for PostgresRepoRepository {
async fn insert_account_event(&self, did: &Did, status: AccountStatus) -> Result<(), DbError> {
let active = status.is_active();
let status_str = status.for_firehose().map(|s| s.as_str());
let status_str = status.for_firehose();
sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, active, status)
@@ -941,7 +846,7 @@ impl RepoRepository for PostgresRepoRepository {
&self,
did: &Did,
commit_cid: &CidLink,
rev: Option<&Tid>,
rev: Option<&str>,
commit_bytes: &[u8],
) -> Result<(), DbError> {
let cid_bytes = commit_cid
@@ -957,7 +862,7 @@ impl RepoRepository for PostgresRepoRepository {
"#,
did.as_str(),
commit_cid.as_str(),
rev.map(|r| r.as_str()),
rev,
&block_cids as &[Vec<u8>],
&block_data as &[Vec<u8>]
)
@@ -974,7 +879,7 @@ impl RepoRepository for PostgresRepoRepository {
did: &Did,
commit_cid: &CidLink,
mst_root_cid: &CidLink,
rev: &Tid,
rev: &str,
commit_bytes: &[u8],
mst_root_bytes: &[u8],
) -> Result<(), DbError> {
@@ -1004,7 +909,7 @@ impl RepoRepository for PostgresRepoRepository {
&blobs,
&block_cids as &[Vec<u8>],
&block_data as &[Vec<u8>],
rev.as_str()
rev
)
.execute(&self.pool)
.await
@@ -1111,16 +1016,13 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
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()
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),
}))
}
async fn get_events_since_seq(
@@ -1144,7 +1046,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(collect_sequenced_rows(rows))
rows.into_iter().map(map_sequenced_row).collect()
}
None => {
let rows = sqlx::query_as!(
@@ -1159,7 +1061,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(collect_sequenced_rows(rows))
rows.into_iter().map(map_sequenced_row).collect()
}
}
}
@@ -1182,7 +1084,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(collect_sequenced_rows(rows))
rows.into_iter().map(map_sequenced_row).collect()
}
async fn get_event_by_seq(
@@ -1222,7 +1124,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(collect_sequenced_rows(rows))
rows.into_iter().map(map_sequenced_row).collect()
}
async fn list_repos_paginated(
@@ -1245,17 +1147,16 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
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)?,
})
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,
})
.collect()
.collect())
}
async fn get_repo_root_cid_by_user_id(
@@ -1269,7 +1170,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
opt_column(cid, col::REPOS_REPO_ROOT_CID)
Ok(cid.map(CidLink::from))
}
async fn import_repo_data(
@@ -1562,10 +1463,6 @@ impl RepoRepository for PostgresRepoRepository {
let event = input.commit_event;
let (event_block_cids, event_block_data) = inline_into_paired_blocks(event.blocks);
let event_blob_strs: Option<Vec<String>> = event
.blobs
.as_ref()
.map(|blobs| blobs.iter().map(|c| c.to_string()).collect());
sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)
@@ -1576,11 +1473,11 @@ impl RepoRepository for PostgresRepoRepository {
event.commit_cid.as_ref().map(|c| c.as_str()),
event.prev_cid.as_ref().map(|c| c.as_str()),
event.ops,
event_blob_strs.as_deref(),
event.blobs.as_deref(),
&event_block_cids as &[Vec<u8>],
&event_block_data as &[Vec<u8>],
event.prev_data_cid.as_ref().map(|c| c.as_str()),
event.rev.as_deref()
event.rev
)
.execute(&mut *tx)
.await
@@ -1611,15 +1508,14 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
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)?,
})
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,
})
.collect()
.collect())
}
async fn get_users_needing_record_blobs_backfill(
@@ -1640,14 +1536,13 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
rows.into_iter()
.map(|r| {
Ok(UserNeedingRecordBlobsBackfill {
user_id: r.user_id,
did: column(r.did, col::USERS_DID)?,
})
Ok(rows
.into_iter()
.map(|r| UserNeedingRecordBlobsBackfill {
user_id: r.user_id,
did: Did::from(r.did),
})
.collect()
.collect())
}
async fn insert_record_blobs(
+91 -112
View File
@@ -7,12 +7,10 @@ use tranquil_db_traits::{
SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, SessionRefreshData,
SessionRepository, SessionToken, SessionTokenCreate,
};
use tranquil_types::{Did, Jti, PasswordHash};
use tranquil_types::Did;
use uuid::Uuid;
use super::col;
use super::user::map_sqlx_error;
use super::{column, opt_column};
pub struct PostgresSessionRepository {
pool: PgPool,
@@ -36,8 +34,8 @@ impl SessionRepository for PostgresSessionRepository {
RETURNING id
"#,
data.did.as_str(),
data.access_jti.as_str(),
data.refresh_jti.as_str(),
data.access_jti,
data.refresh_jti,
data.access_expires_at,
data.refresh_expires_at,
data.login_type.is_legacy(),
@@ -55,7 +53,7 @@ impl SessionRepository for PostgresSessionRepository {
async fn get_session_by_access_jti(
&self,
access_jti: &Jti,
access_jti: &str,
) -> Result<Option<SessionToken>, DbError> {
let row = sqlx::query!(
r#"
@@ -65,35 +63,32 @@ impl SessionRepository for PostgresSessionRepository {
FROM session_tokens
WHERE access_jti = $1
"#,
access_jti.as_str()
access_jti
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
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()
Ok(row.map(|r| SessionToken {
id: SessionId::new(r.id),
did: Did::from(r.did),
access_jti: r.access_jti,
refresh_jti: r.refresh_jti,
access_expires_at: r.access_expires_at,
refresh_expires_at: r.refresh_expires_at,
login_type: LoginType::from_legacy_flag(r.legacy_login),
mfa_verified: r.mfa_verified,
scope: r.scope,
controller_did: r.controller_did.map(Did::from),
app_password_name: r.app_password_name,
created_at: r.created_at,
updated_at: r.updated_at,
}))
}
async fn get_session_for_refresh(
&self,
refresh_jti: &Jti,
refresh_jti: &str,
) -> Result<Option<SessionForRefresh>, DbError> {
let row = sqlx::query!(
r#"
@@ -103,33 +98,30 @@ impl SessionRepository for PostgresSessionRepository {
JOIN user_keys k ON u.id = k.user_id
WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()
"#,
refresh_jti.as_str()
refresh_jti
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
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()
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),
}))
}
async fn delete_session_by_access_jti(
&self,
access_jti: &Jti,
access_jti: &str,
did: &Did,
) -> Result<u64, DbError> {
let result = sqlx::query!(
"DELETE FROM session_tokens WHERE access_jti = $1 AND did = $2",
access_jti.as_str(),
access_jti,
did.as_str()
)
.execute(&self.pool)
@@ -139,7 +131,11 @@ impl SessionRepository for PostgresSessionRepository {
Ok(result.rows_affected())
}
async fn delete_session_by_id(&self, session_id: SessionId, did: &Did) -> Result<u64, DbError> {
async fn delete_session_by_id(
&self,
session_id: SessionId,
did: &Did,
) -> Result<u64, DbError> {
let result = sqlx::query!(
"DELETE FROM session_tokens WHERE id = $1 AND did = $2",
session_id.as_i32(),
@@ -164,12 +160,12 @@ impl SessionRepository for PostgresSessionRepository {
async fn delete_sessions_by_did_except_jti(
&self,
did: &Did,
except_jti: &Jti,
except_jti: &str,
) -> Result<u64, DbError> {
let result = sqlx::query!(
"DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2",
did.as_str(),
except_jti.as_str()
except_jti
)
.execute(&self.pool)
.await
@@ -196,7 +192,7 @@ impl SessionRepository for PostgresSessionRepository {
.into_iter()
.map(|r| SessionListItem {
id: SessionId::new(r.id),
access_jti: Jti::from(r.access_jti),
access_jti: r.access_jti,
created_at: r.created_at,
refresh_expires_at: r.refresh_expires_at,
})
@@ -207,7 +203,7 @@ impl SessionRepository for PostgresSessionRepository {
&self,
session_id: SessionId,
did: &Did,
) -> Result<Option<Jti>, DbError> {
) -> Result<Option<String>, DbError> {
let row = sqlx::query_scalar!(
"SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
session_id.as_i32(),
@@ -217,7 +213,7 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(Jti::from))
Ok(row)
}
async fn delete_sessions_by_app_password(
@@ -241,7 +237,7 @@ impl SessionRepository for PostgresSessionRepository {
&self,
did: &Did,
app_password_name: &str,
) -> Result<Vec<Jti>, DbError> {
) -> Result<Vec<String>, DbError> {
let rows = sqlx::query_scalar!(
"SELECT access_jti FROM session_tokens WHERE did = $1 AND app_password_name = $2",
did.as_str(),
@@ -251,10 +247,10 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows.into_iter().map(Jti::from).collect())
Ok(rows)
}
async fn lookup_refresh_grace(&self, refresh_jti: &Jti) -> Result<RefreshGraceLookup, DbError> {
async fn lookup_refresh_grace(&self, refresh_jti: &str) -> Result<RefreshGraceLookup, DbError> {
let row = sqlx::query!(
r#"
SELECT u.used_at, st.id AS session_id, st.did, st.scope, st.controller_did,
@@ -266,7 +262,7 @@ impl SessionRepository for PostgresSessionRepository {
JOIN user_keys k ON us.id = k.user_id
WHERE u.refresh_jti = $1
"#,
refresh_jti.as_str()
refresh_jti
)
.fetch_optional(&self.pool)
.await
@@ -282,11 +278,11 @@ 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: column(r.did, col::SESSION_TOKENS_DID)?,
did: Did::from(r.did),
scope: r.scope,
controller_did: opt_column(r.controller_did, col::SESSION_TOKENS_CONTROLLER_DID)?,
access_jti: Jti::from(r.access_jti),
refresh_jti: Jti::from(r.refresh_jti),
controller_did: r.controller_did.map(Did::from),
access_jti: r.access_jti,
refresh_jti: r.refresh_jti,
access_expires_at: r.access_expires_at,
refresh_expires_at: r.refresh_expires_at,
key_bytes: r.key_bytes,
@@ -294,7 +290,7 @@ impl SessionRepository for PostgresSessionRepository {
}))
} else {
Ok(RefreshGraceLookup::Compromised {
did: column(r.did, col::SESSION_TOKENS_DID)?,
did: Did::from(r.did),
session_id: SessionId::new(r.session_id),
key_bytes: r.key_bytes,
encryption_version: r.encryption_version.unwrap_or(0),
@@ -316,23 +312,19 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
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,
)?,
})
Ok(rows
.into_iter()
.map(|r| AppPasswordRecord {
id: r.id,
user_id: r.user_id,
name: r.name,
password_hash: r.password_hash,
created_at: r.created_at,
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
scopes: r.scopes,
created_by_controller_did: r.created_by_controller_did.map(Did::from),
})
.collect()
.collect())
}
async fn get_app_passwords_for_login(
@@ -353,23 +345,19 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
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,
)?,
})
Ok(rows
.into_iter()
.map(|r| AppPasswordRecord {
id: r.id,
user_id: r.user_id,
name: r.name,
password_hash: r.password_hash,
created_at: r.created_at,
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
scopes: r.scopes,
created_by_controller_did: r.created_by_controller_did.map(Did::from),
})
.collect()
.collect())
}
async fn get_app_password_by_name(
@@ -390,22 +378,16 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
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()
Ok(row.map(|r| AppPasswordRecord {
id: r.id,
user_id: r.user_id,
name: r.name,
password_hash: r.password_hash,
created_at: r.created_at,
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
scopes: r.scopes,
created_by_controller_did: r.created_by_controller_did.map(Did::from),
}))
}
async fn create_app_password(&self, data: &AppPasswordCreate) -> Result<Uuid, DbError> {
@@ -417,7 +399,7 @@ impl SessionRepository for PostgresSessionRepository {
"#,
data.user_id,
data.name,
data.password_hash.as_str(),
data.password_hash,
data.privilege.is_privileged(),
data.scopes,
data.created_by_controller_did.as_ref().map(|d| d.as_str())
@@ -517,10 +499,7 @@ impl SessionRepository for PostgresSessionRepository {
Ok(())
}
async fn get_app_password_hashes_by_did(
&self,
did: &Did,
) -> Result<Vec<PasswordHash>, DbError> {
async fn get_app_password_hashes_by_did(&self, did: &Did) -> Result<Vec<String>, DbError> {
let rows = sqlx::query_scalar!(
r#"SELECT ap.password_hash FROM app_passwords ap
JOIN users u ON ap.user_id = u.id
@@ -531,7 +510,7 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows.into_iter().map(PasswordHash::new).collect())
Ok(rows)
}
async fn refresh_session_atomic(
@@ -545,7 +524,7 @@ impl SessionRepository for PostgresSessionRepository {
// rest see `rows_affected == 0`.
let claimed = sqlx::query!(
"INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING",
data.old_refresh_jti.as_str(),
data.old_refresh_jti,
data.session_id.as_i32()
)
.execute(&mut *tx)
@@ -588,8 +567,8 @@ impl SessionRepository for PostgresSessionRepository {
refresh_expires_at = $4, updated_at = NOW()
WHERE id = $5
"#,
data.new_access_jti.as_str(),
data.new_refresh_jti.as_str(),
data.new_access_jti,
data.new_refresh_jti,
data.new_access_expires_at,
data.new_refresh_expires_at,
data.session_id.as_i32()
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -4,16 +4,10 @@ version.workspace = true
edition.workspace = true
license.workspace = true
[features]
testing = []
cache-keys = ["dep:tranquil-types"]
[dependencies]
tranquil-types = { workspace = true, optional = true }
tranquil-config = { workspace = true }
async-trait = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
-103
View File
@@ -1,103 +0,0 @@
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)
}
-45
View File
@@ -1,15 +1,6 @@
#[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;
@@ -66,42 +57,6 @@ 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;
-74
View File
@@ -1,74 +0,0 @@
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(())
}
}
+3 -4
View File
@@ -5,11 +5,10 @@ edition.workspace = true
license.workspace = true
[features]
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:tranquil-infra"]
default = []
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:urlencoding"]
[dependencies]
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 +19,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"] }
+97 -309
View File
@@ -6,11 +6,8 @@ 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(60 * 60);
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 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;
@@ -19,13 +16,6 @@ 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,21 +34,20 @@ impl CacheEntry {
}
struct SchemaStore {
schemas: HashMap<Nsid, PositiveEntry>,
insertion_order: VecDeque<Nsid>,
schemas: HashMap<String, PositiveEntry>,
insertion_order: VecDeque<String>,
}
pub struct DynamicRegistry {
store: RwLock<SchemaStore>,
negative_cache: RwLock<HashMap<Nsid, NegativeEntry>>,
in_flight: RwLock<HashMap<Nsid, Arc<Notify>>>,
negative_cache: RwLock<HashMap<String, NegativeEntry>>,
in_flight: RwLock<HashMap<String, Arc<Notify>>>,
network_disabled: AtomicBool,
shared: RwLock<Option<Arc<dyn Cache>>>,
}
struct InFlightGuard<'a> {
registry: &'a DynamicRegistry,
nsid: Nsid,
nsid: String,
}
impl Drop for InFlightGuard<'_> {
@@ -80,18 +69,9 @@ 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 =
@@ -104,7 +84,7 @@ impl DynamicRegistry {
self.network_disabled.store(disabled, Ordering::Relaxed);
}
pub fn get_cached(&self, nsid: &Nsid) -> Option<Arc<LexiconDoc>> {
pub fn get_cached(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
self.store
.read()
.schemas
@@ -112,7 +92,7 @@ impl DynamicRegistry {
.map(|e| Arc::clone(&e.doc))
}
pub(crate) fn get_entry(&self, nsid: &Nsid) -> Option<CacheEntry> {
pub(crate) fn get_entry(&self, nsid: &str) -> Option<CacheEntry> {
let now = Instant::now();
self.store.read().schemas.get(nsid).map(|e| {
if e.expires_at > now {
@@ -123,27 +103,23 @@ impl DynamicRegistry {
})
}
pub fn is_negative_cached(&self, nsid: &Nsid) -> bool {
self.negative_remaining(nsid).is_some()
}
fn negative_remaining(&self, nsid: &Nsid) -> Option<Duration> {
self.negative_cache
.read()
pub fn is_negative_cached(&self, nsid: &str) -> bool {
let cache = self.negative_cache.read();
cache
.get(nsid)
.and_then(|entry| entry.expires_at.checked_duration_since(Instant::now()))
.is_some_and(|entry| entry.expires_at > Instant::now())
}
fn insert_negative(&self, nsid: &Nsid, ttl: Duration) {
fn insert_negative(&self, nsid: &str) {
let mut cache = self.negative_cache.write();
if cache.len() >= MAX_DYNAMIC_SCHEMAS {
let now = Instant::now();
cache.retain(|_, entry| entry.expires_at > now);
}
cache.insert(
nsid.clone(),
nsid.to_string(),
NegativeEntry {
expires_at: Instant::now() + ttl,
expires_at: Instant::now() + NEGATIVE_CACHE_TTL,
},
);
}
@@ -182,63 +158,25 @@ 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) {
fn bump_expiry(&self, nsid: &str, duration: Duration) {
let mut store = self.store.write();
if let Some(entry) = store.schemas.get_mut(nsid) {
entry.expires_at = Instant::now() + duration;
}
}
pub async fn resolve_and_cache(&self, nsid: &Nsid) -> Result<Arc<LexiconDoc>, ResolveError> {
pub async fn resolve_and_cache(&self, nsid: &str) -> Result<Arc<LexiconDoc>, ResolveError> {
self.resolve_and_cache_with(nsid, |n| async move { resolve_lexicon(&n).await })
.await
}
async fn resolve_and_cache_with<F, Fut>(
&self,
nsid: &Nsid,
nsid: &str,
resolver: F,
) -> Result<Arc<LexiconDoc>, ResolveError>
where
F: FnOnce(Nsid) -> Fut,
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
{
match self.get_entry(nsid) {
@@ -250,12 +188,12 @@ impl DynamicRegistry {
async fn refresh_stale<F, Fut>(
&self,
nsid: &Nsid,
nsid: &str,
stale: Arc<LexiconDoc>,
resolver: F,
) -> Result<Arc<LexiconDoc>, ResolveError>
where
F: FnOnce(Nsid) -> Fut,
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
{
if self.network_disabled.load(Ordering::Relaxed) {
@@ -263,24 +201,16 @@ impl DynamicRegistry {
}
match self.acquire_leadership(nsid) {
Some(_guard) => match resolver(nsid.clone()).await {
Ok(doc) => {
self.shared_put(&doc).await;
Ok(self.insert_schema(doc))
}
Some(_guard) => match resolver(nsid.to_string()).await {
Ok(doc) => Ok(self.insert_schema(doc)),
Err(e) => {
let (doc, source) = match self.shared_get(nsid).await {
Some(doc) => (doc, "shared"),
None => (stale, "local"),
};
self.bump_expiry(nsid, REFRESH_FAILURE_BACKOFF);
tracing::warn!(
nsid = %nsid,
nsid = nsid,
error = %e,
source,
"lexicon refresh failed, serving cached entry"
"lexicon refresh failed, serving stale cached entry"
);
Ok(doc)
Ok(stale)
}
},
None => {
@@ -292,85 +222,62 @@ impl DynamicRegistry {
async fn resolve_fresh<F, Fut>(
&self,
nsid: &Nsid,
nsid: &str,
resolver: F,
) -> Result<Arc<LexiconDoc>, ResolveError>
where
F: FnOnce(Nsid) -> Fut,
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
{
if let Some(doc) = self.shared_get(nsid).await {
return Ok(doc);
}
if let Some(remaining) = self.negative_remaining(nsid) {
return Err(ResolveError::NegativelyCached {
nsid: nsid.clone(),
ttl_secs: remaining.as_secs(),
});
}
if self.shared_is_negative(nsid).await {
// Cache reports 0 remaining TTL for shared negative hit,
// so we mirror for the backoff rather than a full `NEGATIVE_CACHE_TTL`.
self.insert_negative(nsid, REFRESH_FAILURE_BACKOFF);
return Err(ResolveError::NegativelyCached {
nsid: nsid.clone(),
ttl_secs: REFRESH_FAILURE_BACKOFF.as_secs(),
});
}
if self.network_disabled.load(Ordering::Relaxed) {
return Err(ResolveError::NetworkDisabled);
}
if self.is_negative_cached(nsid) {
return Err(ResolveError::NegativelyCached {
nsid: nsid.to_string(),
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
});
}
match self.acquire_leadership(nsid) {
Some(_guard) => match resolver(nsid.clone()).await {
Ok(doc) => {
self.shared_put(&doc).await;
Ok(self.insert_schema(doc))
}
Some(_guard) => match resolver(nsid.to_string()).await {
Ok(doc) => Ok(self.insert_schema(doc)),
Err(e) => {
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"
);
self.insert_negative(nsid);
tracing::debug!(nsid = nsid, error = %e, "caching negative resolution result");
Err(e)
}
},
None => {
self.wait_for_leader(nsid).await;
match (self.get_cached(nsid), self.negative_remaining(nsid)) {
(Some(doc), _) => Ok(doc),
(None, Some(remaining)) => Err(ResolveError::NegativelyCached {
nsid: nsid.clone(),
ttl_secs: remaining.as_secs(),
match self.get_cached(nsid) {
Some(doc) => Ok(doc),
None if self.is_negative_cached(nsid) => Err(ResolveError::NegativelyCached {
nsid: nsid.to_string(),
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
}),
None => Err(ResolveError::LeaderAborted {
nsid: nsid.to_string(),
}),
(None, None) => Err(ResolveError::LeaderAborted { nsid: nsid.clone() }),
}
}
}
}
fn acquire_leadership(&self, nsid: &Nsid) -> Option<InFlightGuard<'_>> {
fn acquire_leadership(&self, nsid: &str) -> Option<InFlightGuard<'_>> {
let mut map = self.in_flight.write();
if map.contains_key(nsid) {
None
} else {
map.insert(nsid.clone(), Arc::new(Notify::new()));
map.insert(nsid.to_string(), Arc::new(Notify::new()));
Some(InFlightGuard {
registry: self,
nsid: nsid.clone(),
nsid: nsid.to_string(),
})
}
}
async fn wait_for_leader(&self, nsid: &Nsid) {
async fn wait_for_leader(&self, nsid: &str) {
let notify = {
let map = self.in_flight.read();
match map.get(nsid) {
@@ -393,7 +300,7 @@ impl DynamicRegistry {
}
#[cfg(test)]
fn expire_now(&self, nsid: &Nsid) {
fn expire_now(&self, nsid: &str) {
let mut store = self.store.write();
if let Some(entry) = store.schemas.get_mut(nsid) {
entry.expires_at = Instant::now();
@@ -410,34 +317,29 @@ impl Default for DynamicRegistry {
#[cfg(test)]
mod tests {
use super::*;
use tranquil_infra::MemoryCache;
fn nsid(s: &str) -> Nsid {
s.parse().unwrap()
}
#[test]
fn test_negative_cache() {
let registry = DynamicRegistry::new();
assert!(!registry.is_negative_cached(&nsid("pet.nel.negative")));
assert!(!registry.is_negative_cached("com.example.test"));
registry.insert_negative(&nsid("pet.nel.negative"), NEGATIVE_CACHE_TTL);
assert!(registry.is_negative_cached(&nsid("pet.nel.negative")));
registry.insert_negative("com.example.test");
assert!(registry.is_negative_cached("com.example.test"));
}
#[tokio::test]
async fn test_negative_cache_returns_appropriate_error_variant() {
let registry = DynamicRegistry::new();
registry.insert_negative(&nsid("pet.nel.cached"), NEGATIVE_CACHE_TTL);
registry.insert_negative("com.example.cached");
let err = registry
.resolve_and_cache(&nsid("pet.nel.cached"))
.resolve_and_cache("com.example.cached")
.await
.unwrap_err();
assert!(
matches!(err, ResolveError::NegativelyCached { .. }),
"negative cache hit must surface as NegativelyCached, got: {}",
!matches!(err, ResolveError::InvalidNsid(_)),
"negative cache hit should not return InvalidNsid - the NSID is valid, it just failed resolution recently. got: {}",
err
);
}
@@ -445,11 +347,7 @@ mod tests {
#[test]
fn test_empty_lookup() {
let registry = DynamicRegistry::new();
assert!(
registry
.get_cached(&nsid("com.example.nonexistent"))
.is_none()
);
assert!(registry.get_cached("com.example.nonexistent").is_none());
assert_eq!(registry.schema_count(), 0);
}
@@ -458,7 +356,7 @@ mod tests {
let registry = DynamicRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: nsid("com.example.test"),
id: "com.example.test".to_string(),
defs: HashMap::new(),
};
@@ -466,11 +364,11 @@ mod tests {
assert_eq!(arc.id, "com.example.test");
assert_eq!(registry.schema_count(), 1);
let retrieved = registry.get_cached(&nsid("com.example.test"));
let retrieved = registry.get_cached("com.example.test");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().id, "com.example.test");
let entry = registry.get_entry(&nsid("com.example.test")).unwrap();
let entry = registry.get_entry("com.example.test").unwrap();
assert!(entry.is_fresh(), "freshly inserted entry must be fresh");
}
@@ -478,17 +376,17 @@ mod tests {
fn test_negative_cache_cleared_on_insert() {
let registry = DynamicRegistry::new();
registry.insert_negative(&nsid("pet.nel.cleared"), NEGATIVE_CACHE_TTL);
assert!(registry.is_negative_cached(&nsid("pet.nel.cleared")));
registry.insert_negative("com.example.test");
assert!(registry.is_negative_cached("com.example.test"));
let doc = LexiconDoc {
lexicon: 1,
id: nsid("pet.nel.cleared"),
id: "com.example.test".to_string(),
defs: HashMap::new(),
};
registry.insert_schema(doc);
assert!(!registry.is_negative_cached(&nsid("pet.nel.cleared")));
assert!(!registry.is_negative_cached("com.example.test"));
}
#[test]
@@ -496,25 +394,17 @@ mod tests {
let registry = DynamicRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: nsid("pet.nel.stale"),
id: "pet.nel.stale".to_string(),
defs: HashMap::new(),
};
registry.insert_schema(doc);
assert!(
registry
.get_entry(&nsid("pet.nel.stale"))
.unwrap()
.is_fresh()
);
assert!(registry.get_entry("pet.nel.stale").unwrap().is_fresh());
registry.expire_now(&nsid("pet.nel.stale"));
registry.expire_now("pet.nel.stale");
assert!(
!registry
.get_entry(&nsid("pet.nel.stale"))
.unwrap()
.is_fresh(),
!registry.get_entry("pet.nel.stale").unwrap().is_fresh(),
"entry past expiry must be reported stale"
);
}
@@ -524,16 +414,16 @@ mod tests {
let registry = DynamicRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: nsid("pet.nel.flaky"),
id: "pet.nel.flaky".to_string(),
defs: HashMap::new(),
};
registry.insert_schema(doc);
registry.expire_now(&nsid("pet.nel.flaky"));
registry.expire_now("pet.nel.flaky");
let result = registry
.resolve_and_cache_with(&nsid("pet.nel.flaky"), |n| async move {
.resolve_and_cache_with("pet.nel.flaky", |n| async move {
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
domain: n.into_inner(),
domain: n,
reason: "simulated failure".to_string(),
})
})
@@ -542,14 +432,11 @@ mod tests {
let served = result.expect("stale entry must be served when refresh fails");
assert_eq!(served.id, "pet.nel.flaky");
assert!(
registry
.get_entry(&nsid("pet.nel.flaky"))
.unwrap()
.is_fresh(),
registry.get_entry("pet.nel.flaky").unwrap().is_fresh(),
"failed refresh must bump expiry so subsequent lookups skip the resolver"
);
assert!(
!registry.is_negative_cached(&nsid("pet.nel.flaky")),
!registry.is_negative_cached("pet.nel.flaky"),
"stale refresh failure must not poison negative cache"
);
}
@@ -559,13 +446,13 @@ mod tests {
let registry = DynamicRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: nsid("pet.nel.fresh"),
id: "pet.nel.fresh".to_string(),
defs: HashMap::new(),
};
registry.insert_schema(doc);
let result = registry
.resolve_and_cache_with(&nsid("pet.nel.fresh"), |_| async move {
.resolve_and_cache_with("pet.nel.fresh", |_| async move {
panic!("resolver must not run on fresh hit")
})
.await;
@@ -578,15 +465,15 @@ mod tests {
let registry = DynamicRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: nsid("pet.nel.offline"),
id: "pet.nel.offline".to_string(),
defs: HashMap::new(),
};
registry.insert_schema(doc);
registry.expire_now(&nsid("pet.nel.offline"));
registry.expire_now("pet.nel.offline");
registry.set_network_disabled(true);
let result = registry
.resolve_and_cache_with(&nsid("pet.nel.offline"), |_| async move {
.resolve_and_cache_with("pet.nel.offline", |_| async move {
panic!("resolver must not run when network disabled")
})
.await;
@@ -599,21 +486,16 @@ mod tests {
let registry = DynamicRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: nsid("pet.nel.refresh"),
id: "pet.nel.refresh".to_string(),
defs: HashMap::new(),
};
registry.insert_schema(doc);
registry.expire_now(&nsid("pet.nel.refresh"));
registry.expire_now("pet.nel.refresh");
assert!(
!registry
.get_entry(&nsid("pet.nel.refresh"))
.unwrap()
.is_fresh()
);
assert!(!registry.get_entry("pet.nel.refresh").unwrap().is_fresh());
let refreshed = registry
.resolve_and_cache_with(&nsid("pet.nel.refresh"), |n| async move {
.resolve_and_cache_with("pet.nel.refresh", |n| async move {
Ok(LexiconDoc {
lexicon: 1,
id: n,
@@ -625,10 +507,7 @@ mod tests {
assert_eq!(refreshed.id, "pet.nel.refresh");
assert!(
registry
.get_entry(&nsid("pet.nel.refresh"))
.unwrap()
.is_fresh(),
registry.get_entry("pet.nel.refresh").unwrap().is_fresh(),
"refresh must restore freshness"
);
}
@@ -645,7 +524,7 @@ mod tests {
let calls = Arc::clone(&calls);
tokio::spawn(async move {
registry
.resolve_and_cache_with(&nsid("pet.nel.herd"), |n| {
.resolve_and_cache_with("pet.nel.herd", |n| {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
@@ -686,13 +565,13 @@ mod tests {
let calls = Arc::clone(&calls);
tokio::spawn(async move {
registry
.resolve_and_cache_with(&nsid("pet.nel.failHerd"), |n| {
.resolve_and_cache_with("pet.nel.failHerd", |n| {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(50)).await;
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
domain: n.into_inner(),
domain: n,
reason: "simulated".to_string(),
})
}
@@ -711,7 +590,7 @@ mod tests {
1,
"single-flight must coalesce failing resolves too"
);
assert!(registry.is_negative_cached(&nsid("pet.nel.failHerd")));
assert!(registry.is_negative_cached("pet.nel.failHerd"));
}
async fn futures_collect<T>(handles: Vec<tokio::task::JoinHandle<T>>) -> Vec<T> {
@@ -729,7 +608,7 @@ mod tests {
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
let doc = LexiconDoc {
lexicon: 1,
id: nsid(&format!("pet.nel.schema{}", i)),
id: format!("pet.nel.schema{}", i),
defs: HashMap::new(),
};
registry.insert_schema(doc);
@@ -738,23 +617,23 @@ mod tests {
let trigger = LexiconDoc {
lexicon: 1,
id: nsid("pet.nel.trigger"),
id: "pet.nel.trigger".to_string(),
defs: HashMap::new(),
};
registry.insert_schema(trigger);
assert!(
registry.get_cached(&nsid("pet.nel.schema0")).is_none(),
registry.get_cached("pet.nel.schema0").is_none(),
"oldest entry should be evicted"
);
assert!(
registry.get_cached(&nsid("pet.nel.trigger")).is_some(),
registry.get_cached("pet.nel.trigger").is_some(),
"newly inserted entry should exist"
);
let evict_count = MAX_DYNAMIC_SCHEMAS / 4;
assert!(
registry
.get_cached(&nsid(&format!("pet.nel.schema{}", evict_count)))
.get_cached(&format!("pet.nel.schema{}", evict_count))
.is_some(),
"entry after eviction window should survive"
);
@@ -765,7 +644,7 @@ mod tests {
let registry = DynamicRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: nsid("pet.nel.tracked"),
id: "pet.nel.tracked".to_string(),
defs: HashMap::new(),
};
let arc = registry.insert_schema(doc);
@@ -777,7 +656,7 @@ mod tests {
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
registry.insert_schema(LexiconDoc {
lexicon: 1,
id: nsid(&format!("pet.nel.filler{}", i)),
id: format!("pet.nel.filler{}", i),
defs: HashMap::new(),
});
});
@@ -787,95 +666,4 @@ 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
);
}
}
+19 -39
View File
@@ -1,12 +1,11 @@
use crate::schema::{LexDef, LexObject, LexiconDoc, ParsedRef, parse_ref};
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use tranquil_types::Nsid;
static REGISTRY: OnceLock<LexiconRegistry> = OnceLock::new();
pub struct LexiconRegistry {
schemas: HashMap<Nsid, Arc<LexiconDoc>>,
schemas: HashMap<String, Arc<LexiconDoc>>,
#[cfg(feature = "resolve")]
dynamic: crate::dynamic::DynamicRegistry,
}
@@ -40,17 +39,11 @@ impl LexiconRegistry {
self.dynamic.insert_schema(doc);
}
pub fn get_doc(&self, nsid: &Nsid) -> Option<Arc<LexiconDoc>> {
self.get_doc_by_key(nsid.as_str())
}
fn get_doc_by_key(&self, key: &str) -> Option<Arc<LexiconDoc>> {
self.schemas.get(key).cloned().or_else(|| {
pub fn get_doc(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
self.schemas.get(nsid).cloned().or_else(|| {
#[cfg(feature = "resolve")]
{
Nsid::new(key)
.ok()
.and_then(|nsid| self.dynamic.get_cached(&nsid))
self.dynamic.get_cached(nsid)
}
#[cfg(not(feature = "resolve"))]
{
@@ -59,7 +52,7 @@ impl LexiconRegistry {
})
}
pub fn get_record_def(&self, nsid: &Nsid) -> Option<Arc<LexiconDoc>> {
pub fn get_record_def(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
let doc = self.get_doc(nsid)?;
match doc.defs.get("main")? {
LexDef::Record(_) => Some(doc),
@@ -70,15 +63,15 @@ impl LexiconRegistry {
pub fn resolve_ref(&self, reference: &str, context_nsid: &str) -> Option<ResolvedRef> {
match parse_ref(reference) {
ParsedRef::Local(local) => {
let doc = self.get_doc_by_key(context_nsid)?;
let doc = self.get_doc(context_nsid)?;
Self::def_to_resolved(&doc, local)
}
ParsedRef::Qualified { nsid, fragment } => {
let doc = self.get_doc_by_key(nsid)?;
let doc = self.get_doc(nsid)?;
Self::def_to_resolved(&doc, fragment)
}
ParsedRef::Bare(nsid) => {
let doc = self.get_doc_by_key(nsid)?;
let doc = self.get_doc(nsid)?;
Self::def_to_resolved(&doc, "main")
}
}
@@ -97,7 +90,7 @@ impl LexiconRegistry {
}
}
pub fn has_schema(&self, nsid: &Nsid) -> bool {
pub fn has_schema(&self, nsid: &str) -> bool {
self.get_doc(nsid).is_some()
}
@@ -116,20 +109,15 @@ impl LexiconRegistry {
#[cfg(feature = "resolve")]
pub async fn resolve_dynamic(
&self,
nsid: &Nsid,
nsid: &str,
) -> Result<Arc<LexiconDoc>, crate::resolve::ResolveError> {
self.dynamic.resolve_and_cache(nsid).await
}
#[cfg(feature = "resolve")]
pub fn is_negative_cached(&self, nsid: &Nsid) -> bool {
pub fn is_negative_cached(&self, nsid: &str) -> 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 {
@@ -158,15 +146,11 @@ impl ResolvedRef {
mod tests {
use super::*;
fn nsid(s: &str) -> Nsid {
s.parse().unwrap()
}
#[test]
fn test_empty_registry() {
let registry = LexiconRegistry::new();
assert_eq!(registry.schema_count(), 0);
assert!(!registry.has_schema(&nsid("app.bsky.feed.post")));
assert!(!registry.has_schema("app.bsky.feed.post"));
}
#[test]
@@ -174,19 +158,19 @@ mod tests {
let mut registry = LexiconRegistry::new();
let doc = LexiconDoc {
lexicon: 1,
id: nsid("com.example.test"),
id: "com.example.test".to_string(),
defs: HashMap::new(),
};
registry.register(doc);
assert_eq!(registry.schema_count(), 1);
assert!(registry.has_schema(&nsid("com.example.test")));
assert!(!registry.has_schema(&nsid("com.example.other")));
assert!(registry.has_schema("com.example.test"));
assert!(!registry.has_schema("com.example.other"));
}
#[test]
fn test_get_record_def() {
let registry = crate::test_schemas::test_registry();
let doc = registry.get_record_def(&nsid("com.test.basic"));
let doc = registry.get_record_def("com.test.basic");
assert!(doc.is_some());
let doc = doc.unwrap();
match doc.defs.get("main").unwrap() {
@@ -201,11 +185,7 @@ mod tests {
#[test]
fn test_get_record_def_unknown() {
let registry = LexiconRegistry::new();
assert!(
registry
.get_record_def(&nsid("com.example.nonexistent"))
.is_none()
);
assert!(registry.get_record_def("com.example.nonexistent").is_none());
}
#[test]
@@ -225,7 +205,7 @@ mod tests {
#[test]
fn test_has_schema() {
let registry = crate::test_schemas::test_registry();
assert!(registry.has_schema(&nsid("com.test.basic")));
assert!(!registry.has_schema(&nsid("com.example.nonexistent")));
assert!(registry.has_schema("com.test.basic"));
assert!(!registry.has_schema("com.example.nonexistent"));
}
}
+128 -137
View File
@@ -4,10 +4,6 @@ use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use reqwest::Client;
use std::sync::OnceLock;
use std::time::Duration;
use tranquil_types::did_doc::extract_pds_endpoint;
use tranquil_types::{
Did, Nsid, SchemaHostUrl, UrlKind, dns_guard, redirect_policy, url_kind, url_reach_permits,
};
static RESOLVER_CLIENT: OnceLock<Client> = OnceLock::new();
@@ -20,8 +16,7 @@ fn client() -> &'static Client {
.connect_timeout(Duration::from_secs(5))
.pool_max_idle_per_host(4)
.pool_idle_timeout(Duration::from_secs(60))
.redirect(redirect_policy(url_kind::SchemaHost::REACH_POLICY))
.dns_resolver(dns_guard(url_kind::SchemaHost::REACH_POLICY))
.redirect(reqwest::redirect::Policy::limited(3))
.build()
.expect("failed to build lexicon resolver HTTP client")
})
@@ -57,67 +52,54 @@ async fn read_body_limited(resp: reqwest::Response, max_bytes: usize) -> Result<
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("failed to derive authority from NSID: {0}")]
InvalidNsid(String),
#[error("DNS lookup failed for {domain}: {reason}")]
DnsLookup { domain: String, reason: String },
#[error("no DID found in DNS TXT records for {domain}")]
NoDid { domain: String },
#[error("DID document fetch failed for {did}: {reason}")]
DidResolution { did: Did, reason: String },
DidResolution { did: String, reason: String },
#[error("no PDS endpoint found in DID document for {did}")]
NoPdsEndpoint { did: Did },
NoPdsEndpoint { did: String },
#[error("schema fetch failed from {url}: {reason}")]
SchemaFetch { url: String, reason: String },
#[error("no schema record for {nsid} at {url}")]
SchemaNotFound { nsid: Nsid, url: String },
#[error("schema deserialization failed: {0}")]
InvalidSchema(String),
#[error("schema resolution recently failed for {nsid}, cached for {ttl_secs}s")]
NegativelyCached { nsid: Nsid, ttl_secs: u64 },
NegativelyCached { nsid: String, ttl_secs: u64 },
#[error("network resolution disabled")]
NetworkDisabled,
#[error("leader task for {nsid} aborted before completion")]
LeaderAborted { nsid: Nsid },
LeaderAborted { nsid: String },
}
impl ResolveError {
pub fn is_definitive(&self) -> bool {
match self {
Self::NoDid { .. }
| Self::NoPdsEndpoint { .. }
| Self::InvalidSchema(_)
| Self::SchemaNotFound { .. } => true,
Self::DnsLookup { .. }
| Self::DidResolution { .. }
| Self::SchemaFetch { .. }
| Self::NegativelyCached { .. }
| Self::NetworkDisabled
| Self::LeaderAborted { .. } => false,
}
}
}
pub fn nsid_to_authority(nsid: &Nsid) -> String {
pub fn nsid_to_authority(nsid: &str) -> Result<String, ResolveError> {
let mut segments: Vec<&str> = nsid.split('.').collect();
if segments.len() < 3 {
return Err(ResolveError::InvalidNsid(nsid.to_string()));
}
segments.pop();
segments.reverse();
segments.join(".")
Ok(segments.join("."))
}
pub async fn resolve_did_from_dns(authority: &str) -> Result<Did, ResolveError> {
pub async fn resolve_did_from_dns(authority: &str) -> Result<String, ResolveError> {
let resolver = TokioAsyncResolver::tokio_from_system_conf().unwrap_or_else(|e| {
tracing::warn!("falling back to default DNS resolvers: {}", e);
TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default())
});
let extract_did = |lookup: hickory_resolver::lookup::TxtLookup| -> Option<Did> {
let extract_did = |lookup: hickory_resolver::lookup::TxtLookup| -> Option<String> {
lookup
.iter()
.flat_map(|record| record.txt_data())
.find_map(|txt| {
let txt_str = String::from_utf8_lossy(txt);
txt_str
.strip_prefix("did=")
.and_then(|did| Did::new(did.trim()).ok())
txt_str.strip_prefix("did=").and_then(|did| {
let did = did.trim();
did.starts_with("did:").then(|| did.to_string())
})
})
};
@@ -144,9 +126,9 @@ pub async fn resolve_did_from_dns(authority: &str) -> Result<Did, ResolveError>
}
pub async fn resolve_pds_endpoint(
did: &Did,
did: &str,
plc_directory_url: Option<&str>,
) -> Result<SchemaHostUrl, ResolveError> {
) -> Result<String, ResolveError> {
let plc_base = plc_directory_url.unwrap_or(DEFAULT_PLC_DIRECTORY);
let url = match did
@@ -154,23 +136,10 @@ pub async fn resolve_pds_endpoint(
.and_then(|(_, rest)| rest.split_once(':'))
{
Some(("plc", _)) => format!("{}/{}", plc_base.trim_end_matches('/'), did),
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(),
});
}
}
}
Some(("web", domain)) => format!("https://{}/.well-known/did.json", domain),
_ => {
return Err(ResolveError::DidResolution {
did: did.clone(),
did: did.to_string(),
reason: "unsupported DID method".to_string(),
});
}
@@ -181,46 +150,58 @@ pub async fn resolve_pds_endpoint(
.send()
.await
.map_err(|e| ResolveError::DidResolution {
did: did.clone(),
did: did.to_string(),
reason: e.to_string(),
})?;
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
.await
.map_err(|reason| ResolveError::DidResolution {
did: did.clone(),
did: did.to_string(),
reason,
})?;
let doc: serde_json::Value =
serde_json::from_slice(&body).map_err(|e| ResolveError::DidResolution {
did: did.clone(),
did: did.to_string(),
reason: e.to_string(),
})?;
extract_pds_endpoint(&doc).map_err(|_| ResolveError::NoPdsEndpoint { did: did.clone() })
extract_pds_endpoint(&doc).ok_or(ResolveError::NoPdsEndpoint {
did: did.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")
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())
})
})
}
pub async fn fetch_schema_from_pds(
pds_endpoint: &SchemaHostUrl,
did: &Did,
nsid: &Nsid,
pds_endpoint: &str,
did: &str,
nsid: &str,
) -> Result<LexiconDoc, ResolveError> {
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 url = format!(
"{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=com.atproto.lexicon.schema&rkey={}",
pds_endpoint.trim_end_matches('/'),
urlencoding::encode(did),
urlencoding::encode(nsid)
);
let resp = client()
.get(request_url)
.get(&url)
.send()
.await
.map_err(|e| ResolveError::SchemaFetch {
@@ -230,27 +211,10 @@ pub async fn fetch_schema_from_pds(
let status = resp.status();
if !status.is_success() {
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),
}),
};
return Err(ResolveError::SchemaFetch {
url,
reason: format!("HTTP {}", status),
});
}
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
@@ -277,8 +241,8 @@ pub async fn fetch_schema_from_pds(
.map_err(|e| ResolveError::InvalidSchema(e.to_string()))
}
fn validate_fetched_schema(doc: &LexiconDoc, nsid: &Nsid) -> Result<(), ResolveError> {
if doc.id != *nsid {
fn validate_fetched_schema(doc: &LexiconDoc, nsid: &str) -> Result<(), ResolveError> {
if doc.id != nsid {
return Err(ResolveError::InvalidSchema(format!(
"schema id '{}' does not match requested NSID '{}'",
doc.id, nsid
@@ -293,22 +257,22 @@ fn validate_fetched_schema(doc: &LexiconDoc, nsid: &Nsid) -> Result<(), ResolveE
Ok(())
}
pub async fn resolve_lexicon(nsid: &Nsid) -> Result<LexiconDoc, ResolveError> {
pub async fn resolve_lexicon(nsid: &str) -> Result<LexiconDoc, ResolveError> {
resolve_lexicon_with_config(nsid, None).await
}
pub async fn resolve_lexicon_with_config(
nsid: &Nsid,
nsid: &str,
plc_directory_url: Option<&str>,
) -> Result<LexiconDoc, ResolveError> {
let authority = nsid_to_authority(nsid);
tracing::debug!(nsid = %nsid, authority = %authority, "resolving lexicon schema");
let authority = nsid_to_authority(nsid)?;
tracing::debug!(nsid = nsid, authority = %authority, "resolving lexicon schema");
let did = resolve_did_from_dns(&authority).await?;
tracing::debug!(nsid = %nsid, did = %did, "resolved authority DID");
tracing::debug!(nsid = nsid, did = %did, "resolved authority DID");
let pds_endpoint = resolve_pds_endpoint(&did, plc_directory_url).await?;
tracing::debug!(nsid = %nsid, pds = %pds_endpoint, "resolved PDS endpoint");
tracing::debug!(nsid = nsid, pds = %pds_endpoint, "resolved PDS endpoint");
let doc = fetch_schema_from_pds(&pds_endpoint, &did, nsid).await?;
validate_fetched_schema(&doc, nsid)?;
@@ -317,8 +281,8 @@ pub async fn resolve_lexicon_with_config(
}
pub async fn resolve_lexicon_from_did(
nsid: &Nsid,
did: &Did,
nsid: &str,
did: &str,
plc_directory_url: Option<&str>,
) -> Result<LexiconDoc, ResolveError> {
let pds_endpoint = resolve_pds_endpoint(did, plc_directory_url).await?;
@@ -331,73 +295,100 @@ pub async fn resolve_lexicon_from_did(
mod tests {
use super::*;
fn nsid(s: &str) -> Nsid {
s.parse().unwrap()
}
#[test]
fn is_record_absent_recognizes_only_the_reference_pds_absence_shapes() {
assert!(is_record_absent(
"RecordNotFound",
"Could not locate record: at://did:plc:nel/com.atproto.lexicon.schema/x"
));
assert!(is_record_absent("RecordNotFound", ""));
assert!(is_record_absent(
"InvalidRequest",
"Could not locate record"
));
assert!(!is_record_absent(
"InvalidRequest",
"Error: rkey must be a valid record key"
));
assert!(!is_record_absent("InvalidRequest", ""));
assert!(!is_record_absent("InternalServerError", ""));
assert!(!is_record_absent("RateLimitExceeded", ""));
assert!(!is_record_absent("", ""));
}
#[test]
fn test_nsid_to_authority() {
assert_eq!(
nsid_to_authority(&nsid("app.bsky.feed.post")),
nsid_to_authority("app.bsky.feed.post").unwrap(),
"feed.bsky.app"
);
assert_eq!(
nsid_to_authority(&nsid("com.atproto.repo.strongRef")),
nsid_to_authority("com.atproto.repo.strongRef").unwrap(),
"repo.atproto.com"
);
assert_eq!(
nsid_to_authority(&nsid("com.germnetwork.social.post")),
nsid_to_authority("com.germnetwork.social.post").unwrap(),
"social.germnetwork.com"
);
assert!(nsid_to_authority("tooShort").is_err());
}
#[test]
fn test_nsid_to_authority_three_segments() {
assert_eq!(
nsid_to_authority(&nsid("org.example.record")),
nsid_to_authority("org.example.record").unwrap(),
"example.org"
);
}
#[test]
fn test_extract_pds_endpoint_valid() {
let doc = serde_json::json!({
"service": [{
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}]
});
assert_eq!(
extract_pds_endpoint(&doc),
Some("https://pds.example.com".to_string())
);
}
#[test]
fn test_extract_pds_endpoint_multiple_services() {
let doc = serde_json::json!({
"service": [
{
"type": "AtprotoLabeler",
"serviceEndpoint": "https://labeler.example.com"
},
{
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}
]
});
assert_eq!(
extract_pds_endpoint(&doc),
Some("https://pds.example.com".to_string())
);
}
#[test]
fn test_extract_pds_endpoint_missing() {
let doc = serde_json::json!({
"service": [{
"type": "AtprotoLabeler",
"serviceEndpoint": "https://labeler.example.com"
}]
});
assert_eq!(extract_pds_endpoint(&doc), None);
}
#[test]
fn test_extract_pds_endpoint_no_services() {
let doc = serde_json::json!({});
assert_eq!(extract_pds_endpoint(&doc), None);
}
#[test]
fn test_validate_fetched_schema_ok() {
let doc = LexiconDoc {
lexicon: 1,
id: nsid("com.example.thing"),
id: "com.example.thing".to_string(),
defs: Default::default(),
};
assert!(validate_fetched_schema(&doc, &nsid("com.example.thing")).is_ok());
assert!(validate_fetched_schema(&doc, "com.example.thing").is_ok());
}
#[test]
fn test_validate_fetched_schema_id_mismatch() {
let doc = LexiconDoc {
lexicon: 1,
id: nsid("com.example.other"),
id: "com.example.other".to_string(),
defs: Default::default(),
};
let err = validate_fetched_schema(&doc, &nsid("com.example.thing")).unwrap_err();
let err = validate_fetched_schema(&doc, "com.example.thing").unwrap_err();
assert!(matches!(err, ResolveError::InvalidSchema(_)));
}
@@ -405,10 +396,10 @@ mod tests {
fn test_validate_fetched_schema_bad_version() {
let doc = LexiconDoc {
lexicon: 99,
id: nsid("com.example.thing"),
id: "com.example.thing".to_string(),
defs: Default::default(),
};
let err = validate_fetched_schema(&doc, &nsid("com.example.thing")).unwrap_err();
let err = validate_fetched_schema(&doc, "com.example.thing").unwrap_err();
assert!(matches!(err, ResolveError::InvalidSchema(_)));
}
}
+16 -17
View File
@@ -1,16 +1,15 @@
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use std::collections::HashMap;
use tranquil_types::Nsid;
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
pub struct LexiconDoc {
pub lexicon: u32,
pub id: Nsid,
pub id: String,
#[serde(default)]
pub defs: HashMap<String, LexDef>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum LexDef {
#[serde(rename = "record")]
@@ -35,14 +34,14 @@ pub enum LexDef {
PermissionSet {},
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
pub struct LexRecord {
#[serde(default)]
pub key: Option<String>,
pub record: LexObject,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
pub struct LexObject {
#[serde(default)]
pub required: Vec<String>,
@@ -52,7 +51,7 @@ pub struct LexObject {
pub properties: HashMap<String, LexProperty>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum LexProperty {
#[serde(rename = "string")]
@@ -79,7 +78,7 @@ pub enum LexProperty {
Object(LexObject),
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexString {
#[serde(default)]
@@ -102,7 +101,7 @@ pub struct LexString {
pub default: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
pub struct LexInteger {
#[serde(default)]
pub minimum: Option<i64>,
@@ -116,7 +115,7 @@ pub struct LexInteger {
pub const_value: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexBytes {
#[serde(default)]
@@ -125,7 +124,7 @@ pub struct LexBytes {
pub min_length: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexBlob {
#[serde(default)]
@@ -134,7 +133,7 @@ pub struct LexBlob {
pub max_size: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexArray {
pub items: Box<LexProperty>,
@@ -144,7 +143,7 @@ pub struct LexArray {
pub max_length: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
pub struct LexUnion {
#[serde(default)]
pub refs: Vec<String>,
@@ -152,14 +151,14 @@ pub struct LexUnion {
pub closed: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexRef {
#[serde(rename = "ref")]
pub reference: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Deserialize)]
pub enum StringFormat {
#[serde(rename = "did")]
Did,
@@ -204,6 +203,6 @@ pub fn parse_ref(reference: &str) -> ParsedRef<'_> {
}
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexStringDef {}
+31 -38
View File
@@ -47,7 +47,7 @@ fn ref_to_context_nsid<'a>(reference: &'a str, current_context: &'a str) -> &'a
pub fn validate_record(
registry: &LexiconRegistry,
nsid: &tranquil_types::Nsid,
nsid: &str,
value: &serde_json::Value,
) -> Result<(), LexValidationError> {
let doc = registry
@@ -527,11 +527,6 @@ mod tests {
use super::*;
use crate::test_schemas::test_registry;
use serde_json::json;
use tranquil_types::Nsid;
fn nsid(s: &str) -> Nsid {
s.parse().unwrap()
}
#[test]
fn test_validate_valid_record() {
@@ -541,7 +536,7 @@ mod tests {
"text": "Hello, world!",
"createdAt": "2024-01-01T00:00:00.000Z"
});
assert!(validate_record(&registry, &nsid("com.test.basic"), &record).is_ok());
assert!(validate_record(&registry, "com.test.basic", &record).is_ok());
}
#[test]
@@ -551,7 +546,7 @@ mod tests {
"$type": "com.test.basic",
"createdAt": "2024-01-01T00:00:00.000Z"
});
let err = validate_record(&registry, &nsid("com.test.basic"), &record).unwrap_err();
let err = validate_record(&registry, "com.test.basic", &record).unwrap_err();
assert!(matches!(err, LexValidationError::MissingRequired { .. }));
}
@@ -563,7 +558,7 @@ mod tests {
"text": "a".repeat(101),
"createdAt": "2024-01-01T00:00:00.000Z"
});
let err = validate_record(&registry, &nsid("com.test.basic"), &record).unwrap_err();
let err = validate_record(&registry, "com.test.basic", &record).unwrap_err();
assert!(matches!(err, LexValidationError::InvalidField { .. }));
}
@@ -575,7 +570,7 @@ mod tests {
"text": "a".repeat(51),
"createdAt": "2024-01-01T00:00:00.000Z"
});
let err = validate_record(&registry, &nsid("com.test.basic"), &record).unwrap_err();
let err = validate_record(&registry, "com.test.basic", &record).unwrap_err();
assert!(matches!(err, LexValidationError::InvalidField { .. }));
}
@@ -587,7 +582,7 @@ mod tests {
"$type": "com.test.profile",
"displayName": emoji_text
});
let err = validate_record(&registry, &nsid("com.test.profile"), &record).unwrap_err();
let err = validate_record(&registry, "com.test.profile", &record).unwrap_err();
assert!(matches!(err, LexValidationError::InvalidField { .. }));
}
@@ -600,7 +595,7 @@ mod tests {
"createdAt": "2024-01-01T00:00:00.000Z",
"count": 101
});
let err = validate_record(&registry, &nsid("com.test.basic"), &record).unwrap_err();
let err = validate_record(&registry, "com.test.basic", &record).unwrap_err();
assert!(matches!(err, LexValidationError::InvalidField { .. }));
let record_neg = json!({
@@ -609,7 +604,7 @@ mod tests {
"createdAt": "2024-01-01T00:00:00.000Z",
"count": -1
});
let err = validate_record(&registry, &nsid("com.test.basic"), &record_neg).unwrap_err();
let err = validate_record(&registry, "com.test.basic", &record_neg).unwrap_err();
assert!(matches!(err, LexValidationError::InvalidField { .. }));
}
@@ -622,7 +617,7 @@ mod tests {
"createdAt": "2024-01-01T00:00:00.000Z",
"count": 5.0
});
assert!(validate_record(&registry, &nsid("com.test.basic"), &record).is_ok());
assert!(validate_record(&registry, "com.test.basic", &record).is_ok());
let record_frac = json!({
"$type": "com.test.basic",
@@ -630,7 +625,7 @@ mod tests {
"createdAt": "2024-01-01T00:00:00.000Z",
"count": 5.5
});
let err = validate_record(&registry, &nsid("com.test.basic"), &record_frac).unwrap_err();
let err = validate_record(&registry, "com.test.basic", &record_frac).unwrap_err();
assert!(matches!(err, LexValidationError::InvalidField { .. }));
}
@@ -643,7 +638,7 @@ mod tests {
"createdAt": "2024-01-01T00:00:00.000Z",
"active": "not-a-bool"
});
let err = validate_record(&registry, &nsid("com.test.basic"), &record).unwrap_err();
let err = validate_record(&registry, "com.test.basic", &record).unwrap_err();
assert!(matches!(err, LexValidationError::InvalidField { .. }));
}
@@ -656,7 +651,7 @@ mod tests {
"createdAt": "2024-01-01T00:00:00.000Z",
"tags": ["a", "b", "c", "d"]
});
let err = validate_record(&registry, &nsid("com.test.basic"), &record).unwrap_err();
let err = validate_record(&registry, "com.test.basic", &record).unwrap_err();
assert!(matches!(err, LexValidationError::InvalidField { .. }));
}
@@ -669,7 +664,7 @@ mod tests {
"createdAt": "2024-01-01T00:00:00.000Z",
"tags": ["a", "b", "c"]
});
assert!(validate_record(&registry, &nsid("com.test.basic"), &record).is_ok());
assert!(validate_record(&registry, "com.test.basic", &record).is_ok());
}
#[test]
@@ -683,7 +678,7 @@ mod tests {
},
"createdAt": "2024-01-01T00:00:00.000Z"
});
assert!(validate_record(&registry, &nsid("com.test.withref"), &record).is_ok());
assert!(validate_record(&registry, "com.test.withref", &record).is_ok());
}
#[test]
@@ -696,7 +691,7 @@ mod tests {
},
"createdAt": "2024-01-01T00:00:00.000Z"
});
let err = validate_record(&registry, &nsid("com.test.withref"), &record).unwrap_err();
let err = validate_record(&registry, "com.test.withref", &record).unwrap_err();
assert!(matches!(err, LexValidationError::MissingRequired { .. }));
}
@@ -718,7 +713,7 @@ mod tests {
}
}
});
assert!(validate_record(&registry, &nsid("com.test.withreply"), &record).is_ok());
assert!(validate_record(&registry, "com.test.withreply", &record).is_ok());
}
#[test]
@@ -743,7 +738,7 @@ mod tests {
]
}
});
assert!(validate_record(&registry, &nsid("com.test.withreply"), &record).is_ok());
assert!(validate_record(&registry, "com.test.withreply", &record).is_ok());
let bad_embed = json!({
"$type": "com.test.withreply",
@@ -755,7 +750,7 @@ mod tests {
}
});
assert!(
validate_record(&registry, &nsid("com.test.withreply"), &bad_embed).is_err(),
validate_record(&registry, "com.test.withreply", &bad_embed).is_err(),
"union with bare NSID ref must validate the matched schema"
);
}
@@ -857,7 +852,7 @@ mod tests {
}
}
});
assert!(validate_record(&registry, &nsid("com.test.withreply"), &record).is_ok());
assert!(validate_record(&registry, "com.test.withreply", &record).is_ok());
let bad_external = json!({
"$type": "com.test.withreply",
@@ -871,7 +866,7 @@ mod tests {
}
});
assert!(
validate_record(&registry, &nsid("com.test.withreply"), &bad_external).is_err(),
validate_record(&registry, "com.test.withreply", &bad_external).is_err(),
"local #ref in cross-schema union must resolve against the correct schema"
);
}
@@ -887,7 +882,7 @@ mod tests {
{ "$type": "com.test.withgate#disableRule" }
]
});
assert!(validate_record(&registry, &nsid("com.test.withgate"), &record).is_ok());
assert!(validate_record(&registry, "com.test.withgate", &record).is_ok());
}
#[test]
@@ -898,14 +893,14 @@ mod tests {
"subject": "did:plc:abc123",
"createdAt": "2024-01-01T00:00:00.000Z"
});
assert!(validate_record(&registry, &nsid("com.test.withdid"), &record).is_ok());
assert!(validate_record(&registry, "com.test.withdid", &record).is_ok());
let bad_did = json!({
"$type": "com.test.withdid",
"subject": "not-a-did",
"createdAt": "2024-01-01T00:00:00.000Z"
});
assert!(validate_record(&registry, &nsid("com.test.withdid"), &bad_did).is_err());
assert!(validate_record(&registry, "com.test.withdid", &bad_did).is_err());
}
#[test]
@@ -916,15 +911,14 @@ mod tests {
"name": "test",
"value": null
});
assert!(validate_record(&registry, &nsid("com.test.nullable"), &record).is_ok());
assert!(validate_record(&registry, "com.test.nullable", &record).is_ok());
}
#[test]
fn test_validate_unknown_lexicon() {
let registry = test_registry();
let record = json!({"$type": "com.example.nonexistent"});
let err =
validate_record(&registry, &nsid("com.example.nonexistent"), &record).unwrap_err();
let err = validate_record(&registry, "com.example.nonexistent", &record).unwrap_err();
assert!(matches!(err, LexValidationError::LexiconNotFound(_)));
}
@@ -937,14 +931,14 @@ mod tests {
"createdAt": "2024-01-01T00:00:00.000Z",
"unknownField": "this is fine"
});
assert!(validate_record(&registry, &nsid("com.test.basic"), &record).is_ok());
assert!(validate_record(&registry, "com.test.basic", &record).is_ok());
}
#[test]
fn test_validate_no_required_fields() {
let registry = test_registry();
let record = json!({"$type": "com.test.profile"});
assert!(validate_record(&registry, &nsid("com.test.profile"), &record).is_ok());
assert!(validate_record(&registry, "com.test.profile", &record).is_ok());
}
#[test]
@@ -954,7 +948,7 @@ mod tests {
"$type": "com.test.profile",
"displayName": "a".repeat(11)
});
let err = validate_record(&registry, &nsid("com.test.profile"), &record).unwrap_err();
let err = validate_record(&registry, "com.test.profile", &record).unwrap_err();
assert!(matches!(err, LexValidationError::InvalidField { .. }));
}
@@ -967,7 +961,7 @@ mod tests {
"value": null
});
assert!(
validate_record(&registry, &nsid("com.test.requirednullable"), &record).is_ok(),
validate_record(&registry, "com.test.requirednullable", &record).is_ok(),
"a field that is both required and nullable must accept null values"
);
}
@@ -981,8 +975,7 @@ mod tests {
});
assert!(
matches!(
validate_record(&registry, &nsid("com.test.requirednullable"), &record)
.unwrap_err(),
validate_record(&registry, "com.test.requirednullable", &record).unwrap_err(),
LexValidationError::MissingRequired { .. }
),
"a field that is required+nullable must still be present (even if null)"
@@ -998,7 +991,7 @@ mod tests {
"value": "hello"
});
assert!(
validate_record(&registry, &nsid("com.test.requirednullable"), &record).is_ok(),
validate_record(&registry, "com.test.requirednullable", &record).is_ok(),
"a field that is required+nullable must accept non-null values"
);
}
@@ -71,10 +71,10 @@ async fn test_resolve_pds_endpoint_from_plc() {
.mount(&plc_server)
.await;
let endpoint = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri()))
let endpoint = resolve_pds_endpoint(did, Some(&plc_server.uri()))
.await
.unwrap();
assert_eq!(endpoint.as_str(), "https://pds.example.com");
assert_eq!(endpoint, "https://pds.example.com");
}
#[tokio::test]
@@ -94,7 +94,7 @@ async fn test_resolve_pds_endpoint_no_pds_service() {
.mount(&plc_server)
.await;
let result = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri())).await;
let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await;
assert!(matches!(result, Err(ResolveError::NoPdsEndpoint { .. })));
}
@@ -109,13 +109,13 @@ async fn test_resolve_pds_endpoint_plc_not_found() {
.mount(&plc_server)
.await;
let result = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri())).await;
let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_resolve_pds_endpoint_unsupported_did_method() {
let result = resolve_pds_endpoint(&"did:key:z6MkTest".parse().unwrap(), None).await;
let result = resolve_pds_endpoint("did:key:z6MkTest", None).await;
assert!(matches!(result, Err(ResolveError::DidResolution { .. })));
}
@@ -130,17 +130,14 @@ 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,10 +146,10 @@ async fn test_resolve_pds_endpoint_multiple_services_picks_pds() {
.mount(&plc_server)
.await;
let endpoint = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri()))
let endpoint = resolve_pds_endpoint(did, Some(&plc_server.uri()))
.await
.unwrap();
assert_eq!(endpoint.as_str(), "https://pds.example.com");
assert_eq!(endpoint, "https://pds.example.com");
}
#[tokio::test]
@@ -170,13 +167,9 @@ async fn test_fetch_schema_from_pds_success() {
.mount(&pds_server)
.await;
let doc = fetch_schema_from_pds(
&pds_server.uri().parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
.await
.unwrap();
let doc = fetch_schema_from_pds(&pds_server.uri(), did, nsid)
.await
.unwrap();
assert_eq!(doc.id, nsid);
assert_eq!(doc.lexicon, 1);
assert!(doc.defs.contains_key("main"));
@@ -197,12 +190,7 @@ async fn test_fetch_schema_missing_value_field() {
.mount(&pds_server)
.await;
let result = fetch_schema_from_pds(
&pds_server.uri().parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
.await;
let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await;
assert!(matches!(result, Err(ResolveError::SchemaFetch { .. })));
}
@@ -224,12 +212,7 @@ async fn test_fetch_schema_invalid_lexicon_json() {
.mount(&pds_server)
.await;
let result = fetch_schema_from_pds(
&pds_server.uri().parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
.await;
let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await;
assert!(matches!(result, Err(ResolveError::InvalidSchema(_))));
}
@@ -257,13 +240,9 @@ async fn test_full_chain_plc_to_schema() {
.mount(&pds_server)
.await;
let doc = resolve_lexicon_from_did(
&nsid.parse().unwrap(),
&did.parse().unwrap(),
Some(&plc_server.uri()),
)
.await
.unwrap();
let doc = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri()))
.await
.unwrap();
assert_eq!(doc.id, nsid);
assert_eq!(doc.lexicon, 1);
}
@@ -292,12 +271,7 @@ async fn test_full_chain_schema_id_mismatch_rejected() {
.mount(&pds_server)
.await;
let result = resolve_lexicon_from_did(
&nsid.parse().unwrap(),
&did.parse().unwrap(),
Some(&plc_server.uri()),
)
.await;
let result = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri())).await;
assert!(matches!(result, Err(ResolveError::InvalidSchema(_))));
}
@@ -330,12 +304,7 @@ async fn test_full_chain_bad_lexicon_version_rejected() {
.mount(&pds_server)
.await;
let result = resolve_lexicon_from_did(
&nsid.parse().unwrap(),
&did.parse().unwrap(),
Some(&plc_server.uri()),
)
.await;
let result = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri())).await;
assert!(matches!(result, Err(ResolveError::InvalidSchema(_))));
}
@@ -354,13 +323,9 @@ async fn test_pds_trailing_slash_handled() {
.await;
let pds_url_with_slash = format!("{}/", pds_server.uri());
let doc = fetch_schema_from_pds(
&pds_url_with_slash.parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
.await
.unwrap();
let doc = fetch_schema_from_pds(&pds_url_with_slash, did, nsid)
.await
.unwrap();
assert_eq!(doc.id, nsid);
}
@@ -379,12 +344,7 @@ async fn test_fetch_schema_error_status_gives_meaningful_error() {
.mount(&pds_server)
.await;
let result = fetch_schema_from_pds(
&pds_server.uri().parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
.await;
let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await;
let err = result.unwrap_err();
let err_msg = err.to_string();
assert!(
@@ -405,6 +365,6 @@ async fn test_plc_server_timeout() {
.mount(&plc_server)
.await;
let result = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri())).await;
let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await;
assert!(result.is_err());
}
-8
View File
@@ -11,7 +11,6 @@ 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 }
@@ -34,10 +33,3 @@ 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,5 +1,4 @@
use super::*;
use tranquil_types::Nsid;
#[derive(Debug, Serialize)]
pub struct ScopeInfo {
@@ -9,49 +8,18 @@ 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)]
pub struct ConsentResponse {
pub request_uri: String,
pub client_id: ClientId,
pub client_id: String,
pub client_name: Option<String>,
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,
pub did: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -120,7 +88,7 @@ pub async fn consent_get(
};
let did = flow_with_user.did().clone();
let client_cache = &state.client_metadata_cache;
let client_cache = ClientMetadataCache::new(3600);
let client_metadata = client_cache
.get(&request_data.parameters.client_id)
.await
@@ -148,69 +116,61 @@ pub async fn consent_get(
None
};
let authority = match delegation_grant.as_ref() {
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
None => scope_resolution::Authority::FullSelf,
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 effective =
scope_resolution::resolve_effective_scopes(&*state.cache, requested_scope_str, authority)
.await;
let requested_scopes: Vec<&str> = effective.permitted.split_whitespace().collect();
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 consent_client_id = ClientId::from(request_data.parameters.client_id.clone());
let preferences = state
.repos
.oauth
.get_scope_preferences(&did, &request_data.parameters.client_id)
.get_scope_preferences(&did, &consent_client_id)
.await
.unwrap_or_default();
let pref_map: std::collections::HashMap<_, _> = preferences
.iter()
.map(|p| (p.scope.as_str(), p.granted))
.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 requested_scope_strings: Vec<String> =
requested_scopes.iter().map(|s| s.to_string()).collect();
let show_consent = should_show_consent(
state.repos.oauth.as_ref(),
&did,
&request_data.parameters.client_id,
&presented_item_strings,
&consent_client_id,
&requested_scope_strings,
)
.await
.unwrap_or(true);
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
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 {
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 {
"AT Protocol baseline scope (permissions determined by selected options below)"
.to_string()
} else {
def.description.to_string()
};
let name = if described == "atproto" && has_granular_scopes {
let name = if *scope == "atproto" && has_granular_scopes {
"AT Protocol Access".to_string()
} else {
def.display_name.to_string()
@@ -221,76 +181,33 @@ pub async fn consent_get(
desc,
name,
)
} else if described.starts_with("ref:") {
} else if scope.starts_with("ref:") {
(
"Reference".to_string(),
false,
"Referenced scope".to_string(),
described.to_string(),
scope.to_string(),
)
} else {
(
"Other".to_string(),
false,
format!("Access to {}", described),
described.to_string(),
format!("Access to {}", scope),
scope.to_string(),
)
};
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,
let granted = pref_map.get(*scope).copied();
ScopeInfo {
scope: scope.to_string(),
category,
required,
description,
display_name,
granted,
}
})
.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
@@ -339,10 +256,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(),
did: did.to_string(),
handle: account_handle,
is_delegation,
controller_did: controller_did_resp,
@@ -428,53 +343,16 @@ pub async fn consent_post(
None => None,
};
let authority = match delegation_grant.as_ref() {
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
None => scope_resolution::Authority::FullSelf,
};
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)),
let effective_scope_str = if let Some(ref grant) = delegation_grant {
tranquil_pds::delegation::intersect_scopes(
original_scope_str,
grant.granted_scopes.as_str(),
)
.collect();
let atproto_was_requested = presented_items.iter().any(|s| s == "atproto");
} else {
original_scope_str.to_string()
};
let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
let atproto_was_requested = requested_scopes.contains(&"atproto");
if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) {
return json_error(
StatusCode::BAD_REQUEST,
@@ -482,8 +360,7 @@ pub async fn consent_post(
"The atproto scope was requested and must be approved",
);
}
let mut final_approved: Vec<String> = form.approved_scopes.clone();
final_approved.retain(|s| presented_items.iter().any(|p| p == s) || s == "atproto");
let final_approved: Vec<String> = form.approved_scopes.clone();
if final_approved.is_empty() {
return json_error(
StatusCode::BAD_REQUEST,
@@ -501,17 +378,18 @@ pub async fn consent_post(
);
}
if form.remember {
let preferences: Vec<ScopePreference> = presented_items
let preferences: Vec<ScopePreference> = requested_scopes
.iter()
.map(|s| ScopePreference {
scope: s.clone(),
granted: form.approved_scopes.contains(s),
scope: s.to_string(),
granted: form.approved_scopes.contains(&s.to_string()),
})
.collect();
let consent_post_client_id = ClientId::from(request_data.parameters.client_id.clone());
let _ = state
.repos
.oauth
.upsert_scope_preferences(&did, &request_data.parameters.client_id, &preferences)
.upsert_scope_preferences(&did, &consent_post_client_id, &preferences)
.await;
}
if let Err(e) = state
@@ -522,15 +400,20 @@ pub async fn consent_post(
{
tracing::warn!("Failed to update request scope: {:?}", e);
}
let code = AuthorizationCode::generate();
let code = Code::generate();
let consent_post_device_id = request_data
.device_id
.as_ref()
.map(|d| DeviceIdType::new(d.0.clone()));
let consent_post_code = AuthorizationCode::from(code.0.clone());
if state
.repos
.oauth
.update_authorization_request(
&consent_post_request_id,
&did,
request_data.device_id.as_ref(),
&code,
consent_post_device_id.as_ref(),
&consent_post_code,
)
.await
.is_err()
@@ -544,7 +427,7 @@ pub async fn consent_post(
let redirect_uri = &request_data.parameters.redirect_uri;
let intermediate_url = build_intermediate_redirect_url(
redirect_uri,
code.as_str(),
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.map(|m| m.as_str()),
);
@@ -80,7 +80,7 @@ pub async fn authorize_get(
"Authorization request has expired. Please start a new request.",
);
}
let client_cache = &state.client_metadata_cache;
let client_cache = ClientMetadataCache::new(3600);
let client_name = client_cache
.get(&request_data.parameters.client_id)
.await
@@ -105,12 +105,12 @@ pub async fn authorize_get(
let normalized = NormalizedLoginIdentifier::normalize(login_hint, hostname_for_handles);
tracing::info!(normalized = %normalized, "Normalized login_hint");
let hint_identifier = tranquil_types::AtIdentifier::new(normalized.as_str()).ok();
let hint_lookup = match hint_identifier {
Some(ref id) => state.repos.user.get_login_check_by_identifier(id).await,
None => Ok(None),
};
match hint_lookup {
match state
.repos
.user
.get_login_check_by_identifier(normalized.as_str())
.await
{
Ok(Some(user)) => {
tracing::info!(did = %user.did, has_password = user.password_hash.is_some(), "Found user for login_hint");
let is_delegated = state
@@ -399,12 +399,12 @@ pub async fn authorize_post(
pds_hostname = %tranquil_config::get().server.hostname,
"Normalized username for lookup"
);
let login_identifier = tranquil_types::AtIdentifier::new(normalized_username.as_str()).ok();
let login_lookup = match login_identifier {
Some(ref id) => state.repos.user.get_login_info_by_identifier(id).await,
None => Ok(None),
};
let user = match login_lookup {
let user = match state
.repos
.user
.get_login_info_by_identifier(normalized_username.as_str())
.await
{
Ok(Some(u)) => u,
Ok(None) => {
let _ = bcrypt::verify(
@@ -478,7 +478,7 @@ pub async fn authorize_post(
}
let password_valid = match &user.password_hash {
Some(hash) => match bcrypt::verify(&form.password, hash.as_str()) {
Some(hash) => match bcrypt::verify(&form.password, hash) {
Ok(valid) => valid,
Err(_) => {
return show_login_error("An error occurred. Please try again.", json_response);
@@ -606,13 +606,14 @@ pub async fn authorize_post(
}
}
}
let mut device_id: Option<DeviceId> = extract_device_cookie(&headers);
let mut device_id: Option<DeviceIdType> = extract_device_cookie(&headers);
let mut new_cookie: Option<String> = None;
if form.remember_device {
let final_device_id = if let Some(existing_id) = &device_id {
existing_id.clone()
} else {
let new_id = DeviceId::generate();
let new_device_id_typed = DeviceIdType::new(new_id.0.clone());
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
@@ -622,14 +623,14 @@ pub async fn authorize_post(
if state
.repos
.oauth
.create_device(&new_id, &device_data)
.create_device(&new_device_id_typed, &device_data)
.await
.is_ok()
{
new_cookie = Some(make_device_cookie(&new_id));
device_id = Some(new_id.clone());
new_cookie = Some(make_device_cookie(&new_device_id_typed));
device_id = Some(new_device_id_typed.clone());
}
new_id
new_device_id_typed
};
let _ = state
.repos
@@ -656,10 +657,11 @@ pub async fn authorize_post(
.split_whitespace()
.map(|s| s.to_string())
.collect();
let client_id_typed = ClientId::from(request_data.parameters.client_id.clone());
let needs_consent = should_show_consent(
state.repos.oauth.as_ref(),
&user.did,
&request_data.parameters.client_id,
&client_id_typed,
&requested_scopes,
)
.await
@@ -689,8 +691,9 @@ pub async fn authorize_post(
}
return redirect_see_other(&consent_url);
}
let code = AuthorizationCode::generate();
let code = Code::generate();
let auth_post_device_id = device_id.clone();
let auth_post_code = AuthorizationCode::from(code.0.clone());
if state
.repos
.oauth
@@ -698,7 +701,7 @@ pub async fn authorize_post(
&form_request_id,
&user.did,
auth_post_device_id.as_ref(),
&code,
&auth_post_code,
)
.await
.is_err()
@@ -708,7 +711,7 @@ pub async fn authorize_post(
if json_response {
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
code.as_str(),
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.map(|m| m.as_str()),
);
@@ -725,7 +728,7 @@ pub async fn authorize_post(
} else {
let redirect_url = build_success_redirect(
&request_data.parameters.redirect_uri,
code.as_str(),
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.map(|m| m.as_str()),
);
@@ -866,6 +869,7 @@ pub async fn authorize_select(
.into_response();
}
let has_totp = tranquil_api::server::has_totp_enabled(&state, &did).await;
let select_early_device_typed = device_id.clone();
if has_totp {
let device_is_trusted =
tranquil_api::server::is_device_trusted(state.repos.oauth.as_ref(), &device_id, &did)
@@ -874,7 +878,7 @@ pub async fn authorize_select(
if state
.repos
.oauth
.set_authorization_did(&select_request_id, &did, Some(&device_id))
.set_authorization_did(&select_request_id, &did, Some(&select_early_device_typed))
.await
.is_err()
{
@@ -938,16 +942,17 @@ pub async fn authorize_select(
}
}
}
let select_device_typed = device_id.clone();
let _ = state
.repos
.oauth
.upsert_account_device(&did, &device_id)
.upsert_account_device(&did, &select_device_typed)
.await;
if state
.repos
.oauth
.set_authorization_did(&select_request_id, &did, Some(&device_id))
.set_authorization_did(&select_request_id, &did, Some(&select_device_typed))
.await
.is_err()
{
@@ -14,7 +14,8 @@ 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, DeviceData, DeviceId, OAuthError, Prompt, SessionId, db::should_show_consent,
AuthFlow, ClientMetadataCache, Code, DeviceData, DeviceId, OAuthError, Prompt, SessionId,
db::should_show_consent, scopes::expand_include_scopes,
};
use tranquil_pds::rate_limit::{
OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit,
@@ -23,7 +24,7 @@ use tranquil_pds::rate_limit::{
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Handle, PlainPassword};
use tranquil_pds::util::ClientIp;
use tranquil_types::{AuthorizationCode, ClientId, RequestId};
use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, RequestId};
use urlencoding::encode as url_encode;
const DEVICE_COOKIE_NAME: &str = "oauth_device_id";
@@ -110,7 +111,8 @@ fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
}
fn make_device_cookie(device_id: &tranquil_types::DeviceId) -> String {
let signed_value = tranquil_pds::config::AuthConfig::get().sign_device_cookie(device_id);
let signed_value =
tranquil_pds::config::AuthConfig::get().sign_device_cookie(device_id.as_str());
format!(
"{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000",
DEVICE_COOKIE_NAME, signed_value
@@ -126,7 +128,7 @@ pub struct AuthorizeQuery {
#[derive(Debug, Serialize)]
pub struct AuthorizeResponse {
pub client_id: ClientId,
pub client_id: String,
pub client_name: Option<String>,
pub scope: Option<String>,
pub redirect_uri: String,
@@ -299,7 +301,6 @@ mod consent;
mod login;
mod passkey;
mod registration;
pub mod scope_resolution;
mod two_factor;
pub use consent::*;
@@ -19,10 +19,11 @@ pub async fn check_user_has_passkeys(
let bare_identifier =
BareLoginIdentifier::from_identifier(&query.identifier, hostname_for_handles);
let user = match tranquil_types::AtIdentifier::new(bare_identifier.as_str()) {
Ok(ref id) => state.repos.user.get_login_check_by_identifier(id).await,
Err(_) => Ok(None),
};
let user = state
.repos
.user
.get_login_check_by_identifier(bare_identifier.as_str())
.await;
let has_passkeys = match user {
Ok(Some(u)) => tranquil_api::server::has_passkeys_for_user(&state, &u.did).await,
@@ -51,10 +52,11 @@ pub async fn check_user_security_status(
let normalized_identifier =
NormalizedLoginIdentifier::normalize(&query.identifier, hostname_for_handles);
let user = match tranquil_types::AtIdentifier::new(normalized_identifier.as_str()) {
Ok(ref id) => state.repos.user.get_login_check_by_identifier(id).await,
Err(_) => Ok(None),
};
let user = state
.repos
.user
.get_login_check_by_identifier(normalized_identifier.as_str())
.await;
let (has_passkeys, has_totp, has_password, is_delegated, did): (
bool,
@@ -236,11 +238,12 @@ async fn passkey_start_named(
let normalized_username =
NormalizedLoginIdentifier::normalize(&identifier, hostname_for_handles);
let passkey_lookup = match tranquil_types::AtIdentifier::new(normalized_username.as_str()) {
Ok(ref id) => state.repos.user.get_login_info_by_identifier(id).await,
Err(_) => Ok(None),
};
let user = match passkey_lookup {
let user = match state
.repos
.user
.get_login_info_by_identifier(normalized_username.as_str())
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return (
@@ -624,10 +627,11 @@ pub async fn passkey_finish(
.map(|s| s.to_string())
.collect();
let passkey_finish_client_id = ClientId::from(request_data.parameters.client_id.clone());
let needs_consent = should_show_consent(
state.repos.oauth.as_ref(),
&did,
&request_data.parameters.client_id,
&passkey_finish_client_id,
&requested_scopes,
)
.await
@@ -641,8 +645,9 @@ pub async fn passkey_finish(
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
}
let code = AuthorizationCode::generate();
let code = Code::generate();
let passkey_final_device_id = device_id.clone();
let passkey_final_code = AuthorizationCode::from(code.0.clone());
if state
.repos
.oauth
@@ -650,7 +655,7 @@ pub async fn passkey_finish(
&passkey_finish_request_id,
&did,
passkey_final_device_id.as_ref(),
&code,
&passkey_final_code,
)
.await
.is_err()
@@ -667,7 +672,7 @@ pub async fn passkey_finish(
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
code.as_str(),
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.map(|m| m.as_str()),
);
@@ -12,16 +12,7 @@ pub async fn register_complete(
_rate_limit: OAuthRateLimited<OAuthRegisterCompleteLimit>,
Json(form): Json<RegisterCompleteInput>,
) -> Response {
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 did = Did::from(form.did.clone());
let request_id = RequestId::from(form.request_uri.clone());
let request_data = match state
@@ -106,12 +97,12 @@ pub async fn register_complete(
}
if let Some(existing_did) = &request_data.did
&& existing_did != &did
&& existing_did != &form.did
{
tracing::warn!(
request_uri = %form.request_uri,
existing_did = %existing_did,
attempted_did = %did,
attempted_did = %form.did,
"register_complete attempted with different DID than already bound"
);
return (
@@ -149,13 +140,13 @@ pub async fn register_complete(
};
let mut password_valid = password_hashes.iter().fold(false, |acc, hash| {
acc | bcrypt::verify(&form.app_password, hash.as_str()).unwrap_or(false)
acc | bcrypt::verify(&form.app_password, hash).unwrap_or(false)
});
if !password_valid
&& let Ok(Some(account_hash)) = state.repos.user.get_password_hash_by_did(&did).await
{
password_valid = bcrypt::verify(&form.app_password, account_hash.as_str()).unwrap_or(false);
password_valid = bcrypt::verify(&form.app_password, &account_hash).unwrap_or(false);
}
if !password_valid {
@@ -246,10 +237,11 @@ pub async fn register_complete(
.split_whitespace()
.map(|s| s.to_string())
.collect();
let client_id_typed = ClientId::from(request_data.parameters.client_id.clone());
let needs_consent = should_show_consent(
state.repos.oauth.as_ref(),
&did,
&request_data.parameters.client_id,
&client_id_typed,
&requested_scopes,
)
.await
@@ -268,11 +260,12 @@ pub async fn register_complete(
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
}
let code = AuthorizationCode::generate();
let code = Code::generate();
let auth_code = AuthorizationCode::from(code.0.clone());
if let Err(e) = state
.repos
.oauth
.update_authorization_request(&request_id, &did, None, &code)
.update_authorization_request(&request_id, &did, None, &auth_code)
.await
{
tracing::error!(
@@ -299,7 +292,7 @@ pub async fn register_complete(
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
code.as_str(),
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.map(|m| m.as_str()),
);
@@ -322,7 +315,8 @@ pub async fn establish_session(
(id, None)
}
None => {
let device_id = DeviceId::generate();
let new_id = DeviceId::generate();
let device_typed = DeviceIdType::new(new_id.0.clone());
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
@@ -333,7 +327,7 @@ pub async fn establish_session(
if let Err(e) = state
.repos
.oauth
.create_device(&device_id, &device_data)
.create_device(&device_typed, &device_data)
.await
{
tracing::error!(error = ?e, "Failed to create device");
@@ -350,7 +344,7 @@ pub async fn establish_session(
if let Err(e) = state
.repos
.oauth
.upsert_account_device(did, &device_id)
.upsert_account_device(did, &device_typed)
.await
{
tracing::error!(error = ?e, "Failed to link device to account");
@@ -364,8 +358,8 @@ pub async fn establish_session(
.into_response();
}
let cookie = make_device_cookie(&device_id);
(device_id, Some(cookie))
let cookie = make_device_cookie(&device_typed);
(device_typed, Some(cookie))
}
};

Some files were not shown because too many files have changed in this diff Show More