From d8407f5ab5b0b45bbee3e45cd6061ef5f24c4256 Mon Sep 17 00:00:00 2001 From: Artur Jurat Date: Tue, 30 Jun 2026 19:53:09 +0200 Subject: [PATCH] feat(backport): Add entitlement feature code --- Cargo.lock | 24 +++++++++ Cargo.toml | 1 + modules/meteroid/Cargo.toml | 1 + .../crates/diesel-models/src/entitlements.rs | 3 ++ .../diesel-models/src/query/entitlements.rs | 38 ++++++++++++- .../crates/diesel-models/src/schema.rs | 1 + .../meteroid-store/src/domain/entitlements.rs | 6 +++ .../src/repositories/entitlements.rs | 41 +++++++++++++- .../src/services/entitlements.rs | 2 + .../2026-06-30-120000_feature_code/down.sql | 8 +++ .../2026-06-30-120000_feature_code/up.sql | 9 ++++ .../api/entitlements/v1/entitlements.proto | 1 + .../proto/api/entitlements/v1/models.proto | 2 + .../meteroid/src/api/entitlements/mapping.rs | 3 ++ .../meteroid/src/api/entitlements/service.rs | 54 +++++++++++++++++++ .../src/api_rest/entitlements/mapping.rs | 3 ++ .../src/api_rest/entitlements/model.rs | 4 ++ .../src/api_rest/entitlements/router.rs | 8 +-- .../tests/integration/test_entitlements.rs | 23 ++++++++ .../meteroid/tests/integration/test_quote.rs | 2 + .../entitlements/EntitlementDialog.tsx | 49 ++++++++++++++++- .../entitlements/EntityEntitlementDialog.tsx | 2 + .../creation/EntitlementSpecDialog.tsx | 2 + .../creation/resolveEntitlementSpecs.ts | 3 ++ .../features/entitlements/creation/types.ts | 1 + .../features/FeatureCreateSheet.tsx | 51 +++++++++++++++++- .../features/FeatureDetailSheet.tsx | 7 +++ .../web-app/features/entitlements/utils.ts | 13 +++++ .../pages/tenants/catalog/features.tsx | 1 + spec/api/v1/openapi.json | 20 +++++-- 30 files changed, 369 insertions(+), 14 deletions(-) create mode 100644 modules/meteroid/migrations/diesel/2026-06-30-120000_feature_code/down.sql create mode 100644 modules/meteroid/migrations/diesel/2026-06-30-120000_feature_code/up.sql diff --git a/Cargo.lock b/Cargo.lock index 19dee9823..e8fddbf09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4734,6 +4734,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "lazy-regex" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -5097,6 +5120,7 @@ dependencies = [ "itertools", "jsonwebtoken", "kafka", + "lazy-regex", "log", "metering", "metering-grpc", diff --git a/Cargo.toml b/Cargo.toml index c2b9a9571..fbd8907c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,6 +132,7 @@ prost-build = "0.14.1" prost-types = "0.14.1" quick_cache = "0.6.2" quote = "1.0.35" +lazy-regex = "3" rand = "0.10.0" rand_chacha = "0.10.0" rand_distr = "0.6.0" diff --git a/modules/meteroid/Cargo.toml b/modules/meteroid/Cargo.toml index 9bc4f6185..9d6e2565a 100644 --- a/modules/meteroid/Cargo.toml +++ b/modules/meteroid/Cargo.toml @@ -87,6 +87,7 @@ meteroid-store.workspace = true meteroid-invoicing.workspace = true meteroid-mailer.workspace = true rand.workspace = true +lazy-regex.workspace = true rusty-money.workspace = true meteroid-seeder = { workspace = true } hubspot-client = { workspace = true } diff --git a/modules/meteroid/crates/diesel-models/src/entitlements.rs b/modules/meteroid/crates/diesel-models/src/entitlements.rs index 52da8499b..ed86fd27a 100644 --- a/modules/meteroid/crates/diesel-models/src/entitlements.rs +++ b/modules/meteroid/crates/diesel-models/src/entitlements.rs @@ -33,6 +33,7 @@ pub struct FeatureRow { pub tenant_id: TenantId, pub product_id: Option, pub name: String, + pub code: String, pub description: Option, pub feature_type: FeatureTypeEnum, pub status: FeatureStatusEnum, @@ -49,6 +50,7 @@ pub struct FeatureRowNew { pub tenant_id: TenantId, pub product_id: Option, pub name: String, + pub code: String, pub description: Option, pub feature_type: FeatureTypeEnum, pub status: FeatureStatusEnum, @@ -59,6 +61,7 @@ pub struct FeatureRowNew { #[diesel(table_name = crate::schema::feature)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct FeatureRowPatch { + // `code` intentionally omitted — it is an immutable addressing key. pub name: Option, pub description: Option>, pub product_id: Option>, diff --git a/modules/meteroid/crates/diesel-models/src/query/entitlements.rs b/modules/meteroid/crates/diesel-models/src/query/entitlements.rs index f136e309f..19a789bc8 100644 --- a/modules/meteroid/crates/diesel-models/src/query/entitlements.rs +++ b/modules/meteroid/crates/diesel-models/src/query/entitlements.rs @@ -7,7 +7,8 @@ use crate::errors::IntoDbResult; use crate::extend::pagination::{Paginate, PaginatedVec, PaginationRequest}; use crate::{DbResult, PgConn}; use common_domain::ids::{ - BaseId, EntitlementEntityId, EntitlementId, FeatureId, PlanVersionId, ProductId, TenantId, + AliasOr, BaseId, EntitlementEntityId, EntitlementId, FeatureId, PlanVersionId, ProductId, + TenantId, }; use diesel::{ BoolExpressionMethods, ExpressionMethods, Insertable, IntoSql, NullableExpressionMethods, @@ -63,6 +64,41 @@ impl FeatureRow { }) } + pub async fn find_by_id_or_code( + conn: &mut PgConn, + id_or_code: AliasOr, + param_tenant_id: TenantId, + ) -> DbResult { + use crate::schema::feature::dsl as f_dsl; + use crate::schema::product; + use diesel_async::RunQueryDsl; + + let mut query = f_dsl::feature + .left_join(product::table) + .filter(f_dsl::tenant_id.eq(param_tenant_id)) + .select(( + FeatureRow::as_select(), + (product::id, product::name).nullable(), + )) + .into_boxed(); + + match id_or_code { + AliasOr::Id(id) => query = query.filter(f_dsl::id.eq(id)), + AliasOr::Alias(code) => query = query.filter(f_dsl::code.eq(code)), + } + log::debug!("{}", debug_query::(&query)); + + let (feature, product_opt): (FeatureRow, Option<(ProductId, String)>) = query + .first(conn) + .await + .attach("Error while finding feature by id or code") + .into_db_result()?; + Ok(FeatureWithProductRow { + feature, + product: product_opt.map(|(id, name)| FeatureProductMeta { id, name }), + }) + } + pub async fn list( conn: &mut PgConn, param_tenant_id: TenantId, diff --git a/modules/meteroid/crates/diesel-models/src/schema.rs b/modules/meteroid/crates/diesel-models/src/schema.rs index 5651d0261..9d4b0df8e 100644 --- a/modules/meteroid/crates/diesel-models/src/schema.rs +++ b/modules/meteroid/crates/diesel-models/src/schema.rs @@ -663,6 +663,7 @@ diesel::table! { tenant_id -> Uuid, product_id -> Nullable, name -> Text, + code -> Text, description -> Nullable, feature_type -> FeatureTypeEnum, status -> FeatureStatusEnum, diff --git a/modules/meteroid/crates/meteroid-store/src/domain/entitlements.rs b/modules/meteroid/crates/meteroid-store/src/domain/entitlements.rs index 21badc20c..136d62fe9 100644 --- a/modules/meteroid/crates/meteroid-store/src/domain/entitlements.rs +++ b/modules/meteroid/crates/meteroid-store/src/domain/entitlements.rs @@ -81,6 +81,7 @@ pub struct Feature { /// Product this feature belongs to. `None` for tenant-global features. pub product: Option, pub name: String, + pub code: String, pub description: Option, pub feature_type: FeatureType, pub status: FeatureStatusEnum, @@ -112,6 +113,7 @@ impl TryFrom for Feature { name: p.name, }), name: feature.name, + code: feature.code, description: feature.description, feature_type, status: feature.status.into(), @@ -127,6 +129,7 @@ pub struct FeatureNew { pub tenant_id: TenantId, pub product_id: Option, pub name: String, + pub code: String, pub description: Option, pub feature_type: FeatureType, pub entitlement: Option, @@ -138,6 +141,7 @@ impl From for FeatureRowNew { tenant_id, product_id, name, + code, description, feature_type, entitlement: _, @@ -151,6 +155,7 @@ impl From for FeatureRowNew { tenant_id, product_id, name, + code, description, feature_type: feature_type_enum, status: DbFeatureStatusEnum::Active, @@ -299,6 +304,7 @@ pub struct FeatureProductRef { pub struct FeatureRef { pub id: FeatureId, pub name: String, + pub code: String, pub product: Option, } diff --git a/modules/meteroid/crates/meteroid-store/src/repositories/entitlements.rs b/modules/meteroid/crates/meteroid-store/src/repositories/entitlements.rs index 14cf042d7..c8156d4ac 100644 --- a/modules/meteroid/crates/meteroid-store/src/repositories/entitlements.rs +++ b/modules/meteroid/crates/meteroid-store/src/repositories/entitlements.rs @@ -12,7 +12,7 @@ use crate::store::PgConn; use crate::{Store, StoreResult}; use chrono::{DateTime, Datelike, Days, Duration, Months, NaiveDate, NaiveDateTime, NaiveTime}; use common_domain::ids::{ - AddOnId, BaseId, CustomerId, EntitlementEntityId, EntitlementId, FeatureId, PlanId, + AddOnId, AliasOr, BaseId, CustomerId, EntitlementEntityId, EntitlementId, FeatureId, PlanId, PlanVersionId, ProductId, QuoteId, SubscriptionId, TenantId, }; use diesel_models::add_ons::AddOnRow; @@ -60,6 +60,12 @@ pub trait EntitlementsInterface { async fn get_feature(&self, id: FeatureId, tenant_id: TenantId) -> StoreResult; + async fn get_feature_by_id_or_code( + &self, + id_or_code: AliasOr, + tenant_id: TenantId, + ) -> StoreResult; + async fn list_features( &self, tenant_id: TenantId, @@ -252,6 +258,37 @@ impl EntitlementsInterface for Store { Ok(feature) } + async fn get_feature_by_id_or_code( + &self, + id_or_code: AliasOr, + tenant_id: TenantId, + ) -> StoreResult { + let mut conn = self.get_conn().await?; + + let row = FeatureRow::find_by_id_or_code(&mut conn, id_or_code, tenant_id) + .await + .map_err(Into::>::into)?; + + let feature_id = row.feature.id; + let mut feature: Feature = row.try_into()?; + + let entitlement_rows = EntitlementRow::list_by_entity( + &mut conn, + tenant_id, + EntitlementEntityId::Feature(feature_id), + ) + .await + .map_err(Into::>::into)?; + + feature.entitlement = entitlement_rows + .into_iter() + .next() + .map(TryInto::try_into) + .transpose()?; + + Ok(feature) + } + async fn list_features( &self, tenant_id: TenantId, @@ -1511,6 +1548,7 @@ fn resolve( feature: FeatureRef { id: feature.id, name: feature.name.clone(), + code: feature.code.clone(), product: feature.product.clone(), }, value: resolved_value, @@ -1733,6 +1771,7 @@ mod tests { tenant_id: TenantId::new(), product: None, name: id.to_string(), + code: id.to_string(), description: None, feature_type, status: FeatureStatusEnum::Active, diff --git a/modules/meteroid/crates/meteroid-store/src/services/entitlements.rs b/modules/meteroid/crates/meteroid-store/src/services/entitlements.rs index 8e4ce57e1..3433f5a31 100644 --- a/modules/meteroid/crates/meteroid-store/src/services/entitlements.rs +++ b/modules/meteroid/crates/meteroid-store/src/services/entitlements.rs @@ -381,6 +381,7 @@ mod tests { feature: FeatureRef { id: FeatureId::new(), name: "test".to_string(), + code: "test".to_string(), product: None, }, origin: ResolvedOrigin { @@ -506,6 +507,7 @@ mod tests { let feature = FeatureRef { id: FeatureId::new(), name: "deleted-metric".to_string(), + code: "deleted-metric".to_string(), product: None, }; let origin = ResolvedOrigin { diff --git a/modules/meteroid/migrations/diesel/2026-06-30-120000_feature_code/down.sql b/modules/meteroid/migrations/diesel/2026-06-30-120000_feature_code/down.sql new file mode 100644 index 000000000..4499721ac --- /dev/null +++ b/modules/meteroid/migrations/diesel/2026-06-30-120000_feature_code/down.sql @@ -0,0 +1,8 @@ +ALTER TABLE feature + DROP CONSTRAINT IF EXISTS feature_tenant_code_key; + +ALTER TABLE feature + DROP COLUMN IF EXISTS code; + +ALTER TABLE feature + ADD CONSTRAINT feature_tenant_id_name_key UNIQUE (tenant_id, name); diff --git a/modules/meteroid/migrations/diesel/2026-06-30-120000_feature_code/up.sql b/modules/meteroid/migrations/diesel/2026-06-30-120000_feature_code/up.sql new file mode 100644 index 000000000..03ae205ad --- /dev/null +++ b/modules/meteroid/migrations/diesel/2026-06-30-120000_feature_code/up.sql @@ -0,0 +1,9 @@ +ALTER TABLE feature ADD COLUMN code TEXT; + +UPDATE feature SET code = id::text WHERE code IS NULL; + +ALTER TABLE feature ALTER COLUMN code SET NOT NULL; + +ALTER TABLE feature ADD CONSTRAINT feature_tenant_code_key UNIQUE (tenant_id, code); + +ALTER TABLE feature DROP CONSTRAINT IF EXISTS feature_tenant_id_name_key; diff --git a/modules/meteroid/proto/api/entitlements/v1/entitlements.proto b/modules/meteroid/proto/api/entitlements/v1/entitlements.proto index 603da37db..5ca63089c 100644 --- a/modules/meteroid/proto/api/entitlements/v1/entitlements.proto +++ b/modules/meteroid/proto/api/entitlements/v1/entitlements.proto @@ -12,6 +12,7 @@ message CreateFeatureRequest { // Product the feature belongs to. Unset ⇒ tenant-global feature. optional string product_id = 4; optional EntitlementValue entitlement = 5; + string code = 6; } message CreateFeatureResponse { diff --git a/modules/meteroid/proto/api/entitlements/v1/models.proto b/modules/meteroid/proto/api/entitlements/v1/models.proto index 10e9f85bf..ecdc269ea 100644 --- a/modules/meteroid/proto/api/entitlements/v1/models.proto +++ b/modules/meteroid/proto/api/entitlements/v1/models.proto @@ -68,6 +68,7 @@ message FeatureRef { string id = 1; string name = 2; optional ProductRef product = 3; + string code = 4; } message Feature { @@ -79,6 +80,7 @@ message Feature { // Product the feature belongs to. Unset ⇒ tenant-global feature. optional ProductRef product = 6; string created_at = 7; + string code = 8; } message EntitlementEntity { diff --git a/modules/meteroid/src/api/entitlements/mapping.rs b/modules/meteroid/src/api/entitlements/mapping.rs index c71ebc6fe..4a37c5788 100644 --- a/modules/meteroid/src/api/entitlements/mapping.rs +++ b/modules/meteroid/src/api/entitlements/mapping.rs @@ -217,6 +217,7 @@ pub fn feature_to_proto(f: Feature) -> proto::Feature { status: feature_status_to_proto(f.status), product, created_at: f.created_at.as_proto(), + code: f.code, } } @@ -280,6 +281,7 @@ pub fn effective_entitlement_to_proto(r: EffectiveEntitlement) -> proto::Effecti feature: Some(proto::FeatureRef { id: r.feature.id.as_proto(), name: r.feature.name, + code: r.feature.code, product: r.feature.product.map(|p| proto::ProductRef { id: p.id.as_proto(), name: p.name, @@ -314,6 +316,7 @@ pub fn resolved_entitlement_to_proto(r: ResolvedEntitlement) -> proto::ResolvedE feature: Some(proto::FeatureRef { id: r.feature.id.as_proto(), name: r.feature.name, + code: r.feature.code, product: r.feature.product.map(|p| proto::ProductRef { id: p.id.as_proto(), name: p.name, diff --git a/modules/meteroid/src/api/entitlements/service.rs b/modules/meteroid/src/api/entitlements/service.rs index 5d9890de0..c057b88cb 100644 --- a/modules/meteroid/src/api/entitlements/service.rs +++ b/modules/meteroid/src/api/entitlements/service.rs @@ -40,6 +40,8 @@ impl EntitlementsService for EntitlementsComponents { let feature_type = mapping::feature_type_from_proto(inner.feature_type)?; let product_id = mapping::product_id_from_proto(inner.product_id)?; + let code = validate_feature_code(&inner.code)?; + let entitlement = inner .entitlement .map(|v| mapping::entitlement_value_from_proto(Some(v))) @@ -51,6 +53,7 @@ impl EntitlementsService for EntitlementsComponents { tenant_id, product_id, name: inner.name, + code, description: inner.description, feature_type, entitlement, @@ -596,3 +599,54 @@ impl EntitlementsService for EntitlementsComponents { })) } } + +/// Mirrors FE regex `^[a-z0-9][a-z0-9_-]*$` with an additional 128-char cap. +fn validate_feature_code(code: &str) -> Result { + let code = code.trim(); + if code.len() > 128 { + return Err(Status::invalid_argument(format!( + "invalid feature code: too long ({} > 128 chars)", + code.len() + ))); + } + if !lazy_regex::regex_is_match!(r"^[a-z0-9][a-z0-9_-]*$", code) { + return Err(Status::invalid_argument( + "invalid feature code: must start with a lowercase letter or digit, then only lowercase letters, digits, '-', '_'", + )); + } + Ok(code.to_string()) +} + +#[cfg(test)] +mod tests { + use super::validate_feature_code; + + #[test] + fn accepts_valid_slugs() { + for c in ["audit_log", "mfa_policy", "sso", "a1-b2_c3"] { + assert!(validate_feature_code(c).is_ok(), "{c} should be valid"); + } + } + + #[test] + fn trims_then_validates() { + assert_eq!(validate_feature_code(" audit_log ").unwrap(), "audit_log"); + } + + #[test] + fn rejects_invalid_slugs() { + for c in [ + "", + " ", + "Audit_Log", + "audit log", + "audit'log", + "feature.v2", + "-leading", + "_leading", + "über", + ] { + assert!(validate_feature_code(c).is_err(), "{c} should be invalid"); + } + } +} diff --git a/modules/meteroid/src/api_rest/entitlements/mapping.rs b/modules/meteroid/src/api_rest/entitlements/mapping.rs index 4bc233a60..54002e704 100644 --- a/modules/meteroid/src/api_rest/entitlements/mapping.rs +++ b/modules/meteroid/src/api_rest/entitlements/mapping.rs @@ -6,6 +6,7 @@ pub fn feature_to_rest(f: domain::Feature) -> Feature { Feature { id: f.id, name: f.name, + code: f.code, description: f.description, feature_type: f.feature_type.into(), status: f.status.into(), @@ -71,6 +72,7 @@ pub fn resolved_entitlement_to_rest(r: domain::ResolvedEntitlement) -> ResolvedE feature: FeatureRef { id: r.feature.id, name: r.feature.name, + code: r.feature.code, product: r.feature.product.map(|p| ProductRef { id: p.id, name: p.name, @@ -111,6 +113,7 @@ pub fn effective_entitlement_to_rest(r: domain::EffectiveEntitlement) -> Effecti feature: FeatureRef { id: r.feature.id, name: r.feature.name, + code: r.feature.code, product: r.feature.product.map(|p| ProductRef { id: p.id, name: p.name, diff --git a/modules/meteroid/src/api_rest/entitlements/model.rs b/modules/meteroid/src/api_rest/entitlements/model.rs index 4428e9a54..5a5a26025 100644 --- a/modules/meteroid/src/api_rest/entitlements/model.rs +++ b/modules/meteroid/src/api_rest/entitlements/model.rs @@ -265,6 +265,8 @@ pub struct Feature { #[serde(serialize_with = "string_serde::serialize")] pub id: FeatureId, pub name: String, + /// Unique key used to reference this feature in your code. Cannot be changed after creation. + pub code: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub feature_type: FeatureType, @@ -308,6 +310,8 @@ pub struct FeatureRef { #[serde(serialize_with = "string_serde::serialize")] pub id: FeatureId, pub name: String, + /// Unique key used to reference this feature in your code. Cannot be changed after creation. + pub code: String, #[serde(skip_serializing_if = "Option::is_none")] pub product: Option, } diff --git a/modules/meteroid/src/api_rest/entitlements/router.rs b/modules/meteroid/src/api_rest/entitlements/router.rs index 69ddb1785..c8126c900 100644 --- a/modules/meteroid/src/api_rest/entitlements/router.rs +++ b/modules/meteroid/src/api_rest/entitlements/router.rs @@ -88,8 +88,8 @@ pub(crate) async fn list_features( #[utoipa::path( get, tag = "Features", - path = "/api/v1/features/{feature_id}", - params(("feature_id" = FeatureId, Path, description = "Feature ID")), + path = "/api/v1/features/{id_or_code}", + params(("id_or_code" = String, Path, description = "Feature ID or code")), responses( (status = 200, description = "Feature details", body = Feature), (status = 401, description = "Unauthorized", body = RestErrorResponse), @@ -100,12 +100,12 @@ pub(crate) async fn list_features( #[axum::debug_handler] pub(crate) async fn get_feature( Extension(authorized_state): Extension, - Path(feature_id): Path, + Valid(Path(id_or_code)): Valid>>, State(app_state): State, ) -> Result { let feature = app_state .store - .get_feature(feature_id, authorized_state.tenant_id) + .get_feature_by_id_or_code(id_or_code, authorized_state.tenant_id) .await .map_err(|e| { log::error!("Error fetching feature: {e}"); diff --git a/modules/meteroid/tests/integration/test_entitlements.rs b/modules/meteroid/tests/integration/test_entitlements.rs index 5a8091be8..064325ed2 100644 --- a/modules/meteroid/tests/integration/test_entitlements.rs +++ b/modules/meteroid/tests/integration/test_entitlements.rs @@ -35,6 +35,7 @@ async fn test_features_crud() { .clone() .create_feature(CreateFeatureRequest { name: "api-access".into(), + code: "api-access".into(), description: Some("API access feature".into()), product_id: None, feature_type: Some(FeatureType { @@ -243,6 +244,7 @@ async fn test_entitlements_crud() { .clone() .create_feature(CreateFeatureRequest { name: "seats".into(), + code: "seats".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -426,6 +428,7 @@ async fn test_get_effective_entitlements() { .clone() .create_feature(CreateFeatureRequest { name: "api-calls".into(), + code: "api-calls".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -535,6 +538,7 @@ async fn test_create_feature_with_entitlement() { .clone() .create_feature(CreateFeatureRequest { name: "bulk-export".into(), + code: "bulk-export".into(), description: Some("Bulk export access".into()), product_id: None, feature_type: Some(FeatureType { @@ -688,6 +692,7 @@ async fn test_kill_switch_suppresses_feature_resolution() { .clone() .create_feature(CreateFeatureRequest { name: "premium-export".into(), + code: "premium-export".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -806,6 +811,7 @@ async fn test_archived_feature_excluded_from_resolution() { .clone() .create_feature(CreateFeatureRequest { name: "dormant-feat".into(), + code: "dormant-feat".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -958,6 +964,7 @@ async fn test_subscription_override_beats_plan_grant() { .clone() .create_feature(CreateFeatureRequest { name: "api-quota".into(), + code: "api-quota".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -1072,6 +1079,7 @@ async fn test_feature_baseline_entitlement_lowest_priority() { .clone() .create_feature(CreateFeatureRequest { name: "everyone-default".into(), + code: "everyone-default".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -1140,6 +1148,7 @@ async fn test_unique_feature_entity_collision_rejected() { .clone() .create_feature(CreateFeatureRequest { name: "unique-test".into(), + code: "unique-test".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -1205,6 +1214,7 @@ async fn test_value_variant_must_match_feature_type() { .clone() .create_feature(CreateFeatureRequest { name: "bool-feat".into(), + code: "bool-feat".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -1238,6 +1248,7 @@ async fn test_value_variant_must_match_feature_type() { .clone() .create_feature(CreateFeatureRequest { name: "metered-feat".into(), + code: "metered-feat".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -1294,6 +1305,7 @@ async fn test_inline_subscription_entitlement_creation() { .clone() .create_feature(CreateFeatureRequest { name: "inline-bool-feature".into(), + code: "inline-bool-feature".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -1442,6 +1454,7 @@ async fn test_disabled_entitlement_resolved_and_overrideable() { .clone() .create_feature(CreateFeatureRequest { name: "disabled-test-feat".into(), + code: "disabled-test-feat".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -1576,6 +1589,7 @@ async fn test_resolved_entitlements_for_subscription_includes_chain() { .clone() .create_feature(CreateFeatureRequest { name: "chain-test-bool".into(), + code: "chain-test-bool".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -1805,6 +1819,7 @@ async fn test_resolved_entitlements_for_addon_includes_product_features() { .clone() .create_feature(CreateFeatureRequest { name: "addon-product-bool-f1".into(), + code: "addon-product-bool-f1".into(), description: None, product_id: Some(product_id.clone()), feature_type: Some(FeatureType { @@ -1842,6 +1857,7 @@ async fn test_resolved_entitlements_for_addon_includes_product_features() { .clone() .create_feature(CreateFeatureRequest { name: "addon-product-metered-f2".into(), + code: "addon-product-metered-f2".into(), description: None, product_id: Some(product_id.clone()), feature_type: Some(FeatureType { @@ -1974,6 +1990,7 @@ async fn test_batch_create_entitlements_skips_existing() { .clone() .create_feature(CreateFeatureRequest { name: format!("batch-skip-feat-{}", i), + code: format!("batch-skip-feat-{}", i), description: None, product_id: None, feature_type: Some(FeatureType { @@ -2171,6 +2188,7 @@ async fn test_product_grouping_in_effective_entitlements() { .clone() .create_feature(CreateFeatureRequest { name: "product-feat-a".into(), + code: "product-feat-a".into(), description: None, product_id: Some(ids::PRODUCT_PLATFORM_FEE_ID.as_proto()), feature_type: Some(FeatureType { @@ -2192,6 +2210,7 @@ async fn test_product_grouping_in_effective_entitlements() { .clone() .create_feature(CreateFeatureRequest { name: "product-feat-b".into(), + code: "product-feat-b".into(), description: None, product_id: Some(ids::PRODUCT_SEATS_ID.as_proto()), feature_type: Some(FeatureType { @@ -2213,6 +2232,7 @@ async fn test_product_grouping_in_effective_entitlements() { .clone() .create_feature(CreateFeatureRequest { name: "global-feat-c".into(), + code: "global-feat-c".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -2392,6 +2412,7 @@ async fn test_plan_version_overrides_addon_entitlement() { .clone() .create_feature(CreateFeatureRequest { name: "pv-beats-addon".into(), + code: "pv-beats-addon".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -2540,6 +2561,7 @@ async fn test_same_priority_addons_take_permissive_max() { .clone() .create_feature(CreateFeatureRequest { name: "permissive-same-pri".into(), + code: "permissive-same-pri".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -2693,6 +2715,7 @@ async fn test_plan_version_target_excludes_linked_add_on_entitlements() { .clone() .create_feature(CreateFeatureRequest { name: "pv-linked-addon-feat".into(), + code: "pv-linked-addon-feat".into(), description: None, product_id: Some(add_on.product_id.clone()), feature_type: Some(FeatureType { diff --git a/modules/meteroid/tests/integration/test_quote.rs b/modules/meteroid/tests/integration/test_quote.rs index cf654d0c6..68667d9c8 100644 --- a/modules/meteroid/tests/integration/test_quote.rs +++ b/modules/meteroid/tests/integration/test_quote.rs @@ -377,6 +377,7 @@ async fn test_quote_with_inline_entitlements() { .clone() .create_feature(CreateFeatureRequest { name: "quote-bool-feature".into(), + code: "quote-bool-feature".into(), description: None, product_id: None, feature_type: Some(FeatureType { @@ -511,6 +512,7 @@ async fn test_quote_conversion_carries_entitlements() { .clone() .create_feature(CreateFeatureRequest { name: "conv-bool-feature".into(), + code: "conv-bool-feature".into(), description: None, product_id: None, feature_type: Some(FeatureType { diff --git a/modules/web/web-app/features/entitlements/EntitlementDialog.tsx b/modules/web/web-app/features/entitlements/EntitlementDialog.tsx index 154c06bca..3cd2d9c70 100644 --- a/modules/web/web-app/features/entitlements/EntitlementDialog.tsx +++ b/modules/web/web-app/features/entitlements/EntitlementDialog.tsx @@ -38,6 +38,13 @@ import { RESET_PERIOD_TYPES, type ResetPeriodType, } from '@/features/entitlements/creation/types' +import { + FEATURE_CODE_CHARSET_MESSAGE, + FEATURE_CODE_LENGTH_MESSAGE, + FEATURE_CODE_MAX_LENGTH, + FEATURE_CODE_REGEX, + slugifyCode, +} from '@/features/entitlements/utils' import { useDebounceValue } from '@/hooks/useDebounce' import { useZodForm } from '@/hooks/useZodForm' import { useQuery } from '@/lib/connectrpc' @@ -50,6 +57,7 @@ import { CalendarUnit, FeatureStatus } from '@/rpc/api/entitlements/v1/models_pb export type EntitlementFormValues = { featureId?: string featureName?: string + featureCode?: string featureDescription?: string featureType: 'boolean' | 'metered' metricId?: string @@ -82,6 +90,7 @@ const schema = z .object({ featureId: z.string().optional(), featureName: z.string().optional(), + featureCode: z.string().optional(), featureDescription: z.string().optional(), featureType: z.enum(['boolean', 'metered']), metricId: z.string().optional(), @@ -96,6 +105,16 @@ const schema = z message: 'Metric is required for metered features', path: ['metricId'], }) + // Code is only collected when creating a new feature (featureName set). Mirror the + // feature catalog's charset and length rules so the backend never rejects it. + .refine(d => !d.featureName || (d.featureCode ?? '').length <= FEATURE_CODE_MAX_LENGTH, { + message: FEATURE_CODE_LENGTH_MESSAGE, + path: ['featureCode'], + }) + .refine(d => !d.featureName || FEATURE_CODE_REGEX.test(d.featureCode ?? ''), { + message: FEATURE_CODE_CHARSET_MESSAGE, + path: ['featureCode'], + }) const defaultValues: Partial = { featureType: 'boolean', @@ -259,7 +278,35 @@ export function EntitlementDialog({ Feature name * - + { + field.onChange(e) + // Auto-fill the code from the name until the user edits it. + if (!form.formState.dirtyFields.featureCode) { + form.setValue('featureCode', slugifyCode(e.target.value), { + shouldValidate: true, + }) + } + }} + /> + + + + )} + /> + ( + + + Code *{' '} + (stable identifier) + + + diff --git a/modules/web/web-app/features/entitlements/EntityEntitlementDialog.tsx b/modules/web/web-app/features/entitlements/EntityEntitlementDialog.tsx index fdef1f5b4..ae2927387 100644 --- a/modules/web/web-app/features/entitlements/EntityEntitlementDialog.tsx +++ b/modules/web/web-app/features/entitlements/EntityEntitlementDialog.tsx @@ -7,6 +7,7 @@ import { EntitlementDialog, EntitlementFormValues, } from '@/features/entitlements/EntitlementDialog' +import { slugifyCode } from '@/features/entitlements/utils' import { useQuery } from '@/lib/connectrpc' import { createEntitlement, @@ -194,6 +195,7 @@ export const EntityEntitlementDialog = ({ } else { const created = await createFeatureMutation.mutateAsync({ name: data.featureName!, + code: data.featureCode || slugifyCode(data.featureName!), description: data.featureDescription || undefined, featureType: data.featureType === 'boolean' diff --git a/modules/web/web-app/features/entitlements/creation/EntitlementSpecDialog.tsx b/modules/web/web-app/features/entitlements/creation/EntitlementSpecDialog.tsx index 15556d55e..4d41e44bd 100644 --- a/modules/web/web-app/features/entitlements/creation/EntitlementSpecDialog.tsx +++ b/modules/web/web-app/features/entitlements/creation/EntitlementSpecDialog.tsx @@ -40,6 +40,7 @@ export function EntitlementSpecDialog({ open, onOpenChange, onAdd, initialSpec, ? { featureId: initialSpec.featureId, featureName: initialSpec.featureName, + featureCode: initialSpec.featureCode, featureType: initialSpec.featureType, metricId: initialSpec.metricId, boolEnabled: initialSpec.boolEnabled, @@ -59,6 +60,7 @@ export function EntitlementSpecDialog({ open, onOpenChange, onAdd, initialSpec, onAdd({ featureId: data.featureId, featureName: data.featureName, + featureCode: data.featureCode, featureDisplayName, featureType: data.featureType, metricId: data.metricId, diff --git a/modules/web/web-app/features/entitlements/creation/resolveEntitlementSpecs.ts b/modules/web/web-app/features/entitlements/creation/resolveEntitlementSpecs.ts index 7475d1a8e..ed880c428 100644 --- a/modules/web/web-app/features/entitlements/creation/resolveEntitlementSpecs.ts +++ b/modules/web/web-app/features/entitlements/creation/resolveEntitlementSpecs.ts @@ -3,6 +3,8 @@ import { type MessageInitShape } from '@bufbuild/protobuf' import { CreateFeatureRequestSchema, type CreateFeatureResponse } from '@/rpc/api/entitlements/v1/entitlements_pb' import { type EntitlementSpec } from '@/rpc/api/entitlements/v1/models_pb' +import { slugifyCode } from '../utils' + import { type PendingEntitlementSpec, pendingSpecToEntitlementSpec } from './types' // Resolves pending specs into proto EntitlementSpec[]. @@ -28,6 +30,7 @@ export async function resolveEntitlementSpecs( const res = await createFeature({ name: spec.featureName, + code: spec.featureCode || slugifyCode(spec.featureName), featureType: spec.featureType === 'boolean' ? { Inner: { case: 'boolean', value: {} } } diff --git a/modules/web/web-app/features/entitlements/creation/types.ts b/modules/web/web-app/features/entitlements/creation/types.ts index 4850cad04..de02326fb 100644 --- a/modules/web/web-app/features/entitlements/creation/types.ts +++ b/modules/web/web-app/features/entitlements/creation/types.ts @@ -32,6 +32,7 @@ export type PendingEntitlementSpec = { // exactly one of featureId (existing) or featureName (new) must be set featureId?: string featureName?: string + featureCode?: string // stable code for a new feature; only set alongside featureName featureDisplayName: string // shown in the pending list; equals featureName for new features featureType: 'boolean' | 'metered' metricId?: string // only set when featureName is set and featureType === 'metered' diff --git a/modules/web/web-app/features/entitlements/features/FeatureCreateSheet.tsx b/modules/web/web-app/features/entitlements/features/FeatureCreateSheet.tsx index efbbf9cd8..5384b7989 100644 --- a/modules/web/web-app/features/entitlements/features/FeatureCreateSheet.tsx +++ b/modules/web/web-app/features/entitlements/features/FeatureCreateSheet.tsx @@ -36,7 +36,14 @@ import { z } from 'zod' import { EntityEntitlementsSection } from '@/features/entitlements/EntityEntitlementsSection' import { EntitlementValueFields } from '@/features/entitlements/creation/EntitlementValueFields' -import { FeatureKind } from '@/features/entitlements/utils' +import { + FEATURE_CODE_CHARSET_MESSAGE, + FEATURE_CODE_LENGTH_MESSAGE, + FEATURE_CODE_MAX_LENGTH, + FEATURE_CODE_REGEX, + FeatureKind, + slugifyCode, +} from '@/features/entitlements/utils' import { useZodForm } from '@/hooks/useZodForm' import { useQuery } from '@/lib/connectrpc' import { listBillableMetrics } from '@/rpc/api/billablemetrics/v1/billablemetrics-BillableMetricsService_connectquery' @@ -51,6 +58,11 @@ import { listProducts } from '@/rpc/api/products/v1/products-ProductsService_con const schema = z .object({ name: z.string().min(1, 'Required'), + code: z + .string() + .min(1, 'Required') + .max(FEATURE_CODE_MAX_LENGTH, FEATURE_CODE_LENGTH_MESSAGE) + .regex(FEATURE_CODE_REGEX, FEATURE_CODE_CHARSET_MESSAGE), description: z.string().optional(), productId: z.string().optional(), type: z.enum(['boolean', 'metered']), @@ -74,6 +86,7 @@ type FormData = z.infer interface Props { featureId?: string initialName?: string + initialCode?: string initialDescription?: string initialProductId?: string initialKind?: FeatureKind @@ -141,6 +154,7 @@ function buildEntitlementValue( export const FeatureCreateSheet = ({ featureId, initialName = '', + initialCode = '', initialDescription = '', initialProductId, initialKind = { type: 'boolean' }, @@ -161,6 +175,7 @@ export const FeatureCreateSheet = ({ schema, defaultValues: { name: initialName, + code: initialCode, description: initialDescription, productId: initialProductId ?? '', type: initialKind.type, @@ -200,6 +215,7 @@ export const FeatureCreateSheet = ({ } else { await createMutation.mutateAsync({ name: data.name, + code: data.code, description: data.description, productId: data.productId || undefined, featureType: @@ -239,7 +255,38 @@ export const FeatureCreateSheet = ({ Name - + { + field.onChange(e) + // Auto-fill the code from the name until the user edits it. + if (!isEdit && !form.formState.dirtyFields.code) { + form.setValue('code', slugifyCode(e.target.value), { + shouldValidate: true, + }) + } + }} + /> + + + + )} + /> + + ( + + + Code{' '} + + (stable identifier{isEdit ? ', immutable' : ''}) + + + + diff --git a/modules/web/web-app/features/entitlements/features/FeatureDetailSheet.tsx b/modules/web/web-app/features/entitlements/features/FeatureDetailSheet.tsx index cb9a80766..46a31a6c0 100644 --- a/modules/web/web-app/features/entitlements/features/FeatureDetailSheet.tsx +++ b/modules/web/web-app/features/entitlements/features/FeatureDetailSheet.tsx @@ -150,6 +150,13 @@ export const FeatureDetailSheet = () => { +
+
+ Code +
+ +
+ {feature.description && (
diff --git a/modules/web/web-app/features/entitlements/utils.ts b/modules/web/web-app/features/entitlements/utils.ts index 94c91651f..cb0b4ea7a 100644 --- a/modules/web/web-app/features/entitlements/utils.ts +++ b/modules/web/web-app/features/entitlements/utils.ts @@ -37,6 +37,19 @@ export function groupByProduct( }) } +/** Feature-code rules, mirrored by the backend's `validate_feature_code`. Shared by every code-entry form. */ +export const FEATURE_CODE_REGEX = /^[a-z0-9][a-z0-9_-]*$/ +export const FEATURE_CODE_MAX_LENGTH = 128 +export const FEATURE_CODE_CHARSET_MESSAGE = "Lowercase letters, digits, '-', '_' (e.g. audit_log)" +export const FEATURE_CODE_LENGTH_MESSAGE = `Must be ${FEATURE_CODE_MAX_LENGTH} characters or fewer` + +/** Derive a slug code from a feature name, matching the backend's allowed charset `^[a-z0-9][a-z0-9_-]*$`. */ +export const slugifyCode = (name: string): string => + name + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '_') + .replace(/^[_-]+/, '') + // Local domain types — use these in UI, map from proto at the boundary export type BooleanFeatureKind = { type: 'boolean' } diff --git a/modules/web/web-app/pages/tenants/catalog/features.tsx b/modules/web/web-app/pages/tenants/catalog/features.tsx index 67762dbf4..7c265a9bb 100644 --- a/modules/web/web-app/pages/tenants/catalog/features.tsx +++ b/modules/web/web-app/pages/tenants/catalog/features.tsx @@ -33,6 +33,7 @@ export const FeatureEdit = () => {