mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-25 10:46:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
546d342136 | ||
|
|
c680f3c419 | ||
|
|
806cb4b8c5 | ||
|
|
458b9b7e0d | ||
|
|
83fda8b57a | ||
|
|
dab347cc83 | ||
|
|
08f0e67bf7 | ||
|
|
421b35ff9c | ||
|
|
33e96665b8 | ||
|
|
559cbc1001 | ||
|
|
18cdb612f6 | ||
|
|
2c8568b207 |
Generated
+34
-15
@@ -6094,7 +6094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
@@ -6117,7 +6117,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6131,7 +6131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6146,7 +6146,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
@@ -6154,7 +6154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -6170,7 +6170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -6187,7 +6187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6203,7 +6203,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -6212,9 +6212,27 @@ dependencies = [
|
||||
"tranquil-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hickory-resolver",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
"urlencoding",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -6237,7 +6255,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -6309,6 +6327,7 @@ dependencies = [
|
||||
"tranquil-crypto",
|
||||
"tranquil-db",
|
||||
"tranquil-db-traits",
|
||||
"tranquil-lexicon",
|
||||
"tranquil-oauth",
|
||||
"tranquil-repo",
|
||||
"tranquil-ripple",
|
||||
@@ -6324,7 +6343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -6336,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"backon",
|
||||
@@ -6361,7 +6380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -6377,7 +6396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -6394,7 +6413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cid",
|
||||
|
||||
+5
-1
@@ -16,10 +16,11 @@ members = [
|
||||
"crates/tranquil-db-traits",
|
||||
"crates/tranquil-db",
|
||||
"crates/tranquil-pds",
|
||||
"crates/tranquil-lexicon",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.4.1"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -38,6 +39,9 @@ tranquil-comms = { path = "crates/tranquil-comms" }
|
||||
tranquil-db-traits = { path = "crates/tranquil-db-traits" }
|
||||
tranquil-db = { path = "crates/tranquil-db" }
|
||||
tranquil-ripple = { path = "crates/tranquil-ripple" }
|
||||
tranquil-lexicon = { path = "crates/tranquil-lexicon" }
|
||||
|
||||
unicode-segmentation = "1"
|
||||
|
||||
aes-gcm = "0.10"
|
||||
backon = "1"
|
||||
|
||||
+29
-3
@@ -8,8 +8,23 @@ RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-li
|
||||
WORKDIR /app
|
||||
ARG SLIM="false"
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates ./crates
|
||||
COPY .sqlx ./.sqlx
|
||||
COPY crates/tranquil-types ./crates/tranquil-types
|
||||
COPY crates/tranquil-crypto ./crates/tranquil-crypto
|
||||
COPY crates/tranquil-scopes ./crates/tranquil-scopes
|
||||
COPY crates/tranquil-config ./crates/tranquil-config
|
||||
COPY crates/tranquil-repo ./crates/tranquil-repo
|
||||
COPY crates/tranquil-lexicon ./crates/tranquil-lexicon
|
||||
COPY crates/tranquil-oauth ./crates/tranquil-oauth
|
||||
COPY crates/tranquil-db-traits ./crates/tranquil-db-traits
|
||||
COPY crates/tranquil-infra ./crates/tranquil-infra
|
||||
COPY crates/tranquil-auth ./crates/tranquil-auth
|
||||
COPY crates/tranquil-comms ./crates/tranquil-comms
|
||||
COPY crates/tranquil-db ./crates/tranquil-db
|
||||
COPY crates/tranquil-ripple ./crates/tranquil-ripple
|
||||
COPY crates/tranquil-storage ./crates/tranquil-storage
|
||||
COPY crates/tranquil-cache ./crates/tranquil-cache
|
||||
COPY crates/tranquil-pds ./crates/tranquil-pds
|
||||
COPY migrations ./crates/tranquil-pds/migrations
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/app/target \
|
||||
@@ -20,12 +35,23 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
fi && \
|
||||
cp target/release/tranquil-pds /tmp/tranquil-pds
|
||||
|
||||
FROM alpine:3.23
|
||||
RUN apk add --no-cache msmtp ca-certificates && ln -sf /usr/bin/msmtp /usr/sbin/sendmail
|
||||
FROM alpine:3.23 AS signal-cli
|
||||
RUN apk add --no-cache curl tar
|
||||
ARG SIGNAL_CLI_VERSION=0.13.24
|
||||
RUN curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
|
||||
| tar xz -C /usr/local/bin
|
||||
|
||||
FROM debian:trixie-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends msmtp ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& ln -sf /usr/bin/msmtp /usr/sbin/sendmail
|
||||
COPY --from=signal-cli /usr/local/bin/signal-cli /usr/local/bin/signal-cli
|
||||
VOLUME /var/lib/signal-cli
|
||||
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
|
||||
COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend
|
||||
COPY migrations /app/migrations
|
||||
WORKDIR /app
|
||||
ENV SIGNAL_CLI_CONFIG=/var/lib/signal-cli
|
||||
ENV SERVER_HOST=0.0.0.0
|
||||
ENV SERVER_PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -22,10 +22,13 @@ pub trait CommsSender: Send + Sync {
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SendError {
|
||||
#[error("Failed to spawn sendmail process: {0}")]
|
||||
ProcessSpawn(#[from] std::io::Error),
|
||||
#[error("Sendmail exited with non-zero status: {0}")]
|
||||
SendmailFailed(String),
|
||||
#[error("Failed to spawn {command}: {source}")]
|
||||
ProcessSpawn {
|
||||
command: String,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("{command} exited with non-zero status: {detail}")]
|
||||
ProcessFailed { command: String, detail: String },
|
||||
#[error("Channel not configured: {0:?}")]
|
||||
NotConfigured(CommsChannel),
|
||||
#[error("External service error: {0}")]
|
||||
@@ -160,14 +163,33 @@ impl CommsSender for EmailSender {
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
.spawn()
|
||||
.map_err(|e| SendError::ProcessSpawn {
|
||||
command: self.sendmail_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(email_content.as_bytes()).await?;
|
||||
stdin
|
||||
.write_all(email_content.as_bytes())
|
||||
.await
|
||||
.map_err(|e| SendError::ProcessSpawn {
|
||||
command: self.sendmail_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
}
|
||||
let output = child.wait_with_output().await?;
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| SendError::ProcessSpawn {
|
||||
command: self.sendmail_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(SendError::SendmailFailed(stderr.to_string()));
|
||||
return Err(SendError::ProcessFailed {
|
||||
command: self.sendmail_path.clone(),
|
||||
detail: stderr.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -656,7 +678,10 @@ impl CommsSender for SignalSender {
|
||||
retry_delay(attempt).await;
|
||||
continue;
|
||||
}
|
||||
return Err(SendError::ProcessSpawn(e));
|
||||
return Err(SendError::ProcessSpawn {
|
||||
command: self.signal_cli_path.clone(),
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
if attempt < MAX_RETRIES - 1 {
|
||||
|
||||
@@ -258,7 +258,7 @@ impl TranquilConfig {
|
||||
// -- SSO providers ----------------------------------------------------
|
||||
self.validate_sso_provider("sso.github", &self.sso.github, &mut errors);
|
||||
self.validate_sso_provider("sso.google", &self.sso.google, &mut errors);
|
||||
self.validate_sso_discord(&mut errors);
|
||||
self.validate_sso_provider("sso.discord", &self.sso.discord, &mut errors);
|
||||
self.validate_sso_with_issuer("sso.gitlab", &self.sso.gitlab, &mut errors);
|
||||
self.validate_sso_with_issuer("sso.oidc", &self.sso.oidc, &mut errors);
|
||||
self.validate_sso_apple(&mut errors);
|
||||
@@ -300,14 +300,19 @@ impl TranquilConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_sso_provider(&self, prefix: &str, p: &SsoProviderConfig, errors: &mut Vec<String>) {
|
||||
if p.enabled {
|
||||
if p.client_id.is_none() {
|
||||
fn validate_sso_provider(
|
||||
&self,
|
||||
prefix: &str,
|
||||
p: &impl SsoProviderConfig,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
if p.get_enabled() {
|
||||
if p.get_client_id().is_none() {
|
||||
errors.push(format!(
|
||||
"{prefix}.client_id is required when {prefix}.enabled = true"
|
||||
));
|
||||
}
|
||||
if p.client_secret.is_none() {
|
||||
if p.get_client_secret().is_none() {
|
||||
errors.push(format!(
|
||||
"{prefix}.client_secret is required when {prefix}.enabled = true"
|
||||
));
|
||||
@@ -315,45 +320,17 @@ impl TranquilConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_sso_discord(&self, errors: &mut Vec<String>) {
|
||||
let p = &self.sso.discord;
|
||||
if p.enabled {
|
||||
if p.client_id.is_none() {
|
||||
errors.push(
|
||||
"sso.discord.client_id is required when sso.discord.enabled = true".to_string(),
|
||||
);
|
||||
}
|
||||
if p.client_secret.is_none() {
|
||||
errors.push(
|
||||
"sso.discord.client_secret is required when sso.discord.enabled = true"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_sso_with_issuer(
|
||||
&self,
|
||||
prefix: &str,
|
||||
p: &SsoProviderWithIssuerConfig,
|
||||
p: &(impl SsoProviderConfig + SsoProviderIssuerConfig),
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
if p.enabled {
|
||||
if p.client_id.is_none() {
|
||||
errors.push(format!(
|
||||
"{prefix}.client_id is required when {prefix}.enabled = true"
|
||||
));
|
||||
}
|
||||
if p.client_secret.is_none() {
|
||||
errors.push(format!(
|
||||
"{prefix}.client_secret is required when {prefix}.enabled = true"
|
||||
));
|
||||
}
|
||||
if p.issuer.is_none() {
|
||||
errors.push(format!(
|
||||
"{prefix}.issuer is required when {prefix}.enabled = true"
|
||||
));
|
||||
}
|
||||
self.validate_sso_provider(prefix, p, errors);
|
||||
if p.get_enabled() && p.get_issuer().is_none() {
|
||||
errors.push(format!(
|
||||
"{prefix}.issuer is required when {prefix}.enabled = true"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,59 +749,221 @@ pub struct NotificationConfig {
|
||||
pub batch_size: i64,
|
||||
}
|
||||
|
||||
pub trait SsoProviderConfig {
|
||||
fn get_enabled(&self) -> bool;
|
||||
fn get_client_id(&self) -> &Option<String>;
|
||||
fn get_client_secret(&self) -> &Option<String>;
|
||||
fn get_display_name(&self) -> &Option<String>;
|
||||
}
|
||||
|
||||
pub trait SsoProviderIssuerConfig {
|
||||
fn get_issuer(&self) -> &Option<String>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SsoConfig {
|
||||
#[config(nested)]
|
||||
pub github: SsoProviderConfig,
|
||||
pub github: SsoGitHubConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub discord: SsoDiscordProviderConfig,
|
||||
pub discord: SsoDiscordConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub google: SsoProviderConfig,
|
||||
pub google: SsoGoogleConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub gitlab: SsoProviderWithIssuerConfig,
|
||||
pub gitlab: SsoGitLabConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub oidc: SsoProviderWithIssuerConfig,
|
||||
pub oidc: SsoOidcConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub apple: SsoAppleConfig,
|
||||
}
|
||||
|
||||
// Generic SSO provider (GitHub, Google)
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SsoProviderConfig {
|
||||
#[config(default = false)]
|
||||
pub struct SsoGitHubConfig {
|
||||
#[config(env = "SSO_GITHUB_ENABLED", default = false)]
|
||||
pub enabled: bool,
|
||||
|
||||
#[config(env = "SSO_GITHUB_CLIENT_ID")]
|
||||
pub client_id: Option<String>,
|
||||
|
||||
#[config(env = "SSO_GITHUB_CLIENT_SECRET")]
|
||||
pub client_secret: Option<String>,
|
||||
|
||||
#[config(env = "SSO_GITHUB_DISPLAY_NAME")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
// SSO provider with custom env prefixes for Discord
|
||||
// (since the nested TOML key is `sso.discord` but env vars are `SSO_DISCORD_*`)
|
||||
impl SsoProviderConfig for SsoGitHubConfig {
|
||||
fn get_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
fn get_client_id(&self) -> &Option<String> {
|
||||
&self.client_id
|
||||
}
|
||||
|
||||
fn get_client_secret(&self) -> &Option<String> {
|
||||
&self.client_secret
|
||||
}
|
||||
|
||||
fn get_display_name(&self) -> &Option<String> {
|
||||
&self.display_name
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SsoDiscordProviderConfig {
|
||||
#[config(default = false)]
|
||||
pub struct SsoDiscordConfig {
|
||||
#[config(env = "SSO_DISCORD_ENABLED", default = false)]
|
||||
pub enabled: bool,
|
||||
|
||||
#[config(env = "SSO_DISCORD_CLIENT_ID")]
|
||||
pub client_id: Option<String>,
|
||||
|
||||
#[config(env = "SSO_DISCORD_CLIENT_SECRET")]
|
||||
pub client_secret: Option<String>,
|
||||
|
||||
#[config(env = "SSO_DISCORD_DISPLAY_NAME")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
// SSO providers that require an issuer URL (GitLab, OIDC)
|
||||
impl SsoProviderConfig for SsoDiscordConfig {
|
||||
fn get_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
fn get_client_id(&self) -> &Option<String> {
|
||||
&self.client_id
|
||||
}
|
||||
|
||||
fn get_client_secret(&self) -> &Option<String> {
|
||||
&self.client_secret
|
||||
}
|
||||
|
||||
fn get_display_name(&self) -> &Option<String> {
|
||||
&self.display_name
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SsoProviderWithIssuerConfig {
|
||||
#[config(default = false)]
|
||||
pub struct SsoGoogleConfig {
|
||||
#[config(env = "SSO_GOOGLE_ENABLED", default = false)]
|
||||
pub enabled: bool,
|
||||
|
||||
#[config(env = "SSO_GOOGLE_CLIENT_ID")]
|
||||
pub client_id: Option<String>,
|
||||
|
||||
#[config(env = "SSO_GOOGLE_CLIENT_SECRET")]
|
||||
pub client_secret: Option<String>,
|
||||
|
||||
#[config(env = "SSO_GOOGLE_DISPLAY_NAME")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
impl SsoProviderConfig for SsoGoogleConfig {
|
||||
fn get_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
fn get_client_id(&self) -> &Option<String> {
|
||||
&self.client_id
|
||||
}
|
||||
|
||||
fn get_client_secret(&self) -> &Option<String> {
|
||||
&self.client_secret
|
||||
}
|
||||
|
||||
fn get_display_name(&self) -> &Option<String> {
|
||||
&self.display_name
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SsoGitLabConfig {
|
||||
#[config(env = "SSO_GITLAB_ENABLED", default = false)]
|
||||
pub enabled: bool,
|
||||
|
||||
#[config(env = "SSO_GITLAB_CLIENT_ID")]
|
||||
pub client_id: Option<String>,
|
||||
|
||||
#[config(env = "SSO_GITLAB_CLIENT_SECRET")]
|
||||
pub client_secret: Option<String>,
|
||||
|
||||
#[config(env = "SSO_GITLAB_ISSUER")]
|
||||
pub issuer: Option<String>,
|
||||
|
||||
#[config(env = "SSO_GITLAB_DISPLAY_NAME")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
impl SsoProviderConfig for SsoGitLabConfig {
|
||||
fn get_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
fn get_client_id(&self) -> &Option<String> {
|
||||
&self.client_id
|
||||
}
|
||||
|
||||
fn get_client_secret(&self) -> &Option<String> {
|
||||
&self.client_secret
|
||||
}
|
||||
|
||||
fn get_display_name(&self) -> &Option<String> {
|
||||
&self.display_name
|
||||
}
|
||||
}
|
||||
|
||||
impl SsoProviderIssuerConfig for SsoGitLabConfig {
|
||||
fn get_issuer(&self) -> &Option<String> {
|
||||
&self.issuer
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SsoOidcConfig {
|
||||
#[config(env = "SSO_OIDC_ENABLED", default = false)]
|
||||
pub enabled: bool,
|
||||
|
||||
#[config(env = "SSO_OIDC_CLIENT_ID")]
|
||||
pub client_id: Option<String>,
|
||||
|
||||
#[config(env = "SSO_OIDC_CLIENT_SECRET")]
|
||||
pub client_secret: Option<String>,
|
||||
|
||||
#[config(env = "SSO_OIDC_ISSUER")]
|
||||
pub issuer: Option<String>,
|
||||
|
||||
#[config(env = "SSO_OIDC_DISPLAY_NAME")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
impl SsoProviderConfig for SsoOidcConfig {
|
||||
fn get_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
fn get_client_id(&self) -> &Option<String> {
|
||||
&self.client_id
|
||||
}
|
||||
|
||||
fn get_client_secret(&self) -> &Option<String> {
|
||||
&self.client_secret
|
||||
}
|
||||
|
||||
fn get_display_name(&self) -> &Option<String> {
|
||||
&self.display_name
|
||||
}
|
||||
}
|
||||
|
||||
impl SsoProviderIssuerConfig for SsoOidcConfig {
|
||||
fn get_issuer(&self) -> &Option<String> {
|
||||
&self.issuer
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SsoAppleConfig {
|
||||
#[config(env = "SSO_APPLE_ENABLED", default = false)]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "tranquil-lexicon"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:urlencoding"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
reqwest = { workspace = true, optional = true }
|
||||
hickory-resolver = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
parking_lot = { workspace = true, optional = true }
|
||||
tracing = { workspace = true, optional = true }
|
||||
urlencoding = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
@@ -0,0 +1,278 @@
|
||||
use crate::resolve::{ResolveError, resolve_lexicon};
|
||||
use crate::schema::LexiconDoc;
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const MAX_DYNAMIC_SCHEMAS: usize = 1024;
|
||||
|
||||
struct NegativeEntry {
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
struct SchemaStore {
|
||||
schemas: HashMap<String, Arc<LexiconDoc>>,
|
||||
insertion_order: VecDeque<String>,
|
||||
}
|
||||
|
||||
pub struct DynamicRegistry {
|
||||
store: RwLock<SchemaStore>,
|
||||
negative_cache: RwLock<HashMap<String, NegativeEntry>>,
|
||||
network_disabled: AtomicBool,
|
||||
}
|
||||
|
||||
impl DynamicRegistry {
|
||||
pub fn new() -> Self {
|
||||
let network_disabled =
|
||||
std::env::var("TRANQUIL_LEXICON_OFFLINE").is_ok_and(|v| v == "1" || v == "true");
|
||||
Self {
|
||||
store: RwLock::new(SchemaStore {
|
||||
schemas: HashMap::new(),
|
||||
insertion_order: VecDeque::new(),
|
||||
}),
|
||||
negative_cache: RwLock::new(HashMap::new()),
|
||||
network_disabled: AtomicBool::new(network_disabled),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_network_disabled(&self, disabled: bool) {
|
||||
self.network_disabled.store(disabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn get(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
|
||||
self.store.read().schemas.get(nsid).cloned()
|
||||
}
|
||||
|
||||
pub fn is_negative_cached(&self, nsid: &str) -> bool {
|
||||
let cache = self.negative_cache.read();
|
||||
cache
|
||||
.get(nsid)
|
||||
.is_some_and(|entry| entry.expires_at > Instant::now())
|
||||
}
|
||||
|
||||
fn insert_negative(&self, nsid: &str) {
|
||||
let mut cache = self.negative_cache.write();
|
||||
if cache.len() > MAX_DYNAMIC_SCHEMAS {
|
||||
let now = Instant::now();
|
||||
cache.retain(|_, entry| entry.expires_at > now);
|
||||
}
|
||||
cache.insert(
|
||||
nsid.to_string(),
|
||||
NegativeEntry {
|
||||
expires_at: Instant::now() + NEGATIVE_CACHE_TTL,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn insert_schema(&self, doc: LexiconDoc) -> Arc<LexiconDoc> {
|
||||
let arc = Arc::new(doc);
|
||||
let nsid = arc.id.clone();
|
||||
|
||||
let mut store = self.store.write();
|
||||
|
||||
if store.schemas.len() >= MAX_DYNAMIC_SCHEMAS {
|
||||
tracing::warn!(
|
||||
count = store.schemas.len(),
|
||||
"dynamic schema registry at capacity, evicting oldest entries"
|
||||
);
|
||||
let evict_count = store.schemas.len() / 4;
|
||||
(0..evict_count).for_each(|_| {
|
||||
if let Some(key) = store.insertion_order.pop_front() {
|
||||
store.schemas.remove(&key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if store
|
||||
.schemas
|
||||
.insert(nsid.clone(), Arc::clone(&arc))
|
||||
.is_some()
|
||||
{
|
||||
store.insertion_order.retain(|k| k != &nsid);
|
||||
}
|
||||
store.insertion_order.push_back(nsid.clone());
|
||||
|
||||
self.negative_cache.write().remove(&arc.id);
|
||||
|
||||
arc
|
||||
}
|
||||
|
||||
pub async fn resolve_and_cache(&self, nsid: &str) -> Result<Arc<LexiconDoc>, ResolveError> {
|
||||
if let Some(doc) = self.get(nsid) {
|
||||
return Ok(doc);
|
||||
}
|
||||
|
||||
if self.network_disabled.load(Ordering::Relaxed) {
|
||||
return Err(ResolveError::NetworkDisabled);
|
||||
}
|
||||
|
||||
if self.is_negative_cached(nsid) {
|
||||
return Err(ResolveError::NegativelyCached {
|
||||
nsid: nsid.to_string(),
|
||||
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
|
||||
});
|
||||
}
|
||||
|
||||
match resolve_lexicon(nsid).await {
|
||||
Ok(doc) => Ok(self.insert_schema(doc)),
|
||||
Err(e) => {
|
||||
tracing::debug!(nsid = nsid, error = %e, "caching negative resolution result");
|
||||
self.insert_negative(nsid);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schema_count(&self) -> usize {
|
||||
self.store.read().schemas.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DynamicRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_negative_cache() {
|
||||
let registry = DynamicRegistry::new();
|
||||
assert!(!registry.is_negative_cached("com.example.test"));
|
||||
|
||||
registry.insert_negative("com.example.test");
|
||||
assert!(registry.is_negative_cached("com.example.test"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_negative_cache_returns_appropriate_error_variant() {
|
||||
let registry = DynamicRegistry::new();
|
||||
registry.insert_negative("com.example.cached");
|
||||
|
||||
let err = registry
|
||||
.resolve_and_cache("com.example.cached")
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
!matches!(err, ResolveError::InvalidNsid(_)),
|
||||
"negative cache hit should not return InvalidNsid - the NSID is valid, it just failed resolution recently. got: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_lookup() {
|
||||
let registry = DynamicRegistry::new();
|
||||
assert!(registry.get("com.example.nonexistent").is_none());
|
||||
assert_eq!(registry.schema_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_and_retrieve() {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.test".to_string(),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
|
||||
let arc = registry.insert_schema(doc);
|
||||
assert_eq!(arc.id, "com.example.test");
|
||||
assert_eq!(registry.schema_count(), 1);
|
||||
|
||||
let retrieved = registry.get("com.example.test");
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().id, "com.example.test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negative_cache_cleared_on_insert() {
|
||||
let registry = DynamicRegistry::new();
|
||||
|
||||
registry.insert_negative("com.example.test");
|
||||
assert!(registry.is_negative_cached("com.example.test"));
|
||||
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.test".to_string(),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
|
||||
assert!(!registry.is_negative_cached("com.example.test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eviction_is_fifo() {
|
||||
let registry = DynamicRegistry::new();
|
||||
|
||||
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: format!("com.example.schema{}", i),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(doc);
|
||||
});
|
||||
assert_eq!(registry.schema_count(), MAX_DYNAMIC_SCHEMAS);
|
||||
|
||||
let trigger = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.trigger".to_string(),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.insert_schema(trigger);
|
||||
|
||||
assert!(
|
||||
registry.get("com.example.schema0").is_none(),
|
||||
"oldest entry should be evicted"
|
||||
);
|
||||
assert!(
|
||||
registry.get("com.example.trigger").is_some(),
|
||||
"newly inserted entry should exist"
|
||||
);
|
||||
let evict_count = MAX_DYNAMIC_SCHEMAS / 4;
|
||||
assert!(
|
||||
registry
|
||||
.get(&format!("com.example.schema{}", evict_count))
|
||||
.is_some(),
|
||||
"entry after eviction window should survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eviction_frees_memory() {
|
||||
let registry = DynamicRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.tracked".to_string(),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
let arc = registry.insert_schema(doc);
|
||||
let weak = Arc::downgrade(&arc);
|
||||
drop(arc);
|
||||
|
||||
assert!(weak.upgrade().is_some(), "registry still holds a reference");
|
||||
|
||||
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
|
||||
registry.insert_schema(LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: format!("com.example.filler{}", i),
|
||||
defs: HashMap::new(),
|
||||
});
|
||||
});
|
||||
|
||||
assert!(
|
||||
weak.upgrade().is_none(),
|
||||
"evicted Arc should be freed when no external references remain"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
pub fn is_valid_did(s: &str) -> bool {
|
||||
s.strip_prefix("did:")
|
||||
.and_then(|rest| rest.split_once(':'))
|
||||
.is_some_and(|(method, id)| {
|
||||
!method.is_empty()
|
||||
&& method
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
&& !id.is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_valid_handle(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 253
|
||||
&& s.contains('.')
|
||||
&& s.split('.').all(|seg| {
|
||||
!seg.is_empty()
|
||||
&& seg.len() <= 63
|
||||
&& seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
&& !seg.starts_with('-')
|
||||
&& !seg.ends_with('-')
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_valid_at_uri(s: &str) -> bool {
|
||||
s.strip_prefix("at://").is_some_and(|rest| {
|
||||
let authority = rest.split('/').next().unwrap_or("");
|
||||
is_valid_did(authority) || is_valid_handle(authority)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_valid_datetime(s: &str) -> bool {
|
||||
chrono::DateTime::parse_from_rfc3339(s).is_ok()
|
||||
}
|
||||
|
||||
pub fn is_valid_uri(s: &str) -> bool {
|
||||
s.split_once("://").is_some_and(|(scheme, rest)| {
|
||||
!scheme.is_empty()
|
||||
&& scheme
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
|
||||
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
|
||||
&& !rest.is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_valid_cid(s: &str) -> bool {
|
||||
s.len() >= 8 && s.chars().all(|c| c.is_ascii_alphanumeric()) && s.starts_with(['b', 'z', 'Q'])
|
||||
}
|
||||
|
||||
pub fn is_valid_language(s: &str) -> bool {
|
||||
!s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
}
|
||||
|
||||
pub fn is_valid_tid(s: &str) -> bool {
|
||||
s.len() == 13
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
}
|
||||
|
||||
pub fn is_valid_record_key(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 512
|
||||
&& s != "."
|
||||
&& s != ".."
|
||||
&& s.chars().all(|c| {
|
||||
c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '~' || c == ':'
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_valid_at_identifier(s: &str) -> bool {
|
||||
is_valid_did(s) || is_valid_handle(s)
|
||||
}
|
||||
|
||||
pub fn is_valid_nsid(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.split('.').count() >= 3
|
||||
&& s.split('.').all(|seg| {
|
||||
!seg.is_empty() && seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
})
|
||||
}
|
||||
|
||||
use crate::schema::StringFormat;
|
||||
|
||||
pub fn validate_format(format: &StringFormat, value: &str) -> bool {
|
||||
match format {
|
||||
StringFormat::Did => is_valid_did(value),
|
||||
StringFormat::Handle => is_valid_handle(value),
|
||||
StringFormat::AtUri => is_valid_at_uri(value),
|
||||
StringFormat::Datetime => is_valid_datetime(value),
|
||||
StringFormat::Uri => is_valid_uri(value),
|
||||
StringFormat::Cid => is_valid_cid(value),
|
||||
StringFormat::Language => is_valid_language(value),
|
||||
StringFormat::Tid => is_valid_tid(value),
|
||||
StringFormat::RecordKey => is_valid_record_key(value),
|
||||
StringFormat::AtIdentifier => is_valid_at_identifier(value),
|
||||
StringFormat::Nsid => is_valid_nsid(value),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_dids() {
|
||||
assert!(is_valid_did("did:plc:1234567890abcdefghijk"));
|
||||
assert!(is_valid_did("did:web:example.com"));
|
||||
assert!(!is_valid_did(""));
|
||||
assert!(!is_valid_did("plc:123"));
|
||||
assert!(!is_valid_did("did:"));
|
||||
assert!(!is_valid_did("did:plc:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_handles() {
|
||||
assert!(is_valid_handle("user.bsky.social"));
|
||||
assert!(is_valid_handle("example.com"));
|
||||
assert!(!is_valid_handle("noperiod"));
|
||||
assert!(!is_valid_handle(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_at_uris() {
|
||||
assert!(is_valid_at_uri("at://did:plc:abc/app.bsky.feed.post/123"));
|
||||
assert!(is_valid_at_uri(
|
||||
"at://user.bsky.social/app.bsky.feed.post/123"
|
||||
));
|
||||
assert!(!is_valid_at_uri("https://example.com"));
|
||||
assert!(!is_valid_at_uri("at://"));
|
||||
assert!(!is_valid_at_uri("at://not valid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_datetimes() {
|
||||
assert!(is_valid_datetime("2024-01-01T00:00:00.000Z"));
|
||||
assert!(is_valid_datetime("2024-01-01T00:00:00Z"));
|
||||
assert!(!is_valid_datetime("not-a-date"));
|
||||
assert!(!is_valid_datetime("2024-13-01T00:00:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_uris() {
|
||||
assert!(is_valid_uri("https://example.com"));
|
||||
assert!(is_valid_uri("http://localhost"));
|
||||
assert!(is_valid_uri("ftp://files.example.com/path"));
|
||||
assert!(!is_valid_uri("://x"));
|
||||
assert!(!is_valid_uri("not a uri"));
|
||||
assert!(!is_valid_uri("123://bad"));
|
||||
assert!(!is_valid_uri("https://"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_cids() {
|
||||
assert!(is_valid_cid("bafyreiabcdef123456"));
|
||||
assert!(is_valid_cid(
|
||||
"QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"
|
||||
));
|
||||
assert!(is_valid_cid("zQmSomeMultibase"));
|
||||
assert!(!is_valid_cid("abc"));
|
||||
assert!(!is_valid_cid(""));
|
||||
assert!(!is_valid_cid("xyzinvalidprefix1234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_tids() {
|
||||
assert!(is_valid_tid("3k2n5j2abcdef"));
|
||||
assert!(!is_valid_tid("short"));
|
||||
assert!(!is_valid_tid("3K2N5J2ABCDEF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_record_keys() {
|
||||
assert!(is_valid_record_key("valid-key_123"));
|
||||
assert!(is_valid_record_key("self"));
|
||||
assert!(!is_valid_record_key(""));
|
||||
assert!(!is_valid_record_key("."));
|
||||
assert!(!is_valid_record_key(".."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_nsids() {
|
||||
assert!(is_valid_nsid("app.bsky.feed.post"));
|
||||
assert!(is_valid_nsid("com.atproto.repo.strongRef"));
|
||||
assert!(!is_valid_nsid("too.short"));
|
||||
assert!(!is_valid_nsid(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_did_method_with_digits() {
|
||||
assert!(is_valid_did(
|
||||
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
|
||||
));
|
||||
assert!(is_valid_did("did:3:abc123"));
|
||||
assert!(is_valid_did("did:a1b2:test"));
|
||||
assert!(!is_valid_did("did:UPPER:test"));
|
||||
assert!(!is_valid_did("did::test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_key_with_colon() {
|
||||
assert!(is_valid_record_key("self"));
|
||||
assert!(is_valid_record_key("key:with:colons"));
|
||||
assert!(is_valid_record_key("at:something"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_languages() {
|
||||
assert!(is_valid_language("en"));
|
||||
assert!(is_valid_language("en-US"));
|
||||
assert!(is_valid_language("pt-BR"));
|
||||
assert!(!is_valid_language(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
mod formats;
|
||||
mod registry;
|
||||
mod schema;
|
||||
mod validate;
|
||||
|
||||
#[cfg(feature = "resolve")]
|
||||
mod dynamic;
|
||||
#[cfg(feature = "resolve")]
|
||||
mod resolve;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_schemas;
|
||||
|
||||
pub use formats::{
|
||||
is_valid_at_identifier, is_valid_at_uri, is_valid_cid, is_valid_datetime, is_valid_did,
|
||||
is_valid_handle, is_valid_language, is_valid_nsid, is_valid_record_key, is_valid_tid,
|
||||
is_valid_uri,
|
||||
};
|
||||
pub use registry::LexiconRegistry;
|
||||
pub use schema::{LexiconDoc, ParsedRef, parse_ref};
|
||||
pub use validate::{LexValidationError, validate_record};
|
||||
|
||||
#[cfg(feature = "resolve")]
|
||||
pub use resolve::{
|
||||
ResolveError, fetch_schema_from_pds, resolve_did_from_dns, resolve_lexicon,
|
||||
resolve_lexicon_from_did, resolve_lexicon_with_config, resolve_pds_endpoint,
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
use crate::schema::{LexDef, LexObject, LexiconDoc, ParsedRef, parse_ref};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
static REGISTRY: OnceLock<LexiconRegistry> = OnceLock::new();
|
||||
|
||||
pub struct LexiconRegistry {
|
||||
schemas: HashMap<String, Arc<LexiconDoc>>,
|
||||
#[cfg(feature = "resolve")]
|
||||
dynamic: crate::dynamic::DynamicRegistry,
|
||||
}
|
||||
|
||||
impl Default for LexiconRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl LexiconRegistry {
|
||||
pub fn global() -> &'static Self {
|
||||
REGISTRY.get_or_init(Self::new)
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
schemas: HashMap::new(),
|
||||
#[cfg(feature = "resolve")]
|
||||
dynamic: crate::dynamic::DynamicRegistry::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&mut self, doc: LexiconDoc) {
|
||||
let id = doc.id.clone();
|
||||
self.schemas.insert(id, Arc::new(doc));
|
||||
}
|
||||
|
||||
#[cfg(feature = "resolve")]
|
||||
pub fn preload(&self, doc: LexiconDoc) {
|
||||
self.dynamic.insert_schema(doc);
|
||||
}
|
||||
|
||||
pub fn get_doc(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
|
||||
self.schemas.get(nsid).cloned().or_else(|| {
|
||||
#[cfg(feature = "resolve")]
|
||||
{
|
||||
self.dynamic.get(nsid)
|
||||
}
|
||||
#[cfg(not(feature = "resolve"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_record_def(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
|
||||
let doc = self.get_doc(nsid)?;
|
||||
match doc.defs.get("main")? {
|
||||
LexDef::Record(_) => Some(doc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_ref(&self, reference: &str, context_nsid: &str) -> Option<ResolvedRef> {
|
||||
match parse_ref(reference) {
|
||||
ParsedRef::Local(local) => {
|
||||
let doc = self.get_doc(context_nsid)?;
|
||||
Self::def_to_resolved(&doc, local)
|
||||
}
|
||||
ParsedRef::Qualified { nsid, fragment } => {
|
||||
let doc = self.get_doc(nsid)?;
|
||||
Self::def_to_resolved(&doc, fragment)
|
||||
}
|
||||
ParsedRef::Bare(nsid) => {
|
||||
let doc = self.get_doc(nsid)?;
|
||||
Self::def_to_resolved(&doc, "main")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn def_to_resolved(doc: &Arc<LexiconDoc>, def_name: &str) -> Option<ResolvedRef> {
|
||||
let def = doc.defs.get(def_name)?;
|
||||
match def {
|
||||
LexDef::Object(_) | LexDef::Record(_) | LexDef::Token {} | LexDef::StringDef(_) => {
|
||||
Some(ResolvedRef {
|
||||
doc: Arc::clone(doc),
|
||||
def_name: def_name.to_string(),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_schema(&self, nsid: &str) -> bool {
|
||||
self.get_doc(nsid).is_some()
|
||||
}
|
||||
|
||||
pub fn schema_count(&self) -> usize {
|
||||
let embedded = self.schemas.len();
|
||||
#[cfg(feature = "resolve")]
|
||||
{
|
||||
embedded + self.dynamic.schema_count()
|
||||
}
|
||||
#[cfg(not(feature = "resolve"))]
|
||||
{
|
||||
embedded
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "resolve")]
|
||||
pub async fn resolve_dynamic(
|
||||
&self,
|
||||
nsid: &str,
|
||||
) -> Result<Arc<LexiconDoc>, crate::resolve::ResolveError> {
|
||||
self.dynamic.resolve_and_cache(nsid).await
|
||||
}
|
||||
|
||||
#[cfg(feature = "resolve")]
|
||||
pub fn is_negative_cached(&self, nsid: &str) -> bool {
|
||||
self.dynamic.is_negative_cached(nsid)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResolvedRef {
|
||||
doc: Arc<LexiconDoc>,
|
||||
def_name: String,
|
||||
}
|
||||
|
||||
impl ResolvedRef {
|
||||
pub fn as_object(&self) -> Option<&LexObject> {
|
||||
match self.doc.defs.get(&self.def_name)? {
|
||||
LexDef::Object(obj) => Some(obj),
|
||||
LexDef::Record(rec) => Some(&rec.record),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_token(&self) -> bool {
|
||||
self.doc
|
||||
.defs
|
||||
.get(&self.def_name)
|
||||
.is_some_and(|def| matches!(def, LexDef::Token {} | LexDef::StringDef(_)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_registry() {
|
||||
let registry = LexiconRegistry::new();
|
||||
assert_eq!(registry.schema_count(), 0);
|
||||
assert!(!registry.has_schema("app.bsky.feed.post"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_and_lookup() {
|
||||
let mut registry = LexiconRegistry::new();
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.test".to_string(),
|
||||
defs: HashMap::new(),
|
||||
};
|
||||
registry.register(doc);
|
||||
assert_eq!(registry.schema_count(), 1);
|
||||
assert!(registry.has_schema("com.example.test"));
|
||||
assert!(!registry.has_schema("com.example.other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_record_def() {
|
||||
let registry = crate::test_schemas::test_registry();
|
||||
let doc = registry.get_record_def("com.test.basic");
|
||||
assert!(doc.is_some());
|
||||
let doc = doc.unwrap();
|
||||
match doc.defs.get("main").unwrap() {
|
||||
LexDef::Record(rec) => {
|
||||
assert!(rec.record.required.contains(&"text".to_string()));
|
||||
assert!(rec.record.required.contains(&"createdAt".to_string()));
|
||||
}
|
||||
_ => panic!("expected record def"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_record_def_unknown() {
|
||||
let registry = LexiconRegistry::new();
|
||||
assert!(registry.get_record_def("com.example.nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_ref_cross_schema() {
|
||||
let registry = crate::test_schemas::test_registry();
|
||||
let resolved = registry.resolve_ref("com.test.strongref", "com.test.withref");
|
||||
assert!(resolved.is_some_and(|r| r.as_object().is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_local_ref() {
|
||||
let registry = crate::test_schemas::test_registry();
|
||||
let resolved = registry.resolve_ref("#replyRef", "com.test.withreply");
|
||||
assert!(resolved.is_some_and(|r| r.as_object().is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_schema() {
|
||||
let registry = crate::test_schemas::test_registry();
|
||||
assert!(registry.has_schema("com.test.basic"));
|
||||
assert!(!registry.has_schema("com.example.nonexistent"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
use crate::schema::LexiconDoc;
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
||||
use reqwest::Client;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
static RESOLVER_CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
|
||||
const MAX_RESPONSE_BYTES: usize = 512 * 1024;
|
||||
|
||||
fn client() -> &'static Client {
|
||||
RESOLVER_CLIENT.get_or_init(|| {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.pool_max_idle_per_host(4)
|
||||
.pool_idle_timeout(Duration::from_secs(60))
|
||||
.redirect(reqwest::redirect::Policy::limited(3))
|
||||
.build()
|
||||
.expect("failed to build lexicon resolver HTTP client")
|
||||
})
|
||||
}
|
||||
|
||||
const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory";
|
||||
|
||||
async fn read_body_limited(resp: reqwest::Response, max_bytes: usize) -> Result<Vec<u8>, String> {
|
||||
if let Some(len) = resp.content_length()
|
||||
&& len > max_bytes as u64
|
||||
{
|
||||
return Err(format!(
|
||||
"response too large: {} bytes (max {})",
|
||||
len, max_bytes
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("failed to read response body: {}", e))?;
|
||||
|
||||
if bytes.len() > max_bytes {
|
||||
return Err(format!(
|
||||
"response too large: {} bytes (max {})",
|
||||
bytes.len(),
|
||||
max_bytes
|
||||
));
|
||||
}
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ResolveError {
|
||||
#[error("failed to derive authority from NSID: {0}")]
|
||||
InvalidNsid(String),
|
||||
#[error("DNS lookup failed for {domain}: {reason}")]
|
||||
DnsLookup { domain: String, reason: String },
|
||||
#[error("no DID found in DNS TXT records for {domain}")]
|
||||
NoDid { domain: String },
|
||||
#[error("DID document fetch failed for {did}: {reason}")]
|
||||
DidResolution { did: String, reason: String },
|
||||
#[error("no PDS endpoint found in DID document for {did}")]
|
||||
NoPdsEndpoint { did: String },
|
||||
#[error("schema fetch failed from {url}: {reason}")]
|
||||
SchemaFetch { url: String, reason: String },
|
||||
#[error("schema deserialization failed: {0}")]
|
||||
InvalidSchema(String),
|
||||
#[error("schema resolution recently failed for {nsid}, cached for {ttl_secs}s")]
|
||||
NegativelyCached { nsid: String, ttl_secs: u64 },
|
||||
#[error("network resolution disabled")]
|
||||
NetworkDisabled,
|
||||
}
|
||||
|
||||
pub fn nsid_to_authority(nsid: &str) -> Result<String, ResolveError> {
|
||||
let mut segments: Vec<&str> = nsid.split('.').collect();
|
||||
if segments.len() < 3 {
|
||||
return Err(ResolveError::InvalidNsid(nsid.to_string()));
|
||||
}
|
||||
segments.pop();
|
||||
segments.reverse();
|
||||
Ok(segments.join("."))
|
||||
}
|
||||
|
||||
pub async fn resolve_did_from_dns(authority: &str) -> Result<String, ResolveError> {
|
||||
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
|
||||
|
||||
let extract_did = |lookup: hickory_resolver::lookup::TxtLookup| -> Option<String> {
|
||||
lookup
|
||||
.iter()
|
||||
.flat_map(|record| record.txt_data())
|
||||
.find_map(|txt| {
|
||||
let txt_str = String::from_utf8_lossy(txt);
|
||||
txt_str.strip_prefix("did=").and_then(|did| {
|
||||
let did = did.trim();
|
||||
did.starts_with("did:").then(|| did.to_string())
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
let lexicon_query = format!("_lexicon.{}", authority);
|
||||
if let Ok(lookup) = resolver.txt_lookup(&lexicon_query).await
|
||||
&& let Some(did) = extract_did(lookup)
|
||||
{
|
||||
return Ok(did);
|
||||
}
|
||||
|
||||
let atproto_query = format!("_atproto.{}", authority);
|
||||
let lookup =
|
||||
resolver
|
||||
.txt_lookup(&atproto_query)
|
||||
.await
|
||||
.map_err(|e| ResolveError::DnsLookup {
|
||||
domain: authority.to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
extract_did(lookup).ok_or(ResolveError::NoDid {
|
||||
domain: authority.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn resolve_pds_endpoint(
|
||||
did: &str,
|
||||
plc_directory_url: Option<&str>,
|
||||
) -> Result<String, ResolveError> {
|
||||
let plc_base = plc_directory_url.unwrap_or(DEFAULT_PLC_DIRECTORY);
|
||||
|
||||
let url = match did
|
||||
.split_once(':')
|
||||
.and_then(|(_, rest)| rest.split_once(':'))
|
||||
{
|
||||
Some(("plc", _)) => format!("{}/{}", plc_base.trim_end_matches('/'), did),
|
||||
Some(("web", domain)) => format!("https://{}/.well-known/did.json", domain),
|
||||
_ => {
|
||||
return Err(ResolveError::DidResolution {
|
||||
did: did.to_string(),
|
||||
reason: "unsupported DID method".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let resp = client()
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ResolveError::DidResolution {
|
||||
did: did.to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
|
||||
.await
|
||||
.map_err(|reason| ResolveError::DidResolution {
|
||||
did: did.to_string(),
|
||||
reason,
|
||||
})?;
|
||||
|
||||
let doc: serde_json::Value =
|
||||
serde_json::from_slice(&body).map_err(|e| ResolveError::DidResolution {
|
||||
did: did.to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
extract_pds_endpoint(&doc).ok_or(ResolveError::NoPdsEndpoint {
|
||||
did: did.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_pds_endpoint(doc: &serde_json::Value) -> Option<String> {
|
||||
doc.get("service")
|
||||
.and_then(|s| s.as_array())
|
||||
.and_then(|services| {
|
||||
services.iter().find_map(|svc| {
|
||||
let is_pds = svc
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.is_some_and(|t| t == "AtprotoPersonalDataServer");
|
||||
is_pds
|
||||
.then(|| svc.get("serviceEndpoint").and_then(|ep| ep.as_str()))?
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn fetch_schema_from_pds(
|
||||
pds_endpoint: &str,
|
||||
did: &str,
|
||||
nsid: &str,
|
||||
) -> Result<LexiconDoc, ResolveError> {
|
||||
let url = format!(
|
||||
"{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=com.atproto.lexicon.schema&rkey={}",
|
||||
pds_endpoint.trim_end_matches('/'),
|
||||
urlencoding::encode(did),
|
||||
urlencoding::encode(nsid)
|
||||
);
|
||||
|
||||
let resp = client()
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ResolveError::SchemaFetch {
|
||||
url: url.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(ResolveError::SchemaFetch {
|
||||
url,
|
||||
reason: format!("HTTP {}", status),
|
||||
});
|
||||
}
|
||||
|
||||
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
|
||||
.await
|
||||
.map_err(|reason| ResolveError::SchemaFetch {
|
||||
url: url.clone(),
|
||||
reason,
|
||||
})?;
|
||||
|
||||
let resp_value: serde_json::Value =
|
||||
serde_json::from_slice(&body).map_err(|e| ResolveError::SchemaFetch {
|
||||
url: url.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
let value = resp_value
|
||||
.get("value")
|
||||
.ok_or_else(|| ResolveError::SchemaFetch {
|
||||
url: url.clone(),
|
||||
reason: "response missing 'value' field".to_string(),
|
||||
})?;
|
||||
|
||||
serde_json::from_value::<LexiconDoc>(value.clone())
|
||||
.map_err(|e| ResolveError::InvalidSchema(e.to_string()))
|
||||
}
|
||||
|
||||
fn validate_fetched_schema(doc: &LexiconDoc, nsid: &str) -> Result<(), ResolveError> {
|
||||
if doc.id != nsid {
|
||||
return Err(ResolveError::InvalidSchema(format!(
|
||||
"schema id '{}' does not match requested NSID '{}'",
|
||||
doc.id, nsid
|
||||
)));
|
||||
}
|
||||
if doc.lexicon != 1 {
|
||||
return Err(ResolveError::InvalidSchema(format!(
|
||||
"unsupported lexicon version: {}",
|
||||
doc.lexicon
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resolve_lexicon(nsid: &str) -> Result<LexiconDoc, ResolveError> {
|
||||
resolve_lexicon_with_config(nsid, None).await
|
||||
}
|
||||
|
||||
pub async fn resolve_lexicon_with_config(
|
||||
nsid: &str,
|
||||
plc_directory_url: Option<&str>,
|
||||
) -> Result<LexiconDoc, ResolveError> {
|
||||
let authority = nsid_to_authority(nsid)?;
|
||||
tracing::debug!(nsid = nsid, authority = %authority, "resolving lexicon schema");
|
||||
|
||||
let did = resolve_did_from_dns(&authority).await?;
|
||||
tracing::debug!(nsid = nsid, did = %did, "resolved authority DID");
|
||||
|
||||
let pds_endpoint = resolve_pds_endpoint(&did, plc_directory_url).await?;
|
||||
tracing::debug!(nsid = nsid, pds = %pds_endpoint, "resolved PDS endpoint");
|
||||
|
||||
let doc = fetch_schema_from_pds(&pds_endpoint, &did, nsid).await?;
|
||||
validate_fetched_schema(&doc, nsid)?;
|
||||
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
pub async fn resolve_lexicon_from_did(
|
||||
nsid: &str,
|
||||
did: &str,
|
||||
plc_directory_url: Option<&str>,
|
||||
) -> Result<LexiconDoc, ResolveError> {
|
||||
let pds_endpoint = resolve_pds_endpoint(did, plc_directory_url).await?;
|
||||
let doc = fetch_schema_from_pds(&pds_endpoint, did, nsid).await?;
|
||||
validate_fetched_schema(&doc, nsid)?;
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_nsid_to_authority() {
|
||||
assert_eq!(
|
||||
nsid_to_authority("app.bsky.feed.post").unwrap(),
|
||||
"feed.bsky.app"
|
||||
);
|
||||
assert_eq!(
|
||||
nsid_to_authority("com.atproto.repo.strongRef").unwrap(),
|
||||
"repo.atproto.com"
|
||||
);
|
||||
assert_eq!(
|
||||
nsid_to_authority("com.germnetwork.social.post").unwrap(),
|
||||
"social.germnetwork.com"
|
||||
);
|
||||
assert!(nsid_to_authority("tooShort").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nsid_to_authority_three_segments() {
|
||||
assert_eq!(
|
||||
nsid_to_authority("org.example.record").unwrap(),
|
||||
"example.org"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pds_endpoint_valid() {
|
||||
let doc = serde_json::json!({
|
||||
"service": [{
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": "https://pds.example.com"
|
||||
}]
|
||||
});
|
||||
assert_eq!(
|
||||
extract_pds_endpoint(&doc),
|
||||
Some("https://pds.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pds_endpoint_multiple_services() {
|
||||
let doc = serde_json::json!({
|
||||
"service": [
|
||||
{
|
||||
"type": "AtprotoLabeler",
|
||||
"serviceEndpoint": "https://labeler.example.com"
|
||||
},
|
||||
{
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": "https://pds.example.com"
|
||||
}
|
||||
]
|
||||
});
|
||||
assert_eq!(
|
||||
extract_pds_endpoint(&doc),
|
||||
Some("https://pds.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pds_endpoint_missing() {
|
||||
let doc = serde_json::json!({
|
||||
"service": [{
|
||||
"type": "AtprotoLabeler",
|
||||
"serviceEndpoint": "https://labeler.example.com"
|
||||
}]
|
||||
});
|
||||
assert_eq!(extract_pds_endpoint(&doc), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pds_endpoint_no_services() {
|
||||
let doc = serde_json::json!({});
|
||||
assert_eq!(extract_pds_endpoint(&doc), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_fetched_schema_ok() {
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.thing".to_string(),
|
||||
defs: Default::default(),
|
||||
};
|
||||
assert!(validate_fetched_schema(&doc, "com.example.thing").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_fetched_schema_id_mismatch() {
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 1,
|
||||
id: "com.example.other".to_string(),
|
||||
defs: Default::default(),
|
||||
};
|
||||
let err = validate_fetched_schema(&doc, "com.example.thing").unwrap_err();
|
||||
assert!(matches!(err, ResolveError::InvalidSchema(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_fetched_schema_bad_version() {
|
||||
let doc = LexiconDoc {
|
||||
lexicon: 99,
|
||||
id: "com.example.thing".to_string(),
|
||||
defs: Default::default(),
|
||||
};
|
||||
let err = validate_fetched_schema(&doc, "com.example.thing").unwrap_err();
|
||||
assert!(matches!(err, ResolveError::InvalidSchema(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LexiconDoc {
|
||||
pub lexicon: u32,
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub defs: HashMap<String, LexDef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum LexDef {
|
||||
#[serde(rename = "record")]
|
||||
Record(LexRecord),
|
||||
#[serde(rename = "object")]
|
||||
Object(LexObject),
|
||||
#[serde(rename = "token")]
|
||||
Token {},
|
||||
#[serde(rename = "string")]
|
||||
StringDef(LexStringDef),
|
||||
#[serde(rename = "query")]
|
||||
Query {},
|
||||
#[serde(rename = "procedure")]
|
||||
Procedure {},
|
||||
#[serde(rename = "subscription")]
|
||||
Subscription {},
|
||||
#[serde(rename = "params")]
|
||||
Params {},
|
||||
#[serde(rename = "permission")]
|
||||
Permission {},
|
||||
#[serde(rename = "permission-set")]
|
||||
PermissionSet {},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LexRecord {
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
pub record: LexObject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LexObject {
|
||||
#[serde(default)]
|
||||
pub required: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub nullable: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, LexProperty>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum LexProperty {
|
||||
#[serde(rename = "string")]
|
||||
String(LexString),
|
||||
#[serde(rename = "integer")]
|
||||
Integer(LexInteger),
|
||||
#[serde(rename = "boolean")]
|
||||
Boolean {},
|
||||
#[serde(rename = "bytes")]
|
||||
Bytes(LexBytes),
|
||||
#[serde(rename = "cid-link")]
|
||||
CidLink {},
|
||||
#[serde(rename = "blob")]
|
||||
Blob(LexBlob),
|
||||
#[serde(rename = "unknown")]
|
||||
Unknown {},
|
||||
#[serde(rename = "ref")]
|
||||
Ref(LexRef),
|
||||
#[serde(rename = "union")]
|
||||
Union(LexUnion),
|
||||
#[serde(rename = "array")]
|
||||
Array(LexArray),
|
||||
#[serde(rename = "object")]
|
||||
Object(LexObject),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexString {
|
||||
#[serde(default)]
|
||||
pub max_length: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub min_length: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_graphemes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub min_graphemes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub format: Option<StringFormat>,
|
||||
#[serde(default)]
|
||||
pub known_values: Option<Vec<String>>,
|
||||
#[serde(rename = "enum", default)]
|
||||
pub enum_values: Option<Vec<String>>,
|
||||
#[serde(rename = "const", default)]
|
||||
pub const_value: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LexInteger {
|
||||
#[serde(default)]
|
||||
pub minimum: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub maximum: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub default: Option<i64>,
|
||||
#[serde(rename = "enum", default)]
|
||||
pub enum_values: Option<Vec<i64>>,
|
||||
#[serde(rename = "const", default)]
|
||||
pub const_value: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexBytes {
|
||||
#[serde(default)]
|
||||
pub max_length: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub min_length: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexBlob {
|
||||
#[serde(default)]
|
||||
pub accept: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub max_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexArray {
|
||||
pub items: Box<LexProperty>,
|
||||
#[serde(default)]
|
||||
pub min_length: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_length: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LexUnion {
|
||||
#[serde(default)]
|
||||
pub refs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexRef {
|
||||
#[serde(rename = "ref")]
|
||||
pub reference: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub enum StringFormat {
|
||||
#[serde(rename = "did")]
|
||||
Did,
|
||||
#[serde(rename = "handle")]
|
||||
Handle,
|
||||
#[serde(rename = "at-uri")]
|
||||
AtUri,
|
||||
#[serde(rename = "datetime")]
|
||||
Datetime,
|
||||
#[serde(rename = "uri")]
|
||||
Uri,
|
||||
#[serde(rename = "cid")]
|
||||
Cid,
|
||||
#[serde(rename = "language")]
|
||||
Language,
|
||||
#[serde(rename = "tid")]
|
||||
Tid,
|
||||
#[serde(rename = "record-key")]
|
||||
RecordKey,
|
||||
#[serde(rename = "at-identifier")]
|
||||
AtIdentifier,
|
||||
#[serde(rename = "nsid")]
|
||||
Nsid,
|
||||
}
|
||||
|
||||
pub enum ParsedRef<'a> {
|
||||
Local(&'a str),
|
||||
Qualified { nsid: &'a str, fragment: &'a str },
|
||||
Bare(&'a str),
|
||||
}
|
||||
|
||||
pub fn parse_ref(reference: &str) -> ParsedRef<'_> {
|
||||
match reference.strip_prefix('#') {
|
||||
Some(local) => ParsedRef::Local(local),
|
||||
None => {
|
||||
let stripped = reference.strip_prefix("lex:").unwrap_or(reference);
|
||||
match stripped.split_once('#') {
|
||||
Some((nsid, fragment)) => ParsedRef::Qualified { nsid, fragment },
|
||||
None => ParsedRef::Bare(stripped),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexStringDef {}
|
||||
@@ -0,0 +1,291 @@
|
||||
use crate::registry::LexiconRegistry;
|
||||
use crate::schema::LexiconDoc;
|
||||
|
||||
pub(crate) fn test_registry() -> LexiconRegistry {
|
||||
let mut registry = LexiconRegistry::new();
|
||||
all().into_iter().for_each(|doc| registry.register(doc));
|
||||
registry
|
||||
}
|
||||
|
||||
fn parse(json: serde_json::Value) -> LexiconDoc {
|
||||
serde_json::from_value(json).expect("invalid test schema JSON")
|
||||
}
|
||||
|
||||
fn all() -> Vec<LexiconDoc> {
|
||||
[
|
||||
basic_schema(),
|
||||
profile_schema(),
|
||||
with_ref_schema(),
|
||||
strong_ref_schema(),
|
||||
with_reply_schema(),
|
||||
images_schema(),
|
||||
external_schema(),
|
||||
with_gate_schema(),
|
||||
with_did_schema(),
|
||||
nullable_schema(),
|
||||
required_nullable_schema(),
|
||||
]
|
||||
.into()
|
||||
}
|
||||
|
||||
fn basic_schema() -> LexiconDoc {
|
||||
parse(serde_json::json!({
|
||||
"lexicon": 1,
|
||||
"id": "com.test.basic",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["text", "createdAt"],
|
||||
"properties": {
|
||||
"text": {"type": "string", "maxLength": 100, "maxGraphemes": 50},
|
||||
"createdAt": {"type": "string", "format": "datetime"},
|
||||
"count": {"type": "integer", "minimum": 0, "maximum": 100},
|
||||
"active": {"type": "boolean"},
|
||||
"tags": {
|
||||
"type": "array", "maxLength": 3,
|
||||
"items": {"type": "string", "maxLength": 50}
|
||||
},
|
||||
"langs": {
|
||||
"type": "array", "maxLength": 2,
|
||||
"items": {"type": "string", "format": "language"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn profile_schema() -> LexiconDoc {
|
||||
parse(serde_json::json!({
|
||||
"lexicon": 1,
|
||||
"id": "com.test.profile",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"displayName": {"type": "string", "maxGraphemes": 10, "maxLength": 100},
|
||||
"description": {"type": "string", "maxGraphemes": 50, "maxLength": 500},
|
||||
"avatar": {"type": "blob", "accept": ["image/png", "image/jpeg"], "maxSize": 1000000}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn with_ref_schema() -> LexiconDoc {
|
||||
parse(serde_json::json!({
|
||||
"lexicon": 1,
|
||||
"id": "com.test.withref",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["subject", "createdAt"],
|
||||
"properties": {
|
||||
"subject": {"type": "ref", "ref": "com.test.strongref"},
|
||||
"createdAt": {"type": "string", "format": "datetime"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn strong_ref_schema() -> LexiconDoc {
|
||||
parse(serde_json::json!({
|
||||
"lexicon": 1,
|
||||
"id": "com.test.strongref",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "object",
|
||||
"required": ["uri", "cid"],
|
||||
"properties": {
|
||||
"uri": {"type": "string", "format": "at-uri"},
|
||||
"cid": {"type": "string", "format": "cid"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn with_reply_schema() -> LexiconDoc {
|
||||
parse(serde_json::json!({
|
||||
"lexicon": 1,
|
||||
"id": "com.test.withreply",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["text", "createdAt"],
|
||||
"properties": {
|
||||
"text": {"type": "string"},
|
||||
"createdAt": {"type": "string", "format": "datetime"},
|
||||
"reply": {"type": "ref", "ref": "#replyRef"},
|
||||
"embed": {
|
||||
"type": "union",
|
||||
"refs": ["com.test.images", "com.test.external"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"replyRef": {
|
||||
"type": "object",
|
||||
"required": ["root", "parent"],
|
||||
"properties": {
|
||||
"root": {"type": "ref", "ref": "com.test.strongref"},
|
||||
"parent": {"type": "ref", "ref": "com.test.strongref"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn images_schema() -> LexiconDoc {
|
||||
parse(serde_json::json!({
|
||||
"lexicon": 1,
|
||||
"id": "com.test.images",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "object",
|
||||
"required": ["images"],
|
||||
"properties": {
|
||||
"images": {
|
||||
"type": "array", "maxLength": 4,
|
||||
"items": {"type": "ref", "ref": "#image"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"type": "object",
|
||||
"required": ["image", "alt"],
|
||||
"properties": {
|
||||
"image": {"type": "blob", "accept": ["image/*"], "maxSize": 1000000},
|
||||
"alt": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn external_schema() -> LexiconDoc {
|
||||
parse(serde_json::json!({
|
||||
"lexicon": 1,
|
||||
"id": "com.test.external",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "object",
|
||||
"required": ["external"],
|
||||
"properties": {
|
||||
"external": {"type": "ref", "ref": "#external"}
|
||||
}
|
||||
},
|
||||
"external": {
|
||||
"type": "object",
|
||||
"required": ["uri", "title", "description"],
|
||||
"properties": {
|
||||
"uri": {"type": "string", "format": "uri"},
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn with_gate_schema() -> LexiconDoc {
|
||||
parse(serde_json::json!({
|
||||
"lexicon": 1,
|
||||
"id": "com.test.withgate",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["post", "createdAt"],
|
||||
"properties": {
|
||||
"post": {"type": "string", "format": "at-uri"},
|
||||
"createdAt": {"type": "string", "format": "datetime"},
|
||||
"rules": {
|
||||
"type": "array", "maxLength": 5,
|
||||
"items": {"type": "union", "refs": ["#disableRule"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"disableRule": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn with_did_schema() -> LexiconDoc {
|
||||
parse(serde_json::json!({
|
||||
"lexicon": 1,
|
||||
"id": "com.test.withdid",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["subject", "createdAt"],
|
||||
"properties": {
|
||||
"subject": {"type": "string", "format": "did"},
|
||||
"createdAt": {"type": "string", "format": "datetime"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn nullable_schema() -> LexiconDoc {
|
||||
parse(serde_json::json!({
|
||||
"lexicon": 1,
|
||||
"id": "com.test.nullable",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"nullable": ["value"],
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"value": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn required_nullable_schema() -> LexiconDoc {
|
||||
parse(serde_json::json!({
|
||||
"lexicon": 1,
|
||||
"id": "com.test.requirednullable",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["name", "value"],
|
||||
"nullable": ["value"],
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"value": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
use crate::formats::validate_format;
|
||||
use crate::registry::LexiconRegistry;
|
||||
use crate::schema::{
|
||||
LexArray, LexBlob, LexBytes, LexDef, LexObject, LexProperty, LexString, LexUnion, ParsedRef,
|
||||
parse_ref,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
const MAX_RECURSION_DEPTH: u32 = 32;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LexValidationError {
|
||||
#[error("Lexicon not found: {0}")]
|
||||
LexiconNotFound(String),
|
||||
#[error("Missing required field: {path}")]
|
||||
MissingRequired { path: String },
|
||||
#[error("Invalid field at {path}: {message}")]
|
||||
InvalidField { path: String, message: String },
|
||||
#[error("Recursion depth exceeded at {path}")]
|
||||
RecursionDepthExceeded { path: String },
|
||||
}
|
||||
|
||||
impl LexValidationError {
|
||||
fn field(path: &str, message: impl Into<String>) -> Self {
|
||||
Self::InvalidField {
|
||||
path: path.to_string(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_union_ref(reference: &str, context_nsid: &str) -> String {
|
||||
match parse_ref(reference) {
|
||||
ParsedRef::Local(local) => format!("{}#{}", context_nsid, local),
|
||||
ParsedRef::Qualified { nsid, fragment } => format!("{}#{}", nsid, fragment),
|
||||
ParsedRef::Bare(nsid) => nsid.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ref_to_context_nsid<'a>(reference: &'a str, current_context: &'a str) -> &'a str {
|
||||
match parse_ref(reference) {
|
||||
ParsedRef::Local(_) => current_context,
|
||||
ParsedRef::Qualified { nsid, .. } | ParsedRef::Bare(nsid) => nsid,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_record(
|
||||
registry: &LexiconRegistry,
|
||||
nsid: &str,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<(), LexValidationError> {
|
||||
let doc = registry
|
||||
.get_record_def(nsid)
|
||||
.ok_or_else(|| LexValidationError::LexiconNotFound(nsid.to_string()))?;
|
||||
|
||||
let LexDef::Record(rec) = doc
|
||||
.defs
|
||||
.get("main")
|
||||
.expect("get_record_def guarantees main exists")
|
||||
else {
|
||||
unreachable!("get_record_def guarantees main is Record")
|
||||
};
|
||||
|
||||
validate_object(registry, nsid, &rec.record, value, "", 0)
|
||||
}
|
||||
|
||||
fn validate_object(
|
||||
registry: &LexiconRegistry,
|
||||
context_nsid: &str,
|
||||
schema: &LexObject,
|
||||
value: &serde_json::Value,
|
||||
path: &str,
|
||||
depth: u32,
|
||||
) -> Result<(), LexValidationError> {
|
||||
if depth > MAX_RECURSION_DEPTH {
|
||||
return Err(LexValidationError::RecursionDepthExceeded {
|
||||
path: path.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let obj = value
|
||||
.as_object()
|
||||
.ok_or_else(|| LexValidationError::field(path, "expected an object"))?;
|
||||
|
||||
schema.required.iter().try_for_each(|field| {
|
||||
let is_present = obj
|
||||
.get(field.as_str())
|
||||
.is_some_and(|v| !v.is_null() || schema.nullable.contains(field));
|
||||
if is_present {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(LexValidationError::MissingRequired {
|
||||
path: field_path(path, field),
|
||||
})
|
||||
}
|
||||
})?;
|
||||
|
||||
schema
|
||||
.properties
|
||||
.iter()
|
||||
.filter(|(key, _)| obj.contains_key(key.as_str()))
|
||||
.try_for_each(|(key, prop)| {
|
||||
let field_val = &obj[key.as_str()];
|
||||
let fp = field_path(path, key);
|
||||
|
||||
if schema.nullable.contains(key) && field_val.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
validate_property(registry, context_nsid, prop, field_val, &fp, depth + 1)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_property(
|
||||
registry: &LexiconRegistry,
|
||||
context_nsid: &str,
|
||||
prop: &LexProperty,
|
||||
value: &serde_json::Value,
|
||||
path: &str,
|
||||
depth: u32,
|
||||
) -> Result<(), LexValidationError> {
|
||||
if depth > MAX_RECURSION_DEPTH {
|
||||
return Err(LexValidationError::RecursionDepthExceeded {
|
||||
path: path.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
match prop {
|
||||
LexProperty::String(lex_str) => validate_string(lex_str, value, path),
|
||||
LexProperty::Integer(lex_int) => {
|
||||
let n = value
|
||||
.as_i64()
|
||||
.or_else(|| {
|
||||
value.as_f64().and_then(|f| {
|
||||
(f.fract() == 0.0 && (i64::MIN as f64..=i64::MAX as f64).contains(&f))
|
||||
.then_some(f as i64)
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| LexValidationError::field(path, "expected an integer"))?;
|
||||
if let Some(min) = lex_int.minimum
|
||||
&& n < min
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("value {} below minimum {}", n, min),
|
||||
));
|
||||
}
|
||||
if let Some(max) = lex_int.maximum
|
||||
&& n > max
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("value {} above maximum {}", n, max),
|
||||
));
|
||||
}
|
||||
if let Some(ref enum_vals) = lex_int.enum_values
|
||||
&& !enum_vals.contains(&n)
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("value {} not in enum", n),
|
||||
));
|
||||
}
|
||||
if let Some(const_val) = lex_int.const_value
|
||||
&& n != const_val
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("expected const value {}", const_val),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
LexProperty::Boolean {} => value
|
||||
.is_boolean()
|
||||
.then_some(())
|
||||
.ok_or_else(|| LexValidationError::field(path, "expected a boolean")),
|
||||
LexProperty::CidLink {} => validate_cid_link(value, path),
|
||||
LexProperty::Blob(lex_blob) => validate_blob_ref(lex_blob, value, path),
|
||||
LexProperty::Unknown {} => Ok(()),
|
||||
LexProperty::Bytes(lex_bytes) => validate_bytes(lex_bytes, value, path),
|
||||
LexProperty::Ref(lex_ref) => validate_ref(
|
||||
registry,
|
||||
context_nsid,
|
||||
&lex_ref.reference,
|
||||
value,
|
||||
path,
|
||||
depth,
|
||||
),
|
||||
LexProperty::Union(union_def) => {
|
||||
validate_union(registry, context_nsid, union_def, value, path, depth)
|
||||
}
|
||||
LexProperty::Array(array_def) => {
|
||||
validate_array(registry, context_nsid, array_def, value, path, depth)
|
||||
}
|
||||
LexProperty::Object(obj_def) => {
|
||||
validate_object(registry, context_nsid, obj_def, value, path, depth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_string(
|
||||
lex_str: &LexString,
|
||||
value: &serde_json::Value,
|
||||
path: &str,
|
||||
) -> Result<(), LexValidationError> {
|
||||
let s = value
|
||||
.as_str()
|
||||
.ok_or_else(|| LexValidationError::field(path, "expected a string"))?;
|
||||
|
||||
if let Some(max_len) = lex_str.max_length
|
||||
&& s.len() as u64 > max_len
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("string length {} exceeds max_length {}", s.len(), max_len),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(min_len) = lex_str.min_length
|
||||
&& (s.len() as u64) < min_len
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("string length {} below min_length {}", s.len(), min_len),
|
||||
));
|
||||
}
|
||||
|
||||
if lex_str.max_graphemes.is_some() || lex_str.min_graphemes.is_some() {
|
||||
let count = s.graphemes(true).count() as u64;
|
||||
if let Some(max_graphemes) = lex_str.max_graphemes
|
||||
&& count > max_graphemes
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!(
|
||||
"grapheme count {} exceeds max_graphemes {}",
|
||||
count, max_graphemes
|
||||
),
|
||||
));
|
||||
}
|
||||
if let Some(min_graphemes) = lex_str.min_graphemes
|
||||
&& count < min_graphemes
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!(
|
||||
"grapheme count {} below min_graphemes {}",
|
||||
count, min_graphemes
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref format) = lex_str.format
|
||||
&& !validate_format(format, s)
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("invalid format: {:?}", format),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref enum_vals) = lex_str.enum_values
|
||||
&& !enum_vals.iter().any(|v| v == s)
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("value '{}' not in enum", s),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref const_val) = lex_str.const_value
|
||||
&& s != const_val.as_str()
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("expected const value '{}'", const_val),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_cid_link(value: &serde_json::Value, path: &str) -> Result<(), LexValidationError> {
|
||||
let obj = value
|
||||
.as_object()
|
||||
.ok_or_else(|| LexValidationError::field(path, "expected cid-link object"))?;
|
||||
|
||||
if !obj.contains_key("$link") {
|
||||
return Err(LexValidationError::field(path, "cid-link missing $link"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_blob_ref(
|
||||
lex_blob: &LexBlob,
|
||||
value: &serde_json::Value,
|
||||
path: &str,
|
||||
) -> Result<(), LexValidationError> {
|
||||
let obj = value
|
||||
.as_object()
|
||||
.ok_or_else(|| LexValidationError::field(path, "expected blob object"))?;
|
||||
|
||||
let has_type = obj
|
||||
.get("$type")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|t| t == "blob");
|
||||
|
||||
let has_ref = obj.contains_key("ref") && obj.contains_key("mimeType");
|
||||
|
||||
let has_cid = obj.contains_key("cid");
|
||||
|
||||
if !has_type && !has_ref && !has_cid {
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
"invalid blob reference structure",
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref accept) = lex_blob.accept {
|
||||
let mime_type = obj.get("mimeType").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let matched = accept
|
||||
.iter()
|
||||
.any(|pattern| match pattern.strip_suffix("/*") {
|
||||
Some(prefix) => {
|
||||
mime_type.starts_with(prefix)
|
||||
&& mime_type.as_bytes().get(prefix.len()) == Some(&b'/')
|
||||
}
|
||||
None => mime_type == pattern,
|
||||
});
|
||||
if !mime_type.is_empty() && !matched {
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("blob mimeType '{}' not in accepted types", mime_type),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(max_size), Some(size)) =
|
||||
(lex_blob.max_size, obj.get("size").and_then(|v| v.as_u64()))
|
||||
&& size > max_size
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("blob size {} exceeds max_size {}", size, max_size),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_bytes(
|
||||
lex_bytes: &LexBytes,
|
||||
value: &serde_json::Value,
|
||||
path: &str,
|
||||
) -> Result<(), LexValidationError> {
|
||||
let obj = value
|
||||
.as_object()
|
||||
.ok_or_else(|| LexValidationError::field(path, "expected bytes object with $bytes key"))?;
|
||||
|
||||
let encoded = obj
|
||||
.get("$bytes")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| LexValidationError::field(path, "bytes object missing $bytes key"))?;
|
||||
|
||||
let byte_len = encoded.len() as u64 * 3 / 4;
|
||||
|
||||
if let Some(max_len) = lex_bytes.max_length
|
||||
&& byte_len > max_len
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("bytes length ~{} exceeds max_length {}", byte_len, max_len),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(min_len) = lex_bytes.min_length
|
||||
&& byte_len < min_len
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("bytes length ~{} below min_length {}", byte_len, min_len),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_ref(
|
||||
registry: &LexiconRegistry,
|
||||
context_nsid: &str,
|
||||
reference: &str,
|
||||
value: &serde_json::Value,
|
||||
path: &str,
|
||||
depth: u32,
|
||||
) -> Result<(), LexValidationError> {
|
||||
let target_context = ref_to_context_nsid(reference, context_nsid);
|
||||
match registry.resolve_ref(reference, context_nsid) {
|
||||
Some(resolved) => {
|
||||
if resolved.is_token() {
|
||||
Ok(())
|
||||
} else if let Some(obj) = resolved.as_object() {
|
||||
validate_object(registry, target_context, obj, value, path, depth + 1)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_union(
|
||||
registry: &LexiconRegistry,
|
||||
context_nsid: &str,
|
||||
union_def: &LexUnion,
|
||||
value: &serde_json::Value,
|
||||
path: &str,
|
||||
depth: u32,
|
||||
) -> Result<(), LexValidationError> {
|
||||
let obj = value
|
||||
.as_object()
|
||||
.ok_or_else(|| LexValidationError::field(path, "union value must be an object"))?;
|
||||
|
||||
let type_str = obj
|
||||
.get("$type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| LexValidationError::field(path, "union object missing $type"))?;
|
||||
|
||||
let matched_ref = union_def.refs.iter().find(|r| {
|
||||
let resolved = resolve_union_ref(r, context_nsid);
|
||||
resolved == type_str
|
||||
});
|
||||
|
||||
match matched_ref {
|
||||
Some(reference) => validate_ref(registry, context_nsid, reference, value, path, depth),
|
||||
None => {
|
||||
if union_def.closed {
|
||||
Err(LexValidationError::field(
|
||||
path,
|
||||
format!("union type '{}' not in allowed refs", type_str),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_array(
|
||||
registry: &LexiconRegistry,
|
||||
context_nsid: &str,
|
||||
array_def: &LexArray,
|
||||
value: &serde_json::Value,
|
||||
path: &str,
|
||||
depth: u32,
|
||||
) -> Result<(), LexValidationError> {
|
||||
let arr = value
|
||||
.as_array()
|
||||
.ok_or_else(|| LexValidationError::field(path, "expected an array"))?;
|
||||
|
||||
if let Some(max_len) = array_def.max_length
|
||||
&& arr.len() as u64 > max_len
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("array length {} exceeds max_length {}", arr.len(), max_len),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(min_len) = array_def.min_length
|
||||
&& (arr.len() as u64) < min_len
|
||||
{
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
format!("array length {} below min_length {}", arr.len(), min_len),
|
||||
));
|
||||
}
|
||||
|
||||
arr.iter().enumerate().try_for_each(|(i, item)| {
|
||||
let item_path = format!("{}/{}", path, i);
|
||||
validate_property(
|
||||
registry,
|
||||
context_nsid,
|
||||
&array_def.items,
|
||||
item,
|
||||
&item_path,
|
||||
depth + 1,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn field_path(parent: &str, field: &str) -> String {
|
||||
if parent.is_empty() {
|
||||
field.to_string()
|
||||
} else {
|
||||
format!("{}/{}", parent, field)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_schemas::test_registry;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_validate_valid_record() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.basic",
|
||||
"text": "Hello, world!",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
});
|
||||
assert!(validate_record(®istry, "com.test.basic", &record).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_missing_required() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.basic",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
});
|
||||
let err = validate_record(®istry, "com.test.basic", &record).unwrap_err();
|
||||
assert!(matches!(err, LexValidationError::MissingRequired { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_string_too_long_bytes() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.basic",
|
||||
"text": "a".repeat(101),
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
});
|
||||
let err = validate_record(®istry, "com.test.basic", &record).unwrap_err();
|
||||
assert!(matches!(err, LexValidationError::InvalidField { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_string_too_many_graphemes() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.basic",
|
||||
"text": "a".repeat(51),
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
});
|
||||
let err = validate_record(®istry, "com.test.basic", &record).unwrap_err();
|
||||
assert!(matches!(err, LexValidationError::InvalidField { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_grapheme_counting_emoji() {
|
||||
let registry = test_registry();
|
||||
let emoji_text = "👨👩👧👦".repeat(11);
|
||||
let record = json!({
|
||||
"$type": "com.test.profile",
|
||||
"displayName": emoji_text
|
||||
});
|
||||
let err = validate_record(®istry, "com.test.profile", &record).unwrap_err();
|
||||
assert!(matches!(err, LexValidationError::InvalidField { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_integer_bounds() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.basic",
|
||||
"text": "ok",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"count": 101
|
||||
});
|
||||
let err = validate_record(®istry, "com.test.basic", &record).unwrap_err();
|
||||
assert!(matches!(err, LexValidationError::InvalidField { .. }));
|
||||
|
||||
let record_neg = json!({
|
||||
"$type": "com.test.basic",
|
||||
"text": "ok",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"count": -1
|
||||
});
|
||||
let err = validate_record(®istry, "com.test.basic", &record_neg).unwrap_err();
|
||||
assert!(matches!(err, LexValidationError::InvalidField { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_integer_float_coercion() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.basic",
|
||||
"text": "ok",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"count": 5.0
|
||||
});
|
||||
assert!(validate_record(®istry, "com.test.basic", &record).is_ok());
|
||||
|
||||
let record_frac = json!({
|
||||
"$type": "com.test.basic",
|
||||
"text": "ok",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"count": 5.5
|
||||
});
|
||||
let err = validate_record(®istry, "com.test.basic", &record_frac).unwrap_err();
|
||||
assert!(matches!(err, LexValidationError::InvalidField { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_boolean() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.basic",
|
||||
"text": "ok",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"active": "not-a-bool"
|
||||
});
|
||||
let err = validate_record(®istry, "com.test.basic", &record).unwrap_err();
|
||||
assert!(matches!(err, LexValidationError::InvalidField { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_array_max_length() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.basic",
|
||||
"text": "ok",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"tags": ["a", "b", "c", "d"]
|
||||
});
|
||||
let err = validate_record(®istry, "com.test.basic", &record).unwrap_err();
|
||||
assert!(matches!(err, LexValidationError::InvalidField { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_array_within_limit() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.basic",
|
||||
"text": "ok",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"tags": ["a", "b", "c"]
|
||||
});
|
||||
assert!(validate_record(®istry, "com.test.basic", &record).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_cross_schema_ref() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.withref",
|
||||
"subject": {
|
||||
"uri": "at://did:plc:abc/com.test.basic/123",
|
||||
"cid": "bafyreiabcdef"
|
||||
},
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
});
|
||||
assert!(validate_record(®istry, "com.test.withref", &record).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_cross_schema_ref_missing_field() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.withref",
|
||||
"subject": {
|
||||
"cid": "bafyreiabcdef"
|
||||
},
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
});
|
||||
let err = validate_record(®istry, "com.test.withref", &record).unwrap_err();
|
||||
assert!(matches!(err, LexValidationError::MissingRequired { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_local_ref_resolution() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.withreply",
|
||||
"text": "reply",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"reply": {
|
||||
"root": {
|
||||
"uri": "at://did:plc:abc/com.test.basic/123",
|
||||
"cid": "bafyreiabcdef"
|
||||
},
|
||||
"parent": {
|
||||
"uri": "at://did:plc:abc/com.test.basic/456",
|
||||
"cid": "bafyreiabcdef"
|
||||
}
|
||||
}
|
||||
});
|
||||
assert!(validate_record(®istry, "com.test.withreply", &record).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_union_bare_nsid_ref() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.withreply",
|
||||
"text": "with images",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"embed": {
|
||||
"$type": "com.test.images",
|
||||
"images": [
|
||||
{
|
||||
"image": {
|
||||
"$type": "blob",
|
||||
"ref": { "$link": "bafyreiabcdef" },
|
||||
"mimeType": "image/jpeg",
|
||||
"size": 12345
|
||||
},
|
||||
"alt": "test"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
assert!(validate_record(®istry, "com.test.withreply", &record).is_ok());
|
||||
|
||||
let bad_embed = json!({
|
||||
"$type": "com.test.withreply",
|
||||
"text": "bad",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"embed": {
|
||||
"$type": "com.test.images",
|
||||
"images": "not-an-array"
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
validate_record(®istry, "com.test.withreply", &bad_embed).is_err(),
|
||||
"union with bare NSID ref must validate the matched schema"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_cross_schema_local_ref_in_union() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.withreply",
|
||||
"text": "external",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"embed": {
|
||||
"$type": "com.test.external",
|
||||
"external": {
|
||||
"uri": "https://example.com",
|
||||
"title": "Example",
|
||||
"description": "A test"
|
||||
}
|
||||
}
|
||||
});
|
||||
assert!(validate_record(®istry, "com.test.withreply", &record).is_ok());
|
||||
|
||||
let bad_external = json!({
|
||||
"$type": "com.test.withreply",
|
||||
"text": "bad",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"embed": {
|
||||
"$type": "com.test.external",
|
||||
"external": {
|
||||
"title": "missing uri and description"
|
||||
}
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
validate_record(®istry, "com.test.withreply", &bad_external).is_err(),
|
||||
"local #ref in cross-schema union must resolve against the correct schema"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_gate_with_union_fragment_ref() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.withgate",
|
||||
"post": "at://did:plc:abc/com.test.basic/123",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"rules": [
|
||||
{ "$type": "com.test.withgate#disableRule" }
|
||||
]
|
||||
});
|
||||
assert!(validate_record(®istry, "com.test.withgate", &record).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_did_format() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.withdid",
|
||||
"subject": "did:plc:abc123",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
});
|
||||
assert!(validate_record(®istry, "com.test.withdid", &record).is_ok());
|
||||
|
||||
let bad_did = json!({
|
||||
"$type": "com.test.withdid",
|
||||
"subject": "not-a-did",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
});
|
||||
assert!(validate_record(®istry, "com.test.withdid", &bad_did).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_nullable_field() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.nullable",
|
||||
"name": "test",
|
||||
"value": null
|
||||
});
|
||||
assert!(validate_record(®istry, "com.test.nullable", &record).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_unknown_lexicon() {
|
||||
let registry = test_registry();
|
||||
let record = json!({"$type": "com.example.nonexistent"});
|
||||
let err = validate_record(®istry, "com.example.nonexistent", &record).unwrap_err();
|
||||
assert!(matches!(err, LexValidationError::LexiconNotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_extra_properties_allowed() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.basic",
|
||||
"text": "ok",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"unknownField": "this is fine"
|
||||
});
|
||||
assert!(validate_record(®istry, "com.test.basic", &record).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_no_required_fields() {
|
||||
let registry = test_registry();
|
||||
let record = json!({"$type": "com.test.profile"});
|
||||
assert!(validate_record(®istry, "com.test.profile", &record).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_profile_display_name_graphemes() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.profile",
|
||||
"displayName": "a".repeat(11)
|
||||
});
|
||||
let err = validate_record(®istry, "com.test.profile", &record).unwrap_err();
|
||||
assert!(matches!(err, LexValidationError::InvalidField { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_required_nullable_field_accepts_null() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.requirednullable",
|
||||
"name": "test",
|
||||
"value": null
|
||||
});
|
||||
assert!(
|
||||
validate_record(®istry, "com.test.requirednullable", &record).is_ok(),
|
||||
"a field that is both required and nullable must accept null values"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_required_nullable_field_rejects_absent() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.requirednullable",
|
||||
"name": "test"
|
||||
});
|
||||
assert!(
|
||||
matches!(
|
||||
validate_record(®istry, "com.test.requirednullable", &record).unwrap_err(),
|
||||
LexValidationError::MissingRequired { .. }
|
||||
),
|
||||
"a field that is required+nullable must still be present (even if null)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_required_nullable_field_accepts_value() {
|
||||
let registry = test_registry();
|
||||
let record = json!({
|
||||
"$type": "com.test.requirednullable",
|
||||
"name": "test",
|
||||
"value": "hello"
|
||||
});
|
||||
assert!(
|
||||
validate_record(®istry, "com.test.requirednullable", &record).is_ok(),
|
||||
"a field that is required+nullable must accept non-null values"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
#![cfg(feature = "resolve")]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
use tranquil_lexicon::{
|
||||
ResolveError, fetch_schema_from_pds, resolve_lexicon_from_did, resolve_pds_endpoint,
|
||||
};
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn mock_did_document(did: &str, pds_endpoint: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"@context": ["https://www.w3.org/ns/did/v1"],
|
||||
"id": did,
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": pds_endpoint
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn mock_lexicon_schema(nsid: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"lexicon": 1,
|
||||
"id": nsid,
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"key": "tid",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["text", "createdAt"],
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 1000,
|
||||
"maxGraphemes": 100
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "datetime"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn mock_get_record_response(nsid: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"uri": format!("at://did:plc:test123/com.atproto.lexicon.schema/{}", nsid),
|
||||
"cid": "bafyreiabcdef",
|
||||
"value": mock_lexicon_schema(nsid)
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_pds_endpoint_from_plc() {
|
||||
let plc_server = MockServer::start().await;
|
||||
let did = "did:plc:testabcdef123";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{}", did)))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(mock_did_document(did, "https://pds.example.com")),
|
||||
)
|
||||
.mount(&plc_server)
|
||||
.await;
|
||||
|
||||
let endpoint = resolve_pds_endpoint(did, Some(&plc_server.uri()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(endpoint, "https://pds.example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_pds_endpoint_no_pds_service() {
|
||||
let plc_server = MockServer::start().await;
|
||||
let did = "did:plc:nopds123";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{}", did)))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": did,
|
||||
"service": [{
|
||||
"type": "AtprotoLabeler",
|
||||
"serviceEndpoint": "https://labeler.example.com"
|
||||
}]
|
||||
})))
|
||||
.mount(&plc_server)
|
||||
.await;
|
||||
|
||||
let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await;
|
||||
assert!(matches!(result, Err(ResolveError::NoPdsEndpoint { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_pds_endpoint_plc_not_found() {
|
||||
let plc_server = MockServer::start().await;
|
||||
let did = "did:plc:missing123";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{}", did)))
|
||||
.respond_with(ResponseTemplate::new(404).set_body_string("not found"))
|
||||
.mount(&plc_server)
|
||||
.await;
|
||||
|
||||
let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_pds_endpoint_unsupported_did_method() {
|
||||
let result = resolve_pds_endpoint("did:key:z6MkTest", None).await;
|
||||
assert!(matches!(result, Err(ResolveError::DidResolution { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_pds_endpoint_multiple_services_picks_pds() {
|
||||
let plc_server = MockServer::start().await;
|
||||
let did = "did:plc:multi123";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{}", did)))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": did,
|
||||
"service": [
|
||||
{
|
||||
"type": "AtprotoLabeler",
|
||||
"serviceEndpoint": "https://labeler.example.com"
|
||||
},
|
||||
{
|
||||
"type": "BskyNotificationService",
|
||||
"serviceEndpoint": "https://notify.example.com"
|
||||
},
|
||||
{
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": "https://pds.example.com"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&plc_server)
|
||||
.await;
|
||||
|
||||
let endpoint = resolve_pds_endpoint(did, Some(&plc_server.uri()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(endpoint, "https://pds.example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_schema_from_pds_success() {
|
||||
let pds_server = MockServer::start().await;
|
||||
let did = "did:plc:schemahost123";
|
||||
let nsid = "com.example.custom.post";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/xrpc/com.atproto.repo.getRecord"))
|
||||
.and(query_param("repo", did))
|
||||
.and(query_param("collection", "com.atproto.lexicon.schema"))
|
||||
.and(query_param("rkey", nsid))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_get_record_response(nsid)))
|
||||
.mount(&pds_server)
|
||||
.await;
|
||||
|
||||
let doc = fetch_schema_from_pds(&pds_server.uri(), did, nsid)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(doc.id, nsid);
|
||||
assert_eq!(doc.lexicon, 1);
|
||||
assert!(doc.defs.contains_key("main"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_schema_missing_value_field() {
|
||||
let pds_server = MockServer::start().await;
|
||||
let did = "did:plc:test123";
|
||||
let nsid = "com.example.missing";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/xrpc/com.atproto.repo.getRecord"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"uri": "at://did:plc:test123/com.atproto.lexicon.schema/com.example.missing",
|
||||
"cid": "bafyreiabcdef"
|
||||
})))
|
||||
.mount(&pds_server)
|
||||
.await;
|
||||
|
||||
let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await;
|
||||
assert!(matches!(result, Err(ResolveError::SchemaFetch { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_schema_invalid_lexicon_json() {
|
||||
let pds_server = MockServer::start().await;
|
||||
let did = "did:plc:test123";
|
||||
let nsid = "com.example.bad";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/xrpc/com.atproto.repo.getRecord"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"uri": "at://test",
|
||||
"cid": "bafyreiabcdef",
|
||||
"value": {
|
||||
"not_a_lexicon": true
|
||||
}
|
||||
})))
|
||||
.mount(&pds_server)
|
||||
.await;
|
||||
|
||||
let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await;
|
||||
assert!(matches!(result, Err(ResolveError::InvalidSchema(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_chain_plc_to_schema() {
|
||||
let plc_server = MockServer::start().await;
|
||||
let pds_server = MockServer::start().await;
|
||||
let did = "did:plc:fullchain123";
|
||||
let nsid = "com.example.social.post";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{}", did)))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(mock_did_document(did, &pds_server.uri())),
|
||||
)
|
||||
.mount(&plc_server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/xrpc/com.atproto.repo.getRecord"))
|
||||
.and(query_param("repo", did))
|
||||
.and(query_param("collection", "com.atproto.lexicon.schema"))
|
||||
.and(query_param("rkey", nsid))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_get_record_response(nsid)))
|
||||
.mount(&pds_server)
|
||||
.await;
|
||||
|
||||
let doc = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(doc.id, nsid);
|
||||
assert_eq!(doc.lexicon, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_chain_schema_id_mismatch_rejected() {
|
||||
let plc_server = MockServer::start().await;
|
||||
let pds_server = MockServer::start().await;
|
||||
let did = "did:plc:mismatch123";
|
||||
let nsid = "com.example.requested.type";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{}", did)))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(mock_did_document(did, &pds_server.uri())),
|
||||
)
|
||||
.mount(&plc_server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/xrpc/com.atproto.repo.getRecord"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(mock_get_record_response("com.example.different.type")),
|
||||
)
|
||||
.mount(&pds_server)
|
||||
.await;
|
||||
|
||||
let result = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri())).await;
|
||||
assert!(matches!(result, Err(ResolveError::InvalidSchema(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_chain_bad_lexicon_version_rejected() {
|
||||
let plc_server = MockServer::start().await;
|
||||
let pds_server = MockServer::start().await;
|
||||
let did = "did:plc:badver123";
|
||||
let nsid = "com.example.versioned.type";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{}", did)))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(mock_did_document(did, &pds_server.uri())),
|
||||
)
|
||||
.mount(&plc_server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/xrpc/com.atproto.repo.getRecord"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"uri": "at://test",
|
||||
"cid": "bafyreiabcdef",
|
||||
"value": {
|
||||
"lexicon": 2,
|
||||
"id": nsid,
|
||||
"defs": {}
|
||||
}
|
||||
})))
|
||||
.mount(&pds_server)
|
||||
.await;
|
||||
|
||||
let result = resolve_lexicon_from_did(nsid, did, Some(&plc_server.uri())).await;
|
||||
assert!(matches!(result, Err(ResolveError::InvalidSchema(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pds_trailing_slash_handled() {
|
||||
let pds_server = MockServer::start().await;
|
||||
let did = "did:plc:slash123";
|
||||
let nsid = "com.example.slash.test";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/xrpc/com.atproto.repo.getRecord"))
|
||||
.and(query_param("repo", did))
|
||||
.and(query_param("rkey", nsid))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_get_record_response(nsid)))
|
||||
.mount(&pds_server)
|
||||
.await;
|
||||
|
||||
let pds_url_with_slash = format!("{}/", pds_server.uri());
|
||||
let doc = fetch_schema_from_pds(&pds_url_with_slash, did, nsid)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(doc.id, nsid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_schema_error_status_gives_meaningful_error() {
|
||||
let pds_server = MockServer::start().await;
|
||||
let did = "did:plc:test123";
|
||||
let nsid = "com.example.notfound";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/xrpc/com.atproto.repo.getRecord"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
|
||||
"error": "RecordNotFound",
|
||||
"message": "record not found"
|
||||
})))
|
||||
.mount(&pds_server)
|
||||
.await;
|
||||
|
||||
let result = fetch_schema_from_pds(&pds_server.uri(), did, nsid).await;
|
||||
let err = result.unwrap_err();
|
||||
let err_msg = err.to_string();
|
||||
assert!(
|
||||
!err_msg.contains("missing 'value' field"),
|
||||
"a 400 response should report the HTTP status, not a parse error. got: {}",
|
||||
err_msg
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plc_server_timeout() {
|
||||
let plc_server = MockServer::start().await;
|
||||
let did = "did:plc:timeout123";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{}", did)))
|
||||
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(30)))
|
||||
.mount(&plc_server)
|
||||
.await;
|
||||
|
||||
let result = resolve_pds_endpoint(did, Some(&plc_server.uri())).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
@@ -17,6 +17,7 @@ tranquil-oauth = { workspace = true }
|
||||
tranquil-comms = { workspace = true }
|
||||
tranquil-db = { workspace = true }
|
||||
tranquil-db-traits = { workspace = true }
|
||||
tranquil-lexicon = { workspace = true, features = ["resolve"] }
|
||||
|
||||
aes-gcm = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
@@ -140,15 +140,14 @@ pub async fn create_account(
|
||||
}
|
||||
}
|
||||
|
||||
let available_domains = tranquil_config::get().server.available_user_domain_list();
|
||||
let cfg = tranquil_config::get();
|
||||
let available_domains = cfg.server.available_user_domain_list();
|
||||
let matched_domain = available_domains
|
||||
.iter()
|
||||
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
|
||||
.max_by_key(|d| d.len());
|
||||
|
||||
let validated_short_handle = if !input.handle.contains('.')
|
||||
|| matched_domain.is_some()
|
||||
{
|
||||
let validated_short_handle = if !input.handle.contains('.') || matched_domain.is_some() {
|
||||
let handle_to_validate = match matched_domain {
|
||||
Some(domain) => input
|
||||
.handle
|
||||
@@ -163,23 +162,10 @@ pub async fn create_account(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if input.handle.contains(' ') || input.handle.contains('\t') {
|
||||
return ApiError::InvalidRequest("Handle cannot contain spaces".into()).into_response();
|
||||
match crate::api::validation::validate_full_domain_handle(&input.handle) {
|
||||
Ok(h) => h,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
}
|
||||
if let Some(c) = input
|
||||
.handle
|
||||
.chars()
|
||||
.find(|c| !c.is_ascii_alphanumeric() && *c != '.' && *c != '-')
|
||||
{
|
||||
return ApiError::InvalidRequest(format!("Handle contains invalid character: {}", c))
|
||||
.into_response();
|
||||
}
|
||||
let handle_lower = input.handle.to_lowercase();
|
||||
if crate::moderation::has_explicit_slur(&handle_lower) {
|
||||
return ApiError::InvalidRequest("Inappropriate language in handle".into())
|
||||
.into_response();
|
||||
}
|
||||
handle_lower
|
||||
};
|
||||
let email: Option<String> = input
|
||||
.email
|
||||
@@ -234,7 +220,7 @@ pub async fn create_account(
|
||||
},
|
||||
})
|
||||
};
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let hostname = &cfg.server.hostname;
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
let handle = match matched_domain {
|
||||
Some(domain) => format!("{}.{}", validated_short_handle, domain),
|
||||
@@ -274,10 +260,8 @@ pub async fn create_account(
|
||||
if !crate::api::server::meta::is_self_hosted_did_web_enabled() {
|
||||
return ApiError::SelfHostedDidWebDisabled.into_response();
|
||||
}
|
||||
let pds_hostname = tranquil_config::get().server.hostname_without_port();
|
||||
let subdomain_host = format!("{}.{}", input.handle, pds_hostname);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
let self_hosted_did = format!("did:web:{}", encoded_handle);
|
||||
info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)");
|
||||
self_hosted_did
|
||||
}
|
||||
|
||||
@@ -122,17 +122,21 @@ pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, KeyError> {
|
||||
}
|
||||
|
||||
pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let hostname_without_port = tranquil_config::get().server.hostname_without_port();
|
||||
let cfg = tranquil_config::get();
|
||||
let hostname = &cfg.server.hostname;
|
||||
let hostname_without_port = cfg.server.hostname_without_port();
|
||||
let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname);
|
||||
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
|
||||
if host_without_port != hostname_without_port
|
||||
&& host_without_port.ends_with(&format!(".{}", hostname_without_port))
|
||||
{
|
||||
let handle = host_without_port
|
||||
.strip_suffix(&format!(".{}", hostname_without_port))
|
||||
.unwrap_or(host_without_port);
|
||||
return serve_subdomain_did_doc(&state, handle, hostname).await;
|
||||
if host_without_port != hostname_without_port {
|
||||
let is_subdomain = cfg
|
||||
.server
|
||||
.available_user_domain_list()
|
||||
.into_iter()
|
||||
.chain(std::iter::once(hostname_without_port.to_string()))
|
||||
.any(|d| host_without_port.ends_with(&format!(".{}", d)));
|
||||
if is_subdomain {
|
||||
return serve_handle_did_doc(&state, host_without_port, hostname).await;
|
||||
}
|
||||
}
|
||||
let did = if hostname.contains(':') {
|
||||
format!("did:web:{}", hostname.replace(':', "%3A"))
|
||||
@@ -151,11 +155,9 @@ pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &str) -> Response {
|
||||
let hostname_for_handles = hostname.split(':').next().unwrap_or(hostname);
|
||||
let subdomain_host = format!("{}.{}", subdomain, hostname_for_handles);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
let expected_did = format!("did:web:{}", encoded_subdomain);
|
||||
async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response {
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
let expected_did = format!("did:web:{}", encoded_handle);
|
||||
let expected_did_typed: crate::types::Did = match expected_did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InvalidRequest("Invalid DID format".into()).into_response(),
|
||||
@@ -682,7 +684,9 @@ pub async fn update_handle(
|
||||
.max_by_key(|d| d.len())
|
||||
.cloned();
|
||||
let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle);
|
||||
let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some()) && !is_domain_itself {
|
||||
let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some())
|
||||
&& !is_domain_itself
|
||||
{
|
||||
let (short_part, full_handle) = match &matched_handle_domain {
|
||||
Some(domain) => {
|
||||
let suffix = format!(".{}", domain);
|
||||
|
||||
@@ -215,15 +215,12 @@ mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_ssrf_safe_https() {
|
||||
assert!(is_ssrf_safe("https://api.bsky.app/xrpc/test").is_ok());
|
||||
assert!(is_ssrf_safe("https://1.1.1.1/xrpc/test").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn test_ssrf_blocks_http_by_default() {
|
||||
let result = is_ssrf_safe("http://external.example.com/xrpc/test");
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(SsrfError::InsecureProtocol(_)) | Err(SsrfError::DnsResolutionFailed(_))
|
||||
));
|
||||
let result = is_ssrf_safe("http://93.184.216.34/xrpc/test");
|
||||
assert!(matches!(result, Err(SsrfError::InsecureProtocol(_))));
|
||||
}
|
||||
#[test]
|
||||
fn test_ssrf_allows_localhost_http() {
|
||||
@@ -231,6 +228,21 @@ mod tests {
|
||||
assert!(is_ssrf_safe("http://localhost:8080/test").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn test_ssrf_blocks_non_unicast_ip() {
|
||||
assert!(matches!(
|
||||
is_ssrf_safe("https://0.0.0.0/test"),
|
||||
Err(SsrfError::NonUnicastIp(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
is_ssrf_safe("https://224.0.0.1/test"),
|
||||
Err(SsrfError::NonUnicastIp(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
is_ssrf_safe("https://255.255.255.255/test"),
|
||||
Err(SsrfError::NonUnicastIp(_))
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn test_validate_at_uri() {
|
||||
let result = validate_at_uri("at://did:plc:test/app.bsky.feed.post/abc123");
|
||||
assert!(result.is_ok());
|
||||
|
||||
@@ -65,7 +65,9 @@ async fn process_single_write(
|
||||
collection,
|
||||
rkey.as_ref(),
|
||||
validate.requires_lexicon(),
|
||||
) {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return Err(*err_response),
|
||||
}
|
||||
@@ -116,7 +118,9 @@ async fn process_single_write(
|
||||
collection,
|
||||
Some(rkey),
|
||||
validate.requires_lexicon(),
|
||||
) {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return Err(*err_response),
|
||||
}
|
||||
|
||||
@@ -3,29 +3,17 @@ use crate::types::{Nsid, Rkey};
|
||||
use crate::validation::{RecordValidator, ValidationError, ValidationStatus};
|
||||
use axum::response::Response;
|
||||
|
||||
pub fn validate_record(record: &serde_json::Value, collection: &Nsid) -> Result<(), Box<Response>> {
|
||||
validate_record_with_rkey(record, collection, None)
|
||||
}
|
||||
|
||||
pub fn validate_record_with_rkey(
|
||||
record: &serde_json::Value,
|
||||
collection: &Nsid,
|
||||
rkey: Option<&Rkey>,
|
||||
) -> Result<(), Box<Response>> {
|
||||
let validator = RecordValidator::new();
|
||||
validation_error_to_response(validator.validate_with_rkey(
|
||||
record,
|
||||
collection.as_str(),
|
||||
rkey.map(|r| r.as_str()),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn validate_record_with_status(
|
||||
pub async fn validate_record_with_status(
|
||||
record: &serde_json::Value,
|
||||
collection: &Nsid,
|
||||
rkey: Option<&Rkey>,
|
||||
require_lexicon: bool,
|
||||
) -> Result<ValidationStatus, Box<Response>> {
|
||||
let registry = tranquil_lexicon::LexiconRegistry::global();
|
||||
if !registry.has_schema(collection.as_str()) {
|
||||
let _ = registry.resolve_dynamic(collection.as_str()).await;
|
||||
}
|
||||
|
||||
let validator = RecordValidator::new().require_lexicon(require_lexicon);
|
||||
match validator.validate_with_rkey(record, collection.as_str(), rkey.map(|r| r.as_str())) {
|
||||
Ok(status) => Ok(status),
|
||||
@@ -33,15 +21,6 @@ pub fn validate_record_with_status(
|
||||
}
|
||||
}
|
||||
|
||||
fn validation_error_to_response(
|
||||
result: Result<ValidationStatus, ValidationError>,
|
||||
) -> Result<(), Box<Response>> {
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(validation_error_to_box_response(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn validation_error_to_box_response(e: ValidationError) -> Box<Response> {
|
||||
use axum::response::IntoResponse;
|
||||
let msg = match e {
|
||||
|
||||
@@ -136,7 +136,9 @@ pub async fn create_record(
|
||||
&input.collection,
|
||||
input.rkey.as_ref(),
|
||||
input.validate.requires_lexicon(),
|
||||
) {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return Ok(*err_response),
|
||||
}
|
||||
@@ -456,7 +458,9 @@ pub async fn put_record(
|
||||
&input.collection,
|
||||
Some(&input.rkey),
|
||||
input.validate.requires_lexicon(),
|
||||
) {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return Ok(*err_response),
|
||||
}
|
||||
|
||||
@@ -112,8 +112,9 @@ pub async fn create_passkey_account(
|
||||
.map(|d| d.starts_with("did:web:"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let available_domains = tranquil_config::get().server.available_user_domain_list();
|
||||
let cfg = tranquil_config::get();
|
||||
let hostname = &cfg.server.hostname;
|
||||
let available_domains = cfg.server.available_user_domain_list();
|
||||
let matched_domain = available_domains
|
||||
.iter()
|
||||
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
|
||||
@@ -134,7 +135,10 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
input.handle.to_lowercase()
|
||||
match crate::api::validation::validate_full_domain_handle(&input.handle) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return ApiError::InvalidHandle(None).into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let email = input
|
||||
@@ -246,10 +250,11 @@ pub async fn create_passkey_account(
|
||||
|
||||
let did = match did_type {
|
||||
"web" => {
|
||||
let pds_hostname = tranquil_config::get().server.hostname_without_port();
|
||||
let subdomain_host = format!("{}.{}", input.handle, pds_hostname);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
|
||||
if !crate::api::server::meta::is_self_hosted_did_web_enabled() {
|
||||
return ApiError::SelfHostedDidWebDisabled.into_response();
|
||||
}
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
let self_hosted_did = format!("did:web:{}", encoded_handle);
|
||||
info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account");
|
||||
self_hosted_did
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use crate::AccountStatus;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::extractor::{Auth, Permissive};
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
use axum::http::Method;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
@@ -10,7 +9,6 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::LazyLock;
|
||||
use tracing::{error, info, warn};
|
||||
@@ -59,109 +57,22 @@ pub struct GetServiceAuthOutput {
|
||||
|
||||
pub async fn get_service_auth(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
auth: Auth<Permissive>,
|
||||
Query(params): Query<GetServiceAuthParams>,
|
||||
) -> Response {
|
||||
let auth_header = crate::util::get_header_str(&headers, axum::http::header::AUTHORIZATION);
|
||||
let dpop_proof = crate::util::get_header_str(&headers, crate::util::HEADER_DPOP);
|
||||
info!(
|
||||
has_auth_header = auth_header.is_some(),
|
||||
has_dpop_proof = dpop_proof.is_some(),
|
||||
did = %&auth.did,
|
||||
is_oauth = auth.is_oauth(),
|
||||
aud = %params.aud,
|
||||
lxm = ?params.lxm,
|
||||
"getServiceAuth called"
|
||||
);
|
||||
let auth_header = match auth_header {
|
||||
Some(h) => h.trim(),
|
||||
None => {
|
||||
warn!("getServiceAuth: no Authorization header");
|
||||
return ApiError::AuthenticationRequired.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let extracted = match crate::auth::extract_auth_token_from_header(Some(auth_header)) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
warn!(auth_scheme = ?auth_header.split_whitespace().next(), "getServiceAuth: invalid auth scheme");
|
||||
return ApiError::AuthenticationRequired.into_response();
|
||||
}
|
||||
};
|
||||
let token = extracted.token;
|
||||
|
||||
let auth_user = if extracted.scheme.is_dpop() {
|
||||
match crate::oauth::verify::verify_oauth_access_token(
|
||||
state.oauth_repo.as_ref(),
|
||||
&token,
|
||||
dpop_proof,
|
||||
Method::GET.as_str(),
|
||||
&crate::util::build_full_url(&format!(
|
||||
"/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}",
|
||||
params.aud,
|
||||
params.lxm.as_ref().map_or("", |n| n.as_str())
|
||||
)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let did: Did = match result.did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return ApiError::InternalError(Some("Invalid DID in token".into()))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
crate::auth::AuthenticatedUser {
|
||||
did,
|
||||
is_admin: false,
|
||||
status: AccountStatus::Active,
|
||||
scope: result.scope,
|
||||
key_bytes: None,
|
||||
controller_did: None,
|
||||
auth_source: crate::auth::AuthSource::OAuth,
|
||||
}
|
||||
}
|
||||
Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[("DPoP-Nonce", nonce)],
|
||||
Json(json!({
|
||||
"error": "use_dpop_nonce",
|
||||
"message": "DPoP nonce required"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(crate::oauth::OAuthError::ExpiredToken(msg)) => {
|
||||
warn!(error = %msg, "getServiceAuth DPoP token expired");
|
||||
return ApiError::OAuthExpiredToken(Some(msg)).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "getServiceAuth DPoP auth validation failed");
|
||||
return ApiError::AuthenticationFailed(Some(format!("{:?}", e))).into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match crate::auth::validate_bearer_token_for_service_auth(state.user_repo.as_ref(), &token)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user,
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "getServiceAuth auth validation failed");
|
||||
return ApiError::from(e).into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
info!(
|
||||
did = %&auth_user.did,
|
||||
is_oauth = auth_user.is_oauth(),
|
||||
has_key = auth_user.key_bytes.is_some(),
|
||||
"getServiceAuth auth validated"
|
||||
);
|
||||
let key_bytes = match &auth_user.key_bytes {
|
||||
let key_bytes = match &auth.key_bytes {
|
||||
Some(kb) => kb.clone(),
|
||||
None => {
|
||||
warn!(did = %&auth_user.did, "getServiceAuth: OAuth token has no key_bytes, fetching from DB");
|
||||
match state.user_repo.get_user_info_by_did(&auth_user.did).await {
|
||||
warn!(did = %&auth.did, "getServiceAuth: no key_bytes in auth, fetching from DB");
|
||||
match state.user_repo.get_user_info_by_did(&auth.did).await {
|
||||
Ok(Some(info)) => match info.key_bytes {
|
||||
Some(key_bytes_enc) => {
|
||||
match crate::config::decrypt_key(&key_bytes_enc, info.encryption_version) {
|
||||
@@ -202,15 +113,15 @@ pub async fn get_service_auth(
|
||||
|
||||
if let Some(method) = lxm {
|
||||
if let Err(e) = crate::auth::scope_check::check_rpc_scope(
|
||||
&auth_user.auth_source,
|
||||
auth_user.scope.as_deref(),
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
params.aud.as_str(),
|
||||
method.as_str(),
|
||||
) {
|
||||
return e;
|
||||
}
|
||||
} else if auth_user.is_oauth() {
|
||||
let permissions = auth_user.permissions();
|
||||
} else if auth.is_oauth() {
|
||||
let permissions = auth.permissions();
|
||||
if !permissions.has_full_access() {
|
||||
return ApiError::InvalidRequest(
|
||||
"OAuth tokens with granular scopes must specify an lxm parameter".into(),
|
||||
@@ -219,15 +130,7 @@ pub async fn get_service_auth(
|
||||
}
|
||||
}
|
||||
|
||||
let is_takendown = state
|
||||
.user_repo
|
||||
.get_status_by_did(&auth_user.did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|s| s.takedown_ref.is_some());
|
||||
|
||||
if is_takendown && lxm != Some(&*CREATE_ACCOUNT_NSID) {
|
||||
if auth.status.is_takendown() && lxm != Some(&*CREATE_ACCOUNT_NSID) {
|
||||
return ApiError::InvalidToken(Some("Bad token scope".into())).into_response();
|
||||
}
|
||||
|
||||
@@ -265,7 +168,7 @@ pub async fn get_service_auth(
|
||||
}
|
||||
|
||||
let service_token = match crate::auth::create_service_token(
|
||||
&auth_user.did,
|
||||
&auth.did,
|
||||
params.aud.as_str(),
|
||||
lxm_for_token,
|
||||
&key_bytes,
|
||||
|
||||
@@ -68,7 +68,7 @@ pub async fn create_session(
|
||||
let pds_host = &tranquil_config::get().server.hostname;
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let normalized_identifier =
|
||||
NormalizedLoginIdentifier::normalize(&input.identifier, &hostname_for_handles);
|
||||
NormalizedLoginIdentifier::normalize(&input.identifier, hostname_for_handles);
|
||||
info!(
|
||||
"Normalized identifier: {} -> {}",
|
||||
input.identifier, normalized_identifier
|
||||
|
||||
@@ -258,6 +258,52 @@ pub enum ReservedHandlePolicy {
|
||||
Reject,
|
||||
}
|
||||
|
||||
pub fn validate_full_domain_handle(handle: &str) -> Result<String, HandleValidationError> {
|
||||
let handle = handle.trim();
|
||||
|
||||
if handle.is_empty() {
|
||||
return Err(HandleValidationError::Empty);
|
||||
}
|
||||
|
||||
if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') {
|
||||
return Err(HandleValidationError::ContainsSpaces);
|
||||
}
|
||||
|
||||
if handle.len() > MAX_HANDLE_LENGTH {
|
||||
return Err(HandleValidationError::TooLong);
|
||||
}
|
||||
|
||||
if handle
|
||||
.chars()
|
||||
.any(|c| !c.is_ascii_alphanumeric() && c != '.' && c != '-')
|
||||
{
|
||||
return Err(HandleValidationError::InvalidCharacters);
|
||||
}
|
||||
|
||||
if !handle.contains('.') {
|
||||
return Err(HandleValidationError::InvalidCharacters);
|
||||
}
|
||||
|
||||
let labels: Vec<&str> = handle.split('.').collect();
|
||||
let has_invalid_label = labels.iter().any(|label| {
|
||||
label.is_empty()
|
||||
|| label.len() > MAX_DOMAIN_LABEL_LENGTH
|
||||
|| label.starts_with('-')
|
||||
|| label.ends_with('-')
|
||||
});
|
||||
if has_invalid_label {
|
||||
return Err(HandleValidationError::InvalidCharacters);
|
||||
}
|
||||
|
||||
let handle_lower = handle.to_lowercase();
|
||||
|
||||
if crate::moderation::has_explicit_slur(&handle_lower) {
|
||||
return Err(HandleValidationError::BannedWord);
|
||||
}
|
||||
|
||||
Ok(handle_lower)
|
||||
}
|
||||
|
||||
pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
|
||||
validate_service_handle(handle, ReservedHandlePolicy::Reject)
|
||||
}
|
||||
|
||||
@@ -355,7 +355,7 @@ async fn validate_bearer_token_with_options_internal(
|
||||
)
|
||||
.await;
|
||||
|
||||
let status_cache_key = crate::cache_keys::user_status_key(&did.to_string());
|
||||
let status_cache_key = crate::cache_keys::user_status_key(did.as_ref());
|
||||
let cached = CachedUserStatus {
|
||||
deactivated: user.deactivated_at.is_some(),
|
||||
takendown: user.takedown_ref.is_some(),
|
||||
@@ -394,7 +394,7 @@ async fn validate_bearer_token_with_options_internal(
|
||||
match verify_access_token_typed(token, &decrypted_key) {
|
||||
Ok(token_data) => {
|
||||
let jti = &token_data.claims.jti;
|
||||
let session_cache_key = crate::cache_keys::session_key(&did, &jti);
|
||||
let session_cache_key = crate::cache_keys::session_key(&did, jti);
|
||||
let mut session_valid = false;
|
||||
|
||||
if let Some(c) = cache {
|
||||
@@ -530,6 +530,7 @@ pub enum AccountRequirement {
|
||||
AnyStatus,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn validate_token_with_dpop(
|
||||
user_repo: &dyn UserRepository,
|
||||
oauth_repo: &dyn OAuthRepository,
|
||||
|
||||
@@ -653,8 +653,6 @@ pub fn app(state: AppState) -> Router {
|
||||
get(oauth::endpoints::oauth_authorization_server),
|
||||
);
|
||||
|
||||
if cfg!(feature = "frontend") {}
|
||||
|
||||
let router = Router::new()
|
||||
.nest_service("/xrpc", xrpc_service)
|
||||
.nest("/oauth", oauth_router)
|
||||
@@ -662,7 +660,7 @@ pub fn app(state: AppState) -> Router {
|
||||
.route("/metrics", get(metrics::metrics_handler))
|
||||
.route("/health", get(api::server::health))
|
||||
.route("/robots.txt", get(api::server::robots_txt))
|
||||
.route("/logo", get(api::server::get_logo))
|
||||
.route("/favicon.ico", get(api::server::get_logo))
|
||||
.route("/u/{handle}/did.json", get(api::identity::user_did_doc))
|
||||
.route(
|
||||
"/webhook/telegram",
|
||||
@@ -716,7 +714,7 @@ pub fn app(state: AppState) -> Router {
|
||||
|
||||
let spa_router = Router::new().fallback_service(ServeFile::new(&index_path));
|
||||
|
||||
let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path));
|
||||
let serve_dir = ServeDir::new(frontend_dir).not_found_service(ServeFile::new(&index_path));
|
||||
|
||||
return router
|
||||
.route(
|
||||
|
||||
@@ -256,7 +256,7 @@ pub async fn authorize_get(
|
||||
if let Some(ref login_hint) = request_data.parameters.login_hint {
|
||||
tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation");
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let normalized = NormalizedLoginIdentifier::normalize(login_hint, &hostname_for_handles);
|
||||
let normalized = NormalizedLoginIdentifier::normalize(login_hint, hostname_for_handles);
|
||||
tracing::info!(normalized = %normalized, "Normalized login_hint");
|
||||
|
||||
match state
|
||||
@@ -530,7 +530,7 @@ pub async fn authorize_post(
|
||||
};
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let normalized_username =
|
||||
NormalizedLoginIdentifier::normalize(&form.username, &hostname_for_handles);
|
||||
NormalizedLoginIdentifier::normalize(&form.username, hostname_for_handles);
|
||||
tracing::debug!(
|
||||
original_username = %form.username,
|
||||
normalized_username = %normalized_username,
|
||||
@@ -2102,7 +2102,7 @@ pub async fn check_user_has_passkeys(
|
||||
) -> Response {
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let bare_identifier =
|
||||
BareLoginIdentifier::from_identifier(&query.identifier, &hostname_for_handles);
|
||||
BareLoginIdentifier::from_identifier(&query.identifier, hostname_for_handles);
|
||||
|
||||
let user = state
|
||||
.user_repo
|
||||
@@ -2134,7 +2134,7 @@ pub async fn check_user_security_status(
|
||||
) -> Response {
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let normalized_identifier =
|
||||
NormalizedLoginIdentifier::normalize(&query.identifier, &hostname_for_handles);
|
||||
NormalizedLoginIdentifier::normalize(&query.identifier, hostname_for_handles);
|
||||
|
||||
let user = state
|
||||
.user_repo
|
||||
@@ -2242,7 +2242,7 @@ pub async fn passkey_start(
|
||||
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let normalized_username =
|
||||
NormalizedLoginIdentifier::normalize(&form.identifier, &hostname_for_handles);
|
||||
NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles);
|
||||
|
||||
let user = match state
|
||||
.user_repo
|
||||
|
||||
@@ -743,6 +743,7 @@ pub async fn get_pending_registration(
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckHandleQuery {
|
||||
pub handle: String,
|
||||
pub domain: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -773,7 +774,13 @@ pub async fn check_handle_available(
|
||||
};
|
||||
|
||||
let available_domains = tranquil_config::get().server.available_user_domain_list();
|
||||
let full_handle = format!("{}.{}", validated, &available_domains[0]);
|
||||
if let Some(ref d) = query.domain
|
||||
&& !available_domains.iter().any(|ad| ad == d)
|
||||
{
|
||||
return Err(ApiError::InvalidRequest("Unknown user domain".into()));
|
||||
}
|
||||
let domain = query.domain.as_deref().unwrap_or(&available_domains[0]);
|
||||
let full_handle = format!("{}.{}", validated, domain);
|
||||
let handle_typed: crate::types::Handle = match full_handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||
@@ -855,12 +862,32 @@ pub async fn complete_registration(
|
||||
.await?
|
||||
.ok_or(ApiError::SsoSessionExpired)?;
|
||||
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let available_domains = tranquil_config::get().server.available_user_domain_list();
|
||||
let cfg = tranquil_config::get();
|
||||
let hostname = &cfg.server.hostname;
|
||||
let available_domains = cfg.server.available_user_domain_list();
|
||||
|
||||
let handle = match crate::api::validation::validate_short_handle(&input.handle) {
|
||||
Ok(h) => format!("{}.{}", h, &available_domains[0]),
|
||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||
let matched_domain = available_domains
|
||||
.iter()
|
||||
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
|
||||
.max_by_key(|d| d.len());
|
||||
|
||||
let handle = if !input.handle.contains('.') || matched_domain.is_some() {
|
||||
let handle_to_validate = match matched_domain {
|
||||
Some(domain) => input
|
||||
.handle
|
||||
.strip_suffix(&format!(".{}", domain))
|
||||
.unwrap_or(&input.handle),
|
||||
None => &input.handle,
|
||||
};
|
||||
match crate::api::validation::validate_short_handle(handle_to_validate) {
|
||||
Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])),
|
||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||
}
|
||||
} else {
|
||||
match crate::api::validation::validate_full_domain_handle(&input.handle) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||
}
|
||||
};
|
||||
|
||||
let verification_channel = input
|
||||
@@ -981,10 +1008,11 @@ pub async fn complete_registration(
|
||||
|
||||
let did = match did_type {
|
||||
"web" => {
|
||||
let pds_hostname = tranquil_config::get().server.hostname_without_port();
|
||||
let subdomain_host = format!("{}.{}", input.handle, pds_hostname);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
|
||||
if !crate::api::server::meta::is_self_hosted_did_web_enabled() {
|
||||
return Err(ApiError::SelfHostedDidWebDisabled);
|
||||
}
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
let self_hosted_did = format!("did:web:{}", encoded_handle);
|
||||
tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account");
|
||||
self_hosted_did
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ impl AppState {
|
||||
.acquire_timeout(std::time::Duration::from_secs(acquire_timeout_secs))
|
||||
.idle_timeout(std::time::Duration::from_secs(300))
|
||||
.max_lifetime(std::time::Duration::from_secs(1800))
|
||||
.connect(&database_url)
|
||||
.connect(database_url)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to Postgres: {}", e))?;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
use tranquil_lexicon::LexValidationError;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ValidationError {
|
||||
@@ -75,375 +76,150 @@ impl RecordValidator {
|
||||
collection: &str,
|
||||
rkey: Option<&str>,
|
||||
) -> Result<ValidationStatus, ValidationError> {
|
||||
let obj = record.as_object().ok_or_else(|| {
|
||||
ValidationError::InvalidRecord("Record must be an object".to_string())
|
||||
})?;
|
||||
let record_type = obj
|
||||
.get("$type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ValidationError::MissingType)?;
|
||||
if record_type != collection {
|
||||
return Err(ValidationError::TypeMismatch {
|
||||
expected: collection.to_string(),
|
||||
actual: record_type.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(created_at) = obj.get("createdAt").and_then(|v| v.as_str()) {
|
||||
validate_datetime(created_at, "createdAt")?;
|
||||
}
|
||||
match record_type {
|
||||
"app.bsky.feed.post" => Self::validate_post(obj)?,
|
||||
"app.bsky.actor.profile" => Self::validate_profile(obj)?,
|
||||
"app.bsky.feed.like" => Self::validate_like(obj)?,
|
||||
"app.bsky.feed.repost" => Self::validate_repost(obj)?,
|
||||
"app.bsky.graph.follow" => Self::validate_follow(obj)?,
|
||||
"app.bsky.graph.block" => Self::validate_block(obj)?,
|
||||
"app.bsky.graph.list" => Self::validate_list(obj)?,
|
||||
"app.bsky.graph.listitem" => Self::validate_list_item(obj)?,
|
||||
"app.bsky.feed.generator" => Self::validate_feed_generator(obj, rkey)?,
|
||||
"app.bsky.feed.threadgate" => Self::validate_threadgate(obj)?,
|
||||
"app.bsky.labeler.service" => Self::validate_labeler_service(obj)?,
|
||||
"app.bsky.graph.starterpack" => Self::validate_starterpack(obj)?,
|
||||
_ => {
|
||||
let (record_type, obj) = validate_preamble(record, collection)?;
|
||||
let registry = tranquil_lexicon::LexiconRegistry::global();
|
||||
|
||||
match tranquil_lexicon::validate_record(registry, record_type, record) {
|
||||
Ok(()) => {
|
||||
check_banned_content(record_type, obj, rkey)?;
|
||||
Ok(ValidationStatus::Valid)
|
||||
}
|
||||
Err(LexValidationError::LexiconNotFound(_)) => {
|
||||
if self.require_lexicon {
|
||||
return Err(ValidationError::UnknownType(record_type.to_string()));
|
||||
}
|
||||
return Ok(ValidationStatus::Unknown);
|
||||
}
|
||||
}
|
||||
Ok(ValidationStatus::Valid)
|
||||
}
|
||||
|
||||
fn validate_post(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
|
||||
if !obj.contains_key("text") {
|
||||
return Err(ValidationError::MissingField("text".to_string()));
|
||||
}
|
||||
if !obj.contains_key("createdAt") {
|
||||
return Err(ValidationError::MissingField("createdAt".to_string()));
|
||||
}
|
||||
if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
|
||||
let grapheme_count = text.chars().count();
|
||||
if grapheme_count > 3000 {
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: "text".to_string(),
|
||||
message: format!(
|
||||
"Text exceeds maximum length of 3000 characters (got {})",
|
||||
grapheme_count
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(langs) = obj.get("langs").and_then(|v| v.as_array())
|
||||
&& langs.len() > 3
|
||||
{
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: "langs".to_string(),
|
||||
message: "Maximum 3 languages allowed".to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) {
|
||||
if tags.len() > 8 {
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: "tags".to_string(),
|
||||
message: "Maximum 8 tags allowed".to_string(),
|
||||
});
|
||||
}
|
||||
for (i, tag) in tags.iter().enumerate() {
|
||||
if let Some(tag_str) = tag.as_str() {
|
||||
if tag_str.len() > 640 {
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: format!("tags/{}", i),
|
||||
message: "Tag exceeds maximum length of 640 bytes".to_string(),
|
||||
});
|
||||
}
|
||||
if crate::moderation::has_explicit_slur(tag_str) {
|
||||
return Err(ValidationError::BannedContent {
|
||||
path: format!("tags/{}", i),
|
||||
});
|
||||
}
|
||||
Err(ValidationError::UnknownType(record_type.to_string()))
|
||||
} else {
|
||||
check_banned_content(record_type, obj, rkey)?;
|
||||
Ok(ValidationStatus::Unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(facets) = obj.get("facets").and_then(|v| v.as_array()) {
|
||||
for (i, facet) in facets.iter().enumerate() {
|
||||
if let Some(features) = facet.get("features").and_then(|v| v.as_array()) {
|
||||
for (j, feature) in features.iter().enumerate() {
|
||||
let is_tag = feature
|
||||
.get("$type")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|t| t == "app.bsky.richtext.facet#tag");
|
||||
if is_tag
|
||||
&& let Some(tag) = feature.get("tag").and_then(|v| v.as_str())
|
||||
&& crate::moderation::has_explicit_slur(tag)
|
||||
{
|
||||
return Err(ValidationError::BannedContent {
|
||||
path: format!("facets/{}/features/{}/tag", i, j),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(LexValidationError::MissingRequired { path }) => {
|
||||
Err(ValidationError::MissingField(path))
|
||||
}
|
||||
Err(LexValidationError::InvalidField { path, message }) => {
|
||||
Err(ValidationError::InvalidField { path, message })
|
||||
}
|
||||
Err(LexValidationError::RecursionDepthExceeded { path }) => {
|
||||
Err(ValidationError::InvalidField {
|
||||
path,
|
||||
message: "recursion depth exceeded".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_profile(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
|
||||
if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) {
|
||||
let grapheme_count = display_name.chars().count();
|
||||
if grapheme_count > 640 {
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: "displayName".to_string(),
|
||||
message: format!(
|
||||
"Display name exceeds maximum length of 640 characters (got {})",
|
||||
grapheme_count
|
||||
),
|
||||
});
|
||||
}
|
||||
if crate::moderation::has_explicit_slur(display_name) {
|
||||
return Err(ValidationError::BannedContent {
|
||||
path: "displayName".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(description) = obj.get("description").and_then(|v| v.as_str()) {
|
||||
let grapheme_count = description.chars().count();
|
||||
if grapheme_count > 2560 {
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: "description".to_string(),
|
||||
message: format!(
|
||||
"Description exceeds maximum length of 2560 characters (got {})",
|
||||
grapheme_count
|
||||
),
|
||||
});
|
||||
}
|
||||
if crate::moderation::has_explicit_slur(description) {
|
||||
return Err(ValidationError::BannedContent {
|
||||
path: "description".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_like(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
|
||||
if !obj.contains_key("subject") {
|
||||
return Err(ValidationError::MissingField("subject".to_string()));
|
||||
}
|
||||
if !obj.contains_key("createdAt") {
|
||||
return Err(ValidationError::MissingField("createdAt".to_string()));
|
||||
}
|
||||
Self::validate_strong_ref(obj.get("subject"), "subject")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_repost(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
|
||||
if !obj.contains_key("subject") {
|
||||
return Err(ValidationError::MissingField("subject".to_string()));
|
||||
}
|
||||
if !obj.contains_key("createdAt") {
|
||||
return Err(ValidationError::MissingField("createdAt".to_string()));
|
||||
}
|
||||
Self::validate_strong_ref(obj.get("subject"), "subject")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_follow(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
|
||||
if !obj.contains_key("subject") {
|
||||
return Err(ValidationError::MissingField("subject".to_string()));
|
||||
}
|
||||
if !obj.contains_key("createdAt") {
|
||||
return Err(ValidationError::MissingField("createdAt".to_string()));
|
||||
}
|
||||
if let Some(subject) = obj.get("subject").and_then(|v| v.as_str())
|
||||
&& !subject.starts_with("did:")
|
||||
{
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: "subject".to_string(),
|
||||
message: "Subject must be a DID".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_block(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
|
||||
if !obj.contains_key("subject") {
|
||||
return Err(ValidationError::MissingField("subject".to_string()));
|
||||
}
|
||||
if !obj.contains_key("createdAt") {
|
||||
return Err(ValidationError::MissingField("createdAt".to_string()));
|
||||
}
|
||||
if let Some(subject) = obj.get("subject").and_then(|v| v.as_str())
|
||||
&& !subject.starts_with("did:")
|
||||
{
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: "subject".to_string(),
|
||||
message: "Subject must be a DID".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_list(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
|
||||
if !obj.contains_key("name") {
|
||||
return Err(ValidationError::MissingField("name".to_string()));
|
||||
}
|
||||
if !obj.contains_key("purpose") {
|
||||
return Err(ValidationError::MissingField("purpose".to_string()));
|
||||
}
|
||||
if !obj.contains_key("createdAt") {
|
||||
return Err(ValidationError::MissingField("createdAt".to_string()));
|
||||
}
|
||||
if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
|
||||
if name.is_empty() || name.len() > 64 {
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: "name".to_string(),
|
||||
message: "Name must be 1-64 characters".to_string(),
|
||||
});
|
||||
}
|
||||
if crate::moderation::has_explicit_slur(name) {
|
||||
return Err(ValidationError::BannedContent {
|
||||
path: "name".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_list_item(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
|
||||
if !obj.contains_key("subject") {
|
||||
return Err(ValidationError::MissingField("subject".to_string()));
|
||||
}
|
||||
if !obj.contains_key("list") {
|
||||
return Err(ValidationError::MissingField("list".to_string()));
|
||||
}
|
||||
if !obj.contains_key("createdAt") {
|
||||
return Err(ValidationError::MissingField("createdAt".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_feed_generator(
|
||||
obj: &serde_json::Map<String, Value>,
|
||||
rkey: Option<&str>,
|
||||
) -> Result<(), ValidationError> {
|
||||
if !obj.contains_key("did") {
|
||||
return Err(ValidationError::MissingField("did".to_string()));
|
||||
}
|
||||
if !obj.contains_key("displayName") {
|
||||
return Err(ValidationError::MissingField("displayName".to_string()));
|
||||
}
|
||||
if !obj.contains_key("createdAt") {
|
||||
return Err(ValidationError::MissingField("createdAt".to_string()));
|
||||
}
|
||||
if let Some(rkey) = rkey
|
||||
&& crate::moderation::has_explicit_slur(rkey)
|
||||
{
|
||||
return Err(ValidationError::BannedContent {
|
||||
path: "rkey".to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) {
|
||||
if display_name.is_empty() || display_name.len() > 240 {
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: "displayName".to_string(),
|
||||
message: "displayName must be 1-240 characters".to_string(),
|
||||
});
|
||||
}
|
||||
if crate::moderation::has_explicit_slur(display_name) {
|
||||
return Err(ValidationError::BannedContent {
|
||||
path: "displayName".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_starterpack(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
|
||||
if !obj.contains_key("name") {
|
||||
return Err(ValidationError::MissingField("name".to_string()));
|
||||
}
|
||||
if !obj.contains_key("createdAt") {
|
||||
return Err(ValidationError::MissingField("createdAt".to_string()));
|
||||
}
|
||||
if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
|
||||
if name.is_empty() || name.len() > 500 {
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: "name".to_string(),
|
||||
message: "name must be 1-500 characters".to_string(),
|
||||
});
|
||||
}
|
||||
if crate::moderation::has_explicit_slur(name) {
|
||||
return Err(ValidationError::BannedContent {
|
||||
path: "name".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(description) = obj.get("description").and_then(|v| v.as_str()) {
|
||||
if description.len() > 3000 {
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: "description".to_string(),
|
||||
message: "description must be at most 3000 characters".to_string(),
|
||||
});
|
||||
}
|
||||
if crate::moderation::has_explicit_slur(description) {
|
||||
return Err(ValidationError::BannedContent {
|
||||
path: "description".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_threadgate(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
|
||||
if !obj.contains_key("post") {
|
||||
return Err(ValidationError::MissingField("post".to_string()));
|
||||
}
|
||||
if !obj.contains_key("createdAt") {
|
||||
return Err(ValidationError::MissingField("createdAt".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_labeler_service(
|
||||
obj: &serde_json::Map<String, Value>,
|
||||
) -> Result<(), ValidationError> {
|
||||
if !obj.contains_key("policies") {
|
||||
return Err(ValidationError::MissingField("policies".to_string()));
|
||||
}
|
||||
if !obj.contains_key("createdAt") {
|
||||
return Err(ValidationError::MissingField("createdAt".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_strong_ref(value: Option<&Value>, path: &str) -> Result<(), ValidationError> {
|
||||
let obj =
|
||||
value
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| ValidationError::InvalidField {
|
||||
path: path.to_string(),
|
||||
message: "Must be a strong reference object".to_string(),
|
||||
})?;
|
||||
if !obj.contains_key("uri") {
|
||||
return Err(ValidationError::MissingField(format!("{}/uri", path)));
|
||||
}
|
||||
if !obj.contains_key("cid") {
|
||||
return Err(ValidationError::MissingField(format!("{}/cid", path)));
|
||||
}
|
||||
if let Some(uri) = obj.get("uri").and_then(|v| v.as_str())
|
||||
&& !uri.starts_with("at://")
|
||||
{
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: format!("{}/uri", path),
|
||||
message: "URI must be an at:// URI".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_preamble<'a>(
|
||||
record: &'a Value,
|
||||
collection: &str,
|
||||
) -> Result<(&'a str, &'a serde_json::Map<String, Value>), ValidationError> {
|
||||
let obj = record
|
||||
.as_object()
|
||||
.ok_or_else(|| ValidationError::InvalidRecord("Record must be an object".to_string()))?;
|
||||
let record_type = obj
|
||||
.get("$type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ValidationError::MissingType)?;
|
||||
if record_type != collection {
|
||||
return Err(ValidationError::TypeMismatch {
|
||||
expected: collection.to_string(),
|
||||
actual: record_type.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(created_at) = obj.get("createdAt").and_then(|v| v.as_str()) {
|
||||
validate_datetime(created_at, "createdAt")?;
|
||||
}
|
||||
Ok((record_type, obj))
|
||||
}
|
||||
|
||||
fn check_banned_content(
|
||||
record_type: &str,
|
||||
obj: &serde_json::Map<String, Value>,
|
||||
rkey: Option<&str>,
|
||||
) -> Result<(), ValidationError> {
|
||||
match record_type {
|
||||
"app.bsky.feed.post" => {
|
||||
check_post_banned_content(obj)?;
|
||||
}
|
||||
"app.bsky.actor.profile" => {
|
||||
check_string_field(obj, "displayName")?;
|
||||
check_string_field(obj, "description")?;
|
||||
}
|
||||
"app.bsky.graph.list" => {
|
||||
check_string_field(obj, "name")?;
|
||||
}
|
||||
"app.bsky.graph.starterpack" => {
|
||||
check_string_field(obj, "name")?;
|
||||
check_string_field(obj, "description")?;
|
||||
}
|
||||
"app.bsky.feed.generator" => {
|
||||
if let Some(rkey) = rkey
|
||||
&& crate::moderation::has_explicit_slur(rkey)
|
||||
{
|
||||
return Err(ValidationError::BannedContent {
|
||||
path: "rkey".to_string(),
|
||||
});
|
||||
}
|
||||
check_string_field(obj, "displayName")?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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)| {
|
||||
if let Some(tag_str) = tag.as_str()
|
||||
&& crate::moderation::has_explicit_slur(tag_str)
|
||||
{
|
||||
return Err(ValidationError::BannedContent {
|
||||
path: format!("tags/{}", i),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
if let Some(facets) = obj.get("facets").and_then(|v| v.as_array()) {
|
||||
facets.iter().enumerate().try_for_each(|(i, facet)| {
|
||||
if let Some(features) = facet.get("features").and_then(|v| v.as_array()) {
|
||||
features.iter().enumerate().try_for_each(|(j, feature)| {
|
||||
let is_tag = feature
|
||||
.get("$type")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|t| t == "app.bsky.richtext.facet#tag");
|
||||
if is_tag
|
||||
&& let Some(tag) = feature.get("tag").and_then(|v| v.as_str())
|
||||
&& crate::moderation::has_explicit_slur(tag)
|
||||
{
|
||||
return Err(ValidationError::BannedContent {
|
||||
path: format!("facets/{}/features/{}/tag", i, j),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_string_field(
|
||||
obj: &serde_json::Map<String, Value>,
|
||||
field: &str,
|
||||
) -> Result<(), ValidationError> {
|
||||
if let Some(value) = obj.get(field).and_then(|v| v.as_str())
|
||||
&& crate::moderation::has_explicit_slur(value)
|
||||
{
|
||||
return Err(ValidationError::BannedContent {
|
||||
path: field.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_datetime(value: &str, path: &str) -> Result<(), ValidationError> {
|
||||
if chrono::DateTime::parse_from_rfc3339(value).is_err() {
|
||||
if !tranquil_lexicon::is_valid_datetime(value) {
|
||||
return Err(ValidationError::InvalidDatetime {
|
||||
path: path.to_string(),
|
||||
});
|
||||
@@ -452,84 +228,22 @@ fn validate_datetime(value: &str, path: &str) -> Result<(), ValidationError> {
|
||||
}
|
||||
|
||||
pub fn validate_record_key(rkey: &str) -> Result<(), ValidationError> {
|
||||
if rkey.is_empty() {
|
||||
return Err(ValidationError::InvalidRecord(
|
||||
"Record key cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if rkey.len() > 512 {
|
||||
return Err(ValidationError::InvalidRecord(
|
||||
"Record key exceeds maximum length of 512".to_string(),
|
||||
));
|
||||
}
|
||||
if rkey == "." || rkey == ".." {
|
||||
return Err(ValidationError::InvalidRecord(
|
||||
"Record key cannot be '.' or '..'".to_string(),
|
||||
));
|
||||
}
|
||||
let valid_chars = rkey
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '~');
|
||||
if !valid_chars {
|
||||
return Err(ValidationError::InvalidRecord(
|
||||
"Record key contains invalid characters (must be alphanumeric, '.', '-', '_', or '~')"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_valid_did(did: &str) -> bool {
|
||||
if !did.starts_with("did:") {
|
||||
return false;
|
||||
}
|
||||
let parts: Vec<&str> = did.splitn(3, ':').collect();
|
||||
if parts.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
let method = parts[1];
|
||||
if method.is_empty() || !method.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
let id = parts[2];
|
||||
!id.is_empty()
|
||||
}
|
||||
|
||||
pub fn validate_did(did: &str) -> Result<(), ValidationError> {
|
||||
if !is_valid_did(did) {
|
||||
return Err(ValidationError::InvalidField {
|
||||
path: "did".to_string(),
|
||||
message: "Invalid DID format".to_string(),
|
||||
});
|
||||
if !tranquil_lexicon::is_valid_record_key(rkey) {
|
||||
return Err(ValidationError::InvalidRecord(format!(
|
||||
"Invalid record key: '{}'",
|
||||
rkey
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_collection_nsid(collection: &str) -> Result<(), ValidationError> {
|
||||
if collection.is_empty() {
|
||||
return Err(ValidationError::InvalidRecord(
|
||||
"Collection NSID cannot be empty".to_string(),
|
||||
));
|
||||
if !tranquil_lexicon::is_valid_nsid(collection) {
|
||||
return Err(ValidationError::InvalidRecord(format!(
|
||||
"Invalid collection NSID: '{}'",
|
||||
collection
|
||||
)));
|
||||
}
|
||||
let parts: Vec<&str> = collection.split('.').collect();
|
||||
if parts.len() < 3 {
|
||||
return Err(ValidationError::InvalidRecord(
|
||||
"Collection NSID must have at least 3 segments".to_string(),
|
||||
));
|
||||
}
|
||||
parts.iter().try_for_each(|part| {
|
||||
if part.is_empty() {
|
||||
return Err(ValidationError::InvalidRecord(
|
||||
"Collection NSID segments cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
|
||||
return Err(ValidationError::InvalidRecord(
|
||||
"Collection NSID segments must be alphanumeric or hyphens".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -630,109 +344,3 @@ fn is_common_password(password: &str) -> bool {
|
||||
let lower = password.to_lowercase();
|
||||
COMMON_PASSWORDS.iter().any(|p| p.to_lowercase() == lower)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_validate_post() {
|
||||
let validator = RecordValidator::new();
|
||||
let valid_post = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Hello, world!",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&valid_post, "app.bsky.feed.post")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_post_missing_text() {
|
||||
let validator = RecordValidator::new();
|
||||
let invalid_post = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
});
|
||||
assert!(
|
||||
validator
|
||||
.validate(&invalid_post, "app.bsky.feed.post")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_type_mismatch() {
|
||||
let validator = RecordValidator::new();
|
||||
let record = json!({
|
||||
"$type": "app.bsky.feed.like",
|
||||
"subject": {"uri": "at://did:plc:test/app.bsky.feed.post/123", "cid": "bafyrei..."},
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
});
|
||||
let result = validator.validate(&record, "app.bsky.feed.post");
|
||||
assert!(matches!(result, Err(ValidationError::TypeMismatch { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_unknown_type() {
|
||||
let validator = RecordValidator::new();
|
||||
let record = json!({
|
||||
"$type": "com.example.custom",
|
||||
"data": "test"
|
||||
});
|
||||
assert_eq!(
|
||||
validator.validate(&record, "com.example.custom").unwrap(),
|
||||
ValidationStatus::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_unknown_type_strict() {
|
||||
let validator = RecordValidator::new().require_lexicon(true);
|
||||
let record = json!({
|
||||
"$type": "com.example.custom",
|
||||
"data": "test"
|
||||
});
|
||||
let result = validator.validate(&record, "com.example.custom");
|
||||
assert!(matches!(result, Err(ValidationError::UnknownType(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_record_key() {
|
||||
assert!(validate_record_key("valid-key_123").is_ok());
|
||||
assert!(validate_record_key("3k2n5j2").is_ok());
|
||||
assert!(validate_record_key(".").is_err());
|
||||
assert!(validate_record_key("..").is_err());
|
||||
assert!(validate_record_key("").is_err());
|
||||
assert!(validate_record_key("invalid/key").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_collection_nsid() {
|
||||
assert!(validate_collection_nsid("app.bsky.feed.post").is_ok());
|
||||
assert!(validate_collection_nsid("com.atproto.repo.record").is_ok());
|
||||
assert!(validate_collection_nsid("invalid").is_err());
|
||||
assert!(validate_collection_nsid("a.b").is_err());
|
||||
assert!(validate_collection_nsid("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_did() {
|
||||
assert!(is_valid_did("did:plc:1234567890abcdefghijk"));
|
||||
assert!(is_valid_did("did:web:example.com"));
|
||||
assert!(is_valid_did(
|
||||
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
|
||||
));
|
||||
assert!(!is_valid_did(""));
|
||||
assert!(!is_valid_did("plc:1234567890abcdefghijk"));
|
||||
assert!(!is_valid_did("did:"));
|
||||
assert!(!is_valid_did("did:plc:"));
|
||||
assert!(!is_valid_did("did::something"));
|
||||
assert!(!is_valid_did("DID:plc:test"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,10 +133,6 @@ pub struct FirehoseConsumer {
|
||||
}
|
||||
|
||||
impl FirehoseConsumer {
|
||||
pub async fn connect(port: u16) -> Self {
|
||||
Self::connect_inner(port, None).await
|
||||
}
|
||||
|
||||
pub async fn connect_with_cursor(port: u16, cursor: i64) -> Self {
|
||||
Self::connect_inner(port, Some(cursor)).await
|
||||
}
|
||||
|
||||
@@ -23,10 +23,7 @@ async fn describe_server_returns_configured_domain() {
|
||||
let client = client();
|
||||
let base = base_url_with_domain().await;
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.server.describeServer",
|
||||
base
|
||||
))
|
||||
.get(format!("{}/xrpc/com.atproto.server.describeServer", base))
|
||||
.send()
|
||||
.await
|
||||
.expect("describeServer request failed");
|
||||
@@ -54,10 +51,7 @@ async fn short_handle_uses_configured_domain() {
|
||||
"password": "Testpass123!"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base
|
||||
))
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
@@ -91,10 +85,7 @@ async fn full_handle_with_configured_domain_accepted() {
|
||||
"password": "Testpass123!"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base
|
||||
))
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
@@ -122,10 +113,7 @@ async fn handle_with_pds_hostname_treated_as_custom() {
|
||||
"password": "Testpass123!"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base
|
||||
))
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
@@ -150,10 +138,7 @@ async fn resolve_handle_works_with_configured_domain() {
|
||||
"password": "Testpass123!"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base
|
||||
))
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
@@ -164,10 +149,7 @@ async fn resolve_handle_works_with_configured_domain() {
|
||||
let full_handle = body["handle"].as_str().expect("No handle").to_string();
|
||||
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.identity.resolveHandle",
|
||||
base
|
||||
))
|
||||
.get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base))
|
||||
.query(&[("handle", full_handle.as_str())])
|
||||
.send()
|
||||
.await
|
||||
@@ -201,10 +183,7 @@ async fn admin_update_handle_uses_configured_domain() {
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.identity.resolveHandle",
|
||||
base
|
||||
))
|
||||
.get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base))
|
||||
.query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))])
|
||||
.send()
|
||||
.await
|
||||
@@ -228,10 +207,7 @@ async fn update_handle_bare_uses_configured_domain() {
|
||||
"password": "Testpass123!"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base
|
||||
))
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
@@ -243,10 +219,7 @@ async fn update_handle_bare_uses_configured_domain() {
|
||||
|
||||
let new_short = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.identity.updateHandle",
|
||||
base
|
||||
))
|
||||
.post(format!("{}/xrpc/com.atproto.identity.updateHandle", base))
|
||||
.bearer_auth(&access_jwt)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.json(&json!({ "handle": new_short }))
|
||||
@@ -261,10 +234,7 @@ async fn update_handle_bare_uses_configured_domain() {
|
||||
);
|
||||
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.identity.resolveHandle",
|
||||
base
|
||||
))
|
||||
.get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base))
|
||||
.query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))])
|
||||
.send()
|
||||
.await
|
||||
@@ -276,3 +246,34 @@ async fn update_handle_bare_uses_configured_domain() {
|
||||
"updateHandle with bare handle should use configured domain, not PDS hostname"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn did_web_uses_handle_domain_not_hostname() {
|
||||
unsafe {
|
||||
std::env::set_var("ENABLE_PDS_HOSTED_DID_WEB", "true");
|
||||
}
|
||||
let client = client();
|
||||
let base = base_url_with_domain().await;
|
||||
let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
|
||||
let payload = json!({
|
||||
"handle": short_handle,
|
||||
"email": format!("{}@example.com", short_handle),
|
||||
"password": "Testpass123!",
|
||||
"didType": "web"
|
||||
});
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("createAccount request failed");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
let did = body["did"].as_str().expect("No DID in response");
|
||||
let expected_did = format!("did:web:{}.{}", short_handle, HANDLE_DOMAIN);
|
||||
assert_eq!(
|
||||
did, expected_did,
|
||||
"did:web should use handle domain '{}', not PDS hostname",
|
||||
HANDLE_DOMAIN
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,13 +193,13 @@ async fn test_record_crud_lifecycle() {
|
||||
async fn test_profile_with_blob_lifecycle() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("profile-blob").await;
|
||||
let blob_data = b"This is test blob data for a profile avatar";
|
||||
let blob_data = b"\x89PNG\r\n\x1a\nfake image data for test";
|
||||
let upload_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.uploadBlob",
|
||||
base_url().await
|
||||
))
|
||||
.header(header::CONTENT_TYPE, "text/plain")
|
||||
.header(header::CONTENT_TYPE, "image/png")
|
||||
.bearer_auth(&jwt)
|
||||
.body(blob_data.to_vec())
|
||||
.send()
|
||||
|
||||
@@ -9,467 +9,81 @@ fn now() -> String {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_post_record_validation() {
|
||||
fn test_type_mismatch() {
|
||||
let validator = RecordValidator::new();
|
||||
|
||||
let valid_post = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Hello world!",
|
||||
let record = json!({
|
||||
"$type": "com.example.other",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(matches!(
|
||||
validator.validate(&record, "com.example.expected"),
|
||||
Err(ValidationError::TypeMismatch { expected, actual })
|
||||
if expected == "com.example.expected" && actual == "com.example.other"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_type() {
|
||||
let validator = RecordValidator::new();
|
||||
let record = json!({"text": "Hello"});
|
||||
assert!(matches!(
|
||||
validator.validate(&record, "com.example.test"),
|
||||
Err(ValidationError::MissingType)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_object() {
|
||||
let validator = RecordValidator::new();
|
||||
let record = json!("just a string");
|
||||
assert!(matches!(
|
||||
validator.validate(&record, "com.example.test"),
|
||||
Err(ValidationError::InvalidRecord(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_type_lenient() {
|
||||
let validator = RecordValidator::new();
|
||||
let record = json!({"$type": "com.custom.record", "data": "test"});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&valid_post, "app.bsky.feed.post")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let missing_text = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&missing_text, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "text")
|
||||
);
|
||||
|
||||
let missing_created_at = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Hello"
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&missing_created_at, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "createdAt")
|
||||
);
|
||||
|
||||
let text_too_long = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "a".repeat(3001),
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&text_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "text")
|
||||
);
|
||||
|
||||
let text_at_limit = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "a".repeat(3000),
|
||||
"createdAt": now()
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&text_at_limit, "app.bsky.feed.post")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let too_many_langs = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Hello",
|
||||
"createdAt": now(),
|
||||
"langs": ["en", "fr", "de", "es"]
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&too_many_langs, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "langs")
|
||||
);
|
||||
|
||||
let three_langs_ok = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Hello",
|
||||
"createdAt": now(),
|
||||
"langs": ["en", "fr", "de"]
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&three_langs_ok, "app.bsky.feed.post")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let too_many_tags = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Hello",
|
||||
"createdAt": now(),
|
||||
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8", "tag9"]
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&too_many_tags, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "tags")
|
||||
);
|
||||
|
||||
let eight_tags_ok = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Hello",
|
||||
"createdAt": now(),
|
||||
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8"]
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&eight_tags_ok, "app.bsky.feed.post")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let tag_too_long = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Hello",
|
||||
"createdAt": now(),
|
||||
"tags": ["t".repeat(641)]
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&tag_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path.starts_with("tags/"))
|
||||
validator.validate(&record, "com.custom.record").unwrap(),
|
||||
ValidationStatus::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_record_validation() {
|
||||
let validator = RecordValidator::new();
|
||||
|
||||
let valid = json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": "Test User",
|
||||
"description": "A test user profile"
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&valid, "app.bsky.actor.profile")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let empty_ok = json!({
|
||||
"$type": "app.bsky.actor.profile"
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&empty_ok, "app.bsky.actor.profile")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let displayname_too_long = json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": "n".repeat(641)
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&displayname_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName")
|
||||
);
|
||||
|
||||
let description_too_long = json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"description": "d".repeat(2561)
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&description_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "description")
|
||||
);
|
||||
fn test_unknown_type_strict() {
|
||||
let validator = RecordValidator::new().require_lexicon(true);
|
||||
let record = json!({"$type": "com.custom.record", "data": "test"});
|
||||
assert!(matches!(
|
||||
validator.validate(&record, "com.custom.record"),
|
||||
Err(ValidationError::UnknownType(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_like_and_repost_validation() {
|
||||
fn test_datetime_validation() {
|
||||
let validator = RecordValidator::new();
|
||||
|
||||
let valid_like = json!({
|
||||
"$type": "app.bsky.feed.like",
|
||||
"subject": {
|
||||
"uri": "at://did:plc:test/app.bsky.feed.post/123",
|
||||
"cid": "bafyreig6xxxxxyyyyyzzzzzz"
|
||||
},
|
||||
"createdAt": now()
|
||||
});
|
||||
let valid = json!({"$type": "com.custom.record", "createdAt": "2024-01-15T10:30:00.000Z"});
|
||||
assert_eq!(
|
||||
validator.validate(&valid, "com.custom.record").unwrap(),
|
||||
ValidationStatus::Unknown
|
||||
);
|
||||
|
||||
let with_offset =
|
||||
json!({"$type": "com.custom.record", "createdAt": "2024-01-15T10:30:00+05:30"});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&valid_like, "app.bsky.feed.like")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let missing_subject = json!({
|
||||
"$type": "app.bsky.feed.like",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&missing_subject, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f == "subject")
|
||||
);
|
||||
|
||||
let missing_subject_uri = json!({
|
||||
"$type": "app.bsky.feed.like",
|
||||
"subject": {
|
||||
"cid": "bafyreig6xxxxxyyyyyzzzzzz"
|
||||
},
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&missing_subject_uri, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f.contains("uri"))
|
||||
);
|
||||
|
||||
let invalid_subject_uri = json!({
|
||||
"$type": "app.bsky.feed.like",
|
||||
"subject": {
|
||||
"uri": "https://example.com/not-at-uri",
|
||||
"cid": "bafyreig6xxxxxyyyyyzzzzzz"
|
||||
},
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&invalid_subject_uri, "app.bsky.feed.like"), Err(ValidationError::InvalidField { path, .. }) if path.contains("uri"))
|
||||
);
|
||||
|
||||
let valid_repost = json!({
|
||||
"$type": "app.bsky.feed.repost",
|
||||
"subject": {
|
||||
"uri": "at://did:plc:test/app.bsky.feed.post/123",
|
||||
"cid": "bafyreig6xxxxxyyyyyzzzzzz"
|
||||
},
|
||||
"createdAt": now()
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&valid_repost, "app.bsky.feed.repost")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let repost_missing_subject = json!({
|
||||
"$type": "app.bsky.feed.repost",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&repost_missing_subject, "app.bsky.feed.repost"), Err(ValidationError::MissingField(f)) if f == "subject")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_follow_and_block_validation() {
|
||||
let validator = RecordValidator::new();
|
||||
|
||||
let valid_follow = json!({
|
||||
"$type": "app.bsky.graph.follow",
|
||||
"subject": "did:plc:test12345",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&valid_follow, "app.bsky.graph.follow")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let missing_follow_subject = json!({
|
||||
"$type": "app.bsky.graph.follow",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&missing_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::MissingField(f)) if f == "subject")
|
||||
);
|
||||
|
||||
let invalid_follow_subject = json!({
|
||||
"$type": "app.bsky.graph.follow",
|
||||
"subject": "not-a-did",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&invalid_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::InvalidField { path, .. }) if path == "subject")
|
||||
);
|
||||
|
||||
let valid_block = json!({
|
||||
"$type": "app.bsky.graph.block",
|
||||
"subject": "did:plc:blocked123",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&valid_block, "app.bsky.graph.block")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let invalid_block_subject = json!({
|
||||
"$type": "app.bsky.graph.block",
|
||||
"subject": "not-a-did",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&invalid_block_subject, "app.bsky.graph.block"), Err(ValidationError::InvalidField { path, .. }) if path == "subject")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_and_graph_records_validation() {
|
||||
let validator = RecordValidator::new();
|
||||
|
||||
let valid_list = json!({
|
||||
"$type": "app.bsky.graph.list",
|
||||
"name": "My List",
|
||||
"purpose": "app.bsky.graph.defs#modlist",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&valid_list, "app.bsky.graph.list")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let list_name_too_long = json!({
|
||||
"$type": "app.bsky.graph.list",
|
||||
"name": "n".repeat(65),
|
||||
"purpose": "app.bsky.graph.defs#modlist",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&list_name_too_long, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name")
|
||||
);
|
||||
|
||||
let list_empty_name = json!({
|
||||
"$type": "app.bsky.graph.list",
|
||||
"name": "",
|
||||
"purpose": "app.bsky.graph.defs#modlist",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&list_empty_name, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name")
|
||||
);
|
||||
|
||||
let valid_list_item = json!({
|
||||
"$type": "app.bsky.graph.listitem",
|
||||
"subject": "did:plc:test123",
|
||||
"list": "at://did:plc:owner/app.bsky.graph.list/mylist",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&valid_list_item, "app.bsky.graph.listitem")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_misc_record_types_validation() {
|
||||
let validator = RecordValidator::new();
|
||||
|
||||
let valid_generator = json!({
|
||||
"$type": "app.bsky.feed.generator",
|
||||
"did": "did:web:example.com",
|
||||
"displayName": "My Feed",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&valid_generator, "app.bsky.feed.generator")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let generator_displayname_too_long = json!({
|
||||
"$type": "app.bsky.feed.generator",
|
||||
"did": "did:web:example.com",
|
||||
"displayName": "f".repeat(241),
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(
|
||||
matches!(validator.validate(&generator_displayname_too_long, "app.bsky.feed.generator"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName")
|
||||
);
|
||||
|
||||
let valid_threadgate = json!({
|
||||
"$type": "app.bsky.feed.threadgate",
|
||||
"post": "at://did:plc:test/app.bsky.feed.post/123",
|
||||
"createdAt": now()
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&valid_threadgate, "app.bsky.feed.threadgate")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let valid_labeler = json!({
|
||||
"$type": "app.bsky.labeler.service",
|
||||
"policies": {
|
||||
"labelValues": ["spam", "nsfw"]
|
||||
},
|
||||
"createdAt": now()
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&valid_labeler, "app.bsky.labeler.service")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_and_format_validation() {
|
||||
let validator = RecordValidator::new();
|
||||
let strict_validator = RecordValidator::new().require_lexicon(true);
|
||||
|
||||
let custom_record = json!({
|
||||
"$type": "com.custom.record",
|
||||
"data": "test"
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&custom_record, "com.custom.record")
|
||||
.validate(&with_offset, "com.custom.record")
|
||||
.unwrap(),
|
||||
ValidationStatus::Unknown
|
||||
);
|
||||
|
||||
let invalid = json!({"$type": "com.custom.record", "createdAt": "2024/01/15"});
|
||||
assert!(matches!(
|
||||
strict_validator.validate(&custom_record, "com.custom.record"),
|
||||
Err(ValidationError::UnknownType(_))
|
||||
));
|
||||
|
||||
let type_mismatch = json!({
|
||||
"$type": "app.bsky.feed.like",
|
||||
"subject": {"uri": "at://test", "cid": "bafytest"},
|
||||
"createdAt": now()
|
||||
});
|
||||
assert!(matches!(
|
||||
validator.validate(&type_mismatch, "app.bsky.feed.post"),
|
||||
Err(ValidationError::TypeMismatch { expected, actual }) if expected == "app.bsky.feed.post" && actual == "app.bsky.feed.like"
|
||||
));
|
||||
|
||||
let missing_type = json!({
|
||||
"text": "Hello"
|
||||
});
|
||||
assert!(matches!(
|
||||
validator.validate(&missing_type, "app.bsky.feed.post"),
|
||||
Err(ValidationError::MissingType)
|
||||
));
|
||||
|
||||
let not_object = json!("just a string");
|
||||
assert!(matches!(
|
||||
validator.validate(¬_object, "app.bsky.feed.post"),
|
||||
Err(ValidationError::InvalidRecord(_))
|
||||
));
|
||||
|
||||
let valid_datetime = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Test",
|
||||
"createdAt": "2024-01-15T10:30:00.000Z"
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&valid_datetime, "app.bsky.feed.post")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let datetime_with_offset = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Test",
|
||||
"createdAt": "2024-01-15T10:30:00+05:30"
|
||||
});
|
||||
assert_eq!(
|
||||
validator
|
||||
.validate(&datetime_with_offset, "app.bsky.feed.post")
|
||||
.unwrap(),
|
||||
ValidationStatus::Valid
|
||||
);
|
||||
|
||||
let invalid_datetime = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Test",
|
||||
"createdAt": "2024/01/15"
|
||||
});
|
||||
assert!(matches!(
|
||||
validator.validate(&invalid_datetime, "app.bsky.feed.post"),
|
||||
validator.validate(&invalid, "com.custom.record"),
|
||||
Err(ValidationError::InvalidDatetime { .. })
|
||||
));
|
||||
}
|
||||
@@ -501,6 +115,12 @@ fn test_record_key_validation() {
|
||||
Err(ValidationError::InvalidRecord(_))
|
||||
));
|
||||
assert!(validate_record_key(&"k".repeat(512)).is_ok());
|
||||
|
||||
assert!(
|
||||
validate_record_key("key:with:colons").is_ok(),
|
||||
"AT Protocol record keys allow colons"
|
||||
);
|
||||
assert!(validate_record_key("at:something").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,17 +6,58 @@ use helpers::*;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn ensure_test_schemas() {
|
||||
use std::sync::Once;
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
let registry = tranquil_lexicon::LexiconRegistry::global();
|
||||
let post_schema: tranquil_lexicon::LexiconDoc = serde_json::from_value(json!({
|
||||
"lexicon": 1,
|
||||
"id": "com.test.feed.post",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"key": "tid",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["text", "createdAt"],
|
||||
"properties": {
|
||||
"text": { "type": "string", "maxLength": 300, "maxGraphemes": 300 },
|
||||
"createdAt": { "type": "string", "format": "datetime" },
|
||||
"reply": { "type": "ref", "ref": "#replyRef" },
|
||||
"embed": { "type": "union", "refs": [] },
|
||||
"langs": { "type": "array", "maxLength": 3, "items": { "type": "string", "format": "language" } },
|
||||
"tags": { "type": "array", "maxLength": 8, "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } },
|
||||
"facets": { "type": "array", "items": { "type": "unknown" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"replyRef": {
|
||||
"type": "object",
|
||||
"required": ["root", "parent"],
|
||||
"properties": {
|
||||
"root": { "type": "unknown" },
|
||||
"parent": { "type": "unknown" }
|
||||
}
|
||||
}
|
||||
}
|
||||
})).expect("invalid post schema");
|
||||
registry.preload(post_schema);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_record_response_schema() {
|
||||
ensure_test_schemas();
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-create").await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"collection": "com.test.feed.post",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"$type": "com.test.feed.post",
|
||||
"text": "Testing conformance",
|
||||
"createdAt": now
|
||||
}
|
||||
@@ -73,10 +114,10 @@ async fn test_create_record_no_validation_status_when_validate_false() {
|
||||
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"collection": "com.test.feed.post",
|
||||
"validate": false,
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"$type": "com.test.feed.post",
|
||||
"text": "Testing without validation",
|
||||
"createdAt": now
|
||||
}
|
||||
@@ -106,16 +147,17 @@ async fn test_create_record_no_validation_status_when_validate_false() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_record_response_schema() {
|
||||
ensure_test_schemas();
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-put").await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"collection": "com.test.feed.post",
|
||||
"rkey": "conformance-put",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"$type": "com.test.feed.post",
|
||||
"text": "Testing putRecord conformance",
|
||||
"createdAt": now
|
||||
}
|
||||
@@ -160,10 +202,10 @@ async fn test_delete_record_response_schema() {
|
||||
|
||||
let create_payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"collection": "com.test.feed.post",
|
||||
"rkey": "to-delete",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"$type": "com.test.feed.post",
|
||||
"text": "This will be deleted",
|
||||
"createdAt": now
|
||||
}
|
||||
@@ -182,7 +224,7 @@ async fn test_delete_record_response_schema() {
|
||||
|
||||
let delete_payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"collection": "com.test.feed.post",
|
||||
"rkey": "to-delete"
|
||||
});
|
||||
let delete_res = client
|
||||
@@ -215,7 +257,7 @@ async fn test_delete_record_noop_response() {
|
||||
|
||||
let delete_payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"collection": "com.test.feed.post",
|
||||
"rkey": "nonexistent-record"
|
||||
});
|
||||
let delete_res = client
|
||||
@@ -240,6 +282,7 @@ async fn test_delete_record_noop_response() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_writes_response_schema() {
|
||||
ensure_test_schemas();
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-apply").await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
@@ -249,20 +292,20 @@ async fn test_apply_writes_response_schema() {
|
||||
"writes": [
|
||||
{
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": "app.bsky.feed.post",
|
||||
"collection": "com.test.feed.post",
|
||||
"rkey": "apply-test-1",
|
||||
"value": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"$type": "com.test.feed.post",
|
||||
"text": "First post",
|
||||
"createdAt": now
|
||||
}
|
||||
},
|
||||
{
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": "app.bsky.feed.post",
|
||||
"collection": "com.test.feed.post",
|
||||
"rkey": "apply-test-2",
|
||||
"value": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"$type": "com.test.feed.post",
|
||||
"text": "Second post",
|
||||
"createdAt": now
|
||||
}
|
||||
@@ -312,16 +355,17 @@ async fn test_apply_writes_response_schema() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_writes_update_and_delete_results() {
|
||||
ensure_test_schemas();
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-apply-upd").await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let create_payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"collection": "com.test.feed.post",
|
||||
"rkey": "to-update",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"$type": "com.test.feed.post",
|
||||
"text": "Original",
|
||||
"createdAt": now
|
||||
}
|
||||
@@ -342,17 +386,17 @@ async fn test_apply_writes_update_and_delete_results() {
|
||||
"writes": [
|
||||
{
|
||||
"$type": "com.atproto.repo.applyWrites#update",
|
||||
"collection": "app.bsky.feed.post",
|
||||
"collection": "com.test.feed.post",
|
||||
"rkey": "to-update",
|
||||
"value": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"$type": "com.test.feed.post",
|
||||
"text": "Updated",
|
||||
"createdAt": now
|
||||
}
|
||||
},
|
||||
{
|
||||
"$type": "com.atproto.repo.applyWrites#delete",
|
||||
"collection": "app.bsky.feed.post",
|
||||
"collection": "com.test.feed.post",
|
||||
"rkey": "to-update"
|
||||
}
|
||||
]
|
||||
@@ -415,7 +459,7 @@ async fn test_get_record_error_code() {
|
||||
))
|
||||
.query(&[
|
||||
("repo", did.as_str()),
|
||||
("collection", "app.bsky.feed.post"),
|
||||
("collection", "com.test.feed.post"),
|
||||
("rkey", "nonexistent"),
|
||||
])
|
||||
.send()
|
||||
@@ -520,14 +564,14 @@ async fn test_put_record_noop_same_content() {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let record = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"$type": "com.test.feed.post",
|
||||
"text": "This content will not change",
|
||||
"createdAt": now
|
||||
});
|
||||
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"collection": "com.test.feed.post",
|
||||
"rkey": "noop-test",
|
||||
"record": record.clone()
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use tranquil_lexicon::is_valid_did;
|
||||
use tranquil_pds::api::validation::{
|
||||
HandleValidationError, MAX_DOMAIN_LABEL_LENGTH, MAX_EMAIL_LENGTH, MAX_LOCAL_PART_LENGTH,
|
||||
MAX_SERVICE_HANDLE_LOCAL_PART, is_valid_email, validate_short_handle,
|
||||
};
|
||||
use tranquil_pds::validation::{
|
||||
is_valid_did, validate_collection_nsid, validate_password, validate_record_key,
|
||||
};
|
||||
use tranquil_pds::validation::{validate_collection_nsid, validate_password, validate_record_key};
|
||||
|
||||
#[test]
|
||||
fn test_record_key_boundary_min() {
|
||||
@@ -59,7 +58,6 @@ fn test_record_key_invalid_chars() {
|
||||
assert!(validate_record_key("a+b").is_err());
|
||||
assert!(validate_record_key("a=b").is_err());
|
||||
assert!(validate_record_key("a?b").is_err());
|
||||
assert!(validate_record_key("a:b").is_err());
|
||||
assert!(validate_record_key("a;b").is_err());
|
||||
assert!(validate_record_key("a<b").is_err());
|
||||
assert!(validate_record_key("a>b").is_err());
|
||||
@@ -160,7 +158,7 @@ fn test_did_validation_method_case() {
|
||||
|
||||
#[test]
|
||||
fn test_did_validation_method_chars() {
|
||||
assert!(!is_valid_did("did:plc1:abc"));
|
||||
assert!(is_valid_did("did:plc1:abc"));
|
||||
assert!(!is_valid_did("did:plc-x:abc"));
|
||||
assert!(!is_valid_did("did:plc_x:abc"));
|
||||
}
|
||||
|
||||
@@ -390,57 +390,79 @@
|
||||
|
||||
[sso]
|
||||
[sso.github]
|
||||
# Can also be specified via environment variable `SSO_GITHUB_ENABLED`.
|
||||
# Default value: false
|
||||
#enabled = false
|
||||
|
||||
# Can also be specified via environment variable `SSO_GITHUB_CLIENT_ID`.
|
||||
#client_id =
|
||||
|
||||
# Can also be specified via environment variable `SSO_GITHUB_CLIENT_SECRET`.
|
||||
#client_secret =
|
||||
|
||||
# Can also be specified via environment variable `SSO_GITHUB_DISPLAY_NAME`.
|
||||
#display_name =
|
||||
|
||||
[sso.discord]
|
||||
# Can also be specified via environment variable `SSO_DISCORD_ENABLED`.
|
||||
# Default value: false
|
||||
#enabled = false
|
||||
|
||||
# Can also be specified via environment variable `SSO_DISCORD_CLIENT_ID`.
|
||||
#client_id =
|
||||
|
||||
# Can also be specified via environment variable `SSO_DISCORD_CLIENT_SECRET`.
|
||||
#client_secret =
|
||||
|
||||
# Can also be specified via environment variable `SSO_DISCORD_DISPLAY_NAME`.
|
||||
#display_name =
|
||||
|
||||
[sso.google]
|
||||
# Can also be specified via environment variable `SSO_GOOGLE_ENABLED`.
|
||||
# Default value: false
|
||||
#enabled = false
|
||||
|
||||
# Can also be specified via environment variable `SSO_GOOGLE_CLIENT_ID`.
|
||||
#client_id =
|
||||
|
||||
# Can also be specified via environment variable `SSO_GOOGLE_CLIENT_SECRET`.
|
||||
#client_secret =
|
||||
|
||||
# Can also be specified via environment variable `SSO_GOOGLE_DISPLAY_NAME`.
|
||||
#display_name =
|
||||
|
||||
[sso.gitlab]
|
||||
# Can also be specified via environment variable `SSO_GITLAB_ENABLED`.
|
||||
# Default value: false
|
||||
#enabled = false
|
||||
|
||||
# Can also be specified via environment variable `SSO_GITLAB_CLIENT_ID`.
|
||||
#client_id =
|
||||
|
||||
# Can also be specified via environment variable `SSO_GITLAB_CLIENT_SECRET`.
|
||||
#client_secret =
|
||||
|
||||
# Can also be specified via environment variable `SSO_GITLAB_ISSUER`.
|
||||
#issuer =
|
||||
|
||||
# Can also be specified via environment variable `SSO_GITLAB_DISPLAY_NAME`.
|
||||
#display_name =
|
||||
|
||||
[sso.oidc]
|
||||
# Can also be specified via environment variable `SSO_OIDC_ENABLED`.
|
||||
# Default value: false
|
||||
#enabled = false
|
||||
|
||||
# Can also be specified via environment variable `SSO_OIDC_CLIENT_ID`.
|
||||
#client_id =
|
||||
|
||||
# Can also be specified via environment variable `SSO_OIDC_CLIENT_SECRET`.
|
||||
#client_secret =
|
||||
|
||||
# Can also be specified via environment variable `SSO_OIDC_ISSUER`.
|
||||
#issuer =
|
||||
|
||||
# Can also be specified via environment variable `SSO_OIDC_DISPLAY_NAME`.
|
||||
#display_name =
|
||||
|
||||
[sso.apple]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="/favicon.ico" type="image/png">
|
||||
<title>Tranquil PDS</title>
|
||||
<style>
|
||||
:root {
|
||||
@@ -367,7 +368,7 @@
|
||||
|
||||
<nav>
|
||||
<div class="nav-left">
|
||||
<img src="/logo" alt="Logo" class="nav-logo hidden" id="navLogo">
|
||||
<img src="/favicon.ico" alt="Logo" class="nav-logo hidden" id="navLogo">
|
||||
<span class="hostname" id="hostname">loading...</span>
|
||||
<span class="user-count hidden" id="userCount"></span>
|
||||
</div>
|
||||
@@ -556,7 +557,7 @@
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch("/logo", { method: "HEAD" })
|
||||
fetch("/favicon.ico", { method: "HEAD" })
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
document.getElementById("navLogo").classList.remove(
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
logoCid = config.logoCid
|
||||
originalLogoCid = config.logoCid
|
||||
if (config.logoCid) {
|
||||
logoPreview = '/logo'
|
||||
logoPreview = '/favicon.ico'
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadConfig'))
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface RegistrationFlowState {
|
||||
error: string | null;
|
||||
submitting: boolean;
|
||||
pdsHostname: string;
|
||||
selectedDomain: string;
|
||||
handleAvailable: boolean | null;
|
||||
checkingHandle: boolean;
|
||||
discordInUse: boolean;
|
||||
@@ -68,6 +69,7 @@ export function createRegistrationFlow(
|
||||
error: null,
|
||||
submitting: false,
|
||||
pdsHostname,
|
||||
selectedDomain: "",
|
||||
handleAvailable: null,
|
||||
checkingHandle: false,
|
||||
discordInUse: false,
|
||||
@@ -84,7 +86,10 @@ export function createRegistrationFlow(
|
||||
}
|
||||
|
||||
function getFullHandle(): string {
|
||||
return `${state.info.handle.trim()}.${state.pdsHostname}`;
|
||||
const handle = state.info.handle.trim();
|
||||
if (handle.includes('.')) return handle;
|
||||
const domain = state.selectedDomain || state.pdsHostname;
|
||||
return `${handle}.${domain}`;
|
||||
}
|
||||
|
||||
function extractDomain(did: string): string {
|
||||
@@ -132,10 +137,10 @@ export function createRegistrationFlow(
|
||||
}
|
||||
state.checkingHandle = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ handle });
|
||||
if (state.selectedDomain) params.set("domain", state.selectedDomain);
|
||||
const response = await fetch(
|
||||
`${getPdsEndpoint()}/oauth/sso/check-handle-available?handle=${
|
||||
encodeURIComponent(handle)
|
||||
}`,
|
||||
`${getPdsEndpoint()}/oauth/sso/check-handle-available?${params}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
state.handleAvailable = data.available === true;
|
||||
@@ -239,7 +244,7 @@ export function createRegistrationFlow(
|
||||
}
|
||||
|
||||
const result = await api.createAccount({
|
||||
handle: state.info.handle.trim(),
|
||||
handle: getFullHandle(),
|
||||
email: state.info.email.trim(),
|
||||
password: state.info.password!,
|
||||
inviteCode: state.info.inviteCode?.trim() || undefined,
|
||||
@@ -291,7 +296,7 @@ export function createRegistrationFlow(
|
||||
}
|
||||
|
||||
const result = await api.createPasskeyAccount({
|
||||
handle: unsafeAsHandle(state.info.handle.trim()),
|
||||
handle: unsafeAsHandle(getFullHandle()),
|
||||
email: state.info.email?.trim()
|
||||
? unsafeAsEmail(state.info.email.trim())
|
||||
: undefined,
|
||||
@@ -532,6 +537,9 @@ export function createRegistrationFlow(
|
||||
getPdsDid,
|
||||
getFullHandle,
|
||||
extractDomain,
|
||||
setSelectedDomain(domain: string) {
|
||||
state.selectedDomain = domain;
|
||||
},
|
||||
|
||||
proceedFromInfo,
|
||||
selectKeyMode,
|
||||
|
||||
@@ -64,7 +64,7 @@ function setFavicon(hasLogo: boolean) {
|
||||
link.rel = "icon";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = "/logo";
|
||||
link.href = "/favicon.ico";
|
||||
} else if (link) {
|
||||
link.remove();
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
flow = createRegistrationFlow('passkey', hostname)
|
||||
}
|
||||
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
|
||||
if (flow) flow.setSelectedDomain(selectedDomain)
|
||||
} catch (e) {
|
||||
console.error('Failed to load server info:', e)
|
||||
} finally {
|
||||
@@ -353,7 +354,7 @@
|
||||
placeholder={$_('register.handlePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
onInput={(v) => { flow!.info.handle = v }}
|
||||
onDomainChange={(d) => { selectedDomain = d }}
|
||||
onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }}
|
||||
/>
|
||||
{#if fullHandle()}
|
||||
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
$effect(() => {
|
||||
void selectedDomain
|
||||
if (checkHandleTimeout) {
|
||||
clearTimeout(checkHandleTimeout)
|
||||
}
|
||||
@@ -167,7 +168,9 @@
|
||||
handleError = null
|
||||
|
||||
try {
|
||||
const response = await fetch(`/oauth/sso/check-handle-available?handle=${encodeURIComponent(handle)}`)
|
||||
const params = new URLSearchParams({ handle })
|
||||
if (selectedDomain) params.set('domain', selectedDomain)
|
||||
const response = await fetch(`/oauth/sso/check-handle-available?${params}`)
|
||||
const data = await response.json()
|
||||
handleAvailable = data.available
|
||||
if (!data.available && data.reason) {
|
||||
@@ -222,6 +225,9 @@
|
||||
return
|
||||
}
|
||||
|
||||
const fullHandle = !handle.includes('.') && selectedDomain
|
||||
? `${handle.trim()}.${selectedDomain}`
|
||||
: handle.trim()
|
||||
submitting = true
|
||||
|
||||
try {
|
||||
@@ -233,7 +239,7 @@
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
handle,
|
||||
handle: fullHandle,
|
||||
email: email || null,
|
||||
invite_code: inviteCode || null,
|
||||
verification_channel: verificationChannel,
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
flow = createRegistrationFlow('passkey', hostname)
|
||||
}
|
||||
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
|
||||
if (flow) flow.setSelectedDomain(selectedDomain)
|
||||
} catch (e) {
|
||||
console.error('Failed to load server info:', e)
|
||||
} finally {
|
||||
@@ -368,7 +369,7 @@
|
||||
placeholder={$_('register.handlePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
onInput={(v) => { flow!.info.handle = v }}
|
||||
onDomainChange={(d) => { selectedDomain = d }}
|
||||
onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }}
|
||||
/>
|
||||
{#if flow.info.handle.includes('.')}
|
||||
<p class="hint warning">{$_('register.handleDotWarning')}</p>
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
flow = createRegistrationFlow('password', hostname)
|
||||
}
|
||||
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
|
||||
if (flow) flow.setSelectedDomain(selectedDomain)
|
||||
} catch (e) {
|
||||
console.error('Failed to load server info:', e)
|
||||
} finally {
|
||||
@@ -313,7 +314,7 @@
|
||||
placeholder={$_('register.handlePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
onInput={(v) => { flow!.info.handle = v }}
|
||||
onDomainChange={(d) => { selectedDomain = d }}
|
||||
onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }}
|
||||
/>
|
||||
{#if flow.info.handle.includes('.')}
|
||||
<p class="hint warning">{$_('register.handleDotWarning')}</p>
|
||||
|
||||
@@ -165,6 +165,7 @@
|
||||
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
$effect(() => {
|
||||
void selectedDomain
|
||||
if (checkHandleTimeout) {
|
||||
clearTimeout(checkHandleTimeout)
|
||||
}
|
||||
@@ -182,7 +183,9 @@
|
||||
handleError = null
|
||||
|
||||
try {
|
||||
const response = await fetch(`/oauth/sso/check-handle-available?handle=${encodeURIComponent(handle)}`)
|
||||
const params = new URLSearchParams({ handle })
|
||||
if (selectedDomain) params.set('domain', selectedDomain)
|
||||
const response = await fetch(`/oauth/sso/check-handle-available?${params}`)
|
||||
const data = await response.json()
|
||||
handleAvailable = data.available
|
||||
if (!data.available && data.reason) {
|
||||
@@ -269,6 +272,9 @@
|
||||
return
|
||||
}
|
||||
|
||||
const fullHandle = !handle.includes('.') && selectedDomain
|
||||
? `${handle.trim()}.${selectedDomain}`
|
||||
: handle.trim()
|
||||
submitting = true
|
||||
|
||||
try {
|
||||
@@ -280,7 +286,7 @@
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
handle,
|
||||
handle: fullHandle,
|
||||
email: email || null,
|
||||
invite_code: inviteCode || null,
|
||||
verification_channel: verificationChannel,
|
||||
|
||||
@@ -59,6 +59,7 @@ export TRANQUIL_PDS_TEST_INFRA_READY="1"
|
||||
export TRANQUIL_PDS_ALLOW_INSECURE_SECRETS="1"
|
||||
export SKIP_IMPORT_VERIFICATION="true"
|
||||
export DISABLE_RATE_LIMITING="1"
|
||||
export TRANQUIL_LEXICON_OFFLINE="1"
|
||||
EOF
|
||||
echo ""
|
||||
echo "Infrastructure ready!"
|
||||
|
||||
Reference in New Issue
Block a user