) -> ApiResult {
}
let scope = db
- .create_scope(&staff.id, true, &scope, user_id)
+ .create_scope(
+ &staff.id,
+ true,
+ &scope,
+ user_id,
+ &ScopeDescription::default(),
+ )
.await
.map_err(|e| map_unique_violation(e, ApiError::ScopeAlreadyExists))?;
diff --git a/api/src/api/package.rs b/api/src/api/package.rs
index af59f9db..7aee6b27 100644
--- a/api/src/api/package.rs
+++ b/api/src/api/package.rs
@@ -2384,10 +2384,7 @@ mod test {
use crate::db::Permissions;
use crate::db::PublishingTaskStatus;
use crate::db::TokenType;
- use crate::ids::PackageName;
- use crate::ids::PackagePath;
- use crate::ids::ScopeName;
- use crate::ids::Version;
+ use crate::ids::{PackageName, PackagePath, ScopeName, Version, ScopeDescription};
use crate::publish::tests::create_mock_tarball;
use crate::publish::tests::process_tarball_setup;
use crate::publish::tests::process_tarball_setup2;
@@ -2585,7 +2582,13 @@ mod test {
// create scope2 for user2, try creating a package with user1
let scope2 = ScopeName::new("scope2".into()).unwrap();
t.db()
- .create_scope(&t.user2.user.id, false, &scope2, t.user2.user.id)
+ .create_scope(
+ &t.user2.user.id,
+ false,
+ &scope2,
+ t.user2.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
let mut resp = t
diff --git a/api/src/api/scope.rs b/api/src/api/scope.rs
index 9ecba9c2..4b150c0a 100644
--- a/api/src/api/scope.rs
+++ b/api/src/api/scope.rs
@@ -63,8 +63,10 @@ static RESERVED_SCOPES: OnceLock> =
#[instrument(name = "POST /api/scopes", skip(req), err, fields(scope))]
async fn create_handler(mut req: Request) -> ApiResult {
- let ApiCreateScopeRequest { scope } = decode_json(&mut req).await?;
+ let ApiCreateScopeRequest { scope, description } =
+ decode_json(&mut req).await?;
Span::current().record("scope", field::display(&scope));
+ Span::current().record("description", field::display(&description));
let db = req.data::().unwrap();
@@ -94,7 +96,7 @@ async fn create_handler(mut req: Request) -> ApiResult {
}
let scope = db
- .create_scope(&user.id, false, &scope, user.id)
+ .create_scope(&user.id, false, &scope, user.id, &description)
.await
.map_err(|e| map_unique_violation(e, ApiError::ScopeAlreadyExists))?;
@@ -163,6 +165,11 @@ async fn update_handler(
)
.await?
}
+ ApiUpdateScopeRequest::Description(description) => {
+ let (user, sudo) = iam.check_scope_admin_access(&scope).await?;
+ db.scope_set_description(&user.id, sudo, &scope, description)
+ .await?
+ }
};
let user = db
@@ -493,8 +500,7 @@ pub async fn delete_invite_handler(
#[cfg(test)]
pub mod tests {
use super::*;
- use crate::ids::PackageName;
- use crate::ids::ScopeName;
+ use crate::ids::{PackageName, ScopeDescription, ScopeName};
use crate::util::test::ApiResultExt;
use crate::util::test::TestSetup;
use serde_json::json;
@@ -511,7 +517,9 @@ pub mod tests {
let mut resp = t
.http()
.post("/api/scopes")
- .body_json(json!({ "scope": "scope1" }))
+ .body_json(
+ json!({ "scope": "scope1", "description": "" }),
+ )
.call()
.await
.unwrap();
@@ -526,7 +534,7 @@ pub mod tests {
let mut resp = t
.http()
.post("/api/scopes")
- .body_json(json!({ "scope": "scope1" }))
+ .body_json(json!({ "scope": "scope1", "description": "" }))
.call()
.await
.unwrap();
@@ -538,7 +546,7 @@ pub mod tests {
let mut resp = t
.http()
.post("/api/scopes")
- .body_json(json!({ "scope": "scop-e1" }))
+ .body_json(json!({ "scope": "scop-e1", "description": "" }))
.call()
.await
.unwrap();
@@ -550,7 +558,7 @@ pub mod tests {
let mut resp = t
.http()
.post("/api/scopes")
- .body_json(json!({ "scope": "scope 1" }))
+ .body_json(json!({ "scope": "scope 1", "description": "" }))
.call()
.await
.unwrap();
@@ -561,7 +569,7 @@ pub mod tests {
let mut resp = t
.http()
.post("/api/scopes")
- .body_json(json!({ "scope": "somebadword" }))
+ .body_json(json!({ "scope": "somebadword", "description": "" }))
.call()
.await
.unwrap();
@@ -572,7 +580,7 @@ pub mod tests {
let mut resp = t
.http()
.post("/api/scopes")
- .body_json(json!({ "scope": "react" }))
+ .body_json(json!({ "scope": "react", "description": "" }))
.call()
.await
.unwrap();
@@ -598,7 +606,7 @@ pub mod tests {
let mut resp = t
.http()
.post("/api/scopes")
- .body_json(json!({ "scope": "scope1" }))
+ .body_json(json!({ "scope": "scope1", "description": "Super scope 🐢 !!!" }))
.call()
.await
.unwrap();
@@ -606,7 +614,7 @@ pub mod tests {
let mut resp: Response = t
.http()
.post("/api/scopes")
- .body_json(json!({ "scope": "scope2" }))
+ .body_json(json!({ "scope": "scope2", "description": "Super scope 🐢 !!!" }))
.call()
.await
.unwrap();
@@ -616,7 +624,7 @@ pub mod tests {
let mut resp: Response = t
.http()
.post("/api/scopes")
- .body_json(json!({ "scope": "scope3" }))
+ .body_json(json!({ "scope": "scope3", "description": "Another super scope 🐢 !!!" }))
.call()
.await
.unwrap();
@@ -774,7 +782,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
let members = list_members(&mut t).await;
@@ -838,7 +852,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -887,7 +907,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -936,7 +962,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -999,7 +1031,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1064,7 +1102,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1125,7 +1169,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1170,7 +1220,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1217,7 +1273,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1279,7 +1341,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1341,7 +1409,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1411,7 +1485,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1481,7 +1561,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1553,7 +1639,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1624,7 +1716,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1679,7 +1777,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1721,7 +1825,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1765,7 +1875,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1824,7 +1940,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1886,7 +2008,13 @@ pub mod tests {
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1969,7 +2097,13 @@ pub mod tests {
// create scope
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -1990,7 +2124,13 @@ pub mod tests {
// create scope
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
t.db()
@@ -2031,7 +2171,13 @@ pub mod tests {
// create scope
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
@@ -2068,7 +2214,13 @@ pub mod tests {
// create scope and package
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
let name = PackageName::new("foo".to_owned()).unwrap();
@@ -2091,7 +2243,13 @@ pub mod tests {
// create scope and package
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
t.db()
@@ -2119,14 +2277,26 @@ pub mod tests {
// create scope
let scope_name = ScopeName::try_from("scope1").unwrap();
t.db()
- .create_scope(&t.user1.user.id, false, &scope_name, t.user1.user.id)
+ .create_scope(
+ &t.user1.user.id,
+ false,
+ &scope_name,
+ t.user1.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
for i in 0..3 {
let scope_name = ScopeName::try_from(format!("temp{i}")).unwrap();
t.db()
- .create_scope(&t.user2.user.id, false, &scope_name, t.user2.user.id)
+ .create_scope(
+ &t.user2.user.id,
+ false,
+ &scope_name,
+ t.user2.user.id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
}
diff --git a/api/src/api/types.rs b/api/src/api/types.rs
index 4157f973..72f0c001 100644
--- a/api/src/api/types.rs
+++ b/api/src/api/types.rs
@@ -4,6 +4,7 @@ use std::borrow::Cow;
use crate::db::*;
use crate::ids::PackageName;
use crate::ids::PackagePath;
+use crate::ids::ScopeDescription;
use crate::ids::ScopeName;
use crate::ids::Version;
use crate::provenance::ProvenanceBundle;
@@ -192,6 +193,7 @@ impl From for ApiFullUser {
#[serde(rename_all = "camelCase")]
pub struct ApiScope {
pub scope: ScopeName,
+ pub description: ScopeDescription,
pub updated_at: DateTime,
pub created_at: DateTime,
}
@@ -200,6 +202,7 @@ impl From for ApiScope {
fn from(scope: Scope) -> Self {
Self {
scope: scope.scope,
+ description: scope.description,
updated_at: scope.updated_at,
created_at: scope.created_at,
}
@@ -221,6 +224,7 @@ pub struct ApiScopeQuotas {
#[serde(rename_all = "camelCase")]
pub struct ApiFullScope {
pub scope: ScopeName,
+ pub description: ScopeDescription,
pub creator: ApiUser,
pub updated_at: DateTime,
pub created_at: DateTime,
@@ -235,6 +239,7 @@ impl From<(Scope, ScopeUsage, UserPublic)> for ApiFullScope {
assert_eq!(scope.creator, user.id);
Self {
scope: scope.scope,
+ description: scope.description,
creator: user.into(),
updated_at: scope.updated_at,
created_at: scope.created_at,
@@ -263,6 +268,7 @@ pub enum ApiScopeOrFullScope {
#[serde(rename_all = "camelCase")]
pub struct ApiCreateScopeRequest {
pub scope: ScopeName,
+ pub description: ScopeDescription,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -724,6 +730,8 @@ pub enum ApiUpdateScopeRequest {
GhActionsVerifyActor(bool),
#[serde(rename = "requirePublishingFromCI")]
RequirePublishingFromCI(bool),
+ #[serde(rename = "description")]
+ Description(Option),
}
#[derive(Debug, Serialize, Deserialize)]
diff --git a/api/src/db/database.rs b/api/src/db/database.rs
index 87026fc3..3d208e4e 100644
--- a/api/src/db/database.rs
+++ b/api/src/db/database.rs
@@ -13,6 +13,7 @@ use uuid::Uuid;
use crate::api::ApiMetrics;
use crate::ids::PackageName;
use crate::ids::PackagePath;
+use crate::ids::ScopeDescription;
use crate::ids::ScopeName;
use crate::ids::Version;
@@ -923,8 +924,9 @@ impl Database {
&self,
actor_id: &Uuid,
is_sudo: bool,
- scope: &ScopeName,
+ scope_name: &ScopeName,
user_id: Uuid,
+ scope_description: &ScopeDescription,
) -> Result {
let mut tx = self.pool.begin().await?;
@@ -938,7 +940,7 @@ impl Database {
"create_scope"
},
json!({
- "scope": scope,
+ "scope": scope_name,
"user_id": user_id,
}),
)
@@ -951,6 +953,7 @@ impl Database {
INSERT INTO scopes (scope, creator) VALUES ($1, $2)
RETURNING
scope,
+ description,
creator,
package_limit,
new_package_per_week_limit,
@@ -966,6 +969,7 @@ impl Database {
)
SELECT
scope as "scope: ScopeName",
+ description as "description: ScopeDescription",
creator,
package_limit,
new_package_per_week_limit,
@@ -976,8 +980,8 @@ impl Database {
created_at
FROM ins_scope
"#,
- scope as _,
- user_id
+ scope_name,
+ user_id,
)
.fetch_one(&mut *tx)
.await?;
@@ -1076,6 +1080,7 @@ impl Database {
)
SELECT
scopes.scope as "scope_scope: ScopeName",
+ scopes.description as "scope_description: ScopeDescription",
scopes.creator as "scope_creator",
scopes.package_limit as "scope_package_limit",
scopes.new_package_per_week_limit as "scope_new_package_per_week_limit",
@@ -1096,6 +1101,7 @@ impl Database {
.map(|r| {
let scope = Scope {
scope: r.scope_scope,
+ description: r.scope_description,
creator: r.scope_creator,
updated_at: r.scope_updated_at,
created_at: r.scope_created_at,
@@ -1153,6 +1159,7 @@ impl Database {
let scopes = sqlx::query(&format!(
r#"SELECT
scopes.scope as "scope_scope",
+ scopes.description as "scope_description",
scopes.creator as "scope_creator",
scopes.package_limit as "scope_package_limit",
scopes.new_package_per_week_limit as "scope_new_package_per_week_limit",
@@ -1207,6 +1214,7 @@ impl Database {
Scope,
r#"SELECT
scope as "scope: ScopeName",
+ description as "description: ScopeDescription",
creator,
package_limit,
new_package_per_week_limit,
@@ -1229,6 +1237,7 @@ impl Database {
Scope,
r#"SELECT
scope as "scope: ScopeName",
+ description as "description: ScopeDescription",
creator,
package_limit,
new_package_per_week_limit,
@@ -1238,7 +1247,7 @@ impl Database {
updated_at,
created_at
FROM scopes WHERE scope = $1"#,
- scope as _
+ scope
)
.fetch_optional(&self.pool)
.await
@@ -1292,6 +1301,7 @@ impl Database {
UPDATE scopes SET verify_oidc_actor = $1 WHERE scope = $2
RETURNING
scope as "scope: ScopeName",
+ description as "description: ScopeDescription",
creator,
package_limit,
new_package_per_week_limit,
@@ -1345,6 +1355,7 @@ impl Database {
UPDATE scopes SET require_publishing_from_ci = $1 WHERE scope = $2
RETURNING
scope as "scope: ScopeName",
+ description as "description: ScopeDescription",
creator,
package_limit,
new_package_per_week_limit,
@@ -1366,6 +1377,56 @@ impl Database {
Ok(scope)
}
+ #[instrument(name = "Database::scope_set_description", skip(self), err)]
+ pub async fn scope_set_description(
+ &self,
+ actor_id: &Uuid,
+ is_sudo: bool,
+ scope: &ScopeName,
+ description: Option,
+ ) -> Result {
+ let mut tx = self.pool.begin().await?;
+
+ audit_log(
+ &mut tx,
+ actor_id,
+ is_sudo,
+ "scope_set_description",
+ json!({
+ "scope": scope,
+ "description": description,
+ }),
+ )
+ .await?;
+
+ let scope = sqlx::query_as!(
+ Scope,
+ r#"
+ UPDATE scopes SET description = $1 WHERE scope = $2
+ RETURNING
+ scope as "scope: ScopeName",
+ description as "description: ScopeDescription",
+ creator,
+ package_limit,
+ new_package_per_week_limit,
+ publish_attempts_per_week_limit,
+ verify_oidc_actor,
+ require_publishing_from_ci,
+ updated_at,
+ created_at
+
+ "#,
+ description,
+ scope as _
+ )
+ .fetch_one(&mut *tx)
+ .await?;
+
+ tx.commit().await?;
+
+ Ok(scope)
+ }
+
#[instrument(name = "Database::list_packages_by_scope", skip(self), err)]
pub async fn list_packages_by_scope(
&self,
@@ -2325,6 +2386,7 @@ impl Database {
Scope,
r#"SELECT
scopes.scope as "scope: ScopeName",
+ scopes.description as "description: ScopeDescription",
scopes.creator,
scopes.package_limit,
scopes.new_package_per_week_limit,
diff --git a/api/src/db/models.rs b/api/src/db/models.rs
index 6c722ff6..11603fe5 100644
--- a/api/src/db/models.rs
+++ b/api/src/db/models.rs
@@ -15,6 +15,7 @@ use uuid::Uuid;
use crate::ids::PackageName;
use crate::ids::PackagePath;
+use crate::ids::ScopeDescription;
use crate::ids::ScopeName;
use crate::ids::Version;
@@ -208,6 +209,7 @@ pub struct NewPublishingTask<'s> {
#[derive(Debug)]
pub struct Scope {
pub scope: ScopeName,
+ pub description: ScopeDescription,
pub creator: Uuid,
pub updated_at: DateTime,
pub created_at: DateTime,
@@ -237,6 +239,7 @@ impl FromRow<'_, sqlx::postgres::PgRow> for Scope {
fn from_row(row: &sqlx::postgres::PgRow) -> Result {
Ok(Self {
scope: try_get_row_or(row, "scope", "scope_scope")?,
+ description: try_get_row_or(row, "description", "scope_description")?,
creator: try_get_row_or(row, "creator", "scope_creator")?,
updated_at: try_get_row_or(row, "updated_at", "scope_updated_at")?,
created_at: try_get_row_or(row, "created_at", "scope_created_at")?,
diff --git a/api/src/db/tests.rs b/api/src/db/tests.rs
index d679b265..ddf67640 100644
--- a/api/src/db/tests.rs
+++ b/api/src/db/tests.rs
@@ -2,6 +2,7 @@
use crate::db::*;
use crate::ids::PackageName;
use crate::ids::PackagePath;
+use crate::ids::ScopeDescription;
use crate::ids::ScopeName;
use crate::ids::Version;
use crate::npm::NPM_TARBALL_REVISION;
@@ -19,7 +20,13 @@ async fn publishing_tasks() {
let config_file = "/jsr.json".try_into().unwrap();
let _scope = db
- .create_scope(&user_id, false, &scope_name, user_id)
+ .create_scope(
+ &user_id,
+ false,
+ &scope_name,
+ user_id,
+ &ScopeDescription::default(),
+ )
.await
.unwrap();
let res = db.create_package(&scope_name, &package_name).await.unwrap();
@@ -217,9 +224,15 @@ async fn packages() {
let scope_name = "scope".try_into().unwrap();
let package_name = "testpkg".try_into().unwrap();
- db.create_scope(&alice.id, false, &scope_name, alice.id)
- .await
- .unwrap();
+ db.create_scope(
+ &alice.id,
+ false,
+ &scope_name,
+ alice.id,
+ &ScopeDescription::default(),
+ )
+ .await
+ .unwrap();
let alice2 = db.get_user(alice.id).await.unwrap().unwrap();
assert_eq!(alice2.scope_usage, 1);
@@ -285,9 +298,15 @@ async fn scope_members() {
let scope_name = "scope".try_into().unwrap();
- db.create_scope(&bob.id, false, &scope_name, bob.id)
- .await
- .unwrap();
+ db.create_scope(
+ &bob.id,
+ false,
+ &scope_name,
+ bob.id,
+ &ScopeDescription::default(),
+ )
+ .await
+ .unwrap();
let scope = db
.get_scope(&ScopeName::try_from("scope").unwrap())
@@ -350,7 +369,7 @@ async fn create_package_version_and_finalize_publishing_task() {
.await
.unwrap();
- db.create_scope(&bob.id, false, &scope, bob.id)
+ db.create_scope(&bob.id, false, &scope, bob.id, &ScopeDescription::default())
.await
.unwrap();
@@ -461,9 +480,15 @@ async fn package_files() {
let package_name = "testpkg".try_into().unwrap();
let version = "1.2.3".try_into().unwrap();
- db.create_scope(&user.id, false, &scope_name, user.id)
- .await
- .unwrap();
+ db.create_scope(
+ &user.id,
+ false,
+ &scope_name,
+ user.id,
+ &ScopeDescription::default(),
+ )
+ .await
+ .unwrap();
let CreatePackageResult::Ok(package) =
db.create_package(&scope_name, &package_name).await.unwrap()
diff --git a/api/src/ids.rs b/api/src/ids.rs
index 4dc0a91a..2d43aca2 100644
--- a/api/src/ids.rs
+++ b/api/src/ids.rs
@@ -144,6 +144,126 @@ pub enum ScopeNameValidateError {
DoubleHyphens,
}
+/// A scope description, like 'This is a user scope' or 'Admin scope'.
+/// The description must be at least 5 characters long, and at most 200 characters and can be empty.
+/// The description can contain utf-8 characters, including emoji.
+#[derive(Clone, PartialEq, Eq, Hash)]
+pub struct ScopeDescription(String);
+
+impl ScopeDescription {
+ pub fn new(
+ description: String,
+ ) -> Result {
+ if description.len() > 200 {
+ return Err(ScopeDescriptionValidateError::TooLong);
+ }
+
+ if description.len() < 5 && !description.is_empty() {
+ return Err(ScopeDescriptionValidateError::TooShort);
+ }
+
+ Ok(ScopeDescription(description))
+ }
+
+ pub fn default() -> Self {
+ ScopeDescription("".to_owned())
+ }
+}
+
+impl TryFrom<&str> for ScopeDescription {
+ type Error = ScopeDescriptionValidateError;
+ fn try_from(value: &str) -> Result {
+ Self::new(value.to_owned())
+ }
+}
+
+impl TryFrom for ScopeDescription {
+ type Error = ScopeDescriptionValidateError;
+ fn try_from(value: String) -> Result {
+ Self::new(value)
+ }
+}
+
+impl std::fmt::Display for ScopeDescription {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+impl std::fmt::Debug for ScopeDescription {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+impl<'a> serde::Deserialize<'a> for ScopeDescription {
+ fn deserialize(deserializer: D) -> Result
+ where
+ D: serde::Deserializer<'a>,
+ {
+ let s: String = String::deserialize(deserializer)?;
+ Self::new(s).map_err(serde::de::Error::custom)
+ }
+}
+
+impl serde::Serialize for ScopeDescription {
+ fn serialize(&self, serializer: S) -> Result
+ where
+ S: serde::Serializer,
+ {
+ self.0.serialize(serializer)
+ }
+}
+
+impl sqlx::Decode<'_, Postgres> for ScopeDescription {
+ fn decode(
+ value: PgValueRef<'_>,
+ ) -> Result> {
+ let s: String = sqlx::Decode::<'_, Postgres>::decode(value)?;
+ Self::new(s).map_err(|e| Box::new(e) as _)
+ }
+}
+
+impl<'q> sqlx::Encode<'q, Postgres> for ScopeDescription {
+ fn encode_by_ref(
+ &self,
+ buf: &mut >::ArgumentBuffer,
+ ) -> sqlx::encode::IsNull {
+ >::encode_by_ref(
+ &self.0, buf,
+ )
+ }
+}
+
+impl sqlx::Type for ScopeDescription {
+ fn type_info() -> ::TypeInfo {
+ >::type_info()
+ }
+}
+
+impl sqlx::postgres::PgHasArrayType for ScopeDescription {
+ fn array_type_info() -> sqlx::postgres::PgTypeInfo {
+ ::array_type_info()
+ }
+}
+
+impl std::ops::Deref for ScopeDescription {
+ type Target = String;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+#[derive(Debug, Clone, Error)]
+pub enum ScopeDescriptionValidateError {
+ #[error("scope description must be at most 200 characters long")]
+ TooLong,
+
+ #[error("scope description must be at least 5 character long")]
+ TooShort,
+}
+
/// A package name, like 'foo' or 'bar'. The name is not prefixed with an @.
/// The name must be at least 2 character long, and at most 58 characters long.
/// The name must only contain alphanumeric characters and hyphens.
diff --git a/api/src/tasks.rs b/api/src/tasks.rs
index 0da287dc..f8788650 100644
--- a/api/src/tasks.rs
+++ b/api/src/tasks.rs
@@ -466,9 +466,7 @@ mod tests {
use crate::db::NewPackageVersion;
use crate::db::PackageVersionMeta;
use crate::gcp::BigQueryQueryResult;
- use crate::ids::PackageName;
- use crate::ids::ScopeName;
- use crate::ids::Version;
+ use crate::ids::{PackageName, ScopeDescription, ScopeName, Version};
use super::deserialize_version_download_count_from_bigquery;
@@ -551,12 +549,24 @@ mod tests {
let v0_219_3 = Version::new("0.219.3").unwrap();
let v1_0_0 = Version::new("1.0.0").unwrap();
- db.create_scope(&Uuid::nil(), false, &std, Uuid::nil())
- .await
- .unwrap();
- db.create_scope(&Uuid::nil(), false, &luca, Uuid::nil())
- .await
- .unwrap();
+ db.create_scope(
+ &Uuid::nil(),
+ false,
+ &std,
+ Uuid::nil(),
+ &ScopeDescription::default(),
+ )
+ .await
+ .unwrap();
+ db.create_scope(
+ &Uuid::nil(),
+ false,
+ &luca,
+ Uuid::nil(),
+ &ScopeDescription::default(),
+ )
+ .await
+ .unwrap();
db.create_package(&std, &fs).await.unwrap();
db.create_package(&luca, &flag).await.unwrap();
db.create_package_version_for_test(NewPackageVersion {
diff --git a/api/src/util.rs b/api/src/util.rs
index 77ce011c..94f696ae 100644
--- a/api/src/util.rs
+++ b/api/src/util.rs
@@ -426,6 +426,7 @@ pub mod test {
use crate::errors_internal::ApiErrorStruct;
use crate::gcp::FakeGcsTester;
use crate::util::sanitize_redirect_url;
+ use crate::ids::ScopeDescription;
use crate::ApiError;
use crate::MainRouterOptions;
use hyper::http::HeaderName;
@@ -552,9 +553,15 @@ pub mod test {
let scope_name = "scope".try_into().unwrap();
- db.create_scope(&user1.user.id, false, &scope_name, user1.user.id)
- .await
- .unwrap();
+ db.create_scope(
+ &user1.user.id,
+ false,
+ &scope_name,
+ user1.user.id,
+ &ScopeDescription::default(),
+ )
+ .await
+ .unwrap();
let (scope, _, _) = db
.update_scope_limits(
&staff_user.user.id,
diff --git a/frontend/islands/new.tsx b/frontend/islands/new.tsx
index 1f3cacbb..ecd8f3d2 100644
--- a/frontend/islands/new.tsx
+++ b/frontend/islands/new.tsx
@@ -5,12 +5,16 @@ import {
useSignal,
useSignalEffect,
} from "@preact/signals";
-import { Package, Scope, User } from "../utils/api_types.ts";
-import { api, path } from "../utils/api.ts";
import { ComponentChildren } from "preact";
import twas from "twas";
+import { api, path } from "../utils/api.ts";
+import {
+ validatePackageName,
+ validateScopeDescription,
+ validateScopeName,
+} from "../utils/ids.ts";
import { TicketModal } from "./TicketModal.tsx";
-
+import type { Package, Scope, User } from "../utils/api_types.ts";
interface IconColorProps {
done: Signal;
children: ComponentChildren;
@@ -158,27 +162,24 @@ export function ScopeSelect(
function CreateScope(
props: {
initialValue: string | undefined;
- onCreate: (scope: string) => void;
+ onCreate: (scope: string, description: string) => void;
locked: boolean;
user: User;
},
) {
const newScope = useSignal(props.initialValue ?? "");
+ const description = useSignal("");
const errorCode = useSignal("");
const error = useSignal("");
const message = useComputed(() => {
if (error.value) return error.value;
- if (newScope.value.length === 0) {
- return "";
- }
- if (newScope.value.length > 20) {
- return "Scope name cannot be longer than 20 characters.";
- }
- if (!/^[a-z0-9\-]+$/.test(newScope.value)) {
- return "Scope name can only contain lowercase letters, numbers, and hyphens.";
+ const validationError = validateScopeName(newScope.value);
+ if (validationError) {
+ return validationError;
}
- if (/^-/.test(newScope.value)) {
- return "Scope name must start with a letter or number.";
+ const descriptionError = validateScopeDescription(description.value);
+ if (descriptionError) {
+ return descriptionError;
}
return "";
});
@@ -188,9 +189,10 @@ function CreateScope(
const resp = await api.post(path`/scopes`, {
scope: newScope.value,
+ description: description.value,
});
if (resp.ok) {
- props.onCreate(newScope.value);
+ props.onCreate(newScope.value, description.value);
} else {
console.error(resp);
errorCode.value = resp.code;
@@ -224,6 +226,28 @@ function CreateScope(
}}
/>
+
{errorCode.value === "scopeNameReserved" && (
@@ -295,17 +319,9 @@ export function PackageName(
const message = useComputed(() => {
if (error.value) return error.value;
if (name.value.length === 0) return "";
- if (name.value.startsWith("@")) {
- return "Enter only the package name, do not include the scope.";
- }
- if (name.value.length > 58) {
- return "Package name cannot be longer than 58 characters.";
- }
- if (!/^[a-z0-9\-]+$/.test(name.value)) {
- return "Package name can only contain lowercase letters, numbers, and hyphens.";
- }
- if (/^-/.test(name.value)) {
- return "Package name must start with a letter or number.";
+ const validationError = validatePackageName(name.value);
+ if (validationError) {
+ return validationError;
}
return "";
});
diff --git a/frontend/routes/@[scope]/(_components)/ScopeHeader.tsx b/frontend/routes/@[scope]/(_components)/ScopeHeader.tsx
index e161ed2a..3e42cc20 100644
--- a/frontend/routes/@[scope]/(_components)/ScopeHeader.tsx
+++ b/frontend/routes/@[scope]/(_components)/ScopeHeader.tsx
@@ -7,8 +7,15 @@ export interface ScopeHeaderProps {
export function ScopeHeader(props: ScopeHeaderProps) {
return (
-
- @{props.scope.scope}
-
+ <>
+
+ @{props.scope.scope}
+
+ {props.scope.description && (
+
+ {props.scope.description}
+
+ )}
+ >
);
}
diff --git a/frontend/routes/@[scope]/(_islands)/ScopeDescriptionForm.tsx b/frontend/routes/@[scope]/(_islands)/ScopeDescriptionForm.tsx
new file mode 100644
index 00000000..fe121a4e
--- /dev/null
+++ b/frontend/routes/@[scope]/(_islands)/ScopeDescriptionForm.tsx
@@ -0,0 +1,108 @@
+// Copyright 2024 the JSR authors. All rights reserved. MIT license.
+import { useSignal } from "@preact/signals";
+import { useState } from "preact/hooks";
+import { TbCheck, TbPencil, TbX } from "tb-icons";
+import { api, path } from "../../../utils/api.ts";
+import { validateScopeDescription } from "../../../utils/ids.ts";
+import type { FullScope } from "../../../utils/api_types.ts";
+
+interface ScopeDescriptionFormProps {
+ scope: FullScope;
+}
+
+export function ScopeDescriptionForm(
+ { scope: initialScope }: ScopeDescriptionFormProps,
+) {
+ const scope = useSignal(initialScope);
+ const isEditing = useSignal(false);
+ const editedDescription = useSignal(scope.value.description ?? "");
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ async function handleSave() {
+ setIsLoading(true);
+ setError(null);
+ const validationError = validateScopeDescription(
+ editedDescription.value,
+ );
+
+ if (validationError) {
+ setError(validationError);
+ setIsLoading(false);
+ return;
+ }
+
+ const resp = await api.patch(path`/scopes/${scope.value.scope}`, {
+ description: editedDescription.value,
+ });
+
+ setIsLoading(false);
+ if (resp.ok) {
+ // Update the local scope signal with the new description
+ scope.value = { ...scope.value, description: editedDescription.value };
+ isEditing.value = false;
+ } else {
+ setError(resp.message ?? "Failed to update description.");
+ console.error("Failed to save scope description:", resp);
+ }
+ }
+
+ function handleCancel() {
+ editedDescription.value = scope.value.description ?? "";
+ isEditing.value = false;
+ setError(null);
+ }
+
+ if (isEditing.value) {
+ return (
+
+
+ );
+ }
+
+ return (
+
+
+ {scope.value.description || No description provided.}
+
+
+
+ );
+}
diff --git a/frontend/routes/@[scope]/~/settings.tsx b/frontend/routes/@[scope]/~/settings.tsx
index d0715487..af98b36b 100644
--- a/frontend/routes/@[scope]/~/settings.tsx
+++ b/frontend/routes/@[scope]/~/settings.tsx
@@ -1,14 +1,15 @@
// Copyright 2024 the JSR authors. All rights reserved. MIT license.
import { HttpError } from "fresh";
import { ComponentChildren } from "preact";
+import { TbCheck, TbTrash } from "tb-icons";
import { define } from "../../../util.ts";
import { ScopeHeader } from "../(_components)/ScopeHeader.tsx";
import { ScopeNav } from "../(_components)/ScopeNav.tsx";
+import { ScopeDescriptionForm } from "../(_islands)/ScopeDescriptionForm.tsx";
import { FullScope, User } from "../../../utils/api_types.ts";
import { scopeDataWithMember } from "../../../utils/data.ts";
import { path } from "../../../utils/api.ts";
import { QuotaCard } from "../../../components/QuotaCard.tsx";
-import TbCheck from "tb-icons/TbCheck";
import { scopeIAM } from "../../../utils/iam.ts";
import { TicketModal } from "../../../islands/TicketModal.tsx";
@@ -19,6 +20,7 @@ export default define.page(function ScopeSettingsPage(
+ The description of the scope{" "}
+ @{scope.scope}:
+
+
+
+ );
+}
+
function ScopeQuotas({ scope, user }: { scope: FullScope; user: User }) {
return (
@@ -264,6 +279,7 @@ function DeleteScope({ scope }: { scope: FullScope }) {
name="action"
value="deleteScope"
>
+
Delete scope
{!isEmpty && (
diff --git a/frontend/utils/api_types.ts b/frontend/utils/api_types.ts
index fff2f25b..76310943 100644
--- a/frontend/utils/api_types.ts
+++ b/frontend/utils/api_types.ts
@@ -20,6 +20,7 @@ export interface FullUser extends User {
export interface Scope {
scope: string;
+ description: string | null;
updatedAt: string;
createdAt: string;
}
diff --git a/frontend/utils/ids.ts b/frontend/utils/ids.ts
new file mode 100644
index 00000000..134ef45f
--- /dev/null
+++ b/frontend/utils/ids.ts
@@ -0,0 +1,42 @@
+// Copyright 2024 the JSR authors. All rights reserved. MIT license.
+export const validateScopeName = (name: string) => {
+ if (name.length > 100) {
+ return "Name must be less than 100 characters.";
+ }
+ if (name.length < 3) {
+ return "Name must be at least 3 characters.";
+ }
+ if (!/^[a-zA-Z0-9-_]+$/.test(name)) {
+ return "Name can only contain letters, numbers, dashes, and underscores.";
+ }
+ return null;
+};
+
+export const validateScopeDescription = (description: string) => {
+ if (description.length > 200) {
+ return "Description must be less than 200 characters.";
+ }
+
+ if (description !== "" && description.length < 5) {
+ return "Description must be at least 5 characters. If you don't want to add a description, please leave it blank.";
+ }
+
+ return null;
+};
+
+export const validatePackageName = (name: string) => {
+ if (name.startsWith("@")) {
+ return "Enter only the package name, do not include the scope.";
+ }
+ if (name.length > 58) {
+ return "Package name cannot be longer than 58 characters.";
+ }
+ if (!/^[a-z0-9\-]+$/.test(name)) {
+ return "Package name can only contain lowercase letters, numbers, and hyphens.";
+ }
+ if (/^-/.test(name)) {
+ return "Package name must start with a letter or number.";
+ }
+
+ return null;
+};