Compare commits

..
Author SHA1 Message Date
nelind 72fa88d79a chore: bump version to 0.6.6 2026-07-26 00:25:56 +02:00
nelindandTangled 596b9b15fd feat(docs): explain our definition of "correct PDS implementation" 2026-07-25 18:31:06 +03:00
nelindandTangled 59934cc184 feat: add bsky and bsky-support cargo features to manage bsky specific code 2026-07-25 18:31:06 +03:00
nelindandTangled 34a47e6e5a chore: clean up Cargo.toml files with cargo-shear and remove default feature on lib crates 2026-07-25 18:31:06 +03:00
LewisandTangled aca78bb8d3 scopes: tweak tests, add translations
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-07-25 11:08:27 +03:00
LewisandTangled 25d7d24d4e types: drop unchecked construction from validated newtypes
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled b6274bb3c4 api: validated newtypes from their checked constructors
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled 2a96a8f420 server: report absent repo rev instead of an empty one
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled 9ad70bda9e sync: parse xrpc query params into real types
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled c3a8240154 tests: assert user_blocks matches reachable set after every write
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled 1e2311f8fc repo: keep user_blocks equal to the reachable block set
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled 17905115d8 store: revalidate stored mutation sets on replay
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled 6ed568dbfb store: rebuild derived indexes when stored format version is older
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled d00d72895a store: record-by-cid reverse index
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled 2f1e22a950 store: add record-by-cid key shapes & chunked scan helpers
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 946cb9740f store: warn & skip instead of failing on unreadable event payloads
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 0c7cccb14c invite: require owning account for generated codes
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 4f37ac26cd store: typed revs thru metastore keys & requests
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled bbe9f6f3b3 db: decode sequenced event rows leniently
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 4e3b2f2af4 handle: reject handles whose tld can never resolve
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 932b0c07d4 db: make stored handle optional when it no longer parses
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 01a71ece7c db: check column conversions when mapping rows
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 8abb6cc741 db: name column behind invalid value
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 2bfea64ffc api: add xrpc query extractor
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled e931268f7e types: validate on construction & normalize what gets stored
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 14a086cb13 store: fix inline block span parsing in event sidecars
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
TrezyandTangled 7244551ae1 refactor: clean up property names and document stringy values
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled 01d93e44e7 fix: show the user which scopes will not be granted based on delegation
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled fa50d7d54e fix: prevent rpc scopes from escaping permission set namespace
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled 19e7ec29f5 fix: prevent transient permission set publishers from causing auth refresh failures
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled 311fbfcb86 feat: add handling for more scope failures
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled 515c058006 refactor: use Nsid newtype instead of strings
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled aca6dd926e chore: clean up dead code
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled 9c6730579e feat: display bundled permission-sets on consent screen
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled 348ac887fc fix: use JWT scopes as source-of-truth for access and refresh tokens
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:26 +03:00
TrezyandTangled ecdda4c555 feat: cache expanded permission sets 2026-07-24 20:11:26 +03:00
TrezyandTangled f17adc6f88 refactor: use tranquil-scopes instead of bespoke scope handling in delegation auth
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:26 +03:00
LewisandTangled 00ca223b5f identity: force did:web signkey check to #atproto verification
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-23 10:34:21 +03:00
nelind 9ccec274aa chore: nix flake update 2026-07-17 02:14:30 +02:00
nelind f2754efeb2 chore(nix): use fetcherVersion 4 of fetchPnmpDeps 2026-07-17 02:14:30 +02:00
nelindandTangled 4416f50c87 feat(config): add more default crawlers/relays 2026-07-15 08:58:35 +03:00
170 changed files with 8187 additions and 2528 deletions
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT r.record_cid AS \"record_cid!\"\n FROM records r\n WHERE r.repo_id = $1\n AND r.record_cid = ANY($2)\n AND NOT EXISTS (\n SELECT 1 FROM UNNEST($3::text[], $4::text[]) AS k(collection, rkey)\n WHERE k.collection = r.collection AND k.rkey = r.rkey\n )\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "record_cid!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid",
"TextArray",
"TextArray",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "105807a41c7337e7aa46bace29ab613030fd4fbf6845baddab9c0b2009972c02"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT block_cid AS \"block_cid!\" FROM user_blocks\n WHERE user_id = $1 AND repo_rev > $2\n ORDER BY repo_rev ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "block_cid!",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "30570ed3866840d1258c8768a5c8a23ade40700c05ddbbf7fc4f64bfa95b1ed4"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, email, email_verified, handle\n FROM users\n WHERE LOWER(email) = $1",
"query": "SELECT id, did, email, email_verified\n FROM users\n WHERE LOWER(email) = $1",
"describe": {
"columns": [
{
@@ -22,11 +22,6 @@
"ordinal": 3,
"name": "email_verified",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "handle",
"type_info": "Text"
}
],
"parameters": {
@@ -38,9 +33,8 @@
false,
false,
true,
false,
false
]
},
"hash": "b230a27fce54d4f79de4ffcc754bf6c7e5a889623e64cdd7aaec791b3553ed83"
"hash": "55b5e5cb13e09c4261e0452ef7e21240b504096666692c8fbd5c7dcd58607bc7"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT block_cid AS \"block_cid!\" FROM user_blocks\n WHERE user_id = $1\n ORDER BY repo_rev ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "block_cid!",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "85cc0cd1e62a30fa67d415b7a01164f962a422513e8f0737553321fd9987a56c"
}
+55
View File
@@ -4,6 +4,7 @@
In order of importance:
- If your change involves how Tranquil implements atproto make sure its correct! See more below.
- **You must run your change! Every contribution that says "here's xyz. untested." does not help the project.**
- Relevant tests to your PR must pass. The whole suite doesn't have to be proven to have run, because there are a *ton* of tests and they're quite heavy, but hopefully there are existing tests for whatever you're PRing, and if there aren't, please add those too.
- Run cargo fmt :P
@@ -16,6 +17,60 @@ Things that would also be nice but aren't like, a pain in our side:
- Big changes should be stacked PRs that are broken up into digestible pieces. Those stacked PRs should hopefully be able to be merged individually if necessary.
### How we define a "correct" PDS implementation
The atproto specs are notoriously imprecise, ambiguious,
lacks specifications for large parts of the protocol and network (even including what implementing a PDS entails!)
and is generally none specific.
This is bad.
We won't waste time here describing all the ways in which that is problematic,
the important thing for Tranquil is that this means that "follows spec" is not sufficient to describe a "correct" PDS implementation.
Thus we need to come up with a description of "correct".
In order of importance the following rules describe what "correct" means for Tranquil:
- The specs take precedence.
If the spec *is* specific enough then follow it.
Even if the reference implementation doesn't.
- If the specs aren't sufficiently specific
rely on the reference implementation, potential supporting documents or discussions,
and/or community sentiment or common sense.
If the matter is still debated and/or PBCs opinion differs from community sentiment we generally side with the community.
- Examples here include what features and APIs to implement,
here we look at what the reference implementation implements
as well as https://github.com/bluesky-social/atproto/discussions/2350 as a supporting document.
Another example is whether `include` scopes are allowed to use a `*` `aud` parameter.
Discussion here has happened in https://github.com/bluesky-social/atproto/issues/4490.
PBC has voiced an opinion that this should be disallowed,
community sentiment seems to strongly lean to allowing it. Tranquil allows it.
- Please mark locations like this with a `// SPECAMB: ...` comment explaining the ambiguity
and what parts of the reference implementation and/or supporting documents have been used as reference.
- If the reference implementation has behaviour that is only ever relevant for the Bluesky application.
Implementions of such behaviour **must** be gated behind a `bsky-support` cargo feature of the implementing crate.
- Examples here include bluesky feedgen specific service proxying behaviour,
the `app.bsky.actor.getPreferences` and `app.bsky.actor.putPreferences` APIs,
and special handling of the `X-BSKY-TOPICS` HTTP header during service proxying.
- Please add a comment next to these implementations with an explanation of the behaviour.
- Most of these behaviours are required for proper functioning of the official Bluesky client, though not all.
If the behaviour isn't required for the official client consider not implementing it.
- One such behaviour that we have a *hard rule* to never implement is default proxying to a configured Bluesky appview
for `app.bsky.*` APIs and as fallback for `com.atproto.repo.getRecord`.
Many third-party Bluesky clients rely on this behaviour, the official client used to do the same but does not anymore.
Third-party clients breaking because they don't specify an `atproto-proxy` header is thus *not* a Tranquil bug but a bug in said clients.
- Bluesky is the only application that will ever recieve application specific behaviour like this.
It does so only because such a big section of atproto usage is Bluesky
and because Bluesky is the only application that can practically rely on application specific behaviour.
Application specific behaviour for other applications may still be added to Tranquil if such behaviour is a Tranquil feature,
for example for Tranquils rudimentary banned content moderation feature,
and not something said application relies on for proper functioning.
There is bound to be edge cases that these rules don't fully cover.
Here common sense, community sentiment, furthering the goals of atproto itself, and ultimately maintainer opinion take precedence over support for any individual applicaion.
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
+30 -75
View File
@@ -2486,7 +2486,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@@ -7630,9 +7629,8 @@ dependencies = [
[[package]]
name = "tranquil-api"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"anyhow",
"axum",
"backon",
"base32",
@@ -7666,22 +7664,19 @@ dependencies = [
"tokio",
"tracing",
"tranquil-config",
"tranquil-db",
"tranquil-db-traits",
"tranquil-lexicon",
"tranquil-pds",
"tranquil-scopes",
"tranquil-signal",
"tranquil-types",
"urlencoding",
"uuid",
"webauthn-rs",
"zip",
]
[[package]]
name = "tranquil-auth"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"anyhow",
"base32",
@@ -7698,13 +7693,14 @@ dependencies = [
"totp-rs",
"tranquil-config",
"tranquil-crypto",
"tranquil-types",
"urlencoding",
"uuid",
]
[[package]]
name = "tranquil-cache"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7719,7 +7715,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7733,7 +7729,6 @@ dependencies = [
"rsa",
"secrecy",
"serde_json",
"sqlx",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -7745,15 +7740,14 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"confique",
"serde",
]
[[package]]
name = "tranquil-crypto"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -7769,7 +7763,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"chrono",
@@ -7786,7 +7780,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7802,18 +7796,17 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"bytes",
"futures",
"thiserror 2.0.18",
"tranquil-config",
]
[[package]]
name = "tranquil-lexicon"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"chrono",
"futures",
@@ -7825,6 +7818,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
"tranquil-types",
"unicode-segmentation",
"urlencoding",
"wiremock",
@@ -7832,7 +7826,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"anyhow",
"axum",
@@ -7855,8 +7849,9 @@ dependencies = [
[[package]]
name = "tranquil-oauth-server"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bcrypt",
@@ -7880,6 +7875,7 @@ dependencies = [
"tranquil-crypto",
"tranquil-db-traits",
"tranquil-pds",
"tranquil-scopes",
"tranquil-types",
"urlencoding",
"uuid",
@@ -7888,7 +7884,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"aes-gcm",
"anyhow",
@@ -7900,7 +7896,6 @@ dependencies = [
"base32",
"base64 0.22.1",
"bcrypt",
"bs58",
"bytes",
"chrono",
"ciborium",
@@ -7916,7 +7911,6 @@ dependencies = [
"hmac",
"http 1.4.0",
"image",
"infer",
"ipld-core",
"iroh-car",
"jacquard-common",
@@ -7958,7 +7952,6 @@ dependencies = [
"tranquil-cache",
"tranquil-comms",
"tranquil-config",
"tranquil-crypto",
"tranquil-db",
"tranquil-db-traits",
"tranquil-lexicon",
@@ -7977,12 +7970,11 @@ dependencies = [
"webauthn-rs",
"webauthn-rs-proto",
"wiremock",
"zip",
]
[[package]]
name = "tranquil-repo"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"bytes",
"cid",
@@ -7994,7 +7986,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"backon",
@@ -8023,7 +8015,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"axum",
"futures",
@@ -8034,12 +8026,13 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
"tranquil-types",
"urlencoding",
]
[[package]]
name = "tranquil-server"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"arc-swap",
"axum",
@@ -8076,7 +8069,7 @@ dependencies = [
[[package]]
name = "tranquil-signal"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"chrono",
@@ -8084,7 +8077,6 @@ dependencies = [
"futures",
"presage",
"rand 0.9.2",
"serde",
"serde_json",
"sqlx",
"tempfile",
@@ -8092,14 +8084,13 @@ dependencies = [
"tokio",
"tokio-util",
"tracing",
"tranquil-signal",
"url",
"uuid",
]
[[package]]
name = "tranquil-storage"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"aws-config",
@@ -8116,7 +8107,7 @@ dependencies = [
[[package]]
name = "tranquil-store"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"bytes",
@@ -8156,7 +8147,6 @@ dependencies = [
"tranquil-db",
"tranquil-db-traits",
"tranquil-oauth",
"tranquil-repo",
"tranquil-store",
"tranquil-types",
"uuid",
@@ -8165,7 +8155,7 @@ dependencies = [
[[package]]
name = "tranquil-sync"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"anyhow",
"axum",
@@ -8187,15 +8177,18 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"base64 0.22.1",
"chrono",
"cid",
"jacquard-common",
"rand 0.8.5",
"serde",
"serde_json",
"sqlx",
"thiserror 2.0.18",
"uuid",
]
[[package]]
@@ -8247,12 +8240,6 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typenum"
version = "1.19.0"
@@ -9454,20 +9441,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "7.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
dependencies = [
"crc32fast",
"flate2",
"indexmap 2.13.0",
"memchr",
"typed-path",
"zopfli",
]
[[package]]
name = "zkcredential"
version = "0.1.0"
@@ -9516,30 +9489,12 @@ dependencies = [
"zkcredential",
]
[[package]]
name = "zlib-rs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.1"
+1 -3
View File
@@ -26,7 +26,7 @@ members = [
]
[workspace.package]
version = "0.6.5"
version = "0.6.6"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -47,7 +47,6 @@ tranquil-db = { path = "crates/tranquil-db" }
tranquil-ripple = { path = "crates/tranquil-ripple" }
tranquil-lexicon = { path = "crates/tranquil-lexicon" }
tranquil-pds = { path = "crates/tranquil-pds" }
tranquil-server = { path = "crates/tranquil-server" }
tranquil-sync = { path = "crates/tranquil-sync" }
tranquil-oauth-server = { path = "crates/tranquil-oauth-server" }
tranquil-api = { path = "crates/tranquil-api" }
@@ -142,7 +141,6 @@ 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"
+4 -4
View File
@@ -8,13 +8,10 @@ license.workspace = true
tranquil-pds = { workspace = true }
tranquil-types = { workspace = true }
tranquil-config = { workspace = true }
tranquil-db = { workspace = true }
tranquil-db-traits = { workspace = true }
tranquil-lexicon = { workspace = true, features = ["resolve"] }
tranquil-scopes = { workspace = true }
tranquil-signal = { workspace = true }
anyhow = { workspace = true }
axum = { workspace = true }
backon = { workspace = true }
base32 = { workspace = true }
@@ -50,4 +47,7 @@ tracing = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
webauthn-rs = { workspace = true }
zip = { workspace = true }
[features]
bsky = ["bsky-support"]
bsky-support = []
-3
View File
@@ -1,3 +0,0 @@
mod preferences;
pub use preferences::{get_preferences, put_preferences};
+2 -1
View File
@@ -200,7 +200,8 @@ pub fn create_self_hosted_did_web(handle: &str) -> Result<Did, ApiError> {
return Err(ApiError::SelfHostedDidWebDisabled);
}
let encoded_handle = handle.replace(':', "%3A");
Ok(Did::from(format!("did:web:{}", encoded_handle)))
Did::new(format!("did:web:{}", encoded_handle))
.map_err(|_| ApiError::InvalidHandle(Some("Handle is not a valid did:web".into())))
}
pub enum CredentialMatch {
+1 -1
View File
@@ -392,7 +392,7 @@ pub async fn create_delegated_account(
controller_scopes: input.controller_scopes.as_str().to_string(),
encrypted_key_bytes: repo.encrypted_key_bytes,
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
commit_cid: CidLink::from(repo.commit_cid.to_string()),
commit_cid: CidLink::from(&repo.commit_cid),
repo_rev: repo.repo_rev.clone(),
genesis_block_cids: repo.genesis_block_cids,
};
+14 -11
View File
@@ -149,17 +149,21 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
}
};
let handle = parse_start_handle(interaction.data.as_ref().and_then(|d| d.options.as_deref()));
if let Some(ref h) = handle
&& Handle::new(h).is_err()
let handle = match parse_start_handle(
interaction.data.as_ref().and_then(|d| d.options.as_deref()),
)
.map(Handle::new)
.transpose()
{
return Json(json!({
"type": 4,
"data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64}
}))
.into_response();
}
Ok(h) => h,
Err(_) => {
return Json(json!({
"type": 4,
"data": {"content": "Invalid handle format. Handle should look like: nel.oyster.cafe", "flags": 64}
}))
.into_response();
}
};
debug!(
discord_username = %discord_username,
@@ -168,7 +172,6 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
"Received /start from Discord user"
);
let handle = handle.map(Handle::from);
match state
.repos
.user
+4 -3
View File
@@ -65,7 +65,7 @@ async fn try_reactivate_migration(
.await
{
Ok(reactivated) => {
info!(did = %did, old_handle = %reactivated.old_handle, new_handle = %handle, "Preparing existing account for inbound migration");
info!(did = %did, old_handle = ?reactivated.old_handle, new_handle = %handle, "Preparing existing account for inbound migration");
let secret_key_bytes = match state
.repos
.user
@@ -202,7 +202,8 @@ pub async fn create_account(
let token = extracted.token;
if is_service_token(&token) {
let verifier = ServiceTokenVerifier::new();
let create_account_lxm = Nsid::from("com.atproto.server.createAccount".to_string());
let create_account_lxm = Nsid::new("com.atproto.server.createAccount")
.expect("com.atproto.server.createAccount is a valid NSID");
match verifier
.verify_service_token(&token, Some(&create_account_lxm))
.await
@@ -448,7 +449,7 @@ pub async fn create_account(
Ok(r) => r,
Err(e) => return e.into_response(),
};
let commit_cid = CidLink::from(repo.commit_cid.to_string());
let commit_cid = CidLink::from(&repo.commit_cid);
let repo_rev = repo.repo_rev.clone();
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
+10 -3
View File
@@ -51,7 +51,9 @@ pub async fn resolve_handle(
}
};
let cache_key = tranquil_pds::cache_keys::handle_key(&handle);
if let Some(did) = state.cache.get(&cache_key).await {
if let Some(cached) = state.cache.get(&cache_key).await
&& let Ok(did) = tranquil_pds::Did::new(cached)
{
return DidResponse::response(did).into_response();
}
let user = state.repos.user.get_by_handle(&handle).await;
@@ -425,10 +427,15 @@ pub async fn verify_did_web(
let expected_multibase = expected_signing_key
.strip_prefix("did:key:")
.ok_or(DidWebVerifyError::InvalidSigningKey)?;
let did_prefixed_key_id = format!("{}#atproto", did);
let has_matching_key = verification_methods.iter().any(|vm| {
vm["publicKeyMultibase"]
let is_atproto_method = vm["id"]
.as_str()
.is_some_and(|pk| pk == expected_multibase)
.is_some_and(|id| id == "#atproto" || id == did_prefixed_key_id);
is_atproto_method
&& vm["publicKeyMultibase"]
.as_str()
.is_some_and(|pk| pk == expected_multibase)
});
if !has_matching_key {
return Err(DidWebVerifyError::KeyMismatch(
+19 -15
View File
@@ -109,7 +109,7 @@ pub async fn init_genesis_repo(
encrypted_key_bytes,
commit_cid,
mst_root_cid: mst_root,
repo_rev: Tid::from(rev.as_ref().to_string()),
repo_rev: Tid::from(rev.clone()),
genesis_block_cids: vec![mst_root.to_bytes(), commit_cid.to_bytes()],
})
}
@@ -198,27 +198,31 @@ pub async fn sequence_new_account(
if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event(
state,
did,
&CidLink::from(repo.commit_cid.to_string()),
&CidLink::from(&repo.commit_cid),
Some(&repo.repo_rev),
)
.await
{
tracing::warn!("Failed to sequence sync event for {}: {}", did, e);
}
let profile_record = serde_json::json!({
"$type": "app.bsky.actor.profile",
"displayName": display_name
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
state,
did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
// TODO: make this configurable and also deduplicate with tranquil-oauth-server/src/sso_endpoints.rs:1210
#[cfg(feature = "bsky")]
{
tracing::warn!("Failed to create default profile for {}: {}", did, e);
let profile_record = serde_json::json!({
"$type": "app.bsky.actor.profile",
"displayName": display_name
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
state,
did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
{
tracing::warn!("Failed to create default profile for {}: {}", did, e);
};
}
}
+26 -11
View File
@@ -1,5 +1,12 @@
pub mod actor;
// BSKY: Bluesky requires PDSs to implement its private preferences API
#[cfg(feature = "bsky-support")]
pub mod actor {
mod preferences;
pub use preferences::{get_preferences, put_preferences};
}
pub mod admin;
#[cfg(feature = "bsky")]
pub mod age_assurance;
pub mod common;
pub mod delegation;
@@ -21,7 +28,7 @@ pub fn api_routes() -> axum::Router<AppState> {
let blob_body_limit =
DefaultBodyLimit::max(tranquil_config::get().server.max_blob_size as usize);
axum::Router::new()
let router = axum::Router::new()
.route("/_health", get(server::health))
.route(
"/com.atproto.server.describeServer",
@@ -373,14 +380,6 @@ pub fn api_routes() -> axum::Router<AppState> {
post(admin::update_subject_status),
)
.route("/com.atproto.admin.sendEmail", post(admin::send_email))
.route(
"/app.bsky.actor.getPreferences",
get(actor::get_preferences),
)
.route(
"/app.bsky.actor.putPreferences",
post(actor::put_preferences),
)
.route(
"/com.atproto.temp.checkSignupQueue",
get(temp::check_signup_queue),
@@ -438,7 +437,21 @@ pub fn api_routes() -> axum::Router<AppState> {
.route(
"/_delegation.resolveController",
get(delegation::resolve_controller),
);
#[cfg(feature = "bsky-support")]
let router = router
.route(
"/app.bsky.actor.getPreferences",
get(actor::get_preferences),
)
.route(
"/app.bsky.actor.putPreferences",
post(actor::put_preferences),
);
#[cfg(feature = "bsky")]
let router = router
.route(
"/app.bsky.ageassurance.getState",
get(age_assurance::get_state),
@@ -446,7 +459,9 @@ pub fn api_routes() -> axum::Router<AppState> {
.route(
"/app.bsky.unspecced.getAgeAssuranceState",
get(age_assurance::get_age_assurance_state),
)
);
router
}
pub fn well_known_api_routes() -> axum::Router<AppState> {
+5 -2
View File
@@ -6,6 +6,7 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::LazyLock;
use tracing::{error, info, warn};
use tranquil_pds::api::ApiError;
use tranquil_pds::api::proxy_client::{is_ssrf_safe, proxy_client};
@@ -13,6 +14,9 @@ use tranquil_pds::auth::{AnyUser, Auth};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Nsid};
static CREATE_REPORT_NSID: LazyLock<Nsid> =
LazyLock::new(|| "com.atproto.moderation.createReport".parse().unwrap());
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReportReasonType {
#[serde(rename = "com.atproto.moderation.defs#reasonSpam")]
@@ -145,11 +149,10 @@ async fn proxy_to_report_service(
},
};
let report_lxm = Nsid::from("com.atproto.moderation.createReport".to_string());
let service_token = match tranquil_pds::auth::create_service_token(
&auth_user.did,
service_did,
Some(&report_lxm),
Some(&CREATE_REPORT_NSID),
&key_bytes,
) {
Ok(t) => t,
@@ -147,12 +147,14 @@ pub async fn request_channel_verification(
match channel {
CommsChannel::Email => {
let hostname = &tranquil_config::get().server.hostname;
let fallback_handle = Handle::from("user".to_string());
let handle = handle.ok_or_else(|| {
ApiError::InternalError(Some("Email verification requires a handle".into()))
})?;
tranquil_pds::comms::comms_repo::enqueue_email_update(
state.repos.infra.as_ref(),
user_id,
identifier,
handle.unwrap_or(&fallback_handle),
handle,
&formatted_token,
hostname,
)
+5 -1
View File
@@ -11,6 +11,7 @@ use multihash::Multihash;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::pin::Pin;
use std::sync::LazyLock;
use tracing::{debug, error, info, warn};
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{Auth, AuthAny, NotTakendown, Permissive, VerifyScope};
@@ -19,6 +20,9 @@ use tranquil_pds::state::AppState;
use tranquil_pds::types::{CidLink, Did, Nsid};
use tranquil_pds::util::get_header_str;
static UPLOAD_BLOB_NSID: LazyLock<Nsid> =
LazyLock::new(|| "com.atproto.repo.uploadBlob".parse().unwrap());
fn detect_mime_type(data: &[u8], client_hint: &str) -> String {
if let Some(kind) = infer::get(data) {
let detected = kind.mime_type().to_string();
@@ -48,7 +52,7 @@ pub async fn upload_blob(
) -> Result<Response, ApiError> {
let (did, controller_did): (Did, Option<Did>) = match &auth {
AuthAny::Service(service) => {
service.require_lxm(&Nsid::from("com.atproto.repo.uploadBlob".to_string()))?;
service.require_lxm(&UPLOAD_BLOB_NSID)?;
(service.did.clone(), None)
}
AuthAny::User(user) => {
+35 -12
View File
@@ -233,7 +233,7 @@ pub async fn import_repo(
ApiError::InternalError(None)
})?;
let new_root_cid_link = CidLink::from(&new_root_cid);
let new_rev_tid = tranquil_pds::types::Tid::from(new_rev_str.clone());
let new_rev_tid = tranquil_pds::types::Tid::from(new_rev.clone());
state
.repos
.repo
@@ -243,17 +243,40 @@ pub async fn import_repo(
error!("Failed to update repo root: {:?}", e);
ApiError::InternalError(None)
})?;
let mut all_block_cids: Vec<Vec<u8>> = blocks.keys().map(|c| c.to_bytes()).collect();
all_block_cids.push(new_root_cid.to_bytes());
state
.repos
.repo
.insert_user_blocks(user_id, &all_block_cids, &new_rev_tid)
.await
.map_err(|e| {
error!("Failed to insert user_blocks: {:?}", e);
ApiError::InternalError(None)
})?;
match tranquil_pds::scheduled::collect_current_repo_blocks(
&state.block_store,
&new_root_cid,
)
.await
{
Ok(reachable) => {
if !reachable.is_complete() {
error!(
unreadable = reachable.unreadable,
"scheduling a structural repair because the imported repo walk could \
not read every block"
);
tranquil_pds::repo_ops::schedule_repo_repair(&state, user_id);
}
state
.repos
.repo
.insert_user_blocks(user_id, &reachable.block_cids, &new_rev_tid)
.await
.map_err(|e| {
error!("Failed to insert user_blocks: {:?}", e);
ApiError::InternalError(None)
})?;
}
Err(e) => {
error!(
"Failed to walk the imported repo: {:?}. The root is already updated and \
a scheduled structural repair will rebuild user_blocks",
e
);
tranquil_pds::repo_ops::schedule_repo_repair(&state, user_id);
}
}
let new_root_str = new_root_cid.to_string();
info!(
"Created new commit for imported repo: cid={}, rev={}",
@@ -1 +0,0 @@
pub use tranquil_pds::repo_ops::*;
+17 -7
View File
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::borrow::Cow;
use std::str::FromStr;
use tracing::warn;
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{
Active, Auth, AuthSource, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
@@ -181,8 +182,15 @@ async fn create_record_inner(
else {
continue;
};
let conflict_rkey = Rkey::from(conflict_rkey_str.to_string());
let conflict_collection = Nsid::from(conflict_col_str.to_string());
let (Ok(conflict_rkey), Ok(conflict_collection)) =
(Rkey::new(conflict_rkey_str), Nsid::new(conflict_col_str))
else {
warn!(
uri = %conflict_uri,
"skipping a backlink conflict whose stored URI doesn't parse"
);
continue;
};
let conflict_key = format!("{}/{}", conflict_collection, conflict_rkey);
let prev_cid = match mst.get(&conflict_key).await {
@@ -373,11 +381,8 @@ async fn put_record_inner(
let record_ipld = tranquil_pds::util::json_to_ipld(&input.record);
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
let record_cid = ctx
.tracking_store
.put(&record_bytes)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
let record_cid = jacquard_repo::mst::util::compute_cid(&record_bytes)
.map_err(|_| ApiError::InvalidRecord("Failed to compute record CID".into()))?;
if existing_cid == Some(record_cid) {
return Ok(PutRecordOutput {
@@ -388,6 +393,11 @@ async fn put_record_inner(
});
}
ctx.tracking_store
.put(&record_bytes)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
let record_uri = AtUri::from_parts(did, &input.collection, &input.rkey);
let (new_mst, op, is_update, backlinks_to_remove) = match existing_cid {
Some(prev_cid) => {
@@ -27,7 +27,8 @@ pub struct CheckAccountStatusOutput {
pub activated: bool,
pub valid_did: bool,
pub repo_commit: String,
pub repo_rev: Tid,
#[serde(serialize_with = "serialize_optional_rev")]
pub repo_rev: Option<Tid>,
pub repo_blocks: i64,
pub indexed_records: i64,
pub private_state_values: i64,
@@ -35,6 +36,13 @@ pub struct CheckAccountStatusOutput {
pub imported_blobs: i64,
}
fn serialize_optional_rev<S: serde::Serializer>(
rev: &Option<Tid>,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(rev.as_ref().map_or("", Tid::as_str))
}
pub async fn check_account_status(
State(state): State<AppState>,
auth: Auth<Permissive>,
@@ -65,23 +73,20 @@ pub async fn check_account_status(
.count_user_blocks(user_id)
.await
.unwrap_or(0);
let repo_rev = if let Some(rev) = repo_rev_from_db {
rev
} else if !repo_commit.is_empty() {
if let Ok(cid) = Cid::from_str(&repo_commit) {
if let Ok(Some(block)) = state.block_store.get(&cid).await {
let repo_rev = match (repo_rev_from_db, Cid::from_str(&repo_commit)) {
(Some(rev), _) => Some(rev),
(None, Ok(cid)) => state
.block_store
.get(&cid)
.await
.ok()
.flatten()
.and_then(|block| {
Commit::from_cbor(&block)
.ok()
.map(|c| Tid::from(c.rev().to_string()))
.unwrap_or_else(|| Tid::from(String::new()))
} else {
Tid::from(String::new())
}
} else {
Tid::from(String::new())
}
} else {
Tid::from(String::new())
.map(|commit| Tid::from(commit.rev().clone()))
}),
(None, Err(_)) => None,
};
let record_count: i64 = state.repos.repo.count_records(user_id).await.unwrap_or(0);
let imported_blobs: i64 = state
@@ -449,7 +454,7 @@ pub async fn activate_account(
if let Ok(Some(block)) = state.block_store.get(&cid).await {
Commit::from_cbor(&block)
.ok()
.map(|c| Tid::from(c.rev().to_string()))
.map(|c| Tid::from(c.rev().clone()))
} else {
None
}
+19 -17
View File
@@ -43,7 +43,7 @@ pub async fn create_invite_code(
match state
.repos
.infra
.create_invite_code(&code, input.use_count, Some(&for_account))
.create_invite_code(&code, input.use_count, &for_account)
.await
{
Ok(true) => Ok(Json(CreateInviteCodeOutput { code })),
@@ -115,7 +115,7 @@ pub async fn create_invite_codes(
async move {
let codes: Vec<InviteCodeValue> = (0..code_count).map(|_| gen_invite_code()).collect();
infra_repo
.create_invite_codes_batch(&codes, use_count, admin_user_id, Some(&account))
.create_invite_codes_batch(&codes, use_count, admin_user_id, &account)
.await
.map(|_| AccountCodes { account, codes })
}
@@ -188,27 +188,24 @@ pub async fn get_account_invite_codes(
let codes = futures::future::join_all(filtered_codes.into_iter().map(|info| {
let infra_repo = state.repos.infra.clone();
async move {
let uses = infra_repo
let uses: Vec<InviteCodeUse> = infra_repo
.get_invite_code_uses(&info.code)
.await
.map(|use_rows| {
use_rows
.into_iter()
.map(|u| InviteCodeUse {
used_by: u.used_by_did.to_string(),
used_by_handle: u.used_by_handle.map(|h| h.to_string()),
used_at: u.used_at.to_rfc3339(),
})
.collect::<Vec<_>>()
.log_db_err("fetching invite code uses")?
.into_iter()
.map(|u| InviteCodeUse {
used_by: u.used_by_did.to_string(),
used_by_handle: u.used_by_handle.map(|h| h.to_string()),
used_at: u.used_at.to_rfc3339(),
})
.unwrap_or_default();
.collect();
let use_count = i32::try_from(uses.len()).unwrap_or(i32::MAX);
if !include_used && use_count >= info.available_uses {
return None;
return Ok(None);
}
Some(InviteCode {
Ok(Some(InviteCode {
code: info.code,
available: info.available_uses,
disabled: false,
@@ -219,11 +216,16 @@ pub async fn get_account_invite_codes(
.unwrap_or_else(|| "admin".to_string()),
created_at: info.created_at.to_rfc3339(),
uses,
})
}))
}
}))
.await;
let codes: Vec<InviteCode> = codes.into_iter().flatten().collect();
let codes: Vec<InviteCode> = codes
.into_iter()
.collect::<Result<Vec<Option<InviteCode>>, ApiError>>()?
.into_iter()
.flatten()
.collect();
Ok(Json(GetAccountInviteCodesOutput { codes }))
}
@@ -69,7 +69,8 @@ pub async fn create_passkey_account(
let token = extracted.token;
if is_service_token(&token) {
let verifier = ServiceTokenVerifier::new();
let create_account_lxm = Nsid::from("com.atproto.server.createAccount".to_string());
let create_account_lxm = Nsid::new("com.atproto.server.createAccount")
.expect("com.atproto.server.createAccount is a valid NSID");
match verifier
.verify_service_token(&token, Some(&create_account_lxm))
.await
@@ -313,7 +314,7 @@ pub async fn create_passkey_account(
encrypted_key_bytes: repo.encrypted_key_bytes,
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
reserved_key_id,
commit_cid: tranquil_types::CidLink::from(repo.commit_cid.to_string()),
commit_cid: tranquil_types::CidLink::from(&repo.commit_cid),
repo_rev: repo.repo_rev.clone(),
genesis_block_cids: repo.genesis_block_cids,
invite_code: invite_registration.into_invite_code(),
+17 -9
View File
@@ -45,15 +45,23 @@ pub async fn request_password_reset(
None
};
let user_id = match state
.repos
.user
.get_id_by_email_or_handle(
normalized,
&Handle::from(normalized_handle.as_str().to_string()),
)
.await
{
let lookup = match Handle::new(normalized_handle.as_str()) {
Ok(handle) => {
state
.repos
.user
.get_id_by_email_or_handle(normalized, &handle)
.await
}
Err(_) => state
.repos
.user
.get_by_email(normalized)
.await
.map(|user| user.map(|user| user.id)),
};
let user_id = match lookup {
Ok(Some(id)) => id,
Ok(None) => {
info!("Password reset requested for unknown identifier");
@@ -21,7 +21,8 @@ fn public_key_to_did_key(signing_key: &SigningKey) -> Did {
multicodec_key.extend_from_slice(&SECP256K1_MULTICODEC_PREFIX);
multicodec_key.extend_from_slice(&compressed_pubkey);
let encoded = multibase::encode(multibase::Base::Base58Btc, &multicodec_key);
Did::from(format!("did:key:{}", encoded))
Did::new(format!("did:key:{}", encoded))
.expect("did:key with a multibase-encoded secp256k1 key is a valid DID")
}
#[derive(Deserialize)]
+14 -2
View File
@@ -62,8 +62,20 @@ pub async fn handle_telegram_webhook(
&& let Some(from) = message.from
&& let Some(username) = from.username
{
let handle =
parse_start_handle(message.text.as_deref()).map(tranquil_types::Handle::from);
let handle = match parse_start_handle(message.text.as_deref())
.map(tranquil_types::Handle::new)
.transpose()
{
Ok(h) => h,
Err(e) => {
warn!(
telegram_username = %username,
error = %e,
"Ignoring /start with an invalid handle"
);
return StatusCode::OK;
}
};
debug!(
telegram_username = %username,
+10 -11
View File
@@ -58,9 +58,16 @@ pub async fn dereference_scope(
for part in scope_parts {
if let Some(cid_str) = part.strip_prefix("ref:") {
let cache_key = tranquil_pds::cache_keys::scope_ref_key(
&tranquil_types::CidLink::from(cid_str.to_string()),
);
let cid = match Cid::from_str(cid_str) {
Ok(c) => c,
Err(_) => {
tracing::warn!("Invalid CID in scope ref: {}", cid_str);
continue;
}
};
let cache_key =
tranquil_pds::cache_keys::scope_ref_key(&tranquil_types::CidLink::from(&cid));
if let Some(cached) = state.cache.get(&cache_key).await {
for s in cached.split_whitespace() {
if !resolved_scopes.contains(&s.to_string()) {
@@ -70,14 +77,6 @@ pub async fn dereference_scope(
continue;
}
let cid = match Cid::from_str(cid_str) {
Ok(c) => c,
Err(_) => {
tracing::warn!("Invalid CID in scope ref: {}", cid_str);
continue;
}
};
let block_bytes = match state.block_store.get(&cid).await {
Ok(Some(b)) => b,
Ok(None) => {
-31
View File
@@ -1,31 +0,0 @@
use axum::{
Json,
extract::State,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use tranquil_pds::api::SuccessResponse;
use tranquil_pds::state::AppState;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmChannelVerificationInput {
pub channel: tranquil_db_traits::CommsChannel,
pub identifier: String,
pub code: String,
}
pub async fn confirm_channel_verification(
State(state): State<AppState>,
Json(input): Json<ConfirmChannelVerificationInput>,
) -> Response {
let token_input = crate::server::VerifyTokenInput {
token: input.code,
identifier: input.identifier,
};
match crate::server::verify_token_internal(&state, token_input).await {
Ok(_output) => SuccessResponse::ok().into_response(),
Err(e) => e.into_response(),
}
}
-1
View File
@@ -5,7 +5,6 @@ edition.workspace = true
license.workspace = true
[features]
default = []
valkey = ["dep:redis"]
[dependencies]
-1
View File
@@ -19,7 +19,6 @@ reqwest = { workspace = true }
rsa = { workspace = true }
secrecy = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
-1
View File
@@ -6,4 +6,3 @@ license.workspace = true
[dependencies]
confique = { workspace = true }
serde = { workspace = true }
+3 -1
View File
@@ -23,7 +23,8 @@ pub struct DelegationGrant {
#[serde(rename_all = "camelCase")]
pub struct DelegatedAccountInfo {
pub did: Did,
pub handle: Handle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handle: Option<Handle>,
pub granted_scopes: DbScope,
pub granted_at: DateTime<Utc>,
}
@@ -32,6 +33,7 @@ pub struct DelegatedAccountInfo {
#[serde(rename_all = "camelCase")]
pub struct ControllerInfo {
pub did: Did,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handle: Option<Handle>,
pub granted_scopes: DbScope,
pub granted_at: DateTime<Utc>,
+29
View File
@@ -1,5 +1,31 @@
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ColumnRef {
table: &'static str,
column: &'static str,
}
impl ColumnRef {
pub const fn new(table: &'static str, column: &'static str) -> Self {
Self { table, column }
}
pub const fn table(&self) -> &'static str {
self.table
}
pub const fn column(&self) -> &'static str {
self.column
}
}
impl std::fmt::Display for ColumnRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}", self.table, self.column)
}
}
#[derive(Debug, Error)]
pub enum DbError {
#[error("Database query error: {0}")]
@@ -29,6 +55,9 @@ pub enum DbError {
#[error("Corrupt data in column: {0}")]
CorruptData(&'static str),
#[error("Column {0} has a value that isn't valid for its type")]
InvalidColumn(ColumnRef),
#[error("Other database error: {0}")]
Other(String),
}
+2 -2
View File
@@ -250,7 +250,7 @@ pub trait InfraRepository: Send + Sync {
&self,
code: &InviteCode,
use_count: i32,
for_account: Option<&Did>,
for_account: &Did,
) -> Result<bool, DbError>;
async fn create_invite_codes_batch(
@@ -258,7 +258,7 @@ pub trait InfraRepository: Send + Sync {
codes: &[InviteCode],
use_count: i32,
created_by_user: Uuid,
for_account: Option<&Did>,
for_account: &Did,
) -> Result<(), DbError>;
async fn get_invite_code_available_uses(
+1 -1
View File
@@ -20,7 +20,7 @@ pub use delegation::{
AuditLogEntry, ControllerInfo, DelegatedAccountInfo, DelegationActionType, DelegationGrant,
DelegationRepository,
};
pub use error::DbError;
pub use error::{ColumnRef, DbError};
pub use infra::{
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest,
DeletionRequestWithToken, InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder,
+8 -1
View File
@@ -410,6 +410,13 @@ pub trait RepoRepository: Send + Sync {
async fn get_record_by_cid(&self, cid: &CidLink)
-> Result<Option<RecordWithTakedown>, DbError>;
async fn referenced_record_cids(
&self,
repo_id: Uuid,
cids: &[CidLink],
excluded_keys: &[(&Nsid, &Rkey)],
) -> Result<Vec<CidLink>, DbError>;
async fn set_record_takedown(
&self,
cid: &CidLink,
@@ -432,7 +439,7 @@ pub trait RepoRepository: Send + Sync {
async fn get_user_block_cids_since_rev(
&self,
user_id: Uuid,
since_rev: &Tid,
since_rev: Option<&Tid>,
) -> Result<Vec<Vec<u8>>, DbError>;
async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>;
+1 -2
View File
@@ -670,7 +670,6 @@ pub struct UserForVerification {
pub did: Did,
pub email: Option<String>,
pub email_verified: bool,
pub handle: Handle,
}
#[derive(Debug, Clone)]
@@ -1119,7 +1118,7 @@ pub struct MigrationReactivationInput {
#[derive(Debug, Clone)]
pub struct ReactivatedAccountInfo {
pub user_id: Uuid,
pub old_handle: Handle,
pub old_handle: Option<Handle>,
}
#[derive(Debug, Clone)]
-2
View File
@@ -5,9 +5,7 @@ edition.workspace = true
license.workspace = true
[features]
default = ["postgres"]
postgres = []
sqlite = []
[dependencies]
tranquil-db-traits = { workspace = true }
+3 -1
View File
@@ -4,6 +4,8 @@ use tranquil_db_traits::{Backlink, BacklinkRepository, DbError};
use tranquil_types::{AtUri, Nsid};
use uuid::Uuid;
use super::col;
use super::column_vec;
use super::user::map_sqlx_error;
pub struct PostgresBacklinkRepository {
@@ -49,7 +51,7 @@ impl BacklinkRepository for PostgresBacklinkRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results.into_iter().map(Into::into).collect())
column_vec(results, col::BACKLINKS_URI)
}
async fn add_backlinks(&self, repo_id: Uuid, backlinks: &[Backlink]) -> Result<(), DbError> {
+28 -18
View File
@@ -6,7 +6,9 @@ use tranquil_db_traits::{
use tranquil_types::{AtUri, CidLink, Did, Tid};
use uuid::Uuid;
use super::col;
use super::user::map_sqlx_error;
use super::{column, column_vec, opt_column};
pub struct PostgresBlobRepository {
pool: PgPool,
@@ -42,7 +44,7 @@ impl BlobRepository for PostgresBlobRepository {
.await
.map_err(map_sqlx_error)?;
Ok(result.map(CidLink::from))
opt_column(result, col::BLOBS_CID)
}
async fn get_blob_metadata(&self, cid: &CidLink) -> Result<Option<BlobMetadata>, DbError> {
@@ -73,10 +75,14 @@ impl BlobRepository for PostgresBlobRepository {
.await
.map_err(map_sqlx_error)?;
Ok(result.map(|r| BlobWithTakedown {
cid: CidLink::from(r.cid),
takedown_ref: r.takedown_ref,
}))
result
.map(|r| {
Ok(BlobWithTakedown {
cid: column(r.cid, col::BLOBS_CID)?,
takedown_ref: r.takedown_ref,
})
})
.transpose()
}
async fn get_blob_storage_key(&self, cid: &CidLink) -> Result<Option<String>, DbError> {
@@ -109,7 +115,7 @@ impl BlobRepository for PostgresBlobRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results.into_iter().map(CidLink::from).collect())
column_vec(results, col::BLOBS_CID)
}
async fn list_blobs_since_rev(&self, did: &Did, since: &Tid) -> Result<Vec<CidLink>, DbError> {
@@ -124,7 +130,7 @@ impl BlobRepository for PostgresBlobRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results.into_iter().map(CidLink::from).collect())
column_vec(results, col::REPO_SEQ_BLOBS)
}
async fn count_blobs_by_user(&self, user_id: Uuid) -> Result<i64, DbError> {
@@ -244,13 +250,15 @@ impl BlobRepository for PostgresBlobRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results
results
.into_iter()
.map(|r| MissingBlobInfo {
blob_cid: CidLink::from(r.blob_cid),
record_uri: AtUri::from(r.record_uri),
.map(|r| {
Ok(MissingBlobInfo {
blob_cid: column(r.blob_cid, col::RECORD_BLOBS_BLOB_CID)?,
record_uri: column(r.record_uri, col::RECORD_BLOBS_RECORD_URI)?,
})
})
.collect())
.collect()
}
async fn count_distinct_record_blobs(&self, repo_id: Uuid) -> Result<i64, DbError> {
@@ -277,13 +285,15 @@ impl BlobRepository for PostgresBlobRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results
results
.into_iter()
.map(|r| BlobForExport {
cid: CidLink::from(r.cid),
storage_key: r.storage_key,
mime_type: r.mime_type,
.map(|r| {
Ok(BlobForExport {
cid: column(r.cid, col::BLOBS_CID)?,
storage_key: r.storage_key,
mime_type: r.mime_type,
})
})
.collect())
.collect()
}
}
+55 -41
View File
@@ -7,7 +7,9 @@ use tranquil_db_traits::{
use tranquil_types::Did;
use uuid::Uuid;
use super::col;
use super::user::map_sqlx_error;
use super::{column, legacy_column, opt_column};
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
#[sqlx(type_name = "delegation_action_type", rename_all = "snake_case")]
@@ -166,16 +168,19 @@ impl DelegationRepository for PostgresDelegationRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| DelegationGrant {
id: r.id,
delegated_did: r.delegated_did.into(),
controller_did: r.controller_did.into(),
granted_scopes: DbScope::from_db(r.granted_scopes),
granted_at: r.granted_at,
granted_by: r.granted_by.into(),
revoked_at: r.revoked_at,
revoked_by: r.revoked_by.map(Into::into),
}))
row.map(|r| {
Ok(DelegationGrant {
id: r.id,
delegated_did: column(r.delegated_did, col::ACCOUNT_DELEGATIONS_DELEGATED_DID)?,
controller_did: column(r.controller_did, col::ACCOUNT_DELEGATIONS_CONTROLLER_DID)?,
granted_scopes: DbScope::from_db(r.granted_scopes),
granted_at: r.granted_at,
granted_by: column(r.granted_by, col::ACCOUNT_DELEGATIONS_GRANTED_BY)?,
revoked_at: r.revoked_at,
revoked_by: opt_column(r.revoked_by, col::ACCOUNT_DELEGATIONS_REVOKED_BY)?,
})
})
.transpose()
}
async fn get_delegations_for_account(
@@ -205,17 +210,18 @@ impl DelegationRepository for PostgresDelegationRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| ControllerInfo {
did: r.controller_did.into(),
handle: r.handle.map(Into::into),
granted_scopes: DbScope::from_db(r.granted_scopes),
granted_at: r.granted_at,
is_active: r.is_active,
is_local: r.is_local,
rows.into_iter()
.map(|r| {
Ok(ControllerInfo {
did: column(r.controller_did, col::ACCOUNT_DELEGATIONS_CONTROLLER_DID)?,
handle: r.handle.and_then(|h| legacy_column(h, col::USERS_HANDLE)),
granted_scopes: DbScope::from_db(r.granted_scopes),
granted_at: r.granted_at,
is_active: r.is_active,
is_local: r.is_local,
})
})
.collect())
.collect()
}
async fn get_accounts_controlled_by(
@@ -243,15 +249,16 @@ impl DelegationRepository for PostgresDelegationRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| DelegatedAccountInfo {
did: r.did.into(),
handle: r.handle.into(),
granted_scopes: DbScope::from_db(r.granted_scopes),
granted_at: r.granted_at,
rows.into_iter()
.map(|r| {
Ok(DelegatedAccountInfo {
did: column(r.did, col::USERS_DID)?,
handle: legacy_column(r.handle, col::USERS_HANDLE),
granted_scopes: DbScope::from_db(r.granted_scopes),
granted_at: r.granted_at,
})
})
.collect())
.collect()
}
async fn count_active_controllers(&self, delegated_did: &Did) -> Result<i64, DbError> {
@@ -353,20 +360,27 @@ impl DelegationRepository for PostgresDelegationRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| AuditLogEntry {
id: r.id,
delegated_did: r.delegated_did.into(),
actor_did: r.actor_did.into(),
controller_did: r.controller_did.map(Into::into),
action_type: r.action_type.into(),
action_details: r.action_details,
ip_address: r.ip_address,
user_agent: r.user_agent,
created_at: r.created_at,
rows.into_iter()
.map(|r| {
Ok(AuditLogEntry {
id: r.id,
delegated_did: column(
r.delegated_did,
col::DELEGATION_AUDIT_LOG_DELEGATED_DID,
)?,
actor_did: column(r.actor_did, col::DELEGATION_AUDIT_LOG_ACTOR_DID)?,
controller_did: opt_column(
r.controller_did,
col::DELEGATION_AUDIT_LOG_CONTROLLER_DID,
)?,
action_type: r.action_type.into(),
action_details: r.action_details,
ip_address: r.ip_address,
user_agent: r.user_agent,
created_at: r.created_at,
})
})
.collect())
.collect()
}
async fn count_audit_log_entries(&self, delegated_did: &Did) -> Result<i64, DbError> {
+121 -88
View File
@@ -7,10 +7,12 @@ use tranquil_db_traits::{
InviteCodeSortOrder, InviteCodeState, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo,
QueuedComms, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode,
};
use tranquil_types::{CidLink, Did, Handle, InviteCode};
use tranquil_types::{CidLink, Did, InviteCode};
use uuid::Uuid;
use super::col;
use super::user::map_sqlx_error;
use super::{column, legacy_column, opt_column};
pub struct PostgresInfraRepository {
pool: PgPool,
@@ -153,9 +155,9 @@ impl InfraRepository for PostgresInfraRepository {
&self,
code: &InviteCode,
use_count: i32,
for_account: Option<&Did>,
for_account: &Did,
) -> Result<bool, DbError> {
let for_account_str = for_account.map(|d| d.as_str());
let for_account_str = for_account.as_str();
let result = sqlx::query!(
r#"INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)
SELECT $1, $2, id, $3 FROM users WHERE is_admin = true LIMIT 1"#,
@@ -175,9 +177,9 @@ impl InfraRepository for PostgresInfraRepository {
codes: &[InviteCode],
use_count: i32,
created_by_user: Uuid,
for_account: Option<&Did>,
for_account: &Did,
) -> Result<(), DbError> {
let for_account_str = for_account.map(|d| d.as_str());
let for_account_str = for_account.as_str();
let code_strs: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
sqlx::query!(
r#"INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)
@@ -250,17 +252,19 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results
results
.into_iter()
.map(|r| InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: None,
.map(|r| {
Ok(InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
created_at: r.created_at,
created_by: None,
})
})
.collect())
.collect()
}
async fn get_invite_code_uses(&self, code: &InviteCode) -> Result<Vec<InviteCodeUse>, DbError> {
@@ -278,11 +282,13 @@ impl InfraRepository for PostgresInfraRepository {
Ok(results
.into_iter()
.map(|r| InviteCodeUse {
code: code.clone(),
used_by_did: Did::from(r.did),
used_by_handle: Some(Handle::from(r.handle)),
used_at: r.used_at,
.filter_map(|r| {
Some(InviteCodeUse {
code: code.clone(),
used_by_did: legacy_column(r.did, col::USERS_DID)?,
used_by_handle: legacy_column(r.handle, col::USERS_HANDLE),
used_at: r.used_at,
})
})
.collect())
}
@@ -436,10 +442,10 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results
results
.into_iter()
.map(|r| (r.id, Did::from(r.did)))
.collect())
.map(|r| Ok((r.id, column(r.did, col::USERS_DID)?)))
.collect()
}
async fn get_invite_code_uses_batch(
@@ -459,15 +465,17 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results
results
.into_iter()
.map(|r| InviteCodeUse {
code: InviteCode::from(r.code),
used_by_did: Did::from(r.did),
used_by_handle: None,
used_at: r.used_at,
.map(|r| {
Ok(InviteCodeUse {
code: InviteCode::from(r.code),
used_by_did: column(r.did, col::USERS_DID)?,
used_by_handle: None,
used_at: r.used_at,
})
})
.collect())
.collect()
}
async fn get_invites_created_by_user(
@@ -485,17 +493,19 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results
results
.into_iter()
.map(|r| InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
.map(|r| {
Ok(InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
created_at: r.created_at,
created_by: Some(column(r.created_by, col::USERS_DID)?),
})
})
.collect())
.collect()
}
async fn get_invite_code_info(
@@ -513,14 +523,18 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(result.map(|r| InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
}))
result
.map(|r| {
Ok(InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
created_at: r.created_at,
created_by: Some(column(r.created_by, col::USERS_DID)?),
})
})
.transpose()
}
async fn get_invite_codes_by_users(
@@ -539,22 +553,22 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results
results
.into_iter()
.map(|r| {
(
Ok((
r.created_by_user,
InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
created_by: Some(column(r.created_by, col::USERS_DID)?),
},
)
))
})
.collect())
.collect()
}
async fn get_invite_code_used_by_user(
@@ -683,10 +697,14 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(result.map(|r| DeletionRequest {
did: Did::from(r.did),
expires_at: r.expires_at,
}))
result
.map(|r| {
Ok(DeletionRequest {
did: column(r.did, col::ACCOUNT_DELETION_REQUESTS_DID)?,
expires_at: r.expires_at,
})
})
.transpose()
}
async fn delete_deletion_request(&self, token: &str) -> Result<(), DbError> {
@@ -1027,16 +1045,20 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(result.map(|r| AdminAccountInfo {
id: r.id,
did: Did::from(r.did),
handle: Handle::from(r.handle),
email: r.email,
created_at: r.created_at,
invites_disabled: r.invites_disabled.unwrap_or(false),
email_verified: r.email_verified,
deactivated_at: r.deactivated_at,
}))
result
.map(|r| {
Ok(AdminAccountInfo {
id: r.id,
did: column(r.did, col::USERS_DID)?,
handle: column(r.handle, col::USERS_HANDLE)?,
email: r.email,
created_at: r.created_at,
invites_disabled: r.invites_disabled.unwrap_or(false),
email_verified: r.email_verified,
deactivated_at: r.deactivated_at,
})
})
.transpose()
}
async fn get_admin_account_infos_by_dids(
@@ -1058,15 +1080,17 @@ impl InfraRepository for PostgresInfraRepository {
Ok(results
.into_iter()
.map(|r| AdminAccountInfo {
id: r.id,
did: Did::from(r.did),
handle: Handle::from(r.handle),
email: r.email,
created_at: r.created_at,
invites_disabled: r.invites_disabled.unwrap_or(false),
email_verified: r.email_verified,
deactivated_at: r.deactivated_at,
.filter_map(|r| {
Some(AdminAccountInfo {
id: r.id,
did: legacy_column(r.did, col::USERS_DID)?,
handle: legacy_column(r.handle, col::USERS_HANDLE)?,
email: r.email,
created_at: r.created_at,
invites_disabled: r.invites_disabled.unwrap_or(false),
email_verified: r.email_verified,
deactivated_at: r.deactivated_at,
})
})
.collect())
}
@@ -1105,11 +1129,14 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| DeletionRequestWithToken {
token: r.token,
did: Did::new(r.did).expect("valid DID in database"),
expires_at: r.expires_at,
}))
row.map(|r| {
Ok(DeletionRequestWithToken {
token: r.token,
did: column(r.did, col::ACCOUNT_DELETION_REQUESTS_DID)?,
expires_at: r.expires_at,
})
})
.transpose()
}
async fn get_latest_comms_for_user(
@@ -1202,14 +1229,20 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| ReservedSigningKeyFull {
id: r.id,
did: r.did.map(|d| Did::new(d).expect("valid DID in database")),
public_key_did_key: Did::from(r.public_key_did_key),
private_key_bytes: r.private_key_bytes,
expires_at: r.expires_at,
used_at: r.used_at,
}))
row.map(|r| {
Ok(ReservedSigningKeyFull {
id: r.id,
did: opt_column(r.did, col::RESERVED_SIGNING_KEYS_DID)?,
public_key_did_key: column(
r.public_key_did_key,
col::RESERVED_SIGNING_KEYS_PUBLIC_KEY_DID_KEY,
)?,
private_key_bytes: r.private_key_bytes,
expires_at: r.expires_at,
used_at: r.used_at,
})
})
.transpose()
}
async fn get_plc_tokens_by_did(&self, did: &Did) -> Result<Vec<PlcTokenInfo>, DbError> {
+99
View File
@@ -10,7 +10,106 @@ mod sso;
mod user;
use sqlx::PgPool;
use std::str::FromStr;
use std::sync::Arc;
use tranquil_db_traits::{ColumnRef, DbError};
pub(crate) mod col {
use tranquil_db_traits::ColumnRef;
pub const ACCOUNT_DELEGATIONS_CONTROLLER_DID: ColumnRef =
ColumnRef::new("account_delegations", "controller_did");
pub const ACCOUNT_DELEGATIONS_DELEGATED_DID: ColumnRef =
ColumnRef::new("account_delegations", "delegated_did");
pub const ACCOUNT_DELEGATIONS_GRANTED_BY: ColumnRef =
ColumnRef::new("account_delegations", "granted_by");
pub const ACCOUNT_DELEGATIONS_REVOKED_BY: ColumnRef =
ColumnRef::new("account_delegations", "revoked_by");
pub const ACCOUNT_DELETION_REQUESTS_DID: ColumnRef =
ColumnRef::new("account_deletion_requests", "did");
pub const APP_PASSWORDS_CREATED_BY_CONTROLLER_DID: ColumnRef =
ColumnRef::new("app_passwords", "created_by_controller_did");
pub const BACKLINKS_URI: ColumnRef = ColumnRef::new("backlinks", "uri");
pub const BLOBS_CID: ColumnRef = ColumnRef::new("blobs", "cid");
pub const DELEGATION_AUDIT_LOG_ACTOR_DID: ColumnRef =
ColumnRef::new("delegation_audit_log", "actor_did");
pub const DELEGATION_AUDIT_LOG_CONTROLLER_DID: ColumnRef =
ColumnRef::new("delegation_audit_log", "controller_did");
pub const DELEGATION_AUDIT_LOG_DELEGATED_DID: ColumnRef =
ColumnRef::new("delegation_audit_log", "delegated_did");
pub const INVITE_CODES_FOR_ACCOUNT: ColumnRef = ColumnRef::new("invite_codes", "for_account");
pub const OAUTH_2FA_CHALLENGE_DID: ColumnRef = ColumnRef::new("oauth_2fa_challenge", "did");
pub const OAUTH_AUTHORIZATION_REQUEST_CONTROLLER_DID: ColumnRef =
ColumnRef::new("oauth_authorization_request", "controller_did");
pub const OAUTH_AUTHORIZATION_REQUEST_DID: ColumnRef =
ColumnRef::new("oauth_authorization_request", "did");
pub const OAUTH_TOKEN_CONTROLLER_DID: ColumnRef =
ColumnRef::new("oauth_token", "controller_did");
pub const OAUTH_TOKEN_DID: ColumnRef = ColumnRef::new("oauth_token", "did");
pub const PASSKEYS_DID: ColumnRef = ColumnRef::new("passkeys", "did");
pub const RECORD_BLOBS_BLOB_CID: ColumnRef = ColumnRef::new("record_blobs", "blob_cid");
pub const RECORD_BLOBS_RECORD_URI: ColumnRef = ColumnRef::new("record_blobs", "record_uri");
pub const RECORDS_COLLECTION: ColumnRef = ColumnRef::new("records", "collection");
pub const RECORDS_RECORD_CID: ColumnRef = ColumnRef::new("records", "record_cid");
pub const RECORDS_RKEY: ColumnRef = ColumnRef::new("records", "rkey");
pub const REPO_SEQ_BLOBS: ColumnRef = ColumnRef::new("repo_seq", "blobs");
pub const REPO_SEQ_BLOCKS_CIDS: ColumnRef = ColumnRef::new("repo_seq", "blocks_cids");
pub const REPO_SEQ_COMMIT_CID: ColumnRef = ColumnRef::new("repo_seq", "commit_cid");
pub const REPO_SEQ_DID: ColumnRef = ColumnRef::new("repo_seq", "did");
pub const REPO_SEQ_HANDLE: ColumnRef = ColumnRef::new("repo_seq", "handle");
pub const REPO_SEQ_PREV_CID: ColumnRef = ColumnRef::new("repo_seq", "prev_cid");
pub const REPO_SEQ_PREV_DATA_CID: ColumnRef = ColumnRef::new("repo_seq", "prev_data_cid");
pub const REPO_SEQ_REV: ColumnRef = ColumnRef::new("repo_seq", "rev");
pub const REPOS_REPO_REV: ColumnRef = ColumnRef::new("repos", "repo_rev");
pub const REPOS_REPO_ROOT_CID: ColumnRef = ColumnRef::new("repos", "repo_root_cid");
pub const RESERVED_SIGNING_KEYS_DID: ColumnRef = ColumnRef::new("reserved_signing_keys", "did");
pub const RESERVED_SIGNING_KEYS_PUBLIC_KEY_DID_KEY: ColumnRef =
ColumnRef::new("reserved_signing_keys", "public_key_did_key");
pub const SESSION_TOKENS_CONTROLLER_DID: ColumnRef =
ColumnRef::new("session_tokens", "controller_did");
pub const SESSION_TOKENS_DID: ColumnRef = ColumnRef::new("session_tokens", "did");
pub const USERS_DID: ColumnRef = ColumnRef::new("users", "did");
pub const USERS_HANDLE: ColumnRef = ColumnRef::new("users", "handle");
}
pub(crate) fn column<T: FromStr>(value: String, name: ColumnRef) -> Result<T, DbError> {
T::from_str(&value).map_err(|_| {
tracing::error!(
column = %name,
value = %value,
"column has a value that isn't valid for its type"
);
DbError::InvalidColumn(name)
})
}
pub(crate) fn opt_column<T: FromStr>(
value: Option<String>,
name: ColumnRef,
) -> Result<Option<T>, DbError> {
value.map(|v| column(v, name)).transpose()
}
pub(crate) fn legacy_column<T: FromStr>(value: String, name: ColumnRef) -> Option<T> {
match T::from_str(&value) {
Ok(v) => Some(v),
Err(_) => {
tracing::warn!(
column = %name,
value = %value,
"ignoring a column value that isn't valid for its type"
);
None
}
}
}
pub(crate) fn column_vec<T: FromStr>(
values: Vec<String>,
name: ColumnRef,
) -> Result<Vec<T>, DbError> {
values.into_iter().map(|v| column(v, name)).collect()
}
pub use backlink::PostgresBacklinkRepository;
pub use blob::PostgresBlobRepository;
+45 -48
View File
@@ -11,11 +11,12 @@ use tranquil_oauth::{
SessionId as OAuthSessionId, TokenData,
};
use tranquil_types::{
AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, Handle, RefreshToken, RequestId,
TokenId,
AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, RefreshToken, RequestId, TokenId,
};
use uuid::Uuid;
use super::col;
use super::column;
use super::user::map_sqlx_error;
const REGISTRATION_FLOW_EXTENDED_EXPIRY_SECS: i64 = 600;
@@ -98,7 +99,7 @@ impl OAuthRepository for PostgresOAuthRepository {
did: r
.did
.parse()
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
token_id: TokenId::from(r.token_id),
created_at: r.created_at,
updated_at: r.updated_at,
@@ -115,7 +116,7 @@ impl OAuthRepository for PostgresOAuthRepository {
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
})),
None => Ok(None),
}
@@ -144,7 +145,7 @@ impl OAuthRepository for PostgresOAuthRepository {
did: r
.did
.parse()
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
token_id: TokenId::from(r.token_id),
created_at: r.created_at,
updated_at: r.updated_at,
@@ -161,7 +162,7 @@ impl OAuthRepository for PostgresOAuthRepository {
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
},
))),
None => Ok(None),
@@ -193,7 +194,7 @@ impl OAuthRepository for PostgresOAuthRepository {
did: r
.did
.parse()
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
token_id: TokenId::from(r.token_id),
created_at: r.created_at,
updated_at: r.updated_at,
@@ -210,7 +211,7 @@ impl OAuthRepository for PostgresOAuthRepository {
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
},
))),
None => Ok(None),
@@ -326,7 +327,7 @@ impl OAuthRepository for PostgresOAuthRepository {
did: r
.did
.parse()
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
token_id: TokenId::from(r.token_id),
created_at: r.created_at,
updated_at: r.updated_at,
@@ -343,7 +344,7 @@ impl OAuthRepository for PostgresOAuthRepository {
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
})
})
.collect()
@@ -476,18 +477,14 @@ impl OAuthRepository for PostgresOAuthRepository {
client_auth,
parameters,
expires_at: r.expires_at,
did: r
.did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid DID in DB".into()))?,
did: r.did.map(|s| s.parse()).transpose().map_err(|_| {
DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_DID)
})?,
device_id: r.device_id.map(DeviceId::from),
code: r.code.map(AuthorizationCode::from),
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?,
controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(
|_| DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_CONTROLLER_DID),
)?,
}))
}
None => Ok(None),
@@ -570,18 +567,14 @@ impl OAuthRepository for PostgresOAuthRepository {
client_auth,
parameters,
expires_at: r.expires_at,
did: r
.did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid DID in DB".into()))?,
did: r.did.map(|s| s.parse()).transpose().map_err(|_| {
DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_DID)
})?,
device_id: r.device_id.map(DeviceId::from),
code: r.code.map(AuthorizationCode::from),
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?,
controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(
|_| DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_CONTROLLER_DID),
)?,
}))
}
None => Ok(None),
@@ -813,15 +806,16 @@ impl OAuthRepository for PostgresOAuthRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| DeviceAccountRow {
did: Did::from(r.did),
handle: Handle::from(r.handle),
email: r.email,
last_used_at: r.last_used_at,
rows.into_iter()
.map(|r| {
Ok(DeviceAccountRow {
did: column(r.did, col::USERS_DID)?,
handle: column(r.handle, col::USERS_HANDLE)?,
email: r.email,
last_used_at: r.last_used_at,
})
})
.collect())
.collect()
}
async fn verify_account_on_device(
@@ -904,7 +898,7 @@ impl OAuthRepository for PostgresOAuthRepository {
.map_err(map_sqlx_error)?;
Ok(TwoFactorChallenge {
id: row.id,
did: Did::from(row.did),
did: column(row.did, col::OAUTH_2FA_CHALLENGE_DID)?,
request_uri: RequestId::from(row.request_uri),
code: row.code,
attempts: row.attempts,
@@ -928,15 +922,18 @@ impl OAuthRepository for PostgresOAuthRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| TwoFactorChallenge {
id: r.id,
did: Did::from(r.did),
request_uri: RequestId::from(r.request_uri),
code: r.code,
attempts: r.attempts,
created_at: r.created_at,
expires_at: r.expires_at,
}))
row.map(|r| {
Ok(TwoFactorChallenge {
id: r.id,
did: column(r.did, col::OAUTH_2FA_CHALLENGE_DID)?,
request_uri: RequestId::from(r.request_uri),
code: r.code,
attempts: r.attempts,
created_at: r.created_at,
expires_at: r.expires_at,
})
})
.transpose()
}
async fn increment_2fa_attempts(&self, id: Uuid) -> Result<i32, DbError> {
+190 -97
View File
@@ -10,7 +10,9 @@ use tranquil_db_traits::{
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey, Tid};
use uuid::Uuid;
use super::col;
use super::user::map_sqlx_error;
use super::{column, column_vec, legacy_column, opt_column};
struct RecordRow {
rkey: String,
@@ -43,7 +45,7 @@ fn row_to_event_blocks(
) -> Result<Option<EventBlocks>, DbError> {
match (block_cids, block_data) {
(Some(cids), Some(data)) if cids.len() == data.len() => match cids.is_empty() {
true => Ok(legacy_fallback(legacy_blocks_cids)),
true => legacy_fallback(legacy_blocks_cids),
false => Ok(Some(EventBlocks::Inline(
cids.into_iter()
.zip(data)
@@ -57,16 +59,19 @@ fn row_to_event_blocks(
(Some(_), None) | (None, Some(_)) => Err(DbError::CorruptData(
"repo_seq.block_cids/block_data partially populated",
)),
(None, None) => Ok(legacy_fallback(legacy_blocks_cids)),
(None, None) => legacy_fallback(legacy_blocks_cids),
}
}
fn legacy_fallback(legacy_blocks_cids: Option<Vec<String>>) -> Option<EventBlocks> {
fn legacy_fallback(
legacy_blocks_cids: Option<Vec<String>>,
) -> Result<Option<EventBlocks>, DbError> {
match legacy_blocks_cids {
Some(cids) if !cids.is_empty() => Some(EventBlocks::LegacyCids(
cids.into_iter().map(CidLink::from).collect(),
)),
_ => None,
Some(cids) if !cids.is_empty() => Ok(Some(EventBlocks::LegacyCids(column_vec(
cids,
col::REPO_SEQ_BLOCKS_CIDS,
)?))),
_ => Ok(None),
}
}
@@ -97,24 +102,40 @@ fn map_sequenced_row(r: SequencedEventRow) -> Result<SequencedEvent, DbError> {
let blocks = row_to_event_blocks(r.block_cids, r.block_data, r.blocks_cids)?;
Ok(SequencedEvent {
seq: r.seq.into(),
did: Did::from(r.did),
did: column(r.did, col::REPO_SEQ_DID)?,
created_at: r.created_at,
event_type: r.event_type,
commit_cid: r.commit_cid.map(CidLink::from),
prev_cid: r.prev_cid.map(CidLink::from),
prev_data_cid: r.prev_data_cid.map(CidLink::from),
commit_cid: opt_column(r.commit_cid, col::REPO_SEQ_COMMIT_CID)?,
prev_cid: opt_column(r.prev_cid, col::REPO_SEQ_PREV_CID)?,
prev_data_cid: opt_column(r.prev_data_cid, col::REPO_SEQ_PREV_DATA_CID)?,
ops: r.ops,
blobs: r
.blobs
.map(|blobs| blobs.into_iter().map(CidLink::from).collect()),
.map(|blobs| column_vec(blobs, col::REPO_SEQ_BLOBS))
.transpose()?,
blocks,
handle: r.handle.map(Handle::from),
handle: r
.handle
.and_then(|h| legacy_column(h, col::REPO_SEQ_HANDLE)),
active: r.active,
status,
rev: r.rev.map(Tid::from),
rev: opt_column(r.rev, col::REPO_SEQ_REV)?,
})
}
fn collect_sequenced_rows(rows: Vec<SequencedEventRow>) -> Vec<SequencedEvent> {
rows.into_iter()
.filter_map(|r| {
let seq = r.seq;
map_sequenced_row(r)
.inspect_err(|e| {
tracing::error!(seq, error = %e, "skipping a repo_seq row that doesn't decode");
})
.ok()
})
.collect()
}
const SEQUENCER_LOCK_KEY: i64 = 0x0074_7261_6e73_6571;
const SEQUENCER_BATCH_SIZE: i64 = 1000;
@@ -266,7 +287,7 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(result.map(CidLink::from))
opt_column(result, col::REPOS_REPO_ROOT_CID)
}
async fn get_repo(&self, user_id: Uuid) -> Result<Option<RepoInfo>, DbError> {
@@ -278,11 +299,14 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| RepoInfo {
user_id: r.user_id,
repo_root_cid: CidLink::from(r.repo_root_cid),
repo_rev: r.repo_rev.map(Tid::from),
}))
row.map(|r| {
Ok(RepoInfo {
user_id: r.user_id,
repo_root_cid: column(r.repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
repo_rev: opt_column(r.repo_rev, col::REPOS_REPO_REV)?,
})
})
.transpose()
}
async fn get_repo_root_by_did(&self, did: &Did) -> Result<Option<CidLink>, DbError> {
@@ -294,7 +318,7 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(result.map(CidLink::from))
opt_column(result, col::REPOS_REPO_ROOT_CID)
}
async fn count_repos(&self) -> Result<i64, DbError> {
@@ -312,13 +336,14 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| RepoWithoutRev {
user_id: r.user_id,
repo_root_cid: CidLink::from(r.repo_root_cid),
rows.into_iter()
.map(|r| {
Ok(RepoWithoutRev {
user_id: r.user_id,
repo_root_cid: column(r.repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
})
})
.collect())
.collect()
}
async fn upsert_records(
@@ -405,7 +430,7 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(result.map(CidLink::from))
opt_column(result, col::RECORDS_RECORD_CID)
}
async fn list_records(
@@ -418,13 +443,16 @@ impl RepoRepository for PostgresRepoRepository {
rkey_start: Option<&Rkey>,
rkey_end: Option<&Rkey>,
) -> Result<Vec<RecordInfo>, DbError> {
let to_record_info = |rows: Vec<RecordRow>| {
rows.into_iter()
.map(|r| RecordInfo {
rkey: Rkey::from(r.rkey),
record_cid: CidLink::from(r.record_cid),
let to_record_info = |rows: Vec<RecordRow>| -> Result<Vec<RecordInfo>, DbError> {
Ok(rows
.into_iter()
.filter_map(|r| {
Some(RecordInfo {
rkey: legacy_column(r.rkey, col::RECORDS_RKEY)?,
record_cid: legacy_column(r.record_cid, col::RECORDS_RECORD_CID)?,
})
})
.collect()
.collect())
};
let collection_str = collection.as_str();
@@ -446,7 +474,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(to_record_info(rows))
to_record_info(rows)
}
true => {
let rows = sqlx::query_as!(
@@ -462,7 +490,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(to_record_info(rows))
to_record_info(rows)
}
};
}
@@ -486,7 +514,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(to_record_info(rows))
to_record_info(rows)
}
true => {
let rows = sqlx::query_as!(
@@ -503,7 +531,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(to_record_info(rows))
to_record_info(rows)
}
};
}
@@ -525,7 +553,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(to_record_info(rows))
to_record_info(rows)
}
true => {
let rows = sqlx::query_as!(
@@ -541,7 +569,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(to_record_info(rows))
to_record_info(rows)
}
};
}
@@ -563,7 +591,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(to_record_info(rows))
to_record_info(rows)
}
true => {
let rows = sqlx::query_as!(
@@ -579,7 +607,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(to_record_info(rows))
to_record_info(rows)
}
};
}
@@ -598,7 +626,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(to_record_info(rows))
to_record_info(rows)
}
true => {
let rows = sqlx::query_as!(
@@ -613,7 +641,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(to_record_info(rows))
to_record_info(rows)
}
}
}
@@ -629,10 +657,12 @@ impl RepoRepository for PostgresRepoRepository {
Ok(rows
.into_iter()
.map(|r| FullRecordInfo {
collection: Nsid::from(r.collection),
rkey: Rkey::from(r.rkey),
record_cid: CidLink::from(r.record_cid),
.filter_map(|r| {
Some(FullRecordInfo {
collection: legacy_column(r.collection, col::RECORDS_COLLECTION)?,
rkey: legacy_column(r.rkey, col::RECORDS_RKEY)?,
record_cid: legacy_column(r.record_cid, col::RECORDS_RECORD_CID)?,
})
})
.collect())
}
@@ -646,7 +676,10 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows.into_iter().map(Nsid::from).collect())
Ok(rows
.into_iter()
.filter_map(|c| legacy_column(c, col::RECORDS_COLLECTION))
.collect())
}
async fn count_records(&self, repo_id: Uuid) -> Result<i64, DbError> {
@@ -688,6 +721,45 @@ impl RepoRepository for PostgresRepoRepository {
}))
}
async fn referenced_record_cids(
&self,
repo_id: Uuid,
cids: &[CidLink],
excluded_keys: &[(&Nsid, &Rkey)],
) -> Result<Vec<CidLink>, DbError> {
if cids.is_empty() {
return Ok(Vec::new());
}
let cid_strs: Vec<String> = cids.iter().map(|c| c.as_str().to_owned()).collect();
let (excluded_collections, excluded_rkeys): (Vec<String>, Vec<String>) = excluded_keys
.iter()
.map(|(collection, rkey)| (collection.as_str().to_owned(), rkey.as_str().to_owned()))
.unzip();
let rows = sqlx::query_scalar!(
r#"
SELECT DISTINCT r.record_cid AS "record_cid!"
FROM records r
WHERE r.repo_id = $1
AND r.record_cid = ANY($2)
AND NOT EXISTS (
SELECT 1 FROM UNNEST($3::text[], $4::text[]) AS k(collection, rkey)
WHERE k.collection = r.collection AND k.rkey = r.rkey
)
"#,
repo_id,
&cid_strs,
&excluded_collections,
&excluded_rkeys
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
column_vec(rows, col::RECORDS_RECORD_CID)
}
async fn set_record_takedown(
&self,
cid: &CidLink,
@@ -760,22 +832,37 @@ impl RepoRepository for PostgresRepoRepository {
async fn get_user_block_cids_since_rev(
&self,
user_id: Uuid,
since_rev: &Tid,
since_rev: Option<&Tid>,
) -> Result<Vec<Vec<u8>>, DbError> {
let rows: Vec<(Vec<u8>,)> = sqlx::query_as(
r#"
SELECT block_cid FROM user_blocks
WHERE user_id = $1 AND repo_rev > $2
ORDER BY repo_rev ASC
"#,
)
.bind(user_id)
.bind(since_rev.as_str())
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
let rows = match since_rev {
None => {
sqlx::query_scalar!(
r#"
SELECT block_cid AS "block_cid!" FROM user_blocks
WHERE user_id = $1
ORDER BY repo_rev ASC
"#,
user_id
)
.fetch_all(&self.pool)
.await
}
Some(rev) => {
sqlx::query_scalar!(
r#"
SELECT block_cid AS "block_cid!" FROM user_blocks
WHERE user_id = $1 AND repo_rev > $2
ORDER BY repo_rev ASC
"#,
user_id,
rev.as_str()
)
.fetch_all(&self.pool)
.await
}
};
Ok(rows.into_iter().map(|(cid,)| cid).collect())
rows.map_err(map_sqlx_error)
}
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<(), DbError> {
@@ -1024,13 +1111,16 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| RepoAccountInfo {
user_id: r.id,
did: Did::from(r.did),
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
repo_root_cid: r.repo_root_cid.map(CidLink::from),
}))
row.map(|r| {
Ok(RepoAccountInfo {
user_id: r.id,
did: column(r.did, col::USERS_DID)?,
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
repo_root_cid: opt_column(r.repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
})
})
.transpose()
}
async fn get_events_since_seq(
@@ -1054,7 +1144,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
rows.into_iter().map(map_sequenced_row).collect()
Ok(collect_sequenced_rows(rows))
}
None => {
let rows = sqlx::query_as!(
@@ -1069,7 +1159,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
rows.into_iter().map(map_sequenced_row).collect()
Ok(collect_sequenced_rows(rows))
}
}
}
@@ -1092,7 +1182,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
rows.into_iter().map(map_sequenced_row).collect()
Ok(collect_sequenced_rows(rows))
}
async fn get_event_by_seq(
@@ -1132,7 +1222,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
rows.into_iter().map(map_sequenced_row).collect()
Ok(collect_sequenced_rows(rows))
}
async fn list_repos_paginated(
@@ -1155,16 +1245,17 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| RepoListItem {
did: Did::from(r.did),
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
repo_root_cid: CidLink::from(r.repo_root_cid),
repo_rev: r.repo_rev.map(Tid::from),
rows.into_iter()
.map(|r| {
Ok(RepoListItem {
did: column(r.did, col::USERS_DID)?,
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
repo_root_cid: column(r.repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
repo_rev: opt_column(r.repo_rev, col::REPOS_REPO_REV)?,
})
})
.collect())
.collect()
}
async fn get_repo_root_cid_by_user_id(
@@ -1178,7 +1269,7 @@ impl RepoRepository for PostgresRepoRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(cid.map(CidLink::from))
opt_column(cid, col::REPOS_REPO_ROOT_CID)
}
async fn import_repo_data(
@@ -1520,14 +1611,15 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|(user_id, repo_root_cid, repo_rev)| UserWithoutBlocks {
user_id,
repo_root_cid: CidLink::from(repo_root_cid),
repo_rev: repo_rev.map(Tid::from),
rows.into_iter()
.map(|(user_id, repo_root_cid, repo_rev)| {
Ok(UserWithoutBlocks {
user_id,
repo_root_cid: column(repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
repo_rev: opt_column(repo_rev, col::REPOS_REPO_REV)?,
})
})
.collect())
.collect()
}
async fn get_users_needing_record_blobs_backfill(
@@ -1548,13 +1640,14 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| UserNeedingRecordBlobsBackfill {
user_id: r.user_id,
did: Did::from(r.did),
rows.into_iter()
.map(|r| {
Ok(UserNeedingRecordBlobsBackfill {
user_id: r.user_id,
did: column(r.did, col::USERS_DID)?,
})
})
.collect())
.collect()
}
async fn insert_record_blobs(
+82 -60
View File
@@ -10,7 +10,9 @@ use tranquil_db_traits::{
use tranquil_types::{Did, Jti, PasswordHash};
use uuid::Uuid;
use super::col;
use super::user::map_sqlx_error;
use super::{column, opt_column};
pub struct PostgresSessionRepository {
pool: PgPool,
@@ -69,21 +71,24 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| SessionToken {
id: SessionId::new(r.id),
did: Did::from(r.did),
access_jti: Jti::from(r.access_jti),
refresh_jti: Jti::from(r.refresh_jti),
access_expires_at: r.access_expires_at,
refresh_expires_at: r.refresh_expires_at,
login_type: LoginType::from_legacy_flag(r.legacy_login),
mfa_verified: r.mfa_verified,
scope: r.scope,
controller_did: r.controller_did.map(Did::from),
app_password_name: r.app_password_name,
created_at: r.created_at,
updated_at: r.updated_at,
}))
row.map(|r| {
Ok(SessionToken {
id: SessionId::new(r.id),
did: column(r.did, col::SESSION_TOKENS_DID)?,
access_jti: Jti::from(r.access_jti),
refresh_jti: Jti::from(r.refresh_jti),
access_expires_at: r.access_expires_at,
refresh_expires_at: r.refresh_expires_at,
login_type: LoginType::from_legacy_flag(r.legacy_login),
mfa_verified: r.mfa_verified,
scope: r.scope,
controller_did: opt_column(r.controller_did, col::SESSION_TOKENS_CONTROLLER_DID)?,
app_password_name: r.app_password_name,
created_at: r.created_at,
updated_at: r.updated_at,
})
})
.transpose()
}
async fn get_session_for_refresh(
@@ -104,14 +109,17 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| SessionForRefresh {
id: SessionId::new(r.id),
did: Did::from(r.did),
scope: r.scope,
controller_did: r.controller_did.map(Did::from),
key_bytes: r.key_bytes,
encryption_version: r.encryption_version.unwrap_or(0),
}))
row.map(|r| {
Ok(SessionForRefresh {
id: SessionId::new(r.id),
did: column(r.did, col::SESSION_TOKENS_DID)?,
scope: r.scope,
controller_did: opt_column(r.controller_did, col::SESSION_TOKENS_CONTROLLER_DID)?,
key_bytes: r.key_bytes,
encryption_version: r.encryption_version.unwrap_or(0),
})
})
.transpose()
}
async fn delete_session_by_access_jti(
@@ -274,9 +282,9 @@ impl SessionRepository for PostgresSessionRepository {
let grace_cutoff = Utc::now() - Duration::seconds(REFRESH_GRACE_PERIOD_SECS);
if r.used_at > grace_cutoff {
Ok(RefreshGraceLookup::Replay(RefreshGraceReplay {
did: Did::from(r.did),
did: column(r.did, col::SESSION_TOKENS_DID)?,
scope: r.scope,
controller_did: r.controller_did.map(Did::from),
controller_did: opt_column(r.controller_did, col::SESSION_TOKENS_CONTROLLER_DID)?,
access_jti: Jti::from(r.access_jti),
refresh_jti: Jti::from(r.refresh_jti),
access_expires_at: r.access_expires_at,
@@ -286,7 +294,7 @@ impl SessionRepository for PostgresSessionRepository {
}))
} else {
Ok(RefreshGraceLookup::Compromised {
did: Did::from(r.did),
did: column(r.did, col::SESSION_TOKENS_DID)?,
session_id: SessionId::new(r.session_id),
key_bytes: r.key_bytes,
encryption_version: r.encryption_version.unwrap_or(0),
@@ -308,19 +316,23 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| AppPasswordRecord {
id: r.id,
user_id: r.user_id,
name: r.name,
password_hash: PasswordHash::new(r.password_hash),
created_at: r.created_at,
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
scopes: r.scopes,
created_by_controller_did: r.created_by_controller_did.map(Did::from),
rows.into_iter()
.map(|r| {
Ok(AppPasswordRecord {
id: r.id,
user_id: r.user_id,
name: r.name,
password_hash: PasswordHash::new(r.password_hash),
created_at: r.created_at,
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
scopes: r.scopes,
created_by_controller_did: opt_column(
r.created_by_controller_did,
col::APP_PASSWORDS_CREATED_BY_CONTROLLER_DID,
)?,
})
})
.collect())
.collect()
}
async fn get_app_passwords_for_login(
@@ -341,19 +353,23 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| AppPasswordRecord {
id: r.id,
user_id: r.user_id,
name: r.name,
password_hash: PasswordHash::new(r.password_hash),
created_at: r.created_at,
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
scopes: r.scopes,
created_by_controller_did: r.created_by_controller_did.map(Did::from),
rows.into_iter()
.map(|r| {
Ok(AppPasswordRecord {
id: r.id,
user_id: r.user_id,
name: r.name,
password_hash: PasswordHash::new(r.password_hash),
created_at: r.created_at,
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
scopes: r.scopes,
created_by_controller_did: opt_column(
r.created_by_controller_did,
col::APP_PASSWORDS_CREATED_BY_CONTROLLER_DID,
)?,
})
})
.collect())
.collect()
}
async fn get_app_password_by_name(
@@ -374,16 +390,22 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| AppPasswordRecord {
id: r.id,
user_id: r.user_id,
name: r.name,
password_hash: PasswordHash::new(r.password_hash),
created_at: r.created_at,
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
scopes: r.scopes,
created_by_controller_did: r.created_by_controller_did.map(Did::from),
}))
row.map(|r| {
Ok(AppPasswordRecord {
id: r.id,
user_id: r.user_id,
name: r.name,
password_hash: PasswordHash::new(r.password_hash),
created_at: r.created_at,
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
scopes: r.scopes,
created_by_controller_did: opt_column(
r.created_by_controller_did,
col::APP_PASSWORDS_CREATED_BY_CONTROLLER_DID,
)?,
})
})
.transpose()
}
async fn create_app_password(&self, data: &AppPasswordCreate) -> Result<Uuid, DbError> {
+287 -214
View File
@@ -4,6 +4,8 @@ use sqlx::PgPool;
use tranquil_types::{AtIdentifier, Did, Handle, Jti, PasswordHash, TokenId};
use uuid::Uuid;
use super::col;
use super::{column, legacy_column, opt_column};
use tranquil_db_traits::{
AccountSearchResult, AccountType, ChannelVerificationStatus, CommsChannel, DbError,
DidWebOverrides, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, SsoProviderType,
@@ -87,17 +89,20 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserRow {
id: r.id,
did: Did::from(r.did),
handle: Handle::from(r.handle),
email: r.email,
created_at: r.created_at,
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
is_admin: r.is_admin,
inbound_migration: r.inbound_migration,
}))
row.map(|r| {
Ok(UserRow {
id: r.id,
did: column(r.did, col::USERS_DID)?,
handle: column(r.handle, col::USERS_HANDLE)?,
email: r.email,
created_at: r.created_at,
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
is_admin: r.is_admin,
inbound_migration: r.inbound_migration,
})
})
.transpose()
}
async fn get_by_handle(&self, handle: &Handle) -> Result<Option<UserRow>, DbError> {
@@ -110,17 +115,20 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserRow {
id: r.id,
did: Did::from(r.did),
handle: Handle::from(r.handle),
email: r.email,
created_at: r.created_at,
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
is_admin: r.is_admin,
inbound_migration: r.inbound_migration,
}))
row.map(|r| {
Ok(UserRow {
id: r.id,
did: column(r.did, col::USERS_DID)?,
handle: column(r.handle, col::USERS_HANDLE)?,
email: r.email,
created_at: r.created_at,
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
is_admin: r.is_admin,
inbound_migration: r.inbound_migration,
})
})
.transpose()
}
async fn get_with_key_by_did(&self, did: &Did) -> Result<Option<UserWithKey>, DbError> {
@@ -136,17 +144,20 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserWithKey {
id: r.id,
did: Did::from(r.did),
handle: Handle::from(r.handle),
email: r.email,
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
is_admin: r.is_admin,
key_bytes: r.key_bytes,
encryption_version: r.encryption_version,
}))
row.map(|r| {
Ok(UserWithKey {
id: r.id,
did: column(r.did, col::USERS_DID)?,
handle: column(r.handle, col::USERS_HANDLE)?,
email: r.email,
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
is_admin: r.is_admin,
key_bytes: r.key_bytes,
encryption_version: r.encryption_version,
})
})
.transpose()
}
async fn get_status_by_did(&self, did: &Did) -> Result<Option<UserStatus>, DbError> {
@@ -207,15 +218,18 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| OAuthTokenWithUser {
did: Did::from(r.did),
expires_at: r.expires_at,
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
is_admin: r.is_admin,
key_bytes: r.key_bytes,
encryption_version: r.encryption_version,
}))
row.map(|r| {
Ok(OAuthTokenWithUser {
did: column(r.did, col::OAUTH_TOKEN_DID)?,
expires_at: r.expires_at,
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
is_admin: r.is_admin,
key_bytes: r.key_bytes,
encryption_version: r.encryption_version,
})
})
.transpose()
}
async fn get_user_info_by_did(&self, did: &Did) -> Result<Option<UserInfoForAuth>, DbError> {
@@ -288,14 +302,16 @@ impl UserRepository for PostgresUserRepository {
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| AccountSearchResult {
did: Did::from(r.did),
handle: Handle::from(r.handle),
email: r.email,
created_at: r.created_at,
email_verified: r.email_verified,
deactivated_at: r.deactivated_at,
invites_disabled: r.invites_disabled,
.filter_map(|r| {
Some(AccountSearchResult {
did: legacy_column(r.did, col::USERS_DID)?,
handle: legacy_column(r.handle, col::USERS_HANDLE)?,
email: r.email,
created_at: r.created_at,
email_verified: r.email_verified,
deactivated_at: r.deactivated_at,
invites_disabled: r.invites_disabled,
})
})
.collect())
}
@@ -311,24 +327,27 @@ impl UserRepository for PostgresUserRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserAuthInfo {
id: r.id,
did: Did::from(r.did),
password_hash: r.password_hash.map(PasswordHash::new),
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
channel_verification: ChannelVerificationStatus::from_db_row(
r.email_verified,
r.discord_verified,
r.telegram_verified,
r.signal_verified,
),
}))
row.map(|r| {
Ok(UserAuthInfo {
id: r.id,
did: column(r.did, col::USERS_DID)?,
password_hash: r.password_hash.map(PasswordHash::new),
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
channel_verification: ChannelVerificationStatus::from_db_row(
r.email_verified,
r.discord_verified,
r.telegram_verified,
r.signal_verified,
),
})
})
.transpose()
}
async fn get_by_email(&self, email: &str) -> Result<Option<UserForVerification>, DbError> {
let row = sqlx::query!(
r#"SELECT id, did, email, email_verified, handle
r#"SELECT id, did, email, email_verified
FROM users
WHERE LOWER(email) = $1"#,
email
@@ -336,13 +355,15 @@ impl UserRepository for PostgresUserRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserForVerification {
id: r.id,
did: Did::from(r.did),
email: r.email,
email_verified: r.email_verified,
handle: Handle::from(r.handle),
}))
row.map(|r| {
Ok(UserForVerification {
id: r.id,
did: column(r.did, col::USERS_DID)?,
email: r.email,
email_verified: r.email_verified,
})
})
.transpose()
}
async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> {
@@ -354,15 +375,18 @@ impl UserRepository for PostgresUserRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserCommsPrefs {
email: r.email,
handle: Handle::from(r.handle),
preferred_channel: r.preferred_channel,
preferred_locale: r.preferred_locale,
telegram_chat_id: r.telegram_chat_id,
discord_id: r.discord_id,
signal_username: r.signal_username,
}))
row.map(|r| {
Ok(UserCommsPrefs {
email: r.email,
handle: column(r.handle, col::USERS_HANDLE)?,
preferred_channel: r.preferred_channel,
preferred_locale: r.preferred_locale,
telegram_chat_id: r.telegram_chat_id,
discord_id: r.discord_id,
signal_username: r.signal_username,
})
})
.transpose()
}
async fn get_id_by_did(&self, did: &Did) -> Result<Option<Uuid>, DbError> {
@@ -395,10 +419,13 @@ impl UserRepository for PostgresUserRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserIdAndHandle {
id: r.id,
handle: Handle::from(r.handle),
}))
row.map(|r| {
Ok(UserIdAndHandle {
id: r.id,
handle: column(r.handle, col::USERS_HANDLE)?,
})
})
.transpose()
}
async fn get_did_web_info_by_handle(
@@ -412,11 +439,14 @@ impl UserRepository for PostgresUserRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserDidWebInfo {
id: r.id,
did: Did::from(r.did),
migrated_to_pds: r.migrated_to_pds,
}))
row.map(|r| {
Ok(UserDidWebInfo {
id: r.id,
did: column(r.did, col::USERS_DID)?,
migrated_to_pds: r.migrated_to_pds,
})
})
.transpose()
}
async fn get_did_web_overrides(
@@ -441,7 +471,7 @@ impl UserRepository for PostgresUserRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(handle.map(Handle::from))
opt_column(handle, col::USERS_HANDLE)
}
async fn check_handle_exists(
@@ -538,12 +568,15 @@ impl UserRepository for PostgresUserRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserEmailInfo {
id: r.id,
handle: Handle::from(r.handle),
email: r.email,
email_verified: r.email_verified,
}))
row.map(|r| {
Ok(UserEmailInfo {
id: r.id,
handle: column(r.handle, col::USERS_HANDLE)?,
email: r.email,
email_verified: r.email_verified,
})
})
.transpose()
}
async fn check_email_exists(
@@ -726,11 +759,14 @@ impl UserRepository for PostgresUserRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserIdHandleEmail {
id: r.id,
handle: Handle::from(r.handle),
email: r.email,
}))
row.map(|r| {
Ok(UserIdHandleEmail {
id: r.id,
handle: column(r.handle, col::USERS_HANDLE)?,
email: r.email,
})
})
.transpose()
}
async fn update_preferred_comms_channel(
@@ -794,17 +830,20 @@ impl UserRepository for PostgresUserRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserVerificationInfo {
id: r.id,
handle: Handle::from(r.handle),
email: r.email,
channel_verification: ChannelVerificationStatus::from_db_row(
r.email_verified,
r.discord_verified,
r.telegram_verified,
r.signal_verified,
),
}))
row.map(|r| {
Ok(UserVerificationInfo {
id: r.id,
handle: column(r.handle, col::USERS_HANDLE)?,
email: r.email,
channel_verification: ChannelVerificationStatus::from_db_row(
r.email_verified,
r.discord_verified,
r.telegram_verified,
r.signal_verified,
),
})
})
.transpose()
}
async fn verify_email_channel(&self, user_id: Uuid, email: &str) -> Result<bool, DbError> {
@@ -954,21 +993,22 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| StoredPasskey {
id: r.id,
did: Did::from(r.did),
credential_id: r.credential_id,
public_key: r.public_key,
sign_count: r.sign_count,
created_at: r.created_at,
last_used: r.last_used,
friendly_name: r.friendly_name,
aaguid: r.aaguid,
transports: r.transports,
rows.into_iter()
.map(|r| {
Ok(StoredPasskey {
id: r.id,
did: column(r.did, col::PASSKEYS_DID)?,
credential_id: r.credential_id,
public_key: r.public_key,
sign_count: r.sign_count,
created_at: r.created_at,
last_used: r.last_used,
friendly_name: r.friendly_name,
aaguid: r.aaguid,
transports: r.transports,
})
})
.collect())
.collect()
}
async fn get_passkey_by_credential_id(
@@ -985,18 +1025,21 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| StoredPasskey {
id: r.id,
did: Did::from(r.did),
credential_id: r.credential_id,
public_key: r.public_key,
sign_count: r.sign_count,
created_at: r.created_at,
last_used: r.last_used,
friendly_name: r.friendly_name,
aaguid: r.aaguid,
transports: r.transports,
}))
row.map(|r| {
Ok(StoredPasskey {
id: r.id,
did: column(r.did, col::PASSKEYS_DID)?,
credential_id: r.credential_id,
public_key: r.public_key,
sign_count: r.sign_count,
created_at: r.created_at,
last_used: r.last_used,
friendly_name: r.friendly_name,
aaguid: r.aaguid,
transports: r.transports,
})
})
.transpose()
}
async fn save_passkey(
@@ -1431,13 +1474,14 @@ impl UserRepository for PostgresUserRepository {
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)
.map(|opt| {
opt.map(|r| UserLoginCheck {
did: Did::from(r.did),
.map_err(map_sqlx_error)?
.map(|r| {
Ok(UserLoginCheck {
did: column(r.did, col::USERS_DID)?,
password_hash: r.password_hash.map(PasswordHash::new),
})
})
.transpose()
}
async fn get_login_info_by_identifier(
@@ -1458,11 +1502,11 @@ impl UserRepository for PostgresUserRepository {
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)
.map(|opt| {
opt.map(|row| UserLoginInfo {
.map_err(map_sqlx_error)?
.map(|row| {
Ok(UserLoginInfo {
id: row.id,
did: Did::from(row.did),
did: column(row.did, col::USERS_DID)?,
email: row.email,
password_hash: row.password_hash.map(PasswordHash::new),
password_required: row.password_required,
@@ -1479,6 +1523,7 @@ impl UserRepository for PostgresUserRepository {
account_type: row.account_type,
})
})
.transpose()
}
async fn get_2fa_status_by_did(&self, did: &Did) -> Result<Option<User2faStatus>, DbError> {
@@ -1527,10 +1572,10 @@ impl UserRepository for PostgresUserRepository {
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)
.map(|opt| {
opt.map(|row| UserSessionInfo {
handle: Handle::from(row.handle),
.map_err(map_sqlx_error)?
.map(|row| {
Ok(UserSessionInfo {
handle: column(row.handle, col::USERS_HANDLE)?,
email: row.email,
is_admin: row.is_admin,
deactivated_at: row.deactivated_at,
@@ -1549,6 +1594,7 @@ impl UserRepository for PostgresUserRepository {
email_2fa_enabled: row.email_2fa_enabled,
})
})
.transpose()
}
async fn get_legacy_login_pref(
@@ -1620,12 +1666,12 @@ impl UserRepository for PostgresUserRepository {
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)
.map(|opt| {
opt.map(|row| UserLoginFull {
.map_err(map_sqlx_error)?
.map(|row| {
Ok(UserLoginFull {
id: row.id,
did: Did::from(row.did),
handle: Handle::from(row.handle),
did: column(row.did, col::USERS_DID)?,
handle: column(row.handle, col::USERS_HANDLE)?,
password_hash: row.password_hash.map(PasswordHash::new),
email: row.email,
deactivated_at: row.deactivated_at,
@@ -1645,6 +1691,7 @@ impl UserRepository for PostgresUserRepository {
email_2fa_enabled: row.email_2fa_enabled,
})
})
.transpose()
}
async fn get_confirm_signup_by_did(
@@ -1664,12 +1711,12 @@ impl UserRepository for PostgresUserRepository {
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)
.map(|opt| {
opt.map(|row| UserConfirmSignup {
.map_err(map_sqlx_error)?
.map(|row| {
Ok(UserConfirmSignup {
id: row.id,
did: Did::from(row.did),
handle: Handle::from(row.handle),
did: column(row.did, col::USERS_DID)?,
handle: column(row.handle, col::USERS_HANDLE)?,
email: row.email,
channel: row.channel,
discord_username: row.discord_username,
@@ -1679,6 +1726,7 @@ impl UserRepository for PostgresUserRepository {
encryption_version: row.encryption_version,
})
})
.transpose()
}
async fn get_resend_verification_by_did(
@@ -1697,11 +1745,11 @@ impl UserRepository for PostgresUserRepository {
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)
.map(|opt| {
opt.map(|row| UserResendVerification {
.map_err(map_sqlx_error)?
.map(|row| {
Ok(UserResendVerification {
id: row.id,
handle: Handle::from(row.handle),
handle: column(row.handle, col::USERS_HANDLE)?,
email: row.email,
channel: row.channel,
discord_username: row.discord_username,
@@ -1715,6 +1763,7 @@ impl UserRepository for PostgresUserRepository {
),
})
})
.transpose()
}
async fn set_channel_verified(&self, did: &Did, channel: CommsChannel) -> Result<(), DbError> {
@@ -1760,14 +1809,18 @@ impl UserRepository for PostgresUserRepository {
}
async fn get_handles_by_email(&self, email: &str) -> Result<Vec<Handle>, DbError> {
sqlx::query_scalar!(
let handles = sqlx::query_scalar!(
"SELECT handle FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL ORDER BY created_at DESC",
email
)
.fetch_all(&self.pool)
.await
.map(|handles| handles.into_iter().map(Handle::from).collect())
.map_err(map_sqlx_error)
.map_err(map_sqlx_error)?;
Ok(handles
.into_iter()
.filter_map(|h| legacy_column(h, col::USERS_HANDLE))
.collect())
}
async fn set_password_reset_code(
@@ -1798,15 +1851,16 @@ impl UserRepository for PostgresUserRepository {
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)
.map(|opt| {
opt.map(|row| UserResetCodeInfo {
.map_err(map_sqlx_error)?
.map(|row| {
Ok(UserResetCodeInfo {
id: row.id,
did: Did::from(row.did),
did: column(row.did, col::USERS_DID)?,
preferred_comms_channel: row.preferred_comms_channel,
expires_at: row.password_reset_code_expires_at,
})
})
.transpose()
}
async fn clear_password_reset_code(&self, user_id: Uuid) -> Result<(), DbError> {
@@ -1894,12 +1948,11 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(map_sqlx_error)?;
let did = column(user_did, col::USERS_DID)?;
tx.commit().await.map_err(map_sqlx_error)?;
Ok(PasswordResetResult {
did: Did::from(user_did),
session_jtis,
})
Ok(PasswordResetResult { did, session_jtis })
}
async fn activate_account(&self, did: &Did) -> Result<bool, DbError> {
@@ -2004,14 +2057,15 @@ impl UserRepository for PostgresUserRepository {
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)
.map(|opt| {
opt.map(|row| UserForDeletion {
.map_err(map_sqlx_error)?
.map(|row| {
Ok(UserForDeletion {
id: row.id,
password_hash: row.password_hash.map(PasswordHash::new),
handle: Handle::from(row.handle),
handle: column(row.handle, col::USERS_HANDLE)?,
})
})
.transpose()
}
async fn get_user_key_by_did(&self, did: &Did) -> Result<Option<UserKeyInfo>, DbError> {
@@ -2154,11 +2208,14 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserForDidDoc {
id: r.id,
handle: Handle::from(r.handle),
deactivated_at: r.deactivated_at,
}))
row.map(|r| {
Ok(UserForDidDoc {
id: r.id,
handle: column(r.handle, col::USERS_HANDLE)?,
deactivated_at: r.deactivated_at,
})
})
.transpose()
}
async fn get_user_for_did_doc_build(
@@ -2173,11 +2230,14 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserForDidDocBuild {
id: r.id,
handle: Handle::from(r.handle),
migrated_to_pds: r.migrated_to_pds,
}))
row.map(|r| {
Ok(UserForDidDocBuild {
id: r.id,
handle: column(r.handle, col::USERS_HANDLE)?,
migrated_to_pds: r.migrated_to_pds,
})
})
.transpose()
}
async fn upsert_did_web_overrides(
@@ -2234,13 +2294,16 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserForPasskeySetup {
id: r.id,
handle: Handle::from(r.handle),
recovery_token: r.recovery_token,
recovery_token_expires_at: r.recovery_token_expires_at,
password_required: r.password_required,
}))
row.map(|r| {
Ok(UserForPasskeySetup {
id: r.id,
handle: column(r.handle, col::USERS_HANDLE)?,
recovery_token: r.recovery_token,
recovery_token_expires_at: r.recovery_token_expires_at,
password_required: r.password_required,
})
})
.transpose()
}
async fn get_user_for_passkey_recovery(
@@ -2257,12 +2320,15 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserForPasskeyRecovery {
id: r.id,
did: Did::from(r.did),
handle: Handle::from(r.handle),
password_required: r.password_required,
}))
row.map(|r| {
Ok(UserForPasskeyRecovery {
id: r.id,
did: column(r.did, col::USERS_DID)?,
handle: column(r.handle, col::USERS_HANDLE)?,
password_required: r.password_required,
})
})
.transpose()
}
async fn set_recovery_token(
@@ -2292,13 +2358,16 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| UserForRecovery {
id: r.id,
did: Did::from(r.did),
preferred_comms_channel: r.preferred_comms_channel,
recovery_token: r.recovery_token,
recovery_token_expires_at: r.recovery_token_expires_at,
}))
row.map(|r| {
Ok(UserForRecovery {
id: r.id,
did: column(r.did, col::USERS_DID)?,
preferred_comms_channel: r.preferred_comms_channel,
recovery_token: r.recovery_token,
recovery_token_expires_at: r.recovery_token_expires_at,
})
})
.transpose()
}
async fn get_accounts_scheduled_for_deletion(
@@ -2322,10 +2391,12 @@ impl UserRepository for PostgresUserRepository {
Ok(rows
.into_iter()
.map(|r| tranquil_db_traits::ScheduledDeletionAccount {
id: r.id,
did: Did::from(r.did),
handle: Handle::from(r.handle),
.filter_map(|r| {
Some(tranquil_db_traits::ScheduledDeletionAccount {
id: r.id,
did: legacy_column(r.did, col::USERS_DID)?,
handle: legacy_column(r.handle, col::USERS_HANDLE)?,
})
})
.collect())
}
@@ -3029,13 +3100,15 @@ impl UserRepository for PostgresUserRepository {
));
}
let old_handle = legacy_column(old_handle, col::USERS_HANDLE);
tx.commit()
.await
.map_err(|e| tranquil_db_traits::MigrationReactivationError::Database(e.to_string()))?;
Ok(tranquil_db_traits::ReactivatedAccountInfo {
user_id: account_id,
old_handle: Handle::from(old_handle),
old_handle,
})
}
-2
View File
@@ -5,8 +5,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
tranquil-config = { workspace = true }
async-trait = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
-1
View File
@@ -5,7 +5,6 @@ edition.workspace = true
license.workspace = true
[features]
default = []
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:urlencoding"]
[dependencies]
+7
View File
@@ -11,6 +11,7 @@ tranquil-types = { workspace = true }
tranquil-config = { workspace = true }
tranquil-crypto = { workspace = true }
tranquil-db-traits = { workspace = true }
tranquil-scopes = { workspace = true }
axum = { workspace = true }
base64 = { workspace = true }
@@ -33,3 +34,9 @@ tracing = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
webauthn-rs = { workspace = true }
[dev-dependencies]
async-trait = { workspace = true }
[features]
bsky = []
@@ -1,4 +1,5 @@
use super::*;
use tranquil_types::Nsid;
#[derive(Debug, Serialize)]
pub struct ScopeInfo {
@@ -8,6 +9,33 @@ pub struct ScopeInfo {
pub description: String,
pub display_name: String,
pub granted: Option<bool>,
pub restricted: bool,
}
#[derive(Debug, Serialize)]
pub struct PermissionSetInfo {
pub nsid: Nsid,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
pub include_scope: String,
pub expanded: Vec<ScopeInfo>,
pub granted: Option<bool>,
pub restricted: bool,
}
#[derive(Debug, Serialize)]
pub struct FailedSetInfo {
// `given_*` is the value as requested by the client.
// Left as strings because the value from the client could be malformed.
#[serde(rename = "nsid")]
pub given_nsid: String,
#[serde(rename = "aud", skip_serializing_if = "Option::is_none")]
pub given_aud: Option<String>,
pub reason: tranquil_scopes::ResolveFailure,
}
#[derive(Debug, Serialize)]
@@ -18,6 +46,8 @@ pub struct ConsentResponse {
pub client_uri: Option<String>,
pub logo_uri: Option<String>,
pub scopes: Vec<ScopeInfo>,
pub permission_sets: Vec<PermissionSetInfo>,
pub failed_sets: Vec<FailedSetInfo>,
pub show_consent: bool,
pub did: Did,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -116,26 +146,14 @@ pub async fn consent_get(
None
};
let effective_scope_str = if let Some(ref grant) = delegation_grant {
tranquil_pds::delegation::intersect_scopes(
requested_scope_str,
grant.granted_scopes.as_str(),
)
} else {
requested_scope_str.to_string()
let authority = match delegation_grant.as_ref() {
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
None => scope_resolution::Authority::FullSelf,
};
let expanded_scope_str = match expand_include_scopes(&effective_scope_str).await {
Ok(s) => s,
Err(e) => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_scope",
&format!("Failed to expand permission set: {e}"),
);
}
};
let requested_scopes: Vec<&str> = expanded_scope_str.split_whitespace().collect();
let effective =
scope_resolution::resolve_effective_scopes(&*state.cache, requested_scope_str, authority)
.await;
let requested_scopes: Vec<&str> = effective.permitted.split_whitespace().collect();
let preferences = state
.repos
.oauth
@@ -146,30 +164,42 @@ pub async fn consent_get(
.iter()
.map(|p| (p.scope.as_str(), p.granted))
.collect();
let requested_scope_strings: Vec<String> =
requested_scopes.iter().map(|s| s.to_string()).collect();
let presented_item_strings: Vec<String> = effective
.outcome
.passthrough
.iter()
.cloned()
.chain(effective.outcome.sets.iter().map(|g| match &g.aud {
Some(a) => format!("include:{}?aud={}", g.nsid, a),
None => format!("include:{}", g.nsid),
}))
.collect();
let show_consent = should_show_consent(
state.repos.oauth.as_ref(),
&did,
&request_data.parameters.client_id,
&requested_scope_strings,
&presented_item_strings,
)
.await
.unwrap_or(true);
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
let scopes: Vec<ScopeInfo> = requested_scopes
.iter()
.map(|scope| {
let (category, required, description, display_name) = if let Some(def) =
tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(*scope)
{
let desc = if *scope == "atproto" && has_granular_scopes {
let grant_scope_str: Option<&str> =
delegation_grant.as_ref().map(|g| g.granted_scopes.as_str());
let is_restricted = |scope: &str| -> bool {
grant_scope_str.is_some_and(|g| !tranquil_pds::delegation::grant_covers(g, scope))
};
let make_scope_info = |scope: &str| -> ScopeInfo {
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 *scope == "atproto" && has_granular_scopes {
let name = if scope == "atproto" && has_granular_scopes {
"AT Protocol Access".to_string()
} else {
def.display_name.to_string()
@@ -195,18 +225,60 @@ pub async fn consent_get(
scope.to_string(),
)
};
let granted = pref_map.get(*scope).copied();
ScopeInfo {
scope: scope.to_string(),
category,
required,
description,
display_name,
granted,
let granted = pref_map.get(scope).copied();
ScopeInfo {
scope: scope.to_string(),
category,
required,
description,
display_name,
granted,
restricted: is_restricted(scope),
}
};
let scopes: Vec<ScopeInfo> = effective
.outcome
.passthrough
.iter()
.map(|s| make_scope_info(s))
.collect();
let permission_sets: Vec<PermissionSetInfo> = effective
.outcome
.sets
.iter()
.map(|g| {
let include_scope = match &g.aud {
Some(a) => format!("include:{}?aud={}", g.nsid, a),
None => format!("include:{}", g.nsid),
};
let expanded: Vec<ScopeInfo> = g.expanded.iter().map(|s| make_scope_info(s)).collect();
let restricted = !expanded.is_empty() && expanded.iter().all(|s| s.restricted);
PermissionSetInfo {
nsid: g.nsid.clone(),
aud: g.aud.clone(),
title: g.title.clone(),
detail: g.detail.clone(),
granted: pref_map.get(include_scope.as_str()).copied(),
include_scope,
expanded,
restricted,
}
})
.collect();
let failed_sets: Vec<FailedSetInfo> = effective
.outcome
.failures
.iter()
.map(|f| FailedSetInfo {
given_nsid: f.given_nsid.clone(),
given_aud: f.given_aud.clone(),
reason: f.reason.clone(),
})
.collect();
let account_handle = state
.repos
.user
@@ -255,6 +327,8 @@ pub async fn consent_get(
client_uri: client_metadata.as_ref().and_then(|m| m.client_uri.clone()),
logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()),
scopes,
permission_sets,
failed_sets,
show_consent,
did: did.clone(),
handle: account_handle,
@@ -342,16 +416,53 @@ pub async fn consent_post(
None => None,
};
let effective_scope_str = if let Some(ref grant) = delegation_grant {
tranquil_pds::delegation::intersect_scopes(
original_scope_str,
grant.granted_scopes.as_str(),
)
} else {
original_scope_str.to_string()
let authority = match delegation_grant.as_ref() {
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
None => scope_resolution::Authority::FullSelf,
};
let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
let atproto_was_requested = requested_scopes.contains(&"atproto");
let effective =
scope_resolution::resolve_effective_scopes(&*state.cache, original_scope_str, authority)
.await;
let include_token = |nsid: &str, aud: &Option<String>| -> String {
match aud {
Some(a) => format!("include:{}?aud={}", nsid, a),
None => format!("include:{}", nsid),
}
};
let approved_failed_sets: Vec<String> = effective
.outcome
.failures
.iter()
.filter(|f| {
form.approved_scopes
.contains(&include_token(&f.given_nsid, &f.given_aud))
})
.map(|f| f.given_nsid.clone())
.collect();
if !approved_failed_sets.is_empty() {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_scope",
&format!(
"Could not resolve approved permission set(s): {}",
approved_failed_sets.join(", ")
),
);
}
let presented_items: Vec<String> = effective
.outcome
.passthrough
.iter()
.cloned()
.chain(
effective
.outcome
.sets
.iter()
.map(|g| include_token(&g.nsid, &g.aud)),
)
.collect();
let atproto_was_requested = presented_items.iter().any(|s| s == "atproto");
if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) {
return json_error(
StatusCode::BAD_REQUEST,
@@ -359,7 +470,8 @@ pub async fn consent_post(
"The atproto scope was requested and must be approved",
);
}
let final_approved: Vec<String> = form.approved_scopes.clone();
let mut final_approved: Vec<String> = form.approved_scopes.clone();
final_approved.retain(|s| presented_items.iter().any(|p| p == s) || s == "atproto");
if final_approved.is_empty() {
return json_error(
StatusCode::BAD_REQUEST,
@@ -377,11 +489,11 @@ pub async fn consent_post(
);
}
if form.remember {
let preferences: Vec<ScopePreference> = requested_scopes
let preferences: Vec<ScopePreference> = presented_items
.iter()
.map(|s| ScopePreference {
scope: s.to_string(),
granted: form.approved_scopes.contains(&s.to_string()),
scope: s.clone(),
granted: form.approved_scopes.contains(s),
})
.collect();
let _ = state
@@ -15,7 +15,7 @@ use tranquil_pds::auth::{BareLoginIdentifier, NormalizedLoginIdentifier};
use tranquil_pds::comms::comms_repo::enqueue_2fa_code;
use tranquil_pds::oauth::{
AuthFlow, ClientMetadataCache, DeviceData, DeviceId, OAuthError, Prompt, SessionId,
db::should_show_consent, scopes::expand_include_scopes,
db::should_show_consent,
};
use tranquil_pds::rate_limit::{
OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit,
@@ -300,6 +300,7 @@ mod consent;
mod login;
mod passkey;
mod registration;
pub mod scope_resolution;
mod two_factor;
pub use consent::*;
@@ -12,7 +12,16 @@ pub async fn register_complete(
_rate_limit: OAuthRateLimited<OAuthRegisterCompleteLimit>,
Json(form): Json<RegisterCompleteInput>,
) -> Response {
let did = Did::from(form.did.clone());
let Ok(did) = Did::new(form.did.as_str()) else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid DID."
})),
)
.into_response();
};
let request_id = RequestId::from(form.request_uri.clone());
let request_data = match state
@@ -97,12 +106,12 @@ pub async fn register_complete(
}
if let Some(existing_did) = &request_data.did
&& existing_did != &form.did
&& existing_did != &did
{
tracing::warn!(
request_uri = %form.request_uri,
existing_did = %existing_did,
attempted_did = %form.did,
attempted_did = %did,
"register_complete attempted with different DID than already bound"
);
return (
@@ -0,0 +1,122 @@
use tranquil_db_traits::DbScope;
use tranquil_pds::cache::Cache;
use tranquil_pds::delegation::intersect_scopes;
use tranquil_pds::oauth::permission_set_resolver::expand_scopes;
use tranquil_scopes::ExpansionOutcome;
pub enum Authority<'a> {
FullSelf,
Delegated(&'a DbScope),
}
pub struct EffectiveScopes {
// The expanded set of scopes, minus any denied by delegation
pub permitted: String,
// The expanded set of scopes, before delegation processing
pub outcome: ExpansionOutcome,
}
pub async fn resolve_effective_scopes(
cache: &dyn Cache,
requested: &str,
authority: Authority<'_>,
) -> EffectiveScopes {
let outcome = expand_scopes(cache, requested).await;
let expanded = outcome.to_scope_string();
let permitted = match authority {
Authority::FullSelf => expanded,
Authority::Delegated(granted) => intersect_scopes(&expanded, granted.as_str()),
};
EffectiveScopes { permitted, outcome }
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use tranquil_pds::cache::{Cache, CacheError};
#[derive(Default)]
struct MapCache(Mutex<HashMap<String, String>>);
#[async_trait::async_trait]
impl Cache for MapCache {
async fn get(&self, k: &str) -> Option<String> {
self.0.lock().unwrap().get(k).cloned()
}
async fn set(&self, k: &str, v: &str, _t: Duration) -> Result<(), CacheError> {
self.0.lock().unwrap().insert(k.into(), v.into());
Ok(())
}
async fn delete(&self, k: &str) -> Result<(), CacheError> {
self.0.lock().unwrap().remove(k);
Ok(())
}
async fn get_bytes(&self, _k: &str) -> Option<Vec<u8>> {
None
}
async fn set_bytes(&self, _k: &str, _v: &[u8], _t: Duration) -> Result<(), CacheError> {
Ok(())
}
}
fn cache_with(nsid: &str, scopes: &str) -> MapCache {
let c = MapCache::default();
let key = tranquil_pds::cache_keys::permission_set_key(
&tranquil_types::Nsid::new(nsid).unwrap(),
None,
);
let json = serde_json::json!({
"scope": scopes,
"title": null,
"detail": null,
"refreshed_at": chrono::Utc::now().timestamp(),
})
.to_string();
c.0.lock().unwrap().insert(key, json);
c
}
#[tokio::test]
async fn full_self_keeps_all_expanded() {
let c = cache_with(
"io.atcr.authFullApp",
"repo:io.atcr.manifest?action=create identity:*",
);
let eff = resolve_effective_scopes(
&c,
"atproto include:io.atcr.authFullApp",
Authority::FullSelf,
)
.await;
assert!(eff.permitted.contains("atproto"));
assert!(
eff.permitted
.contains("repo:io.atcr.manifest?action=create")
);
assert!(eff.permitted.contains("identity:*"));
assert!(eff.outcome.failures.is_empty());
}
#[tokio::test]
async fn delegated_intersects_expanded_against_grant() {
let c = cache_with(
"io.atcr.authFullApp",
"repo:io.atcr.manifest?action=create identity:*",
);
let granted = DbScope::new("atproto repo:* blob:*/* account:*?action=manage").unwrap();
let eff = resolve_effective_scopes(
&c,
"atproto include:io.atcr.authFullApp",
Authority::Delegated(&granted),
)
.await;
assert!(eff.permitted.contains("atproto"));
assert!(
eff.permitted
.contains("repo:io.atcr.manifest?action=create")
);
assert!(!eff.permitted.contains("identity"));
}
}
@@ -7,12 +7,10 @@ use axum::http::{HeaderMap, Method};
use chrono::{Duration, Utc};
use tranquil_db_traits::RefreshTokenLookup;
use tranquil_pds::config::AuthConfig;
use tranquil_pds::delegation::intersect_scopes;
use tranquil_pds::oauth::{
AuthFlow, ClientAuth, ClientMetadataCache, DPoPVerifier, OAuthError, RefreshToken, TokenData,
TokenId,
db::{enforce_token_limit_for_user, lookup_refresh_token},
scopes::expand_include_scopes,
verify_client_auth,
};
use tranquil_pds::state::AppState;
@@ -132,46 +130,55 @@ pub async fn handle_authorization_code_grant(
let refresh_token = RefreshToken::generate();
let now = Utc::now();
let (raw_scope, controller_did) = if let Some(ref controller) = authorized.controller_did {
let grant = state
.repos
.delegation
.get_delegation(&did, controller)
.await
.ok()
.flatten();
let granted_scopes = match grant {
Some(g) => g.granted_scopes,
None => {
return Err(OAuthError::InvalidGrant(
"Delegation grant not found or revoked".to_string(),
));
}
};
let requested = authorized.parameters.scope.as_deref().unwrap_or("atproto");
let intersected = intersect_scopes(requested, granted_scopes.as_str());
(Some(intersected), Some(controller.clone()))
} else {
(authorized.parameters.scope.clone(), None)
};
let controller_did = authorized.controller_did.clone();
let requested_scope = authorized.parameters.scope.clone();
let final_scope = if let Some(ref scope) = raw_scope {
if scope.contains("include:") {
Some(expand_include_scopes(scope).await.map_err(|e| {
OAuthError::InvalidScope(format!("Failed to expand permission set: {e}"))
})?)
let granted_scopes: Option<tranquil_db_traits::DbScope> =
if let Some(ref controller) = controller_did {
let grant = state
.repos
.delegation
.get_delegation(&did, controller)
.await
.ok()
.flatten()
.ok_or_else(|| {
OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string())
})?;
Some(grant.granted_scopes.clone())
} else {
raw_scope
}
} else {
raw_scope
None
};
let authority = match granted_scopes.as_ref() {
Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g),
None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf,
};
let requested_for_resolve = requested_scope.as_deref().unwrap_or("atproto");
let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes(
&*state.cache,
requested_for_resolve,
authority,
)
.await;
if !effective.outcome.failures.is_empty() {
let names: Vec<String> = effective
.outcome
.failures
.iter()
.map(|f| f.given_nsid.clone())
.collect();
return Err(OAuthError::InvalidScope(format!(
"Could not resolve permission set(s): {}",
names.join(", ")
)));
}
let resolved_scope = effective.permitted;
let access_token = create_access_token_with_delegation(
&token_id,
&did,
dpop_jkt.as_ref(),
final_scope.as_deref(),
Some(resolved_scope.as_str()),
controller_did.as_ref(),
)?;
let stored_client_auth = authorized.client_auth.unwrap_or(ClientAuth::None);
@@ -195,7 +202,7 @@ pub async fn handle_authorization_code_grant(
details: None,
code: None,
current_refresh_token: Some(refresh_token.clone()),
scope: final_scope.clone(),
scope: requested_scope.clone(),
controller_did: controller_did.clone(),
};
state
@@ -237,12 +244,58 @@ pub async fn handle_authorization_code_grant(
},
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS,
refresh_token: Some(refresh_token),
scope: final_scope,
scope: Some(resolved_scope.clone()),
sub: Some(did),
}),
))
}
async fn recompute_resolved_scope(
state: &AppState,
token_data: &TokenData,
) -> Result<String, OAuthError> {
let requested = token_data.scope.as_deref().unwrap_or("atproto");
let granted_scopes: Option<tranquil_db_traits::DbScope> =
if let Some(ref controller) = token_data.controller_did {
let grant = state
.repos
.delegation
.get_delegation(&token_data.did, controller)
.await
.ok()
.flatten()
.ok_or_else(|| {
OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string())
})?;
Some(grant.granted_scopes.clone())
} else {
None
};
let authority = match granted_scopes.as_ref() {
Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g),
None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf,
};
let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes(
&*state.cache,
requested,
authority,
)
.await;
if !effective.outcome.failures.is_empty() {
let names: Vec<String> = effective
.outcome
.failures
.iter()
.map(|f| f.given_nsid.clone())
.collect();
return Err(OAuthError::InvalidScope(format!(
"Permission set(s) expired and unresolvable: {}",
names.join(", ")
)));
}
Ok(effective.permitted)
}
pub async fn handle_refresh_token_grant(
state: AppState,
_headers: HeaderMap,
@@ -282,11 +335,12 @@ pub async fn handle_refresh_token_grant(
"Refresh token reuse within grace period, returning existing tokens"
);
let dpop_jkt = token_data.parameters.dpop_jkt.as_ref();
let resolved = recompute_resolved_scope(&state, &token_data).await?;
let access_token = create_access_token_with_delegation(
&token_data.token_id,
&token_data.did,
dpop_jkt,
token_data.scope.as_deref(),
Some(resolved.as_str()),
token_data.controller_did.as_ref(),
)?;
let mut response_headers = HeaderMap::new();
@@ -307,7 +361,7 @@ pub async fn handle_refresh_token_grant(
},
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS,
refresh_token: token_data.current_refresh_token,
scope: token_data.scope,
scope: Some(resolved),
sub: Some(token_data.did),
}),
));
@@ -396,11 +450,12 @@ pub async fn handle_refresh_token_grant(
new_expires_at = %new_expires_at,
"Refresh token rotated successfully"
);
let resolved = recompute_resolved_scope(&state, &token_data).await?;
let access_token = create_access_token_with_delegation(
&token_data.token_id,
&token_data.did,
dpop_jkt.as_ref(),
token_data.scope.as_deref(),
Some(resolved.as_str()),
token_data.controller_did.as_ref(),
)?;
let mut response_headers = HeaderMap::new();
@@ -421,7 +476,7 @@ pub async fn handle_refresh_token_grant(
},
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS,
refresh_token: Some(new_refresh_token),
scope: token_data.scope,
scope: Some(resolved),
sub: Some(token_data.did),
}),
))
@@ -106,6 +106,10 @@ pub async fn introspect_token(
Ok(info) => info,
Err(_) => return Ok(Json(inactive_response)),
};
let jwt_info = match tranquil_pds::oauth::verify::extract_oauth_token_info(&request.token) {
Ok(info) => info,
Err(_) => return Ok(Json(inactive_response)),
};
let token_id = TokenId::from(token_info.sid.clone());
let token_data = match state.repos.oauth.get_token_by_id(&token_id).await {
Ok(Some(data)) => data,
@@ -118,7 +122,7 @@ pub async fn introspect_token(
let issuer = format!("https://{}", pds_hostname);
Ok(Json(IntrospectResponse {
active: true,
scope: token_data.scope,
scope: jwt_info.scope,
client_id: Some(token_data.client_id),
username: None,
token_type: if token_data.parameters.dpop_jkt.is_some() {
@@ -129,7 +133,7 @@ pub async fn introspect_token(
exp: Some(token_info.exp),
iat: Some(token_info.iat),
nbf: Some(token_info.iat),
sub: Some(token_data.did.to_string()),
sub: Some(jwt_info.did.to_string()),
aud: Some(issuer.clone()),
iss: Some(issuer),
jti: Some(token_info.jti),
@@ -1023,7 +1023,9 @@ pub async fn complete_registration(
}
let encoded_handle = handle.replace(':', "%3A");
let self_hosted_did =
tranquil_pds::types::Did::from(format!("did:web:{}", encoded_handle));
tranquil_pds::types::Did::new(format!("did:web:{}", encoded_handle)).map_err(
|_| ApiError::InvalidHandle(Some("Handle is not a valid did:web".into())),
)?;
tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account");
self_hosted_did
}
@@ -1151,8 +1153,8 @@ pub async fn complete_registration(
.filter(|s| !s.is_empty()),
encrypted_key_bytes: encrypted_key_bytes.clone(),
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
commit_cid: tranquil_types::CidLink::from(commit_cid.to_string()),
repo_rev: tranquil_types::Tid::from(rev.as_ref().to_string()),
commit_cid: tranquil_types::CidLink::from_cid(&commit_cid),
repo_rev: tranquil_types::Tid::from(rev.clone()),
genesis_block_cids,
invite_code: invite_registration.into_invite_code(),
birthdate_pref,
@@ -1207,20 +1209,23 @@ pub async fn complete_registration(
tracing::warn!("Failed to sequence account event for {}: {}", did, e);
}
let profile_record = json!({
"$type": "app.bsky.actor.profile",
"displayName": handle.as_str()
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
&state,
&did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
#[cfg(feature = "bsky")]
{
tracing::warn!("Failed to create default profile for {}: {}", did, e);
let profile_record = json!({
"$type": "app.bsky.actor.profile",
"displayName": handle.as_str()
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
&state,
&did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
{
tracing::warn!("Failed to create default profile for {}: {}", did, e);
};
}
let app_password = generate_app_password();
+10 -13
View File
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
tranquil-types = { workspace = true }
tranquil-config = { workspace = true }
tranquil-crypto = { workspace = true }
tranquil-storage = { workspace = true }
tranquil-cache = { workspace = true }
tranquil-repo = { workspace = true }
@@ -29,13 +28,11 @@ axum = { workspace = true }
base32 = { workspace = true }
base64 = { workspace = true }
bcrypt = { workspace = true }
bs58 = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true }
cid = { workspace = true }
ed25519-dalek = { workspace = true }
futures = { workspace = true }
hex = { workspace = true }
futures-util = { workspace = true }
governor = { workspace = true }
hickory-resolver = { workspace = true }
@@ -43,7 +40,6 @@ hkdf = { workspace = true }
hmac = { workspace = true }
http = { workspace = true }
image = { workspace = true }
infer = { workspace = true }
ipld-core = { workspace = true }
iroh-car = { workspace = true }
jacquard-common = { workspace = true }
@@ -57,7 +53,6 @@ multihash = { workspace = true }
p256 = { workspace = true }
parking_lot = { workspace = true }
rand = { workspace = true }
redis = { workspace = true, optional = true }
regex = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
@@ -70,7 +65,6 @@ sqlx = { workspace = true }
subtle = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
tokio-util = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
@@ -80,16 +74,14 @@ urlencoding = { workspace = true }
uuid = { workspace = true }
webauthn-rs = { workspace = true }
webauthn-rs-proto = { workspace = true }
zip = { workspace = true }
aws-config = { workspace = true, optional = true }
aws-sdk-s3 = { workspace = true, optional = true }
[features]
default = ["frontend", "s3", "valkey"]
bsky = ["bsky-support"]
bsky-support = []
external-infra = []
s3-storage = ["tranquil-storage/s3", "dep:aws-config", "dep:aws-sdk-s3"]
s3 = ["s3-storage"]
valkey = ["tranquil-cache/valkey", "dep:redis"]
postgres = ["tranquil-db/postgres"]
s3 = ["tranquil-storage/s3"]
valkey = ["tranquil-cache/valkey"]
frontend = []
native-tls-roots = ["tranquil-oauth/native-tls-roots"]
@@ -105,3 +97,8 @@ tranquil-api = { workspace = true }
tranquil-oauth-server = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
wiremock = { workspace = true }
hex = { workspace = true }
tokio-tungstenite = { workspace = true }
aws-config = { workspace = true }
aws-sdk-s3 = { workspace = true }
redis = { workspace = true }
+2
View File
@@ -763,6 +763,8 @@ impl From<crate::api::validation::HandleValidationError> for ApiError {
HandleValidationError::BannedWord => {
Self::InvalidHandle(Some("Inappropriate language in handle".to_string()))
}
HandleValidationError::UnusableHandleDomain
| HandleValidationError::NoHandleDomains => Self::InternalError(Some(e.to_string())),
_ => Self::InvalidHandle(Some(e.to_string())),
}
}
+1
View File
@@ -2,6 +2,7 @@ pub mod error;
pub mod invite;
pub mod proxy;
pub mod proxy_client;
pub mod query;
pub mod responses;
pub mod validation;
+30 -23
View File
@@ -19,9 +19,7 @@ use tower::{Service, util::BoxCloneSyncService};
use tracing::{error, info, warn};
static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
[
"app.bsky.actor.getPreferences",
"app.bsky.actor.putPreferences",
let mut methods: HashSet<&str> = [
"com.atproto.admin.deleteAccount",
"com.atproto.admin.disableAccountInvites",
"com.atproto.admin.disableInviteCodes",
@@ -103,7 +101,13 @@ static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
"com.atproto.temp.dereferenceScope",
]
.into_iter()
.collect()
.collect();
// BSKY: the Bluesky preferences API must be implemented by PDSs
if cfg!(feature = "bsky-support") {
methods.insert("app.bsky.actor.getPreferences");
methods.insert("app.bsky.actor.putPreferences");
};
methods
});
fn is_protected_method(method: &str) -> bool {
@@ -111,6 +115,7 @@ fn is_protected_method(method: &str) -> bool {
}
/// Fetch the `feed` generator record from the AppView and return its `did`.
#[cfg(feature = "bsky-support")]
async fn resolve_feed_generator_did(appview_url: &str, query: Option<&str>) -> Option<Did> {
#[derive(serde::Deserialize)]
struct GetFeedQuery {
@@ -201,6 +206,7 @@ impl<S: Service<Request, Response = Response, Error = Infallible>> Service<Reque
}
// If the age assurance override is set and this is an age assurance call then we dont want to proxy even if the client requests it
#[cfg(feature = "bsky")]
if tranquil_config::get().server.age_assurance_override
&& (path.ends_with("app.bsky.ageassurance.getState")
|| path.ends_with("app.bsky.unspecced.getAgeAssuranceState"))
@@ -328,27 +334,28 @@ async fn proxy_handler(
},
};
// getFeed must be audienced to the feed generator, not the AppView.
let (token_aud, token_lxm) = if method == "app.bsky.feed.getFeed" {
match resolve_feed_generator_did(&resolved.url, query.as_deref()).await {
Some(feed_did) => (
feed_did,
"app.bsky.feed.getFeedSkeleton"
.parse::<Nsid>()
.expect("getFeedSkeleton is a valid NSID"),
),
None => {
warn!(
"getFeed proxy: could not resolve feed generator DID; refusing \
// BSKY: getFeed must be audienced to the feed generator, not the AppView.
let (token_aud, token_lxm) =
if cfg!(feature = "bsky-support") && method == "app.bsky.feed.getFeed" {
match resolve_feed_generator_did(&resolved.url, query.as_deref()).await {
Some(feed_did) => (
feed_did,
"app.bsky.feed.getFeedSkeleton"
.parse::<Nsid>()
.expect("getFeedSkeleton is a valid NSID"),
),
None => {
warn!(
"getFeed proxy: could not resolve feed generator DID; refusing \
to mint an AppView-audienced token"
);
return ApiError::InvalidRequest("Could not resolve feed".into())
.into_response();
);
return ApiError::InvalidRequest("Could not resolve feed".into())
.into_response();
}
}
}
} else {
(resolved.did.clone(), method_nsid.clone())
};
} else {
(resolved.did.clone(), method_nsid.clone())
};
match crate::auth::create_service_token(
&auth_user.did,
+11 -1
View File
@@ -147,7 +147,9 @@ impl std::fmt::Display for SsrfError {
}
impl std::error::Error for SsrfError {}
// TODO: update to match https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/pipethrough.ts
// Currently spec says nothing at all!! about forwarding headers during proxying and https://github.com/bluesky-social/atproto/discussions/2350
#[cfg(feature = "bsky-support")]
pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| {
[
HeaderName::from_static("accept-language"),
@@ -156,6 +158,14 @@ pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| {
http::header::CONTENT_TYPE,
]
});
#[cfg(not(feature = "bsky-support"))]
pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 3]> = LazyLock::new(|| {
[
HeaderName::from_static("accept-language"),
crate::util::HEADER_ATPROTO_ACCEPT_LABELERS,
http::header::CONTENT_TYPE,
]
});
pub static RESPONSE_HEADERS_TO_FORWARD: LazyLock<[HeaderName; 6]> = LazyLock::new(|| {
[
crate::util::HEADER_ATPROTO_REPO_REV,
+18
View File
@@ -0,0 +1,18 @@
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use serde::de::DeserializeOwned;
use super::error::ApiError;
pub struct XrpcQuery<T>(pub T);
impl<T: DeserializeOwned, S: Send + Sync> FromRequestParts<S> for XrpcQuery<T> {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let raw = parts.uri.query().unwrap_or_default();
serde_urlencoded::from_str(raw)
.map(Self)
.map_err(|e| ApiError::InvalidRequest(e.to_string()))
}
}
+2 -2
View File
@@ -28,8 +28,8 @@ pub struct DidResponse {
}
impl DidResponse {
pub fn response(did: impl Into<Did>) -> impl IntoResponse {
Json(Self { did: did.into() })
pub fn response(did: Did) -> impl IntoResponse {
Json(Self { did })
}
}
+76 -16
View File
@@ -101,13 +101,17 @@ fn validate_email_detailed(email: &str) -> Result<(), EmailValidationError> {
pub enum HandleValidationError {
Empty,
TooShort,
TooLong,
TooLong { max: usize },
InvalidCharacters,
StartsWithInvalidChar,
EndsWithInvalidChar,
ContainsSpaces,
BannedWord,
Reserved,
InvalidSyntax,
DisallowedTld,
UnusableHandleDomain,
NoHandleDomains,
}
impl std::fmt::Display for HandleValidationError {
@@ -119,11 +123,9 @@ impl std::fmt::Display for HandleValidationError {
"Handle must be at least {} characters",
MIN_HANDLE_LENGTH
),
Self::TooLong => write!(
f,
"Handle exceeds maximum length of {} characters",
MAX_SERVICE_HANDLE_LOCAL_PART
),
Self::TooLong { max } => {
write!(f, "Handle exceeds maximum length of {} characters", max)
}
Self::InvalidCharacters => write!(
f,
"Handle contains invalid characters. Only alphanumeric characters and hyphens are allowed"
@@ -135,6 +137,15 @@ impl std::fmt::Display for HandleValidationError {
Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"),
Self::BannedWord => write!(f, "Inappropriate language in handle"),
Self::Reserved => write!(f, "Reserved handle"),
Self::InvalidSyntax => write!(f, "Handle does not match atproto handle syntax"),
Self::DisallowedTld => write!(f, "Handle uses a reserved TLD and cannot resolve"),
Self::UnusableHandleDomain => write!(
f,
"This server's handle domain has a reserved TLD, so no handle under it is a valid atproto handle"
),
Self::NoHandleDomains => {
write!(f, "No handle domains are configured on this server")
}
}
}
}
@@ -159,7 +170,9 @@ pub fn validate_full_domain_handle(handle: &str) -> Result<Handle, HandleValidat
}
if handle.len() > MAX_HANDLE_LENGTH {
return Err(HandleValidationError::TooLong);
return Err(HandleValidationError::TooLong {
max: MAX_HANDLE_LENGTH,
});
}
if handle
@@ -190,7 +203,11 @@ pub fn validate_full_domain_handle(handle: &str) -> Result<Handle, HandleValidat
return Err(HandleValidationError::BannedWord);
}
Ok(Handle::from(handle_lower))
let handle = Handle::new(handle_lower).map_err(|_| HandleValidationError::InvalidSyntax)?;
match handle.has_disallowed_tld() {
true => Err(HandleValidationError::DisallowedTld),
false => Ok(handle),
}
}
pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
@@ -210,16 +227,40 @@ pub fn resolve_handle_input(input: &str) -> Result<Handle, HandleValidationError
None => input,
};
let validated = validate_short_handle(handle_to_validate)?;
Ok(Handle::from(format!(
"{}.{}",
validated,
matched_domain.unwrap_or(&available_domains[0])
)))
let domain = matched_domain
.or_else(|| available_domains.first())
.ok_or(HandleValidationError::NoHandleDomains)?;
let handle = Handle::new(format!("{}.{}", validated, domain))
.map_err(|_| HandleValidationError::InvalidSyntax)?;
match handle.has_disallowed_tld() {
true => Err(HandleValidationError::UnusableHandleDomain),
false => Ok(handle),
}
} else {
validate_full_domain_handle(input)
}
}
pub fn domain_forms_valid_handles(domain: &str) -> bool {
Handle::new(format!("whelk.{domain}")).is_ok_and(|h| !h.has_disallowed_tld())
}
pub fn warn_unusable_handle_domains() {
tranquil_config::get()
.server
.user_handle_domain_list()
.iter()
.filter(|domain| !domain_forms_valid_handles(domain))
.for_each(|domain| {
tracing::error!(
domain = %domain,
"configured handle domain can't form a valid atproto handle, so every account \
creation under it will be rejected. Set server.user_handle_domains to a domain \
whose TLD isn't reserved."
);
});
}
pub fn validate_service_handle(
handle: &str,
reserved_policy: ReservedHandlePolicy,
@@ -239,7 +280,9 @@ pub fn validate_service_handle(
}
if handle.len() > MAX_SERVICE_HANDLE_LOCAL_PART {
return Err(HandleValidationError::TooLong);
return Err(HandleValidationError::TooLong {
max: MAX_SERVICE_HANDLE_LOCAL_PART,
});
}
if let Some(first_char) = handle.chars().next()
@@ -314,6 +357,19 @@ mod tests {
assert_eq!(validate_short_handle("abc"), Ok("abc".to_string()));
}
#[test]
fn full_domain_handles_with_reserved_tlds_are_rejected() {
assert!(validate_full_domain_handle("whelk.oyster.cafe").is_ok());
assert_eq!(
validate_full_domain_handle("whelk.pds.internal"),
Err(HandleValidationError::DisallowedTld)
);
assert_eq!(
validate_full_domain_handle("handle.invalid"),
Err(HandleValidationError::DisallowedTld)
);
}
#[test]
fn test_invalid_handles() {
assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty));
@@ -388,11 +444,15 @@ mod tests {
);
assert_eq!(
validate_short_handle("exactly19characters"),
Err(HandleValidationError::TooLong)
Err(HandleValidationError::TooLong {
max: MAX_SERVICE_HANDLE_LOCAL_PART
})
);
assert_eq!(
validate_short_handle("waytoolongusername123456789"),
Err(HandleValidationError::TooLong)
Err(HandleValidationError::TooLong {
max: MAX_SERVICE_HANDLE_LOCAL_PART
})
);
}
+8 -8
View File
@@ -189,7 +189,7 @@ mod tests {
#[tokio::test]
async fn test_create_and_validate_token() {
let cache = MockCache::new();
let did = Did::from("did:plc:teq".to_string());
let did = Did::new("did:plc:teq").expect("valid DID");
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
.await
@@ -206,7 +206,7 @@ mod tests {
#[tokio::test]
async fn test_token_consumed_after_use() {
let cache = MockCache::new();
let did = Did::from("did:plc:teq".to_string());
let did = Did::new("did:plc:teq").expect("valid DID");
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
.await
@@ -224,7 +224,7 @@ mod tests {
#[tokio::test]
async fn test_invalid_token_rejected() {
let cache = MockCache::new();
let did = Did::from("did:plc:teq".to_string());
let did = Did::new("did:plc:teq").expect("valid DID");
let _token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
.await
@@ -238,7 +238,7 @@ mod tests {
#[tokio::test]
async fn test_wrong_purpose_rejected() {
let cache = MockCache::new();
let did = Did::from("did:plc:teq".to_string());
let did = Did::new("did:plc:teq").expect("valid DID");
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
.await
@@ -253,7 +253,7 @@ mod tests {
async fn test_token_format() {
// The emitted token is the display form: uppercase `XXXXX-XXXXX`.
let cache = MockCache::new();
let did = Did::from("did:plc:teq".to_string());
let did = Did::new("did:plc:teq").expect("valid DID");
(0..50).for_each(|_| {
let token = futures::executor::block_on(create_email_token(
&cache,
@@ -270,7 +270,7 @@ mod tests {
#[tokio::test]
async fn test_case_insensitive_validation() {
let cache = MockCache::new();
let did = Did::from("did:plc:teq".to_string());
let did = Did::new("did:plc:teq").expect("valid DID");
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
.await
@@ -285,7 +285,7 @@ mod tests {
#[tokio::test]
async fn test_hyphen_insensitive_validation() {
let cache = MockCache::new();
let did = Did::from("did:plc:teq".to_string());
let did = Did::new("did:plc:teq").expect("valid DID");
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
.await
@@ -300,7 +300,7 @@ mod tests {
#[tokio::test]
async fn test_noop_cache_returns_unavailable() {
let cache = crate::cache::NoOpCache;
let did = Did::from("did:plc:whelk".to_string());
let did = Did::new("did:plc:whelk").expect("valid DID");
let result = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail).await;
assert_eq!(result.unwrap_err(), TokenError::CacheUnavailable);
+1 -1
View File
@@ -624,7 +624,7 @@ mod tests {
use super::*;
fn n(s: &str) -> crate::types::Nsid {
crate::types::Nsid::from(s.to_string())
crate::types::Nsid::new(s).expect("valid NSID")
}
#[test]
+7
View File
@@ -39,3 +39,10 @@ pub fn scope_ref_key(cid: &CidLink) -> String {
pub fn auto_verify_sent_key(did: &Did) -> String {
format!("auto_verify_sent:{}", did)
}
pub fn permission_set_key(nsid: &tranquil_types::Nsid, aud: Option<&str>) -> String {
match aud {
Some(a) => format!("permset:{}:{}", nsid, a),
None => format!("permset:{}", nsid),
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ pub use roles::{
};
pub use scopes::{
EDITOR_FULL_SCOPES, InvalidDelegationScopeError, OWNER_FULL_SCOPES, SCOPE_PRESETS, ScopePreset,
ValidatedDelegationScope, intersect_scopes,
ValidatedDelegationScope, grant_covers, intersect_scopes,
};
pub use tranquil_db_traits::DelegationActionType;
+46 -108
View File
@@ -1,5 +1,7 @@
use std::collections::HashSet;
use tranquil_scopes::{covers, parse_scope};
pub use tranquil_db_traits::{
DbScope as ValidatedDelegationScope, InvalidScopeError as InvalidDelegationScopeError,
};
@@ -46,12 +48,13 @@ pub const SCOPE_PRESETS: &[ScopePreset] = &[
pub fn intersect_scopes(requested: &str, granted: &str) -> String {
let requested_set: HashSet<&str> = requested.split_whitespace().collect();
let granted_set: HashSet<&str> = granted.split_whitespace().collect();
let granted_parsed: Vec<tranquil_scopes::ParsedScope> =
granted.split_whitespace().map(parse_scope).collect();
let mut scopes: Vec<&str> = requested_set
.iter()
.filter(|requested_scope| {
**requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_set)
**requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_parsed)
})
.copied()
.chain(requested_set.contains("atproto").then_some("atproto"))
@@ -60,81 +63,18 @@ pub fn intersect_scopes(requested: &str, granted: &str) -> String {
scopes.join(" ")
}
fn any_granted_covers(requested: &str, granted: &HashSet<&str>) -> bool {
granted
.iter()
.any(|granted_scope| scope_covers(granted_scope, requested))
}
fn scope_covers(granted: &str, requested: &str) -> bool {
if granted == requested {
pub fn grant_covers(granted: &str, scope: &str) -> bool {
if scope == "atproto" {
return true;
}
let (granted_base, granted_params) = split_scope(granted);
let (requested_base, requested_params) = split_scope(requested);
let base_matches = if granted_base.ends_with(":*")
&& requested_base.starts_with(&granted_base[..granted_base.len() - 1])
{
true
} else if let Some(prefix) = granted_base.strip_suffix(".*")
&& requested_base.starts_with(prefix)
&& requested_base.len() > prefix.len()
{
true
} else {
granted_base == requested_base
};
if !base_matches {
return false;
}
match (granted_params, requested_params) {
(None, _) => true,
(Some(_), None) => true,
(Some(gp), Some(rp)) => params_cover(gp, rp),
}
let granted_parsed: Vec<tranquil_scopes::ParsedScope> =
granted.split_whitespace().map(parse_scope).collect();
any_granted_covers(scope, &granted_parsed)
}
fn params_cover(granted_params: &str, requested_params: &str) -> bool {
let granted_kv: HashSet<(&str, &str)> = granted_params
.split('&')
.filter_map(|pair| pair.split_once('='))
.collect();
let requested_kv: HashSet<(&str, &str)> = requested_params
.split('&')
.filter_map(|pair| pair.split_once('='))
.collect();
let granted_keys: HashSet<&str> = granted_kv.iter().map(|(k, _)| *k).collect();
let requested_keys: HashSet<&str> = requested_kv.iter().map(|(k, _)| *k).collect();
requested_keys.iter().all(|key| {
if !granted_keys.contains(key) {
return false;
}
let requested_values: HashSet<&str> = requested_kv
.iter()
.filter(|(k, _)| k == key)
.map(|(_, v)| *v)
.collect();
let granted_values: HashSet<&str> = granted_kv
.iter()
.filter(|(k, _)| k == key)
.map(|(_, v)| *v)
.collect();
requested_values.is_subset(&granted_values)
})
}
fn split_scope(scope: &str) -> (&str, Option<&str>) {
if let Some(idx) = scope.find('?') {
(&scope[..idx], Some(&scope[idx + 1..]))
} else {
(scope, None)
}
fn any_granted_covers(requested: &str, granted: &[tranquil_scopes::ParsedScope]) -> bool {
let requested_parsed = parse_scope(requested);
granted.iter().any(|g| covers(g, &requested_parsed))
}
#[cfg(test)]
@@ -280,12 +220,12 @@ mod tests {
}
#[test]
fn test_intersect_granted_with_params_covers_requested_no_params() {
fn test_intersect_partial_action_grant_drops_actionless_request() {
let result = intersect_scopes(
"repo:app.bsky.feed.post",
"repo:*?action=create&action=delete",
);
assert_eq!(result, "repo:app.bsky.feed.post");
assert_eq!(result, "");
}
#[test]
@@ -297,39 +237,6 @@ mod tests {
assert_eq!(result, "repo:*?action=create");
}
#[test]
fn test_scope_covers_base_only() {
assert!(scope_covers("repo:*", "repo:app.bsky.feed.post"));
assert!(scope_covers(
"repo:*",
"repo:app.bsky.feed.post?action=create"
));
assert!(!scope_covers("blob:*/*", "repo:app.bsky.feed.post"));
}
#[test]
fn test_scope_covers_params() {
assert!(scope_covers("repo:*?action=create", "repo:*?action=create"));
assert!(!scope_covers(
"repo:*?action=create",
"repo:*?action=delete"
));
assert!(scope_covers(
"repo:*?action=create&action=delete",
"repo:*?action=create"
));
assert!(!scope_covers(
"repo:*?action=create",
"repo:*?action=create&action=delete"
));
}
#[test]
fn test_scope_covers_no_granted_params_means_all() {
assert!(scope_covers("repo:*", "repo:*?action=create"));
assert!(scope_covers("repo:*", "repo:*?action=delete"));
}
#[test]
fn test_validate_scopes_valid() {
assert!(ValidatedDelegationScope::new("atproto").is_ok());
@@ -353,4 +260,35 @@ mod tests {
});
});
}
#[test]
fn test_grant_covers_matches_intersection() {
let granted = "atproto repo:* blob:*/* account:*?action=manage";
let intersected = intersect_scopes(
"repo:app.bsky.feed.post?action=create identity:* account:*?action=manage",
granted,
);
assert!(grant_covers(
granted,
"repo:app.bsky.feed.post?action=create"
));
assert!(grant_covers(granted, "account:*?action=manage"));
assert!(!grant_covers(granted, "identity:*"));
assert_eq!(
grant_covers(granted, "identity:*"),
intersected.contains("identity")
);
}
#[test]
fn test_grant_covers_atproto_always_true() {
assert!(grant_covers("", "atproto"));
assert!(grant_covers("repo:*", "atproto"));
}
#[test]
fn test_grant_covers_empty_grant_covers_nothing_else() {
assert!(!grant_covers("", "repo:app.bsky.feed.post?action=create"));
assert!(!grant_covers("", "identity:*"));
}
}
@@ -1,6 +1,9 @@
use std::collections::HashSet;
use std::sync::LazyLock;
// TODO: make all of this configurable.
// PDS implementation should not impose any reserved domains imo.
// some of these are even bad to have as defaults let alone non-configurables
const ATP_SPECIFIC: &[&str] = &[
"at", "atp", "plc", "pds", "did", "repo", "tid", "nsid", "xrpc", "lex", "lexicon", "bsky",
"bluesky", "handle",
+2
View File
@@ -1,5 +1,6 @@
pub mod client;
pub mod db;
pub mod permission_set_resolver;
pub mod scopes;
pub mod verify;
@@ -20,6 +21,7 @@ pub use tranquil_oauth::{
compute_pkce_challenge, verify_client_auth,
};
pub use permission_set_resolver::expand_scopes;
pub use scopes::{AccountAction, AccountAttr, RepoAction, ScopeError, ScopePermissions};
pub use verify::{
OAuthAuthError, OAuthUser, VerifyResult, generate_dpop_nonce, verify_oauth_access_token,
@@ -0,0 +1,264 @@
use crate::cache::Cache;
use crate::cache_keys::permission_set_key;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tranquil_scopes::{
ExpansionOutcome, FailedSet, ResolveFailure, ResolvedSetGroup, ScopeExpansionError,
fetch_and_expand, parse_include_scope,
};
use tranquil_types::Nsid;
#[derive(Serialize, Deserialize)]
struct CachedPermissionSet {
scope: String,
title: Option<String>,
detail: Option<String>,
#[serde(default)]
refreshed_at: i64,
}
const STALE_AFTER_SECS: i64 = 24 * 60 * 60;
const PERMISSION_SET_CACHE_TTL_SECS: u64 = 90 * 24 * 60 * 60;
fn now_secs() -> i64 {
chrono::Utc::now().timestamp()
}
fn is_stale(refreshed_at: i64) -> bool {
now_secs().saturating_sub(refreshed_at) >= STALE_AFTER_SECS
}
pub async fn expand_scopes(cache: &dyn Cache, scope_string: &str) -> ExpansionOutcome {
let mut outcome = ExpansionOutcome::default();
for tok in scope_string.split_whitespace() {
match tok.strip_prefix("include:") {
None => outcome.passthrough.push(tok.to_string()),
Some(rest) => {
let (nsid, aud) = parse_include_scope(rest);
match resolve_one(cache, nsid, aud).await {
Ok(group) => outcome.sets.push(group),
Err(reason) => outcome.failures.push(FailedSet {
given_nsid: nsid.to_string(),
given_aud: aud.map(str::to_string),
reason,
}),
}
}
}
}
outcome
}
async fn resolve_one(
cache: &dyn Cache,
nsid: &str,
aud: Option<&str>,
) -> Result<ResolvedSetGroup, ResolveFailure> {
let parsed = Nsid::new(nsid).map_err(|_| ResolveFailure::Malformed)?;
let key = permission_set_key(&parsed, aud);
let cached = cache
.get(&key)
.await
.and_then(|json| serde_json::from_str::<CachedPermissionSet>(&json).ok());
if let Some(v) = &cached
&& !is_stale(v.refreshed_at)
{
return Ok(group_from(
parsed,
aud,
v.scope.clone(),
v.title.clone(),
v.detail.clone(),
));
}
match fetch_and_expand(&parsed, aud).await {
Ok(fetched) => {
let stored = CachedPermissionSet {
scope: fetched.expanded.clone(),
title: fetched.title.clone(),
detail: fetched.detail.clone(),
refreshed_at: now_secs(),
};
if let Ok(json) = serde_json::to_string(&stored) {
let _ = cache
.set(
&key,
&json,
Duration::from_secs(PERMISSION_SET_CACHE_TTL_SECS),
)
.await;
}
Ok(group_from(
parsed,
aud,
fetched.expanded,
fetched.title,
fetched.detail,
))
}
Err(e) => match cached {
Some(v) => Ok(group_from(parsed, aud, v.scope, v.title, v.detail)),
None => Err(map_err(&e)),
},
}
}
fn group_from(
nsid: Nsid,
aud: Option<&str>,
scope: String,
title: Option<String>,
detail: Option<String>,
) -> ResolvedSetGroup {
ResolvedSetGroup {
nsid,
aud: aud.map(str::to_string),
title,
detail,
expanded: scope.split_whitespace().map(str::to_string).collect(),
}
}
fn map_err(e: &ScopeExpansionError) -> ResolveFailure {
use ScopeExpansionError as E;
match e {
E::InvalidNsid(_) => ResolveFailure::Malformed,
E::RecordNotFound => ResolveFailure::NotFound,
E::UnexpectedType(_) => ResolveFailure::NotAPermissionSet,
E::MissingDefinition(_) => ResolveFailure::MalformedLexicon,
E::EmptyPermissions => ResolveFailure::EmptyPermissions,
E::DnsResolution(_) | E::HttpFailed(_) | E::DidResolution(_) => ResolveFailure::Unreachable,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::{Cache, CacheError};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
#[derive(Default)]
struct MapCache(Mutex<HashMap<String, String>>);
#[async_trait::async_trait]
impl Cache for MapCache {
async fn get(&self, key: &str) -> Option<String> {
self.0.lock().unwrap().get(key).cloned()
}
async fn set(&self, key: &str, value: &str, _ttl: Duration) -> Result<(), CacheError> {
self.0
.lock()
.unwrap()
.insert(key.to_string(), value.to_string());
Ok(())
}
async fn delete(&self, key: &str) -> Result<(), CacheError> {
self.0.lock().unwrap().remove(key);
Ok(())
}
async fn get_bytes(&self, _key: &str) -> Option<Vec<u8>> {
None
}
async fn set_bytes(&self, _k: &str, _v: &[u8], _t: Duration) -> Result<(), CacheError> {
Ok(())
}
}
fn seed_at(cache: &MapCache, nsid: &str, scope: &str, refreshed_at: i64) {
let key =
crate::cache_keys::permission_set_key(&tranquil_types::Nsid::new(nsid).unwrap(), None);
let val = serde_json::to_string(&CachedPermissionSet {
scope: scope.to_string(),
title: Some("Basic".into()),
detail: None,
refreshed_at,
})
.unwrap();
cache.0.lock().unwrap().insert(key, val);
}
fn seed(cache: &MapCache, nsid: &str, scope: &str) {
seed_at(cache, nsid, scope, now_secs());
}
#[tokio::test]
async fn cache_hit_expands_without_network() {
let cache = MapCache::default();
seed(
&cache,
"io.atcr.authFullApp",
"repo:io.atcr.manifest?action=create identity:*",
);
let out = expand_scopes(&cache, "atproto include:io.atcr.authFullApp").await;
assert!(out.failures.is_empty());
assert_eq!(out.passthrough, vec!["atproto".to_string()]);
assert_eq!(out.sets.len(), 1);
assert_eq!(out.sets[0].nsid, "io.atcr.authFullApp");
assert!(
out.flat_scopes()
.iter()
.any(|s| s == "repo:io.atcr.manifest?action=create")
);
}
#[tokio::test]
async fn stale_entry_is_served_when_refresh_fails() {
let cache = MapCache::default();
seed_at(
&cache,
"nonexistent.fake.permissionSet",
"repo:nonexistent.fake.record?action=create",
now_secs() - STALE_AFTER_SECS - 1,
);
let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await;
assert!(
out.failures.is_empty(),
"stale entry should survive an unresolvable publisher"
);
assert_eq!(out.sets.len(), 1);
assert!(
out.flat_scopes()
.iter()
.any(|s| s == "repo:nonexistent.fake.record?action=create")
);
}
#[tokio::test]
async fn entry_without_refreshed_at_is_treated_as_stale_but_usable() {
let cache = MapCache::default();
let key = crate::cache_keys::permission_set_key(
&tranquil_types::Nsid::new("nonexistent.fake.permissionSet").unwrap(),
None,
);
// Shape written before `refreshed_at` existed.
let legacy =
r#"{"scope":"repo:nonexistent.fake.record?action=create","title":null,"detail":null}"#;
cache.0.lock().unwrap().insert(key, legacy.to_string());
let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await;
assert!(out.failures.is_empty());
assert_eq!(out.sets.len(), 1);
}
#[tokio::test]
async fn passthrough_scopes_untouched() {
let cache = MapCache::default();
let out = expand_scopes(&cache, "atproto repo:app.bsky.feed.post?action=create").await;
assert!(out.failures.is_empty());
assert!(out.sets.is_empty());
assert_eq!(out.flat_scopes().len(), 2);
}
#[tokio::test]
async fn cache_miss_unresolvable_is_a_failure() {
let cache = MapCache::default();
let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await;
assert_eq!(out.sets.len(), 0);
assert_eq!(out.failures.len(), 1);
assert_eq!(out.failures[0].given_nsid, "nonexistent.fake.permissionSet");
}
}
+2 -3
View File
@@ -1,7 +1,6 @@
pub use tranquil_scopes::{
AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, IncludeScope,
ParsedScope, RepoAction, RepoScope, RpcScope, SCOPE_DEFINITIONS, ScopeCategory,
ScopeDefinition, ScopeError, ScopeExpansionError, ScopePermissions, expand_include_scopes,
format_scope_for_display, get_required_scopes, get_scope_definition, is_valid_scope,
parse_scope, parse_scope_string,
ScopeDefinition, ScopeError, ScopeExpansionError, ScopePermissions, format_scope_for_display,
get_required_scopes, get_scope_definition, is_valid_scope, parse_scope, parse_scope_string,
};
+1 -1
View File
@@ -96,7 +96,7 @@ pub async fn verify_oauth_access_token(
did: token_data.did,
token_id,
client_id: token_data.client_id,
scope: token_data.scope,
scope: token_info.scope,
})
}
+3 -2
View File
@@ -561,7 +561,8 @@ pub fn did_for_genesis_op(signed_op: &Value) -> Result<Did, PlcError> {
let hash = hasher.finalize();
let encoded = base32::encode(Alphabet::Rfc4648Lower { padding: false }, &hash);
let truncated = &encoded[..24];
Ok(Did::from(format!("did:plc:{}", truncated)))
Ok(Did::new(format!("did:plc:{}", truncated))
.expect("did:plc with 24 base32 characters of a sha256 digest is a valid DID"))
}
pub fn validate_plc_operation(op: &Value) -> Result<PlcOpType, PlcError> {
@@ -784,7 +785,7 @@ mod tests {
let result = create_genesis_operation(
&key,
Some(operator_key),
&crate::types::Handle::from("whelk.nel.pet".to_string()),
&crate::types::Handle::new("whelk.nel.pet").expect("valid handle"),
"https://nel.pet",
)
.unwrap();
+250 -38
View File
@@ -14,7 +14,7 @@ use jacquard_repo::mst::{Mst, VerifiedWriteOp};
use jacquard_repo::storage::BlockStore;
use k256::ecdsa::SigningKey;
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::str::FromStr;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
@@ -115,6 +115,10 @@ pub fn extract_blob_cids(record: &Value) -> Vec<crate::types::CidLink> {
use crate::types::AtUri;
use tranquil_db_traits::{Backlink, BacklinkPath};
// TODO: it really really should not be necessary to extract backlinks and store those.
// especially not in a way that isnt generic.
// figure out what the fuck is going on here
// (lewis do you remember why you did this???)
pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec<Backlink> {
let record_type = record
.get("$type")
@@ -262,7 +266,7 @@ pub async fn repair_repo_structure(
ApiError::InternalError(None)
})?;
let data_root = commit.data;
let repo_rev = crate::types::Tid::from(commit.rev().to_string());
let repo_rev = crate::types::Tid::from(commit.rev().clone());
let records = state
.repos
@@ -323,30 +327,67 @@ pub async fn repair_repo_structure(
missing.len()
))));
}
}
let block_cids =
crate::scheduled::collect_current_repo_blocks(&state.block_store, &current_root_cid)
.await
.map_err(|e| {
error!("repair: re-walk for user_blocks backfill failed: {}", e);
ApiError::InternalError(None)
})?;
let walk = crate::scheduled::collect_current_repo_blocks(&state.block_store, &current_root_cid)
.await
.map_err(|e| {
error!("repair: re-walk for user_blocks backfill failed: {}", e);
ApiError::InternalError(None)
})?;
let recorded = state
.repos
.repo
.get_user_block_cids_since_rev(user_id, None)
.await
.map_err(|e| {
error!("repair: reading recorded user_blocks set failed: {}", e);
ApiError::InternalError(None)
})?;
let reachable: HashSet<&[u8]> = walk.block_cids.iter().map(Vec::as_slice).collect();
let stale: Vec<Vec<u8>> = match walk.is_complete() {
true => recorded
.into_iter()
.filter(|cid| !reachable.contains(cid.as_slice()))
.collect(),
false => {
error!(
user_id = %user_id,
unreadable = walk.unreadable,
"repair: repo walk couldn't read every block, so its complement isn't the \
unreachable set. Backfilling without pruning."
);
Vec::new()
}
};
state
.repos
.repo
.insert_user_blocks(user_id, &walk.block_cids, &repo_rev)
.await
.map_err(|e| {
error!("repair: user_blocks backfill failed: {}", e);
ApiError::InternalError(None)
})?;
if !stale.is_empty() {
state
.repos
.repo
.insert_user_blocks(user_id, &block_cids, &repo_rev)
.delete_user_blocks(user_id, &stale)
.await
.map_err(|e| {
error!("repair: user_blocks backfill failed: {}", e);
error!("repair: pruning unreachable user_blocks rows failed: {}", e);
ApiError::InternalError(None)
})?;
warn!(
user_id = %user_id,
blocks = block_cids.len(),
"repair: backfilled user_blocks from repaired DAG"
);
}
warn!(
user_id = %user_id,
blocks = walk.block_cids.len(),
pruned = stale.len(),
"repair: rewrote user_blocks from current DAG"
);
Ok(outcome)
}
@@ -460,6 +501,69 @@ pub fn schedule_repo_repair(state: &AppState, user_id: Uuid) {
});
}
async fn leaves_referenced_elsewhere(
state: &AppState,
user_id: Uuid,
dropped_leaves: &BTreeSet<Cid>,
ops: &[RecordOp],
) -> BTreeSet<Cid> {
if dropped_leaves.is_empty() {
return BTreeSet::new();
}
let by_link: BTreeMap<crate::types::CidLink, Cid> = dropped_leaves
.iter()
.map(|cid| (crate::types::CidLink::from_cid(cid), *cid))
.collect();
let cids: Vec<crate::types::CidLink> = by_link.keys().cloned().collect();
// Because the commit only runs later on, records table still shows
// this write's pre-write state at this point
// so excluding the keys that this write touches is the thing that
// keeps a deleted record from counting as a reference to its own block.
let touched: Vec<(&Nsid, &Rkey)> = ops.iter().map(RecordOp::collection_rkey).collect();
// The two failure directions aren't symmetric at all
// because a user_blocks row for a block that's already gone is only noise
// that the scheduled repair clears...
// While a *missing* row for a block that's still reachable
// makes `getRepo` with a since-cursor give an incomplete tree
// and thus any doubt at all about the lookup ought to mean keeping every row.
let keep_everything = |reason: &str| {
error!(
user_id = %user_id,
"{reason}. Keeping the user_blocks row for every record block this write removed. \
Their refcounts still drop, so a row may remain after its block is gone until the \
scheduled structural repair rewrites the set."
);
schedule_repo_repair(state, user_id);
dropped_leaves.clone()
};
match state
.repos
.repo
.referenced_record_cids(user_id, &cids, &touched)
.await
{
Ok(referenced) => referenced
.iter()
.map(|link| by_link.get(link).copied())
.collect::<Option<BTreeSet<Cid>>>()
.unwrap_or_else(|| {
keep_everything("record cid reference lookup returned a cid that wasn't queried")
}),
Err(e) => keep_everything(&format!("record cid reference lookup failed: {e:?}")),
}
}
pub async fn reachable_tree_cids<S: BlockStore + Sync + 'static>(
mst: &Mst<S>,
) -> Result<BTreeSet<Cid>, jacquard_repo::error::RepoError> {
let nodes = mst.collect_node_cids().await?;
let leaves = mst.leaves().await?;
Ok(nodes
.into_iter()
.chain(leaves.into_iter().map(|(_, cid)| cid))
.collect())
}
pub async fn finalize_repo_write(
state: &AppState,
ctx: RepoWriteContext,
@@ -472,7 +576,6 @@ pub async fn finalize_repo_write(
.map_err(|e| ApiError::from_mst_error("MST persist", &e))?;
let written_bytes = ctx.tracking_store.take_written_blocks();
let new_tree_cids: Vec<Cid> = written_bytes.keys().copied().collect();
let storage_for_proof = Arc::new(ctx.tracking_store.clone());
let original_settled = Mst::load(storage_for_proof.clone(), ctx.prev_data_cid, None);
@@ -545,27 +648,124 @@ pub async fn finalize_repo_write(
}
}
let obsolete_cids = match original_settled.diff(&new_settled).await {
let replaced_refs: Vec<Cid> = params
.ops
.iter()
.filter_map(RecordOp::replaced_cid)
.collect();
let (new_tree_cids, new_mst_bytes, dropped) = match original_settled.diff(&new_settled).await {
Ok(diff) => {
let mut obsolete: Vec<Cid> =
Vec::with_capacity(1 + diff.removed_mst_blocks.len() + diff.removed_cids.len());
obsolete.push(ctx.current_root_cid);
obsolete.extend(diff.removed_mst_blocks);
obsolete.extend(diff.removed_cids);
obsolete
// The moment that a leaf CID leaves its old position (leaves, get it)
// the diff will report it as removed
// even where the new tree goes on using that same block somewhere else
// so filtering the removed leaves against `live` will stop this write
// from dropping a block the new tree still needs.
// The diffing minuses the unchanged nodes from `removed_mst_blocks` itself
// and the same filter only covers a node which matches the new root.
let live: BTreeSet<Cid> = diff
.new_mst_blocks
.keys()
.copied()
.chain(diff.new_leaf_cids.iter().copied())
.chain(std::iter::once(new_mst_root))
.collect();
let dropped_leaves: BTreeSet<Cid> = diff
.removed_cids
.iter()
.copied()
.filter(|cid| !live.contains(cid))
.collect();
let still_referenced =
leaves_referenced_elsewhere(state, params.user_id, &dropped_leaves, &params.ops)
.await;
let removed_mst_blocks: Vec<Cid> = diff
.removed_mst_blocks
.iter()
.copied()
.filter(|cid| !live.contains(cid))
.collect();
// These sets have distinct purposes:
// - `unreachable` deletes user_blocks rows
// and so has to leave out any block another record still references
// - `decrements` drops one refcount for every ref this write released
// including the replaced record CIDs that other records
// continue on referencing
//
// Neither set includes the previous data root
// since the diff reports subtree nodes and never a root of its own.
// So that block keeps its refcount and its user_blocks row
// until the leaked-refcount sweep reclaims it.
let dropped = DroppedBlocks {
unreachable: std::iter::once(ctx.current_root_cid)
.chain(removed_mst_blocks.iter().copied())
.chain(
dropped_leaves
.iter()
.copied()
.filter(|cid| !still_referenced.contains(cid)),
)
.collect(),
decrements: std::iter::once(ctx.current_root_cid)
.chain(removed_mst_blocks)
.chain(replaced_refs)
.collect(),
};
(live.into_iter().collect(), diff.new_mst_blocks, dropped)
}
Err(e) => {
error!(
"MST diff failed during finalize_repo_write: {e}. \
Proceeding with commit CID only; leaked blocks \
will be reclaimed by reachability GC."
user_id = %params.user_id,
"MST diff failed during finalize_repo_write: {e}. Walking the new MST insetad \
and scheduling a structural repair."
);
vec![ctx.current_root_cid]
schedule_repo_repair(state, params.user_id);
let dropped = DroppedBlocks {
unreachable: vec![ctx.current_root_cid],
decrements: std::iter::once(ctx.current_root_cid)
.chain(replaced_refs)
.collect(),
};
match reachable_tree_cids(&new_settled).await {
Ok(cids) => (cids.into_iter().collect(), BTreeMap::new(), dropped),
Err(walk_err) => {
error!(
"MST walk after diff failure also failed: {walk_err}. \
Recording only blocks this write observed and the commit CID. \
user_blocks stays incomplete until a structural repair rewrites the tree."
);
(
written_bytes.keys().copied().collect(),
BTreeMap::new(),
dropped,
)
}
}
}
};
// Under jacquard-repo 0.9 that tranquil pins for now,
// `Mst::persist` skips any subtree that's already in store
// so `written_bytes` under-reports the new node set
// and `new_mst_blocks` is full
// where each of these blocks still needs a `put` to take a refcount
// even though its bytes are already on disk.
let recovered: Vec<(Cid, Bytes)> = new_mst_bytes
.iter()
.filter(|(cid, _)| !written_bytes.contains_key(*cid))
.map(|(cid, bytes)| (*cid, bytes.clone()))
.collect();
if !recovered.is_empty() {
state.block_store.put_many(recovered).await.map_err(|e| {
error!("failed to reference MST blocks recovered from the diff: {e}");
ApiError::InternalError(None)
})?;
}
let mut block_bytes = written_bytes;
block_bytes.extend(relevant);
block_bytes.extend(new_mst_bytes);
let result = commit_and_log(
state,
@@ -580,7 +780,7 @@ pub async fn finalize_repo_write(
block_bytes,
new_tree_cids,
blobs: params.blob_cids,
obsolete_cids,
dropped,
backlinks_to_add: params.backlinks_to_add,
backlinks_to_remove: params.backlinks_to_remove,
},
@@ -649,6 +849,13 @@ pub enum RecordOp {
}
impl RecordOp {
pub fn replaced_cid(&self) -> Option<Cid> {
match self {
Self::Create { .. } => None,
Self::Update { prev, .. } | Self::Delete { prev, .. } => Some(*prev.as_cid()),
}
}
pub fn collection_rkey(&self) -> (&Nsid, &Rkey) {
match self {
Self::Create {
@@ -669,6 +876,11 @@ pub struct CommitResult {
pub rev: crate::types::Tid,
}
pub struct DroppedBlocks {
pub unreachable: Vec<Cid>,
pub decrements: Vec<Cid>,
}
pub struct CommitParams<'a> {
pub did: &'a Did,
pub user_id: Uuid,
@@ -680,7 +892,7 @@ pub struct CommitParams<'a> {
pub block_bytes: std::collections::HashMap<Cid, Bytes>,
pub new_tree_cids: Vec<Cid>,
pub blobs: &'a [crate::types::CidLink],
pub obsolete_cids: Vec<Cid>,
pub dropped: DroppedBlocks,
pub backlinks_to_add: Vec<Backlink>,
pub backlinks_to_remove: Vec<AtUri>,
}
@@ -705,7 +917,7 @@ pub async fn commit_and_log(
mut block_bytes,
new_tree_cids,
blobs,
obsolete_cids,
dropped,
backlinks_to_add,
backlinks_to_remove,
} = params;
@@ -726,7 +938,7 @@ pub async fn commit_and_log(
let signing_key =
SigningKey::from_slice(&key_bytes).map_err(|e| CommitError::InvalidKey(e.to_string()))?;
let rev = Tid::now(LimitedU32::MIN);
let rev_str = rev.to_string();
let repo_rev = crate::types::Tid::from(rev.clone());
let (new_commit_bytes, _sig) =
create_signed_commit(did, new_mst_root, &rev, current_root_cid, &signing_key)?;
let new_root_cid =
@@ -751,7 +963,7 @@ pub async fn commit_and_log(
.map(|c| c.to_bytes())
.collect();
let obsolete_bytes: Vec<Vec<u8>> = obsolete_cids.iter().map(|c| c.to_bytes()).collect();
let obsolete_bytes: Vec<Vec<u8>> = dropped.unreachable.iter().map(|c| c.to_bytes()).collect();
let final_ops: HashMap<(&Nsid, &Rkey), &RecordOp> =
ops.iter().map(|op| (op.collection_rkey(), op)).collect();
@@ -864,7 +1076,7 @@ pub async fn commit_and_log(
blobs: Some(blobs.to_vec()),
blocks: Some(inline_blocks),
prev_data_cid: prev_data_cid.map(crate::types::CidLink::from),
rev: Some(crate::types::Tid::from(rev_str.clone())),
rev: Some(repo_rev.clone()),
};
let input = ApplyCommitInput {
@@ -872,7 +1084,7 @@ pub async fn commit_and_log(
did: did.clone(),
expected_root_cid: current_root_cid.map(crate::types::CidLink::from),
new_root_cid: crate::types::CidLink::from(new_root_cid),
new_rev: crate::types::Tid::from(rev_str.clone()),
new_rev: repo_rev.clone(),
new_block_cids: all_block_cids,
obsolete_block_cids: obsolete_bytes,
record_upserts,
@@ -895,7 +1107,7 @@ pub async fn commit_and_log(
let apply_result = (|| {
let bs = state.block_store.clone();
let decrements = obsolete_cids.clone();
let decrements = dropped.decrements.clone();
async move { bs.decrement_refs(&decrements).await }
})
.retry(
@@ -907,7 +1119,7 @@ pub async fn commit_and_log(
.await;
if let Err(e) = apply_result {
let leaked: Vec<String> = obsolete_cids.iter().map(Cid::to_string).collect();
let leaked: Vec<String> = dropped.decrements.iter().map(Cid::to_string).collect();
tracing::error!(
error = %e,
user_id = %user_id,
@@ -920,7 +1132,7 @@ pub async fn commit_and_log(
Ok(CommitResult {
commit_cid: new_root_cid,
rev: crate::types::Tid::from(rev_str),
rev: repo_rev,
})
}
pub async fn create_record_internal(
+47 -7
View File
@@ -37,7 +37,7 @@ async fn process_repo_rev(
}
};
let commit = Commit::from_cbor(&block).map_err(|_| user_id)?;
let rev = crate::types::Tid::from(commit.rev().to_string());
let rev = crate::types::Tid::from(commit.rev().clone());
repo_repo
.update_repo_rev(user_id, &rev)
.await
@@ -98,14 +98,22 @@ async fn process_user_blocks(
repo_rev: Option<crate::types::Tid>,
) -> Result<(uuid::Uuid, usize), uuid::Uuid> {
let root_cid = Cid::from_str(&repo_root_cid).map_err(|_| user_id)?;
let block_cids = collect_current_repo_blocks(block_store, &root_cid)
let walk = collect_current_repo_blocks(block_store, &root_cid)
.await
.map_err(|_| user_id)?;
if !walk.is_complete() {
error!(
user_id = %user_id,
unreadable = walk.unreadable,
"user_blocks backfill recorded an incomplete set becuase the walk couldn't read every block"
);
}
let block_cids = walk.block_cids;
if block_cids.is_empty() {
return Err(user_id);
}
let count = block_cids.len();
let rev = repo_rev.unwrap_or_else(|| crate::types::Tid::from("0".to_string()));
let rev = repo_rev.unwrap_or_else(crate::types::Tid::earliest);
repo_repo
.insert_user_blocks(user_id, &block_cids, &rev)
.await
@@ -162,13 +170,35 @@ pub async fn backfill_user_blocks(repo_repo: Arc<dyn RepoRepository>, block_stor
info!(success, failed, "Completed user_blocks backfill");
}
// When a walk couldn't read every block,
// what comes back is a subset of the reachable set
// ...and the complement of a subset is *not* the unreachable set.
// So callers check `is_complete` before they prune
// or one unreadable block is enough to delete live rows.
//
// The count only covers a block that the store reports as missing or corrupt,
// whereas a block whose bytes decode as neither commit or MST node ends
// traversal without counting
// so `is_complete` is necessary for a prune and not sufficient.
pub struct RepoWalk {
pub block_cids: Vec<Vec<u8>>,
pub unreadable: usize,
}
impl RepoWalk {
pub fn is_complete(&self) -> bool {
self.unreadable == 0
}
}
pub async fn collect_current_repo_blocks(
block_store: &AnyBlockStore,
head_cid: &Cid,
) -> anyhow::Result<Vec<Vec<u8>>> {
) -> anyhow::Result<RepoWalk> {
let mut block_cids: Vec<Vec<u8>> = Vec::new();
let mut to_visit = vec![*head_cid];
let mut visited = std::collections::HashSet::new();
let mut unreadable = 0usize;
while let Some(cid) = to_visit.pop() {
if visited.contains(&cid) {
@@ -179,9 +209,14 @@ pub async fn collect_current_repo_blocks(
let block = match block_store.get(&cid).await {
Ok(Some(b)) => b,
Ok(None) => continue,
Ok(None) => {
warn!(cid = %cid, "block missing during repo walk, so its subtree stays unvisited");
unreadable += 1;
continue;
}
Err(e) if crate::api::error::ApiError::detail_is_repo_corruption(&format!("{e:#}")) => {
warn!(cid = %cid, error = %format!("{e:#}"), "skipping corrupt block during repo walk");
unreadable += 1;
continue;
}
Err(e) => anyhow::bail!("Failed to get block {}: {:?}", cid, e),
@@ -215,7 +250,10 @@ pub async fn collect_current_repo_blocks(
}
}
Ok(block_cids)
Ok(RepoWalk {
block_cids,
unreadable,
})
}
async fn process_record_blobs(
@@ -771,7 +809,9 @@ pub async fn generate_repo_car(
block_store: &AnyBlockStore,
head_cid: &Cid,
) -> Result<Vec<u8>, RepoCarError> {
let block_cids_bytes = collect_current_repo_blocks(block_store, head_cid).await?;
let block_cids_bytes = collect_current_repo_blocks(block_store, head_cid)
.await?
.block_cids;
let block_cids: Vec<Cid> = block_cids_bytes
.iter()
.filter_map(|b| match Cid::try_from(b.as_slice()) {
+4
View File
@@ -30,6 +30,10 @@ pub fn init_rate_limit_override() {
}
}
pub fn set_rate_limiting_disabled(disabled: bool) {
RATE_LIMITING_DISABLED.store(disabled, Ordering::Relaxed);
}
#[derive(Clone)]
pub struct AppState {
pub repos: Arc<PostgresRepositories>,
+1 -1
View File
@@ -2,5 +2,5 @@ pub use tranquil_storage::{
BlobStorage, FilesystemBlobStorage, StorageError, StreamUploadResult, create_blob_storage,
};
#[cfg(feature = "s3-storage")]
#[cfg(feature = "s3")]
pub use tranquil_storage::S3BlobStorage;
+13 -5
View File
@@ -227,15 +227,23 @@ fn walk_mst_node(
{
let blob_refs = find_blob_refs_ipld(&record_value, 0);
let parts: Vec<&str> = full_key.split('/').collect();
if parts.len() >= 2 {
let collection = Nsid::from(parts[..parts.len() - 1].join("/"));
let rkey = Rkey::from(parts[parts.len() - 1].to_string());
records.push(ImportedRecord {
let parsed = match parts.len() >= 2 {
true => Nsid::new(parts[..parts.len() - 1].join("/"))
.ok()
.zip(Rkey::new(parts[parts.len() - 1].to_string()).ok()),
false => None,
};
match parsed {
Some((collection, rkey)) => records.push(ImportedRecord {
collection,
rkey,
cid: record_cid,
blob_refs,
});
}),
None => tracing::warn!(
key = %full_key,
"skipping a CAR record whose MST key isn't a valid collection/rkey pair"
),
}
}
+2 -1
View File
@@ -100,7 +100,8 @@ impl CarVerifier {
.ok_or_else(|| VerifyError::BlockNotFound(root_cid.to_string()))?;
let commit =
Commit::from_cbor(root_block).map_err(|e| VerifyError::InvalidCommit(e.to_string()))?;
let commit_did = commit.did().to_string().into();
let commit_did = tranquil_types::Did::new(commit.did().to_string())
.map_err(|e| VerifyError::InvalidCommit(e.to_string()))?;
let data_cid = commit.data();
self.verify_mst_structure(data_cid, blocks)?;
debug!("MST structure verified for commit: {:?}", commit);
+2
View File
@@ -2,6 +2,8 @@ pub use tranquil_types::*;
use std::sync::LazyLock;
#[cfg(feature = "bsky")]
pub static PROFILE_COLLECTION: LazyLock<Nsid> =
LazyLock::new(|| "app.bsky.actor.profile".parse().unwrap());
#[cfg(feature = "bsky")]
pub static PROFILE_RKEY: LazyLock<Rkey> = LazyLock::new(|| "self".parse().unwrap());
+8 -1
View File
@@ -87,6 +87,7 @@ pub const HEADER_ATPROTO_ACCEPT_LABELERS: HeaderName =
pub const HEADER_ATPROTO_REPO_REV: HeaderName = HeaderName::from_static("atproto-repo-rev");
pub const HEADER_ATPROTO_CONTENT_LABELERS: HeaderName =
HeaderName::from_static("atproto-content-labelers");
#[cfg(feature = "bsky-support")]
pub const HEADER_X_BSKY_TOPICS: HeaderName = HeaderName::from_static("x-bsky-topics");
pub fn get_header_str(
@@ -247,7 +248,13 @@ pub fn build_full_url(path: &str) -> String {
let cfg = tranquil_config::get();
let normalized_path = if !path.starts_with("/xrpc/")
&& (path.starts_with("/com.atproto.")
|| path.starts_with("/app.bsky.")
// BSKY: Bluesky requires that the PDS implement some app.bsky.* endpoints so we need to deal with those here too.
// TODO: surely we can figure out a way to do this more generically?
|| (if cfg!(feature = "bsky-support") {
path.starts_with("/app.bsky.")
} else {
true
})
|| path.starts_with("/_"))
{
format!("/xrpc{path}")
@@ -138,20 +138,25 @@ fn check_banned_content(
rkey: Option<&Rkey>,
) -> Result<(), ValidationError> {
match record_type {
#[cfg(feature = "bsky")]
"app.bsky.feed.post" => {
check_post_banned_content(obj)?;
}
#[cfg(feature = "bsky")]
"app.bsky.actor.profile" => {
check_string_field(obj, "displayName")?;
check_string_field(obj, "description")?;
}
#[cfg(feature = "bsky")]
"app.bsky.graph.list" => {
check_string_field(obj, "name")?;
}
#[cfg(feature = "bsky")]
"app.bsky.graph.starterpack" => {
check_string_field(obj, "name")?;
check_string_field(obj, "description")?;
}
#[cfg(feature = "bsky")]
"app.bsky.feed.generator" => {
if let Some(rkey) = rkey
&& crate::moderation::has_explicit_slur(rkey)
@@ -167,6 +172,7 @@ fn check_banned_content(
Ok(())
}
#[cfg(feature = "bsky")]
fn check_post_banned_content(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) {
tags.iter().enumerate().try_for_each(|(i, tag)| {
+8 -8
View File
@@ -1,8 +1,8 @@
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
use aws_config::BehaviorVersion;
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
use aws_sdk_s3::Client as S3Client;
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
use aws_sdk_s3::config::Credentials;
use chrono::Utc;
use reqwest::{Client, StatusCode, header};
@@ -56,9 +56,9 @@ pub struct ServerInstance {
pub distributed_rate_limiter: Option<Arc<dyn DistributedRateLimiter>>,
}
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
use testcontainers::GenericImage;
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
use testcontainers::core::ContainerPort;
#[cfg(not(feature = "external-infra"))]
use testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner};
@@ -66,7 +66,7 @@ use testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner};
use testcontainers_modules::postgres::Postgres;
#[cfg(not(feature = "external-infra"))]
static DB_CONTAINER: OnceLock<ContainerAsync<Postgres>> = OnceLock::new();
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
static S3_CONTAINER: OnceLock<ContainerAsync<GenericImage>> = OnceLock::new();
#[allow(dead_code)]
@@ -195,7 +195,7 @@ async fn setup_with_external_infra() -> String {
spawn_app(database_url).await
}
#[cfg(all(not(feature = "external-infra"), not(feature = "s3-storage")))]
#[cfg(all(not(feature = "external-infra"), not(feature = "s3")))]
async fn setup_with_testcontainers() -> String {
let temp_dir = std::env::temp_dir().join(format!("tranquil-pds-test-{}", uuid::Uuid::new_v4()));
let blob_path = temp_dir.join("blobs");
@@ -227,7 +227,7 @@ async fn setup_with_testcontainers() -> String {
spawn_app(connection_string).await
}
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
async fn setup_with_testcontainers() -> String {
let s3_container = GenericImage::new("cgr.dev/chainguard/minio", "latest")
.with_exposed_port(ContainerPort::Tcp(9000))
+133
View File
@@ -175,6 +175,139 @@ async fn test_external_did_web_no_local_doc() {
);
}
async fn reserve_signing_key(client: &reqwest::Client, base: &str, did: &str) -> String {
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.reserveSigningKey",
base
))
.json(&json!({ "did": did }))
.send()
.await
.expect("Failed to reserve signing key");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Response wasn't JSON");
body["signingKey"]
.as_str()
.expect("No signingKey returned")
.to_string()
}
async fn assert_reserved_key_placement_rejected(
build_methods: impl FnOnce(&str, &str, &str) -> Value,
) {
let client = client();
let base = base_url().await;
let mock_server = MockServer::start().await;
let mock_uri = mock_server.uri();
let mock_addr = mock_uri.trim_start_matches("http://");
let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
let handle = format!("wm{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let pds_endpoint = common::pds_endpoint();
let signing_key = reserve_signing_key(&client, base, &did).await;
let signing_multibase = signing_key
.strip_prefix("did:key:")
.expect("signingKey should start with did:key:");
let decoy_did = format!(
"did:web:{}.nel.pet",
&uuid::Uuid::new_v4().simple().to_string()[..12]
);
let decoy_key = reserve_signing_key(&client, base, &decoy_did).await;
let decoy_multibase = decoy_key
.strip_prefix("did:key:")
.expect("decoy signingKey should start with did:key:");
let did_doc = json!({
"@context": ["https://www.w3.org/ns/did/v1"],
"id": did,
"verificationMethod": build_methods(signing_multibase, decoy_multibase, &did),
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": pds_endpoint
}]
});
Mock::given(method("GET"))
.and(path("/.well-known/did.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(did_doc))
.mount(&mock_server)
.await;
let payload = json!({
"handle": handle,
"email": format!("{}@nel.pet", handle),
"password": "Testpass123!",
"didType": "web-external",
"did": did,
"signingKey": signing_key
});
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
.json(&payload)
.send()
.await
.expect("Failed to send request");
assert_ne!(
res.status(),
StatusCode::OK,
"createAccount must reject a did:web doc whose #atproto method isn't the reserved signing key"
);
let body: Value = res.json().await.expect("Response was not JSON");
let message = body["message"]
.as_str()
.or_else(|| body["error"].as_str())
.unwrap_or("");
assert!(
message.contains("reserved signing key"),
"error should report signing-key mismatch, got: {:?}",
body
);
}
#[tokio::test]
async fn test_external_did_web_signing_key_under_wrong_method_rejected() {
assert_reserved_key_placement_rejected(|signing_multibase, decoy_multibase, did| {
json!([
{
"id": format!("{}#atproto", did),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": decoy_multibase
},
{
"id": format!("{}#atproto_reserved", did),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": signing_multibase
}
])
})
.await;
}
#[tokio::test]
async fn test_external_did_web_signing_key_under_foreign_atproto_fragment_rejected() {
assert_reserved_key_placement_rejected(|signing_multibase, decoy_multibase, did| {
json!([
{
"id": "did:web:squid.nel.pet#atproto",
"type": "Multikey",
"controller": did,
"publicKeyMultibase": signing_multibase
},
{
"id": format!("{}#atproto", did),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": decoy_multibase
}
])
})
.await;
}
#[tokio::test]
async fn test_plc_operations_blocked_for_did_web() {
let client = client();
@@ -145,7 +145,7 @@ async fn sync_event_carries_inline_commit_block() {
let commit_bytes = b"synthetic sync commit block payload".to_vec();
let commit_cid = synthetic_cid(&commit_bytes);
let cid_link: CidLink = (&commit_cid).into();
let rev = tranquil_types::Tid::from("3kabcdefghij2".to_string());
let rev = tranquil_types::Tid::new("3kabcdefghij2").expect("valid TID");
let baseline = repos.repo.get_max_seq().await.expect("get_max_seq");
repos
@@ -192,7 +192,7 @@ async fn genesis_commit_event_carries_inline_blocks() {
let mst_root_cid = synthetic_cid(&mst_root_bytes);
let commit_link: CidLink = (&commit_cid).into();
let mst_link: CidLink = (&mst_root_cid).into();
let rev = tranquil_types::Tid::from("3kabcdefghij3".to_string());
let rev = tranquil_types::Tid::new("3kabcdefghij3").expect("valid TID");
let baseline = repos.repo.get_max_seq().await.expect("get_max_seq");
repos
+19 -175
View File
@@ -5,7 +5,7 @@ use common::*;
use helpers::*;
use reqwest::StatusCode;
use serde_json::{Value, json};
use tranquil_types::Did;
use tranquil_types::{Did, Nsid, Rkey};
#[tokio::test]
async fn test_delete_record_marks_blocks_obsolete() {
@@ -13,22 +13,17 @@ async fn test_delete_record_marks_blocks_obsolete() {
let base = base_url().await;
let repos = get_test_repos().await;
let (did, jwt) = setup_new_user("gc-after-delete").await;
let did = Did::new(did).expect("setup_new_user returned a valid DID");
let user_id = repos
.user
.get_id_by_did(&Did::new(did.clone()).unwrap())
.get_id_by_did(&did)
.await
.expect("DB error")
.expect("User not found");
let count_baseline = repos
.repo
.count_user_blocks(user_id)
.await
.expect("count_user_blocks failed");
let collection = "app.bsky.feed.post";
let rkey = format!("gc_test_{}", Utc::now().timestamp_millis());
let collection = Nsid::new("app.bsky.feed.post".to_string()).expect("valid NSID");
let rkey = Rkey::new(format!("gc_test_{}", Utc::now().timestamp_millis())).expect("valid rkey");
let create_payload = json!({
"repo": did,
"collection": collection,
@@ -65,17 +60,7 @@ async fn test_delete_record_marks_blocks_obsolete() {
.expect("createRecord response missing cid")
.to_string();
let count_after_create = repos
.repo
.count_user_blocks(user_id)
.await
.expect("count_user_blocks failed");
assert!(
count_after_create > count_baseline,
"user_blocks count did not grow after createRecord (baseline={}, after_create={})",
count_baseline,
count_after_create
);
assert_user_blocks_matches_repo(user_id, "createRecord").await;
let delete_payload = json!({
"repo": did,
@@ -96,28 +81,13 @@ async fn test_delete_record_marks_blocks_obsolete() {
delete_res.text().await
);
let count_after_delete = repos
.repo
.count_user_blocks(user_id)
.await
.expect("count_user_blocks failed");
assert!(
count_after_delete < count_after_create,
"user_blocks count did not shrink after deleteRecord \
(baseline={}, after_create={}, after_delete={}). \
The delete path produced no obsolete CIDs beyond the prior commit root, \
which is the regression this test guards against.",
count_baseline,
count_after_create,
count_after_delete
);
assert_user_blocks_matches_repo(user_id, "deleteRecord").await;
let get_res = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base))
.query(&[
("repo", did.as_str()),
("collection", collection),
("collection", collection.as_str()),
("rkey", rkey.as_str()),
])
.send()
@@ -138,16 +108,18 @@ async fn test_update_record_marks_old_record_block_obsolete() {
let base = base_url().await;
let repos = get_test_repos().await;
let (did, jwt) = setup_new_user("gc-after-update").await;
let did = Did::new(did).expect("setup_new_user returned a valid DID");
let user_id = repos
.user
.get_id_by_did(&Did::new(did.clone()).unwrap())
.get_id_by_did(&did)
.await
.expect("DB error")
.expect("User not found");
let collection = "app.bsky.feed.post";
let rkey = format!("gc_update_{}", Utc::now().timestamp_millis());
let collection = Nsid::new("app.bsky.feed.post".to_string()).expect("valid NSID");
let rkey =
Rkey::new(format!("gc_update_{}", Utc::now().timestamp_millis())).expect("valid rkey");
let put_v1 = json!({
"repo": did,
@@ -168,11 +140,7 @@ async fn test_update_record_marks_old_record_block_obsolete() {
.expect("Failed to send putRecord v1");
assert_eq!(res.status(), StatusCode::OK, "first putRecord failed");
let count_after_create = repos
.repo
.count_user_blocks(user_id)
.await
.expect("count_user_blocks failed");
assert_user_blocks_matches_repo(user_id, "the first putRecord").await;
let put_v2 = json!({
"repo": did,
@@ -193,133 +161,7 @@ async fn test_update_record_marks_old_record_block_obsolete() {
.expect("Failed to send putRecord v2");
assert_eq!(res.status(), StatusCode::OK, "second putRecord failed");
let count_after_update = repos
.repo
.count_user_blocks(user_id)
.await
.expect("count_user_blocks failed");
assert!(
count_after_update <= count_after_create + 1,
"user_blocks count grew by more than 1 after putRecord update \
(after_create={}, after_update={}). The previous version's record block \
should have been marked obsolete; instead it appears to be leaking.",
count_after_create,
count_after_update
);
}
#[tokio::test]
async fn test_delete_in_populated_repo_marks_merged_subtree_blocks_obsolete() {
let client = client();
let base = base_url().await;
let repos = get_test_repos().await;
let (did, jwt) = setup_new_user("gc-merge").await;
let user_id = repos
.user
.get_id_by_did(&Did::new(did.clone()).unwrap())
.await
.expect("DB error")
.expect("User not found");
let collection = "app.bsky.feed.post";
let record_count = 64usize;
let now_ms = Utc::now().timestamp_millis();
let rkeys: Vec<String> = (0..record_count)
.map(|i| format!("gc_merge_{}_{:04}", now_ms, i))
.collect();
let create_results =
futures::future::try_join_all(rkeys.iter().enumerate().map(|(i, rkey)| {
let client = client.clone();
let jwt = jwt.clone();
let did = did.clone();
let base = base.to_string();
let payload = json!({
"repo": did,
"collection": collection,
"rkey": rkey,
"record": {
"$type": collection,
"text": format!("seed record {}", i),
"createdAt": Utc::now().to_rfc3339()
}
});
async move {
let res = client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
.bearer_auth(&jwt)
.json(&payload)
.send()
.await
.expect("Failed to send createRecord");
if res.status() != StatusCode::OK {
return Err(format!("seed createRecord failed: {}", res.status()));
}
Ok::<(), String>(())
}
}))
.await;
create_results.expect("seeding records failed");
let count_after_seed = repos
.repo
.count_user_blocks(user_id)
.await
.expect("count_user_blocks failed");
let target_rkey = &rkeys[record_count / 2];
let delete_payload = json!({
"repo": did,
"collection": collection,
"rkey": target_rkey,
});
let delete_res = client
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base))
.bearer_auth(&jwt)
.json(&delete_payload)
.send()
.await
.expect("Failed to send deleteRecord");
assert_eq!(
delete_res.status(),
StatusCode::OK,
"deleteRecord did not return 200: {:?}",
delete_res.text().await
);
let count_after_delete = repos
.repo
.count_user_blocks(user_id)
.await
.expect("count_user_blocks failed");
assert!(
count_after_delete < count_after_seed,
"user_blocks did not shrink after deleting from a populated repo \
(after_seed={}, after_delete={}). The path-walk-based obsolete \
calculation does not capture sibling subtree blocks orphaned by \
delete-merge; only an MST-diff-based calculation does.",
count_after_seed,
count_after_delete
);
let get_res = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base))
.query(&[
("repo", did.as_str()),
("collection", collection),
("rkey", target_rkey.as_str()),
])
.send()
.await
.expect("Failed to send getRecord");
assert!(
!get_res.status().is_success(),
"deleted record is still resolvable via getRecord (status={})",
get_res.status(),
);
assert_user_blocks_matches_repo(user_id, "the second putRecord").await;
}
#[tokio::test]
@@ -339,9 +181,11 @@ async fn test_delete_decrements_tranquil_store_refcounts() {
.as_tranquil_store()
.expect("tranquil-store backend selected but block_store is not TranquilStore");
let (did, jwt) = setup_new_user("gc-store-decrement").await;
let did = Did::new(did).expect("setup_new_user returned a valid DID");
let collection = "app.bsky.feed.post";
let rkey = format!("gc_store_{}", Utc::now().timestamp_millis());
let collection = Nsid::new("app.bsky.feed.post".to_string()).expect("valid NSID");
let rkey =
Rkey::new(format!("gc_store_{}", Utc::now().timestamp_millis())).expect("valid rkey");
let create_res = client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
@@ -107,9 +107,11 @@ async fn mst_blocks_survive_full_store_reopen() {
let head_cid = cid::Cid::try_from(repo_root_str.as_str()).expect("invalid cid");
let car_blocks = tranquil_pds::scheduled::collect_current_repo_blocks(block_store, &head_cid)
let walk = tranquil_pds::scheduled::collect_current_repo_blocks(block_store, &head_cid)
.await
.expect("collect blocks");
assert!(walk.is_complete(), "repo walk read every block it reached");
let car_blocks = walk.block_cids;
let block_count_before = car_blocks.len();
+63
View File
@@ -480,6 +480,69 @@ pub fn get_multikey_from_signing_key(signing_key: &k256::ecdsa::SigningKey) -> S
multibase::encode(multibase::Base::Base58Btc, buf)
}
async fn reachable_blocks(user_id: uuid::Uuid) -> std::collections::BTreeSet<cid::Cid> {
use jacquard_repo::storage::BlockStore;
let repos = super::common::get_test_repos().await;
let store = super::common::get_test_block_store().await;
let root_str = repos
.repo
.get_repo_root_cid_by_user_id(user_id)
.await
.expect("DB error fetching repo root")
.expect("repo root not found");
let root_cid = cid::Cid::try_from(root_str.as_str()).expect("repo root isn't a valid CID");
let commit_bytes = store
.get(&root_cid)
.await
.expect("block store error fetching commit")
.expect("commit block not in block store");
let data_cid = jacquard_repo::commit::Commit::from_cbor(&commit_bytes)
.expect("commit block doesn't parse")
.data;
let mst = jacquard_repo::mst::Mst::load(std::sync::Arc::new(store.clone()), data_cid, None);
let mut cids = tranquil_pds::repo_ops::reachable_tree_cids(&mst)
.await
.expect("walking the new MST failed");
cids.insert(root_cid);
cids
}
async fn recorded_blocks(user_id: uuid::Uuid) -> std::collections::BTreeSet<cid::Cid> {
super::common::get_test_repos()
.await
.repo
.get_user_block_cids_since_rev(user_id, None)
.await
.expect("get_user_block_cids_since_rev failed")
.iter()
.map(|b| cid::Cid::try_from(b.as_slice()).expect("invalid CID in user_blocks"))
.collect()
}
#[allow(dead_code)]
pub async fn assert_user_blocks_matches_repo(user_id: uuid::Uuid, phase: &str) {
let reachable = reachable_blocks(user_id).await;
let recorded = recorded_blocks(user_id).await;
let missing: Vec<String> = reachable
.difference(&recorded)
.map(cid::Cid::to_string)
.collect();
let stale: Vec<String> = recorded
.difference(&reachable)
.map(cid::Cid::to_string)
.collect();
assert!(
missing.is_empty() && stale.is_empty(),
"user_blocks doesn't match the blocks reachable from the repo root after {phase}. \
reachable={} recorded={} missing={missing:?} stale={stale:?}",
reachable.len(),
recorded.len(),
);
}
#[allow(dead_code)]
pub async fn get_user_signing_key(did: &str) -> Option<Vec<u8>> {
let repos = super::common::get_test_repos().await;
+7 -7
View File
@@ -40,7 +40,7 @@ fn create_unsigned_jwt(header: &Value, claims: &Value) -> String {
#[test]
fn test_signature_attacks() {
let key_bytes = generate_user_key();
let did = Did::from("did:plc:whelk".to_string());
let did = Did::new("did:plc:whelk").expect("valid DID");
let token = create_access_token(&did, &key_bytes).expect("create token");
let parts: Vec<&str> = token.split('.').collect();
@@ -143,7 +143,7 @@ fn test_algorithm_substitution_attacks() {
#[test]
fn test_token_type_confusion() {
let key_bytes = generate_user_key();
let did = Did::from("did:plc:whelk".to_string());
let did = Did::new("did:plc:whelk").expect("valid DID");
let refresh_token = create_refresh_token(&did, &key_bytes).expect("create refresh token");
let result = verify_access_token(&refresh_token, &key_bytes);
@@ -169,8 +169,8 @@ fn test_token_type_confusion() {
let service_token = create_service_token(
&did,
&Did::from("did:web:nel.pet".to_string()),
Some(&Nsid::from("cafe.oyster.method".to_string())),
&Did::new("did:web:nel.pet").expect("valid DID"),
Some(&Nsid::new("cafe.oyster.method").expect("valid NSID")),
&key_bytes,
)
.unwrap();
@@ -434,7 +434,7 @@ fn test_claim_validation() {
#[test]
fn test_did_and_jti_extraction() {
let key_bytes = generate_user_key();
let did = Did::from("did:plc:limpet".to_string());
let did = Did::new("did:plc:limpet").expect("valid DID");
let token = create_access_token(&did, &key_bytes).expect("create token");
assert_eq!(get_did_from_token(&token).unwrap(), did);
@@ -459,7 +459,7 @@ fn test_did_and_jti_extraction() {
#[test]
fn test_header_injection_and_constant_time() {
let key_bytes = generate_user_key();
let did = Did::from("did:plc:whelk".to_string());
let did = Did::new("did:plc:whelk").expect("valid DID");
let header = json!({
"alg": "ES256K", "typ": TokenType::Access.as_str(),
@@ -501,7 +501,7 @@ async fn test_server_rejects_invalid_tokens() {
let key_bytes = generate_user_key();
let forged_token =
create_access_token(&Did::from("did:plc:lyna".to_string()), &key_bytes).unwrap();
create_access_token(&Did::new("did:plc:lyna").expect("valid DID"), &key_bytes).unwrap();
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("Bearer {}", forged_token))
+32 -2
View File
@@ -605,6 +605,7 @@ async fn test_oauth_multiple_clients_same_user() {
("redirect_uri", "https://client1.example.com/callback"),
("code_challenge", &challenge1),
("code_challenge_method", "S256"),
("scope", "atproto repo:app.bsky.feed.post?action=create"),
])
.send()
.await
@@ -631,7 +632,7 @@ async fn test_oauth_multiple_clients_same_user() {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto", "transition:generic"], "remember": false}))
.json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto", "repo:app.bsky.feed.post?action=create"], "remember": false}))
.send().await.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location1 = consent_body["redirect_uri"].as_str().unwrap().to_string();
@@ -666,6 +667,7 @@ async fn test_oauth_multiple_clients_same_user() {
("redirect_uri", "https://client2.example.com/callback"),
("code_challenge", &challenge2),
("code_challenge_method", "S256"),
("scope", "atproto repo:app.bsky.feed.post?action=create"),
])
.send()
.await
@@ -692,7 +694,7 @@ async fn test_oauth_multiple_clients_same_user() {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto", "transition:generic"], "remember": false}))
.json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto", "repo:app.bsky.feed.post?action=create"], "remember": false}))
.send().await.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location2 = consent_body["redirect_uri"].as_str().unwrap().to_string();
@@ -763,6 +765,33 @@ async fn test_oauth_multiple_clients_same_user() {
StatusCode::OK,
"Client 2 token should work"
);
let ungranted_collection = "app.bsky.graph.follow";
let denied_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(token1)
.json(&json!({
"repo": user_did,
"collection": ungranted_collection,
"record": {
"$type": ungranted_collection,
"subject": user_did,
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(
denied_res.status(),
StatusCode::FORBIDDEN,
"Client 1 token shouldn't write outside its granted collection"
);
let denied_body: Value = denied_res.json().await.unwrap();
assert_eq!(
denied_body["error"].as_str(),
Some("InsufficientScope"),
"Write outside the granted collection should fail on scope"
);
let list_res = http_client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
.bearer_auth(token1)
@@ -770,6 +799,7 @@ async fn test_oauth_multiple_clients_same_user() {
.send()
.await
.unwrap();
assert_eq!(list_res.status(), StatusCode::OK, "listRecords should work");
let list_body: Value = list_res.json().await.unwrap();
let records = list_body["records"].as_array().unwrap();
assert_eq!(
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,7 @@ fn now() -> String {
}
fn c(s: &str) -> Nsid {
Nsid::from(s.to_string())
Nsid::new(s).expect("valid NSID")
}
#[test]
@@ -144,7 +144,7 @@ async fn repair_fails_loud_on_missing_leaf_block() {
let recorded = repos
.repo
.get_user_block_cids_since_rev(user_id, &tranquil_types::Tid::from(String::new()))
.get_user_block_cids_since_rev(user_id, None)
.await
.expect("read user_blocks");
assert!(
+3 -3
View File
@@ -512,9 +512,9 @@ async fn follow_uris_pointing_to(repo_id: uuid::Uuid, target_did: &str) -> Vec<S
let repos = get_test_repos().await;
let probe = Backlink {
uri: AtUri::from_parts(
&Did::from("did:plc:periwinkle".to_string()),
&Nsid::from("app.bsky.graph.follow".to_string()),
&Rkey::from("probe".to_string()),
&Did::new("did:plc:periwinkle").expect("valid DID"),
&Nsid::new("app.bsky.graph.follow").expect("valid NSID"),
&Rkey::new("probe").expect("valid rkey"),
),
path: BacklinkPath::Subject,
link_to: target_did.to_string(),
@@ -6,7 +6,7 @@ use tranquil_pds::oauth::scopes::{
use tranquil_types::Nsid;
fn c(s: &str) -> Nsid {
Nsid::from(s.to_string())
Nsid::new(s).expect("valid NSID")
}
#[test]

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