Compare commits

...
266 changed files with 21923 additions and 16978 deletions
+128 -130
View File
@@ -2743,7 +2743,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Create one-use Plutonium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. Not available on self-hosted instances. Requires GIFT_CODES_GENERATE permission.",
"description": "Create one-use premium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. On self-hosted instances the premium mode must be mirror. Requires GIFT_CODES_GENERATE permission.",
"security": [{"adminApiKey": []}],
"requestBody": {
"required": true,
@@ -10523,7 +10523,6 @@
"additionalProperties": false
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
@@ -10639,7 +10638,9 @@
"favicon_url": {"nullable": true, "type": "string"},
"theme_color": {"nullable": true, "type": "string"},
"status_page_url": {"nullable": true, "type": "string"},
"status_page_incident_history_url": {"nullable": true, "type": "string"}
"status_page_incident_history_url": {"nullable": true, "type": "string"},
"premium_product_name": {"type": "string"},
"premium_info_url": {"nullable": true, "type": "string"}
},
"required": [
"product_name",
@@ -10650,7 +10651,9 @@
"favicon_url",
"theme_color",
"status_page_url",
"status_page_incident_history_url"
"status_page_incident_history_url",
"premium_product_name",
"premium_info_url"
],
"additionalProperties": false
},
@@ -10949,12 +10952,12 @@
},
"required": ["attachment_decay"],
"additionalProperties": false
}
},
"billing": {"$ref": "#/components/schemas/InstanceBillingResponse"}
},
"required": [
"sso",
"gateway_rollout",
"voice_noise_suppression",
"push_relay",
"domain_migration",
"altcha_captcha",
@@ -10965,7 +10968,8 @@
"app_public",
"policy",
"integrations",
"media"
"media",
"billing"
],
"additionalProperties": false
},
@@ -11089,10 +11093,6 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/GatewayRolloutConfigUpdateRequest"}]
},
"voice_noise_suppression": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
},
"push_relay": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PushRelayConfigUpdateRequest"}]},
"domain_migration": {
"nullable": true,
@@ -11153,7 +11153,9 @@
"favicon_url": {"nullable": true, "type": "string", "maxLength": 2048},
"theme_color": {"nullable": true, "type": "string", "maxLength": 64},
"status_page_url": {"nullable": true, "type": "string", "maxLength": 2048},
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048}
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048},
"premium_product_name": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 40},
"premium_info_url": {"nullable": true, "type": "string", "maxLength": 2048}
}
},
"setup": {"nullable": true, "type": "object", "properties": {"configured": {"type": "boolean"}}},
@@ -11321,7 +11323,8 @@
}
}
}
}
},
"billing": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/InstanceBillingUpdateRequest"}]}
}
},
"ListGuildStickersResponse": {
@@ -15180,6 +15183,28 @@
{"name": "BANNER_UNSET", "value": "2", "description": "Guild member banner is unset"}
]
},
"InstanceBillingUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"nullable": true, "type": "boolean"},
"stripe_secret_key": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4096},
"stripe_webhook_secret": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4096},
"automatic_tax": {"nullable": true, "type": "boolean"},
"tax_id_collection": {"nullable": true, "type": "boolean"},
"terms_consent_required": {"nullable": true, "type": "boolean"},
"default_currency": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PremiumCurrency"}]},
"prices": {
"nullable": true,
"type": "object",
"additionalProperties": {"$ref": "#/components/schemas/BillingPriceSetUpdateRequest"}
},
"country_currencies": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/BillingCountryCurrenciesSchema"}]
},
"legacy_prices": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/BillingLegacyPricesSchema"}]}
}
},
"InstanceCaptchaProviderSchema": {"type": "string", "enum": ["hcaptcha", "turnstile", "none"]},
"InstanceRegistrationModeSchema": {
"description": "Registration mode",
@@ -15277,50 +15302,6 @@
}
},
"PushRelayConfigUpdateRequest": {"type": "object", "properties": {"relay_consent_accepted": {"type": "boolean"}}},
"VoiceNoiseSuppressionConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"default_backend": {"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}]},
"enabled_backends": {
"maxItems": 7,
"type": "array",
"items": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
},
"allow_user_override": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"guild_overrides": {
"maxItems": 200,
"type": "array",
"items": {
"type": "object",
"properties": {
"guild_id": {"type": "string", "pattern": "^\\d{1,20}$"},
"backend": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
},
"required": ["guild_id", "backend"]
}
},
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
}
},
"GatewayRolloutConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15335,9 +15316,84 @@
"voice_e2ee_scope": {"type": "string", "enum": ["guild_feature_only", "platform_wide"]}
}
},
"VoiceNoiseSuppressionBackendSchema": {
"type": "string",
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
"BillingLegacyPricesSchema": {
"type": "object",
"additionalProperties": {
"maxItems": 32,
"type": "array",
"items": {"$ref": "#/components/schemas/StripePriceIdSchema"}
}
},
"BillingCountryCurrenciesSchema": {
"type": "object",
"additionalProperties": {"$ref": "#/components/schemas/PremiumCurrency"}
},
"BillingPriceSetUpdateRequest": {
"type": "object",
"properties": {
"monthly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"yearly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"gift_1_month": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"gift_1_year": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]}
}
},
"PremiumCurrency": {"type": "string", "pattern": "^[A-Z]{3}$"},
"StripePriceIdSchema": {"type": "string", "maxLength": 255, "pattern": "^price_[A-Za-z0-9]+$"},
"InstanceBillingResponse": {
"type": "object",
"properties": {
"enabled": {"nullable": true, "type": "boolean"},
"effective_enabled": {"type": "boolean"},
"stripe_secret_key_set": {"type": "boolean"},
"stripe_webhook_secret_set": {"type": "boolean"},
"stripe_secret_key_stored": {"type": "boolean"},
"stripe_webhook_secret_stored": {"type": "boolean"},
"automatic_tax": {"nullable": true, "type": "boolean"},
"tax_id_collection": {"nullable": true, "type": "boolean"},
"terms_consent_required": {"nullable": true, "type": "boolean"},
"effective_automatic_tax": {"type": "boolean"},
"effective_tax_id_collection": {"type": "boolean"},
"effective_terms_consent_required": {"type": "boolean"},
"default_currency": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PremiumCurrency"}]},
"prices": {
"nullable": true,
"type": "object",
"additionalProperties": {"$ref": "#/components/schemas/BillingPriceSetResponse"}
},
"country_currencies": {"nullable": true, "type": "object", "additionalProperties": {"type": "string"}},
"legacy_prices": {
"nullable": true,
"type": "object",
"additionalProperties": {"type": "array", "items": {"type": "string"}}
},
"billing_active": {"type": "boolean"},
"stripe_serviceable": {"type": "boolean"},
"catalog_mode": {"$ref": "#/components/schemas/BillingCatalogModeSchema"},
"webhook_url": {"type": "string"}
},
"required": [
"enabled",
"effective_enabled",
"stripe_secret_key_set",
"stripe_webhook_secret_set",
"stripe_secret_key_stored",
"stripe_webhook_secret_stored",
"automatic_tax",
"tax_id_collection",
"terms_consent_required",
"effective_automatic_tax",
"effective_tax_id_collection",
"effective_terms_consent_required",
"default_currency",
"prices",
"country_currencies",
"legacy_prices",
"billing_active",
"stripe_serviceable",
"catalog_mode",
"webhook_url"
],
"additionalProperties": false
},
"ExperimentDeliveryConfigResponse": {
"type": "object",
@@ -15509,76 +15565,6 @@
"required": ["relay_consent_accepted", "relay_consent_accepted_at", "relay_consent_accepted_by"],
"additionalProperties": false
},
"VoiceNoiseSuppressionConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"default_backend": {
"default": "standard",
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}]
},
"enabled_backends": {
"default": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
"maxItems": 7,
"type": "array",
"items": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
},
"allow_user_override": {"default": true, "type": "boolean"},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"default": "voice-ns-v1", "type": "string", "minLength": 1, "maxLength": 64},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"guild_overrides": {
"default": [],
"maxItems": 200,
"type": "array",
"items": {
"type": "object",
"properties": {
"guild_id": {"type": "string", "pattern": "^\\d{1,20}$"},
"backend": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
},
"required": ["guild_id", "backend"],
"additionalProperties": false
}
},
"suppression_strength": {"default": 80, "type": "integer", "minimum": 0, "maximum": 100}
},
"required": [
"enabled",
"config_version",
"default_backend",
"enabled_backends",
"allow_user_override",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"guild_overrides",
"suppression_strength"
],
"additionalProperties": false
},
"GatewayRolloutConfigResponse": {
"type": "object",
"properties": {
@@ -15609,6 +15595,18 @@
],
"additionalProperties": false
},
"BillingCatalogModeSchema": {"type": "string", "enum": ["env", "operator"]},
"BillingPriceSetResponse": {
"type": "object",
"properties": {
"monthly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"yearly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"gift_1_month": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
"gift_1_year": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]}
},
"required": ["monthly", "yearly", "gift_1_month", "gift_1_year"],
"additionalProperties": false
},
"JobLedgerEntrySchema": {
"type": "object",
"properties": {
+5
View File
@@ -4,6 +4,7 @@ use super::client::{AdminApiClient, ApiResult};
use super::types::{
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
InstancePremiumDiscovery,
};
impl AdminApiClient {
@@ -11,6 +12,10 @@ impl AdminApiClient {
self.get("/admin/instance/config", None).await
}
pub async fn get_instance_premium_discovery(&self) -> ApiResult<InstancePremiumDiscovery> {
self.get("/.well-known/fluxer", None).await
}
pub async fn update_instance_config(
&self,
update: &InstanceConfigUpdateRequest,
@@ -0,0 +1,332 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::{InstanceConfigResponse, PremiumMode};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const BILLING_MAX_CURRENCIES: usize = 64;
pub const BILLING_MAX_COUNTRY_CURRENCIES: usize = 300;
pub const BILLING_MAX_LEGACY_SLOTS: usize = 256;
pub const BILLING_MAX_LEGACY_PRICES_PER_SLOT: usize = 32;
pub const BILLING_PRICE_SLOTS: [&str; 4] = ["monthly", "yearly", "gift_1_month", "gift_1_year"];
pub const PREMIUM_PRODUCT_NAME_MAX_CHARS: usize = 40;
pub const TRI_STATE_DEFAULT: &str = "default";
pub const TRI_STATE_ON: &str = "on";
pub const TRI_STATE_OFF: &str = "off";
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BillingCatalogMode {
#[default]
Env,
Operator,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct BillingPriceSet {
pub monthly: Option<String>,
pub yearly: Option<String>,
pub gift_1_month: Option<String>,
pub gift_1_year: Option<String>,
}
impl BillingPriceSet {
pub fn has_recurring_pair(&self) -> bool {
self.monthly.is_some() && self.yearly.is_some()
}
pub fn is_empty(&self) -> bool {
self.monthly.is_none()
&& self.yearly.is_none()
&& self.gift_1_month.is_none()
&& self.gift_1_year.is_none()
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct InstanceBillingResponse {
pub enabled: Option<bool>,
#[serde(default)]
pub effective_enabled: bool,
#[serde(default)]
pub stripe_secret_key_set: bool,
#[serde(default)]
pub stripe_webhook_secret_set: bool,
#[serde(default)]
pub stripe_secret_key_stored: bool,
#[serde(default)]
pub stripe_webhook_secret_stored: bool,
pub default_currency: Option<String>,
pub prices: Option<BTreeMap<String, BillingPriceSet>>,
pub country_currencies: Option<BTreeMap<String, String>>,
pub legacy_prices: Option<BTreeMap<String, Vec<String>>>,
#[serde(default)]
pub billing_active: bool,
#[serde(default)]
pub stripe_serviceable: bool,
#[serde(default)]
pub catalog_mode: BillingCatalogMode,
#[serde(default)]
pub webhook_url: String,
pub automatic_tax: Option<bool>,
pub tax_id_collection: Option<bool>,
pub terms_consent_required: Option<bool>,
#[serde(default)]
pub effective_automatic_tax: bool,
#[serde(default)]
pub effective_tax_id_collection: bool,
#[serde(default)]
pub effective_terms_consent_required: bool,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceBillingUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stripe_secret_key: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stripe_webhook_secret: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_currency: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prices: Option<Option<BTreeMap<String, BillingPriceSet>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub country_currencies: Option<Option<BTreeMap<String, String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legacy_prices: Option<Option<BTreeMap<String, Vec<String>>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub automatic_tax: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tax_id_collection: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terms_consent_required: Option<Option<bool>>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscovery {
#[serde(default)]
pub app_public: InstancePremiumDiscoveryAppPublic,
#[serde(default)]
pub features: InstancePremiumDiscoveryFeatures,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryAppPublic {
#[serde(default)]
pub branding: InstancePremiumDiscoveryBranding,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryBranding {
pub premium_product_name: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryFeatures {
#[serde(default)]
pub premium_enabled: bool,
}
impl InstancePremiumDiscovery {
pub fn premium_product_name(&self) -> Option<&str> {
self.app_public
.branding
.premium_product_name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PremiumBranding {
pub name: Option<String>,
pub premium_enabled: bool,
}
impl PremiumBranding {
pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self {
Self {
name: discovery.premium_product_name().map(str::to_owned),
premium_enabled: discovery.features.premium_enabled,
}
}
pub fn from_instance_config(config: &InstanceConfigResponse) -> Self {
Self::from_config_parts(
config.self_hosted,
&config.app_public.branding.premium_product_name,
config.policy.premium_mode,
)
}
fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self {
let name = name.trim();
Self {
name: (!name.is_empty()).then(|| name.to_owned()),
premium_enabled: !self_hosted || matches!(premium_mode, PremiumMode::Mirror),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::generated::types as generated_types;
use serde_json::json;
#[test]
fn billing_response_round_trips_through_the_generated_contract() {
let value = json!({
"enabled": true,
"effective_enabled": true,
"stripe_secret_key_set": true,
"stripe_webhook_secret_set": false,
"stripe_secret_key_stored": true,
"stripe_webhook_secret_stored": false,
"default_currency": "GBP",
"prices": {
"GBP": {
"monthly": "price_1Monthly",
"yearly": "price_1Yearly",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": {"GB": "GBP"},
"legacy_prices": {"monthly_GBP": ["price_1Old"]},
"billing_active": false,
"stripe_serviceable": false,
"catalog_mode": "operator",
"webhook_url": "https://api.example.com/stripe/webhook",
"automatic_tax": null,
"tax_id_collection": false,
"terms_consent_required": true,
"effective_automatic_tax": false,
"effective_tax_id_collection": false,
"effective_terms_consent_required": true
});
let generated: generated_types::InstanceBillingResponse =
serde_json::from_value(value.clone()).expect("generated billing response");
let ours: InstanceBillingResponse =
serde_json::from_value(value.clone()).expect("hand-written billing response");
assert_eq!(ours.catalog_mode, BillingCatalogMode::Operator);
assert!(ours.stripe_secret_key_stored);
assert_eq!(ours.automatic_tax, None);
assert_eq!(ours.tax_id_collection, Some(false));
assert!(ours.effective_terms_consent_required);
assert!(ours.prices.as_ref().expect("prices")["GBP"].has_recurring_pair());
assert_eq!(serde_json::to_value(&ours).expect("serializable"), value);
assert_eq!(
serde_json::to_value(generated).expect("serializable generated"),
value
);
}
#[test]
fn default_billing_response_matches_the_generated_contract() {
let value = serde_json::to_value(InstanceBillingResponse::default()).expect("serializable");
serde_json::from_value::<generated_types::InstanceBillingResponse>(value.clone())
.expect("generated billing response");
assert_eq!(value["catalog_mode"], json!("env"));
assert_eq!(value["prices"], json!(null));
}
#[test]
fn billing_update_preserves_explicit_nulls_and_omits_untouched_fields() {
let mut prices = BTreeMap::new();
prices.insert(
"SEK".to_owned(),
BillingPriceSet {
monthly: Some("price_1Monthly".to_owned()),
yearly: Some("price_1Yearly".to_owned()),
..Default::default()
},
);
let update = InstanceBillingUpdateRequest {
enabled: Some(None),
stripe_secret_key: Some(None),
default_currency: Some(None),
prices: Some(Some(prices)),
country_currencies: Some(None),
legacy_prices: Some(Some(BTreeMap::new())),
automatic_tax: Some(None),
tax_id_collection: Some(Some(true)),
terms_consent_required: Some(Some(false)),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::InstanceBillingUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({
"enabled": null,
"stripe_secret_key": null,
"default_currency": null,
"prices": {
"SEK": {
"monthly": "price_1Monthly",
"yearly": "price_1Yearly",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": null,
"legacy_prices": {},
"automatic_tax": null,
"tax_id_collection": true,
"terms_consent_required": false
})
);
assert_eq!(
serde_json::to_value(InstanceBillingUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn premium_discovery_reads_the_name_and_feature_flag() {
let discovery: InstancePremiumDiscovery = serde_json::from_value(json!({
"app_public": {"branding": {"product_name": "Example", "premium_product_name": " Gold "}},
"features": {"premium_enabled": true, "stripe_enabled": false}
}))
.expect("discovery");
assert_eq!(discovery.premium_product_name(), Some("Gold"));
assert!(discovery.features.premium_enabled);
let empty: InstancePremiumDiscovery =
serde_json::from_value(json!({})).expect("empty discovery");
assert_eq!(empty.premium_product_name(), None);
assert!(!empty.features.premium_enabled);
assert_eq!(
PremiumBranding::from_discovery(&discovery),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: true
}
);
}
#[test]
fn premium_branding_from_instance_config_matches_discovery_rules() {
assert_eq!(
PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false
}
);
assert!(
PremiumBranding::from_config_parts(true, "Gold", PremiumMode::Mirror).premium_enabled
);
assert_eq!(
PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone),
PremiumBranding {
name: None,
premium_enabled: true
}
);
}
}
+18 -154
View File
@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize};
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
use super::{InstanceBillingResponse, InstanceBillingUpdateRequest};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstanceConfigResponse {
@@ -21,8 +21,6 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub media: InstanceMediaResponse,
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
#[serde(default)]
pub push_relay: PushRelayConfigResponse,
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
@@ -32,6 +30,8 @@ pub struct InstanceConfigResponse {
pub profile_timezone: ProfileTimezoneConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
#[serde(default)]
pub billing: InstanceBillingResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -336,6 +336,9 @@ pub struct AppBrandingConfigResponse {
pub theme_color: Option<String>,
pub status_page_url: Option<String>,
pub status_page_incident_history_url: Option<String>,
#[serde(default = "default_premium_product_name")]
pub premium_product_name: String,
pub premium_info_url: Option<String>,
}
impl Default for AppBrandingConfigResponse {
@@ -350,6 +353,8 @@ impl Default for AppBrandingConfigResponse {
theme_color: None,
status_page_url: None,
status_page_incident_history_url: None,
premium_product_name: default_premium_product_name(),
premium_info_url: None,
}
}
}
@@ -358,6 +363,10 @@ fn default_product_name() -> String {
"Fluxer".to_owned()
}
fn default_premium_product_name() -> String {
"Premium".to_owned()
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct AppSetupConfigResponse {
#[serde(default)]
@@ -460,103 +469,6 @@ pub const ALTCHA_CAPTCHA_DEFAULT_SALT: &str = "altcha-captcha-v1";
pub const ALTCHA_CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=100_000;
pub const ALTCHA_CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=1_000_000;
pub const PROFILE_TIMEZONE_DEFAULT_SALT: &str = "profile-timezone-v1";
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
pub const ALL: [Self; 7] = [
Self::None,
Self::Standard,
Self::Gate,
Self::Speex,
Self::Rnnoise,
Self::Gtcrn,
Self::DeepFilter,
];
pub fn label(&self) -> &'static str {
match self {
Self::None => "None (pass-through)",
Self::Standard => "Standard (WebRTC)",
Self::Gate => "Noise gate",
Self::Speex => "Speex",
Self::Rnnoise => "RNNoise",
Self::Gtcrn => "GTCRN",
Self::DeepFilter => "DeepFilterNet",
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct VoiceNoiseSuppressionGuildOverride {
pub guild_id: String,
pub backend: NoiseSuppressionBackend,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct VoiceNoiseSuppressionConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub default_backend: NoiseSuppressionBackend,
pub enabled_backends: Vec<NoiseSuppressionBackend>,
pub allow_user_override: bool,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub suppression_strength: u32,
}
impl Default for VoiceNoiseSuppressionConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
default_backend: NoiseSuppressionBackend::Standard,
enabled_backends: NoiseSuppressionBackend::ALL.to_vec(),
allow_user_override: true,
rollout_basis_points: 0,
rollout_salt: "voice-ns-v1".to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
suppression_strength: 80,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_backend: Option<NoiseSuppressionBackend>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_backends: Option<Vec<NoiseSuppressionBackend>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_user_override: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suppression_strength: Option<u32>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
@@ -844,8 +756,6 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<InstanceMediaUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_relay: Option<PushRelayConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
@@ -855,6 +765,8 @@ pub struct InstanceConfigUpdateRequest {
pub profile_timezone: Option<ProfileTimezoneConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub billing: Option<InstanceBillingUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -1063,6 +975,10 @@ pub struct AppBrandingConfigUpdateRequest {
pub status_page_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_page_incident_history_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_product_name: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_info_url: Option<Option<String>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -1167,30 +1083,11 @@ mod tests {
use crate::api::generated::types as generated_types;
use serde_json::json;
#[test]
fn noise_suppression_backend_choices_use_the_generated_wire_contract() {
assert_eq!(
serde_json::to_value(NoiseSuppressionBackend::ALL).expect("serializable backends"),
json!([
"none",
"standard",
"gate",
"speex",
"rnnoise",
"gtcrn",
"deep_filter"
])
);
assert!(serde_json::from_value::<NoiseSuppressionBackend>(json!("deepfilter")).is_err());
}
#[test]
fn default_instance_experiment_config_matches_the_published_contract() {
let schema: serde_json::Value =
serde_json::from_str(include_str!("../../../openapi-admin.json"))
.expect("admin schema");
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
.expect("default noise config");
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
.expect("default domain migration config");
let altcha_captcha = serde_json::from_value::<AltchaCaptchaConfigResponse>(json!({}))
@@ -1199,7 +1096,6 @@ mod tests {
.expect("default profile timezone config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let noise = serde_json::to_value(noise).expect("serializable noise config");
let domain_migration =
serde_json::to_value(domain_migration).expect("serializable domain migration config");
let altcha_captcha =
@@ -1207,8 +1103,6 @@ mod tests {
let profile_timezone =
serde_json::to_value(profile_timezone).expect("serializable profile timezone config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
serde_json::from_value(noise.clone()).expect("generated noise config contract");
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
serde_json::from_value(domain_migration.clone())
.expect("generated domain migration config contract");
@@ -1220,10 +1114,6 @@ mod tests {
.expect("generated profile timezone config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
assert_eq!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
);
assert_eq!(
serde_json::to_value(generated_domain_migration)
.expect("serializable generated domain migration config"),
@@ -1245,7 +1135,6 @@ mod tests {
delivery
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("DomainMigrationConfigResponse", domain_migration),
("AltchaCaptchaConfigResponse", altcha_captcha),
("ProfileTimezoneConfigResponse", profile_timezone),
@@ -1260,31 +1149,6 @@ mod tests {
}
}
#[test]
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
let update = VoiceNoiseSuppressionConfigUpdateRequest {
enabled_backends: Some(Vec::new()),
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
guild_overrides: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::VoiceNoiseSuppressionConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
);
assert_eq!(
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn domain_migration_update_preserves_empty_lists_and_omitted_fields() {
let update = DomainMigrationConfigUpdateRequest {
+2
View File
@@ -9,6 +9,7 @@ mod codes;
mod common;
mod discovery;
mod guild_assets;
mod instance_billing;
mod instance_config;
mod jobs;
mod limit_config;
@@ -28,6 +29,7 @@ pub use codes::*;
pub use common::*;
pub use discovery::*;
pub use guild_assets::*;
pub use instance_billing::*;
pub use instance_config::*;
pub use jobs::*;
pub use limit_config::*;
+597
View File
@@ -0,0 +1,597 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::{
client::ApiError,
types::{
AppBrandingConfigUpdateRequest, AppPublicConfigUpdateRequest,
BILLING_MAX_COUNTRY_CURRENCIES, BILLING_MAX_CURRENCIES,
BILLING_MAX_LEGACY_PRICES_PER_SLOT, BILLING_MAX_LEGACY_SLOTS, BILLING_PRICE_SLOTS,
BillingPriceSet, InstanceBillingUpdateRequest, InstanceConfigUpdateRequest,
PREMIUM_PRODUCT_NAME_MAX_CHARS, TRI_STATE_DEFAULT, TRI_STATE_OFF, TRI_STATE_ON,
},
},
middleware::flash::FlashData,
utils::forms::MultiValueForm,
};
use std::collections::BTreeMap;
const PRICE_ID_MAX_CHARS: usize = 255;
const INFO_URL_MAX_CHARS: usize = 2048;
pub(super) fn build_billing_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
let premium_product_name = if form.contains_key("billing_premium_product_name") {
Some(parse_premium_product_name(
form.clean("billing_premium_product_name"),
)?)
} else {
None
};
let premium_info_url = if form.contains_key("billing_premium_info_url") {
Some(parse_info_url(form.clean("billing_premium_info_url"))?)
} else {
None
};
let branding = (premium_product_name.is_some() || premium_info_url.is_some()).then(|| {
AppBrandingConfigUpdateRequest {
premium_product_name,
premium_info_url,
..Default::default()
}
});
let prices = if form.contains_key("billing_price_currency") {
Some(parse_price_rows(form)?)
} else {
None
};
let default_currency = if form.contains_key("billing_default_currency") {
Some(
form.clean("billing_default_currency")
.map(|value| parse_currency(&value))
.transpose()?,
)
} else {
None
};
let country_currencies = if form.contains_key("billing_country_currencies") {
Some(parse_country_currencies(
form.first("billing_country_currencies").unwrap_or(""),
)?)
} else {
None
};
let legacy_prices = if form.contains_key("billing_legacy_prices") {
Some(parse_legacy_prices(
form.first("billing_legacy_prices").unwrap_or(""),
)?)
} else {
None
};
if let Some(Some(prices)) = &prices {
if let Some(Some(currency)) = &default_currency
&& !prices.contains_key(currency)
{
return Err(format!(
"Default currency {currency} has no row in the price table"
));
}
if let Some(Some(countries)) = &country_currencies
&& let Some((country, currency)) = countries
.iter()
.find(|(_, currency)| !prices.contains_key(*currency))
{
return Err(format!(
"{country} maps to {currency}, which has no row in the price table"
));
}
}
Ok(InstanceConfigUpdateRequest {
app_public: branding.map(|branding| AppPublicConfigUpdateRequest {
branding: Some(branding),
..Default::default()
}),
billing: Some(InstanceBillingUpdateRequest {
enabled: parse_tri_state(form, "billing_enabled")?,
stripe_secret_key: secret_update(
form,
"billing_stripe_secret_key",
"billing_clear_stripe_secret_key",
),
stripe_webhook_secret: secret_update(
form,
"billing_stripe_webhook_secret",
"billing_clear_stripe_webhook_secret",
),
default_currency,
prices,
country_currencies,
legacy_prices,
automatic_tax: parse_tri_state(form, "billing_automatic_tax")?,
tax_id_collection: parse_tri_state(form, "billing_tax_id_collection")?,
terms_consent_required: parse_tri_state(form, "billing_terms_consent_required")?,
}),
..Default::default()
})
}
fn parse_tri_state(form: &MultiValueForm, key: &str) -> Result<Option<Option<bool>>, String> {
if !form.contains_key(key) {
return Ok(None);
}
match form.first(key).map(str::trim).unwrap_or("") {
TRI_STATE_DEFAULT => Ok(Some(None)),
TRI_STATE_ON => Ok(Some(Some(true))),
TRI_STATE_OFF => Ok(Some(Some(false))),
other => Err(format!("Invalid choice \"{other}\" for {key}")),
}
}
pub(super) fn billing_result<T>(result: Result<T, ApiError>) -> FlashData {
match result {
Ok(_) => FlashData::success("Premium and billing settings updated"),
Err(error) => {
tracing::warn!(%error, "admin API request failed: update billing config");
match validation_message(&error) {
Some(message) => FlashData::error(format!(
"Failed to update premium and billing settings: {message}"
)),
None => FlashData::error("Failed to update premium and billing settings"),
}
}
}
}
fn validation_message(error: &ApiError) -> Option<String> {
let ApiError::Http {
status: 400,
message,
} = error
else {
return None;
};
let body: serde_json::Value = serde_json::from_str(message).ok()?;
let first = body["errors"].as_array().and_then(|errors| errors.first());
let detail = first.and_then(|error| {
let message = error["message"].as_str()?;
Some(
match error["path"].as_str().filter(|path| !path.is_empty()) {
Some(path) => format!("{path}: {message}"),
None => message.to_owned(),
},
)
});
detail.or_else(|| body["message"].as_str().map(str::to_owned))
}
fn secret_update(form: &MultiValueForm, key: &str, clear_key: &str) -> Option<Option<String>> {
match form.clean(key) {
Some(secret) => Some(Some(secret)),
None if form.bool_value(clear_key) => Some(None),
None => None,
}
}
fn parse_premium_product_name(value: Option<String>) -> Result<Option<String>, String> {
match value {
Some(name) if name.encode_utf16().count() > PREMIUM_PRODUCT_NAME_MAX_CHARS => Err(format!(
"Premium name must be at most {PREMIUM_PRODUCT_NAME_MAX_CHARS} characters"
)),
other => Ok(other),
}
}
fn parse_info_url(value: Option<String>) -> Result<Option<String>, String> {
let Some(value) = value else {
return Ok(None);
};
let valid = value.chars().count() <= INFO_URL_MAX_CHARS
&& url::Url::parse(&value).is_ok_and(|url| {
matches!(url.scheme(), "http" | "https")
&& url.host_str().is_some_and(|h| !h.is_empty())
});
if valid {
Ok(Some(value))
} else {
Err("Premium info URL must be an absolute http or https URL".to_owned())
}
}
fn parse_currency(value: &str) -> Result<String, String> {
let currency = value.trim().to_ascii_uppercase();
if currency.len() == 3 && currency.bytes().all(|byte| byte.is_ascii_uppercase()) {
Ok(currency)
} else {
Err(format!(
"Invalid currency \"{}\": use a 3-letter ISO 4217 code such as GBP",
value.trim()
))
}
}
fn parse_country(value: &str) -> Result<String, String> {
let country = value.trim().to_ascii_uppercase();
if country.len() == 2 && country.bytes().all(|byte| byte.is_ascii_uppercase()) {
Ok(country)
} else {
Err(format!(
"Invalid country \"{}\": use a 2-letter ISO 3166 code such as SE",
value.trim()
))
}
}
fn parse_price_id(value: &str) -> Result<String, String> {
let id = value.trim();
let valid = id.len() <= PRICE_ID_MAX_CHARS
&& id.strip_prefix("price_").is_some_and(|rest| {
!rest.is_empty() && rest.bytes().all(|b| b.is_ascii_alphanumeric())
});
if valid {
Ok(id.to_owned())
} else {
Err(format!(
"Invalid Stripe price ID \"{id}\": it must look like price_1AbC"
))
}
}
fn parse_optional_price_id(value: Option<&String>) -> Result<Option<String>, String> {
match value
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
Some(id) => parse_price_id(id).map(Some),
None => Ok(None),
}
}
fn parse_price_rows(
form: &MultiValueForm,
) -> Result<Option<BTreeMap<String, BillingPriceSet>>, String> {
let currencies = form.values("billing_price_currency");
let column = |key: &str, index: usize| form.values(key).get(index);
let mut prices = BTreeMap::new();
for (index, currency) in currencies.iter().enumerate() {
if currency.trim().is_empty() {
continue;
}
let currency = parse_currency(currency)?;
let set = BillingPriceSet {
monthly: parse_optional_price_id(column("billing_price_monthly", index))?,
yearly: parse_optional_price_id(column("billing_price_yearly", index))?,
gift_1_month: parse_optional_price_id(column("billing_price_gift_1_month", index))?,
gift_1_year: parse_optional_price_id(column("billing_price_gift_1_year", index))?,
};
if set.is_empty() {
return Err(format!("{currency} needs at least one price ID"));
}
if prices.insert(currency.clone(), set).is_some() {
return Err(format!(
"{currency} appears more than once in the price table"
));
}
}
if prices.len() > BILLING_MAX_CURRENCIES {
return Err(format!(
"The price table holds at most {BILLING_MAX_CURRENCIES} currencies"
));
}
Ok((!prices.is_empty()).then_some(prices))
}
fn key_value_lines(value: &str) -> impl Iterator<Item = Result<(&str, &str), String>> {
value
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
line.split_once('=')
.map(|(key, value)| (key.trim(), value.trim()))
.ok_or_else(|| format!("Line \"{line}\" must use the form KEY=VALUE"))
})
}
fn parse_country_currencies(value: &str) -> Result<Option<BTreeMap<String, String>>, String> {
let mut countries = BTreeMap::new();
for line in key_value_lines(value) {
let (country, currency) = line?;
let country = parse_country(country)?;
let currency = parse_currency(currency)?;
if countries.insert(country.clone(), currency).is_some() {
return Err(format!("{country} is mapped more than once"));
}
}
if countries.len() > BILLING_MAX_COUNTRY_CURRENCIES {
return Err(format!(
"At most {BILLING_MAX_COUNTRY_CURRENCIES} country mappings are allowed"
));
}
Ok((!countries.is_empty()).then_some(countries))
}
fn parse_legacy_slot(value: &str) -> Result<String, String> {
let invalid = || {
format!(
"Invalid legacy price slot \"{value}\": use monthly, yearly, gift_1_month or gift_1_year followed by _ and a currency, such as monthly_GBP"
)
};
let (slot, currency) = value.rsplit_once('_').ok_or_else(invalid)?;
let slot = slot.to_ascii_lowercase();
if !BILLING_PRICE_SLOTS.contains(&slot.as_str()) {
return Err(invalid());
}
let currency = parse_currency(currency).map_err(|_| invalid())?;
Ok(format!("{slot}_{currency}"))
}
fn parse_legacy_prices(value: &str) -> Result<Option<BTreeMap<String, Vec<String>>>, String> {
let mut legacy: BTreeMap<String, Vec<String>> = BTreeMap::new();
for line in key_value_lines(value) {
let (slot, ids) = line?;
let slot = parse_legacy_slot(slot)?;
let entry = legacy.entry(slot.clone()).or_default();
for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) {
let id = parse_price_id(id)?;
if !entry.contains(&id) {
entry.push(id);
}
}
if entry.is_empty() {
return Err(format!("{slot} needs at least one price ID"));
}
if entry.len() > BILLING_MAX_LEGACY_PRICES_PER_SLOT {
return Err(format!(
"{slot} holds at most {BILLING_MAX_LEGACY_PRICES_PER_SLOT} legacy price IDs"
));
}
}
if legacy.len() > BILLING_MAX_LEGACY_SLOTS {
return Err(format!(
"At most {BILLING_MAX_LEGACY_SLOTS} legacy price slots are allowed"
));
}
Ok((!legacy.is_empty()).then_some(legacy))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::generated::types as generated_types;
use serde_json::json;
fn full_form(extra: &str) -> MultiValueForm {
let base = "billing_premium_product_name=%20Gold%20\
&billing_premium_info_url=https%3A%2F%2Fexample.com%2Fgold\
&billing_enabled=on\
&billing_automatic_tax=default&billing_tax_id_collection=on&billing_terms_consent_required=off\
&billing_stripe_secret_key=\
&billing_stripe_webhook_secret=whsec_new\
&billing_default_currency=gbp\
&billing_price_currency=gbp&billing_price_monthly=price_1GbpM&billing_price_yearly=price_1GbpY\
&billing_price_gift_1_month=&billing_price_gift_1_year=price_1GbpG\
&billing_price_currency=SEK&billing_price_monthly=price_1SekM&billing_price_yearly=price_1SekY\
&billing_price_gift_1_month=&billing_price_gift_1_year=\
&billing_price_currency=&billing_price_monthly=&billing_price_yearly=\
&billing_price_gift_1_month=&billing_price_gift_1_year=\
&billing_country_currencies=se%3Dsek%0D%0AGB%20%3D%20GBP%0D%0A\
&billing_legacy_prices=monthly_GBP%3Dprice_1OldA%0Amonthly_gbp%3Dprice_1OldB%2Cprice_1OldA%0Ayearly_SEK%3Dprice_1OldC";
MultiValueForm::parse(format!("{base}{extra}").as_bytes())
}
#[test]
fn full_billing_form_builds_the_expected_patch() {
let update = build_billing_update(&full_form("")).expect("valid form");
let value = serde_json::to_value(&update).expect("serializable");
serde_json::from_value::<generated_types::InstanceConfigUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({
"app_public": {
"branding": {
"premium_product_name": "Gold",
"premium_info_url": "https://example.com/gold"
}
},
"billing": {
"enabled": true,
"stripe_webhook_secret": "whsec_new",
"default_currency": "GBP",
"prices": {
"GBP": {
"monthly": "price_1GbpM",
"yearly": "price_1GbpY",
"gift_1_month": null,
"gift_1_year": "price_1GbpG"
},
"SEK": {
"monthly": "price_1SekM",
"yearly": "price_1SekY",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": {"GB": "GBP", "SE": "SEK"},
"legacy_prices": {
"monthly_GBP": ["price_1OldA", "price_1OldB"],
"yearly_SEK": ["price_1OldC"]
},
"automatic_tax": null,
"tax_id_collection": true,
"terms_consent_required": false
}
})
);
}
#[test]
fn blank_fields_clear_and_the_default_choice_sends_null() {
let form = MultiValueForm::parse(
b"billing_premium_product_name=&billing_premium_info_url=&billing_enabled=default\
&billing_stripe_secret_key=&billing_clear_stripe_secret_key=true\
&billing_stripe_webhook_secret=\
&billing_default_currency=\
&billing_price_currency=&billing_price_monthly=price_1Ignored\
&billing_country_currencies=&billing_legacy_prices=",
);
let value = serde_json::to_value(build_billing_update(&form).expect("valid form")).unwrap();
assert_eq!(
value,
json!({
"app_public": {
"branding": {"premium_product_name": null, "premium_info_url": null}
},
"billing": {
"enabled": null,
"stripe_secret_key": null,
"default_currency": null,
"prices": null,
"country_currencies": null,
"legacy_prices": null
}
})
);
}
#[test]
fn a_new_secret_wins_over_the_clear_checkbox() {
let form = MultiValueForm::parse(
b"billing_stripe_secret_key=%20sk_live_x%20&billing_clear_stripe_secret_key=true",
);
let billing = build_billing_update(&form)
.expect("valid form")
.billing
.expect("billing");
assert_eq!(
billing.stripe_secret_key,
Some(Some("sk_live_x".to_owned()))
);
assert_eq!(billing.stripe_webhook_secret, None);
}
#[test]
fn absent_form_keys_leave_their_fields_untouched() {
let update = build_billing_update(&MultiValueForm::parse(b"billing_enabled=off"))
.expect("valid form");
assert!(update.app_public.is_none());
assert_eq!(
serde_json::to_value(update.billing).unwrap(),
json!({"enabled": false})
);
let untouched = build_billing_update(&MultiValueForm::parse(b"")).expect("valid form");
assert_eq!(serde_json::to_value(untouched.billing).unwrap(), json!({}));
}
#[test]
fn invalid_input_is_rejected_with_a_message() {
let cases: &[(&str, &str)] = &[
(
"billing_premium_info_url=ftp%3A%2F%2Fexample.com",
"http or https",
),
("billing_premium_info_url=example.com", "http or https"),
("billing_default_currency=GB", "Invalid currency"),
("billing_enabled=true", "Invalid choice"),
("billing_automatic_tax=maybe", "Invalid choice"),
(
"billing_price_currency=GBPX&billing_price_monthly=price_1A",
"Invalid currency",
),
(
"billing_price_currency=GBP&billing_price_monthly=prod_1A",
"Invalid Stripe price ID",
),
(
"billing_price_currency=GBP&billing_price_monthly=price_1-A",
"Invalid Stripe price ID",
),
("billing_price_currency=GBP", "needs at least one price ID"),
(
"billing_price_currency=GBP&billing_price_monthly=price_1A&billing_price_currency=gbp&billing_price_monthly=price_1B",
"more than once",
),
("billing_country_currencies=SWE%3DSEK", "Invalid country"),
("billing_country_currencies=SE", "KEY=VALUE"),
(
"billing_country_currencies=SE%3DSEK%0ASE%3DEUR",
"mapped more than once",
),
(
"billing_legacy_prices=weekly_GBP%3Dprice_1A",
"Invalid legacy price slot",
),
(
"billing_legacy_prices=monthly_GBP%3D",
"needs at least one price ID",
),
(
"billing_default_currency=EUR&billing_price_currency=GBP&billing_price_monthly=price_1A",
"Default currency EUR has no row",
),
(
"billing_country_currencies=SE%3DSEK&billing_price_currency=GBP&billing_price_monthly=price_1A",
"SE maps to SEK",
),
];
let long_name = format!("billing_premium_product_name={}", "A".repeat(41));
let emoji_name = format!(
"billing_premium_product_name=Gold{}",
"%F0%9F%92%8E".repeat(20)
);
let long_case = [
(long_name.as_str(), "at most 40"),
(emoji_name.as_str(), "at most 40"),
];
for (body, expected) in long_case.iter().chain(cases.iter()) {
let error =
build_billing_update(&MultiValueForm::parse(body.as_bytes())).expect_err(body);
assert!(error.contains(expected), "{body}: {error}");
}
}
#[test]
fn premium_name_limit_counts_utf16_units() {
let name = format!("{}{}", "A".repeat(39), "\u{1F48E}");
assert_eq!(name.chars().count(), 40);
assert!(parse_premium_product_name(Some(name)).is_err());
let fits = format!("{}{}", "A".repeat(38), "\u{E9}\u{E9}");
assert_eq!(
parse_premium_product_name(Some(fits.clone())),
Ok(Some(fits))
);
}
#[test]
fn country_currencies_are_not_cross_checked_without_a_price_table() {
let form = MultiValueForm::parse(b"billing_country_currencies=SE%3DSEK");
let billing = build_billing_update(&form).unwrap().billing.unwrap();
assert_eq!(
billing.country_currencies,
Some(Some(BTreeMap::from([("SE".to_owned(), "SEK".to_owned())])))
);
}
#[test]
fn validation_errors_surface_the_first_api_message() {
let error = ApiError::Http {
status: 400,
message: json!({
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [{"path": "billing.enabled", "code": "X", "message": "Switch the premium model to mirror first"}]
})
.to_string(),
};
assert_eq!(
validation_message(&error).as_deref(),
Some("billing.enabled: Switch the premium model to mirror first")
);
let server_error = ApiError::Http {
status: 500,
message: "{}".to_owned(),
};
assert_eq!(validation_message(&server_error), None);
}
}
+11 -9
View File
@@ -8,12 +8,15 @@ use crate::{
flash::{self, FlashData},
},
state::AppState,
templates::{self, pages::gift_codes::MAX_GIFT_CODES},
templates::{
self,
pages::gift_codes::{GiftCodesPremium, MAX_GIFT_CODES},
},
};
use axum::{
Form, Router,
extract::{FromRequest, Query, Request, State},
response::{Html, IntoResponse, Redirect, Response},
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
@@ -46,10 +49,11 @@ async fn gift_codes_page(
Query(query): Query<GiftCodesQuery>,
) -> Response {
let config = state.config();
if config.self_hosted {
return Redirect::to(&format!("{}/dashboard", config.base_path)).into_response();
}
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let premium = GiftCodesPremium::from_branding(
config.self_hosted,
state.premium_branding(&client).await.as_ref(),
);
let generated_codes: Option<Vec<String>> = query
.codes
@@ -60,6 +64,7 @@ async fn gift_codes_page(
config,
&auth.0,
&csrf.0.0,
&premium,
generated_codes.as_deref(),
);
Html(markup.into_string()).into_response()
@@ -72,9 +77,6 @@ async fn gift_codes_post(
) -> Response {
let config = state.config();
let base = &config.base_path;
if config.self_hosted {
return Redirect::to(&format!("{base}/dashboard")).into_response();
}
let form: GiftCodesForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
+1
View File
@@ -5,6 +5,7 @@ pub mod applications;
pub mod auth;
pub mod bans;
mod bans_actions;
mod billing_actions;
pub mod codes;
pub mod discovery;
mod guild_tabs;
+5
View File
@@ -221,6 +221,11 @@ async fn instance_config_page(
.get_instance_config()
.await
.log_error("load instance config");
if let Some(instance_config) = &instance_config {
state.remember_premium_branding(crate::api::types::PremiumBranding::from_instance_config(
instance_config,
));
}
let limit_config = client
.get_limit_config()
.await
+37 -402
View File
@@ -19,10 +19,9 @@ use crate::{
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
PremiumMode, ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest,
RegistrationMode, SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode,
ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest, RegistrationMode,
SsoConfigUpdateRequest, VoiceE2eeScope,
},
},
config::AdminConfig,
@@ -195,7 +194,9 @@ pub async fn instance_config_post(
}
"update_policy" => {
let update = build_policy_update(&form);
instance_config_result(client.update_instance_config(&update).await)
let result = client.update_instance_config(&update).await;
remember_premium_branding(&state, &result);
instance_config_result(result)
}
"update_integrations" => {
let update = build_integrations_update(&form);
@@ -205,8 +206,12 @@ pub async fn instance_config_post(
let update = build_media_update(&form);
instance_config_result(client.update_instance_config(&update).await)
}
"update_voice_noise_suppression" => match build_voice_noise_suppression_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
"update_billing" => match super::billing_actions::build_billing_update(&form) {
Ok(update) => {
let result = client.update_instance_config(&update).await;
remember_premium_branding(&state, &result);
super::billing_actions::billing_result(result)
}
Err(message) => FlashData::error(message),
},
"update_push_relay" => {
@@ -352,6 +357,17 @@ pub async fn instance_config_post(
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
}
fn remember_premium_branding(
state: &AppState,
result: &Result<crate::api::types::InstanceConfigResponse, crate::api::client::ApiError>,
) {
if let Ok(instance_config) = result {
state.remember_premium_branding(crate::api::types::PremiumBranding::from_instance_config(
instance_config,
));
}
}
fn render_registration_url_list_response(
config: &AdminConfig,
csrf_token: &str,
@@ -466,7 +482,6 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
}
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
@@ -507,22 +522,13 @@ fn parse_experiment_rollout_salt(
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
));
}
Ok(Some(salt.to_owned()))
}
fn parse_ascii_experiment_rollout_salt(
form: &MultiValueForm,
key: &str,
) -> Result<Option<String>, String> {
let salt = parse_experiment_rollout_salt(form, key)?;
if let Some(value) = salt.as_deref()
&& !value
.bytes()
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
if !salt
.bytes()
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
{
return Err("Rollout salt must use printable ASCII".to_owned());
}
Ok(salt)
Ok(Some(salt.to_owned()))
}
fn is_experiment_snowflake(value: &str) -> bool {
@@ -557,117 +563,6 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
Ok(ids)
}
fn parse_voice_noise_suppression_guild_overrides(
value: &str,
) -> Result<Vec<VoiceNoiseSuppressionGuildOverride>, String> {
let mut overrides: Vec<VoiceNoiseSuppressionGuildOverride> = Vec::new();
for (index, line) in value.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let line_number = index + 1;
let (guild_id, backend) = line.split_once('=').ok_or_else(|| {
format!("Guild overrides line {line_number} must use guild_id=backend")
})?;
let guild_id = guild_id.trim();
if !is_experiment_snowflake(guild_id) {
return Err(format!(
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
));
}
let backend = backend.trim().parse().map_err(|_| {
format!("Guild overrides line {line_number} must name a supported backend")
})?;
if let Some(existing) = overrides
.iter()
.find(|existing| existing.guild_id == guild_id)
{
if existing.backend != backend {
return Err(format!(
"Guild overrides line {line_number} conflicts with an earlier rule for guild {guild_id}"
));
}
continue;
}
if overrides.len() == VOICE_NS_MAX_GUILD_OVERRIDES {
return Err(format!(
"Guild overrides must contain at most {VOICE_NS_MAX_GUILD_OVERRIDES} unique guilds"
));
}
overrides.push(VoiceNoiseSuppressionGuildOverride {
guild_id: guild_id.to_owned(),
backend,
});
}
Ok(overrides)
}
fn build_voice_noise_suppression_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
let selected: Vec<NoiseSuppressionBackend> = form
.list_values_any(&["voice_ns_enabled_backends[]", "voice_ns_enabled_backends"])
.into_iter()
.map(|value| {
value.parse().map_err(|_| {
"Enabled backends must name supported noise suppression backends".to_owned()
})
})
.collect::<Result<_, _>>()?;
let enabled_backends = NoiseSuppressionBackend::ALL
.into_iter()
.filter(|backend| selected.contains(backend))
.collect();
Ok(InstanceConfigUpdateRequest {
voice_noise_suppression: Some(VoiceNoiseSuppressionConfigUpdateRequest {
enabled: Some(form.bool_value("voice_ns_enabled")),
default_backend: form
.first("voice_ns_default_backend")
.map(|value| {
value.parse().map_err(|_| {
"Default backend must name a supported noise suppression backend".to_owned()
})
})
.transpose()?,
enabled_backends: Some(enabled_backends),
allow_user_override: Some(form.bool_value("voice_ns_allow_user_override")),
rollout_basis_points: parse_form_number(
form,
"voice_ns_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_included_user_ids").unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("voice_ns_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
"Excluded user IDs",
)?),
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
form.first("voice_ns_guild_overrides").unwrap_or_default(),
)?),
suppression_strength: parse_form_number(
form,
"voice_ns_suppression_strength",
"Suppression strength",
0,
VOICE_NS_SUPPRESSION_STRENGTH_MAX,
)?,
}),
..Default::default()
})
}
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
push_relay: Some(PushRelayConfigUpdateRequest {
@@ -690,10 +585,7 @@ fn build_domain_migration_update(
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"domain_migration_rollout_salt",
)?,
rollout_salt: parse_experiment_rollout_salt(form, "domain_migration_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_included_user_ids")
.unwrap_or_default(),
@@ -736,7 +628,7 @@ fn build_altcha_captcha_update(
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?,
rollout_salt: parse_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_included_user_ids")
.unwrap_or_default(),
@@ -786,10 +678,7 @@ fn build_profile_timezone_update(
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"profile_timezone_rollout_salt",
)?,
rollout_salt: parse_experiment_rollout_salt(form, "profile_timezone_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_included_user_ids")
.unwrap_or_default(),
@@ -867,6 +756,7 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
theme_color: optional("app_theme_color"),
status_page_url: optional("app_status_page_url"),
status_page_incident_history_url: optional("app_status_page_incident_history_url"),
..Default::default()
}),
setup: Some(AppSetupConfigUpdateRequest {
configured: Some(form.bool_value("app_setup_configured")),
@@ -1417,77 +1307,6 @@ mod tests {
);
}
#[test]
fn build_voice_noise_suppression_update_collects_backends_and_validates_numbers() {
let form = MultiValueForm::parse(
b"voice_ns_enabled=true&voice_ns_allow_user_override=on&voice_ns_default_backend=rnnoise&voice_ns_enabled_backends%5B%5D=deep_filter&voice_ns_enabled_backends%5B%5D=none&voice_ns_enabled_backends%5B%5D=none&voice_ns_rollout_basis_points=10000&voice_ns_suppression_strength=100&voice_ns_rollout_salt=%20voice-ns-v2%20",
);
let request = build_voice_noise_suppression_update(&form).expect("valid form");
let update = request
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.allow_user_override, Some(true));
assert_eq!(
update.default_backend,
Some(NoiseSuppressionBackend::Rnnoise)
);
assert_eq!(
update.enabled_backends,
Some(vec![
NoiseSuppressionBackend::None,
NoiseSuppressionBackend::DeepFilter
])
);
assert_eq!(update.rollout_basis_points, Some(10_000));
assert_eq!(update.suppression_strength, Some(100));
assert_eq!(update.rollout_salt, Some("voice-ns-v2".to_owned()));
}
#[test]
fn build_voice_noise_suppression_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_voice_noise_suppression_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"voice_noise_suppression": {
"enabled": false,
"allow_user_override": false,
"enabled_backends": [],
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"guild_overrides": [],
}})
);
}
#[test]
fn build_voice_noise_suppression_update_reads_user_id_textareas() {
let form = MultiValueForm::parse(
b"voice_ns_included_user_ids=1500000000000000001%0A1500000000000000002&voice_ns_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
);
let update = build_voice_noise_suppression_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(
update.included_user_ids,
Some(vec![
"1500000000000000001".to_owned(),
"1500000000000000002".to_owned()
])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000003".to_owned(),
"1500000000000000004".to_owned()
])
);
}
#[test]
fn parse_experiment_user_ids_splits_newlines_and_commas() {
assert_eq!(
@@ -1547,193 +1366,6 @@ mod tests {
);
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_rejects_malformed_lines() {
for (line, message) in [
("456", "Guild overrides line 3 must use guild_id=backend"),
(
"=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"not-a-guild=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"999999999999999999999=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"456=unknown_backend",
"Guild overrides line 3 must name a supported backend",
),
(
"456=",
"Guild overrides line 3 must name a supported backend",
),
(
"123=gate",
"Guild overrides line 3 conflicts with an earlier rule for guild 123",
),
] {
assert_eq!(
parse_voice_noise_suppression_guild_overrides(&format!("\n123=rnnoise\n{line}"))
.expect_err("invalid guild rule"),
message,
"{line}"
);
}
}
#[test]
fn build_voice_noise_suppression_update_rejects_invalid_numbers() {
for (key, message, above_max) in [
(
"voice_ns_rollout_basis_points",
"Rollout basis points must be a whole number between 0 and 10000",
"10001",
),
(
"voice_ns_suppression_strength",
"Suppression strength must be a whole number between 0 and 100",
"101",
),
] {
for value in [
"",
"%20%20",
"abc",
"-1",
"1.5",
"9999999999999999999999999",
above_max,
] {
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid number"),
message,
"{key}={value}"
);
}
}
}
#[test]
fn build_voice_noise_suppression_update_accepts_padded_numbers() {
let form = MultiValueForm::parse(b"voice_ns_rollout_basis_points=%20250%20");
let update = build_voice_noise_suppression_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(update.rollout_basis_points, Some(250));
}
#[test]
fn build_voice_noise_suppression_update_rejects_invalid_rollout_salts() {
for salt in [
String::new(),
" ".to_owned(),
"é".repeat(65),
"🎲".repeat(33),
] {
let form = MultiValueForm::parse(format!("voice_ns_rollout_salt={salt}").as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid salt"),
"Rollout salt must be between 1 and 64 characters"
);
}
}
#[test]
fn build_voice_noise_suppression_update_preserves_valid_rollout_salts() {
for salt in ["x".to_owned(), "é".repeat(64), "🎲".repeat(32)] {
let form =
MultiValueForm::parse(format!("voice_ns_rollout_salt=%20{salt}%20").as_bytes());
let update = build_voice_noise_suppression_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(update.rollout_salt, Some(salt));
}
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_normalizes_identical_rules() {
let overrides = parse_voice_noise_suppression_guild_overrides(
" 1600000000000000001 = rnnoise \n\n1600000000000000001=rnnoise\n1600000000000000002=speex\n",
).expect("valid guild rules");
assert_eq!(
overrides,
vec![
VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000001".to_owned(),
backend: NoiseSuppressionBackend::Rnnoise,
},
VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000002".to_owned(),
backend: NoiseSuppressionBackend::Speex,
},
]
);
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_GUILD_OVERRIDES)
.map(|index| format!("{index}=gate"))
.collect::<Vec<_>>()
.join("\n");
let overrides =
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n199=gate"))
.expect("valid guild rules at cap");
assert_eq!(overrides.len(), VOICE_NS_MAX_GUILD_OVERRIDES);
assert_eq!(
overrides.last().map(|entry| entry.guild_id.as_str()),
Some("199")
);
assert_eq!(
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n200=gate"))
.expect_err("too many guild rules"),
"Guild overrides must contain at most 200 unique guilds"
);
}
#[test]
fn build_voice_noise_suppression_update_reports_invalid_targeting_fields() {
for (form, message) in [
(
"voice_ns_default_backend=unknown",
"Default backend must name a supported noise suppression backend",
),
(
"voice_ns_default_backend=",
"Default backend must name a supported noise suppression backend",
),
(
"voice_ns_enabled_backends%5B%5D=rnnoise&voice_ns_enabled_backends%5B%5D=unknown",
"Enabled backends must name supported noise suppression backends",
),
(
"voice_ns_included_user_ids=123%2Cinvalid",
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"voice_ns_excluded_user_ids=123%2Cinvalid",
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"voice_ns_guild_overrides=123%3Dgate%0A123%3Drnnoise",
"Guild overrides line 2 conflicts with an earlier rule for guild 123",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid targeting"),
message
);
}
}
#[test]
fn build_domain_migration_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
@@ -1800,6 +1432,10 @@ mod tests {
"domain_migration_rollout_salt=%20%20",
"Rollout salt must be between 1 and 64 characters",
),
(
format!("domain_migration_rollout_salt={}", "x".repeat(65)).as_str(),
"Rollout salt must be between 1 and 64 characters",
),
(
"domain_migration_rollout_salt=caf%C3%A9",
"Rollout salt must use printable ASCII",
@@ -1954,11 +1590,10 @@ mod tests {
fn every_experiment_update_rejects_an_invalid_included_guild_id() {
for (prefix, build) in [
(
"voice_ns",
build_voice_noise_suppression_update
"domain_migration",
build_domain_migration_update
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
),
("domain_migration", build_domain_migration_update),
("altcha_captcha", build_altcha_captcha_update),
("profile_timezone", build_profile_timezone_update),
] {
+118 -33
View File
@@ -4,7 +4,7 @@ use crate::{
acl,
api::{
client::{AdminApiClient, ApiResult, ApiResultExt},
types::AdminUser,
types::{AdminUser, PremiumBranding},
},
middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx},
routes::user_tabs,
@@ -22,6 +22,7 @@ use axum::{
use serde::Deserialize;
const USER_ID_LOOKUP_BATCH: usize = 100;
const DEFAULT_PREMIUM_NAME: &str = "Premium";
#[derive(Deserialize)]
struct UserListQuery {
@@ -87,32 +88,47 @@ async fn users_list(
.unwrap_or(&[]);
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let results = if params.has_id_lookup() {
lookup_users_in_batches(&client, &params.requested_ids)
.await
.log_error("lookup users by ids")
.map(|users| (users, false))
} else if params.has_search() {
let offset = u64::from(params.page) * u64::from(params.limit);
client
.search_users(
params.search_query(),
params.email_query(),
params.ip_query(),
params.limit,
offset,
)
.await
.log_error("search users")
.map(|r| {
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
(r.users, has_more)
})
} else {
None
let searching = params.has_id_lookup() || params.has_search();
let results = async {
if params.has_id_lookup() {
lookup_users_in_batches(&client, &params.requested_ids)
.await
.log_error("lookup users by ids")
.map(|users| (users, false))
} else if params.has_search() {
let offset = u64::from(params.page) * u64::from(params.limit);
client
.search_users(
params.search_query(),
params.email_query(),
params.ip_query(),
params.limit,
offset,
)
.await
.log_error("search users")
.map(|r| {
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
(r.users, has_more)
})
} else {
None
}
};
let badge = async {
if searching {
self_hosted_premium_badge_name(&state, &client).await
} else {
None
}
};
let (results, badge_name) = tokio::join!(results, badge);
let result_users = results.as_ref().map(|r| r.0.as_slice());
let has_more = results.as_ref().is_some_and(|r| r.1);
let premium_badge_name = match result_users {
Some(users) if !users.is_empty() => badge_name,
_ => None,
};
let markup = templates::pages::users_list::users_list_page(
config,
&auth.0,
@@ -120,11 +136,34 @@ async fn users_list(
result_users,
has_more,
can_view_email,
premium_badge_name.as_deref(),
is_results_fragment,
);
Html(markup.into_string()).into_response()
}
async fn self_hosted_premium_badge_name(
state: &AppState,
client: &AdminApiClient,
) -> Option<String> {
if !state.config().self_hosted {
return None;
}
premium_badge_name(state.premium_branding(client).await.as_ref())
}
fn premium_badge_name(branding: Option<&PremiumBranding>) -> Option<String> {
match branding {
Some(branding) => branding.premium_enabled.then(|| {
branding
.name
.clone()
.unwrap_or_else(|| DEFAULT_PREMIUM_NAME.to_owned())
}),
None => Some(DEFAULT_PREMIUM_NAME.to_owned()),
}
}
async fn lookup_users_in_batches(
client: &AdminApiClient,
user_ids: &[String],
@@ -148,10 +187,15 @@ async fn user_detail(
let is_detail_fragment = htmx::targets(&headers, "main-content");
let active_tab = query.tab.as_deref().unwrap_or("overview");
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let user = client
.get_user_by_id(&user_id)
.await
.log_error("load user detail");
let (user, badge_name) = tokio::join!(
async {
client
.get_user_by_id(&user_id)
.await
.log_error("load user detail")
},
self_hosted_premium_badge_name(&state, &client)
);
let tq = to_tab_query(&query);
let admin_acls = auth
.0
@@ -167,6 +211,7 @@ async fn user_detail(
} else {
None
};
let premium_badge_name = user.as_ref().and(badge_name);
let markup = templates::pages::user_detail::user_detail_with_tab(
config,
&auth.0,
@@ -174,6 +219,7 @@ async fn user_detail(
&user_id,
active_tab,
tab_body,
premium_badge_name.as_deref(),
is_detail_fragment,
);
Html(markup.into_string()).into_response()
@@ -275,18 +321,29 @@ async fn user_peek(
) -> Response {
let config = state.config();
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let user = client
.get_user_by_id(&user_id)
.await
.log_error("load user peek");
let (user, badge_name) = tokio::join!(
async {
client
.get_user_by_id(&user_id)
.await
.log_error("load user peek")
},
self_hosted_premium_badge_name(&state, &client)
);
let admin_acls = auth
.0
.admin_user
.as_ref()
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let premium_badge_name = user.as_ref().and(badge_name);
let markup = match user {
Some(ref u) => templates::pages::user_peek::user_peek_fragment(config, u, admin_acls),
Some(ref u) => templates::pages::user_peek::user_peek_fragment(
config,
u,
admin_acls,
premium_badge_name.as_deref(),
),
None => maud::html! {
div class="p-4 text-red-600 text-sm" { "User not found." }
},
@@ -319,3 +376,31 @@ fn append_query_params(url: &mut String, params: &[(String, String)]) {
url.push_str(&urlencoding::encode(value));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn badge_name_follows_the_cached_branding_and_falls_back_to_the_default() {
let gold = PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: true,
};
assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold"));
let unnamed = PremiumBranding {
name: None,
premium_enabled: true,
};
assert_eq!(
premium_badge_name(Some(&unnamed)).as_deref(),
Some("Premium")
);
let everyone = PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false,
};
assert_eq!(premium_badge_name(Some(&everyone)), None);
assert_eq!(premium_badge_name(None).as_deref(), Some("Premium"));
}
}
+49 -2
View File
@@ -1,7 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::AdminConfig;
use std::sync::Arc;
use crate::{
api::{
client::{AdminApiClient, ApiResultExt},
types::PremiumBranding,
},
config::AdminConfig,
};
use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};
const PREMIUM_BRANDING_TTL: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct AppState {
@@ -11,6 +22,7 @@ pub struct AppState {
struct AppStateInner {
pub config: AdminConfig,
pub http_client: reqwest::Client,
premium_branding: Mutex<Option<(Instant, PremiumBranding)>>,
}
impl AppState {
@@ -23,6 +35,7 @@ impl AppState {
inner: Arc::new(AppStateInner {
config,
http_client,
premium_branding: Mutex::new(None),
}),
}
}
@@ -34,6 +47,40 @@ impl AppState {
pub fn http_client(&self) -> &reqwest::Client {
&self.inner.http_client
}
pub fn cached_premium_branding(&self) -> Option<PremiumBranding> {
let cache = self
.inner
.premium_branding
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
cache
.as_ref()
.filter(|(fetched_at, _)| fetched_at.elapsed() < PREMIUM_BRANDING_TTL)
.map(|(_, branding)| branding.clone())
}
pub fn remember_premium_branding(&self, branding: PremiumBranding) {
*self
.inner
.premium_branding
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((Instant::now(), branding));
}
pub async fn premium_branding(&self, client: &AdminApiClient) -> Option<PremiumBranding> {
if let Some(branding) = self.cached_premium_branding() {
return Some(branding);
}
let branding = PremiumBranding::from_discovery(
&client
.get_instance_premium_discovery()
.await
.log_error("load premium branding")?,
);
self.remember_premium_branding(branding.clone());
Some(branding)
}
}
impl axum::extract::FromRef<AppState> for AdminConfig {
@@ -13,12 +13,39 @@ struct BadgeDef {
tooltip: String,
}
fn premium_tooltip(
premium_type: i32,
premium_since: Option<&str>,
is_self_hosted: bool,
self_hosted_premium_name: Option<&str>,
) -> Option<String> {
if is_self_hosted {
let name = self_hosted_premium_name?;
return Some(match premium_since {
Some(since) => format!("{name} subscriber since {since}"),
None => name.to_owned(),
});
}
Some(if premium_type == premium_types::LIFETIME {
match premium_since {
Some(since) => format!("Fluxer Visionary since {since}"),
None => "Fluxer Visionary".into(),
}
} else {
match premium_since {
Some(since) => format!("Fluxer Plutonium subscriber since {since}"),
None => "Fluxer Plutonium".into(),
}
})
}
pub fn user_profile_badges(
static_cdn_endpoint: &str,
flags: u64,
premium_type: Option<i32>,
premium_since: Option<&str>,
is_self_hosted: bool,
self_hosted_premium_name: Option<&str>,
size_sm: bool,
) -> Markup {
let cdn = static_cdn_endpoint.trim_end_matches('/');
@@ -42,23 +69,11 @@ pub fn user_profile_badges(
tooltip: "Fluxer Bug Hunter".into(),
});
}
if !is_self_hosted
&& let Some(pt) = premium_type
if let Some(pt) = premium_type
&& pt != premium_types::NONE
&& let Some(tooltip) =
premium_tooltip(pt, premium_since, is_self_hosted, self_hosted_premium_name)
{
let tooltip = if pt == premium_types::LIFETIME {
match premium_since {
Some(since) => format!("Fluxer Visionary since {since}"),
None => "Fluxer Visionary".into(),
}
} else {
match premium_since {
Some(since) => {
format!("Fluxer Plutonium subscriber since {since}")
}
None => "Fluxer Plutonium".into(),
}
};
badges.push(BadgeDef {
icon_url: format!("{cdn}/badges/plutonium.svg"),
tooltip,
@@ -84,3 +99,38 @@ pub fn user_profile_badges(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render(self_hosted: bool, name: Option<&str>, premium_type: i32) -> String {
user_profile_badges(
"https://static.example.com",
0,
Some(premium_type),
Some("2026-01-01"),
self_hosted,
name,
false,
)
.into_string()
}
#[test]
fn hosted_premium_badges_keep_their_fluxer_labels() {
assert!(
render(false, Some("Gold"), 1).contains("Fluxer Plutonium subscriber since 2026-01-01")
);
assert!(render(false, None, 2).contains("Fluxer Visionary since 2026-01-01"));
}
#[test]
fn self_hosted_premium_badges_use_the_configured_name() {
let markup = render(true, Some("Gold"), 1);
assert!(markup.contains("Gold subscriber since 2026-01-01"));
assert!(!markup.contains("Plutonium"));
assert!(render(true, Some("Gold"), 2).contains("Gold subscriber since"));
assert!(!render(true, None, 1).contains("img"));
}
}
@@ -255,13 +255,12 @@ pub const NAV_SECTIONS: &[NavSection] = &[
],
},
NavSection {
title: "Hosted Features",
title: "Premium",
items: &[item!(
"Gift Codes",
"/gift-codes",
"gift-codes",
[acl::GIFT_CODES_GENERATE],
hosted
[acl::GIFT_CODES_GENERATE]
)],
},
];
+73 -5
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::PremiumBranding,
config::AdminConfig,
middleware::auth::AuthContext,
templates::{
@@ -19,21 +20,52 @@ use maud::{Markup, html};
pub const MAX_GIFT_CODES: u32 = 100;
const DEFAULT_GIFT_COUNT: u32 = 10;
pub struct GiftCodesPremium {
pub name: String,
pub needs_mirror_mode: bool,
}
impl GiftCodesPremium {
pub fn from_branding(self_hosted: bool, branding: Option<&PremiumBranding>) -> Self {
let default_name = if self_hosted { "Premium" } else { "Plutonium" };
Self {
name: branding
.and_then(|branding| branding.name.as_deref())
.unwrap_or(default_name)
.to_owned(),
needs_mirror_mode: self_hosted
&& branding.is_some_and(|branding| !branding.premium_enabled),
}
}
}
pub fn gift_codes_page(
config: &AdminConfig,
auth: &AuthContext,
csrf_token: &str,
premium: &GiftCodesPremium,
generated_codes: Option<&[String]>,
) -> Markup {
let base = &config.base_path;
let codes_value = generated_codes.map(|c| c.join("\n")).unwrap_or_default();
let description = format!(
"Create one-use {} gift URLs with a fixed positive duration. \
Lifetime gifts cannot be generated here.",
premium.name
);
let content = html! {
(page_header(
"Gift Codes",
Some("Create one-use Plutonium gift URLs with a fixed positive \
duration. Lifetime gifts cannot be generated here."),
))
(page_header("Gift Codes", Some(&description)))
@if premium.needs_mirror_mode {
(card(html! {
p class="text-sm text-amber-700" {
"The premium model is Everyone, so every member already has " (premium.name)
" and gift codes cannot be generated or redeemed. Switch the premium model to \
Mirror in Instance Config to use gift codes."
}
}))
}
(card(html! {
div class="flex flex-col gap-4" {
@@ -107,3 +139,39 @@ pub fn gift_codes_page(
};
admin_layout(config, auth, "Gift Codes", "gift-codes", None, content)
}
#[cfg(test)]
mod tests {
use super::*;
fn branding(name: &str, premium_enabled: bool) -> PremiumBranding {
PremiumBranding {
name: Some(name.to_owned()),
premium_enabled,
}
}
#[test]
fn premium_name_comes_from_branding_with_per_deployment_fallbacks() {
let hosted = GiftCodesPremium::from_branding(false, None);
assert_eq!(hosted.name, "Plutonium");
assert!(!hosted.needs_mirror_mode);
let self_hosted = GiftCodesPremium::from_branding(true, None);
assert_eq!(self_hosted.name, "Premium");
assert!(!self_hosted.needs_mirror_mode);
let gold = GiftCodesPremium::from_branding(true, Some(&branding("Gold", true)));
assert_eq!(gold.name, "Gold");
assert!(!gold.needs_mirror_mode);
}
#[test]
fn everyone_mode_is_only_flagged_on_self_hosted_instances() {
assert!(
GiftCodesPremium::from_branding(true, Some(&branding("Gold", false))).needs_mirror_mode
);
assert!(
!GiftCodesPremium::from_branding(false, Some(&branding("Plutonium", false)))
.needs_mirror_mode
);
}
}
@@ -0,0 +1,652 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::{
AppBrandingConfigResponse, BillingCatalogMode, BillingPriceSet, InstanceBillingResponse,
PremiumMode, TRI_STATE_DEFAULT, TRI_STATE_OFF, TRI_STATE_ON,
},
templates::components::{
badge::{BadgeVariant, badge},
form::{
FORM_INPUT_CLASS, FORM_SELECT_CLASS, checkbox, csrf_input, form_actions,
form_field_group, select_chevron, submit_button, text_input, textarea_input,
},
section_card::section_card_with_description,
},
};
use maud::{Markup, html};
const PRICE_COLUMNS: [(&str, &str); 4] = [
("billing_price_monthly", "Monthly"),
("billing_price_yearly", "Yearly"),
("billing_price_gift_1_month", "Gift 1 month"),
("billing_price_gift_1_year", "Gift 1 year"),
];
pub fn billing_blockers(
billing: &InstanceBillingResponse,
premium_mode: PremiumMode,
) -> Vec<&'static str> {
if billing.billing_active {
return Vec::new();
}
let mut blockers = Vec::new();
if matches!(premium_mode, PremiumMode::Everyone) {
blockers.push("the premium model is Everyone, so there is no paid tier to sell");
}
if !billing.effective_enabled {
blockers.push("billing is not enabled");
}
if !billing.stripe_secret_key_set {
blockers.push("no Stripe secret key is set");
}
let has_pair = billing
.prices
.as_ref()
.is_some_and(|prices| prices.values().any(BillingPriceSet::has_recurring_pair));
if billing.catalog_mode == BillingCatalogMode::Operator && !has_pair {
blockers.push("no currency has both a monthly and a yearly price ID");
}
if blockers.is_empty() {
blockers.push(match billing.catalog_mode {
BillingCatalogMode::Env => {
"the environment price catalog has no currency with both a monthly and a yearly price ID"
}
BillingCatalogMode::Operator => "the API reports billing as inactive",
});
}
blockers
}
fn billing_status(billing: &InstanceBillingResponse, premium_mode: PremiumMode) -> Markup {
let blockers = billing_blockers(billing, premium_mode);
html! {
div class="space-y-2" {
div class="flex flex-wrap items-center gap-2" {
@if billing.billing_active {
(badge("Billing active", BadgeVariant::Success))
} @else {
(badge("Billing inactive", BadgeVariant::Default))
}
@match billing.catalog_mode {
BillingCatalogMode::Operator => {
(badge("Catalog: price table", BadgeVariant::Default))
}
BillingCatalogMode::Env => {
(badge("Catalog: environment", BadgeVariant::Default))
}
}
(secret_badge(
"Stripe secret key",
billing.stripe_secret_key_set,
billing.stripe_secret_key_stored,
BadgeVariant::Default,
))
(secret_badge(
"Webhook secret",
billing.stripe_webhook_secret_set,
billing.stripe_webhook_secret_stored,
BadgeVariant::Warning,
))
}
@if !blockers.is_empty() {
p class="text-sm text-neutral-600" {
"Purchases are unavailable because " (blockers.join("; ")) "."
}
}
@if billing.billing_active && !billing.stripe_webhook_secret_set {
p class="text-sm text-amber-700" {
"Without a webhook secret, Stripe events are rejected, so subscriptions never reach accounts."
}
}
}
}
}
fn secret_badge(label: &str, is_set: bool, is_stored: bool, missing: BadgeVariant) -> Markup {
match (is_set, is_stored) {
(_, true) => badge(&format!("{label} set"), BadgeVariant::Success),
(true, false) => badge(&format!("{label} from environment"), BadgeVariant::Success),
(false, false) => badge(&format!("{label} missing"), missing),
}
}
fn secret_field(
name: &str,
clear_name: &str,
label: &str,
is_set: bool,
is_stored: bool,
) -> Markup {
let helper = if !is_stored && is_set {
"Set from the environment. Enter a value to override it, or leave blank to keep using it."
} else {
"Leave blank to keep the current value."
};
html! {
div class="flex flex-col gap-2" {
(form_field_group(
label,
name,
false,
None,
Some(helper),
html! {
input type="password" id=(name) name=(name) value="" class=(FORM_INPUT_CLASS)
autocomplete="new-password";
},
))
@if is_stored {
(checkbox(clear_name, "true", "Clear the stored value", false, true))
}
}
}
}
fn tri_state_value(value: Option<bool>) -> &'static str {
match value {
None => TRI_STATE_DEFAULT,
Some(true) => TRI_STATE_ON,
Some(false) => TRI_STATE_OFF,
}
}
fn tri_state_select(
name: &str,
label: &str,
default_label: &str,
stored: Option<bool>,
helper: &str,
) -> Markup {
let selected = tri_state_value(stored);
let options = [
(TRI_STATE_DEFAULT, default_label),
(TRI_STATE_ON, "On"),
(TRI_STATE_OFF, "Off"),
];
form_field_group(
label,
name,
false,
None,
Some(helper),
html! {
div class="relative" {
select id=(name) name=(name) class=(FORM_SELECT_CLASS) {
@for (value, display) in options {
option value=(value) selected[value == selected] { (display) }
}
}
(select_chevron())
}
},
)
}
fn on_off(value: bool) -> &'static str {
if value { "on" } else { "off" }
}
fn checkout_options(billing: &InstanceBillingResponse) -> Markup {
let automatic_tax = format!(
"Calculates tax at checkout. Needs Stripe Tax activated and a head office address in the Stripe dashboard. Currently {}.",
on_off(billing.effective_automatic_tax)
);
let tax_id = format!(
"Lets buyers add a VAT or other tax ID at checkout. Pair it with automatic tax. Currently {}.",
on_off(billing.effective_tax_id_collection)
);
let terms = format!(
"Buyers must accept your terms of service at checkout. Needs a terms of service URL in the Stripe dashboard public details. Currently {}.",
on_off(billing.effective_terms_consent_required)
);
html! {
div class="space-y-4" {
h4 class="text-sm font-medium text-neutral-900" { "Checkout options" }
div class="grid grid-cols-1 gap-4 sm:grid-cols-3" {
(tri_state_select(
"billing_automatic_tax",
"Automatic tax",
"Use default",
billing.automatic_tax,
&automatic_tax,
))
(tri_state_select(
"billing_tax_id_collection",
"Tax ID collection",
"Use default",
billing.tax_id_collection,
&tax_id,
))
(tri_state_select(
"billing_terms_consent_required",
"Terms consent",
"Use default",
billing.terms_consent_required,
&terms,
))
}
p class="text-xs text-neutral-500" {
"Members manage and cancel subscriptions in the Stripe customer portal. It only opens after you save its \
settings once in the Stripe dashboard under Settings, Billing, Customer portal."
}
}
}
}
fn price_cell(name: &str, label: &str, value: Option<&str>) -> Markup {
html! {
td class="px-2 py-2" {
input type="text" name=(name) value=(value.unwrap_or(""))
placeholder="price_..." aria-label=(label)
autocomplete="off" spellcheck="false"
class=(FORM_INPUT_CLASS);
}
}
}
fn price_row(currency: &str, set: &BillingPriceSet) -> Markup {
let values = [
set.monthly.as_deref(),
set.yearly.as_deref(),
set.gift_1_month.as_deref(),
set.gift_1_year.as_deref(),
];
html! {
tr {
td class="px-2 py-2" {
input type="text" name="billing_price_currency" value=(currency)
placeholder="GBP" maxlength="3" aria-label="Currency"
autocomplete="off" spellcheck="false"
class={(FORM_INPUT_CLASS) " w-24 uppercase"};
}
@for ((name, label), value) in PRICE_COLUMNS.iter().zip(values) {
(price_cell(name, label, value))
}
}
}
}
fn price_table(billing: &InstanceBillingResponse) -> Markup {
let empty = BillingPriceSet::default();
html! {
div class="space-y-2" {
h4 class="text-sm font-medium text-neutral-900" { "Prices" }
p class="text-xs text-neutral-500" {
"One row per currency, using Stripe price IDs from your own account. Monthly and yearly are the \
subscription prices; the gift prices are one-time prices for buying gifts. Clear a currency to \
remove its row. Leave the table empty to use the prices from environment variables."
}
div class="overflow-x-auto" {
table class="min-w-full text-sm" {
thead {
tr class="text-left text-xs text-neutral-500" {
th class="px-2 py-1 font-medium" { "Currency" }
@for (_, label) in PRICE_COLUMNS {
th class="px-2 py-1 font-medium" { (label) }
}
}
}
tbody {
@if let Some(prices) = &billing.prices {
@for (currency, set) in prices {
(price_row(currency, set))
}
}
(price_row("", &empty))
}
}
}
}
}
}
fn country_currencies_text(billing: &InstanceBillingResponse) -> String {
billing
.country_currencies
.iter()
.flatten()
.map(|(country, currency)| format!("{country}={currency}"))
.collect::<Vec<_>>()
.join("\n")
}
fn legacy_prices_text(billing: &InstanceBillingResponse) -> String {
billing
.legacy_prices
.iter()
.flatten()
.flat_map(|(slot, ids)| ids.iter().map(move |id| format!("{slot}={id}")))
.collect::<Vec<_>>()
.join("\n")
}
pub fn premium_billing_section(
base: &str,
csrf_token: &str,
branding: &AppBrandingConfigResponse,
billing: &InstanceBillingResponse,
premium_mode: PremiumMode,
) -> Markup {
let enabled_helper = format!(
"Use environment setting follows FLUXER_STRIPE_ENABLED or the config file. Billing is currently {}.",
on_off(billing.effective_enabled)
);
section_card_with_description(
"Premium & Billing",
"Name the premium tier and sell it through your own Stripe account. Subscriptions and gift purchases need \
the Mirror premium model, a Stripe secret key and at least one currency with monthly and yearly prices.",
html! {
form method="post" action={(base) "/instance-config?action=update_billing"}
data-admin-result-form="true" {
(csrf_input(csrf_token))
div class="space-y-8" {
div class="space-y-4" {
h3 class="text-sm font-semibold text-neutral-900" { "Premium tier" }
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(text_input(
"billing_premium_product_name",
"Premium name",
&branding.premium_product_name,
"Premium",
))
(text_input(
"billing_premium_info_url",
"Premium info URL",
branding.premium_info_url.as_deref().unwrap_or(""),
"https://example.com/premium",
))
}
p class="text-xs text-neutral-500" {
"Clients show this name wherever the premium tier is mentioned. Clear it to use the default. \
The info URL is an optional page that describes the tier."
}
@if matches!(premium_mode, PremiumMode::Everyone) {
p class="text-sm text-amber-700" {
"The premium model is Everyone, so every member already has premium limits and clients hide \
premium. Switch the premium model to Mirror to sell subscriptions or redeem gift codes."
}
}
}
div class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Stripe" }
(billing_status(billing, premium_mode))
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(tri_state_select(
"billing_enabled",
"Billing",
"Use environment setting",
billing.enabled,
&enabled_helper,
))
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(secret_field(
"billing_stripe_secret_key",
"billing_clear_stripe_secret_key",
"Stripe secret key",
billing.stripe_secret_key_set,
billing.stripe_secret_key_stored,
))
(secret_field(
"billing_stripe_webhook_secret",
"billing_clear_stripe_webhook_secret",
"Stripe webhook signing secret",
billing.stripe_webhook_secret_set,
billing.stripe_webhook_secret_stored,
))
}
(form_field_group(
"Webhook URL",
"billing_webhook_url",
false,
None,
Some("Add this endpoint in the Stripe dashboard, then paste its signing secret above."),
html! {
input type="text" id="billing_webhook_url" value=(billing.webhook_url)
readonly class=(FORM_INPUT_CLASS);
},
))
(checkout_options(billing))
}
div class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Catalog" }
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(text_input(
"billing_default_currency",
"Default currency",
billing.default_currency.as_deref().unwrap_or(""),
"GBP",
))
}
p class="text-xs text-neutral-500" {
"Used when a buyer's country has no mapping below. Leave blank to use the first currency in the table."
}
(price_table(billing))
div class="grid grid-cols-1 gap-4 lg:grid-cols-2" {
div class="space-y-2" {
(textarea_input(
"billing_country_currencies",
"Country currencies",
"SE=SEK\nGB=GBP",
&country_currencies_text(billing),
6,
false,
))
p class="text-xs text-neutral-500" {
"One COUNTRY=CURRENCY per line, using 2-letter country codes. Each currency needs a row in the table."
}
}
div class="space-y-2" {
(textarea_input(
"billing_legacy_prices",
"Legacy prices",
"monthly_GBP=price_...",
&legacy_prices_text(billing),
6,
false,
))
p class="text-xs text-neutral-500" {
"Older price IDs that existing subscribers may still be on, one SLOT_CURRENCY=price ID per line. \
Repeat a slot for several IDs. Slots are monthly, yearly, gift_1_month and gift_1_year."
}
}
}
}
(form_actions(html! {
(submit_button("Save premium & billing"))
}))
}
}
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn operator_billing() -> InstanceBillingResponse {
InstanceBillingResponse {
enabled: Some(true),
effective_enabled: true,
stripe_secret_key_set: true,
stripe_webhook_secret_set: true,
stripe_secret_key_stored: true,
stripe_webhook_secret_stored: true,
default_currency: Some("GBP".to_owned()),
prices: Some(BTreeMap::from([(
"GBP".to_owned(),
BillingPriceSet {
monthly: Some("price_1GbpM".to_owned()),
yearly: Some("price_1GbpY".to_owned()),
gift_1_month: None,
gift_1_year: Some("price_1GbpG".to_owned()),
},
)])),
country_currencies: Some(BTreeMap::from([
("GB".to_owned(), "GBP".to_owned()),
("IE".to_owned(), "GBP".to_owned()),
])),
legacy_prices: Some(BTreeMap::from([(
"monthly_GBP".to_owned(),
vec!["price_1OldA".to_owned(), "price_1OldB".to_owned()],
)])),
billing_active: true,
stripe_serviceable: true,
catalog_mode: BillingCatalogMode::Operator,
webhook_url: "https://api.example.com/stripe/webhook".to_owned(),
automatic_tax: None,
tax_id_collection: Some(true),
terms_consent_required: Some(false),
effective_automatic_tax: false,
effective_tax_id_collection: true,
effective_terms_consent_required: false,
}
}
fn branding(name: &str) -> AppBrandingConfigResponse {
AppBrandingConfigResponse {
premium_product_name: name.to_owned(),
premium_info_url: Some("https://example.com/gold".to_owned()),
..Default::default()
}
}
#[test]
fn section_renders_every_field_and_one_empty_price_row() {
let markup = premium_billing_section(
"/admin",
"csrf",
&branding("Gold"),
&operator_billing(),
PremiumMode::Mirror,
)
.into_string();
assert!(markup.contains("action=\"/admin/instance-config?action=update_billing\""));
assert!(markup.contains("data-admin-result-form=\"true\""));
assert!(markup.contains("<option value=\"on\" selected>On</option>"));
assert!(markup.contains("name=\"billing_automatic_tax\""));
assert!(markup.contains("name=\"billing_tax_id_collection\""));
assert!(markup.contains("name=\"billing_terms_consent_required\""));
assert!(markup.contains("Customer portal"));
assert!(markup.contains("name=\"billing_premium_product_name\""));
assert!(markup.contains("value=\"Gold\""));
assert!(markup.contains("value=\"https://example.com/gold\""));
assert!(markup.contains("name=\"billing_enabled\""));
assert!(markup.contains("type=\"password\" id=\"billing_stripe_secret_key\""));
assert!(markup.contains("name=\"billing_clear_stripe_secret_key\""));
assert!(markup.contains("name=\"billing_clear_stripe_webhook_secret\""));
assert!(markup.contains("value=\"https://api.example.com/stripe/webhook\""));
assert!(markup.contains("Billing active"));
assert!(!markup.contains("Purchases are unavailable"));
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 2);
assert_eq!(
markup.matches("name=\"billing_price_gift_1_year\"").count(),
2
);
assert!(markup.contains("value=\"price_1GbpG\""));
assert!(markup.contains("GB=GBP\nIE=GBP"));
assert!(markup.contains("monthly_GBP=price_1OldA\nmonthly_GBP=price_1OldB"));
assert!(!markup.contains("Plutonium"));
assert!(!markup.contains("sk_"));
}
#[test]
fn unset_secrets_have_no_clear_checkbox() {
let billing = InstanceBillingResponse::default();
let markup = premium_billing_section(
"/admin",
"csrf",
&branding("Premium"),
&billing,
PremiumMode::Everyone,
)
.into_string();
assert!(!markup.contains("billing_clear_stripe_secret_key"));
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 1);
assert!(markup.contains("Switch the premium model to Mirror"));
assert!(markup.contains("Catalog: environment"));
}
#[test]
fn env_secrets_are_labelled_and_cannot_be_cleared() {
let billing = InstanceBillingResponse {
stripe_secret_key_set: true,
stripe_webhook_secret_set: true,
..Default::default()
};
let markup = premium_billing_section(
"/admin",
"csrf",
&branding("Premium"),
&billing,
PremiumMode::Mirror,
)
.into_string();
assert!(markup.contains("Stripe secret key from environment"));
assert!(markup.contains("Webhook secret from environment"));
assert!(markup.contains("Set from the environment"));
assert!(!markup.contains("billing_clear_stripe_secret_key"));
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
}
#[test]
fn tri_state_selects_reflect_the_stored_value() {
let render = |stored| {
tri_state_select(
"billing_enabled",
"Billing",
"Use environment setting",
stored,
"",
)
.into_string()
};
assert!(
render(None)
.contains("<option value=\"default\" selected>Use environment setting</option>")
);
assert!(render(Some(true)).contains("<option value=\"on\" selected>On</option>"));
assert!(render(Some(false)).contains("<option value=\"off\" selected>Off</option>"));
}
#[test]
fn blockers_explain_why_billing_is_inactive() {
let mut billing = InstanceBillingResponse::default();
assert_eq!(
billing_blockers(&billing, PremiumMode::Everyone),
vec![
"the premium model is Everyone, so there is no paid tier to sell",
"billing is not enabled",
"no Stripe secret key is set",
]
);
billing.effective_enabled = true;
billing.stripe_secret_key_set = true;
assert_eq!(
billing_blockers(&billing, PremiumMode::Mirror),
vec![
"the environment price catalog has no currency with both a monthly and a yearly price ID"
]
);
billing.catalog_mode = BillingCatalogMode::Operator;
billing.prices = Some(BTreeMap::from([(
"GBP".to_owned(),
BillingPriceSet {
monthly: Some("price_1A".to_owned()),
..Default::default()
},
)]));
assert_eq!(
billing_blockers(&billing, PremiumMode::Mirror),
vec!["no currency has both a monthly and a yearly price ID"]
);
assert!(billing_blockers(&operator_billing(), PremiumMode::Mirror).is_empty());
}
}
@@ -7,10 +7,9 @@ use crate::{
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
InstanceRegistrationResponse, LimitConfigResponse, NoiseSuppressionBackend,
PROFILE_TIMEZONE_DEFAULT_SALT, PendingRegistrationResponse, ProfileTimezoneConfigResponse,
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
InstanceRegistrationResponse, LimitConfigResponse, PROFILE_TIMEZONE_DEFAULT_SALT,
PendingRegistrationResponse, ProfileTimezoneConfigResponse, PushRelayConfigResponse,
RegistrationUrlResponse, SsoConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -26,6 +25,7 @@ use crate::{
section_card::{section_card_simple, section_card_with_description},
},
layout::admin_layout,
pages::instance_billing::premium_billing_section,
},
utils::timestamps::format_admin_timestamp,
};
@@ -127,7 +127,25 @@ pub fn instance_config_page(
"Community & policy",
"Community shape, direct messaging, the premium model, and optional embed services.",
html! {
(policy_config_section(base, csrf_token, &instance_config.policy))
(policy_config_section(
base,
csrf_token,
&instance_config.policy,
&instance_config.app_public.branding.premium_product_name,
))
},
))
(config_group(
"Premium & billing",
"The premium tier's name, Stripe credentials and the prices members pay.",
html! {
(premium_billing_section(
base,
csrf_token,
&instance_config.app_public.branding,
&instance_config.billing,
instance_config.policy.premium_mode,
))
},
))
}
@@ -157,7 +175,6 @@ pub fn instance_config_page(
"Gateway rollout behavior and the limit rules applied to users and guilds.",
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
(altcha_captcha_section(base, csrf_token, &instance_config.altcha_captcha))
(profile_timezone_section(base, csrf_token, &instance_config.profile_timezone))
@@ -209,7 +226,12 @@ fn config_group(title: &str, description: &str, content: Markup) -> Markup {
}
}
fn policy_config_section(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
fn policy_config_section(
base: &str,
csrf_token: &str,
policy: &InstancePolicyResponse,
premium_name: &str,
) -> Markup {
section_card_with_description(
"Community & Policy",
"Control whether this instance runs as a single community, whether direct messages and \
@@ -219,7 +241,7 @@ fn policy_config_section(base: &str, csrf_token: &str, policy: &InstancePolicyRe
div class="space-y-8" {
(single_community_form(base, csrf_token, policy))
(direct_messages_form(base, csrf_token, policy))
(premium_mode_form(base, csrf_token, policy))
(premium_mode_form(base, csrf_token, policy, premium_name))
(services_form(base, csrf_token, policy))
}
},
@@ -363,7 +385,14 @@ fn deferred_phone_gate_form(
}
}
fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
fn premium_mode_form(
base: &str,
csrf_token: &str,
policy: &InstancePolicyResponse,
premium_name: &str,
) -> Markup {
let mirror_label = format!("Mirror (Free and {premium_name} tiers)");
let everyone_label = format!("Everyone (every member gets {premium_name} limits)");
html! {
div class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Premium model" }
@@ -371,8 +400,8 @@ fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyRespon
(csrf_input(csrf_token))
div class="space-y-4" {
(select_input("policy_premium_mode", "Premium model", &[
("mirror", "Mirror (Free and Premium tiers)"),
("everyone", "Everyone (every member gets Plutonium limits)"),
("mirror", mirror_label.as_str()),
("everyone", everyone_label.as_str()),
], policy.premium_mode.as_str()))
(form_actions(html! {
(submit_button("Save premium model"))
@@ -993,234 +1022,6 @@ fn gateway_rollout_section(
)
}
fn voice_noise_suppression_section(
base: &str,
csrf_token: &str,
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
) -> Markup {
let status = if voice_noise_suppression.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let backend_labels =
NoiseSuppressionBackend::ALL.map(|backend| (backend.to_string(), backend.label()));
let backend_options = backend_labels
.iter()
.map(|(value, label)| (value.as_str(), *label))
.collect::<Vec<_>>();
let included_user_ids = voice_noise_suppression.included_user_ids.join("\n");
let excluded_user_ids = voice_noise_suppression.excluded_user_ids.join("\n");
let guild_overrides = voice_noise_suppression
.guild_overrides
.iter()
.map(|entry| format!("{}={}", entry.guild_id, entry.backend))
.collect::<Vec<_>>()
.join("\n");
section_card_with_description(
"Voice Noise Suppression",
"Pick which noise suppression backend targeted clients load in voice calls, and how many \
of them are targeted. While the master switch below is off nothing on this form reaches \
any client: every user keeps the audio pipeline they have today, whatever the rest of \
these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (voice_noise_suppression.config_version)
}
}
(checkbox(
"voice_ns_enabled",
"true",
"Serve noise suppression assignments to clients",
voice_noise_suppression.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
feature is inert and keeps its current behavior, so the rollout, targeting \
and override fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Backends" }
(select_input(
"voice_ns_default_backend",
"Default Backend",
&backend_options,
&voice_noise_suppression.default_backend.to_string(),
))
p class="text-xs text-neutral-500" {
"The backend assigned by always-on user rules and the canary. A default \
that is not ticked below is unavailable, but per-guild overrides can \
still target users."
}
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
@for backend in NoiseSuppressionBackend::ALL {
(checkbox(
"voice_ns_enabled_backends[]",
&backend.to_string(),
backend.label(),
voice_noise_suppression.enabled_backends.contains(&backend),
true,
))
}
}
p class="text-xs text-neutral-500" {
"Backends clients are allowed to load. Unticking one withdraws it from \
every user, including anyone who picked it themselves."
}
(checkbox(
"voice_ns_allow_user_override",
"true",
"Let users pick their own backend from the ticked list",
voice_noise_suppression.allow_user_override,
true,
))
p class="text-xs text-neutral-500" {
"Applies only to users who are already targeted. It never pulls anyone \
into the rollout."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"voice_ns_rollout_basis_points",
"Rollout (basis points)",
&voice_noise_suppression.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"voice_ns_rollout_salt",
"Rollout Salt",
&voice_noise_suppression.rollout_salt,
"voice-ns-v1",
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"voice_ns_include_premium_users",
"true",
"Include premium users",
voice_noise_suppression.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&voice_noise_suppression.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
voice_noise_suppression.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage. This is the per-user kill switch."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Per-guild overrides" }
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_guild_overrides",
"Guild Overrides",
"1600000000000000001=rnnoise\n1600000000000000002=deep_filter",
&guild_overrides,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.guild_overrides.len(),
VOICE_NS_MAX_GUILD_OVERRIDES,
))
p class="text-xs text-neutral-500" {
"One per line as guild_id=backend. A guild \
rule targets callers even outside the canary. Always-on user rules \
take precedence, and excluded users stay off. Invalid lines and \
conflicting rules for the same guild prevent the save. \
Unticked backends stay stored but are inactive."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(number_field(
"voice_ns_suppression_strength",
"Suppression Strength",
&voice_noise_suppression.suppression_strength.to_string(),
Some(0), Some(100), "1",
Some("How aggressively the backend removes noise, 0 to 100. Higher values cut more background but chew more of the voice."),
))
}
(form_actions(html! {
(submit_button("Save Voice Noise Suppression Configuration"))
}))
}
}
},
)
}
fn push_relay_section(
base: &str,
csrf_token: &str,
@@ -2356,7 +2157,7 @@ fn sso_config_section(base: &str, csrf_token: &str, sso: &SsoConfigResponse) ->
fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Markup {
let description = if limit_config.self_hosted.unwrap_or(false) {
"Self-hosted instance with all premium features enabled. Configure user and guild limits."
"Self-hosted instance with all premium features enabled by default. Configure user and guild limits."
} else {
"Configure limit rules that control user and guild restrictions based on traits and features."
};
@@ -2378,34 +2179,6 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
#[cfg(test)]
mod tests {
use super::*;
use crate::api::types::VoiceNoiseSuppressionGuildOverride;
fn rendered_voice_noise_suppression_section(
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
) -> String {
voice_noise_suppression_section("/admin", "csrf", voice_noise_suppression).into_string()
}
#[test]
fn voice_noise_suppression_section_shows_list_counts_and_caps() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
guild_overrides: vec![VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000001".to_owned(),
backend: NoiseSuppressionBackend::Rnnoise,
}],
..VoiceNoiseSuppressionConfigResponse::default()
};
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(markup.contains("1 of 200 stored"));
assert!(!markup.contains("at the cap"));
}
#[test]
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
@@ -2455,14 +2228,24 @@ mod tests {
}
#[test]
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
fn premium_mode_options_use_the_configured_premium_name() {
let markup =
premium_mode_form("/admin", "csrf", &InstancePolicyResponse::default(), "Gold")
.into_string();
assert!(markup.contains("Mirror (Free and Gold tiers)"));
assert!(markup.contains("Everyone (every member gets Gold limits)"));
assert!(!markup.contains("Plutonium"));
}
#[test]
fn domain_migration_section_flags_a_list_at_its_cap() {
let domain_migration = DomainMigrationConfigResponse {
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect(),
..VoiceNoiseSuppressionConfigResponse::default()
..DomainMigrationConfigResponse::default()
};
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
assert!(markup.contains("1000 of 1000 stored"));
assert!(markup.contains("at the cap"));
}
+1
View File
@@ -17,6 +17,7 @@ pub mod gift_codes;
pub mod guild_detail;
pub mod guild_detail_tabs;
pub mod guilds_list;
pub mod instance_billing;
pub mod instance_config;
pub mod job_detail;
pub mod jobs_list;
@@ -38,11 +38,22 @@ pub fn user_detail_page(
auth: &AuthContext,
user: Option<&AdminUser>,
user_id: &str,
premium_badge_name: Option<&str>,
is_htmx: bool,
) -> Markup {
user_detail_with_tab(config, auth, user, user_id, "overview", None, is_htmx)
user_detail_with_tab(
config,
auth,
user,
user_id,
"overview",
None,
premium_badge_name,
is_htmx,
)
}
#[allow(clippy::too_many_arguments)]
pub fn user_detail_with_tab(
config: &AdminConfig,
auth: &AuthContext,
@@ -50,10 +61,13 @@ pub fn user_detail_with_tab(
user_id: &str,
active_tab: &str,
tab_body: Option<Markup>,
premium_badge_name: Option<&str>,
is_htmx: bool,
) -> Markup {
let content = match user {
Some(user) => render_user_detail(config, auth, user, active_tab, tab_body),
Some(user) => {
render_user_detail(config, auth, user, active_tab, tab_body, premium_badge_name)
}
None => not_found_state("User", user_id, None, None),
};
let title = user
@@ -79,6 +93,7 @@ fn render_user_detail(
user: &AdminUser,
active_tab: &str,
tab_body: Option<Markup>,
premium_badge_name: Option<&str>,
) -> Markup {
let display_name = user
.global_name
@@ -143,6 +158,7 @@ fn render_user_detail(
user.premium_type,
user.premium_since.as_deref(),
config.self_hosted,
premium_badge_name,
false,
))
}
@@ -33,7 +33,12 @@ fn status_badge(user: &AdminUser) -> Markup {
}
}
pub fn user_peek_fragment(config: &AdminConfig, user: &AdminUser, admin_acls: &[String]) -> Markup {
pub fn user_peek_fragment(
config: &AdminConfig,
user: &AdminUser,
admin_acls: &[String],
premium_badge_name: Option<&str>,
) -> Markup {
let base = &config.base_path;
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
let display = user
@@ -62,6 +67,7 @@ pub fn user_peek_fragment(config: &AdminConfig, user: &AdminUser, admin_acls: &[
user.premium_type,
user.premium_since.as_deref(),
config.self_hosted,
premium_badge_name,
true,
))
}
+19 -3
View File
@@ -95,6 +95,7 @@ impl UserListParams {
}
}
#[allow(clippy::too_many_arguments)]
pub fn users_list_page(
config: &AdminConfig,
auth: &AuthContext,
@@ -102,10 +103,18 @@ pub fn users_list_page(
results: Option<&[AdminUser]>,
has_more: bool,
can_view_email: bool,
premium_badge_name: Option<&str>,
is_htmx: bool,
) -> Markup {
let base = &config.base_path;
let results_markup = render_results(config, params, results, has_more, can_view_email);
let results_markup = render_results(
config,
params,
results,
has_more,
can_view_email,
premium_badge_name,
);
if is_htmx {
return results_markup;
@@ -215,6 +224,7 @@ fn render_results(
results: Option<&[AdminUser]>,
page_has_more: bool,
can_view_email: bool,
premium_badge_name: Option<&str>,
) -> Markup {
let base = &config.base_path;
html! {
@@ -236,7 +246,7 @@ fn render_results(
"Copy IDs"
}
}
(render_users_table(config, users, can_view_email))
(render_users_table(config, users, can_view_email, premium_badge_name))
script { (maud::PreEscaped(copy_ids_script())) }
@if !params.has_id_lookup() && (params.page > 0 || page_has_more) {
(pagination_controls(base, params, page_has_more))
@@ -301,7 +311,12 @@ fn user_status_badge(user: &AdminUser) -> Markup {
}
}
fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email: bool) -> Markup {
fn render_users_table(
config: &AdminConfig,
users: &[AdminUser],
can_view_email: bool,
premium_badge_name: Option<&str>,
) -> Markup {
let base = &config.base_path;
table_container(html! {
table class="min-w-full divide-y divide-neutral-200" {
@@ -343,6 +358,7 @@ fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email:
user.premium_type,
user.premium_since.as_deref(),
config.self_hosted,
premium_badge_name,
true,
))
}
+4
View File
@@ -55,6 +55,10 @@ impl MultiValueForm {
self.fields.contains_key(key)
}
pub fn values(&self, key: &str) -> &[String] {
self.fields.get(key).map(Vec::as_slice).unwrap_or_default()
}
pub fn first(&self, key: &str) -> Option<&str> {
self.fields
.get(key)
+66 -26
View File
@@ -392,25 +392,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"voice_e2ee_scope": "guild_feature_only",
"future_rollout_knob": 3
},
"voice_noise_suppression": {
"enabled": true,
"config_version": 4,
"default_backend": "rnnoise",
"enabled_backends": ["none", "standard", "rnnoise"],
"allow_user_override": true,
"rollout_basis_points": 10000,
"rollout_salt": "voice-ns-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"suppression_strength": 80,
"future_presentation_knob": "verbose",
"future_knob": 7,
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"]
},
"push_relay": {
"relay_consent_accepted": true,
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
@@ -426,6 +407,10 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"included_guild_ids": [],
"include_premium_users": false,
"future_migration_knob": 9,
"future_presentation_knob": "verbose",
"future_knob": 7,
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"],
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
},
@@ -471,7 +456,9 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"wordmark_url": "https://cdn.example.com/wordmark.svg",
"favicon_url": "https://cdn.example.com/favicon.ico",
"theme_color": "#5865f2",
"future_asset_url": "https://cdn.example.com/future.png"
"future_asset_url": "https://cdn.example.com/future.png",
"premium_product_name": "Gold",
"premium_info_url": "https://example.com/gold"
},
"setup": {"configured": true},
"legal": {
@@ -569,6 +556,36 @@ fn deserialize_instance_config_response_with_unknown_keys() {
}
}
},
"billing": {
"enabled": true,
"effective_enabled": true,
"stripe_secret_key_set": true,
"stripe_webhook_secret_set": false,
"stripe_secret_key_stored": true,
"stripe_webhook_secret_stored": false,
"automatic_tax": null,
"tax_id_collection": true,
"terms_consent_required": false,
"effective_automatic_tax": false,
"effective_tax_id_collection": true,
"effective_terms_consent_required": false,
"default_currency": "GBP",
"prices": {
"GBP": {
"monthly": "price_1GbpM",
"yearly": "price_1GbpY",
"gift_1_month": null,
"gift_1_year": "price_1GbpG"
}
},
"country_currencies": {"GB": "GBP"},
"legacy_prices": {"monthly_GBP": ["price_1OldA"]},
"billing_active": true,
"stripe_serviceable": true,
"catalog_mode": "operator",
"webhook_url": "https://api.example.com/stripe/webhook",
"future_billing_knob": 1
},
"future_section": {"enabled": true, "rollout_basis_points": 10000},
"future_flag": 3
}"##;
@@ -578,11 +595,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
);
assert!(!resp.self_hosted);
assert!(resp.voice_noise_suppression.enabled);
assert_eq!(resp.voice_noise_suppression.config_version, 4);
assert_eq!(resp.voice_noise_suppression.rollout_basis_points, 10000);
assert_eq!(*resp.voice_noise_suppression.rollout_salt, "voice-ns-v1");
assert_eq!(resp.voice_noise_suppression.enabled_backends.len(), 3);
assert!(resp.domain_migration.enabled);
assert_eq!(resp.domain_migration.config_version, 2);
assert_eq!(resp.domain_migration.rollout_basis_points, 2500);
@@ -601,13 +613,40 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert_eq!(resp.profile_timezone.included_user_ids.len(), 1);
assert_eq!(resp.profile_timezone.included_guild_ids.len(), 1);
assert!(resp.profile_timezone.include_premium_users);
assert!(resp.voice_noise_suppression.include_premium_users);
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
assert!(resp.policy.single_community_guild_id.is_none());
assert_eq!(resp.policy.services.gif_enabled, Some(true));
assert_eq!(resp.app_public.branding.product_name, "Fluxer");
assert_eq!(resp.app_public.branding.premium_product_name, "Gold");
assert!(resp.billing.billing_active);
assert!(resp.media.attachment_decay.effective.enabled);
let ours: types::InstanceConfigResponse =
serde_json::from_str(json).expect("hand-written instance config");
assert_eq!(ours.app_public.branding.premium_product_name, "Gold");
assert!(ours.billing.stripe_secret_key_stored);
assert_eq!(ours.billing.tax_id_collection, Some(true));
assert!(ours.billing.effective_tax_id_collection);
assert_eq!(
ours.app_public.branding.premium_info_url.as_deref(),
Some("https://example.com/gold")
);
assert!(ours.billing.billing_active);
assert!(ours.billing.stripe_serviceable);
assert!(!ours.billing.stripe_webhook_secret_set);
assert_eq!(
ours.billing.catalog_mode,
types::BillingCatalogMode::Operator
);
assert_eq!(ours.billing.default_currency.as_deref(), Some("GBP"));
let gbp = &ours.billing.prices.as_ref().expect("prices")["GBP"];
assert_eq!(gbp.gift_1_year.as_deref(), Some("price_1GbpG"));
assert_eq!(gbp.gift_1_month, None);
assert_eq!(
ours.billing.legacy_prices.as_ref().expect("legacy")["monthly_GBP"],
vec!["price_1OldA".to_owned()]
);
let without_unknown_keys = json
.replace("\"future_rollout_knob\": 3,", "")
.replace("\"future_presentation_knob\": \"verbose\",", "")
@@ -621,6 +660,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
)
.replace("\"future_service_enabled\": true,", "")
.replace("\"future_curve\": 1.5,", "")
.replace(",\n \"future_billing_knob\": 1", "")
.replace(
"\"future_section\": {\"enabled\": true, \"rollout_basis_points\": 10000},",
"",
-22
View File
@@ -464,7 +464,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
&[
"/instance-config?action=update_gateway_rollout",
"/instance-config?action=update_sso",
"/instance-config?action=update_voice_noise_suppression",
"/instance-config?action=update_domain_migration",
"/instance-config?action=update_experiment_delivery",
][..],
@@ -1179,27 +1178,6 @@ fn instance_config() -> Value {
"max_concurrent_guild_starts": 16,
"voice_e2ee_scope": "guild_feature_only"
},
"voice_noise_suppression": {
"enabled": false,
"config_version": 0,
"default_backend": "standard",
"enabled_backends": [
"none",
"standard",
"gate",
"speex",
"rnnoise",
"gtcrn",
"deep_filter"
],
"allow_user_override": true,
"rollout_basis_points": 0,
"rollout_salt": "voice-ns-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"suppression_strength": 80
},
"domain_migration": {
"enabled": false,
"config_version": 0,
+16 -4
View File
@@ -12,7 +12,16 @@ import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationEr
import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata';
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
import {getCookie} from 'hono/cookie';
import {type core, type input, type output, ZodObject, ZodOptional, type ZodSafeParseResult, type ZodType} from 'zod';
import {
type core,
type input,
type output,
ZodNullable,
ZodObject,
ZodOptional,
type ZodSafeParseResult,
type ZodType,
} from 'zod';
initializeFluxerErrorMap();
@@ -46,8 +55,9 @@ function extractVariablesFromIssue(issue: core.$ZodIssue): Record<string, unknow
}
function convertEmptyValuesToNull(obj: unknown, schema?: core.$ZodType, isRoot = true): unknown {
while (schema instanceof ZodOptional) schema = schema.unwrap();
if (schema && schemaMetadata.get(schema)?.preserveEmptyValues) return obj;
while (schema instanceof ZodOptional || schema instanceof ZodNullable) schema = schema.unwrap();
const metadata = schema ? schemaMetadata.get(schema) : undefined;
if (metadata?.preserveEmptyValues) return obj;
if (typeof obj === 'string' && obj === '') return null;
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, undefined, false));
if (obj !== null && typeof obj === 'object') {
@@ -59,7 +69,9 @@ function convertEmptyValuesToNull(obj: unknown, schema?: core.$ZodType, isRoot =
convertEmptyValuesToNull(value, shape && Object.hasOwn(shape, key) ? shape[key] : undefined, false),
]),
);
if (!isRoot && Object.values(processed).every((value) => value === null)) return null;
if (!isRoot && !metadata?.preserveNullFields && Object.values(processed).every((value) => value === null)) {
return null;
}
return processed;
}
return obj;
@@ -5,6 +5,7 @@ import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import {isPremiumTieringActive} from '@app/api/stripe/BillingConfigCache';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
@@ -25,14 +26,14 @@ export function CodesAdminController(app: HonoApp) {
operationId: 'create_admin_gift_codes',
summary: 'Issue gift codes',
description:
'Create one-use Plutonium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. Not available on self-hosted instances. Requires GIFT_CODES_GENERATE permission.',
'Create one-use premium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. On self-hosted instances the premium mode must be mirror. Requires GIFT_CODES_GENERATE permission.',
responseSchema: CodesResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
if (Config.instance.selfHosted) {
if (!isPremiumTieringActive()) {
throw new FeatureNotAvailableSelfHostedError();
}
const adminService = ctx.get('adminService');
@@ -22,6 +22,7 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
import {
BrandingAssetUploadRequest,
@@ -39,7 +40,6 @@ import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/Doma
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {ProfileTimezoneConfigSchema} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
@@ -66,7 +66,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
const [
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
pushRelay,
domainMigration,
altchaCaptcha,
@@ -78,7 +77,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
] = await Promise.all([
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
@@ -88,12 +86,13 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getRegistrationUrlsForAdmin(),
instanceConfigRepository.getPendingRegistrations(),
]);
const [appPublic, policy, resolvedServices, integrations, media] = await Promise.all([
const [appPublic, policy, resolvedServices, integrations, media, billing] = await Promise.all([
instanceConfigRepository.getAppPublicConfig(),
instanceConfigRepository.getInstancePolicyConfig(),
instanceConfigRepository.getResolvedServicesConfig(),
instanceConfigRepository.getInstanceIntegrationsAdminConfig(),
instanceConfigRepository.getInstanceMediaAdminConfig(),
instanceConfigRepository.getInstanceBillingAdminConfig(),
]);
return {
sso: {
@@ -113,7 +112,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
redirect_uri: deriveSsoRedirectUri(Config.endpoints.webApp),
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
push_relay: pushRelay,
domain_migration: domainMigration,
altcha_captcha: altchaCaptcha,
@@ -151,6 +149,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
},
integrations,
media,
billing,
};
}
@@ -216,6 +215,95 @@ function relayConsentStamp(
: {relay_consent_accepted_at: null, relay_consent_accepted_by: null};
}
function assertSelfHostedBillingSections(data: InstanceConfigUpdateRequest): void {
if (Config.instance.selfHosted) {
return;
}
if (data.billing) {
throw InputValidationError.create('billing', 'Billing is configured through the environment on this instance');
}
const branding = data.app_public?.branding;
if (!branding) {
return;
}
for (const field of ['premium_product_name', 'premium_info_url'] as const) {
if (readOptionalField(branding, field) !== undefined) {
throw InputValidationError.create(
`app_public.branding.${field}`,
'This setting is only available on self-hosted instances',
);
}
}
}
async function assertBillingCompatibleWithStoredPremiumMode(
billing: NonNullable<InstanceConfigUpdateRequest['billing']>,
): Promise<void> {
const requestedEnabled = readOptionalField(billing, 'enabled');
if (requestedEnabled !== true) {
return;
}
const policy = await getInstanceConfigRepository().readStoredInstancePolicyConfig();
if (policy.premium_mode === 'everyone') {
throw InputValidationError.create('billing.enabled', 'Billing can only be enabled when the premium mode is mirror');
}
}
async function assertPremiumModeCompatibleWithStoredBilling(
requestedBillingEnabled: boolean | null | undefined,
): Promise<void> {
if (!Config.instance.selfHosted) {
return;
}
const repository = getInstanceConfigRepository();
const [policy, billing] = await Promise.all([
repository.readStoredInstancePolicyConfig(),
repository.readStoredInstanceBillingConfig(),
]);
if (policy.premium_mode === 'everyone') {
return;
}
const nextEnabled = requestedBillingEnabled === undefined ? billing.enabled : requestedBillingEnabled;
if (nextEnabled === true) {
throw InputValidationError.create(
'policy.premium_mode',
'Disable billing before switching the premium mode to everyone',
);
}
}
async function assertBillingCompatibleWithPremiumMode(data: InstanceConfigUpdateRequest): Promise<void> {
if (!Config.instance.selfHosted) {
return;
}
const requestedEnabled = data.billing ? readOptionalField(data.billing, 'enabled') : undefined;
const requestedPremiumMode = data.policy ? readOptionalField(data.policy, 'premium_mode') : undefined;
if (requestedEnabled === undefined && requestedPremiumMode === undefined) {
return;
}
const currentPremiumMode = (await getInstanceConfigRepository().getInstancePolicyConfig()).premium_mode;
const nextPremiumMode = requestedPremiumMode ?? currentPremiumMode;
if (nextPremiumMode !== 'everyone') {
return;
}
const nextEnabled =
requestedEnabled === undefined
? (await getInstanceConfigRepository().readStoredInstanceBillingConfig()).enabled
: requestedEnabled;
if (nextEnabled !== true) {
return;
}
if (requestedEnabled !== undefined) {
throw InputValidationError.create('billing.enabled', 'Billing can only be enabled when the premium mode is mirror');
}
if (currentPremiumMode !== 'everyone') {
throw InputValidationError.create(
'policy.premium_mode',
'Disable billing before switching the premium mode to everyone',
);
}
}
function listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined {
const sections = Object.entries(data)
.filter(([, value]) => value != null)
@@ -271,6 +359,8 @@ export function InstanceConfigAdminController(app: HonoApp) {
}),
async (ctx) => {
const data = ctx.req.valid('json');
assertSelfHostedBillingSections(data);
await assertBillingCompatibleWithPremiumMode(data);
const appPublicBeforeUpdate = completesInitialSetup(data, false)
? await instanceConfigRepository.getAppPublicConfig()
: null;
@@ -283,18 +373,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
await getGatewayRolloutConfigPublisher().publish(landed);
}
if (data.voice_noise_suppression) {
const patch = omitUndefinedFields(data.voice_noise_suppression);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateVoiceNoiseSuppressionConfig((current) =>
VoiceNoiseSuppressionConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.push_relay) {
const patch = omitUndefinedFields(data.push_relay);
if (Object.keys(patch).length > 0) {
@@ -412,6 +490,8 @@ export function InstanceConfigAdminController(app: HonoApp) {
data.app_public.branding,
'status_page_incident_history_url',
),
premium_product_name: readOptionalField(data.app_public.branding, 'premium_product_name'),
premium_info_url: readOptionalField(data.app_public.branding, 'premium_info_url'),
})
: undefined,
legal: data.app_public.legal
@@ -504,7 +584,28 @@ export function InstanceConfigAdminController(app: HonoApp) {
});
}
if (data.policy) {
await applyInstancePolicyUpdate(ctx, data.policy);
await applyInstancePolicyUpdate(
ctx,
data.policy,
data.billing ? readOptionalField(data.billing, 'enabled') : undefined,
);
}
if (data.billing) {
await assertBillingCompatibleWithStoredPremiumMode(data.billing);
await instanceConfigRepository.setInstanceBillingConfig(
omitUndefinedFields({
enabled: readOptionalField(data.billing, 'enabled'),
stripe_secret_key: readOptionalField(data.billing, 'stripe_secret_key'),
stripe_webhook_secret: readOptionalField(data.billing, 'stripe_webhook_secret'),
default_currency: readOptionalField(data.billing, 'default_currency'),
prices: readOptionalField(data.billing, 'prices'),
country_currencies: readOptionalField(data.billing, 'country_currencies'),
legacy_prices: readOptionalField(data.billing, 'legacy_prices'),
automatic_tax: readOptionalField(data.billing, 'automatic_tax'),
tax_id_collection: readOptionalField(data.billing, 'tax_id_collection'),
terms_consent_required: readOptionalField(data.billing, 'terms_consent_required'),
}),
);
}
if (data.app_public?.setup) {
await instanceConfigRepository.setAppPublicConfig({
@@ -700,6 +801,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
async function applyInstancePolicyUpdate(
ctx: Context<HonoEnv>,
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
requestedBillingEnabled: boolean | null | undefined,
): Promise<void> {
const instanceConfigRepository = getInstanceConfigRepository();
const appPublic = await instanceConfigRepository.getAppPublicConfig();
@@ -707,6 +809,9 @@ async function applyInstancePolicyUpdate(
policy.single_community_enabled === true
? await ctx.get('userRepository').findUnique(ctx.get('adminUserId'))
: null;
if (policy.premium_mode === 'everyone') {
await assertPremiumModeCompatibleWithStoredBilling(requestedBillingEnabled);
}
let enablesSingleCommunity = false;
await instanceConfigRepository.updateInstancePolicyConfig((current) => {
const planned = planInstancePolicyPatch(policy, current, {
@@ -0,0 +1,365 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {getConfig} from '@app/api/Config';
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import {getAdminRepository, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {getStoredBillingConfig, setStoredBillingConfig} from '@app/api/stripe/BillingConfigCache';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const OPERATOR_SECRET_KEY = 'sk_test_operator_secret_value';
const OPERATOR_WEBHOOK_SECRET = 'whsec_operator_secret_value';
interface GlobalState {
selfHosted: boolean;
premiumMode: ReturnType<typeof getCachedInstancePremiumMode>;
storedBilling: ReturnType<typeof getStoredBillingConfig>;
}
function captureGlobalState(): GlobalState {
return {
selfHosted: getConfig().instance.selfHosted,
premiumMode: getCachedInstancePremiumMode(),
storedBilling: getStoredBillingConfig(),
};
}
function restoreGlobalState(state: GlobalState): void {
getConfig().instance.selfHosted = state.selfHosted;
setCachedInstancePremiumMode(state.premiumMode);
setStoredBillingConfig(state.storedBilling);
}
function useInstanceHarness(selfHosted: boolean) {
const context: {harness: ApiTestHarness} = {harness: undefined as unknown as ApiTestHarness};
let original: GlobalState;
beforeAll(async () => {
original = captureGlobalState();
getConfig().instance.selfHosted = selfHosted;
context.harness = await createApiTestHarness();
});
beforeEach(async () => {
await context.harness.reset();
setStoredBillingConfig(null);
setCachedInstancePremiumMode('everyone');
});
afterAll(async () => {
await context.harness.shutdown();
restoreGlobalState(original);
});
return context;
}
async function createAdmin(harness: ApiTestHarness): Promise<TestAccount> {
return await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
}
function patchConfig(harness: ApiTestHarness, admin: TestAccount, body: Record<string, unknown>) {
return createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
}
const OPERATOR_BILLING = {
enabled: true,
stripe_secret_key: OPERATOR_SECRET_KEY,
stripe_webhook_secret: OPERATOR_WEBHOOK_SECRET,
default_currency: 'GBP',
prices: {
GBP: {
monthly: 'price_monthlygbp',
yearly: 'price_yearlygbp',
gift_1_month: 'price_gift1monthgbp',
gift_1_year: 'price_gift1yeargbp',
},
},
country_currencies: {GB: 'GBP'},
legacy_prices: {monthly_GBP: ['price_oldmonthlygbp']},
};
describe('instance config billing on a self-hosted instance', () => {
const context = useInstanceHarness(true);
it('rejects enabling billing while the premium mode is everyone', async () => {
const admin = await createAdmin(context.harness);
await patchConfig(context.harness, admin, {billing: {enabled: true}})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
.execute();
await patchConfig(context.harness, admin, {billing: {enabled: true}, policy: {premium_mode: 'everyone'}})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
.execute();
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
.get('/admin/instance/config')
.execute();
expect(current.billing.enabled).toBeNull();
expect(current.policy.premium_mode).toBe('everyone');
});
it('saves the environment fallback in everyone mode even when the environment enables billing', async () => {
const admin = await createAdmin(context.harness);
const originalEnabled = getConfig().stripe.enabled;
getConfig().stripe.enabled = true;
try {
const updated = await patchConfig(context.harness, admin, {
app_public: {branding: {premium_product_name: 'Gold'}},
billing: {enabled: null},
}).execute();
expect(updated.billing.enabled).toBeNull();
expect(updated.billing.billing_active).toBe(false);
expect(updated.app_public.branding.premium_product_name).toBe('Gold');
await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}}).execute();
} finally {
getConfig().stripe.enabled = originalEnabled;
}
});
it('enables billing and mirror mode from one request and redacts the secrets', async () => {
const admin = await createAdmin(context.harness);
const updated = await patchConfig(context.harness, admin, {
billing: OPERATOR_BILLING,
policy: {premium_mode: 'mirror'},
}).execute();
expect(updated.policy.premium_mode).toBe('mirror');
expect(updated.billing).toMatchObject({
enabled: true,
effective_enabled: true,
stripe_secret_key_set: true,
stripe_webhook_secret_set: true,
default_currency: 'GBP',
prices: OPERATOR_BILLING.prices,
country_currencies: {GB: 'GBP'},
legacy_prices: {monthly_GBP: ['price_oldmonthlygbp']},
billing_active: true,
catalog_mode: 'operator',
});
expect(updated.billing.webhook_url).toMatch(/\/stripe\/webhook$/);
const {text} = await createBuilder(context.harness, admin.token).get('/admin/instance/config').executeRaw();
expect(text).not.toContain(OPERATOR_SECRET_KEY);
expect(text).not.toContain(OPERATOR_WEBHOOK_SECRET);
expect(JSON.parse(text).billing).toMatchObject({stripe_secret_key_set: true, billing_active: true});
});
it('rejects switching the premium mode to everyone while billing is enabled', async () => {
const admin = await createAdmin(context.harness);
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
.execute();
const unchanged = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
.get('/admin/instance/config')
.execute();
expect(unchanged.policy.premium_mode).toBe('mirror');
const switched = await patchConfig(context.harness, admin, {
billing: {enabled: false},
policy: {premium_mode: 'everyone'},
}).execute();
expect(switched.policy.premium_mode).toBe('everyone');
expect(switched.billing).toMatchObject({enabled: false, effective_enabled: false, billing_active: false});
});
it('clears secrets and the operator catalog with null', async () => {
const admin = await createAdmin(context.harness);
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
const kept = await patchConfig(context.harness, admin, {billing: {default_currency: 'GBP'}}).execute();
expect(kept.billing.prices).toEqual(OPERATOR_BILLING.prices);
const cleared = await patchConfig(context.harness, admin, {
billing: {enabled: true, stripe_webhook_secret: null, prices: null, legacy_prices: null},
}).execute();
expect(cleared.billing).toMatchObject({
enabled: true,
prices: null,
legacy_prices: null,
catalog_mode: 'env',
stripe_secret_key_set: true,
});
});
it('rejects malformed billing input', async () => {
const admin = await createAdmin(context.harness);
for (const billing of [
{prices: {gbp: {monthly: 'price_x'}}},
{prices: {GBP: {monthly: 'not_a_price'}}},
{country_currencies: {GBR: 'GBP'}},
{legacy_prices: {weekly_GBP: ['price_x']}},
{default_currency: 'pounds'},
]) {
await patchConfig(context.harness, admin, {billing})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
.execute();
}
});
it('clears the premium name and info URL when both are sent as null', async () => {
const admin = await createAdmin(context.harness);
await patchConfig(context.harness, admin, {
app_public: {branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'}},
}).execute();
const reset = await patchConfig(context.harness, admin, {
app_public: {branding: {premium_product_name: null, premium_info_url: null}},
}).execute();
expect(reset.app_public.branding).toMatchObject({premium_product_name: 'Premium', premium_info_url: null});
await patchConfig(context.harness, admin, {
app_public: {branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'}},
}).execute();
const resetWithBilling = await patchConfig(context.harness, admin, {
app_public: {branding: {premium_product_name: null, premium_info_url: null}},
billing: {enabled: false},
}).execute();
expect(resetWithBilling.app_public.branding).toMatchObject({
premium_product_name: 'Premium',
premium_info_url: null,
});
});
it('applies billing sections that only hold nulls', async () => {
const admin = await createAdmin(context.harness);
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
const envCatalog = await patchConfig(context.harness, admin, {billing: {prices: null}}).execute();
expect(envCatalog.billing).toMatchObject({prices: null, catalog_mode: 'env', enabled: true});
const followsEnv = await patchConfig(context.harness, admin, {billing: {enabled: null}}).execute();
expect(followsEnv.billing).toMatchObject({enabled: null, effective_enabled: getConfig().stripe.enabled});
});
it('applies the policy before billing so a failed policy change stores no billing', async () => {
const admin = await createAdmin(context.harness);
await patchConfig(context.harness, admin, {app_public: {setup: {configured: true}}}).execute();
await patchConfig(context.harness, admin, {
billing: {enabled: true},
policy: {premium_mode: 'mirror', single_community_enabled: true},
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
.get('/admin/instance/config')
.execute();
expect(current.policy.premium_mode).toBe('everyone');
expect(current.billing.enabled).toBeNull();
});
it('re-checks the stored premium mode right before the billing write', async () => {
const admin = await createAdmin(context.harness);
const repository = getInstanceConfigRepository();
const stored = await repository.getInstancePolicyConfig();
const spy = vi.spyOn(repository, 'getInstancePolicyConfig').mockResolvedValue({...stored, premium_mode: 'mirror'});
try {
await patchConfig(context.harness, admin, {billing: {enabled: true}})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
.execute();
} finally {
spy.mockRestore();
}
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
.get('/admin/instance/config')
.execute();
expect(current.billing.enabled).toBeNull();
});
it('re-checks the stored billing right before the premium mode write', async () => {
const admin = await createAdmin(context.harness);
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
const cached = getStoredBillingConfig();
expect(cached?.enabled).toBe(true);
setStoredBillingConfig(cached === null ? null : {...cached, enabled: false});
await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
.execute();
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
.get('/admin/instance/config')
.execute();
expect(current.policy.premium_mode).toBe('mirror');
expect(current.billing.enabled).toBe(true);
});
it('records the billing section in the audit log without secrets', async () => {
const admin = await createAdmin(context.harness);
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
const logs = await getAdminRepository().listAllAuditLogsPaginated(100000);
const update = logs.find((log) => log.action === 'update_instance_config');
expect(update?.metadata.get('sections')).toBe('billing,policy');
const serialized = JSON.stringify(logs.map((log) => [...log.metadata.entries()]));
expect(serialized).not.toContain(OPERATOR_SECRET_KEY);
expect(serialized).not.toContain(OPERATOR_WEBHOOK_SECRET);
});
it('stores the premium name and info URL and resets the name to the self-hosted default', async () => {
const admin = await createAdmin(context.harness);
const initial = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
.get('/admin/instance/config')
.execute();
expect(initial.app_public.branding).toMatchObject({premium_product_name: 'Premium', premium_info_url: null});
const named = await patchConfig(context.harness, admin, {
app_public: {branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'}},
}).execute();
expect(named.app_public.branding).toMatchObject({
premium_product_name: 'Gold',
premium_info_url: 'https://example.com/gold',
});
const renamedProduct = await patchConfig(context.harness, admin, {
app_public: {branding: {product_name: 'Example Chat'}},
}).execute();
expect(renamedProduct.app_public.branding.premium_product_name).toBe('Gold');
const reset = await patchConfig(context.harness, admin, {
app_public: {branding: {product_name: 'Example Chat', premium_product_name: null, premium_info_url: null}},
}).execute();
expect(reset.app_public.branding).toMatchObject({premium_product_name: 'Premium', premium_info_url: null});
await patchConfig(context.harness, admin, {app_public: {branding: {premium_info_url: 'javascript:alert(1)'}}})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
.execute();
});
});
describe('instance config billing on a hosted instance', () => {
const context = useInstanceHarness(false);
it('keeps the Plutonium default and env billing', async () => {
const admin = await createAdmin(context.harness);
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
.get('/admin/instance/config')
.execute();
expect(current.app_public.branding.premium_product_name).toBe('Plutonium');
expect(current.billing).toMatchObject({enabled: null, catalog_mode: 'env', stripe_secret_key_set: true});
const updated = await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}}).execute();
expect(updated.policy.premium_mode).toBe('everyone');
});
it('rejects the billing section and the premium branding fields', async () => {
const admin = await createAdmin(context.harness);
for (const body of [
{billing: {enabled: true}},
{billing: {enabled: null}},
{billing: {prices: null}},
{billing: OPERATOR_BILLING},
{app_public: {branding: {premium_product_name: 'Gold'}}},
{app_public: {branding: {premium_info_url: 'https://example.com/gold'}}},
{app_public: {branding: {premium_product_name: null, premium_info_url: null}}},
{app_public: {branding: {product_name: 'Fluxer', premium_product_name: 'Gold'}}},
]) {
await patchConfig(context.harness, admin, body)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
.execute();
}
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
.get('/admin/instance/config')
.execute();
expect(current.billing).toMatchObject({enabled: null, catalog_mode: 'env', prices: null});
expect(current.app_public.branding).toMatchObject({premium_product_name: 'Plutonium', premium_info_url: null});
expect(getStoredBillingConfig()?.prices ?? null).toBeNull();
});
it('still accepts other branding fields', async () => {
const admin = await createAdmin(context.harness);
const updated = await patchConfig(context.harness, admin, {
app_public: {branding: {theme_color: '#123456'}},
}).execute();
expect(updated.app_public.branding.theme_color).toBe('#123456');
});
});
+1 -1
View File
@@ -81,8 +81,8 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
PremiumController(routes);
if (!config.instance.selfHosted) {
DonationController(routes);
StripeController(routes);
}
StripeController(routes);
}
function registerInboundSmsWebhook(routes: HonoApp): void {
@@ -333,13 +333,10 @@ export function MessageController(app: HonoApp) {
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Channels', 'Messages'],
description:
'Clears all read state and acknowledgement records for a channel, marking all messages as unread. Returns 204 No Content on success.',
deprecated: true,
description: 'Deprecated. Has no effect on the read state. Returns 204 No Content.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
await ctx.get('readStateService').deleteReadState({userId, channelId});
return ctx.body(null, 204);
},
);
@@ -62,6 +62,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
const target = await createTestAccount(harness);
await ensureSessionStarted(harness, sender.token);
await ensureSessionStarted(harness, target.token);
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
await updateUserSettings(harness, target.token, {default_guilds_restricted: false});
const guild = await createGuild(harness, sender.token, 'Mutual Community');
const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, sender.token, systemChannel.id);
@@ -80,6 +82,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
await ensureSessionStarted(harness, sender.token);
await ensureSessionStarted(harness, target.token);
await createFriendship(harness, sender, target);
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
await updateUserSettings(harness, target.token, {default_guilds_restricted: false});
const guild = await createGuild(harness, sender.token, 'Verified Community');
await createBuilder(harness, '')
.post(`/test/guilds/${guild.id}/features`)
@@ -97,6 +101,25 @@ describe('DM Privacy Bidirectional Enforcement', () => {
.expect(HTTP_STATUS.OK)
.execute();
});
it('blocks message from a non-friend guild member to a new account by default', async () => {
const sender = await createTestAccount(harness);
const target = await createTestAccount(harness);
await ensureSessionStarted(harness, sender.token);
await ensureSessionStarted(harness, target.token);
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
await createFriendship(harness, sender, target);
const guild = await createGuild(harness, sender.token, 'Default Community');
const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, sender.token, systemChannel.id);
await acceptInvite(harness, target.token, invite.code);
const channel = await createDmChannel(harness, sender.token, target.userId);
await removeRelationship(harness, sender.token, target.userId);
await createBuilder(harness, sender.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'default restricted target'})
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
.execute();
});
it('blocks message when sender restricts the only mutual guild', async () => {
const sender = await createTestAccount(harness);
const target = await createTestAccount(harness);
@@ -175,6 +198,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await updateUserSettings(harness, user1.token, {default_guilds_restricted: false});
await updateUserSettings(harness, user2.token, {default_guilds_restricted: false});
const guild = await createGuild(harness, user1.token, 'Shared Community');
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
@@ -12,7 +12,6 @@ import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
import {resolveAltchaCaptchaAssignment} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
export function ExperimentController(app: HonoApp) {
@@ -32,18 +31,15 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, voiceConfig, domainMigrationConfig, altchaCaptchaConfig, profileTimezoneConfig] =
await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
]);
const [delivery, domainMigrationConfig, altchaCaptchaConfig, profileTimezoneConfig] = await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
]);
const user = ctx.get('user');
const userId = user.id.toString();
const targeting = await resolveExperimentTargeting(user, [
voiceConfig,
domainMigrationConfig,
altchaCaptchaConfig,
profileTimezoneConfig,
@@ -52,7 +48,6 @@ export function ExperimentController(app: HonoApp) {
poll_interval_seconds: delivery.poll_interval_seconds,
poll_jitter_percent: delivery.poll_jitter_percent,
assignments: {
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId, targeting),
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId, targeting),
profile_timezone: resolveProfileTimezoneAssignment(profileTimezoneConfig, userId, targeting),
@@ -21,17 +21,12 @@ import {
DEFAULT_PROFILE_TIMEZONE_CONFIG,
INERT_PROFILE_TIMEZONE_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {
DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
type ExperimentAssignmentsResponse,
type ExperimentDeliveryConfigResponse,
readDomainMigrationAssignment,
readVoiceNoiseSuppressionAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
@@ -66,7 +61,6 @@ describe('GET /experiments', () => {
poll_interval_seconds: DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
assignments: {
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
profile_timezone: INERT_PROFILE_TIMEZONE_ASSIGNMENT,
@@ -74,33 +68,6 @@ describe('GET /experiments', () => {
});
});
it('returns the inert assignment while the stored config is disabled but populated', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
enabled: false,
config_version: 9,
rollout_basis_points: 10000,
included_user_ids: [account.userId],
});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(body.assignments.voice_noise_suppression).toEqual({
...INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
config_version: 9,
});
});
it('populates the voice assignment key even when the rollout is disabled', async () => {
const account = await createTestAccount(harness);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(Object.hasOwn(body.assignments, 'voice_noise_suppression')).toBe(true);
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
});
it('populates the domain migration assignment key even when the rollout is disabled', async () => {
const account = await createTestAccount(harness);
@@ -262,11 +229,6 @@ describe('GET /experiments', () => {
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, member.token, invite.code);
const repository = getInstanceConfigRepository();
await repository.setVoiceNoiseSuppressionConfig({
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
@@ -286,7 +248,6 @@ describe('GET /experiments', () => {
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
.get(ENDPOINT)
.execute();
expect(memberBody.assignments.voice_noise_suppression).toMatchObject({user_targeted: true, source: 'user_rule'});
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
expect(memberBody.assignments.altcha_captcha).toEqual({enabled: true});
expect(memberBody.assignments.profile_timezone).toEqual({enabled: true});
@@ -294,7 +255,6 @@ describe('GET /experiments', () => {
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
.get(ENDPOINT)
.execute();
expect(outsiderBody.assignments.voice_noise_suppression).toMatchObject({user_targeted: false, source: null});
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
expect(outsiderBody.assignments.altcha_captcha).toEqual({enabled: false});
expect(outsiderBody.assignments.profile_timezone).toEqual({enabled: false});
@@ -330,89 +290,25 @@ describe('GET /experiments', () => {
const guildIds = ['1500000000000000001', '1500000000000000002'];
const body = await createBuilder<
Record<
'voice_noise_suppression' | 'domain_migration' | 'altcha_captcha' | 'profile_timezone',
'domain_migration' | 'altcha_captcha' | 'profile_timezone',
{included_guild_ids: Array<string>; include_premium_users: boolean}
>
>(harness, admin.token)
.patch('/admin/instance/config')
.body({
voice_noise_suppression: {included_guild_ids: guildIds, include_premium_users: true},
domain_migration: {included_guild_ids: guildIds, include_premium_users: true},
altcha_captcha: {included_guild_ids: guildIds, include_premium_users: true},
profile_timezone: {included_guild_ids: guildIds, include_premium_users: true},
})
.execute();
expect(body.voice_noise_suppression.included_guild_ids).toEqual(guildIds);
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
expect(body.altcha_captcha.included_guild_ids).toEqual(guildIds);
expect(body.profile_timezone.included_guild_ids).toEqual(guildIds);
for (const section of [
body.voice_noise_suppression,
body.domain_migration,
body.altcha_captcha,
body.profile_timezone,
]) {
for (const section of [body.domain_migration, body.altcha_captcha, body.profile_timezone]) {
expect(section.include_premium_users).toBe(true);
}
});
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setExperimentDeliveryConfig({
poll_interval_seconds: 7200,
poll_jitter_percent: 45,
});
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
enabled: true,
config_version: 3,
rollout_basis_points: 10000,
});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(body.poll_interval_seconds).toBe(7200);
expect(body.poll_jitter_percent).toBe(45);
expect(body.assignments.voice_noise_suppression).toMatchObject({enabled: true, config_version: 3});
expect(body.assignments.voice_noise_suppression).not.toHaveProperty('poll_interval_seconds');
expect(body.assignments.voice_noise_suppression).not.toHaveProperty('poll_jitter_percent');
});
it('echoes the config version and resolves the caller through the allowlist', async () => {
const targeted = await createTestAccount(harness);
const untargeted = await createTestAccount(harness);
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
enabled: true,
config_version: 14,
default_backend: 'rnnoise',
rollout_basis_points: 0,
included_user_ids: [targeted.userId],
});
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.voice_noise_suppression).toMatchObject({
enabled: true,
config_version: 14,
user_targeted: true,
backend: 'rnnoise',
source: 'user_rule',
});
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
.get(ENDPOINT)
.execute();
expect(untargetedBody.assignments.voice_noise_suppression).toMatchObject({
enabled: true,
config_version: 14,
user_targeted: false,
backend: null,
source: null,
});
});
it('revalidates with a strong etag and answers 304 when nothing changed', async () => {
const account = await createTestAccount(harness);
@@ -442,34 +338,6 @@ describe('GET /experiments', () => {
expect(preflight.headers.get('access-control-expose-headers')).toContain('ETag');
});
it('serves a fresh body once the voice config changes', async () => {
const account = await createTestAccount(harness);
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
.get(ENDPOINT)
.executeWithResponse();
const staleEtag = first.response.headers.get('etag') as string;
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
enabled: true,
config_version: 1,
rollout_basis_points: 10000,
});
const refreshed = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
.get(ENDPOINT)
.header('If-None-Match', staleEtag)
.executeWithResponse();
expect(refreshed.response.status).toBe(HTTP_STATUS.OK);
expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag);
expect(refreshed.json?.assignments.voice_noise_suppression).toMatchObject({
enabled: true,
config_version: 1,
user_targeted: true,
});
});
it('serves a fresh body once the domain migration config changes', async () => {
const account = await createTestAccount(harness);
@@ -517,39 +385,6 @@ describe('GET /experiments', () => {
expect(refreshed.json?.poll_jitter_percent).toBe(5);
});
it('bumps the config version on every admin update without the client sending one', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const afterFirst = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({voice_noise_suppression: {enabled: true, rollout_basis_points: 10000}})
.execute();
expect(afterFirst.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
const afterSecond = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({voice_noise_suppression: {suppression_strength: 42}})
.execute();
expect(afterSecond.voice_noise_suppression).toMatchObject({config_version: 2, enabled: true});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(body.assignments.voice_noise_suppression).toMatchObject({
enabled: true,
config_version: 2,
suppression_strength: 42,
});
});
it('bumps the domain migration config version on every admin update without the client sending one', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
@@ -591,39 +426,39 @@ describe('GET /experiments', () => {
expect(body.assignments.domain_migration).toEqual({enabled: true});
});
it('leaves the config version alone for an admin update that sets no field', async () => {
it('leaves the domain migration config version alone for an admin update that sets no field', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const afterFirst = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
const afterFirst = await createBuilder<{domain_migration: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({voice_noise_suppression: {enabled: true}})
.body({domain_migration: {enabled: true}})
.execute();
expect(afterFirst.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
expect(afterFirst.domain_migration).toMatchObject({config_version: 1, enabled: true});
const afterEmpty = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
const afterEmpty = await createBuilder<{domain_migration: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({voice_noise_suppression: {}})
.body({domain_migration: {}})
.execute();
expect(afterEmpty.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
expect(afterEmpty.domain_migration).toMatchObject({config_version: 1, enabled: true});
const afterUndefined = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
const afterUndefined = await createBuilder<{domain_migration: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({voice_noise_suppression: {enabled: undefined}})
.body({domain_migration: {enabled: undefined}})
.execute();
expect(afterUndefined.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
expect(afterUndefined.domain_migration).toMatchObject({config_version: 1, enabled: true});
});
it('serves the delivery cadence an admin set through the instance config', async () => {
@@ -7,6 +7,7 @@ import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
import type {User} from '@app/api/models/User';
import {isPremiumTieringActive} from '@app/api/stripe/BillingConfigCache';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {NON_SELF_HOSTED_RESERVED_DISCRIMINATORS} from '@fluxer/constants/src/DiscriminatorConstants';
@@ -64,7 +65,7 @@ export class DiscriminatorService implements IDiscriminatorService {
) {}
private async canUseCustomDiscriminator(user?: User | null): Promise<boolean> {
if (Config.instance.selfHosted) {
if (Config.instance.selfHosted && !isPremiumTieringActive()) {
return true;
}
if (!user) {
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getConfig} from '@app/api/Config';
import {createDefaultLimitConfig} from '@app/api/constants/LimitConfig';
import {DiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
function createService(): DiscriminatorService {
const userRepository = {
async findByUsernameDiscriminator() {
return null;
},
async findDiscriminatorsByUsername() {
return new Set<number>([42]);
},
} as unknown as IUserRepository;
const cacheService = {
async acquireLock() {
return 'token';
},
async releaseLock() {},
async sismember() {
return false;
},
async sadd() {},
async smembers() {
return new Set<string>();
},
} as unknown as ICacheService;
const limitConfigService = {
getConfigSnapshot: () => createDefaultLimitConfig({selfHosted: true, premiumMode: 'mirror'}),
} as unknown as LimitConfigService;
return new DiscriminatorService(userRepository, cacheService, limitConfigService);
}
describe('DiscriminatorService on a self-hosted instance', () => {
let originalSelfHosted: boolean;
let originalPremiumMode: ReturnType<typeof getCachedInstancePremiumMode>;
beforeEach(() => {
originalSelfHosted = getConfig().instance.selfHosted;
originalPremiumMode = getCachedInstancePremiumMode();
getConfig().instance.selfHosted = true;
});
afterEach(() => {
getConfig().instance.selfHosted = originalSelfHosted;
setCachedInstancePremiumMode(originalPremiumMode);
});
test('lets anyone pick a discriminator when everyone is premium', async () => {
setCachedInstancePremiumMode('everyone');
const result = await createService().generateDiscriminator({username: 'someone', requestedDiscriminator: 42});
expect(result).toEqual({discriminator: 42, available: true});
});
test('follows the custom discriminator limit in mirror mode', async () => {
setCachedInstancePremiumMode('mirror');
const result = await createService().generateDiscriminator({username: 'someone', requestedDiscriminator: 42});
expect(result.available).toBe(true);
expect(result.discriminator).not.toBe(42);
});
});
@@ -22,10 +22,6 @@ import {
DEFAULT_DOMAIN_MIGRATION_CONFIG,
type DomainMigrationConfig,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
type VoiceNoiseSuppressionConfig,
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {
DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
type ExperimentDeliveryConfig,
@@ -38,7 +34,6 @@ import {
} from '@pkgs/postgres/src/Client';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
@@ -287,75 +282,6 @@ describe('InstanceConfigRepository', () => {
expect(domains).not.toContain('example.com');
});
it('returns the default voice noise suppression config when the key is absent', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual(DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG);
});
it.each([
{name: 'unparseable text', stored: 'not-json'},
{name: 'a json array', stored: '[]'},
{name: 'out-of-range values', stored: '{"rollout_basis_points":99999}'},
{name: 'an unknown backend', stored: '{"default_backend":"magic"}'},
])('falls back to the default voice noise suppression config for $name', async ({stored}) => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
await repository.setConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, stored);
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual(DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG);
});
it('round-trips a stored voice noise suppression config', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
const config: VoiceNoiseSuppressionConfig = {
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
enabled: true,
config_version: 3,
default_backend: 'rnnoise',
enabled_backends: ['none', 'standard', 'rnnoise'],
allow_user_override: false,
rollout_basis_points: 2500,
rollout_salt: 'voice-ns-v2',
included_user_ids: ['1400000000000000001'],
excluded_user_ids: ['1400000000000000002'],
guild_overrides: [{guild_id: '2400000000000000001', backend: 'rnnoise'}],
suppression_strength: 55,
};
await repository.setVoiceNoiseSuppressionConfig(config);
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual(config);
});
it('fills newly added voice noise suppression fields from the schema defaults', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
await repository.setConfig(
VOICE_NOISE_SUPPRESSION_CONFIG_KEY,
JSON.stringify({enabled: true, config_version: 2, rollout_basis_points: 1000}),
);
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual({
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
enabled: true,
config_version: 2,
rollout_basis_points: 1000,
});
});
it('returns the default domain migration config when the key is absent', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
@@ -494,26 +420,6 @@ describe('InstanceConfigRepository', () => {
});
});
it('publishes a refresh so another repository observes the voice noise suppression config', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const reader = createRepository(kvProvider);
const writer = createRepository(kvProvider);
await expect(reader.getVoiceNoiseSuppressionConfig()).resolves.toEqual(DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG);
await writer.setVoiceNoiseSuppressionConfig({
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
enabled: true,
config_version: 1,
});
await vi.waitFor(async () => {
expect(await reader.getVoiceNoiseSuppressionConfig()).toMatchObject({enabled: true, config_version: 1});
});
});
it('uses the registration URL id as the admin-visible registration code', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
@@ -15,6 +15,12 @@ import {normalizeSsoAllowedEmailDomains} from '@app/api/instance/SsoConfigValida
import {Logger} from '@app/api/Logger';
import {isLimitConfigSnapshot} from '@app/api/limits/LimitConfigValidation';
import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache';
import {
getEffectiveBillingConfig,
isBillingActive,
isStripeServiceable,
setStoredBillingConfig,
} from '@app/api/stripe/BillingConfigCache';
import {InstanceConfiguration} from '@app/api/Tables';
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '@app/api/utils/AttachmentDecay';
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
@@ -40,6 +46,11 @@ import {
type GatewayRolloutConfig,
GatewayRolloutConfigSchema,
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {
type BillingCatalogMode,
type StoredBillingConfig,
StoredBillingConfigSchema,
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
import {
type ProfileTimezoneConfig,
ProfileTimezoneConfigSchema,
@@ -50,10 +61,6 @@ import {
PushRelayConfigSchema,
toLegacyPushServiceDeliveryWire,
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {
type VoiceNoiseSuppressionConfig,
VoiceNoiseSuppressionConfigSchema,
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {
type ExperimentDeliveryConfig,
ExperimentDeliveryConfigSchema,
@@ -75,7 +82,6 @@ import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {z} from 'zod';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
const ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
@@ -90,6 +96,7 @@ const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
const LIMIT_CONFIG_KEY = 'limit_config';
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
const INSTANCE_MEDIA_CONFIG_KEY = 'instance_media_config';
const INSTANCE_BILLING_CONFIG_KEY = 'instance_billing_config';
export const INSTANCE_CONFIG_REFRESH_CHANNEL = 'instance-config-refresh';
export const REGISTRATION_PENDING_APPROVAL_TRAIT = 'registration_pending_approval';
export const REGISTRATION_REJECTED_TRAIT = 'registration_rejected';
@@ -116,6 +123,50 @@ interface InstanceAppPublicConfig extends Omit<InstanceAppPublic, 'setup'> {
setup: Pick<InstanceSetup, 'configured'>;
}
type InstanceBrandingPatch = Partial<Omit<InstanceBranding, 'premium_product_name'>> & {
premium_product_name?: string | null;
};
export type InstanceBillingConfig = StoredBillingConfig;
export type InstanceBillingPriceSetPatch = Partial<NonNullable<StoredBillingConfig['prices']>[string]>;
export interface InstanceBillingConfigPatch {
enabled?: boolean | null;
stripe_secret_key?: string | null;
stripe_webhook_secret?: string | null;
automatic_tax?: boolean | null;
tax_id_collection?: boolean | null;
terms_consent_required?: boolean | null;
default_currency?: string | null;
prices?: Record<string, InstanceBillingPriceSetPatch> | null;
country_currencies?: Record<string, string> | null;
legacy_prices?: Record<string, Array<string>> | null;
}
export interface InstanceBillingAdminConfig {
enabled: boolean | null;
effective_enabled: boolean;
stripe_secret_key_set: boolean;
stripe_webhook_secret_set: boolean;
stripe_secret_key_stored: boolean;
stripe_webhook_secret_stored: boolean;
automatic_tax: boolean | null;
tax_id_collection: boolean | null;
terms_consent_required: boolean | null;
effective_automatic_tax: boolean;
effective_tax_id_collection: boolean;
effective_terms_consent_required: boolean;
default_currency: string | null;
prices: StoredBillingConfig['prices'];
country_currencies: StoredBillingConfig['country_currencies'];
legacy_prices: StoredBillingConfig['legacy_prices'];
billing_active: boolean;
stripe_serviceable: boolean;
catalog_mode: BillingCatalogMode;
webhook_url: string;
}
export type InstancePremiumMode = 'mirror' | 'everyone';
interface LimitConfigInputs {
@@ -356,6 +407,10 @@ function normalizeOptionalPublicString(value: string | null | undefined, fallbac
return value === undefined ? fallback : normalizeOptionalString(value);
}
export function getDefaultPremiumProductName(): string {
return Config.instance.selfHosted ? 'Premium' : 'Plutonium';
}
function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
return {
branding: {
@@ -368,6 +423,8 @@ function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
theme_color: normalizeOptionalString(Config.instance.branding.themeColor),
status_page_url: normalizeOptionalString(Config.instance.branding.statusPageUrl),
status_page_incident_history_url: normalizeOptionalString(Config.instance.branding.statusPageIncidentHistoryUrl),
premium_product_name: getDefaultPremiumProductName(),
premium_info_url: null,
},
setup: {
configured: !Config.instance.selfHosted || Config.instance.setup.configured,
@@ -385,7 +442,6 @@ function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
type StoredConfigSection =
| 'app public'
| 'gateway rollout'
| 'voice noise suppression'
| 'push relay'
| 'domain migration'
| 'altcha captcha'
@@ -394,6 +450,7 @@ type StoredConfigSection =
| 'instance policy'
| 'integrations'
| 'media'
| 'billing'
| 'registration'
| 'registration URLs'
| 'pending registrations'
@@ -524,10 +581,6 @@ function parseStoredGatewayRolloutConfig(raw: string | null): GatewayRolloutConf
return decodeGatewayRolloutConfig(parseStoredConfigValue(raw, 'gateway rollout'));
}
function parseStoredVoiceNoiseSuppressionConfig(raw: string | null): VoiceNoiseSuppressionConfig {
return parseStoredConfigOrDefault(VoiceNoiseSuppressionConfigSchema, raw, 'voice noise suppression');
}
const StoredPushRelayConfigSchema = PushRelayConfigSchema.extend({
config_version: z.number().int().min(0).default(0),
});
@@ -577,7 +630,10 @@ function parseStoredCollection<T>(schema: z.ZodType<T>, raw: string | null, sect
}
const StoredInstanceAppPublicSchema = InstanceAppPublicSchema.extend({
branding: InstanceAppPublicSchema.shape.branding.partial().optional(),
branding: InstanceAppPublicSchema.shape.branding
.extend({premium_product_name: z.string().max(40).nullable()})
.partial()
.optional(),
setup: InstanceAppPublicSchema.shape.setup.pick({configured: true}).partial().optional(),
legal: InstanceAppPublicSchema.shape.legal.partial().optional(),
registration: InstanceAppPublicSchema.shape.registration.partial().optional(),
@@ -589,10 +645,6 @@ function parseStoredAppPublicConfig(raw: string | null): InstanceAppPublicConfig
);
}
function decodeAppPublicConfig(value: unknown): InstanceAppPublicConfig {
return buildAppPublicConfig(validateStoredConfig(StoredInstanceAppPublicSchema, value, 'app public'));
}
function buildAppPublicConfig(config: z.infer<typeof StoredInstanceAppPublicSchema>): InstanceAppPublicConfig {
const defaults = getDefaultAppPublicConfig();
const {branding = {}, setup = {}, legal = {}, registration = {}} = config;
@@ -610,6 +662,9 @@ function buildAppPublicConfig(config: z.infer<typeof StoredInstanceAppPublicSche
branding.status_page_incident_history_url,
defaults.branding.status_page_incident_history_url,
),
premium_product_name:
normalizeOptionalString(branding.premium_product_name) ?? defaults.branding.premium_product_name,
premium_info_url: normalizeOptionalPublicString(branding.premium_info_url, defaults.branding.premium_info_url),
},
setup: {
configured: setup.configured ?? defaults.setup.configured,
@@ -733,6 +788,30 @@ function parseStoredInstanceIntegrationsConfig(raw: string | null): InstanceInte
);
}
function decodeInstanceBillingConfig(value: unknown): InstanceBillingConfig {
return validateStoredConfig(StoredBillingConfigSchema, value, 'billing');
}
function parseStoredInstanceBillingConfig(raw: string | null): InstanceBillingConfig {
return salvageStoredConfig(StoredBillingConfigSchema, readStoredConfigValue(raw, 'billing'), 'billing');
}
function normalizeBillingPrices(
prices: Record<string, InstanceBillingPriceSetPatch> | null,
): Record<string, InstanceBillingPriceSetPatch> | null {
if (prices === null) return null;
const entries = Object.entries(prices).map(([currency, set]): [string, InstanceBillingPriceSetPatch] => [
currency,
{
monthly: set.monthly ?? null,
yearly: set.yearly ?? null,
gift_1_month: set.gift_1_month ?? null,
gift_1_year: set.gift_1_year ?? null,
},
]);
return entries.length === 0 ? null : Object.fromEntries(entries);
}
function secretIsSet(value: unknown): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
@@ -1207,7 +1286,6 @@ export class InstanceConfigRepository {
checkStoredConfig('gateway rollout', () =>
parseStoredGatewayRolloutConfig(snapshot.get(GATEWAY_ROLLOUT_CONFIG_KEY) ?? null),
);
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null);
@@ -1239,6 +1317,7 @@ export class InstanceConfigRepository {
parseStoredInstanceIntegrationsConfig(snapshot.get(INSTANCE_INTEGRATIONS_CONFIG_KEY) ?? null),
);
checkStoredConfig('media', () => parseStoredInstanceMediaConfig(snapshot.get(INSTANCE_MEDIA_CONFIG_KEY) ?? null));
setStoredBillingConfig(parseStoredInstanceBillingConfig(snapshot.get(INSTANCE_BILLING_CONFIG_KEY) ?? null));
const appPublic = parseStoredAppPublicConfig(snapshot.get(APP_PUBLIC_CONFIG_KEY) ?? null);
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
setCachedDateOfBirthCollection(appPublic.registration.collect_date_of_birth);
@@ -1287,27 +1366,6 @@ export class InstanceConfigRepository {
);
}
async getVoiceNoiseSuppressionConfig(): Promise<VoiceNoiseSuppressionConfig> {
const raw = await this.getConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY);
return parseStoredVoiceNoiseSuppressionConfig(raw);
}
async setVoiceNoiseSuppressionConfig(config: VoiceNoiseSuppressionConfig): Promise<void> {
await this.updateVoiceNoiseSuppressionConfig(() => config);
}
updateVoiceNoiseSuppressionConfig(
update: (current: VoiceNoiseSuppressionConfig) => VoiceNoiseSuppressionConfig,
): Promise<VoiceNoiseSuppressionConfig> {
return this.updateStoredConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, (raw) =>
validateStoredConfig(
VoiceNoiseSuppressionConfigSchema,
update(parseStoredVoiceNoiseSuppressionConfig(raw)),
'voice noise suppression',
),
);
}
async getLegacyPushServiceDeliveryWire(): Promise<LegacyPushServiceDeliveryWire> {
const raw = await this.getConfig(PUSH_RELAY_CONFIG_KEY);
return parseStoredPushRelayConfig(raw);
@@ -1433,32 +1491,49 @@ export class InstanceConfigRepository {
}
async setAppPublicConfig(config: {
branding?: Partial<InstanceBranding>;
branding?: InstanceBrandingPatch;
setup?: Partial<InstanceAppPublicConfig['setup']>;
legal?: Partial<InstanceAppPublicConfig['legal']>;
registration?: Partial<InstanceAppPublicConfig['registration']>;
}): Promise<InstanceAppPublicConfig> {
const next = await this.updateStoredConfig(APP_PUBLIC_CONFIG_KEY, (raw) => {
const current = parseStoredAppPublicConfig(raw);
return decodeAppPublicConfig({
branding: {
...current.branding,
...(config.branding ?? {}),
const cache = this.configCache;
const {result: next} = await this.compareAndSetStoredValue(cache, APP_PUBLIC_CONFIG_KEY, (raw) => {
const stored = salvageStoredConfig(
StoredInstanceAppPublicSchema,
readStoredConfigValue(raw, 'app public'),
'app public',
);
const current = buildAppPublicConfig(stored);
const premiumProductName =
config.branding?.premium_product_name !== undefined
? config.branding.premium_product_name
: normalizeOptionalString(stored.branding?.premium_product_name);
const merged = validateStoredConfig(
StoredInstanceAppPublicSchema,
{
branding: {
...current.branding,
...(config.branding ?? {}),
premium_product_name: premiumProductName,
},
setup: {
...current.setup,
...(config.setup ?? {}),
},
legal: {
...current.legal,
...(config.legal ?? {}),
},
registration: {
...current.registration,
...(config.registration ?? {}),
},
},
setup: {
...current.setup,
...(config.setup ?? {}),
},
legal: {
...current.legal,
...(config.legal ?? {}),
},
registration: {
...current.registration,
...(config.registration ?? {}),
},
});
'app public',
);
return {value: JSON.stringify(merged), result: buildAppPublicConfig(merged)};
});
await this.publishRefresh(cache.sourceId);
setCachedDateOfBirthCollection(next.registration.collect_date_of_birth);
return next;
}
@@ -1758,6 +1833,71 @@ export class InstanceConfigRepository {
};
}
async getInstanceBillingConfig(): Promise<InstanceBillingConfig> {
const raw = await this.getConfig(INSTANCE_BILLING_CONFIG_KEY);
const config = parseStoredInstanceBillingConfig(raw);
setStoredBillingConfig(config);
return config;
}
async readStoredInstanceBillingConfig(): Promise<InstanceBillingConfig> {
const cache = this.configCache;
cache.assertActive();
const raw = await this.fetchConfigFromDatabase(INSTANCE_BILLING_CONFIG_KEY);
cache.assertActive();
return parseStoredInstanceBillingConfig(raw);
}
async setInstanceBillingConfig(patch: InstanceBillingConfigPatch): Promise<InstanceBillingConfig> {
const next = await this.updateStoredConfig(INSTANCE_BILLING_CONFIG_KEY, (raw) => {
const current = parseStoredInstanceBillingConfig(raw);
return decodeInstanceBillingConfig({
enabled: patch.enabled === undefined ? current.enabled : patch.enabled,
stripe_secret_key: patch.stripe_secret_key === undefined ? current.stripe_secret_key : patch.stripe_secret_key,
stripe_webhook_secret:
patch.stripe_webhook_secret === undefined ? current.stripe_webhook_secret : patch.stripe_webhook_secret,
automatic_tax: patch.automatic_tax === undefined ? current.automatic_tax : patch.automatic_tax,
tax_id_collection: patch.tax_id_collection === undefined ? current.tax_id_collection : patch.tax_id_collection,
terms_consent_required:
patch.terms_consent_required === undefined ? current.terms_consent_required : patch.terms_consent_required,
default_currency: patch.default_currency === undefined ? current.default_currency : patch.default_currency,
prices: patch.prices === undefined ? current.prices : normalizeBillingPrices(patch.prices),
country_currencies:
patch.country_currencies === undefined ? current.country_currencies : patch.country_currencies,
legacy_prices: patch.legacy_prices === undefined ? current.legacy_prices : patch.legacy_prices,
});
});
setStoredBillingConfig(next);
return next;
}
async getInstanceBillingAdminConfig(): Promise<InstanceBillingAdminConfig> {
const stored = await this.getInstanceBillingConfig();
const effective = getEffectiveBillingConfig();
return {
enabled: stored.enabled,
effective_enabled: effective.enabled,
stripe_secret_key_set: effective.secretKey !== null,
stripe_webhook_secret_set: effective.webhookSecret !== null,
stripe_secret_key_stored: secretIsSet(stored.stripe_secret_key),
stripe_webhook_secret_stored: secretIsSet(stored.stripe_webhook_secret),
automatic_tax: stored.automatic_tax,
tax_id_collection: stored.tax_id_collection,
terms_consent_required: stored.terms_consent_required,
effective_automatic_tax: effective.automaticTax,
effective_tax_id_collection: effective.taxIdCollection,
effective_terms_consent_required: effective.termsConsentRequired,
default_currency: stored.default_currency,
prices: stored.prices,
country_currencies: stored.country_currencies,
legacy_prices: stored.legacy_prices,
billing_active: isBillingActive(effective),
stripe_serviceable: isStripeServiceable(effective),
catalog_mode: effective.catalogMode,
webhook_url: `${Config.endpoints.apiPublic.replace(/\/+$/, '')}/stripe/webhook`,
};
}
async getInstanceCommunityPublicConfig(): Promise<InstanceCommunity> {
const policy = await this.getInstancePolicyConfig();
return {
@@ -0,0 +1,330 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import {
getEffectiveBillingConfig,
getStoredBillingConfig,
setStoredBillingConfig,
} from '@app/api/stripe/BillingConfigCache';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const INSTANCE_BILLING_CONFIG_KEY = 'instance_billing_config';
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
const GBP_PRICES = {
monthly: 'price_GbpMonthly',
yearly: 'price_GbpYearly',
gift_1_month: 'price_GbpGiftMonth',
gift_1_year: 'price_GbpGiftYear',
};
describe('InstanceConfigRepository billing and premium branding', () => {
const repositories: Array<InstanceConfigRepository> = [];
const originalSelfHosted = Config.instance.selfHosted;
let originalStored = getStoredBillingConfig();
let originalPremiumMode = getCachedInstancePremiumMode();
let executor: InMemoryCassandraQueryExecutor;
beforeEach(() => {
originalStored = getStoredBillingConfig();
originalPremiumMode = getCachedInstancePremiumMode();
executor = new InMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
Config.instance.selfHosted = true;
});
afterEach(() => {
for (const repository of repositories) {
repository.shutdown();
}
repositories.length = 0;
Config.instance.selfHosted = originalSelfHosted;
setCachedInstancePremiumMode(originalPremiumMode);
setStoredBillingConfig(originalStored);
});
function createRepository(kvProvider = new MockKVProvider()): InstanceConfigRepository {
const repository = new InstanceConfigRepository(kvProvider);
repositories.push(repository);
return repository;
}
async function readRaw(repository: InstanceConfigRepository, key: string): Promise<unknown> {
const raw = await repository.getConfig(key);
return raw === null ? null : JSON.parse(raw);
}
it('returns an all-null billing config when nothing is stored', async () => {
const repository = createRepository();
expect(await repository.getInstanceBillingConfig()).toEqual({
enabled: null,
stripe_secret_key: null,
stripe_webhook_secret: null,
automatic_tax: null,
tax_id_collection: null,
terms_consent_required: null,
default_currency: null,
prices: null,
country_currencies: null,
legacy_prices: null,
});
expect(getEffectiveBillingConfig().catalogMode).toBe('env');
});
it('merges patches, keeps secrets on undefined, clears them on null and replaces the catalog maps', async () => {
const repository = createRepository();
await repository.setInstanceBillingConfig({
enabled: true,
stripe_secret_key: 'sk_test_operator',
stripe_webhook_secret: 'whsec_operator',
default_currency: 'GBP',
prices: {GBP: GBP_PRICES, CHF: {monthly: 'price_ChfMonthly', yearly: 'price_ChfYearly'}},
country_currencies: {CH: 'CHF'},
legacy_prices: {monthly_GBP: ['price_GbpOld']},
});
const merged = await repository.setInstanceBillingConfig({
default_currency: 'CHF',
prices: {GBP: GBP_PRICES},
country_currencies: {LI: 'CHF'},
});
expect(merged).toEqual({
enabled: true,
stripe_secret_key: 'sk_test_operator',
stripe_webhook_secret: 'whsec_operator',
automatic_tax: null,
tax_id_collection: null,
terms_consent_required: null,
default_currency: 'CHF',
prices: {GBP: GBP_PRICES},
country_currencies: {LI: 'CHF'},
legacy_prices: {monthly_GBP: ['price_GbpOld']},
});
expect(getStoredBillingConfig()).toEqual(merged);
expect(getEffectiveBillingConfig().catalogMode).toBe('operator');
const cleared = await repository.setInstanceBillingConfig({
stripe_webhook_secret: null,
legacy_prices: null,
});
expect(cleared.stripe_secret_key).toBe('sk_test_operator');
expect(cleared.stripe_webhook_secret).toBeNull();
expect(cleared.legacy_prices).toBeNull();
expect(await readRaw(repository, INSTANCE_BILLING_CONFIG_KEY)).toEqual(cleared);
});
it('fills missing price slots with null and treats an empty price map as the env catalog', async () => {
const repository = createRepository();
const partial = await repository.setInstanceBillingConfig({prices: {GBP: {monthly: 'price_GbpMonthly'}}});
expect(partial.prices).toEqual({
GBP: {monthly: 'price_GbpMonthly', yearly: null, gift_1_month: null, gift_1_year: null},
});
const emptied = await repository.setInstanceBillingConfig({prices: {}});
expect(emptied.prices).toBeNull();
expect(getEffectiveBillingConfig().catalogMode).toBe('env');
});
it('rejects a patch that would store an invalid value', async () => {
const repository = createRepository();
await expect(repository.setInstanceBillingConfig({default_currency: 'gbp'})).rejects.toThrow(/billing/);
await expect(repository.setInstanceBillingConfig({prices: {GBP: {monthly: 'not-a-price-id'}}})).rejects.toThrow(
/billing/,
);
expect(await repository.getConfig(INSTANCE_BILLING_CONFIG_KEY)).toBeNull();
});
it('redacts secrets in the admin view and reports the effective state', async () => {
Config.instance.selfHosted = true;
setCachedInstancePremiumMode('mirror');
const repository = createRepository();
await repository.setInstanceBillingConfig({
enabled: true,
stripe_secret_key: 'sk_test_do_not_leak',
stripe_webhook_secret: 'whsec_do_not_leak',
prices: {GBP: GBP_PRICES},
});
const admin = await repository.getInstanceBillingAdminConfig();
expect(JSON.stringify(admin)).not.toContain('do_not_leak');
expect(admin).toEqual({
enabled: true,
effective_enabled: true,
stripe_secret_key_set: true,
stripe_webhook_secret_set: true,
stripe_secret_key_stored: true,
stripe_webhook_secret_stored: true,
automatic_tax: null,
tax_id_collection: null,
terms_consent_required: null,
effective_automatic_tax: false,
effective_tax_id_collection: false,
effective_terms_consent_required: false,
default_currency: null,
prices: {GBP: GBP_PRICES},
country_currencies: null,
legacy_prices: null,
billing_active: true,
stripe_serviceable: true,
catalog_mode: 'operator',
webhook_url: `${Config.endpoints.apiPublic.replace(/\/+$/, '')}/stripe/webhook`,
});
setCachedInstancePremiumMode('everyone');
const everyone = await repository.getInstanceBillingAdminConfig();
expect(everyone.billing_active).toBe(false);
expect(everyone.stripe_serviceable).toBe(false);
});
it('keeps, sets and clears the checkout flags like the other nullable fields', async () => {
const repository = createRepository();
const set = await repository.setInstanceBillingConfig({automatic_tax: true, terms_consent_required: false});
expect(set.automatic_tax).toBe(true);
expect(set.tax_id_collection).toBeNull();
expect(set.terms_consent_required).toBe(false);
const kept = await repository.setInstanceBillingConfig({tax_id_collection: true});
expect(kept.automatic_tax).toBe(true);
expect(kept.tax_id_collection).toBe(true);
expect(kept.terms_consent_required).toBe(false);
const cleared = await repository.setInstanceBillingConfig({automatic_tax: null});
expect(cleared.automatic_tax).toBeNull();
const admin = await repository.getInstanceBillingAdminConfig();
expect(admin.automatic_tax).toBeNull();
expect(admin.tax_id_collection).toBe(true);
expect(admin.terms_consent_required).toBe(false);
expect(admin.effective_automatic_tax).toBe(false);
expect(admin.effective_tax_id_collection).toBe(true);
expect(admin.effective_terms_consent_required).toBe(false);
});
it('reports env-sourced secrets as set but not stored', async () => {
const repository = createRepository();
const admin = await repository.getInstanceBillingAdminConfig();
expect(admin.stripe_secret_key_set).toBe(Boolean(Config.stripe.secretKey));
expect(admin.stripe_webhook_secret_set).toBe(Boolean(Config.stripe.webhookSecret));
expect(admin.stripe_secret_key_stored).toBe(false);
expect(admin.stripe_webhook_secret_stored).toBe(false);
await repository.setInstanceBillingConfig({stripe_webhook_secret: 'whsec_stored_only'});
const stored = await repository.getInstanceBillingAdminConfig();
expect(stored.stripe_secret_key_stored).toBe(false);
expect(stored.stripe_webhook_secret_stored).toBe(true);
});
it('ignores a stored billing config on hosted', async () => {
Config.instance.selfHosted = false;
const repository = createRepository();
await repository.setInstanceBillingConfig({
enabled: false,
stripe_secret_key: 'sk_test_stored',
prices: {GBP: GBP_PRICES},
automatic_tax: false,
tax_id_collection: false,
terms_consent_required: false,
});
const effective = getEffectiveBillingConfig();
expect(effective.catalogMode).toBe('env');
expect(effective.enabled).toBe(Config.stripe.enabled);
expect(effective.secretKey).toBe(Config.stripe.secretKey || null);
const admin = await repository.getInstanceBillingAdminConfig();
expect(admin.catalog_mode).toBe('env');
expect(admin.effective_automatic_tax).toBe(true);
expect(admin.effective_tax_id_collection).toBe(true);
expect(admin.effective_terms_consent_required).toBe(true);
});
it('salvages the valid fields of a partly invalid stored billing config', async () => {
const repository = createRepository();
await repository.setConfig(
INSTANCE_BILLING_CONFIG_KEY,
JSON.stringify({
enabled: 'yes',
stripe_secret_key: 'sk_test_kept',
default_currency: 'GBP',
prices: {
GBP: GBP_PRICES,
gbp: GBP_PRICES,
EUR: {...GBP_PRICES, monthly: 'bogus'},
},
country_currencies: {GB: 'GBP', gb: 'GBP'},
}),
);
const config = await repository.getInstanceBillingConfig();
expect(config.enabled).toBeNull();
expect(config.stripe_secret_key).toBe('sk_test_kept');
expect(config.default_currency).toBe('GBP');
expect(config.prices?.GBP).toEqual(GBP_PRICES);
expect(config.prices).not.toHaveProperty('gbp');
expect(config.prices?.EUR?.monthly ?? null).toBeNull();
expect(config.country_currencies).toEqual({GB: 'GBP'});
});
it('falls back to an empty billing config when the stored value is not JSON', async () => {
const repository = createRepository();
await repository.setConfig(INSTANCE_BILLING_CONFIG_KEY, '{not json');
expect((await repository.getInstanceBillingConfig()).prices).toBeNull();
});
it('updates the process billing cache when another repository publishes a billing change', async () => {
const kvProvider = new MockKVProvider();
const reader = createRepository(kvProvider);
const writer = createRepository(kvProvider);
await reader.getInstanceBillingConfig();
await writer.getInstanceBillingConfig();
setStoredBillingConfig(null);
await writer.setConfig(
INSTANCE_BILLING_CONFIG_KEY,
JSON.stringify({enabled: true, prices: {GBP: GBP_PRICES}, default_currency: 'GBP'}),
);
await vi.waitFor(() => {
expect(getStoredBillingConfig()?.default_currency).toBe('GBP');
});
expect(getEffectiveBillingConfig().catalogMode).toBe('operator');
});
it('defaults the premium product name to Plutonium on hosted and Premium on self-hosted', async () => {
const repository = createRepository();
Config.instance.selfHosted = false;
const hosted = await repository.getAppPublicConfig();
expect(hosted.branding.premium_product_name).toBe('Plutonium');
expect(hosted.branding.premium_info_url).toBeNull();
Config.instance.selfHosted = true;
expect((await repository.getAppPublicConfig()).branding.premium_product_name).toBe('Premium');
});
it('stores the premium name nullable so a reset and unrelated branding saves keep the default', async () => {
Config.instance.selfHosted = true;
const repository = createRepository();
await repository.setAppPublicConfig({branding: {theme_color: '#123456'}});
expect(await readRaw(repository, APP_PUBLIC_CONFIG_KEY)).toMatchObject({
branding: {theme_color: '#123456', premium_product_name: null},
});
const named = await repository.setAppPublicConfig({
branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'},
});
expect(named.branding.premium_product_name).toBe('Gold');
expect(named.branding.premium_info_url).toBe('https://example.com/gold');
const unrelated = await repository.setAppPublicConfig({branding: {product_name: 'Example'}});
expect(unrelated.branding.premium_product_name).toBe('Gold');
expect(unrelated.branding.premium_info_url).toBe('https://example.com/gold');
const reset = await repository.setAppPublicConfig({branding: {premium_product_name: null, premium_info_url: null}});
expect(reset.branding.premium_product_name).toBe('Premium');
expect(reset.branding.premium_info_url).toBeNull();
expect(await readRaw(repository, APP_PUBLIC_CONFIG_KEY)).toMatchObject({
branding: {product_name: 'Example', premium_product_name: null, premium_info_url: null},
});
Config.instance.selfHosted = false;
expect((await repository.getAppPublicConfig()).branding.premium_product_name).toBe('Plutonium');
});
});
@@ -12,6 +12,7 @@ import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceCon
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import {isBillingActive, isPremiumTieringActive, isStripeServiceable} from '@app/api/stripe/BillingConfigCache';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {API_CODE_VERSION} from '@fluxer/constants/src/AppConstants';
import {buildDiscoveryResponse, type DiscoveryStaticInput} from '@fluxer/instance_bootstrap/src/BuildDiscovery';
@@ -66,7 +67,9 @@ function buildDiscoveryStaticInput(
},
features: {
voice_enabled: Config.voice.enabled,
stripe_enabled: Config.stripe.enabled,
stripe_enabled: isBillingActive(),
premium_enabled: isPremiumTieringActive(),
stripe_serviceable: isStripeServiceable(),
self_hosted: Config.instance.selfHosted,
presigned_attachment_uploads: Config.presignedAttachmentUploadsEnabled,
emails_enabled: runtime.emailEnabled,
+1 -1
View File
@@ -216,7 +216,7 @@ export class UserSettings {
friend_source_flags: friendSourceFlags,
incoming_call_flags: IncomingCallFlags.FRIENDS_ONLY,
group_dm_add_permission_flags: GroupDmAddPermissionFlags.FRIENDS_ONLY,
default_guilds_restricted: false,
default_guilds_restricted: true,
bot_default_guilds_restricted: false,
restricted_guilds: new Set(),
bot_restricted_guilds: new Set(),
+32 -50
View File
@@ -3030,7 +3030,8 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Clears all read state and acknowledgement records for a channel, marking all messages as unread. Returns 204 No Content on success.",
"description": "Deprecated. Has no effect on the read state. Returns 204 No Content.",
"deprecated": true,
"security": [{"botToken": []}, {"sessionToken": []}],
"parameters": [
{
@@ -26130,7 +26131,10 @@
"anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}]
},
"currency": {"description": "Currency for the prices", "$ref": "#/components/schemas/PremiumCurrency"},
"gift_currency": {"description": "Currency for gift prices", "$ref": "#/components/schemas/PremiumCurrency"}
"gift_currency": {
"anyOf": [{"$ref": "#/components/schemas/PremiumCurrency"}, {"type": "null"}],
"description": "Currency for gift prices, null when no gift prices are configured"
}
},
"required": ["currency", "gift_currency"],
"additionalProperties": false
@@ -27946,7 +27950,6 @@
"assignments": {
"type": "object",
"properties": {
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"},
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"},
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneAssignmentResponse"}
@@ -29757,6 +29760,14 @@
"status_page_incident_history_url": {
"description": "Optional public status page incident history URL",
"type": ["string", "null"]
},
"premium_product_name": {
"type": "string",
"description": "Name of the premium tier shown by client applications"
},
"premium_info_url": {
"description": "Optional absolute URL of a page describing the premium tier",
"type": ["string", "null"]
}
},
"required": [
@@ -29768,7 +29779,9 @@
"favicon_url",
"theme_color",
"status_page_url",
"status_page_incident_history_url"
"status_page_incident_history_url",
"premium_product_name",
"premium_info_url"
],
"additionalProperties": false,
"description": "Branding values safe to expose to clients"
@@ -29865,7 +29878,18 @@
"type": "object",
"properties": {
"voice_enabled": {"type": "boolean", "description": "Whether voice/video calling is enabled"},
"stripe_enabled": {"type": "boolean", "description": "Whether Stripe payments are enabled"},
"stripe_enabled": {
"type": "boolean",
"description": "Whether premium purchases through Stripe are available"
},
"premium_enabled": {
"type": "boolean",
"description": "Whether this instance has a premium tier, so premium status, gifts and perks apply"
},
"stripe_serviceable": {
"type": "boolean",
"description": "Whether existing Stripe subscriptions can be managed, cancelled and billed on this instance"
},
"self_hosted": {"type": "boolean", "description": "Whether this is a self-hosted instance"},
"presigned_attachment_uploads": {
"type": "boolean",
@@ -29879,6 +29903,8 @@
"required": [
"voice_enabled",
"stripe_enabled",
"premium_enabled",
"stripe_serviceable",
"self_hosted",
"presigned_attachment_uploads",
"emails_enabled"
@@ -31643,50 +31669,6 @@
"required": ["enabled"],
"additionalProperties": false
},
"VoiceNoiseSuppressionAssignmentResponse": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"config_version": {"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991},
"user_targeted": {"type": "boolean"},
"backend": {"anyOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}, {"type": "null"}]},
"source": {"anyOf": [{"type": "string", "enum": ["user_rule", "canary"]}, {"type": "null"}]},
"guild_overrides": {
"type": "array",
"items": {
"type": "object",
"properties": {
"guild_id": {"type": "string", "pattern": "^\\d{1,20}$"},
"backend": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
},
"required": ["guild_id", "backend"],
"additionalProperties": false
}
},
"enabled_backends": {
"type": "array",
"items": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
},
"allow_user_override": {"type": "boolean"},
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
},
"required": [
"enabled",
"config_version",
"user_targeted",
"backend",
"source",
"guild_overrides",
"enabled_backends",
"allow_user_override",
"suppression_strength"
],
"additionalProperties": false
},
"VoiceNoiseSuppressionBackendSchema": {
"type": "string",
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
},
"GiftCodeDurationTypeSchema": {
"description": "Gift code duration unit",
"x-enumNames": ["days", "weeks", "months", "years"],
@@ -32896,7 +32878,7 @@
"enum": ["access_token", "refresh_token"],
"type": "string"
},
"PremiumCurrency": {"type": "string", "enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]},
"PremiumCurrency": {"type": "string", "pattern": "^[A-Z]{3}$"},
"SelfServeRefundIneligibilityReason": {
"type": "string",
"enum": ["no_refundable_purchase", "outside_refund_window", "cooldown_active", "feature_unavailable"]
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createChannel, createGuild} from '@app/api/guild/tests/GuildTestUtils';
import {sendMessage} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface AckResponse {
read_states: Array<{
id: string;
last_message_id: string | null;
}>;
}
describe('DELETE /channels/:channel_id/messages/ack', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('leaves the read state untouched', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Read State Guild');
const channel = await createChannel(harness, account.token, guild.id, 'read-state-channel');
const older = await sendMessage(harness, account.token, channel.id, 'older');
const newer = await sendMessage(harness, account.token, channel.id, 'newer');
await createBuilder<AckResponse>(harness, account.token)
.post('/read-states/ack')
.body({read_states: [{channel_id: channel.id, message_id: newer.id}]})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, account.token)
.delete(`/channels/${channel.id}/messages/ack`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const response = await createBuilder<AckResponse>(harness, account.token)
.post('/read-states/ack')
.body({read_states: [{channel_id: channel.id, message_id: older.id}]})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.read_states[0]?.last_message_id).toBe(newer.id);
});
});
@@ -35,8 +35,6 @@ export abstract class IReadStateRepository {
}>
>;
abstract deleteReadState(userId: UserID, channelId: ChannelID): Promise<void>;
abstract bulkAckMessages(
userId: UserID,
readStates: Array<{
@@ -4,7 +4,6 @@ import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
import {channelIdToMessageId} from '@app/api/BrandedTypes';
import {
BatchBuilder,
deleteOneOrMany,
fetchMany,
fetchManyInChunks,
fetchOne,
@@ -199,15 +198,6 @@ export class ReadStateRepository implements IReadStateRepository {
return appliedUpdates;
}
async deleteReadState(userId: UserID, channelId: ChannelID): Promise<void> {
await deleteOneOrMany(
ReadStates.deleteByPk({
user_id: userId,
channel_id: channelId,
}),
);
}
async bulkAckMessages(
userId: UserID,
readStates: Array<{
@@ -141,10 +141,6 @@ export class ReadStateService {
}
}
async deleteReadState({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
await this.repository.deleteReadState(userId, channelId);
}
async incrementMentionCount({
userId,
channelId,
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
export interface BillingBranding {
productName: string;
premiumName: string;
termsUrl: string;
upiMandateDescription: string;
}
const HOSTED_PRODUCT_NAME = 'Fluxer';
const HOSTED_PREMIUM_NAME = 'Plutonium';
const HOSTED_UPI_MANDATE_DESCRIPTION = 'Fluxer Premium';
export async function getBillingBranding(): Promise<BillingBranding> {
const marketingTermsUrl = `${Config.endpoints.marketing}/terms`;
if (!Config.instance.selfHosted) {
return {
productName: HOSTED_PRODUCT_NAME,
premiumName: HOSTED_PREMIUM_NAME,
termsUrl: marketingTermsUrl,
upiMandateDescription: HOSTED_UPI_MANDATE_DESCRIPTION,
};
}
const appPublic = await getInstanceConfigRepository().getAppPublicConfig();
const productName = appPublic.branding.product_name;
const premiumName = appPublic.branding.premium_product_name;
return {
productName,
premiumName,
termsUrl: appPublic.legal.terms_url ?? marketingTermsUrl,
upiMandateDescription: `${productName} ${premiumName}`,
};
}
@@ -0,0 +1,289 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {Config} from '@app/api/Config';
import {getCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import type {BillingCatalogMode, StoredBillingConfig} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
export type {StoredBillingConfig} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
export interface BillingPriceSet {
monthly: string | null;
yearly: string | null;
gift_1_month: string | null;
gift_1_year: string | null;
}
export interface EffectiveBillingConfig {
enabled: boolean;
secretKey: string | null;
webhookSecret: string | null;
automaticTax: boolean;
taxIdCollection: boolean;
termsConsentRequired: boolean;
catalogMode: BillingCatalogMode;
defaultCurrency: string | null;
prices: Record<string, BillingPriceSet>;
countryCurrencies: Record<string, string>;
legacyPrices: Record<string, Array<string>>;
version: string;
}
type EnvPrices = NonNullable<typeof Config.stripe.prices>;
type EnvPriceKey = keyof EnvPrices;
const ENV_CATALOG: ReadonlyArray<{
currency: string;
monthly: EnvPriceKey;
yearly: EnvPriceKey;
gift_1_month: EnvPriceKey;
gift_1_year: EnvPriceKey;
}> = [
{
currency: 'USD',
monthly: 'monthlyUsd',
yearly: 'yearlyUsd',
gift_1_month: 'gift1MonthUsd',
gift_1_year: 'gift1YearUsd',
},
{
currency: 'EUR',
monthly: 'monthlyEur',
yearly: 'yearlyEur',
gift_1_month: 'gift1MonthEur',
gift_1_year: 'gift1YearEur',
},
{
currency: 'BRL',
monthly: 'monthlyBrl',
yearly: 'yearlyBrl',
gift_1_month: 'gift1MonthBrl',
gift_1_year: 'gift1YearBrl',
},
{
currency: 'DKK',
monthly: 'monthlyDkk',
yearly: 'yearlyDkk',
gift_1_month: 'gift1MonthDkk',
gift_1_year: 'gift1YearDkk',
},
{
currency: 'INR',
monthly: 'monthlyInr',
yearly: 'yearlyInr',
gift_1_month: 'gift1MonthInr',
gift_1_year: 'gift1YearInr',
},
{
currency: 'NOK',
monthly: 'monthlyNok',
yearly: 'yearlyNok',
gift_1_month: 'gift1MonthNok',
gift_1_year: 'gift1YearNok',
},
{
currency: 'PLN',
monthly: 'monthlyPln',
yearly: 'yearlyPln',
gift_1_month: 'gift1MonthPln',
gift_1_year: 'gift1YearPln',
},
{
currency: 'SEK',
monthly: 'monthlySek',
yearly: 'yearlySek',
gift_1_month: 'gift1MonthSek',
gift_1_year: 'gift1YearSek',
},
{
currency: 'TRY',
monthly: 'monthlyTry',
yearly: 'yearlyTry',
gift_1_month: 'gift1MonthTry',
gift_1_year: 'gift1YearTry',
},
];
const PREVIOUS_WEBHOOK_SECRET_TTL_MS = 24 * 60 * 60 * 1000;
let storedBillingConfig: StoredBillingConfig | null = null;
let memoizedSource: string | null = null;
let memoizedConfig: EffectiveBillingConfig | null = null;
let lastEffectiveWebhookSecret: string | null | undefined;
let previousWebhookSecrets: Array<{secret: string; expiresAt: number}> = [];
export function setStoredBillingConfig(stored: StoredBillingConfig | null): void {
storedBillingConfig = stored;
trackWebhookSecret();
}
export function getStoredBillingConfig(): StoredBillingConfig | null {
return storedBillingConfig;
}
export function getActiveStoredBillingConfig(): StoredBillingConfig | null {
return Config.instance.selfHosted ? storedBillingConfig : null;
}
function trackWebhookSecret(): void {
if (!Config.instance.selfHosted) {
previousWebhookSecrets = [];
lastEffectiveWebhookSecret = undefined;
return;
}
const current = computeEffectiveBillingConfig().webhookSecret;
const now = Date.now();
previousWebhookSecrets = previousWebhookSecrets.filter((entry) => entry.expiresAt > now && entry.secret !== current);
if (lastEffectiveWebhookSecret && lastEffectiveWebhookSecret !== current) {
const replaced = lastEffectiveWebhookSecret;
previousWebhookSecrets = [
{secret: replaced, expiresAt: now + PREVIOUS_WEBHOOK_SECRET_TTL_MS},
...previousWebhookSecrets.filter((entry) => entry.secret !== replaced),
];
}
lastEffectiveWebhookSecret = current;
}
export function getAcceptedWebhookSecrets(config: EffectiveBillingConfig = getEffectiveBillingConfig()): Array<string> {
trackWebhookSecret();
const now = Date.now();
const secrets = config.webhookSecret ? [config.webhookSecret] : [];
for (const entry of previousWebhookSecrets) {
if (entry.expiresAt > now && !secrets.includes(entry.secret)) {
secrets.push(entry.secret);
}
}
return secrets;
}
function normalizeId(value: string | null | undefined): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function buildEnvPrices(): Record<string, BillingPriceSet> {
const env = Config.stripe.prices;
const prices: Record<string, BillingPriceSet> = {};
if (!env) return prices;
for (const entry of ENV_CATALOG) {
const set: BillingPriceSet = {
monthly: normalizeId(env[entry.monthly]),
yearly: normalizeId(env[entry.yearly]),
gift_1_month: normalizeId(env[entry.gift_1_month]),
gift_1_year: normalizeId(env[entry.gift_1_year]),
};
if (set.monthly || set.yearly || set.gift_1_month || set.gift_1_year) {
prices[entry.currency] = set;
}
}
return prices;
}
function buildEnvLegacyPrices(): Record<string, Array<string>> {
const legacy: Record<string, Array<string>> = {};
const env: unknown = Config.stripe.legacyPrices;
if (!env || typeof env !== 'object' || Array.isArray(env)) return legacy;
for (const [slot, ids] of Object.entries(env as Record<string, unknown>)) {
if (Array.isArray(ids)) legacy[slot] = ids.filter((id): id is string => typeof id === 'string');
}
return legacy;
}
function buildOperatorPrices(stored: NonNullable<StoredBillingConfig['prices']>): Record<string, BillingPriceSet> {
const prices: Record<string, BillingPriceSet> = {};
for (const [currency, set] of Object.entries(stored)) {
prices[currency] = {
monthly: normalizeId(set.monthly),
yearly: normalizeId(set.yearly),
gift_1_month: normalizeId(set.gift_1_month),
gift_1_year: normalizeId(set.gift_1_year),
};
}
return prices;
}
function computeEffectiveBillingConfig(): Omit<EffectiveBillingConfig, 'version'> {
const selfHosted = Config.instance.selfHosted;
const stored = selfHosted ? storedBillingConfig : null;
const operatorPrices = stored?.prices ?? null;
const catalogMode: BillingCatalogMode = operatorPrices === null ? 'env' : 'operator';
return {
enabled: stored?.enabled ?? Config.stripe.enabled,
secretKey: normalizeId(stored?.stripe_secret_key) ?? normalizeId(Config.stripe.secretKey),
webhookSecret: normalizeId(stored?.stripe_webhook_secret) ?? normalizeId(Config.stripe.webhookSecret),
automaticTax: selfHosted ? (stored?.automatic_tax ?? false) : true,
taxIdCollection: selfHosted ? (stored?.tax_id_collection ?? false) : true,
termsConsentRequired: selfHosted ? (stored?.terms_consent_required ?? false) : true,
catalogMode,
defaultCurrency: catalogMode === 'operator' ? (stored?.default_currency ?? null) : null,
prices: operatorPrices === null ? buildEnvPrices() : buildOperatorPrices(operatorPrices),
countryCurrencies: catalogMode === 'operator' ? {...(stored?.country_currencies ?? {})} : {},
legacyPrices:
stored?.legacy_prices != null
? Object.fromEntries(Object.entries(stored.legacy_prices).map(([slot, ids]) => [slot, [...ids]]))
: buildEnvLegacyPrices(),
};
}
export function getEffectiveBillingConfig(): EffectiveBillingConfig {
const effective = computeEffectiveBillingConfig();
const source = JSON.stringify(effective);
if (memoizedConfig !== null && memoizedSource === source) {
return memoizedConfig;
}
const config: EffectiveBillingConfig = {
...effective,
version: createHash('sha256').update(source).digest('hex').slice(0, 32),
};
memoizedSource = source;
memoizedConfig = config;
return config;
}
export function hasRecurringPricePair(config: EffectiveBillingConfig = getEffectiveBillingConfig()): boolean {
return Object.values(config.prices).some((set) => set.monthly !== null && set.yearly !== null);
}
export function getOperatorCurrencyPreferences(
countryCode: string | null | undefined,
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
): Array<string> {
const configured = Object.keys(config.prices);
const country = countryCode ? countryCode.toUpperCase() : null;
const candidates = [
country ? config.countryCurrencies[country] : undefined,
config.defaultCurrency ?? undefined,
...configured,
];
const preferences: Array<string> = [];
for (const currency of candidates) {
if (currency && configured.includes(currency) && !preferences.includes(currency)) {
preferences.push(currency);
}
}
return preferences;
}
export function isPremiumTieringActive(): boolean {
return !Config.instance.selfHosted || getCachedInstancePremiumMode() === 'mirror';
}
export function isBillingActive(config: EffectiveBillingConfig = getEffectiveBillingConfig()): boolean {
return config.enabled && config.secretKey !== null && hasRecurringPricePair(config) && isPremiumTieringActive();
}
export function isStripeServiceable(config: EffectiveBillingConfig = getEffectiveBillingConfig()): boolean {
if (config.secretKey === null) {
return false;
}
return Config.instance.selfHosted ? isPremiumTieringActive() : config.enabled;
}
export function isCurrentCatalogPriceId(
priceId: string,
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
): boolean {
return Object.values(config.prices).some(
(set) =>
set.monthly === priceId || set.yearly === priceId || set.gift_1_month === priceId || set.gift_1_year === priceId,
);
}
+65 -341
View File
@@ -2,6 +2,12 @@
import {Config} from '@app/api/Config';
import {Logger} from '@app/api/Logger';
import {
type BillingPriceSet,
type EffectiveBillingConfig,
getActiveStoredBillingConfig,
getEffectiveBillingConfig,
} from '@app/api/stripe/BillingConfigCache';
import type {Currency} from '@app/api/utils/CurrencyUtils';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
@@ -64,13 +70,25 @@ const LEGACY_SLOT_CURRENCIES: Record<string, Currency | undefined> = {
try: 'TRY',
};
function parseLegacySlot(slot: string): ProductInfo | null {
const OPERATOR_CURRENCY_PATTERN = /^[A-Z]{3}$/;
const CATALOG_SLOTS: ReadonlyArray<keyof BillingPriceSet> = ['monthly', 'yearly', 'gift_1_month', 'gift_1_year'];
function parseLegacySlotCurrency(suffix: string, catalogMode: EffectiveBillingConfig['catalogMode']): Currency | null {
if (catalogMode === 'operator') {
const upper = suffix.toUpperCase();
return OPERATOR_CURRENCY_PATTERN.test(upper) ? upper : null;
}
return LEGACY_SLOT_CURRENCIES[suffix.toLowerCase()] ?? null;
}
function parseLegacySlot(slot: string, catalogMode: EffectiveBillingConfig['catalogMode']): ProductInfo | null {
const separatorIndex = slot.lastIndexOf('_');
if (separatorIndex <= 0) {
return null;
}
const shape = LEGACY_SLOT_SHAPES[slot.slice(0, separatorIndex)];
const currency = LEGACY_SLOT_CURRENCIES[slot.slice(separatorIndex + 1).toLowerCase()];
const currency = parseLegacySlotCurrency(slot.slice(separatorIndex + 1), catalogMode);
if (!shape || !currency) {
return null;
}
@@ -79,306 +97,55 @@ function parseLegacySlot(slot: string): ProductInfo | null {
export class ProductRegistry {
private products = new Map<string, ProductInfo>();
private readonly config: EffectiveBillingConfig;
constructor() {
constructor(config: EffectiveBillingConfig = getEffectiveBillingConfig()) {
this.config = config;
this.registerConfiguredProducts();
this.registerLegacyProducts();
}
get version(): string {
return this.config.version;
}
private registerConfiguredProducts(): void {
const prices = Config.stripe.prices;
if (!prices) return;
this.registerProduct(prices.monthlyUsd, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'USD',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyEur, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'EUR',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyBrl, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'BRL',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyDkk, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'DKK',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyInr, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'INR',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyNok, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'NOK',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyPln, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'PLN',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlySek, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'SEK',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyTry, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'TRY',
billingCycle: 'monthly',
});
this.registerProduct(prices.yearlyUsd, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'USD',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyEur, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'EUR',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyBrl, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'BRL',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyDkk, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'DKK',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyInr, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'INR',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyNok, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'NOK',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyPln, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'PLN',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlySek, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'SEK',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyTry, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'TRY',
billingCycle: 'yearly',
});
this.registerProduct(prices.gift1MonthUsd, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'USD',
});
this.registerProduct(prices.gift1MonthEur, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'EUR',
});
this.registerProduct(prices.gift1MonthBrl, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'BRL',
});
this.registerProduct(prices.gift1MonthInr, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'INR',
});
this.registerProduct(prices.gift1MonthDkk, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'DKK',
});
this.registerProduct(prices.gift1YearDkk, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'DKK',
});
this.registerProduct(prices.gift1MonthNok, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'NOK',
});
this.registerProduct(prices.gift1YearNok, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'NOK',
});
this.registerProduct(prices.gift1MonthSek, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'SEK',
});
this.registerProduct(prices.gift1YearSek, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'SEK',
});
this.registerProduct(prices.gift1MonthPln, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'PLN',
});
this.registerProduct(prices.gift1MonthTry, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'TRY',
});
this.registerProduct(prices.gift1YearUsd, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'USD',
});
this.registerProduct(prices.gift1YearEur, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'EUR',
});
this.registerProduct(prices.gift1YearBrl, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'BRL',
});
this.registerProduct(prices.gift1YearInr, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'INR',
});
this.registerProduct(prices.gift1YearPln, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'PLN',
});
this.registerProduct(prices.gift1YearTry, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'TRY',
});
for (const [currency, set] of Object.entries(this.config.prices)) {
for (const slot of CATALOG_SLOTS) {
const shape = LEGACY_SLOT_SHAPES[slot];
if (shape) {
this.registerProduct(set[slot] ?? undefined, {...shape, currency});
}
}
}
}
private resolveLegacyPrices(): unknown {
if (this.config.catalogMode === 'env' && getActiveStoredBillingConfig()?.legacy_prices == null) {
return Config.stripe.legacyPrices;
}
return this.config.legacyPrices;
}
private registerLegacyProducts(): void {
const legacyPrices = Config.stripe.legacyPrices;
const legacyPrices = this.resolveLegacyPrices();
if (!legacyPrices) return;
if (typeof legacyPrices !== 'object' || Array.isArray(legacyPrices)) {
Logger.warn({}, 'Ignoring legacy Stripe price configuration that is not an object of slot names to price ids');
return;
}
for (const [slot, priceIds] of Object.entries(legacyPrices)) {
for (const [slot, priceIds] of Object.entries(legacyPrices as Record<string, unknown>)) {
if (!Array.isArray(priceIds)) {
Logger.warn({slot}, 'Ignoring legacy Stripe price slot that is not a list of price IDs');
continue;
}
const info = parseLegacySlot(slot);
const info = parseLegacySlot(slot, this.config.catalogMode);
if (!info) {
Logger.warn({slot}, 'Ignoring legacy Stripe price slot with an unrecognised name or currency');
continue;
}
for (const priceId of priceIds) {
if (!priceId || this.products.has(priceId)) {
if (typeof priceId !== 'string' || !priceId || this.products.has(priceId)) {
continue;
}
this.products.set(priceId, info);
@@ -392,6 +159,10 @@ export class ProductRegistry {
}
}
private getPriceSet(currency: string): BillingPriceSet | null {
return this.config.prices[currency.trim().toUpperCase()] ?? null;
}
getProduct(priceId: string): ProductInfo | null {
return this.products.get(priceId) || null;
}
@@ -401,74 +172,27 @@ export class ProductRegistry {
}
getRecurringSubscriptionPriceId(billingCycle: RecurringBillingCycle, currency: string): string | null {
const normalizedCurrency = currency.trim().toLowerCase();
const prices = Config.stripe.prices;
if (!prices) {
const set = this.getPriceSet(currency);
if (!set) {
return null;
}
if (normalizedCurrency === 'eur') {
return billingCycle === 'monthly' ? (prices.monthlyEur ?? null) : (prices.yearlyEur ?? null);
}
if (normalizedCurrency === 'brl') {
return billingCycle === 'monthly' ? (prices.monthlyBrl ?? null) : (prices.yearlyBrl ?? null);
}
if (normalizedCurrency === 'dkk') {
return billingCycle === 'monthly' ? (prices.monthlyDkk ?? null) : (prices.yearlyDkk ?? null);
}
if (normalizedCurrency === 'inr') {
return billingCycle === 'monthly' ? (prices.monthlyInr ?? null) : (prices.yearlyInr ?? null);
}
if (normalizedCurrency === 'nok') {
return billingCycle === 'monthly' ? (prices.monthlyNok ?? null) : (prices.yearlyNok ?? null);
}
if (normalizedCurrency === 'pln') {
return billingCycle === 'monthly' ? (prices.monthlyPln ?? null) : (prices.yearlyPln ?? null);
}
if (normalizedCurrency === 'sek') {
return billingCycle === 'monthly' ? (prices.monthlySek ?? null) : (prices.yearlySek ?? null);
}
if (normalizedCurrency === 'try') {
return billingCycle === 'monthly' ? (prices.monthlyTry ?? null) : (prices.yearlyTry ?? null);
}
if (normalizedCurrency === 'usd') {
return billingCycle === 'monthly' ? (prices.monthlyUsd ?? null) : (prices.yearlyUsd ?? null);
}
return null;
return billingCycle === 'monthly' ? set.monthly : set.yearly;
}
getGiftPriceId(duration: 'gift_1_month' | 'gift_1_year', currency: string): string | null {
const normalizedCurrency = currency.trim().toLowerCase();
const prices = Config.stripe.prices;
if (!prices) {
const set = this.getPriceSet(currency);
if (!set) {
return null;
}
if (normalizedCurrency === 'eur') {
return duration === 'gift_1_month' ? (prices.gift1MonthEur ?? null) : (prices.gift1YearEur ?? null);
}
if (normalizedCurrency === 'brl') {
return duration === 'gift_1_month' ? (prices.gift1MonthBrl ?? null) : (prices.gift1YearBrl ?? null);
}
if (normalizedCurrency === 'inr') {
return duration === 'gift_1_month' ? (prices.gift1MonthInr ?? null) : (prices.gift1YearInr ?? null);
}
if (normalizedCurrency === 'dkk') {
return duration === 'gift_1_month' ? (prices.gift1MonthDkk ?? null) : (prices.gift1YearDkk ?? null);
}
if (normalizedCurrency === 'nok') {
return duration === 'gift_1_month' ? (prices.gift1MonthNok ?? null) : (prices.gift1YearNok ?? null);
}
if (normalizedCurrency === 'sek') {
return duration === 'gift_1_month' ? (prices.gift1MonthSek ?? null) : (prices.gift1YearSek ?? null);
}
if (normalizedCurrency === 'pln') {
return duration === 'gift_1_month' ? (prices.gift1MonthPln ?? null) : (prices.gift1YearPln ?? null);
}
if (normalizedCurrency === 'try') {
return duration === 'gift_1_month' ? (prices.gift1MonthTry ?? null) : (prices.gift1YearTry ?? null);
}
if (normalizedCurrency === 'usd') {
return duration === 'gift_1_month' ? (prices.gift1MonthUsd ?? null) : (prices.gift1YearUsd ?? null);
}
return null;
return duration === 'gift_1_month' ? set.gift_1_month : set.gift_1_year;
}
}
let cachedRegistry: ProductRegistry | null = null;
export function getProductRegistry(config: EffectiveBillingConfig = getEffectiveBillingConfig()): ProductRegistry {
if (cachedRegistry === null || cachedRegistry.version !== config.version) {
cachedRegistry = new ProductRegistry(config);
}
return cachedRegistry;
}
+31
View File
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {
type EffectiveBillingConfig,
getEffectiveBillingConfig,
isStripeServiceable,
} from '@app/api/stripe/BillingConfigCache';
import {STRIPE_API_VERSION} from '@app/api/stripe/StripeApiVersion';
import Stripe from 'stripe';
let cachedClient: {secretKey: string; client: Stripe} | null = null;
function createStripeClient(secretKey: string): Stripe {
return new Stripe(secretKey, {
apiVersion: STRIPE_API_VERSION,
httpClient: Config.dev.testModeEnabled
? Stripe.createFetchHttpClient((input, init) => globalThis.fetch(input, init))
: undefined,
});
}
export function getStripeClient(config: EffectiveBillingConfig = getEffectiveBillingConfig()): Stripe | null {
if (!config.secretKey || !isStripeServiceable(config)) {
return null;
}
if (cachedClient === null || cachedClient.secretKey !== config.secretKey) {
cachedClient = {secretKey: config.secretKey, client: createStripeClient(config.secretKey)};
}
return cachedClient.client;
}
+58 -4
View File
@@ -6,10 +6,17 @@ import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import {
getEffectiveBillingConfig,
isBillingActive,
isPremiumTieringActive,
isStripeServiceable,
} from '@app/api/stripe/BillingConfigCache';
import {mapGiftCodeToMetadataResponse, mapGiftCodeToResponse} from '@app/api/stripe/StripeModel';
import type {HonoApp} from '@app/api/types/HonoEnv';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {lookupGeoip} from '@app/api/utils/IpUtils';
import {Validator} from '@app/api/Validator';
import {AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
import {StripeWebhookNotAvailableError} from '@fluxer/errors/src/domains/payment/StripeWebhookNotAvailableError';
import {StripeWebhookSignatureInvalidError} from '@fluxer/errors/src/domains/payment/StripeWebhookSignatureInvalidError';
import {StripeWebhookSignatureMissingError} from '@fluxer/errors/src/domains/payment/StripeWebhookSignatureMissingError';
@@ -32,15 +39,37 @@ import {
UrlResponse,
WebhookReceivedResponse,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import {createMiddleware} from 'hono/factory';
async function getPurchaseGeoipCountryCode(request: Request): Promise<string | null> {
const geoip = await lookupGeoip(request);
return geoip.countryCode ?? null;
}
function routeAvailableWhen(isAvailable: () => boolean) {
return createMiddleware<HonoEnv>(async (ctx, next) => {
if (Config.instance.selfHosted && !isAvailable()) {
return AppNotFoundHandler(ctx);
}
return next();
});
}
function isStripeWebhookAvailable(): boolean {
const billing = getEffectiveBillingConfig();
return billing.webhookSecret !== null && isStripeServiceable(billing);
}
const HostedOnlyRoute = routeAvailableWhen(() => false);
const GiftRouteAvailable = routeAvailableWhen(isPremiumTieringActive);
const BillingRouteAvailable = routeAvailableWhen(() => isBillingActive());
const StripeServicingRouteAvailable = routeAvailableWhen(() => isStripeServiceable());
const StripeWebhookRouteAvailable = routeAvailableWhen(isStripeWebhookAvailable);
export function StripeController(app: HonoApp) {
app.post(
'/stripe/webhook',
StripeWebhookRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_WEBHOOK),
OpenAPI({
operationId: 'process_stripe_webhook',
@@ -57,12 +86,13 @@ export function StripeController(app: HonoApp) {
throw new StripeWebhookSignatureMissingError();
}
const stripe = ctx.get('stripeService').getStripe();
if (!stripe || !Config.stripe.webhookSecret) {
const webhookSecret = getEffectiveBillingConfig().webhookSecret;
if (!stripe || !webhookSecret) {
throw new StripeWebhookNotAvailableError();
}
const body = await ctx.req.text();
try {
stripe.webhooks.constructEvent(body, signature, Config.stripe.webhookSecret);
stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch {
throw new StripeWebhookSignatureInvalidError();
}
@@ -72,6 +102,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/stripe/checkout/subscription',
BillingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_SUBSCRIPTION),
LoginRequired,
DefaultUserOnly,
@@ -111,6 +142,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/stripe/checkout/subscription/preapproval',
BillingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL),
LoginRequired,
DefaultUserOnly,
@@ -143,6 +175,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/stripe/checkout/subscription/preapproval/continue',
BillingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL_CONTINUE),
OpenAPI({
operationId: 'continue_localized_card_preapproval_session',
@@ -163,6 +196,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/stripe/checkout/gift',
BillingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_GIFT),
LoginRequired,
DefaultUserOnly,
@@ -195,6 +229,7 @@ export function StripeController(app: HonoApp) {
);
app.get(
'/gifts/:code',
GiftRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.GIFT_CODE_GET),
OpenAPI({
operationId: 'get_gift_code',
@@ -220,6 +255,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/gifts/:code/redeem',
GiftRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.GIFT_CODE_REDEEM),
LoginRequired,
DefaultUserOnly,
@@ -243,6 +279,7 @@ export function StripeController(app: HonoApp) {
);
app.get(
'/users/@me/gifts',
GiftRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.GIFTS_LIST),
LoginRequired,
DefaultUserOnly,
@@ -272,6 +309,7 @@ export function StripeController(app: HonoApp) {
);
app.get(
'/premium/price-ids',
BillingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_PRICE_IDS),
Validator('query', PriceIdsQueryRequest),
OpenAPI({
@@ -292,6 +330,7 @@ export function StripeController(app: HonoApp) {
);
app.get(
'/premium/current-subscription-price',
StripeServicingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_CURRENT_SUBSCRIPTION_PRICE),
LoginRequired,
DefaultUserOnly,
@@ -313,6 +352,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/premium/customer-portal',
StripeServicingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_CUSTOMER_PORTAL),
LoginRequired,
DefaultUserOnly,
@@ -334,6 +374,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/premium/grace/end',
StripeServicingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_PREMIUM_GRACE_END),
LoginRequired,
DefaultUserOnly,
@@ -355,6 +396,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/users/@me/age-verification',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.AGE_VERIFICATION),
LoginRequired,
DefaultUserOnly,
@@ -369,13 +411,18 @@ export function StripeController(app: HonoApp) {
tags: 'Billing',
}),
async (ctx) => {
const ageVerificationService = ctx.get('ageVerificationService');
if (!ageVerificationService) {
return AppNotFoundHandler(ctx);
}
const userId = ctx.get('user').id;
const url = await ctx.get('ageVerificationService').createVerificationSession(userId);
const url = await ageVerificationService.createVerificationSession(userId);
return ctx.json({url});
},
);
app.get(
'/premium/refund-eligibility',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.STRIPE_REFUND_ELIGIBILITY),
LoginRequired,
DefaultUserOnly,
@@ -397,6 +444,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/premium/refund-latest',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.STRIPE_REFUND_LATEST),
LoginRequired,
DefaultUserOnly,
@@ -418,6 +466,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/premium/cancel-subscription',
StripeServicingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CANCEL),
LoginRequired,
DefaultUserOnly,
@@ -438,6 +487,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/premium/reactivate-subscription',
StripeServicingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_REACTIVATE),
LoginRequired,
DefaultUserOnly,
@@ -458,6 +508,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/premium/change-subscription',
StripeServicingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CHANGE),
LoginRequired,
DefaultUserOnly,
@@ -481,6 +532,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/premium/switch-to-list-price',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CHANGE),
LoginRequired,
DefaultUserOnly,
@@ -502,6 +554,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/premium/cancel-pending-subscription-change',
StripeServicingRouteAvailable,
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CHANGE),
LoginRequired,
DefaultUserOnly,
@@ -523,6 +576,7 @@ export function StripeController(app: HonoApp) {
);
app.post(
'/premium/visionary/rejoin',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.STRIPE_VISIONARY_REJOIN),
LoginRequired,
DefaultUserOnly,
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository';
import {Logger} from '@app/api/Logger';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import {seconds} from 'itty-time';
import type Stripe from 'stripe';
export interface StripePriceSummary {
unitAmountMinor: number | null;
}
const PRICE_CACHE_TTL_SECONDS = seconds('1 hour');
const PRICE_CACHE_PRODUCE_TIMEOUT_MS = 90000;
export async function getCachedStripePriceSummary({
stripe,
cacheService,
priceId,
mirror,
}: {
stripe: Stripe | null;
cacheService: ICacheService;
priceId: string | null;
mirror?: BillingRepository;
}): Promise<StripePriceSummary | null> {
if (!priceId || !stripe) {
return null;
}
try {
return await cacheService.getOrSet<StripePriceSummary>(
`stripe_price_summary:${priceId}`,
async () => {
const price = await stripe.prices.retrieve(priceId);
if (mirror) {
try {
await mirror.prices.upsertFromStripe(price);
} catch (mirrorErr) {
Logger.error({mirrorErr, priceId}, 'Mirror upsert failed after Stripe price lookup');
}
}
return {
unitAmountMinor: price.unit_amount ?? null,
};
},
PRICE_CACHE_TTL_SECONDS,
PRICE_CACHE_PRODUCE_TIMEOUT_MS,
);
} catch (error: unknown) {
Logger.warn({error, priceId}, 'Failed to retrieve Stripe price summary');
return null;
}
}
+8 -12
View File
@@ -9,8 +9,8 @@ import type {GuildService} from '@app/api/guild/services/GuildService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {GiftCode} from '@app/api/models/GiftCode';
import type {User} from '@app/api/models/User';
import {ProductRegistry} from '@app/api/stripe/ProductRegistry';
import {STRIPE_API_VERSION} from '@app/api/stripe/StripeApiVersion';
import {getProductRegistry, type ProductRegistry} from '@app/api/stripe/ProductRegistry';
import {getStripeClient} from '@app/api/stripe/StripeClient';
import {PremiumStateService} from '@app/api/stripe/services/PremiumStateService';
import type {
ContinueLocalizedCardPreapprovalResult,
@@ -32,10 +32,10 @@ import type {
SwitchToListPriceResponse,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import Stripe from 'stripe';
import type Stripe from 'stripe';
export class StripeService {
private stripe: Stripe | null = null;
private stripe: Stripe | null;
private productRegistry: ProductRegistry;
private checkoutService: StripeCheckoutService;
private subscriptionService: StripeSubscriptionService;
@@ -52,13 +52,8 @@ export class StripeService {
private cacheService: ICacheService,
private billingRepository: BillingRepository,
) {
this.productRegistry = new ProductRegistry();
if (Config.stripe.enabled && Config.stripe.secretKey) {
this.stripe = new Stripe(Config.stripe.secretKey, {
apiVersion: STRIPE_API_VERSION,
httpClient: Config.dev.testModeEnabled ? Stripe.createFetchHttpClient() : undefined,
});
}
this.productRegistry = getProductRegistry();
this.stripe = getStripeClient();
this.premiumService = new StripePremiumService(
this.userRepository,
this.gatewayService,
@@ -70,6 +65,7 @@ export class StripeService {
this.gatewayService,
this.billingRepository,
this.stripe,
this.cacheService,
);
this.checkoutService = new StripeCheckoutService(
this.stripe,
@@ -164,7 +160,7 @@ export class StripeService {
gift_1_month: string | null;
gift_1_year: string | null;
currency: Currency;
gift_currency: Currency;
gift_currency: Currency | null;
monthly_amount_minor: number | null;
yearly_amount_minor: number | null;
gift_1_month_amount_minor: number | null;
@@ -12,8 +12,10 @@ import type {
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import {Logger} from '@app/api/Logger';
import type {User} from '@app/api/models/User';
import type {RecurringBillingCycle} from '@app/api/stripe/ProductRegistry';
import {ProductRegistry} from '@app/api/stripe/ProductRegistry';
import {isBillingActive} from '@app/api/stripe/BillingConfigCache';
import {getProductRegistry, type RecurringBillingCycle} from '@app/api/stripe/ProductRegistry';
import {getStripeClient} from '@app/api/stripe/StripeClient';
import {getCachedStripePriceSummary} from '@app/api/stripe/StripePriceSummaryCache';
import {getPrimarySubscriptionItem} from '@app/api/stripe/StripeSubscriptionPeriod';
import {
SELF_SERVE_REFUND_COOLDOWN_DAYS,
@@ -22,7 +24,12 @@ import {
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {checkHasActivePaidPremium} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {type Currency, getCurrencyPreferences, getGiftCurrencyPreferences} from '@app/api/utils/CurrencyUtils';
import {
type Currency,
getCurrencyPreferences,
getGiftCurrencyPreferences,
normalizeCatalogCurrency,
} from '@app/api/utils/CurrencyUtils';
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {
@@ -39,6 +46,7 @@ import type {
SelfServeRefundEligibilityResponse,
SelfServeRefundIneligibilityReason,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type Stripe from 'stripe';
const INVOICE_LIMIT = 12;
@@ -53,7 +61,7 @@ interface ResolvedPriceIds {
gift_1_month: string | null;
gift_1_year: string | null;
currency: Currency;
gift_currency: Currency;
gift_currency: Currency | null;
}
interface InvoiceResult {
@@ -102,21 +110,7 @@ function billingCycleFromInterval(value: string | null | undefined): RecurringBi
}
function normalizeCurrency(value: string | null | undefined): Currency | null {
const currency = value?.toUpperCase();
if (
currency === 'USD' ||
currency === 'EUR' ||
currency === 'BRL' ||
currency === 'DKK' ||
currency === 'INR' ||
currency === 'NOK' ||
currency === 'PLN' ||
currency === 'SEK' ||
currency === 'TRY'
) {
return currency;
}
return null;
return normalizeCatalogCurrency(value);
}
function compareNullableDatesDesc(left: Date | null | undefined, right: Date | null | undefined): number {
@@ -212,13 +206,14 @@ function mapPaymentMethod(row: BillingPaymentMethodRow): PremiumBillingPaymentMe
}
export class PremiumStateService {
private readonly productRegistry = new ProductRegistry();
private readonly productRegistry = getProductRegistry();
constructor(
private readonly userRepository: IUserRepository,
private readonly gatewayService: IGatewayService,
private readonly billingRepository: BillingRepository,
private readonly stripe: Stripe | null = null,
private readonly cacheService: ICacheService | null = null,
) {}
async getState(userId: UserID, countryCode?: string): Promise<PremiumStateResponse> {
@@ -294,7 +289,7 @@ export class PremiumStateService {
premium_lifetime_sequence: user.premiumLifetimeSequence,
premium_grace_ends_at: toIso(user.premiumGraceEndsAt),
premium_enabled_override: (user.premiumFlags & PremiumFlags.ENABLED_OVERRIDE) !== 0,
premium_purchase_disabled: (user.premiumFlags & PremiumFlags.PURCHASE_DISABLED) !== 0,
premium_purchase_disabled: (user.premiumFlags & PremiumFlags.PURCHASE_DISABLED) !== 0 || !isBillingActive(),
premium_perks_disabled: (user.premiumFlags & PremiumFlags.PERKS_DISABLED) !== 0,
self_hosted: Config.instance.selfHosted,
bot: user.isBot,
@@ -640,7 +635,7 @@ export class PremiumStateService {
billing_cycle: subscriptionPrice?.billing_cycle ?? null,
effective_at: toIso(subscription?.current_period_end),
};
if (Config.instance.selfHosted || !Config.stripe.enabled || !Config.stripe.secretKey) {
if (Config.instance.selfHosted || !isBillingActive()) {
return {...base, available: false, reason: 'feature_unavailable'};
}
if (!subscription) {
@@ -752,34 +747,55 @@ export class PremiumStateService {
private async resolvePriceIds(countryCode: string | null): Promise<PriceIdsResponse | null> {
const resolved = this.resolveConfiguredPriceIds(countryCode);
if (!resolved) return null;
const [monthlyPrice, yearlyPrice, gift1MonthPrice, gift1YearPrice] = await Promise.all([
resolved.monthly ? this.billingRepository.prices.findById(resolved.monthly) : null,
resolved.yearly ? this.billingRepository.prices.findById(resolved.yearly) : null,
resolved.gift_1_month ? this.billingRepository.prices.findById(resolved.gift_1_month) : null,
resolved.gift_1_year ? this.billingRepository.prices.findById(resolved.gift_1_year) : null,
const [monthlyAmount, yearlyAmount, gift1MonthAmount, gift1YearAmount] = await Promise.all([
this.resolvePriceAmountMinor(resolved.monthly),
this.resolvePriceAmountMinor(resolved.yearly),
this.resolvePriceAmountMinor(resolved.gift_1_month),
this.resolvePriceAmountMinor(resolved.gift_1_year),
]);
return {
...resolved,
monthly_amount_minor: nullableNumber(monthlyPrice?.unit_amount),
yearly_amount_minor: nullableNumber(yearlyPrice?.unit_amount),
gift_1_month_amount_minor: nullableNumber(gift1MonthPrice?.unit_amount),
gift_1_year_amount_minor: nullableNumber(gift1YearPrice?.unit_amount),
monthly_amount_minor: monthlyAmount,
yearly_amount_minor: yearlyAmount,
gift_1_month_amount_minor: gift1MonthAmount,
gift_1_year_amount_minor: gift1YearAmount,
};
}
private async resolvePriceAmountMinor(priceId: string | null): Promise<number | null> {
if (!priceId) {
return null;
}
const mirrored = await this.billingRepository.prices.findById(priceId);
if (mirrored) {
return nullableNumber(mirrored.unit_amount);
}
if (!Config.instance.selfHosted || !this.cacheService) {
return null;
}
const summary = await getCachedStripePriceSummary({
stripe: this.stripe,
cacheService: this.cacheService,
priceId,
mirror: this.billingRepository,
});
return summary?.unitAmountMinor ?? null;
}
private resolveConfiguredPriceIds(countryCode: string | null): ResolvedPriceIds | null {
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
const giftCurrencyPreferences = getGiftCurrencyPreferences(countryCode);
const recurringPrices = this.resolveRecurringPriceIds(recurringCurrencyPreferences);
const giftPrices = this.resolveGiftPriceIds(giftCurrencyPreferences);
if (!recurringPrices || !giftPrices) return null;
if (!recurringPrices) return null;
if (!giftPrices && !Config.instance.selfHosted) return null;
return {
monthly: recurringPrices.monthly,
yearly: recurringPrices.yearly,
gift_1_month: giftPrices.gift_1_month,
gift_1_year: giftPrices.gift_1_year,
gift_1_month: giftPrices?.gift_1_month ?? null,
gift_1_year: giftPrices?.gift_1_year ?? null,
currency: recurringPrices.currency,
gift_currency: giftPrices.gift_currency,
gift_currency: giftPrices?.gift_currency ?? null,
};
}
@@ -826,7 +842,7 @@ export class PremiumStateService {
user: User,
invoices: Array<BillingInvoiceRow>,
): Promise<SelfServeRefundEligibilityResponse> {
if (Config.instance.selfHosted || !Config.stripe.enabled || !Config.stripe.secretKey) {
if (Config.instance.selfHosted || !getStripeClient()) {
return refundEligibility({reason: 'feature_unavailable'});
}
const cooldownExpiresAt = this.cooldownExpiresAt(user);
@@ -8,7 +8,10 @@ import type {UserRow} from '@app/api/database/types/UserTypes';
import {Logger} from '@app/api/Logger';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import type {User} from '@app/api/models/User';
import {getBillingBranding} from '@app/api/stripe/BillingBranding';
import {getEffectiveBillingConfig, isCurrentCatalogPriceId} from '@app/api/stripe/BillingConfigCache';
import type {ProductInfo, ProductRegistry} from '@app/api/stripe/ProductRegistry';
import {getCachedStripePriceSummary, type StripePriceSummary} from '@app/api/stripe/StripePriceSummaryCache';
import {
canProvisionPremiumFromSubscriptionStatus,
getPremiumWillCancelFromSubscription,
@@ -20,7 +23,12 @@ import {
} from '@app/api/stripe/StripeSubscriptionPeriod';
import {extractId} from '@app/api/stripe/StripeUtils';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {type Currency, getCurrencyPreferences, getGiftCurrencyPreferences} from '@app/api/utils/CurrencyUtils';
import {
type Currency,
getCurrencyPreferences,
getGiftCurrencyPreferences,
isLocalizedCurrency,
} from '@app/api/utils/CurrencyUtils';
import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea';
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {PurchaseEmailVerificationRequiredError} from '@fluxer/errors/src/domains/auth/EmailVerificationRequiredError';
@@ -37,10 +45,12 @@ import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import {seconds} from 'itty-time';
import type Stripe from 'stripe';
const PRODUCT_NAME = 'Fluxer';
const PREMIUM_TIER_NAME = 'Plutonium';
export const EU_WITHDRAWAL_WAIVER_TEXT_VERSION = '2026-04-23';
function isStripeResourceMissingError(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'resource_missing';
}
type CheckoutSessionCreateParams = Stripe.Checkout.SessionCreateParams;
type CheckoutSessionMode = CheckoutSessionCreateParams['mode'];
type CheckoutSessionPaymentMethodType = NonNullable<CheckoutSessionCreateParams['payment_method_types']>[number];
@@ -82,7 +92,6 @@ export interface CreateCheckoutSessionParams {
isBusiness?: boolean;
}
const UPI_MANDATE_DESCRIPTION = 'Fluxer Premium';
const PIX_UPI_MANDATE_HEADROOM_MULTIPLIER = 1.25;
interface ResolvedPriceIds {
@@ -91,7 +100,7 @@ interface ResolvedPriceIds {
gift_1_month: string | null;
gift_1_year: string | null;
currency: Currency;
gift_currency: Currency;
gift_currency: Currency | null;
}
interface PriceIdsResponse extends ResolvedPriceIds {
@@ -101,10 +110,6 @@ interface PriceIdsResponse extends ResolvedPriceIds {
gift_1_year_amount_minor: number | null;
}
interface StripePriceSummary {
unitAmountMinor: number | null;
}
interface EuWithdrawalWaiverContext {
accepted: boolean;
acceptedAt: Date | null;
@@ -199,6 +204,8 @@ export class StripeCheckoutService {
priceId,
});
const paymentMethodTypes = this.resolvePaymentMethodTypes(paymentMethod);
const branding = await getBillingBranding();
const billing = getEffectiveBillingConfig();
const checkoutMetadata = {
user_id: userId.toString(),
price_id: priceId,
@@ -217,18 +224,22 @@ export class StripeCheckoutService {
customer: customerId,
client_reference_id: userId.toString(),
metadata: checkoutMetadata,
consent_collection: {
terms_of_service: 'required',
},
custom_text: {
terms_of_service_acceptance: {
message: getContentMessage('billing.eu_withdrawal_waiver_checkout', user.locale, {
product_name: PRODUCT_NAME,
premium_tier_name: PREMIUM_TIER_NAME,
terms_url: `${Config.endpoints.marketing}/terms`,
}),
},
},
...(billing.termsConsentRequired
? {
consent_collection: {
terms_of_service: 'required',
},
custom_text: {
terms_of_service_acceptance: {
message: getContentMessage('billing.eu_withdrawal_waiver_checkout', user.locale, {
product_name: branding.productName,
premium_tier_name: branding.premiumName,
terms_url: branding.termsUrl,
}),
},
},
}
: {}),
line_items: [
{
price: priceId,
@@ -246,10 +257,10 @@ export class StripeCheckoutService {
}
: {}),
automatic_tax: {
enabled: true,
enabled: billing.automaticTax,
},
tax_id_collection: {
enabled: true,
enabled: billing.taxIdCollection,
},
customer_update: {
address: 'auto',
@@ -353,7 +364,7 @@ export class StripeCheckoutService {
success_url: `${Config.endpoints.webApp}/premium-callback?status=preapproval-success&token=${encodeURIComponent(token)}`,
cancel_url: `${Config.endpoints.webApp}/premium-callback?status=preapproval-cancel`,
tax_id_collection: {
enabled: true,
enabled: getEffectiveBillingConfig().taxIdCollection,
},
billing_address_collection: isBusiness ? 'required' : 'auto',
customer_update: {
@@ -559,12 +570,17 @@ export class StripeCheckoutService {
);
throw new StripeInvalidProductConfigurationError();
}
const billing = getEffectiveBillingConfig();
if (billing.catalogMode === 'operator' && !isCurrentCatalogPriceId(priceId, billing)) {
Logger.error({priceId, userId}, 'Checkout requested for a price outside the current operator catalog');
throw new StripeInvalidProductError();
}
const enforcedCountryCode = this.resolveEnforcedPricingCountryCode({countryCode, purchaseGeoipCountryCode});
if (this.requiresCountryCodeForLocalizedCurrency(productInfo.currency) && !enforcedCountryCode) {
Logger.error({priceId, userId, currency: productInfo.currency}, 'Localized price requested without country code');
throw new StripeInvalidProductConfigurationError();
}
if (enforcedCountryCode) {
if (enforcedCountryCode && billing.catalogMode === 'env') {
this.assertPriceMatchesCountryCatalog({countryCode: enforcedCountryCode, priceId, isGift, userId});
}
const user = await this.userRepository.findUnique(userId);
@@ -723,15 +739,11 @@ export class StripeCheckoutService {
}
private requiresLocalizedCardPreapproval(productInfo: ProductInfo): boolean {
return (
this.productRegistry.isRecurringSubscription(productInfo) &&
productInfo.currency !== 'USD' &&
productInfo.currency !== 'EUR'
);
return this.productRegistry.isRecurringSubscription(productInfo) && isLocalizedCurrency(productInfo.currency);
}
private requiresCountryCodeForLocalizedCurrency(currency: Currency): boolean {
return currency !== 'USD' && currency !== 'EUR';
return isLocalizedCurrency(currency);
}
private async rejectLocalizedCardPreapproval(
@@ -988,18 +1000,23 @@ export class StripeCheckoutService {
if (!user.stripeCustomerId) {
throw new StripeNoPurchaseHistoryError();
}
const portalUser = await this.ensureStripeCustomer(user);
const customerId = portalUser.stripeCustomerId;
if (!customerId) {
throw new StripeNoPurchaseHistoryError();
}
try {
const session = await this.stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
customer: customerId,
return_url: `${Config.endpoints.webApp}/premium-callback?status=closed-billing-portal`,
});
if (!session.url) {
Logger.error({userId, customerId: user.stripeCustomerId}, 'Stripe customer portal session missing url');
Logger.error({userId, customerId}, 'Stripe customer portal session missing url');
throw new StripeError('Stripe customer portal session missing url');
}
return session.url;
} catch (error: unknown) {
Logger.error({error, userId, customerId: user.stripeCustomerId}, 'Failed to create customer portal session');
Logger.error({error, userId, customerId}, 'Failed to create customer portal session');
const message = error instanceof Error ? error.message : 'Failed to create customer portal session';
throw new StripeError(message);
}
@@ -1037,8 +1054,6 @@ export class StripeCheckoutService {
private static readonly CUSTOMER_LOCK_TTL_SECONDS = seconds('30 seconds');
private static readonly LOCALIZED_CARD_PREAPPROVAL_CONTINUE_LOCK_TTL_SECONDS = seconds('30 seconds');
private static readonly LOCALIZED_CARD_PREAPPROVAL_TTL_SECONDS = seconds('1 day');
private static readonly PRICE_CACHE_TTL_SECONDS = seconds('1 hour');
private static readonly PRICE_CACHE_PRODUCE_TIMEOUT_MS = 90000;
private resolveConfiguredPriceIds(countryCode?: string): ResolvedPriceIds {
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
@@ -1078,217 +1093,36 @@ export class StripeCheckoutService {
return resolvedPrices;
}
}
if (Config.instance.selfHosted) {
return {gift_1_month: null, gift_1_year: null, gift_currency: null};
}
throw new StripeError(`Stripe gift price ids missing for supported currencies: ${preferredCurrencies.join(', ')}`);
}
private getConfiguredRecurringPriceIdsForCurrency(
currency: Currency,
): Pick<ResolvedPriceIds, 'monthly' | 'yearly' | 'currency'> | null {
const prices = Config.stripe.prices;
if (!prices) {
const monthly = this.productRegistry.getRecurringSubscriptionPriceId('monthly', currency);
const yearly = this.productRegistry.getRecurringSubscriptionPriceId('yearly', currency);
if (!monthly || !yearly) {
return null;
}
switch (currency) {
case 'EUR':
if (!prices.monthlyEur || !prices.yearlyEur) {
return null;
}
return {
monthly: prices.monthlyEur,
yearly: prices.yearlyEur,
currency,
};
case 'BRL':
if (!prices.monthlyBrl || !prices.yearlyBrl) {
return null;
}
return {
monthly: prices.monthlyBrl,
yearly: prices.yearlyBrl,
currency,
};
case 'DKK':
if (!prices.monthlyDkk || !prices.yearlyDkk) {
return null;
}
return {
monthly: prices.monthlyDkk,
yearly: prices.yearlyDkk,
currency,
};
case 'INR':
if (!prices.monthlyInr || !prices.yearlyInr) {
return null;
}
return {
monthly: prices.monthlyInr,
yearly: prices.yearlyInr,
currency,
};
case 'NOK':
if (!prices.monthlyNok || !prices.yearlyNok) {
return null;
}
return {
monthly: prices.monthlyNok,
yearly: prices.yearlyNok,
currency,
};
case 'PLN':
if (!prices.monthlyPln || !prices.yearlyPln) {
return null;
}
return {
monthly: prices.monthlyPln,
yearly: prices.yearlyPln,
currency,
};
case 'SEK':
if (!prices.monthlySek || !prices.yearlySek) {
return null;
}
return {
monthly: prices.monthlySek,
yearly: prices.yearlySek,
currency,
};
case 'TRY':
if (!prices.monthlyTry || !prices.yearlyTry) {
return null;
}
return {
monthly: prices.monthlyTry,
yearly: prices.yearlyTry,
currency,
};
case 'USD':
if (!prices.monthlyUsd || !prices.yearlyUsd) {
return null;
}
return {
monthly: prices.monthlyUsd,
yearly: prices.yearlyUsd,
currency,
};
default:
return null;
}
return {monthly, yearly, currency};
}
private getConfiguredGiftPriceIdsForCurrency(
currency: Currency,
): Pick<ResolvedPriceIds, 'gift_1_month' | 'gift_1_year' | 'gift_currency'> | null {
const prices = Config.stripe.prices;
if (!prices) {
const gift1Month = this.productRegistry.getGiftPriceId('gift_1_month', currency);
const gift1Year = this.productRegistry.getGiftPriceId('gift_1_year', currency);
if (!gift1Month || !gift1Year) {
return null;
}
switch (currency) {
case 'BRL':
if (!prices.gift1MonthBrl || !prices.gift1YearBrl) {
return null;
}
return {
gift_1_month: prices.gift1MonthBrl,
gift_1_year: prices.gift1YearBrl,
gift_currency: 'BRL',
};
case 'INR':
if (!prices.gift1MonthInr || !prices.gift1YearInr) {
return null;
}
return {
gift_1_month: prices.gift1MonthInr,
gift_1_year: prices.gift1YearInr,
gift_currency: 'INR',
};
case 'DKK':
if (!prices.gift1MonthDkk || !prices.gift1YearDkk) {
return null;
}
return {
gift_1_month: prices.gift1MonthDkk,
gift_1_year: prices.gift1YearDkk,
gift_currency: 'DKK',
};
case 'NOK':
if (!prices.gift1MonthNok || !prices.gift1YearNok) {
return null;
}
return {
gift_1_month: prices.gift1MonthNok,
gift_1_year: prices.gift1YearNok,
gift_currency: 'NOK',
};
case 'SEK':
if (!prices.gift1MonthSek || !prices.gift1YearSek) {
return null;
}
return {
gift_1_month: prices.gift1MonthSek,
gift_1_year: prices.gift1YearSek,
gift_currency: 'SEK',
};
case 'PLN':
if (!prices.gift1MonthPln || !prices.gift1YearPln) {
return null;
}
return {
gift_1_month: prices.gift1MonthPln,
gift_1_year: prices.gift1YearPln,
gift_currency: 'PLN',
};
case 'TRY':
if (!prices.gift1MonthTry || !prices.gift1YearTry) {
return null;
}
return {
gift_1_month: prices.gift1MonthTry,
gift_1_year: prices.gift1YearTry,
gift_currency: 'TRY',
};
case 'EUR':
if (!prices.gift1MonthEur || !prices.gift1YearEur) {
return null;
}
return {
gift_1_month: prices.gift1MonthEur,
gift_1_year: prices.gift1YearEur,
gift_currency: 'EUR',
};
case 'USD':
if (!prices.gift1MonthUsd || !prices.gift1YearUsd) {
return null;
}
return {
gift_1_month: prices.gift1MonthUsd,
gift_1_year: prices.gift1YearUsd,
gift_currency: 'USD',
};
default:
return null;
}
return {gift_1_month: gift1Month, gift_1_year: gift1Year, gift_currency: currency};
}
private async getStripePriceSummary(priceId: string | null): Promise<StripePriceSummary | null> {
if (!priceId || !this.stripe) {
return null;
}
try {
return await this.cacheService.getOrSet<StripePriceSummary>(
`stripe_price_summary:${priceId}`,
async () => {
const price = await this.stripe!.prices.retrieve(priceId);
return {
unitAmountMinor: price.unit_amount ?? null,
};
},
StripeCheckoutService.PRICE_CACHE_TTL_SECONDS,
StripeCheckoutService.PRICE_CACHE_PRODUCE_TIMEOUT_MS,
);
} catch (error: unknown) {
Logger.warn({error, priceId}, 'Failed to retrieve Stripe price summary');
return null;
}
return getCachedStripePriceSummary({stripe: this.stripe, cacheService: this.cacheService, priceId});
}
private assertPaymentMethodCompatibility({
@@ -1311,6 +1145,10 @@ export class StripeCheckoutService {
Logger.error({paymentMethod, priceId, userId}, 'Non-card payment method only valid for recurring subscriptions');
throw new StripeInvalidProductConfigurationError();
}
if (getEffectiveBillingConfig().catalogMode === 'operator') {
Logger.error({paymentMethod, priceId, userId}, 'Non-card payment methods are unavailable for operator prices');
throw new StripeInvalidProductConfigurationError();
}
if (paymentMethod === 'pix' && productInfo.currency !== 'BRL') {
Logger.error({priceId, userId, currency: productInfo.currency}, 'Pix payment method requires a BRL price');
throw new StripeInvalidProductConfigurationError();
@@ -1344,7 +1182,8 @@ export class StripeCheckoutService {
paymentMethod: CheckoutPaymentMethod;
priceId: string;
}): Promise<CheckoutSessionPaymentMethodOptions | undefined> {
if (productInfo.currency === 'BRL' && checkoutMode === 'payment') {
const envCatalog = getEffectiveBillingConfig().catalogMode === 'env';
if (envCatalog && productInfo.currency === 'BRL' && checkoutMode === 'payment') {
return {
pix: {
amount_includes_iof: 'always',
@@ -1368,13 +1207,13 @@ export class StripeCheckoutService {
};
}
if (paymentMethod === 'upi') {
const mandateAmount = await this.resolveMandateAmount(priceId);
const [mandateAmount, branding] = await Promise.all([this.resolveMandateAmount(priceId), getBillingBranding()]);
return {
upi: {
mandate_options: {
amount: mandateAmount,
amount_type: 'maximum',
description: UPI_MANDATE_DESCRIPTION,
description: branding.upiMandateDescription,
},
},
};
@@ -1390,7 +1229,48 @@ export class StripeCheckoutService {
return Math.ceil(priceSummary.unitAmountMinor * PIX_UPI_MANDATE_HEADROOM_MULTIPLIER);
}
private async ensureStripeCustomer(user: User): Promise<User> {
private async clearStaleStripeCustomer(user: User): Promise<User> {
if (!Config.instance.selfHosted || !this.stripe || !user.stripeCustomerId) {
return user;
}
const customerId = user.stripeCustomerId;
try {
const customer = await this.stripe.customers.retrieve(customerId);
if (!('deleted' in customer && customer.deleted)) {
return user;
}
} catch (error: unknown) {
if (!isStripeResourceMissingError(error)) {
Logger.warn({error, userId: user.id, customerId}, 'Failed to verify stored Stripe customer');
return user;
}
}
const patch: Partial<UserRow> = {stripe_customer_id: null};
if (user.stripeSubscriptionId && (await this.isStripeSubscriptionMissing(user.stripeSubscriptionId))) {
patch.stripe_subscription_id = null;
}
const updatedUser = await this.userRepository.patchUpsert(user.id, patch, user.toRow());
Logger.info(
{userId: user.id, customerId, clearedFields: Object.keys(patch)},
'Cleared Stripe customer that no longer exists for the configured Stripe account',
);
return updatedUser;
}
private async isStripeSubscriptionMissing(subscriptionId: string): Promise<boolean> {
if (!this.stripe) {
return false;
}
try {
await this.stripe.subscriptions.retrieve(subscriptionId);
return false;
} catch (error: unknown) {
return isStripeResourceMissingError(error);
}
}
private async ensureStripeCustomer(existingUser: User): Promise<User> {
const user = await this.clearStaleStripeCustomer(existingUser);
if (user.stripeCustomerId) {
return user;
}
@@ -23,6 +23,7 @@ import type {StripeGiftService} from '@app/api/stripe/services/StripeGiftService
import type {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {isLocalizedCurrency} from '@app/api/utils/CurrencyUtils';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
@@ -719,7 +720,7 @@ export class StripeCheckoutWebhookHandler {
}
private requiresLocalizedCardEligibility(productInfo: ProductInfo): boolean {
return productInfo.currency !== 'USD' && productInfo.currency !== 'EUR';
return isLocalizedCurrency(productInfo.currency);
}
private getDeclaredCheckoutPaymentMethodType(session: Stripe.Checkout.Session): string | null {
@@ -8,6 +8,7 @@ import {Logger} from '@app/api/Logger';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import {type GiftCode, mapGiftDurationMonthsToFields} from '@app/api/models/GiftCode';
import type {User} from '@app/api/models/User';
import {getBillingBranding} from '@app/api/stripe/BillingBranding';
import type {ProductInfo} from '@app/api/stripe/ProductRegistry';
import type {StripeCheckoutService} from '@app/api/stripe/services/StripeCheckoutService';
import type {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
@@ -119,7 +120,7 @@ export class StripeGiftService {
Logger.debug({userId, giftCode: code}, 'Redeemer passed gift purchase validation');
if (user.premiumType === UserPremiumTypes.LIFETIME) {
Logger.debug({userId, giftCode: code}, 'Rejecting redemption for lifetime user');
throw new CannotRedeemPlutoniumWithVisionaryError();
throw new CannotRedeemPlutoniumWithVisionaryError((await getBillingBranding()).premiumName);
}
await this.userRepository.redeemGiftCode(code, userId);
Logger.debug({userId, giftCode: code}, 'Applied gift redemption row update');
@@ -4,7 +4,6 @@ import type {AdminRepository} from '@app/api/admin/AdminRepository';
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import type {ISessionTerminator} from '@app/api/auth/ISessionTerminator';
import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository';
import {Config} from '@app/api/Config';
import type {IDonationRepository} from '@app/api/donation/IDonationRepository';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
@@ -12,6 +11,7 @@ import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAcco
import type {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {Logger} from '@app/api/Logger';
import {getAcceptedWebhookSecrets} from '@app/api/stripe/BillingConfigCache';
import type {ProductRegistry} from '@app/api/stripe/ProductRegistry';
import type {AgeVerificationService} from '@app/api/stripe/services/AgeVerificationService';
import type {StripeCheckoutService} from '@app/api/stripe/services/StripeCheckoutService';
@@ -116,18 +116,31 @@ export class StripeWebhookService {
);
}
private constructVerifiedEvent(
stripe: Stripe,
body: string,
signature: string,
webhookSecrets: Array<string>,
): Stripe.Event {
const SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60;
let lastError: unknown = null;
for (const webhookSecret of webhookSecrets) {
try {
return stripe.webhooks.constructEvent(body, signature, webhookSecret, SEVEN_DAYS_SECONDS);
} catch (error: unknown) {
lastError = error;
}
}
Logger.error({error: lastError}, 'Invalid webhook signature');
throw new StripeWebhookSignatureInvalidError();
}
async handleWebhook({body, signature}: HandleWebhookParams): Promise<void> {
if (!this.stripe || !Config.stripe.webhookSecret) {
const webhookSecrets = getAcceptedWebhookSecrets();
if (!this.stripe || webhookSecrets.length === 0) {
throw new StripeWebhookNotAvailableError();
}
let event: Stripe.Event;
try {
const SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60;
event = this.stripe.webhooks.constructEvent(body, signature, Config.stripe.webhookSecret, SEVEN_DAYS_SECONDS);
} catch (error: unknown) {
Logger.error({error}, 'Invalid webhook signature');
throw new StripeWebhookSignatureInvalidError();
}
const event = this.constructVerifiedEvent(this.stripe, body, signature, webhookSecrets);
Logger.debug({eventType: event.type, eventId: event.id}, 'Processing Stripe webhook');
const claim = await this.billingRepository.webhookEvents.tryClaim(event.id);
if (claim === 'already_processed') {
@@ -0,0 +1,443 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import {
getAcceptedWebhookSecrets,
getEffectiveBillingConfig,
getOperatorCurrencyPreferences,
getStoredBillingConfig,
isBillingActive,
isCurrentCatalogPriceId,
isPremiumTieringActive,
isStripeServiceable,
type StoredBillingConfig,
setStoredBillingConfig,
} from '@app/api/stripe/BillingConfigCache';
import {getStripeClient} from '@app/api/stripe/StripeClient';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const ENV_PRICES = {
monthlyUsd: 'price_monthly_usd',
yearlyUsd: 'price_yearly_usd',
gift1MonthUsd: 'price_gift_month_usd',
gift1YearUsd: 'price_gift_year_usd',
monthlyEur: 'price_monthly_eur',
yearlyEur: 'price_yearly_eur',
gift1MonthEur: 'price_gift_month_eur',
gift1YearEur: 'price_gift_year_eur',
monthlyBrl: 'price_monthly_brl',
yearlyBrl: 'price_yearly_brl',
gift1MonthBrl: 'price_gift_month_brl',
gift1YearBrl: 'price_gift_year_brl',
monthlyDkk: 'price_monthly_dkk',
yearlyDkk: 'price_yearly_dkk',
gift1MonthDkk: 'price_gift_month_dkk',
gift1YearDkk: 'price_gift_year_dkk',
monthlyInr: 'price_monthly_inr',
yearlyInr: 'price_yearly_inr',
gift1MonthInr: 'price_gift_month_inr',
gift1YearInr: 'price_gift_year_inr',
monthlyNok: 'price_monthly_nok',
yearlyNok: 'price_yearly_nok',
gift1MonthNok: 'price_gift_month_nok',
gift1YearNok: 'price_gift_year_nok',
monthlyPln: 'price_monthly_pln',
yearlyPln: 'price_yearly_pln',
gift1MonthPln: 'price_gift_month_pln',
gift1YearPln: 'price_gift_year_pln',
monthlySek: 'price_monthly_sek',
yearlySek: 'price_yearly_sek',
gift1MonthSek: 'price_gift_month_sek',
gift1YearSek: 'price_gift_year_sek',
monthlyTry: 'price_monthly_try',
yearlyTry: 'price_yearly_try',
gift1MonthTry: 'price_gift_month_try',
gift1YearTry: 'price_gift_year_try',
};
function expectedEnvCatalog(): Record<string, Record<string, string>> {
const catalog: Record<string, Record<string, string>> = {};
for (const currency of ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY']) {
const suffix = currency.toLowerCase();
catalog[currency] = {
monthly: `price_monthly_${suffix}`,
yearly: `price_yearly_${suffix}`,
gift_1_month: `price_gift_month_${suffix}`,
gift_1_year: `price_gift_year_${suffix}`,
};
}
return catalog;
}
function operatorConfig(overrides: Partial<StoredBillingConfig> = {}): StoredBillingConfig {
return {
enabled: true,
stripe_secret_key: 'sk_test_operator',
stripe_webhook_secret: 'whsec_operator',
automatic_tax: null,
tax_id_collection: null,
terms_consent_required: null,
default_currency: 'GBP',
prices: {
GBP: {
monthly: 'price_gbp_monthly',
yearly: 'price_gbp_yearly',
gift_1_month: 'price_gbp_gift_month',
gift_1_year: 'price_gbp_gift_year',
},
CHF: {monthly: 'price_chf_monthly', yearly: 'price_chf_yearly', gift_1_month: null, gift_1_year: null},
},
country_currencies: {CH: 'CHF', LI: 'CHF'},
legacy_prices: {monthly_GBP: ['price_gbp_old']},
...overrides,
};
}
describe('BillingConfigCache', () => {
const stripe = Config.stripe;
const instance = Config.instance;
const original = {
enabled: stripe.enabled,
secretKey: stripe.secretKey,
webhookSecret: stripe.webhookSecret,
prices: stripe.prices,
legacyPrices: stripe.legacyPrices,
selfHosted: instance.selfHosted,
};
let originalPremiumMode = getCachedInstancePremiumMode();
let originalStored = getStoredBillingConfig();
beforeEach(() => {
originalPremiumMode = getCachedInstancePremiumMode();
originalStored = getStoredBillingConfig();
instance.selfHosted = true;
setCachedInstancePremiumMode('mirror');
setStoredBillingConfig(null);
stripe.prices = {...ENV_PRICES};
stripe.legacyPrices = {monthly_try: ['price_old_try']};
});
afterEach(() => {
stripe.enabled = original.enabled;
stripe.secretKey = original.secretKey;
stripe.webhookSecret = original.webhookSecret;
stripe.prices = original.prices;
stripe.legacyPrices = original.legacyPrices;
instance.selfHosted = original.selfHosted;
setCachedInstancePremiumMode(originalPremiumMode);
setStoredBillingConfig(originalStored);
});
it('builds the env catalog for the nine hosted currencies from Config.stripe', () => {
const config = getEffectiveBillingConfig();
expect(config.catalogMode).toBe('env');
expect(config.prices).toEqual(expectedEnvCatalog());
expect(config.countryCurrencies).toEqual({});
expect(config.defaultCurrency).toBeNull();
expect(config.legacyPrices).toEqual({monthly_try: ['price_old_try']});
expect(config.enabled).toBe(true);
expect(config.secretKey).toBe('sk_test_fluxer');
expect(config.webhookSecret).toBe('whsec_test_fluxer');
});
it('leaves unconfigured env currencies out of the catalog', () => {
stripe.prices = {monthlyUsd: 'price_a', yearlyUsd: 'price_b', gift1MonthSek: 'price_c'};
expect(getEffectiveBillingConfig().prices).toEqual({
USD: {monthly: 'price_a', yearly: 'price_b', gift_1_month: null, gift_1_year: null},
SEK: {monthly: null, yearly: null, gift_1_month: 'price_c', gift_1_year: null},
});
});
it('reads env values at call time and changes the version when they change', () => {
const before = getEffectiveBillingConfig();
expect(getEffectiveBillingConfig()).toBe(before);
stripe.prices = {...ENV_PRICES, monthlyUsd: 'price_changed'};
const afterPrice = getEffectiveBillingConfig();
expect(afterPrice.prices.USD?.monthly).toBe('price_changed');
expect(afterPrice.version).not.toBe(before.version);
stripe.webhookSecret = 'whsec_rotated';
const afterSecret = getEffectiveBillingConfig();
expect(afterSecret.webhookSecret).toBe('whsec_rotated');
expect(afterSecret.version).not.toBe(afterPrice.version);
});
it('uses the operator catalog when stored prices are set', () => {
setStoredBillingConfig(operatorConfig());
const config = getEffectiveBillingConfig();
expect(config.catalogMode).toBe('operator');
expect(config.prices).toEqual(operatorConfig().prices);
expect(config.defaultCurrency).toBe('GBP');
expect(config.countryCurrencies).toEqual({CH: 'CHF', LI: 'CHF'});
expect(config.legacyPrices).toEqual({monthly_GBP: ['price_gbp_old']});
expect(config.secretKey).toBe('sk_test_operator');
expect(config.webhookSecret).toBe('whsec_operator');
});
it('falls back to env for each stored null field', () => {
setStoredBillingConfig({
enabled: null,
stripe_secret_key: null,
stripe_webhook_secret: 'whsec_stored',
automatic_tax: null,
tax_id_collection: null,
terms_consent_required: null,
default_currency: null,
prices: null,
country_currencies: {SE: 'SEK'},
legacy_prices: null,
});
const config = getEffectiveBillingConfig();
expect(config.catalogMode).toBe('env');
expect(config.enabled).toBe(true);
expect(config.secretKey).toBe('sk_test_fluxer');
expect(config.webhookSecret).toBe('whsec_stored');
expect(config.prices).toEqual(expectedEnvCatalog());
expect(config.countryCurrencies).toEqual({});
expect(config.legacyPrices).toEqual({monthly_try: ['price_old_try']});
});
it('changes the version when the stored config changes', () => {
const envVersion = getEffectiveBillingConfig().version;
setStoredBillingConfig(operatorConfig());
const operatorVersion = getEffectiveBillingConfig().version;
expect(operatorVersion).not.toBe(envVersion);
setStoredBillingConfig(operatorConfig({stripe_secret_key: 'sk_test_rotated'}));
expect(getEffectiveBillingConfig().version).not.toBe(operatorVersion);
setStoredBillingConfig(null);
expect(getEffectiveBillingConfig().version).toBe(envVersion);
});
it('orders operator currency preferences by country, then default, then catalog order', () => {
setStoredBillingConfig(operatorConfig());
expect(getOperatorCurrencyPreferences('ch')).toEqual(['CHF', 'GBP']);
expect(getOperatorCurrencyPreferences('GB')).toEqual(['GBP', 'CHF']);
expect(getOperatorCurrencyPreferences(null)).toEqual(['GBP', 'CHF']);
setStoredBillingConfig(operatorConfig({default_currency: 'JPY', country_currencies: {CH: 'XXX'}}));
expect(getOperatorCurrencyPreferences('CH')).toEqual(['GBP', 'CHF']);
});
describe('isPremiumTieringActive and isBillingActive', () => {
const cases: Array<{
selfHosted: boolean;
premiumMode: 'mirror' | 'everyone';
stored: StoredBillingConfig | null;
tiering: boolean;
billing: boolean;
}> = [
{selfHosted: false, premiumMode: 'everyone', stored: null, tiering: true, billing: true},
{selfHosted: false, premiumMode: 'mirror', stored: null, tiering: true, billing: true},
{selfHosted: true, premiumMode: 'everyone', stored: null, tiering: false, billing: false},
{selfHosted: true, premiumMode: 'mirror', stored: null, tiering: true, billing: true},
{selfHosted: true, premiumMode: 'everyone', stored: operatorConfig(), tiering: false, billing: false},
{selfHosted: true, premiumMode: 'mirror', stored: operatorConfig(), tiering: true, billing: true},
{
selfHosted: true,
premiumMode: 'mirror',
stored: operatorConfig({enabled: false}),
tiering: true,
billing: false,
},
{
selfHosted: true,
premiumMode: 'mirror',
stored: operatorConfig({
prices: {GBP: {monthly: 'price_gbp_monthly', yearly: null, gift_1_month: null, gift_1_year: null}},
}),
tiering: true,
billing: false,
},
{
selfHosted: true,
premiumMode: 'mirror',
stored: operatorConfig({prices: {}}),
tiering: true,
billing: false,
},
{
selfHosted: false,
premiumMode: 'everyone',
stored: operatorConfig({prices: {}, enabled: false}),
tiering: true,
billing: true,
},
];
it.each(cases)(
'selfHosted=$selfHosted premiumMode=$premiumMode gives tiering=$tiering billing=$billing',
({selfHosted, premiumMode, stored, tiering, billing}) => {
instance.selfHosted = selfHosted;
setCachedInstancePremiumMode(premiumMode);
setStoredBillingConfig(stored);
expect(isPremiumTieringActive()).toBe(tiering);
expect(isBillingActive()).toBe(billing);
},
);
it('is inactive without a secret key, or when env billing is disabled', () => {
instance.selfHosted = false;
stripe.secretKey = '';
expect(isBillingActive()).toBe(false);
stripe.secretKey = original.secretKey;
stripe.enabled = false;
expect(isBillingActive()).toBe(false);
setStoredBillingConfig(operatorConfig({enabled: true}));
expect(isBillingActive()).toBe(false);
instance.selfHosted = true;
expect(isBillingActive()).toBe(true);
});
it('is inactive in env mode when no recurring pair is configured', () => {
instance.selfHosted = false;
stripe.prices = {monthlyUsd: 'price_a', gift1MonthUsd: 'price_b', gift1YearUsd: 'price_c'};
expect(isBillingActive()).toBe(false);
});
});
describe('hosted instances', () => {
it('ignore every stored billing field and keep the env config', () => {
const hostedEnv = (() => {
instance.selfHosted = false;
return getEffectiveBillingConfig();
})();
setStoredBillingConfig(
operatorConfig({
enabled: false,
automatic_tax: false,
tax_id_collection: false,
terms_consent_required: false,
}),
);
const config = getEffectiveBillingConfig();
expect(config).toBe(hostedEnv);
expect(config.catalogMode).toBe('env');
expect(config.enabled).toBe(true);
expect(config.secretKey).toBe('sk_test_fluxer');
expect(config.webhookSecret).toBe('whsec_test_fluxer');
expect(config.prices).toEqual(expectedEnvCatalog());
expect(config.legacyPrices).toEqual({monthly_try: ['price_old_try']});
expect(config.automaticTax).toBe(true);
expect(config.taxIdCollection).toBe(true);
expect(config.termsConsentRequired).toBe(true);
expect(isBillingActive()).toBe(true);
});
});
describe('checkout flags', () => {
it('default to off on self-hosted and follow the stored values', () => {
setStoredBillingConfig(operatorConfig());
let config = getEffectiveBillingConfig();
expect(config.automaticTax).toBe(false);
expect(config.taxIdCollection).toBe(false);
expect(config.termsConsentRequired).toBe(false);
setStoredBillingConfig(
operatorConfig({automatic_tax: true, tax_id_collection: false, terms_consent_required: true}),
);
config = getEffectiveBillingConfig();
expect(config.automaticTax).toBe(true);
expect(config.taxIdCollection).toBe(false);
expect(config.termsConsentRequired).toBe(true);
});
it('default to off on self-hosted without any stored config', () => {
const config = getEffectiveBillingConfig();
expect(config.automaticTax).toBe(false);
expect(config.taxIdCollection).toBe(false);
expect(config.termsConsentRequired).toBe(false);
});
});
describe('isStripeServiceable and getStripeClient', () => {
it('follows env enabled plus key on hosted, exactly like before', () => {
instance.selfHosted = false;
expect(isStripeServiceable()).toBe(true);
expect(getStripeClient()).not.toBeNull();
stripe.enabled = false;
expect(isStripeServiceable()).toBe(false);
expect(getStripeClient()).toBeNull();
stripe.enabled = true;
stripe.secretKey = '';
expect(isStripeServiceable()).toBe(false);
expect(getStripeClient()).toBeNull();
});
it('keeps servicing on self-hosted after sales are switched off or prices removed', () => {
setStoredBillingConfig(operatorConfig({enabled: false, prices: {}}));
expect(isBillingActive()).toBe(false);
expect(isStripeServiceable()).toBe(true);
expect(getStripeClient()).not.toBeNull();
});
it('stops servicing on self-hosted without a key or without premium tiering', () => {
setStoredBillingConfig(operatorConfig({stripe_secret_key: null}));
stripe.secretKey = '';
expect(isStripeServiceable()).toBe(false);
expect(getStripeClient()).toBeNull();
stripe.secretKey = original.secretKey;
setStoredBillingConfig(operatorConfig());
setCachedInstancePremiumMode('everyone');
expect(isStripeServiceable()).toBe(false);
expect(getStripeClient()).toBeNull();
});
});
describe('isCurrentCatalogPriceId', () => {
it('matches current catalog ids only, never legacy ids', () => {
setStoredBillingConfig(operatorConfig());
expect(isCurrentCatalogPriceId('price_gbp_monthly')).toBe(true);
expect(isCurrentCatalogPriceId('price_gbp_gift_year')).toBe(true);
expect(isCurrentCatalogPriceId('price_chf_yearly')).toBe(true);
expect(isCurrentCatalogPriceId('price_gbp_old')).toBe(false);
expect(isCurrentCatalogPriceId('price_monthly_usd')).toBe(false);
});
});
describe('getAcceptedWebhookSecrets', () => {
beforeEach(() => {
instance.selfHosted = false;
getAcceptedWebhookSecrets();
instance.selfHosted = true;
});
afterEach(() => {
vi.restoreAllMocks();
});
it('remembers the previously effective env secret when a stored one replaces it', () => {
getAcceptedWebhookSecrets();
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_first'}));
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_first', 'whsec_test_fluxer']);
});
it('remembers the previous self-hosted webhook secret for 24 hours after a change', () => {
const start = Date.now();
const now = vi.spyOn(Date, 'now').mockReturnValue(start);
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_first'}));
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_first']);
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_second'}));
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_second', 'whsec_first']);
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_second'}));
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_second', 'whsec_first']);
now.mockReturnValue(start + 23 * 60 * 60 * 1000);
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_second', 'whsec_first']);
now.mockReturnValue(start + 24 * 60 * 60 * 1000 + 1);
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_second']);
});
it('drops a remembered secret once it becomes current again', () => {
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_first'}));
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_second'}));
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_first'}));
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_first', 'whsec_second']);
});
it('accepts only the current env secret on hosted', () => {
instance.selfHosted = false;
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_test_fluxer']);
stripe.webhookSecret = 'whsec_rotated';
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_rotated']);
setStoredBillingConfig(operatorConfig({stripe_webhook_secret: 'whsec_stored'}));
expect(getAcceptedWebhookSecrets()).toEqual(['whsec_rotated']);
});
});
});
@@ -0,0 +1,674 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import crypto from 'node:crypto';
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import {
getEffectiveBillingConfig,
getStoredBillingConfig,
isBillingActive,
type StoredBillingConfig,
setStoredBillingConfig,
} from '@app/api/stripe/BillingConfigCache';
import {getProductRegistry, ProductRegistry, ProductType} from '@app/api/stripe/ProductRegistry';
import {getStripeClient} from '@app/api/stripe/StripeClient';
import {setupSyncStripeWebhookWorker} from '@app/api/stripe/tests/StripeWebhookTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import {
createMockWebhookPayload,
createStripeApiHandlers,
type StripeApiHandlers,
} from '@app/api/test/msw/handlers/StripeApiHandlers';
import {server} from '@app/api/test/msw/server';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {getCurrencyPreferences, getGiftCurrencyPreferences, isLocalizedCurrency} from '@app/api/utils/CurrencyUtils';
import processStripeWebhook from '@app/api/worker/tasks/ProcessStripeWebhook';
import {setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import type {PremiumStateResponse, PriceIdsResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {HttpResponse, http} from 'msw';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
const ENV_CURRENCIES = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY'] as const;
function buildFullEnvPrices(): NonNullable<typeof Config.stripe.prices> {
const prices: Record<string, string> = {};
for (const currency of ENV_CURRENCIES) {
const suffix = currency.charAt(0) + currency.slice(1).toLowerCase();
const lower = currency.toLowerCase();
prices[`monthly${suffix}`] = `price_envmonthly${lower}`;
prices[`yearly${suffix}`] = `price_envyearly${lower}`;
prices[`gift1Month${suffix}`] = `price_envgiftmonth${lower}`;
prices[`gift1Year${suffix}`] = `price_envgiftyear${lower}`;
}
return prices as NonNullable<typeof Config.stripe.prices>;
}
const OPERATOR_PRICES = {
GBP: {
monthly: 'price_opgbpmonthly',
yearly: 'price_opgbpyearly',
gift_1_month: 'price_opgbpgiftmonth',
gift_1_year: 'price_opgbpgiftyear',
},
SEK: {
monthly: 'price_opsekmonthly',
yearly: 'price_opsekyearly',
gift_1_month: null,
gift_1_year: null,
},
CHF: {
monthly: 'price_opchfmonthly',
yearly: 'price_opchfyearly',
gift_1_month: 'price_opchfgiftmonth',
gift_1_year: 'price_opchfgiftyear',
},
};
const STORED_WEBHOOK_SECRET = 'whsec_operator_stored';
const ENV_WEBHOOK_SECRET = 'whsec_env_only';
function operatorBilling(overrides: Partial<StoredBillingConfig> = {}): StoredBillingConfig {
return {
enabled: true,
stripe_secret_key: 'sk_test_operator_a',
stripe_webhook_secret: STORED_WEBHOOK_SECRET,
automatic_tax: null,
tax_id_collection: null,
terms_consent_required: null,
default_currency: 'GBP',
prices: OPERATOR_PRICES,
country_currencies: {SE: 'SEK', CH: 'CHF'},
legacy_prices: {monthly_GBP: ['price_opgbplegacymonthly']},
...overrides,
};
}
interface GlobalState {
prices: typeof Config.stripe.prices;
legacyPrices: typeof Config.stripe.legacyPrices;
webhookSecret: typeof Config.stripe.webhookSecret;
selfHosted: boolean;
premiumMode: ReturnType<typeof getCachedInstancePremiumMode>;
storedBilling: StoredBillingConfig | null;
}
function captureGlobalState(): GlobalState {
return {
prices: Config.stripe.prices,
legacyPrices: Config.stripe.legacyPrices,
webhookSecret: Config.stripe.webhookSecret,
selfHosted: Config.instance.selfHosted,
premiumMode: getCachedInstancePremiumMode(),
storedBilling: getStoredBillingConfig(),
};
}
function restoreGlobalState(state: GlobalState): void {
Config.stripe.prices = state.prices;
Config.stripe.legacyPrices = state.legacyPrices;
Config.stripe.webhookSecret = state.webhookSecret;
Config.instance.selfHosted = state.selfHosted;
setCachedInstancePremiumMode(state.premiumMode);
setStoredBillingConfig(state.storedBilling);
}
const WORKER_HELPERS = {logger: new NoopLogger()} as unknown as WorkerTaskHelpers;
function useOperatorBilling(overrides: Partial<StoredBillingConfig> = {}): void {
Config.instance.selfHosted = true;
setCachedInstancePremiumMode('mirror');
setStoredBillingConfig(operatorBilling(overrides));
}
function sessionField(session: object | undefined, key: string): unknown {
return session ? (session as Record<string, unknown>)[key] : undefined;
}
function signWebhook(payload: string, timestamp: number, secret: string): string {
const signature = crypto.createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
return `t=${timestamp},v1=${signature}`;
}
describe('operator billing catalog', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let initialState: GlobalState;
async function createAccount(): Promise<Awaited<ReturnType<typeof createTestAccount>>> {
const storedBilling = getStoredBillingConfig();
const premiumMode = getCachedInstancePremiumMode();
const account = await createTestAccount(harness);
setStoredBillingConfig(storedBilling);
setCachedInstancePremiumMode(premiumMode);
return account;
}
async function createPurchaser(): Promise<string> {
const storedBilling = getStoredBillingConfig();
const premiumMode = getCachedInstancePremiumMode();
const account = await createAccount();
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
setStoredBillingConfig(storedBilling);
setCachedInstancePremiumMode(premiumMode);
return account.token;
}
beforeAll(async () => {
initialState = captureGlobalState();
harness = await createApiTestHarness();
});
afterAll(async () => {
restoreGlobalState(initialState);
await harness.shutdown();
});
beforeEach(() => {
restoreGlobalState(initialState);
Config.stripe.prices = buildFullEnvPrices();
Config.stripe.legacyPrices = {};
stripeHandlers = createStripeApiHandlers({subscriptionsListEmpty: true});
server.use(...stripeHandlers.handlers);
});
afterEach(() => {
restoreGlobalState(initialState);
});
describe('env catalog mode', () => {
test('registers every env price with the same product shape as before', () => {
const registry = new ProductRegistry();
expect(getEffectiveBillingConfig().catalogMode).toBe('env');
for (const currency of ENV_CURRENCIES) {
const lower = currency.toLowerCase();
expect(registry.getProduct(`price_envmonthly${lower}`)).toEqual({
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency,
billingCycle: 'monthly',
});
expect(registry.getProduct(`price_envyearly${lower}`)).toEqual({
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency,
billingCycle: 'yearly',
});
expect(registry.getProduct(`price_envgiftmonth${lower}`)).toEqual({
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency,
});
expect(registry.getProduct(`price_envgiftyear${lower}`)).toEqual({
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency,
});
expect(registry.getRecurringSubscriptionPriceId('monthly', lower)).toBe(`price_envmonthly${lower}`);
expect(registry.getRecurringSubscriptionPriceId('yearly', currency)).toBe(`price_envyearly${lower}`);
expect(registry.getGiftPriceId('gift_1_month', lower)).toBe(`price_envgiftmonth${lower}`);
expect(registry.getGiftPriceId('gift_1_year', currency)).toBe(`price_envgiftyear${lower}`);
}
expect(registry.getRecurringSubscriptionPriceId('monthly', 'GBP')).toBeNull();
});
test('keeps the hosted country routing and localized currency rules', () => {
expect(getCurrencyPreferences('SE')).toEqual(['SEK', 'EUR', 'USD']);
expect(getCurrencyPreferences('GB')).toEqual(['USD', 'EUR']);
expect(getGiftCurrencyPreferences('BR')).toEqual(['USD', 'EUR']);
expect(isLocalizedCurrency('BRL')).toBe(true);
expect(isLocalizedCurrency('USD')).toBe(false);
expect(isLocalizedCurrency('EUR')).toBe(false);
});
test('ignores legacy slots outside the hosted currencies', () => {
Config.stripe.legacyPrices = {monthly_gbp: ['price_legacygbp'], monthly_brl: ['price_legacybrl']};
const registry = getProductRegistry();
expect(registry.getProduct('price_legacygbp')).toBeNull();
expect(registry.getProduct('price_legacybrl')?.currency).toBe('BRL');
});
test('serves the localized SEK price ids for a Swedish request', async () => {
const prices = await createBuilder<PriceIdsResponse>(harness, '')
.get('/premium/price-ids?country_code=SE')
.expect(HTTP_STATUS.OK)
.execute();
expect(prices.currency).toBe('SEK');
expect(prices.monthly).toBe('price_envmonthlysek');
expect(prices.gift_currency).toBe('SEK');
});
test('keeps the hosted tier name, required terms consent and tax collection on hosted', async () => {
setStoredBillingConfig(operatorBilling({terms_consent_required: false, automatic_tax: false}));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: 'price_envgiftmonthusd'})
.expect(HTTP_STATUS.OK)
.execute();
const session = stripeHandlers.spies.createdCheckoutSessions[0];
expect(session?.line_items?.[0]?.price).toBe('price_envgiftmonthusd');
expect(session?.custom_text?.terms_of_service_acceptance?.message).toContain('Fluxer Plutonium');
expect(session?.consent_collection?.terms_of_service).toBe('required');
expect(sessionField(session, 'automatic_tax')).toEqual({enabled: 'true'});
expect(sessionField(session, 'tax_id_collection')).toEqual({enabled: 'true'});
});
test('opens the portal with the stored customer on hosted without checking it first', async () => {
server.use(...createStripeApiHandlers({customerShouldFail: true}).handlers);
const account = await createAccount();
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({stripe_customer_id: 'cus_hosted_existing'})
.execute();
await createBuilder<{url: string}>(harness, account.token)
.post('/premium/customer-portal')
.expect(HTTP_STATUS.OK)
.execute();
const user = await getUserRepository().findUnique(createUserID(BigInt(account.userId)));
expect(user?.stripeCustomerId).toBe('cus_hosted_existing');
});
test('still rejects a localized recurring price without a country code', async () => {
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: 'price_envmonthlybrl'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
});
describe('operator catalog mode', () => {
beforeEach(() => {
useOperatorBilling();
});
test('builds the catalog from the stored prices only', () => {
const registry = getProductRegistry();
expect(getEffectiveBillingConfig().catalogMode).toBe('operator');
expect(registry.getProduct('price_opgbpmonthly')).toEqual({
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'GBP',
billingCycle: 'monthly',
});
expect(registry.getProduct('price_opgbplegacymonthly')?.currency).toBe('GBP');
expect(registry.getProduct('price_envmonthlyusd')).toBeNull();
expect(registry.getRecurringSubscriptionPriceId('yearly', 'gbp')).toBe('price_opgbpyearly');
expect(registry.getGiftPriceId('gift_1_month', 'SEK')).toBeNull();
expect(isLocalizedCurrency('GBP')).toBe(false);
expect(isLocalizedCurrency('BRL')).toBe(false);
});
test('routes countries through country_currencies, then the default currency', async () => {
const swedish = await createBuilder<PriceIdsResponse>(harness, '')
.get('/premium/price-ids?country_code=SE')
.expect(HTTP_STATUS.OK)
.execute();
expect(swedish.currency).toBe('SEK');
expect(swedish.monthly).toBe('price_opsekmonthly');
expect(swedish.yearly).toBe('price_opsekyearly');
expect(swedish.gift_currency).toBe('GBP');
expect(swedish.gift_1_month).toBe('price_opgbpgiftmonth');
const swiss = await createBuilder<PriceIdsResponse>(harness, '')
.get('/premium/price-ids?country_code=CH')
.expect(HTTP_STATUS.OK)
.execute();
expect(swiss.currency).toBe('CHF');
expect(swiss.gift_currency).toBe('CHF');
const american = await createBuilder<PriceIdsResponse>(harness, '')
.get('/premium/price-ids?country_code=US')
.expect(HTTP_STATUS.OK)
.execute();
expect(american.currency).toBe('GBP');
expect(american.yearly).toBe('price_opgbpyearly');
expect(american.gift_1_year).toBe('price_opgbpgiftyear');
});
test('falls back to the first configured currency without a default', () => {
setStoredBillingConfig(operatorBilling({default_currency: null, country_currencies: null}));
expect(getCurrencyPreferences('US')).toEqual(['GBP', 'SEK', 'CHF']);
expect(getGiftCurrencyPreferences('US')).toEqual(['GBP', 'CHF']);
});
test('creates a GBP checkout with the stored price id and no country enforcement', async () => {
const token = await createPurchaser();
const withoutCountry = await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: 'price_opgbpmonthly'})
.expect(HTTP_STATUS.OK)
.execute();
expect(withoutCountry.url).toContain('checkout.stripe.com');
const session = stripeHandlers.spies.createdCheckoutSessions[0];
expect(session?.mode).toBe('subscription');
expect(session?.line_items?.[0]?.price).toBe('price_opgbpmonthly');
expect(session?.metadata?.price_id).toBe('price_opgbpmonthly');
expect(session?.payment_method_types).toBeUndefined();
expect(session?.payment_method_options).toBeUndefined();
expect(session?.consent_collection).toBeUndefined();
expect(session?.custom_text).toBeUndefined();
expect(sessionField(session, 'automatic_tax')).toEqual({enabled: 'false'});
expect(sessionField(session, 'tax_id_collection')).toEqual({enabled: 'false'});
});
test('sends terms consent and tax collection when the operator turns them on', async () => {
useOperatorBilling({automatic_tax: true, tax_id_collection: true, terms_consent_required: true});
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: 'price_opgbpyearly'})
.expect(HTTP_STATUS.OK)
.execute();
const session = stripeHandlers.spies.createdCheckoutSessions[0];
expect(session?.consent_collection?.terms_of_service).toBe('required');
expect(session?.custom_text?.terms_of_service_acceptance?.message).toBeTruthy();
expect(sessionField(session, 'automatic_tax')).toEqual({enabled: 'true'});
expect(sessionField(session, 'tax_id_collection')).toEqual({enabled: 'true'});
});
test('rejects legacy price ids for new checkouts', async () => {
const token = await createPurchaser();
expect(getProductRegistry().getProduct('price_opgbplegacymonthly')).not.toBeNull();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: 'price_opgbplegacymonthly'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('serves subscription prices without gifts when no currency has gift prices', async () => {
useOperatorBilling({
prices: {
GBP: {monthly: 'price_opgbpmonthly', yearly: 'price_opgbpyearly', gift_1_month: null, gift_1_year: null},
},
});
const prices = await createBuilder<PriceIdsResponse>(harness, '')
.get('/premium/price-ids?country_code=GB')
.expect(HTTP_STATUS.OK)
.execute();
expect(prices.currency).toBe('GBP');
expect(prices.monthly).toBe('price_opgbpmonthly');
expect(prices.yearly).toBe('price_opgbpyearly');
expect(prices.gift_1_month ?? null).toBeNull();
expect(prices.gift_1_year ?? null).toBeNull();
expect(prices.gift_currency).toBeNull();
const account = await createAccount();
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
.get('/premium/state')
.expect(HTTP_STATUS.OK)
.execute();
expect(state.pricing.localized?.monthly).toBe('price_opgbpmonthly');
expect(state.pricing.localized?.yearly).toBe('price_opgbpyearly');
expect(state.pricing.localized?.gift_1_month ?? null).toBeNull();
expect(state.pricing.localized?.gift_currency).toBeNull();
});
test('fills premium state amounts from Stripe when the price mirror has no row', async () => {
const suffix = crypto.randomBytes(4).toString('hex');
const monthly = `price_mirrormiss${suffix}monthly`;
const yearly = `price_mirrormiss${suffix}yearly`;
useOperatorBilling({prices: {GBP: {monthly, yearly, gift_1_month: null, gift_1_year: null}}});
expect(await getBillingRepository().prices.findById(monthly)).toBeNull();
const account = await createAccount();
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
.get('/premium/state')
.expect(HTTP_STATUS.OK)
.execute();
expect(state.pricing.localized?.monthly_amount_minor).toBe(499);
expect(state.pricing.localized?.yearly_amount_minor).toBe(4999);
expect(Number((await getBillingRepository().prices.findById(monthly))?.unit_amount)).toBe(499);
});
test('replaces a stored customer that no longer exists in the configured Stripe account', async () => {
server.use(
...createStripeApiHandlers({
subscriptionsListEmpty: true,
customerShouldFail: true,
subscriptionShouldFail: true,
}).handlers,
);
const account = await createAccount();
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({stripe_customer_id: 'cus_from_old_account', stripe_subscription_id: 'sub_from_old_account'})
.execute();
useOperatorBilling();
const portal = await createBuilder<{url: string}>(harness, account.token)
.post('/premium/customer-portal')
.expect(HTTP_STATUS.OK)
.execute();
expect(portal.url).toContain('billing.stripe.com');
const user = await getUserRepository().findUnique(createUserID(BigInt(account.userId)));
expect(user?.stripeCustomerId).toBeTruthy();
expect(user?.stripeCustomerId).not.toBe('cus_from_old_account');
expect(user?.stripeSubscriptionId).toBeNull();
await createBuilder<{url: string}>(harness, account.token)
.post('/stripe/checkout/subscription')
.body({price_id: 'price_opgbpmonthly'})
.expect(HTTP_STATUS.OK)
.execute();
});
test('accepts an operator price that does not match the buyer country', async () => {
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: 'price_opchfyearly', country_code: 'SE'})
.expect(HTTP_STATUS.OK)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions[0]?.line_items?.[0]?.price).toBe('price_opchfyearly');
});
test('never offers the localized card preapproval flow', async () => {
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription/preapproval')
.body({price_id: 'price_opsekmonthly', country_code: 'SE'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects pix and upi for operator prices', async () => {
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: 'price_opgbpmonthly', payment_method: 'pix'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('names the configured premium tier in the checkout terms on a self-hosted instance', async () => {
useOperatorBilling({terms_consent_required: true});
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: 'price_opgbpgiftmonth'})
.expect(HTTP_STATUS.OK)
.execute();
const message =
stripeHandlers.spies.createdCheckoutSessions[0]?.custom_text?.terms_of_service_acceptance?.message;
expect(message).toContain('Premium');
expect(message).not.toContain('Plutonium');
});
});
describe('stripe client', () => {
test('is shared while the secret stays the same and rebuilt when it changes', () => {
useOperatorBilling();
const first = getStripeClient();
expect(first).not.toBeNull();
expect(getStripeClient()).toBe(first);
setStoredBillingConfig(operatorBilling({stripe_secret_key: 'sk_test_operator_b'}));
const second = getStripeClient();
expect(second).not.toBeNull();
expect(second).not.toBe(first);
});
test('stays available on self-hosted after billing is disabled so subscriptions can be serviced', () => {
useOperatorBilling({enabled: false});
expect(isBillingActive()).toBe(false);
expect(getStripeClient()).not.toBeNull();
});
test('is unavailable on hosted when env billing is disabled', () => {
const enabled = Config.stripe.enabled;
Config.stripe.enabled = false;
try {
setStoredBillingConfig(operatorBilling({enabled: true}));
expect(getStripeClient()).toBeNull();
} finally {
Config.stripe.enabled = enabled;
}
});
test('sends requests with the currently stored secret key', async () => {
const authorizations: Array<string | null> = [];
server.use(
http.get('https://api.stripe.com/v1/prices/:id', ({request, params}) => {
authorizations.push(request.headers.get('authorization'));
return HttpResponse.json({id: params.id, object: 'price', unit_amount: 500, currency: 'gbp'});
}),
);
const suffix = crypto.randomBytes(4).toString('hex');
const pricesFor = (tag: string) => ({
GBP: {
monthly: `price_${tag}${suffix}monthly`,
yearly: `price_${tag}${suffix}yearly`,
gift_1_month: `price_${tag}${suffix}giftmonth`,
gift_1_year: `price_${tag}${suffix}giftyear`,
},
});
useOperatorBilling({stripe_secret_key: 'sk_test_rotate_a', prices: pricesFor('a')});
await createBuilder(harness, '').get('/premium/price-ids').expect(HTTP_STATUS.OK).execute();
setStoredBillingConfig(operatorBilling({stripe_secret_key: 'sk_test_rotate_b', prices: pricesFor('b')}));
await createBuilder(harness, '').get('/premium/price-ids').expect(HTTP_STATUS.OK).execute();
expect(authorizations.slice(0, 4).every((value) => value === 'Bearer sk_test_rotate_a')).toBe(true);
expect(authorizations.slice(4)).toHaveLength(4);
expect(authorizations.slice(4).every((value) => value === 'Bearer sk_test_rotate_b')).toBe(true);
});
});
describe('premium purchase availability', () => {
test('reports purchases disabled while billing is inactive', async () => {
Config.stripe.prices = {};
expect(isBillingActive()).toBe(false);
const account = await createAccount();
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
.get('/premium/state')
.expect(HTTP_STATUS.OK)
.execute();
expect(state.effective.premium_purchase_disabled).toBe(true);
expect(state.billing.list_price_switch.reason).toBe('feature_unavailable');
});
test('reports purchases enabled while billing is active', async () => {
expect(isBillingActive()).toBe(true);
const account = await createAccount();
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
.get('/premium/state')
.expect(HTTP_STATUS.OK)
.execute();
expect(state.effective.premium_purchase_disabled).toBe(false);
});
test('reports purchases disabled on a self-hosted instance where everyone is premium', async () => {
Config.instance.selfHosted = true;
setCachedInstancePremiumMode('everyone');
setStoredBillingConfig(operatorBilling());
expect(isBillingActive()).toBe(false);
const account = await createAccount();
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
.get('/premium/state')
.expect(HTTP_STATUS.OK)
.execute();
expect(state.effective.premium_purchase_disabled).toBe(true);
});
});
describe('webhook secret', () => {
beforeEach(() => {
setupSyncStripeWebhookWorker();
Config.stripe.webhookSecret = ENV_WEBHOOK_SECRET;
useOperatorBilling();
});
async function sendWebhook(secret: string): Promise<number> {
const {payload, timestamp} = createMockWebhookPayload({
type: 'customer.created',
data: {object: {id: `cus_operator_${crypto.randomBytes(4).toString('hex')}`}},
});
const {response} = await createBuilder(harness, '')
.post('/stripe/webhook')
.header('stripe-signature', signWebhook(payload, timestamp, secret))
.header('content-type', 'application/json')
.body(payload)
.executeRaw();
return response.status;
}
test('verifies the signature with the stored webhook secret in the api and the worker', async () => {
expect(await sendWebhook(STORED_WEBHOOK_SECRET)).toBe(200);
});
test('rejects a signature made with the overridden env secret', async () => {
expect(await sendWebhook(ENV_WEBHOOK_SECRET)).toBe(401);
});
function signedJob(secret: string): {body: string; signature: string} {
const {payload, timestamp} = createMockWebhookPayload({
type: 'customer.created',
data: {object: {id: `cus_operator_${crypto.randomBytes(4).toString('hex')}`}},
});
return {body: payload, signature: signWebhook(payload, timestamp, secret)};
}
test('accepts a queued event signed with the previous secret in the worker after a rotation', async () => {
setStoredBillingConfig(operatorBilling({stripe_webhook_secret: 'whsec_operator_rotated'}));
await expect(processStripeWebhook(signedJob(STORED_WEBHOOK_SECRET), WORKER_HELPERS)).resolves.toBeUndefined();
await expect(processStripeWebhook(signedJob('whsec_operator_rotated'), WORKER_HELPERS)).resolves.toBeUndefined();
await expect(processStripeWebhook(signedJob('whsec_never_configured'), WORKER_HELPERS)).rejects.toThrow();
});
test('retries instead of discarding a queued event when the self-hosted worker has no Stripe client', async () => {
setWorkerDependenciesForTest({stripe: null});
await expect(processStripeWebhook(signedJob(STORED_WEBHOOK_SECRET), WORKER_HELPERS)).rejects.toThrow();
});
test('still discards a queued event on hosted when Stripe is not configured', async () => {
Config.instance.selfHosted = false;
setWorkerDependenciesForTest({stripe: null});
await expect(processStripeWebhook(signedJob(ENV_WEBHOOK_SECRET), WORKER_HELPERS)).resolves.toBeUndefined();
});
});
});
@@ -0,0 +1,443 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import crypto from 'node:crypto';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {getConfig} from '@app/api/Config';
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import {getInstanceConfigRepository, getLimitConfigService} from '@app/api/middleware/ServiceSingletons';
import {getStoredBillingConfig, setStoredBillingConfig} from '@app/api/stripe/BillingConfigCache';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createStripeApiHandlers} from '@app/api/test/msw/handlers/StripeApiHandlers';
import {server} from '@app/api/test/msw/server';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {WellKnownFluxerResponse} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
const OPERATOR_WEBHOOK_SECRET = 'whsec_operator_secret';
const USD_PRICES = {
monthlyUsd: 'price_monthlyusd',
yearlyUsd: 'price_yearlyusd',
gift1MonthUsd: 'price_gift1monthusd',
gift1YearUsd: 'price_gift1yearusd',
monthlyEur: 'price_monthlyeur',
yearlyEur: 'price_yearlyeur',
gift1MonthEur: 'price_gift1montheur',
gift1YearEur: 'price_gift1yeareur',
};
type StripePrices = ReturnType<typeof getConfig>['stripe']['prices'];
interface GlobalState {
selfHosted: boolean;
premiumMode: ReturnType<typeof getCachedInstancePremiumMode>;
storedBilling: ReturnType<typeof getStoredBillingConfig>;
prices: StripePrices;
}
function captureGlobalState(): GlobalState {
const config = getConfig();
return {
selfHosted: config.instance.selfHosted,
premiumMode: getCachedInstancePremiumMode(),
storedBilling: getStoredBillingConfig(),
prices: config.stripe.prices,
};
}
function restoreGlobalState(state: GlobalState): void {
const config = getConfig();
config.instance.selfHosted = state.selfHosted;
config.stripe.prices = state.prices;
setCachedInstancePremiumMode(state.premiumMode);
setStoredBillingConfig(state.storedBilling);
}
function signWebhook(payload: string, secret: string): string {
const timestamp = Math.floor(Date.now() / 1000);
const signature = crypto.createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
return `t=${timestamp},v1=${signature}`;
}
function webhookPayload(): string {
return JSON.stringify({
id: `evt_${crypto.randomBytes(8).toString('hex')}`,
object: 'event',
type: 'customer.created',
created: Math.floor(Date.now() / 1000),
data: {object: {id: 'cus_test_selfhosted'}},
});
}
async function setPremiumMode(mode: 'mirror' | 'everyone'): Promise<void> {
await getLimitConfigService().updatePolicyConfig({premium_mode: mode});
setCachedInstancePremiumMode(mode);
}
async function generateGiftCode(harness: ApiTestHarness): Promise<string> {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.GIFT_CODES_GENERATE,
]);
const {codes} = await createBuilder<{codes: Array<string>}>(harness, admin.token)
.post('/admin/gift-codes')
.body({count: 1, duration_type: 'months', duration_quantity: 1})
.execute();
const code = codes[0]?.split('/').pop();
if (!code) throw new Error('no gift code generated');
return code;
}
async function expectRoutesNotFound(
harness: ApiTestHarness,
token: string,
routes: ReadonlyArray<readonly ['GET' | 'POST', string]>,
): Promise<void> {
for (const [method, path] of routes) {
expect({path, ...(await readRouteStatus(harness, token, method, path))}).toEqual({
path,
status: HTTP_STATUS.NOT_FOUND,
code: APIErrorCodes.NOT_FOUND,
});
}
}
async function expectRoutesRegistered(
harness: ApiTestHarness,
token: string,
routes: ReadonlyArray<readonly ['GET' | 'POST', string]>,
): Promise<void> {
for (const [method, path] of routes) {
const result = await readRouteStatus(harness, token, method, path);
expect({path, code: result.code}).not.toEqual({path, code: APIErrorCodes.NOT_FOUND});
}
}
async function postSignedWebhook(harness: ApiTestHarness, secret: string): Promise<number> {
const payload = webhookPayload();
const {response} = await createBuilderWithoutAuth(harness)
.post('/stripe/webhook')
.header('stripe-signature', signWebhook(payload, secret))
.header('content-type', 'application/json')
.body(payload)
.executeRaw();
return response.status;
}
async function readRouteStatus(
harness: ApiTestHarness,
token: string | null,
method: 'GET' | 'POST',
path: string,
): Promise<{status: number; code: string | undefined}> {
const builder = token === null ? createBuilderWithoutAuth(harness) : createBuilder(harness, token);
const request = method === 'GET' ? builder.get(path) : builder.post(path).body({});
const {response, json} = await request.executeRaw();
return {status: response.status, code: (json as {code?: string} | undefined)?.code};
}
const PURCHASE_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [
['POST', '/stripe/checkout/subscription'],
['POST', '/stripe/checkout/subscription/preapproval'],
['POST', '/stripe/checkout/subscription/preapproval/continue'],
['POST', '/stripe/checkout/gift'],
['GET', '/premium/price-ids'],
];
const SERVICING_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [
['GET', '/premium/current-subscription-price'],
['POST', '/premium/customer-portal'],
['POST', '/premium/grace/end'],
['POST', '/premium/cancel-subscription'],
['POST', '/premium/reactivate-subscription'],
['POST', '/premium/change-subscription'],
['POST', '/premium/cancel-pending-subscription-change'],
];
const BILLING_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [...PURCHASE_ROUTES, ...SERVICING_ROUTES];
const GIFT_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [
['GET', '/gifts/somegiftcode'],
['POST', '/gifts/somegiftcode/redeem'],
['GET', '/users/@me/gifts'],
];
const HOSTED_ONLY_ROUTES: ReadonlyArray<['GET' | 'POST', string]> = [
['POST', '/users/@me/age-verification'],
['GET', '/premium/refund-eligibility'],
['POST', '/premium/refund-latest'],
['POST', '/premium/switch-to-list-price'],
['POST', '/premium/visionary/rejoin'],
];
describe('self-hosted premium routes', () => {
let harness: ApiTestHarness;
let original: GlobalState;
beforeAll(async () => {
original = captureGlobalState();
getConfig().instance.selfHosted = true;
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
setStoredBillingConfig(null);
setCachedInstancePremiumMode('everyone');
});
afterEach(() => {
getConfig().stripe.prices = original.prices;
setStoredBillingConfig(null);
});
afterAll(async () => {
await harness.shutdown();
restoreGlobalState(original);
});
describe('everyone mode', () => {
test('answers every Stripe and gift route like an unregistered route', async () => {
const account = await createTestAccount(harness);
const unregistered = await readRouteStatus(harness, account.token, 'GET', '/premium/not-a-route');
expect(unregistered).toEqual({status: HTTP_STATUS.NOT_FOUND, code: APIErrorCodes.NOT_FOUND});
for (const [method, path] of [
...BILLING_ROUTES,
...GIFT_ROUTES,
...HOSTED_ONLY_ROUTES,
['POST', '/stripe/webhook'] as const,
]) {
expect({path, ...(await readRouteStatus(harness, account.token, method, path))}).toEqual({
path,
...unregistered,
});
}
});
test('answers before authentication runs', async () => {
expect(await readRouteStatus(harness, null, 'GET', '/users/@me/gifts')).toEqual({
status: HTTP_STATUS.NOT_FOUND,
code: APIErrorCodes.NOT_FOUND,
});
});
test('rejects admin gift code generation', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.GIFT_CODES_GENERATE,
]);
await createBuilder(harness, admin.token)
.post('/admin/gift-codes')
.body({count: 1, duration_type: 'months', duration_quantity: 1})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.FEATURE_NOT_AVAILABLE_SELF_HOSTED)
.execute();
});
test('reports premium and Stripe as disabled in discovery', async () => {
getConfig().stripe.prices = {...USD_PRICES};
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
.get('/.well-known/fluxer')
.execute();
expect(discovery.features).toMatchObject({
premium_enabled: false,
stripe_enabled: false,
stripe_serviceable: false,
self_hosted: true,
});
});
});
describe('mirror mode without billing', () => {
beforeEach(async () => {
await setPremiumMode('mirror');
});
test('serves admin generated gift codes', async () => {
const code = await generateGiftCode(harness);
const gift = await createBuilderWithoutAuth<{code: string}>(harness).get(`/gifts/${code}`).execute();
expect(gift.code).toBe(code);
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post(`/gifts/${code}/redeem`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const gifts = await createBuilder<Array<unknown>>(harness, account.token).get('/users/@me/gifts').execute();
expect(gifts).toEqual([]);
});
test('keeps purchase and hosted-only routes unavailable', async () => {
const account = await createTestAccount(harness);
await expectRoutesNotFound(harness, account.token, [...PURCHASE_ROUTES, ...HOSTED_ONLY_ROUTES]);
});
test('keeps servicing routes and the webhook available while a Stripe key is configured', async () => {
const account = await createTestAccount(harness);
await expectRoutesRegistered(harness, account.token, SERVICING_ROUTES);
expect(await postSignedWebhook(harness, 'whsec_test_fluxer')).toBe(HTTP_STATUS.OK);
});
test('hides servicing routes and the webhook without a Stripe key', async () => {
const config = getConfig();
const secretKey = config.stripe.secretKey;
config.stripe.secretKey = undefined;
try {
const account = await createTestAccount(harness);
await expectRoutesNotFound(harness, account.token, [...SERVICING_ROUTES, ['POST', '/stripe/webhook'] as const]);
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
.get('/.well-known/fluxer')
.execute();
expect(discovery.features).toMatchObject({premium_enabled: true, stripe_serviceable: false});
} finally {
config.stripe.secretKey = secretKey;
}
});
test('reports premium enabled and Stripe disabled in discovery', async () => {
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
.get('/.well-known/fluxer')
.execute();
expect(discovery.features).toMatchObject({
premium_enabled: true,
stripe_enabled: false,
stripe_serviceable: true,
});
});
});
describe('mirror mode with billing configured', () => {
beforeEach(async () => {
await setPremiumMode('mirror');
});
test('serves price ids from the env catalog', async () => {
getConfig().stripe.prices = {...USD_PRICES};
server.use(...createStripeApiHandlers().handlers);
const priceIds = await createBuilderWithoutAuth<Record<string, unknown>>(harness)
.get('/premium/price-ids?country_code=US')
.execute();
expect(priceIds).toMatchObject({monthly: USD_PRICES.monthlyUsd, yearly: USD_PRICES.yearlyUsd});
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
.get('/.well-known/fluxer')
.execute();
expect(discovery.features).toMatchObject({premium_enabled: true, stripe_enabled: true});
});
test('verifies webhooks with the operator webhook secret', async () => {
await getInstanceConfigRepository().setInstanceBillingConfig({
enabled: true,
stripe_webhook_secret: OPERATOR_WEBHOOK_SECRET,
prices: {GBP: {monthly: 'price_monthlygbp', yearly: 'price_yearlygbp'}},
});
const missing = await createBuilderWithoutAuth(harness).post('/stripe/webhook').body('{}').executeRaw();
expect(missing.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
const payload = webhookPayload();
const accepted = await createBuilderWithoutAuth(harness)
.post('/stripe/webhook')
.header('stripe-signature', signWebhook(payload, OPERATOR_WEBHOOK_SECRET))
.header('content-type', 'application/json')
.body(payload)
.executeRaw();
expect(accepted.response.status).toBe(HTTP_STATUS.OK);
const rejected = await createBuilderWithoutAuth(harness)
.post('/stripe/webhook')
.header('stripe-signature', signWebhook(payload, 'whsec_test_fluxer'))
.header('content-type', 'application/json')
.body(payload)
.executeRaw();
expect(rejected.response.status).toBe(HTTP_STATUS.UNAUTHORIZED);
});
test('keeps servicing routes and the webhook available after billing is disabled', async () => {
await getInstanceConfigRepository().setInstanceBillingConfig({
enabled: false,
stripe_webhook_secret: OPERATOR_WEBHOOK_SECRET,
prices: {GBP: {monthly: 'price_monthlygbp', yearly: 'price_yearlygbp'}},
});
const account = await createTestAccount(harness);
await expectRoutesNotFound(harness, account.token, PURCHASE_ROUTES);
await expectRoutesRegistered(harness, account.token, SERVICING_ROUTES);
expect(await postSignedWebhook(harness, OPERATOR_WEBHOOK_SECRET)).toBe(HTTP_STATUS.OK);
const discovery = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
.get('/.well-known/fluxer')
.execute();
expect(discovery.features).toMatchObject({
premium_enabled: true,
stripe_enabled: false,
stripe_serviceable: true,
});
});
test('keeps hosted-only routes unavailable', async () => {
getConfig().stripe.prices = {...USD_PRICES};
const account = await createTestAccount(harness);
for (const [method, path] of HOSTED_ONLY_ROUTES) {
expect({path, ...(await readRouteStatus(harness, account.token, method, path))}).toEqual({
path,
status: HTTP_STATUS.NOT_FOUND,
code: APIErrorCodes.NOT_FOUND,
});
}
});
});
});
describe('hosted premium routes', () => {
let harness: ApiTestHarness;
let original: GlobalState;
beforeAll(async () => {
original = captureGlobalState();
getConfig().instance.selfHosted = false;
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
setStoredBillingConfig(null);
});
afterAll(async () => {
await harness.shutdown();
restoreGlobalState(original);
});
test('keeps every Stripe and gift route registered regardless of premium mode', async () => {
setCachedInstancePremiumMode('everyone');
const account = await createTestAccount(harness);
const routes = [...BILLING_ROUTES, ...GIFT_ROUTES, ...HOSTED_ONLY_ROUTES].filter(
([, path]) => path !== '/users/@me/age-verification',
);
for (const [method, path] of routes) {
const result = await readRouteStatus(harness, account.token, method, path);
expect({path, code: result.code}).not.toEqual({path, code: APIErrorCodes.NOT_FOUND});
}
const webhook = await createBuilderWithoutAuth(harness).post('/stripe/webhook').body('{}').executeRaw();
expect(webhook.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
});
test('serves admin generated gift codes', async () => {
setCachedInstancePremiumMode('everyone');
const code = await generateGiftCode(harness);
const gift = await createBuilderWithoutAuth<{code: string}>(harness).get(`/gifts/${code}`).execute();
expect(gift.code).toBe(code);
});
test('reports premium enabled and Stripe from the configured catalog in discovery', async () => {
const before = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
.get('/.well-known/fluxer')
.execute();
expect(before.features).toMatchObject({premium_enabled: true, stripe_enabled: false, self_hosted: false});
getConfig().stripe.prices = {...USD_PRICES};
try {
const after = await createBuilderWithoutAuth<WellKnownFluxerResponse>(harness)
.get('/.well-known/fluxer')
.execute();
expect(after.features).toMatchObject({premium_enabled: true, stripe_enabled: true});
} finally {
getConfig().stripe.prices = original.prices;
}
});
});
+52 -3
View File
@@ -1,5 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type EffectiveBillingConfig,
getEffectiveBillingConfig,
getOperatorCurrencyPreferences,
} from '@app/api/stripe/BillingConfigCache';
import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea';
import type {PremiumCurrency} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
@@ -9,7 +14,17 @@ export function getCurrency(countryCode: string | null | undefined): Currency {
return getCurrencyPreferences(countryCode)[0];
}
export function getCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
export function getCurrencyPreferences(
countryCode: string | null | undefined,
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
): Array<Currency> {
if (config.catalogMode === 'operator') {
return getOperatorCurrencyPreferences(countryCode, config);
}
return getEnvCurrencyPreferences(countryCode);
}
function getEnvCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
if (!countryCode) {
return ['USD', 'EUR'];
}
@@ -43,8 +58,42 @@ export function getCurrencyPreferences(countryCode: string | null | undefined):
const GIFT_ELIGIBLE_LOCALIZED_CURRENCIES = new Set<Currency>(['DKK', 'NOK', 'SEK']);
export function getGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
return getCurrencyPreferences(countryCode).filter(
const ENV_CATALOG_CURRENCIES = new Set<Currency>(['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY']);
const OPERATOR_CURRENCY_PATTERN = /^[A-Z]{3}$/;
export function getGiftCurrencyPreferences(
countryCode: string | null | undefined,
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
): Array<Currency> {
if (config.catalogMode === 'operator') {
return getOperatorCurrencyPreferences(countryCode, config).filter((currency) => {
const set = config.prices[currency];
return set?.gift_1_month != null && set.gift_1_year != null;
});
}
return getEnvCurrencyPreferences(countryCode).filter(
(currency) => currency === 'USD' || currency === 'EUR' || GIFT_ELIGIBLE_LOCALIZED_CURRENCIES.has(currency),
);
}
export function isLocalizedCurrency(
currency: Currency,
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
): boolean {
return config.catalogMode === 'env' && currency !== 'USD' && currency !== 'EUR';
}
export function normalizeCatalogCurrency(
value: string | null | undefined,
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
): Currency | null {
const currency = value?.trim().toUpperCase();
if (!currency) {
return null;
}
if (config.catalogMode === 'operator') {
return OPERATOR_CURRENCY_PATTERN.test(currency) ? currency : null;
}
return ENV_CATALOG_CURRENCIES.has(currency) ? currency : null;
}
@@ -9,6 +9,7 @@ import {
createGroupDmChannel,
createGuild,
getChannel,
updateUserSettings,
} from '@app/api/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
@@ -39,6 +40,8 @@ describe('Voice Call Ringing', () => {
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await updateUserSettings(harness, user1.token, {default_guilds_restricted: false});
await updateUserSettings(harness, user2.token, {default_guilds_restricted: false});
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
await acceptInvite(harness, user2.token, invite.code);
@@ -97,7 +97,7 @@ import type {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2Toke
import type {ReadStateRepository} from '@app/api/read_state/ReadStateRepository';
import type {ReadStateService} from '@app/api/read_state/ReadStateService';
import type {ReportRepository} from '@app/api/report/ReportRepository';
import {STRIPE_API_VERSION} from '@app/api/stripe/StripeApiVersion';
import {getStripeClient} from '@app/api/stripe/StripeClient';
import {PaymentRepository} from '@app/api/user/repositories/PaymentRepository';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService';
@@ -113,7 +113,7 @@ import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import type {RateLimitService} from '@pkgs/rate_limit/src/RateLimitService';
import type {IVirusScanService} from '@pkgs/virus_scan/src/IVirusScanService';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import Stripe from 'stripe';
import type Stripe from 'stripe';
export interface WorkerDependencies {
kvClient: IKVProvider;
@@ -263,14 +263,6 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
ipInfoService,
});
const billingRepository = new BillingRepository(snowflakeService, kvClient);
let stripe: Stripe | null = null;
if (Config.stripe.enabled && Config.stripe.secretKey) {
stripe = new Stripe(Config.stripe.secretKey, {
apiVersion: STRIPE_API_VERSION,
httpClient: Config.dev.testModeEnabled ? Stripe.createFetchHttpClient() : undefined,
});
Logger.info('Stripe initialized');
}
Logger.info('Worker dependencies initialized successfully');
return {
kvClient,
@@ -325,6 +317,8 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
guildAuditLogService,
contactChangeLogService,
ncmecSubmissionService,
stripe,
get stripe() {
return getStripeClient();
},
};
}
+1 -3
View File
@@ -56,9 +56,7 @@ function registerCronJobs(cron: CronScheduler, jobsStreamMaxAgeMs: number): void
cron.upsert('processPremiumStateReconciliationQueue', 'processPremiumStateReconciliationQueue', {}, '0 * * * * *', {
ledger: false,
});
if (!Config.instance.selfHosted) {
cron.upsert('processExpiredPremiumSweep', 'processExpiredPremiumSweep', {}, '0 0 * * * *', {ledger: false});
}
cron.upsert('processExpiredPremiumSweep', 'processExpiredPremiumSweep', {}, '0 0 * * * *', {ledger: false});
cron.upsert('processInactivityDeletions', 'processInactivityDeletions', {}, '0 0 */6 * * *', {ledger: false});
cron.upsert('expireAttachments', 'expireAttachments', {}, '0 0 */12 * * *', {ledger: false});
if (jobsStreamMaxAgeMs > 0 && jobsStreamMaxAgeMs <= JOBS_STREAM_MAX_AGE_MS) {
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
@@ -9,6 +8,7 @@ import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {Logger} from '@app/api/Logger';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {isPremiumTieringActive} from '@app/api/stripe/BillingConfigCache';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {checkIsPremium, createPremiumClearPatch, shouldStripExpiredPremium} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
@@ -186,8 +186,8 @@ async function processExpiredPremiumSweepCore(deps: SweepDeps): Promise<SweepRes
skipped: 0,
failed: 0,
};
if (Config.instance.selfHosted) {
Logger.debug('Skipping expired premium sweep on a self-hosted instance');
if (!isPremiumTieringActive()) {
Logger.debug('Skipping expired premium sweep because premium tiering is not active');
return result;
}
Logger.debug('Starting expired premium sweep');
@@ -6,6 +6,7 @@ import type {UserRow} from '@app/api/database/types/UserTypes';
import {Logger} from '@app/api/Logger';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import type {User} from '@app/api/models/User';
import {isBillingActive} from '@app/api/stripe/BillingConfigCache';
import {canProvisionPremiumFromSubscriptionStatus} from '@app/api/stripe/StripeSubscriptionAccessPolicy';
import {
getInvoiceLatestLinePeriodEnd,
@@ -274,7 +275,8 @@ async function reconcileUserPremiumStateFromStripe(params: {userId: UserID; stri
);
if (!subscription) {
const hasStalePremium = user.premiumType === UserPremiumTypes.SUBSCRIPTION;
const hasNonStripePremium = Config.instance.selfHosted || (user.premiumFlags & PremiumFlags.ENABLED_OVERRIDE) !== 0;
const hasNonStripePremium =
(Config.instance.selfHosted && !isBillingActive()) || (user.premiumFlags & PremiumFlags.ENABLED_OVERRIDE) !== 0;
if (hasStalePremium && !hasNonStripePremium) {
const patch: Partial<UserRow> = {};
let effectivePremiumUntil = getEffectivePremiumUntil(user);
@@ -2,7 +2,8 @@
import {revokeAllAuthSessions} from '@app/api/auth/AuthSessionRevocation';
import type {ISessionTerminator} from '@app/api/auth/ISessionTerminator';
import {ProductRegistry} from '@app/api/stripe/ProductRegistry';
import {Config} from '@app/api/Config';
import {getProductRegistry} from '@app/api/stripe/ProductRegistry';
import {AgeVerificationService} from '@app/api/stripe/services/AgeVerificationService';
import {StripeCheckoutService} from '@app/api/stripe/services/StripeCheckoutService';
import {StripeGiftService} from '@app/api/stripe/services/StripeGiftService';
@@ -22,11 +23,15 @@ const PayloadSchema = z.object({
const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
const {body, signature} = PayloadSchema.parse(payload);
const deps = getWorkerDependencies();
if (!deps.stripe) {
const stripe = deps.stripe;
if (!stripe) {
if (Config.instance.selfHosted) {
throw new Error('Stripe is not configured on this worker yet; retrying webhook event');
}
helpers.logger.warn('Stripe is not configured; discarding webhook event');
return;
}
const productRegistry = new ProductRegistry();
const productRegistry = getProductRegistry();
const sessionTerminator: ISessionTerminator = {
async terminateAllUserSessions(userId) {
await revokeAllAuthSessions({users: deps.userRepository, gateway: deps.gatewayService}, userId);
@@ -38,21 +43,16 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
deps.guildRepository,
deps.guildService,
);
const checkoutService = new StripeCheckoutService(
deps.stripe,
deps.userRepository,
productRegistry,
deps.cacheService,
);
const checkoutService = new StripeCheckoutService(stripe, deps.userRepository, productRegistry, deps.cacheService);
const subscriptionService = new StripeSubscriptionService(
deps.stripe,
stripe,
deps.userRepository,
productRegistry,
deps.cacheService,
deps.gatewayService,
);
const giftService = new StripeGiftService(
deps.stripe,
stripe,
deps.userRepository,
deps.cacheService,
deps.gatewayService,
@@ -60,12 +60,15 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
premiumService,
subscriptionService,
);
const ageVerificationService = deps.stripe
? new AgeVerificationService(deps.stripe, deps.userRepository, deps.gatewayService, deps.cacheService)
: null;
const refundService = new StripeRefundService(deps.stripe, deps.userRepository, subscriptionService);
const ageVerificationService = new AgeVerificationService(
stripe,
deps.userRepository,
deps.gatewayService,
deps.cacheService,
);
const refundService = new StripeRefundService(stripe, deps.userRepository, subscriptionService);
const webhookService = new StripeWebhookService(
deps.stripe,
stripe,
checkoutService,
deps.userRepository,
deps.userCacheService,
@@ -1,12 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {Logger} from '@app/api/Logger';
import {mapGiftDurationMonthsToFields} from '@app/api/models/GiftCode';
import type {Payment} from '@app/api/models/Payment';
import type {User} from '@app/api/models/User';
import {ProductRegistry} from '@app/api/stripe/ProductRegistry';
import {getProductRegistry} from '@app/api/stripe/ProductRegistry';
import {extractId} from '@app/api/stripe/StripeUtils';
import type {PaymentRepository} from '@app/api/user/repositories/PaymentRepository';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
@@ -71,7 +70,7 @@ async function reconcileCompletedGiftWithoutCode(payment: Payment, purchaser: Us
return;
}
}
const productRegistry = new ProductRegistry();
const productRegistry = getProductRegistry();
const productInfo = payment.priceId ? productRegistry.getProduct(payment.priceId) : null;
if (!productInfo) {
Logger.warn(
@@ -159,7 +158,7 @@ async function reconcileStuckGiftPayment(payment: Payment, purchaser: User, stri
}
}
if (!giftCode) {
const productRegistry = new ProductRegistry();
const productRegistry = getProductRegistry();
const productInfo = payment.priceId ? productRegistry.getProduct(payment.priceId) : null;
if (!productInfo) {
Logger.warn(
@@ -316,9 +315,6 @@ const reconcileUserPayments: WorkerTaskHandler = async (payload, helpers) => {
helpers.logger.debug('Stripe is disabled, skipping user payment reconciliation');
return;
}
if (!Config.stripe.enabled) {
return;
}
const userIdStr = payload.userId as string;
if (!userIdStr) {
helpers.logger.warn({payload}, 'Payment reconciliation task missing userId');
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getConfig} from '@app/api/Config';
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import processExpiredPremiumSweep from '@app/api/worker/tasks/ProcessExpiredPremiumSweep';
@@ -31,14 +32,21 @@ function createHelpers(): WorkerTaskHelpers {
};
}
async function withSelfHosted(selfHosted: boolean, callback: () => Promise<void>): Promise<void> {
async function withSelfHosted(
selfHosted: boolean,
callback: () => Promise<void>,
premiumMode: 'mirror' | 'everyone' = 'everyone',
): Promise<void> {
const config = getConfig();
const originalSelfHosted = config.instance.selfHosted;
const originalPremiumMode = getCachedInstancePremiumMode();
try {
config.instance.selfHosted = selfHosted;
setCachedInstancePremiumMode(premiumMode);
await callback();
} finally {
config.instance.selfHosted = originalSelfHosted;
setCachedInstancePremiumMode(originalPremiumMode);
}
}
@@ -47,7 +55,7 @@ describe('processExpiredPremiumSweep', () => {
clearWorkerDependencies();
});
test('scans no users on a self-hosted instance', async () => {
test('scans no users on a self-hosted instance where everyone is premium', async () => {
const harness = createHarness();
await withSelfHosted(true, async () => {
@@ -57,6 +65,20 @@ describe('processExpiredPremiumSweep', () => {
expect(harness.scanLimits).toEqual([]);
});
test('scans users on a self-hosted instance in mirror mode', async () => {
const harness = createHarness();
await withSelfHosted(
true,
async () => {
await processExpiredPremiumSweep({}, createHelpers());
},
'mirror',
);
expect(harness.scanLimits).toEqual([100]);
});
test('scans users on a hosted instance', async () => {
const harness = createHarness();
+1 -1
View File
@@ -25,7 +25,7 @@
<!--{{FLUXER_BOOTSTRAP}}-->
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(typeof WebSocket!=='function')return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(typeof WebSocket!=='function')return;if(window.location.pathname.indexOf('/migrate/')===0)return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
</head>
<body>
<div id="root"></div>
+7
View File
@@ -508,6 +508,13 @@ export default () => {
priority: 43,
reuseExistingChunk: true,
},
i18n: {
test: /[\\/]node_modules[\\/]@lingui[\\/]/,
name: 'i18n',
priority: 42,
reuseExistingChunk: true,
enforce: true,
},
reactAria: {
test: /[\\/]node_modules[\\/]react-aria-components[\\/]/,
name: 'react-aria',
@@ -557,10 +557,6 @@ const SKELETON_SURFACE_INVARIANTS: ReadonlyArray<SkeletonSurfaceInvariant> = [
file: 'src/features/channel/components/MemberListSkeleton.tsx',
counts: {'style={MEMBER_LIST_METRICS_STYLE}': 2},
},
{
file: 'src/features/channel/components/textarea/InputWrapper.module.css',
requires: ['.composerRoot:has(.statusTypingSlot)::before'],
},
{
file: 'src/features/app/components/layout/GuildsLayout.module.css',
requires: [
+204
View File
@@ -0,0 +1,204 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'urlpattern-polyfill';
import {bootstrapSyntheticHistory} from '@app/app/HistoryBootstrap';
import reactiveI18n, {initI18n} from '@app/app/I18n';
import {Routes} from '@app/app/Routes';
import {AppErrorBoundary} from '@app/features/app/components/AppErrorBoundary';
import {ErrorFallback} from '@app/features/app/components/ErrorFallback';
import type {DomainMigrationSide} from '@app/features/app/domain_migration/DomainMigrationCore';
import {installSelfXssNotice} from '@app/features/devtools/utils/SelfXssNotice';
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
import {installLocaleSwitchWatchdog} from '@app/features/i18n/utils/LocaleSwitchWatchdog';
import {installTranslationDomGuard} from '@app/features/i18n/utils/TranslationDomGuard';
import {installScrollRestoration} from '@app/features/platform/components/router/ScrollRestoration';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {
getFormattedClientInfo,
getFormattedClientInfoSync,
installFluxerConfigDebugApi,
preloadClientInfo,
} from '@app/features/platform/utils/ClientInfo';
import {loadLazyModule} from '@app/features/platform/utils/LazyModuleLoader';
import {scheduleNonLatinScriptFaces} from '@app/features/theme/fonts/ScriptFontLoader';
import {installVoiceSubscriptionDebugApi} from '@app/features/voice/diagnostics/VoiceSubscriptionDebugApi';
import {PASSKEY_BRIDGE_PATH} from '@fluxer/constants/src/PasskeyConstants';
import {i18n} from '@lingui/core';
import type {ReactNode} from 'react';
import ReactDOM from 'react-dom/client';
const logger = new Logger('index');
if (typeof window !== 'undefined' && window.history) {
bootstrapSyntheticHistory();
installScrollRestoration();
}
installFluxerConfigDebugApi();
installVoiceSubscriptionDebugApi();
function createRoot(): ReactDOM.Root {
const container = document.getElementById('root');
if (!container) {
throw new Error('Missing #root element');
}
return ReactDOM.createRoot(container);
}
function mountRoot(content: ReactNode, dataFlxScope: string): void {
installTranslationDomGuard();
createRoot().render(
<AppErrorBoundary
fallback={(error) => (
<AppI18nProvider i18n={i18n}>
<ErrorFallback error={error ?? undefined} data-flx={`${dataFlxScope}.error-fallback`} />
</AppI18nProvider>
)}
data-flx={`${dataFlxScope}.app-error-boundary`}
>
{content}
</AppErrorBoundary>,
);
}
async function logClientInfo(): Promise<void> {
try {
const info = await getFormattedClientInfo();
logger.info(`[CLIENT INFO] ${info}`);
} catch (error) {
logger.warn('Failed to load full client info:', error);
logger.info(`[CLIENT INFO] ${getFormattedClientInfoSync()}`);
}
}
async function preloadMarkdownParser(): Promise<void> {
try {
const {preloadMarkdownParserWasm} = await loadLazyModule(
() => import('@app/features/messaging/utils/markdown/parser/MarkdownParserWasm'),
);
await preloadMarkdownParserWasm();
} catch (error) {
logger.warn('Failed to preload markdown parser:', error);
}
}
async function bootstrapThemeStudio(): Promise<void> {
const markdownParserReady = preloadMarkdownParser();
const [{ThemeStudioStandaloneApp}, {setupHttp}, {default: AccountManager}] = await Promise.all([
loadLazyModule(() => import('@app/features/theme_studio/ThemeStudioStandaloneApp')),
loadLazyModule(() => import('@app/app/SetupHttp')),
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
]);
await AccountManager.bootstrap();
setupHttp();
await markdownParserReady;
mountRoot(
<AppI18nProvider i18n={i18n}>
<ThemeStudioStandaloneApp data-flx="index.render-theme-studio.theme-studio-standalone-app" />
</AppI18nProvider>,
'index.render-theme-studio',
);
}
export async function runPasskeyBridge(side: DomainMigrationSide): Promise<void> {
const hash = window.location.hash;
const opensInOwnTab = window.history.length === 1;
window.history.replaceState(null, '', PASSKEY_BRIDGE_PATH);
const [{PasskeyBridgePage}] = await Promise.all([
loadLazyModule(() => import('@app/features/auth/passkey_migration/PasskeyBridgePage')),
initI18n(),
]);
mountRoot(
<AppI18nProvider i18n={i18n}>
<PasskeyBridgePage
side={side}
hash={hash}
opensInOwnTab={opensInOwnTab}
data-flx="index.passkey-bridge.passkey-bridge-page"
/>
</AppI18nProvider>,
'index.passkey-bridge',
);
}
async function probeSignedOutDomainMigration(): Promise<void> {
try {
const {probeSignedOutDeviceEnrollment} = await loadLazyModule(
() => import('@app/features/app/domain_migration/DomainMigrationTrigger'),
);
probeSignedOutDeviceEnrollment();
} catch (error) {
logger.warn('Failed to start the signed-out domain migration probe:', error);
}
}
async function bootstrapApp(): Promise<void> {
const markdownParserReady = preloadMarkdownParser();
const [
{App},
{setupHttp},
{default: CaptchaInterceptor},
{initializeEmojiParser},
{registerServiceWorker},
{default: AccountManager},
{default: ChannelDisplayName},
_channelFrecency,
_geoIp,
{default: Keybind},
{default: NewDeviceMonitoring},
{default: Notification},
{default: QuickSwitcher},
_runtimeConfig,
{default: StatusPage},
{installMigratedDeviceRemap},
] = await Promise.all([
loadLazyModule(() => import('@app/app/App')),
loadLazyModule(() => import('@app/app/SetupHttp')),
loadLazyModule(() => import('@app/features/auth/components/CaptchaInterceptor')),
loadLazyModule(() => import('@app/features/messaging/utils/markdown/EmojiProviderSetup')),
loadLazyModule(() => import('@app/features/platform/service_worker/Register')),
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
loadLazyModule(() => import('@app/features/channel/state/ChannelDisplayName')),
loadLazyModule(() => import('@app/features/channel/state/ChannelFrecency')),
loadLazyModule(() => import('@app/features/app/state/GeoIP')),
loadLazyModule(() => import('@app/features/input/state/InputKeybind')),
loadLazyModule(() => import('@app/features/auth/state/NewDeviceMonitoring')),
loadLazyModule(() => import('@app/features/ui/state/Notification')),
loadLazyModule(() => import('@app/features/search/state/QuickSwitcher')),
loadLazyModule(() => import('@app/features/app/state/RuntimeConfig')),
loadLazyModule(() => import('@app/features/user/state/StatusPage')),
loadLazyModule(() => import('@app/features/app/domain_migration/DomainMigrationDeviceRemap')),
]);
void preloadClientInfo();
QuickSwitcher.setI18n(reactiveI18n);
ChannelDisplayName.setI18n(reactiveI18n);
Keybind.setI18n(reactiveI18n);
NewDeviceMonitoring.setI18n(reactiveI18n);
Notification.setI18n(reactiveI18n);
CaptchaInterceptor.setI18n(reactiveI18n);
void StatusPage.checkIncidents();
StatusPage.startPolling();
await Promise.all([AccountManager.bootstrap(), installMigratedDeviceRemap()]);
if (AccountManager.currentUserId === null) {
void probeSignedOutDomainMigration();
}
setupHttp();
initializeEmojiParser();
await markdownParserReady;
mountRoot(<App data-flx="index.bootstrap.app" />, 'index.bootstrap');
QuickSwitcher.preloadModal();
registerServiceWorker();
}
export async function runApp(): Promise<void> {
scheduleNonLatinScriptFaces();
await initI18n();
installLocaleSwitchWatchdog();
installSelfXssNotice();
void logClientInfo();
if (window.location.pathname === Routes.THEME_STUDIO) {
await bootstrapThemeStudio();
} else {
await bootstrapApp();
}
}
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {initI18n} from '@app/app/I18n';
import {BootstrapErrorScreen} from '@app/features/app/components/BootstrapErrorScreen';
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {i18n} from '@lingui/core';
import ReactDOM from 'react-dom/client';
const logger = new Logger('index');
export async function reportBootstrapError(error: unknown): Promise<void> {
const normalized = error instanceof Error ? error : new Error(String(error));
logger.error('Failed to bootstrap app:', normalized);
const container = document.getElementById('root');
if (!container) {
throw new Error('Missing #root element');
}
await initI18n();
ReactDOM.createRoot(container).render(
<AppI18nProvider i18n={i18n}>
<BootstrapErrorScreen error={normalized} data-flx="index.bootstrap-error-screen" />
</AppI18nProvider>,
);
}
+3 -6
View File
@@ -132,12 +132,9 @@ var {
--composer-action-gap: 0.25rem;
--composer-surface-color: var(--background-secondary-lighter);
--composer-status-line-height: 1.125rem;
--composer-status-row-height: 1.5rem;
--composer-status-safe-gap: 0.5rem;
--composer-status-safe-area: calc(
var(--composer-status-line-height) +
var(--composer-status-safe-gap) +
var(--composer-status-safe-gap)
);
--composer-status-safe-area: calc(var(--composer-status-row-height) + var(--composer-status-safe-gap));
--guild-list-item-box-size: 3rem;
--guild-list-item-gap: 0.375rem;
--guild-list-item-target-size: calc(var(--guild-list-item-box-size) + var(--guild-list-item-gap));
@@ -173,7 +170,7 @@ var {
--textarea-min-height: var(--footer-box-height);
--textarea-padding-y: var(--footer-box-padding-y);
--composer-box-inset: max(0rem, calc((var(--input-container-min-height) - var(--textarea-min-height)) / 2));
--messages-bottom-clearance: var(--composer-status-safe-area);
--messages-bottom-clearance: var(--composer-status-row-height);
--composer-box-inset-inline: min(
var(--footer-box-inset),
var(--chat-horizontal-padding, var(--chat-horizontal-padding-default))
@@ -18,6 +18,7 @@ import {createRoute} from '@app/features/platform/components/router/RouterBuilde
import type {RouteConfig, RouteContext} from '@app/features/platform/components/router/RouterTypes';
import {Redirect} from '@app/features/platform/components/router/RouterTypes';
import SessionManager from '@app/features/platform/state/AuthSession';
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
import {i18n} from '@lingui/core';
const AuthorizeIPPage = createAuthRoutePage(
@@ -388,5 +389,5 @@ export const authRouteTree = authLayoutRoute.addChildren([
authorizeIPRoute,
pendingRoute,
reportRoute,
...(RuntimeConfig.isSelfHosted() ? [] : [giftRegisterRoute, giftLoginRoute]),
...(shouldShowPremiumFeatures() ? [giftRegisterRoute, giftLoginRoute] : []),
]);
@@ -7,9 +7,12 @@ import {
PRODUCT_NAME,
} from '@app/features/app/config/I18nDisplayConstants';
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import AppStorage, {PRESERVED_RESET_STORAGE_KEYS} from '@app/features/platform/state/PersistentStorage';
import AppStorage, {
PRESERVED_RESET_STORAGE_KEY_PREFIXES,
PRESERVED_RESET_STORAGE_KEYS,
} from '@app/features/platform/state/PersistentStorage';
import {Button} from '@app/features/ui/button/Button';
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
import {APPLICATION_ICON_DESCRIPTOR, FluxerIconMark} from '@app/features/ui/components/icons/FluxerIconMark';
import {ExternalUrls} from '@fluxer/constants/src/ExternalUrls';
import {Trans, useLingui} from '@lingui/react/macro';
import type React from 'react';
@@ -25,12 +28,16 @@ export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error
window.location.reload();
}, []);
const handleReset = useCallback(() => {
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS);
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS, PRESERVED_RESET_STORAGE_KEY_PREFIXES);
window.location.reload();
}, []);
return (
<div className={styles.errorFallbackContainer} data-flx="app.bootstrap-error-screen.error-fallback-container">
<FluxerIcon className={styles.errorFallbackIcon} data-flx="app.bootstrap-error-screen.error-fallback-icon" />
<FluxerIconMark
aria-label={i18n._(APPLICATION_ICON_DESCRIPTOR, {productName: PRODUCT_NAME})}
className={styles.errorFallbackIcon}
data-flx="app.bootstrap-error-screen.error-fallback-icon"
/>
<div className={styles.errorFallbackContent} data-flx="app.bootstrap-error-screen.error-fallback-content">
<h1 className={styles.errorFallbackTitle} data-flx="app.bootstrap-error-screen.error-fallback-title">
<Trans>Failed to start</Trans>
@@ -3,7 +3,10 @@
import errorFallbackStyles from '@app/features/app/components/ErrorFallback.module.css';
import {NativeTitlebar} from '@app/features/app/components/layout/NativeTitlebar';
import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform';
import AppStorage, {PRESERVED_RESET_STORAGE_KEYS} from '@app/features/platform/state/PersistentStorage';
import AppStorage, {
PRESERVED_RESET_STORAGE_KEY_PREFIXES,
PRESERVED_RESET_STORAGE_KEYS,
} from '@app/features/platform/state/PersistentStorage';
import {ensureLatestAssets} from '@app/features/platform/types/Versioning';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {Button} from '@app/features/ui/button/Button';
@@ -154,7 +157,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
)}
<Button
onClick={() => {
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS);
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS, PRESERVED_RESET_STORAGE_KEY_PREFIXES);
location.reload();
}}
variant="danger"
@@ -92,3 +92,17 @@
.comparisonTableContainer {
margin-top: 0.5rem;
}
.redeemSection {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
text-align: center;
}
.redeemDescription {
font-size: 0.875rem;
line-height: 1.375rem;
color: var(--text-primary-muted);
}
@@ -20,6 +20,7 @@ import {SelfServeRefundSection} from '@app/features/app/components/dialogs/compo
import {SubscriptionCard} from '@app/features/app/components/dialogs/components/plutonium/SubscriptionCard';
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import GeoIP from '@app/features/app/state/GeoIP';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import Guilds from '@app/features/guild/state/Guilds';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
@@ -29,6 +30,13 @@ import {
FREE_VS_PREMIUM_DESCRIPTOR,
VERIFY_EMAIL_TO_PURCHASE_PREMIUM_DESCRIPTOR,
} from '@app/features/premium/utils/PremiumMessageDescriptors';
import {
areGiftPurchasesAvailable,
arePremiumPurchasesAvailable,
canServiceStripeSubscriptions,
shouldShowPremiumFeatures,
} from '@app/features/premium/utils/PremiumUtils';
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import MobileLayout from '@app/features/ui/state/MobileLayout';
@@ -97,6 +105,13 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
const isClaimed = currentUser?.isClaimed() ?? false;
const isEmailVerified = currentUser?.verified === true;
const purchaseDisabled = !isClaimed || !isEmailVerified;
const userPurchaseDisabled = premiumState?.effective.premium_purchase_disabled === true;
const purchasesAvailable = arePremiumPurchasesAvailable(userPurchaseDisabled);
const giftPurchasesAvailable = areGiftPurchasesAvailable(priceIds, userPurchaseDisabled);
const hasBillingRelationship = subscriptionStatus.hasEverPurchased || premiumState?.billing.subscription != null;
const billingUnavailable =
!shouldShowPremiumFeatures() ||
(!arePremiumPurchasesAvailable() && !(hasBillingRelationship && canServiceStripeSubscriptions()));
const purchaseDisabledTooltip = !isClaimed
? i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME})
: i18n._(VERIFY_EMAIL_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME});
@@ -162,16 +177,18 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
return (
<div className={styles.giftModeContainer} data-flx="app.plutonium-content.gift-mode-container">
<PlutoniumUpsellBanner data-flx="app.plutonium-content.plutonium-upsell-banner" />
<GiftSection
giftSectionRef={giftSectionRef}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
data-flx="app.plutonium-content.gift-section"
/>
{giftPurchasesAvailable && (
<GiftSection
giftSectionRef={giftSectionRef}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
data-flx="app.plutonium-content.gift-section"
/>
)}
<div ref={perksSectionRef} data-flx="app.plutonium-content.div">
<section className={styles.perksSection} data-flx="app.plutonium-content.perks-section">
<SectionHeader
@@ -246,14 +263,17 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
handleCommunityButtonClick={handleCommunityButtonClick}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
billingUnavailable={billingUnavailable}
data-flx="app.plutonium-content.subscription-card"
/>
<div className={styles.disclaimerContainer} data-flx="app.plutonium-content.disclaimer-container">
<PurchaseDisclaimer align="center" isPremium data-flx="app.plutonium-content.purchase-disclaimer" />
</div>
{purchasesAvailable && (
<div className={styles.disclaimerContainer} data-flx="app.plutonium-content.disclaimer-container">
<PurchaseDisclaimer align="center" isPremium data-flx="app.plutonium-content.purchase-disclaimer" />
</div>
)}
</section>
)}
{subscriptionStatus.hasEverPurchased && (
{subscriptionStatus.hasEverPurchased && !billingUnavailable && (
<>
<PurchaseHistorySection
premiumState={premiumState}
@@ -261,16 +281,37 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
handleOpenCustomerPortal={handleOpenCustomerPortal}
data-flx="app.plutonium-content.purchase-history-section"
/>
<SelfServeRefundSection
eligibility={premiumState?.billing.refund_eligibility ?? null}
refreshPremiumState={() => PremiumCommands.refreshPremiumState(countryCode ?? undefined)}
data-flx="app.plutonium-content.self-serve-refund-section"
/>
{!RuntimeConfig.isSelfHosted() && (
<SelfServeRefundSection
eligibility={premiumState?.billing.refund_eligibility ?? null}
refreshPremiumState={() => PremiumCommands.refreshPremiumState(countryCode ?? undefined)}
data-flx="app.plutonium-content.self-serve-refund-section"
/>
)}
</>
)}
{!subscriptionStatus.shouldShowPremiumCard ? (
{!purchasesAvailable ? (
!subscriptionStatus.shouldShowPremiumCard && (
<section className={styles.redeemSection} data-flx="app.plutonium-content.redeem-section">
<p className={styles.redeemDescription} data-flx="app.plutonium-content.redeem-description">
<Trans comment="Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.">
Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}.
</Trans>
</p>
<Button
variant="primary"
small
onClick={navigateToRedeemGift}
data-flx="app.plutonium-content.redeem-section.button.navigate-to-redeem-gift"
>
<Trans comment="Billing button for entering a premium gift code.">Redeem gift code</Trans>
</Button>
</section>
)
) : !subscriptionStatus.shouldShowPremiumCard ? (
<PricingSection
isGiftMode={isGiftMode}
isGiftMode={isGiftMode && giftPurchasesAvailable}
giftPurchasesAvailable={giftPurchasesAvailable}
setIsGiftMode={setIsGiftMode}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
@@ -283,16 +324,18 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
data-flx="app.plutonium-content.pricing-section"
/>
) : (
<GiftSection
giftSectionRef={giftSectionRef}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
data-flx="app.plutonium-content.gift-section--2"
/>
giftPurchasesAvailable && (
<GiftSection
giftSectionRef={giftSectionRef}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
data-flx="app.plutonium-content.gift-section--2"
/>
)
)}
<div ref={perksSectionRef} data-flx="app.plutonium-content.div--2">
<section className={styles.perksSection} data-flx="app.plutonium-content.perks-section--2">
@@ -308,9 +351,9 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
</div>
</section>
</div>
{!subscriptionStatus.isPremium && (
{!subscriptionStatus.isPremium && purchasesAvailable && (
<BottomCTASection
isGiftMode={isGiftMode}
isGiftMode={isGiftMode && giftPurchasesAvailable}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
giftMonthlyPrice={giftMonthlyPrice}
@@ -3,28 +3,68 @@
import {Routes} from '@app/app/Routes';
import styles from '@app/features/app/components/dialogs/components/PurchaseDisclaimer.module.css';
import {ExternalLink} from '@app/features/app/components/shared/ExternalLink';
import {PAYMENT_PROVIDER_NAME} from '@app/features/app/config/I18nDisplayConstants';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {Trans} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
export const PurchaseDisclaimer = observer(
({isPremium = false, align = 'center'}: {isPremium?: boolean; align?: 'left' | 'center'}) => {
const terms = (
<ExternalLink href={Routes.terms()} data-flx="app.purchase-disclaimer.external-link">
const selfHosted = RuntimeConfig.isSelfHosted();
const termsUrl = selfHosted ? RuntimeConfig.termsUrl : Routes.terms();
const privacyUrl = selfHosted ? RuntimeConfig.privacyUrl : Routes.privacy();
const terms = termsUrl ? (
<ExternalLink href={termsUrl} data-flx="app.purchase-disclaimer.external-link">
<Trans>Terms of service</Trans>
</ExternalLink>
);
const privacy = (
<ExternalLink href={Routes.privacy()} data-flx="app.purchase-disclaimer.external-link--2">
) : null;
const privacy = privacyUrl ? (
<ExternalLink href={privacyUrl} data-flx="app.purchase-disclaimer.external-link--2">
<Trans>Privacy policy</Trans>
</ExternalLink>
);
return (
<p
className={clsx(styles.disclaimer, align === 'center' ? styles.center : styles.left)}
data-flx="app.purchase-disclaimer.disclaimer"
>
{isPremium ? (
) : null;
const termsOnly = termsUrl ? (
isPremium ? (
<Trans comment="Purchase disclaimer when the instance links only its terms of service. The linked text is the terms of service document.">
By purchasing, you agreed to our{' '}
<ExternalLink href={termsUrl} data-flx="app.purchase-disclaimer.external-link--terms-only">
Terms of service
</ExternalLink>
.
</Trans>
) : (
<Trans comment="Purchase disclaimer when the instance links only its terms of service. The linked text is the terms of service document.">
By purchasing, you agree to our{' '}
<ExternalLink href={termsUrl} data-flx="app.purchase-disclaimer.external-link--terms-only">
Terms of service
</ExternalLink>
.
</Trans>
)
) : null;
const privacyOnly = privacyUrl ? (
isPremium ? (
<Trans comment="Purchase disclaimer when the instance links only its privacy policy. The linked text is the privacy policy document.">
By purchasing, you agreed to our{' '}
<ExternalLink href={privacyUrl} data-flx="app.purchase-disclaimer.external-link--privacy-only">
Privacy policy
</ExternalLink>
.
</Trans>
) : (
<Trans comment="Purchase disclaimer when the instance links only its privacy policy. The linked text is the privacy policy document.">
By purchasing, you agree to our{' '}
<ExternalLink href={privacyUrl} data-flx="app.purchase-disclaimer.external-link--privacy-only">
Privacy policy
</ExternalLink>
.
</Trans>
)
) : null;
const agreement =
terms && privacy ? (
isPremium ? (
<Trans>
By purchasing, you agreed to our {terms} and {privacy}.
</Trans>
@@ -32,13 +72,29 @@ export const PurchaseDisclaimer = observer(
<Trans>
By purchasing, you agree to our {terms} and {privacy}.
</Trans>
)}{' '}
<Trans>
Self-serve refunds available within 3 days of payment, once every 30 days. Refunding a subscription cancels
it. EU/EEA buyers waive the 14-day right of withdrawal at checkout to access content immediately. Use the
in-app refund button instead of a chargeback. Chargebacks can permanently restrict your account. Stripe
handles payment securely. We never see your full card number.
</Trans>
)
) : (
(termsOnly ?? privacyOnly)
);
return (
<p
className={clsx(styles.disclaimer, align === 'center' ? styles.center : styles.left)}
data-flx="app.purchase-disclaimer.disclaimer"
>
{agreement}
{agreement ? ' ' : null}
{selfHosted ? (
<Trans comment="Purchase disclaimer on a self-hosted instance. {PAYMENT_PROVIDER_NAME} is the payment processor name.">
{PAYMENT_PROVIDER_NAME} handles payment securely.
</Trans>
) : (
<Trans>
Self-serve refunds available within 3 days of payment, once every 30 days. Refunding a subscription cancels
it. EU/EEA buyers waive the 14-day right of withdrawal at checkout to access content immediately. Use the
in-app refund button instead of a chargeback. Chargebacks can permanently restrict your account. Stripe
handles payment securely. We never see your full card number.
</Trans>
)}
</p>
);
},
@@ -70,6 +70,7 @@ const MESSAGE_1_MONTH_GIFT_DESCRIPTOR = msg({
interface PricingSectionProps {
isGiftMode: boolean;
giftPurchasesAvailable?: boolean;
setIsGiftMode: (value: boolean) => void;
monthlyPrice: string;
yearlyPrice: string;
@@ -84,6 +85,7 @@ interface PricingSectionProps {
export const PricingSection: React.FC<PricingSectionProps> = observer(
({
isGiftMode,
giftPurchasesAvailable = true,
setIsGiftMode,
monthlyPrice,
yearlyPrice,
@@ -100,25 +102,27 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME});
return (
<section className={styles.section} data-flx="app.plutonium.pricing-section.section">
<div
className={styles.toggleContainer}
role="group"
aria-label={i18n._(PURCHASE_MODE_DESCRIPTOR)}
data-flx="app.plutonium.pricing-section.toggle-container"
>
<ToggleButton
active={!isGiftMode}
onClick={() => setIsGiftMode(false)}
label={i18n._(FOR_ME_DESCRIPTOR)}
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode"
/>
<ToggleButton
active={isGiftMode}
onClick={() => setIsGiftMode(true)}
label={i18n._(AS_A_GIFT_DESCRIPTOR)}
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode--2"
/>
</div>
{giftPurchasesAvailable && (
<div
className={styles.toggleContainer}
role="group"
aria-label={i18n._(PURCHASE_MODE_DESCRIPTOR)}
data-flx="app.plutonium.pricing-section.toggle-container"
>
<ToggleButton
active={!isGiftMode}
onClick={() => setIsGiftMode(false)}
label={i18n._(FOR_ME_DESCRIPTOR)}
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode"
/>
<ToggleButton
active={isGiftMode}
onClick={() => setIsGiftMode(true)}
label={i18n._(AS_A_GIFT_DESCRIPTOR)}
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode--2"
/>
</div>
)}
<div className={gridStyles.gridWrapper} data-flx="app.plutonium.pricing-section.div">
<div className={gridStyles.gridTwoColumns} data-flx="app.plutonium.pricing-section.div--2">
{!isGiftMode ? (
@@ -116,6 +116,7 @@ interface SubscriptionCardProps {
handleCommunityButtonClick: () => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
billingUnavailable?: boolean;
}
function getStatusBadgeClass(args: {
@@ -180,6 +181,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
handleCommunityButtonClick,
purchaseDisabled = false,
purchaseDisabledTooltip,
billingUnavailable = false,
}) => {
const {i18n} = useLingui();
const {loadingSwitchToListPrice, handleSwitchToListPrice} = useSubscriptionActions();
@@ -704,7 +706,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
})()}
</div>
<div className={styles.actions} data-flx="app.plutonium.subscription-card.actions">
{isGiftSubscription ? (
{isGiftSubscription || billingUnavailable ? (
wrapIfDisabled(
<Button
variant="primary"
@@ -11,6 +11,7 @@ import {
SUPPORT_EMAIL,
UPI_PAYMENT_METHOD,
} from '@app/features/app/config/I18nDisplayConstants';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {CANCEL_DESCRIPTOR, CLOSE_DESCRIPTOR, OKAY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -140,6 +141,10 @@ const PURCHASES_DISABLED_BODY_DESCRIPTOR = msg({
message: 'Purchases are disabled for this account. Contact {supportEmail} if this looks wrong.',
comment: 'Modal body shown when purchases are disabled. Provides the support email for appeals.',
});
const PURCHASES_DISABLED_SELF_HOSTED_BODY_DESCRIPTOR = msg({
message: 'Purchases are disabled for this account.',
comment: 'Modal body shown on a self-hosted instance when purchases are disabled for the account.',
});
const CHECKOUT_BLOCKED_TITLE_DESCRIPTOR = msg({
message: 'Checkout unavailable',
comment: 'Modal title for the generic "checkout blocked" state when no more specific reason is known.',
@@ -148,6 +153,10 @@ const CHECKOUT_BLOCKED_BODY_DESCRIPTOR = msg({
message: 'Checkout is blocked for this account. Contact {supportEmail} if you need help.',
comment: 'Modal body for the generic "checkout blocked" state. Provides the support email.',
});
const CHECKOUT_BLOCKED_SELF_HOSTED_BODY_DESCRIPTOR = msg({
message: 'Checkout is blocked for this account.',
comment: 'Modal body for the generic "checkout blocked" state on a self-hosted instance.',
});
const CHECKOUT_START_FAILED_TITLE_DESCRIPTOR = msg({
message: "Couldn't start checkout",
comment: 'Title of the generic fallback error modal shown when creating a checkout session fails unexpectedly.',
@@ -234,7 +243,7 @@ function alternativePaymentMethodForCurrency(
isGift: boolean,
plan: Plan,
): CheckoutPaymentMethod | null {
if (isGift || (plan !== 'monthly' && plan !== 'yearly')) {
if (isGift || (plan !== 'monthly' && plan !== 'yearly') || RuntimeConfig.isSelfHosted()) {
return null;
}
if (currency === 'BRL') return 'pix';
@@ -404,9 +413,11 @@ export const useCheckoutActions = (
modal(() => (
<ConfirmModal
title={i18n._(PURCHASES_DISABLED_TITLE_DESCRIPTOR)}
description={i18n._(PURCHASES_DISABLED_BODY_DESCRIPTOR, {
supportEmail: SUPPORT_EMAIL,
})}
description={
RuntimeConfig.isSelfHosted()
? i18n._(PURCHASES_DISABLED_SELF_HOSTED_BODY_DESCRIPTOR)
: i18n._(PURCHASES_DISABLED_BODY_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL})
}
secondaryText={i18n._(CLOSE_DESCRIPTOR)}
data-flx="app.plutonium.use-checkout-actions.handle-checkout-error.confirm-modal--3"
/>
@@ -418,9 +429,11 @@ export const useCheckoutActions = (
modal(() => (
<ConfirmModal
title={i18n._(CHECKOUT_BLOCKED_TITLE_DESCRIPTOR)}
description={i18n._(CHECKOUT_BLOCKED_BODY_DESCRIPTOR, {
supportEmail: SUPPORT_EMAIL,
})}
description={
RuntimeConfig.isSelfHosted()
? i18n._(CHECKOUT_BLOCKED_SELF_HOSTED_BODY_DESCRIPTOR)
: i18n._(CHECKOUT_BLOCKED_BODY_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL})
}
secondaryText={i18n._(CLOSE_DESCRIPTOR)}
data-flx="app.plutonium.use-checkout-actions.handle-checkout-error.confirm-modal--4"
/>
@@ -21,6 +21,7 @@ import * as NotificationUtils from '@app/features/notification/utils/Notificatio
import NativePermission from '@app/features/permissions/system/state/NativePermission';
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
import PremiumState from '@app/features/premium/state/PremiumState';
import {canServiceStripeSubscriptions, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
import Nagbar from '@app/features/ui/state/Nagbar';
import {hasUnavailableElectronNativeContext, isDesktop} from '@app/features/ui/utils/NativeUtils';
@@ -81,6 +82,10 @@ export const useNagbarConditions = (): NagbarConditions => {
const premiumWillCancel = user?.premiumWillCancel ?? false;
const isMockPremium = premiumOverrideType != null && premiumOverrideType > 0;
const isSelfHosted = RuntimeConfig.isSelfHosted();
const showPremium = shouldShowPremiumFeatures();
const canServiceSubscription =
!isSelfHosted ||
(canServiceStripeSubscriptions() && (user?.premiumBillingCycle != null || user?.hasEverPurchased === true));
const [startupVoiceSessionRestoreSnapshotKey, setStartupVoiceSessionRestoreSnapshotKey] = useState<
string | null | undefined
>(undefined);
@@ -109,7 +114,7 @@ export const useNagbarConditions = (): NagbarConditions => {
return true;
})();
const canShowPremiumGracePeriod = (() => {
if (isSelfHosted) return false;
if (!showPremium || !canServiceSubscription) return false;
if (nagbarState.forceHidePremiumGracePeriod) return false;
if (nagbarState.forcePremiumGracePeriod) return true;
if (!user?.premiumUntil || user.premiumType === 2 || premiumWillCancel) return false;
@@ -123,7 +128,7 @@ export const useNagbarConditions = (): NagbarConditions => {
return isInGracePeriod && !nagbarState.premiumGracePeriodDismissed;
})();
const canShowPremiumExpired = (() => {
if (isSelfHosted) return false;
if (!showPremium || !canServiceSubscription) return false;
if (nagbarState.forceHidePremiumExpired) return false;
if (nagbarState.forcePremiumExpired) return true;
if (!user?.premiumUntil || user.premiumType === 2 || premiumWillCancel) return false;
@@ -140,13 +145,13 @@ export const useNagbarConditions = (): NagbarConditions => {
return showExpiredState && !nagbarState.premiumExpiredDismissed;
})();
const canShowGiftInventory = (() => {
if (isSelfHosted) return false;
if (!showPremium) return false;
if (nagbarState.forceHideGiftInventory) return false;
if (nagbarState.forceGiftInventory) return true;
return Boolean(user?.hasUnreadGiftInventory && !nagbarState.giftInventoryDismissed);
})();
const canShowPremiumOnboarding = (() => {
if (isSelfHosted) return false;
if (!showPremium) return false;
if (nagbarState.forceHidePremiumOnboarding) return false;
if (nagbarState.forcePremiumOnboarding) return true;
if (isMockPremium) return false;
@@ -339,7 +339,8 @@ const PREMIUM_MIRROR_NAME_DESCRIPTOR = msg({
comment: 'Premium model option that mirrors free and premium tiers.',
});
const PREMIUM_MIRROR_DESC_DESCRIPTOR = msg({
message: 'Keep free and premium tiers. You can customize the tiers later.',
message:
'Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel.',
comment: 'Description for the mirror premium model.',
});
const PREMIUM_EVERYONE_NAME_DESCRIPTOR = msg({
@@ -10,7 +10,7 @@ import {
import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine';
import {createSkeletonRandomFromKey} from '@app/features/app/components/skeleton/SkeletonSeed';
import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import {flxElementClassName} from '@app/lib/react';
import {observer} from 'mobx-react-lite';
@@ -66,7 +66,7 @@ function createDMActionSkeletonSpecs(
if (layout?.personalNotesVisible ?? true) {
specs.push({key: 'personal-notes', labelWidth: PERSONAL_NOTES_ACTION_LABEL_WIDTH});
}
if (layout?.premiumVisible ?? (!RuntimeConfig.isSelfHosted() && !isMobile)) {
if (layout?.premiumVisible ?? (shouldShowPremiumFeatures() && !isMobile)) {
specs.push({key: 'premium', labelWidth: PREMIUM_ACTION_LABEL_WIDTH});
}
return Object.freeze(specs);
@@ -8,6 +8,14 @@ function getBootstrapProductName(): string {
return productName || 'Fluxer';
}
function getBootstrapPremiumProductName(): string {
if (typeof window === 'undefined') {
return 'Plutonium';
}
const premiumProductName = window.__FLUXER_BOOTSTRAP__?.instance.app_public?.branding?.premium_product_name?.trim();
return premiumProductName || 'Plutonium';
}
export const PRODUCT_NAME = getBootstrapProductName();
export const PREMIUM_PRODUCT_NAME = 'Plutonium';
export const PREMIUM_PRODUCT_NAME = getBootstrapPremiumProductName();
export const PREMIUM_PRODUCT_FULL_NAME = `${PRODUCT_NAME} ${PREMIUM_PRODUCT_NAME}`;
@@ -221,7 +221,13 @@ describe('rewriteImportedAccount', () => {
userId: '1',
token: 'token',
localStorageData: {runtimeConfig: '{}', token: 'token'},
managedStorageData: {runtimeConfig: '{}', token: 'token', 'fluxer.theme': 'dark'},
managedStorageData: {
runtimeConfig: '{}',
token: 'token',
'fluxer.theme': 'dark',
'fluxer.lastPushEndpoint': 'https://push',
[core.DOMAIN_MIGRATION_MARKER_KEY]: '{}',
},
lastActive: NOW,
instance: {apiEndpoint: 'https://web.fluxer.app/api'} as RuntimeConfigSnapshot,
};
@@ -269,7 +275,7 @@ describe('migration gate', () => {
['assignment off', {assignmentEnabled: false}],
['kill switch', {discovery: {...ENABLED_DISCOVERY, enabled: false}}],
['no discovery', {discovery: null}],
['already completed', {marker: {state: 'completed', target: 'https://fluxer.com', at: NOW}}],
['already completed', {marker: {state: 'completed', target: 'https://fluxer.com', at: NOW, attempts: 1}}],
['failed recently', {marker: {state: 'failed', at: NOW - 60_000, attempts: 1}}],
['failed too often', {marker: {state: 'failed', at: NOW - 3 * 24 * 60 * 60 * 1000, attempts: 3}}],
['Android web app', {environment: environment('chromium-android')}],
@@ -315,9 +321,10 @@ describe('marker state', () => {
state: 'completed',
target: 'https://fluxer.com',
at: NOW + 2,
attempts: 2,
});
core.markDomainMigrationFailed(storage, NOW + 3);
expect(core.readDomainMigrationMarker(storage)).toEqual({state: 'failed', at: NOW + 3, attempts: 1});
expect(core.readDomainMigrationMarker(storage)).toEqual({state: 'failed', at: NOW + 3, attempts: 2});
});
it('ignores malformed markers', () => {
@@ -352,7 +359,12 @@ describe('forwarding', () => {
});
it('honours the kill switch', () => {
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
const completed: core.DomainMigrationMarker = {
state: 'completed',
target: 'https://fluxer.com',
at: NOW,
attempts: 1,
};
const browser = environment('none');
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, completed, browser)).toBe(true);
expect(core.shouldForwardCompletedSource({...ENABLED_DISCOVERY, enabled: false}, completed, browser)).toBe(false);
@@ -361,7 +373,12 @@ describe('forwarding', () => {
});
it('forwards installed apps only when standalone forwarding is on', () => {
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
const completed: core.DomainMigrationMarker = {
state: 'completed',
target: 'https://fluxer.com',
at: NOW,
attempts: 1,
};
const forwarding = {...ENABLED_DISCOVERY, standalone_forwarding: true};
const desktop = environment('chromium-desktop');
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, completed, desktop)).toBe(false);
@@ -488,7 +505,12 @@ describe('classifyDomainMigrationInstallKind', () => {
describe('shouldShowDomainMovedNotice', () => {
const source = core.resolveDomainMigrationSide('https://web.fluxer.app');
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
const completed: core.DomainMigrationMarker = {
state: 'completed',
target: 'https://fluxer.com',
at: NOW,
attempts: 1,
};
function notice(overrides: Partial<core.DomainMovedNoticeInput>): core.DomainMovedNoticeInput {
return {
@@ -632,10 +654,17 @@ describe('runDomainMigrationPreMount', () => {
});
it('forwards a migrated source tab with its hash', async () => {
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
visit('https://web.fluxer.app/channels/1/2?a=1#b', ENABLED_DISCOVERY);
writeCompletedMarker();
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/reset#token=abc');
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1/2?a=1#b');
});
it('keeps one-shot routes on the source', async () => {
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
writeCompletedMarker();
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
});
it('stays put when the kill switch is off', async () => {
@@ -703,10 +732,10 @@ describe('runDomainMigrationPreMount', () => {
it('resumes through the target when a migrated source still holds a session', async () => {
writeCompletedMarker();
window.localStorage.setItem('token', 'session-token');
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
visit('https://web.fluxer.app/channels/1/2#x', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith(
`https://fluxer.com/migrate/begin?resume=1&next=${encodeURIComponent('/reset#token=abc')}`,
`https://fluxer.com/migrate/begin?resume=1&imported=1&next=${encodeURIComponent('/channels/1/2#x')}`,
);
expect(core.readDomainMigrationIntent(window.sessionStorage, Date.now())).not.toBeNull();
});
@@ -795,11 +824,14 @@ describe('runDomainMigrationPreMount', () => {
expect(replace.mock.calls[0]?.[0]).toMatch(/^https:\/\/web\.fluxer\.app\/migrate\/export\?n=[\w-]+$/u);
});
it('opens the target directly when the browser already migrated', async () => {
it('opens the target in place when the browser already migrated', async () => {
window.localStorage.setItem('token', 'session-token');
visit('https://fluxer.com/migrate/begin?start=1&next=%2Fapp', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/app');
const replaceState = vi.fn();
vi.stubGlobal('history', {state: null, replaceState});
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
expect(replaceState).toHaveBeenCalledWith(null, '', '/app');
});
it('does not start a migration inside an installed Android app', async () => {
@@ -819,14 +851,24 @@ describe('runDomainMigrationPreMount', () => {
);
});
it('reports back to the source when the target already holds a session', async () => {
it('still imports into a target that already holds a session', async () => {
window.localStorage.setItem('token', 'session-token');
visit('https://fluxer.com/migrate/begin?next=%2Fchannels%2F1', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/migrate/done?next=%2Fchannels%2F1');
expect(replace.mock.calls[0]?.[0]).toMatch(/^https:\/\/web\.fluxer\.app\/migrate\/export\?n=[\w-]+$/u);
expect(window.sessionStorage.getItem(core.DOMAIN_MIGRATION_PENDING_KEY)).not.toBeNull();
window.localStorage.removeItem(core.DOMAIN_MIGRATION_IMPORT_KEY);
visit('https://fluxer.com/migrate/begin?resume=1&next=%2Fchannels%2F1', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1');
expect(replace.mock.calls[0]?.[0]).toMatch(/^https:\/\/web\.fluxer\.app\/migrate\/export\?n=[\w-]+$/u);
core.writeDomainMigrationImport(window.localStorage, {state: 'done', at: Date.now()});
visit('https://fluxer.com/migrate/begin?resume=1&next=%2Fchannels%2F1', ENABLED_DISCOVERY);
const replaceState = vi.fn();
vi.stubGlobal('history', {state: null, replaceState});
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
expect(replaceState).toHaveBeenCalledWith(null, '', '/channels/1');
});
});
@@ -4,17 +4,23 @@ import {
classifyDomainMigrationInstallKind,
type DomainMigrationDisplayMode,
type DomainMigrationEnvironment,
type DomainMigrationGateInput,
type DomainMigrationInstallKind,
type DomainMigrationSide,
domainMovedBrowserMigrationUrl,
domainMovedInstallUrl,
domainMovedManifestId,
isDomainMigrationOneShotRoute,
markDomainMigrationFailed,
readDomainMigrationMarker,
writeDomainMigrationIntent,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import {
AuthSessionStorageKey,
parseStoredSessionValue,
} from '@app/features/platform/state/auth_session/AuthSessionStorage';
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {hasUnavailableElectronNativeContext, isElectron} from '@app/features/ui/utils/NativeUtils';
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {hasUnavailableElectronNativeContext, isElectron} from '@app/features/ui/utils/ElectronRuntime';
import type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
interface NavigatorWithStandalone extends Navigator {
@@ -68,6 +74,29 @@ export function readDomainMigrationEnvironment(): DomainMigrationEnvironment {
};
}
export function readDomainMigrationGateInput(
assignmentEnabled: boolean,
voiceActive: boolean,
): DomainMigrationGateInput {
return {
environment: readDomainMigrationEnvironment(),
assignmentEnabled,
discovery: readDomainMigrationDiscovery(),
marker: readDomainMigrationMarker(getProtectedLocalStorage()),
now: Date.now(),
voiceActive,
oneShotRoute: isDomainMigrationOneShotRoute(window.location.pathname),
};
}
export function startDomainMigrationFromSource(side: DomainMigrationSide): true {
markDomainMigrationFailed(getProtectedLocalStorage(), Date.now());
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.location.replace(`${side.target}/migrate/begin?next=${encodeURIComponent(next)}`);
return true;
}
export async function desktopPasskeysSupported(): Promise<boolean> {
if (!isElectronEnvironment()) {
return true;
@@ -22,6 +22,11 @@ export const DOMAIN_MIGRATION_PENDING_KEY = 'fluxer:domain-migration:pending';
export const DOMAIN_MIGRATION_INTENT_KEY = 'fluxer:domain-migration:intent';
export const DOMAIN_MIGRATION_NOTIFICATIONS_KEY = 'fluxer:domain-migration:notifications';
export const DOMAIN_MIGRATION_MOVED_DISMISSED_KEY = 'fluxer:domain-migration:moved-dismissed-at';
export const DOMAIN_MIGRATION_ENROLLED_KEY = 'fluxer:domain-migration:enrolled';
export const DOMAIN_MIGRATION_PROBED_AT_KEY = 'fluxer:domain-migration:probed-at';
export const DOMAIN_MIGRATION_IMPORT_KEY = 'fluxer:domain-migration:import';
export const DOMAIN_MIGRATION_DEVICES_KEY = 'fluxer:domain-migration:devices';
export const DOMAIN_MIGRATION_STORAGE_KEY_PREFIXES: ReadonlyArray<string> = [DOMAIN_MIGRATION_MARKER_KEY];
export const DOMAIN_MIGRATION_PAYLOAD_VERSION = 1;
export const DOMAIN_MIGRATION_DEFAULT_NEXT_PATH = '/channels/@me';
@@ -31,6 +36,21 @@ export const DOMAIN_MIGRATION_PENDING_MAX_AGE_MS = 10 * 60 * 1000;
export const DOMAIN_MIGRATION_CUSTOM_SOUNDS_MAX_BYTES = 4 * 1024 * 1024;
export const DOMAIN_MIGRATION_THEME_ASSETS_MAX_BYTES = 2 * 1024 * 1024;
export const DOMAIN_MIGRATION_MOVED_DISMISS_MS = 7 * 24 * 60 * 60 * 1000;
export const DOMAIN_MIGRATION_PROBE_INTERVAL_MS = 6 * 60 * 60 * 1000;
export const DOMAIN_MIGRATION_IMPORT_STALE_MS = 2 * 60 * 1000;
export const DOMAIN_MIGRATION_DEVICES_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const ONE_SHOT_ROUTE_PREFIXES: ReadonlyArray<string> = [
'/reset',
'/verify',
'/authorize-ip',
'/wasntme',
'/oauth2/authorize',
'/auth/sso/callback',
'/premium-callback',
'/age-verification-callback',
'/connection-callback',
];
const NEXT_PATH_BASE = 'https://next.invalid';
@@ -92,12 +112,20 @@ export function sanitizeNextPath(value: unknown): string {
return `${pathname}${url.search}${url.hash}`;
}
export function isDomainMigrationStorageKey(key: string): boolean {
return key.startsWith(DOMAIN_MIGRATION_MARKER_KEY);
}
export function isDomainMigrationOneShotRoute(pathname: string): boolean {
return ONE_SHOT_ROUTE_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}
export function buildTargetUrl(target: string, pathname: string, search: string, hash: string): string {
return `${target}${sanitizeNextPath(`${pathname}${search}${hash}`)}`;
}
export type DomainMigrationMarker =
| {state: 'completed'; target: string; at: number}
| {state: 'completed' | 'handed-off'; target: string; at: number; attempts: number}
| {state: 'failed'; at: number; attempts: number};
export function parseDomainMigrationMarker(raw: string | null): DomainMigrationMarker | null {
@@ -109,8 +137,9 @@ export function parseDomainMigrationMarker(raw: string | null): DomainMigrationM
if (typeof value !== 'object' || value === null || typeof value.at !== 'number') {
return null;
}
if (value.state === 'completed' && typeof value.target === 'string') {
return {state: 'completed', target: value.target, at: value.at};
if ((value.state === 'completed' || value.state === 'handed-off') && typeof value.target === 'string') {
const attempts = typeof value.attempts === 'number' && value.attempts > 0 ? value.attempts : 0;
return {state: value.state, target: value.target, at: value.at, attempts};
}
if (value.state === 'failed') {
const attempts = typeof value.attempts === 'number' && value.attempts > 0 ? value.attempts : 1;
@@ -137,12 +166,26 @@ function writeDomainMigrationMarker(storage: StorageLike | null, marker: DomainM
}
export function markDomainMigrationCompleted(storage: StorageLike | null, target: string, now: number): void {
writeDomainMigrationMarker(storage, {state: 'completed', target, at: now});
const attempts = readDomainMigrationMarker(storage)?.attempts ?? 0;
writeDomainMigrationMarker(storage, {state: 'completed', target, at: now, attempts});
}
export function markDomainMigrationHandedOff(
storage: StorageLike | null,
target: string,
now: number,
attempts: number,
): void {
writeDomainMigrationMarker(storage, {state: 'handed-off', target, at: now, attempts});
}
export function isCompletedDomainMigrationMarker(marker: DomainMigrationMarker | null): boolean {
return marker !== null && marker.state !== 'failed';
}
export function markDomainMigrationFailed(storage: StorageLike | null, now: number): void {
const previous = readDomainMigrationMarker(storage);
const attempts = previous?.state === 'failed' ? previous.attempts + 1 : 1;
const attempts = previous?.state === 'failed' ? previous.attempts + 1 : Math.max(previous?.attempts ?? 0, 1);
writeDomainMigrationMarker(storage, {state: 'failed', at: now, attempts});
}
@@ -150,7 +193,7 @@ export function markerAllowsDomainMigration(marker: DomainMigrationMarker | null
if (marker === null) {
return true;
}
if (marker.state === 'completed') {
if (marker.state !== 'failed') {
return false;
}
return (
@@ -158,6 +201,150 @@ export function markerAllowsDomainMigration(marker: DomainMigrationMarker | null
);
}
function readTimestampRecord(storage: StorageLike | null, key: string): number | null {
try {
const value = JSON.parse(storage?.getItem(key) ?? 'null') as unknown;
return isRecord(value) && typeof value.at === 'number' ? value.at : null;
} catch {
return null;
}
}
function writeTimestampRecord(storage: StorageLike | null, key: string, now: number): void {
try {
storage?.setItem(key, JSON.stringify({at: now}));
} catch {}
}
export function readDomainMigrationEnrollment(storage: StorageLike | null): boolean {
return readTimestampRecord(storage, DOMAIN_MIGRATION_ENROLLED_KEY) !== null;
}
export function markDomainMigrationEnrolled(storage: StorageLike | null, now: number): void {
if (!readDomainMigrationEnrollment(storage)) {
writeTimestampRecord(storage, DOMAIN_MIGRATION_ENROLLED_KEY, now);
}
}
export function domainMigrationProbeIsDue(storage: StorageLike | null, now: number): boolean {
const probedAt = readTimestampRecord(storage, DOMAIN_MIGRATION_PROBED_AT_KEY);
return probedAt === null || now - probedAt >= DOMAIN_MIGRATION_PROBE_INTERVAL_MS || probedAt > now;
}
export function markDomainMigrationProbed(storage: StorageLike | null, now: number): void {
writeTimestampRecord(storage, DOMAIN_MIGRATION_PROBED_AT_KEY, now);
}
export interface DomainMigrationImportRecord {
state: 'running' | 'done';
at: number;
}
export function readDomainMigrationImport(storage: StorageLike | null): DomainMigrationImportRecord | null {
try {
const value = JSON.parse(storage?.getItem(DOMAIN_MIGRATION_IMPORT_KEY) ?? 'null') as unknown;
if (!isRecord(value) || typeof value.at !== 'number' || (value.state !== 'running' && value.state !== 'done')) {
return null;
}
return {state: value.state, at: value.at};
} catch {
return null;
}
}
export function writeDomainMigrationImport(
storage: StorageLike | null,
record: DomainMigrationImportRecord | null,
): void {
try {
if (record === null) {
storage?.removeItem(DOMAIN_MIGRATION_IMPORT_KEY);
} else {
storage?.setItem(DOMAIN_MIGRATION_IMPORT_KEY, JSON.stringify(record));
}
} catch {}
}
export function importIsRunning(record: DomainMigrationImportRecord | null, now: number): boolean {
return record?.state === 'running' && now - record.at < DOMAIN_MIGRATION_IMPORT_STALE_MS;
}
export type DomainMigrationMediaDeviceKind = 'audioinput' | 'audiooutput' | 'videoinput';
export interface DomainMigrationMediaDevice {
kind: DomainMigrationMediaDeviceKind;
device_id: string;
label: string;
}
export interface DomainMigrationDeviceMap {
at: number;
devices: Array<DomainMigrationMediaDevice>;
resolved: Array<DomainMigrationMediaDeviceKind>;
}
function isMediaDeviceKind(value: unknown): value is DomainMigrationMediaDeviceKind {
return value === 'audioinput' || value === 'audiooutput' || value === 'videoinput';
}
function isMediaDevice(value: unknown): value is DomainMigrationMediaDevice {
return (
isRecord(value) &&
isMediaDeviceKind(value.kind) &&
typeof value.device_id === 'string' &&
value.device_id.length > 0 &&
typeof value.label === 'string' &&
value.label.length > 0
);
}
export function readDomainMigrationDeviceMap(
storage: StorageLike | null,
now: number,
): DomainMigrationDeviceMap | null {
let raw: string | null;
try {
raw = storage?.getItem(DOMAIN_MIGRATION_DEVICES_KEY) ?? null;
} catch {
return null;
}
if (raw === null) {
return null;
}
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
value = null;
}
if (
!isRecord(value) ||
typeof value.at !== 'number' ||
now - value.at > DOMAIN_MIGRATION_DEVICES_MAX_AGE_MS ||
value.at > now ||
!Array.isArray(value.devices) ||
!Array.isArray(value.resolved)
) {
writeDomainMigrationDeviceMap(storage, null);
return null;
}
return {
at: value.at,
devices: value.devices.filter(isMediaDevice),
resolved: value.resolved.filter(isMediaDeviceKind),
};
}
export function writeDomainMigrationDeviceMap(storage: StorageLike | null, map: DomainMigrationDeviceMap | null): void {
try {
if (map === null) {
storage?.removeItem(DOMAIN_MIGRATION_DEVICES_KEY);
} else {
storage?.setItem(DOMAIN_MIGRATION_DEVICES_KEY, JSON.stringify(map));
}
} catch {}
}
export interface DomainMigrationIntent {
at: number;
handoff_id?: string;
@@ -310,7 +497,11 @@ export function shouldForwardCompletedSource(
marker: DomainMigrationMarker | null,
environment: DomainMigrationEnvironment,
): boolean {
return discovery?.enabled === true && marker?.state === 'completed' && environmentMayForward(environment, discovery);
return (
discovery?.enabled === true &&
isCompletedDomainMigrationMarker(marker) &&
environmentMayForward(environment, discovery)
);
}
export interface DomainMovedNoticeInput {
@@ -330,7 +521,7 @@ export function shouldShowDomainMovedNotice(input: DomainMovedNoticeInput): bool
if (input.dismissedAt !== null && input.now - input.dismissedAt < DOMAIN_MIGRATION_MOVED_DISMISS_MS) {
return false;
}
const completed = input.marker?.state === 'completed';
const completed = isCompletedDomainMigrationMarker(input.marker);
if (input.installKind === 'chromium-desktop') {
return completed;
}
@@ -359,9 +550,7 @@ export function deviceIsInAnonymousRollout(discovery: DomainMigrationDiscoveryRe
export function isExportableLocalStorageKey(key: string): boolean {
return (
!DENIED_LOCAL_STORAGE_KEYS.has(key) &&
!key.startsWith(DOMAIN_MIGRATION_MARKER_KEY) &&
!PUSH_SUBSCRIPTION_KEY_PATTERN.test(key)
!DENIED_LOCAL_STORAGE_KEYS.has(key) && !isDomainMigrationStorageKey(key) && !PUSH_SUBSCRIPTION_KEY_PATTERN.test(key)
);
}
@@ -416,6 +605,7 @@ export interface DomainMigrationPayload {
accounts: Array<StoredAccount>;
custom_sounds?: Array<DomainMigrationCustomSound>;
theme_library?: DomainMigrationThemeLibrary;
media_devices?: Array<DomainMigrationMediaDevice>;
notification_permission: string;
}
@@ -508,16 +698,31 @@ export function parseDomainMigrationPayload(value: unknown, expectedSource: stri
if (value.theme_library !== undefined && !isThemeLibrary(value.theme_library)) {
return null;
}
return value as unknown as DomainMigrationPayload;
const mediaDevices = Array.isArray(value.media_devices) ? value.media_devices.filter(isMediaDevice) : [];
return {
...(value as unknown as DomainMigrationPayload),
media_devices: mediaDevices.length > 0 ? mediaDevices : undefined,
};
}
function withoutRuntimeConfig(snapshot: Record<string, string> | undefined): Record<string, string> {
const {runtimeConfig: _runtimeConfig, ...rest} = snapshot ?? {};
return rest;
function exportableSnapshot(snapshot: Record<string, string> | undefined): Record<string, string> {
return Object.fromEntries(Object.entries(snapshot ?? {}).filter(([key]) => isExportableLocalStorageKey(key)));
}
export function keepValidTargetSession(incoming: StoredAccount, existing: StoredAccount | undefined): StoredAccount {
if (incoming.isValid !== false || !existing?.token || existing.isValid === false) {
return incoming;
}
return {
...incoming,
token: existing.token,
isValid: existing.isValid,
userData: existing.userData ?? incoming.userData,
};
}
export function rewriteImportedAccount(record: StoredAccount, instance: RuntimeConfigSnapshot): StoredAccount {
const managed = withoutRuntimeConfig(record.managedStorageData ?? record.localStorageData);
const managed = exportableSnapshot(record.managedStorageData ?? record.localStorageData);
return {
...record,
localStorageData: managed,
@@ -0,0 +1,178 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type DomainMigrationDeviceMap,
type DomainMigrationMediaDevice,
type DomainMigrationMediaDeviceKind,
readDomainMigrationDeviceMap,
writeDomainMigrationDeviceMap,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {awaitHydration} from '@app/features/platform/utils/MobXPersistence';
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import {
getVoiceAudioDeviceMetadata,
normalizeDeviceMatchLabel,
type VoiceDeviceState,
} from '@app/features/voice/utils/VoiceDeviceManager';
const remappedDeviceIds = new Map<DomainMigrationMediaDeviceKind, Map<string, string>>();
let installed = false;
let pendingDeviceMap: DomainMigrationDeviceMap | null = null;
function devicesOfKind(state: VoiceDeviceState, kind: DomainMigrationMediaDeviceKind): Array<MediaDeviceInfo> {
const devices =
kind === 'audioinput' ? state.inputDevices : kind === 'audiooutput' ? state.outputDevices : state.videoDevices;
return devices.filter(
(device) =>
device.deviceId.trim().length > 0 &&
device.deviceId !== 'default' &&
device.deviceId !== 'communications' &&
getVoiceAudioDeviceMetadata(device) === null,
);
}
function hasLabelledDevices(state: VoiceDeviceState, kind: DomainMigrationMediaDeviceKind): boolean {
const devices = devicesOfKind(state, kind);
const permission = kind === 'videoinput' ? state.permissionStatus.video : state.permissionStatus.audio;
return devices.some((device) => device.label.trim().length > 0) || (permission === 'granted' && devices.length > 0);
}
function groupByLabel<T>(
entries: ReadonlyArray<T>,
labelOf: (entry: T) => string,
idOf: (entry: T) => string,
): Map<string, Array<string>> {
const groups = new Map<string, Array<string>>();
for (const entry of entries) {
const label = labelOf(entry);
if (label.length === 0) {
continue;
}
groups.set(label, [...(groups.get(label) ?? []), idOf(entry)]);
}
return groups;
}
function mapDevicesOfKind(
kind: DomainMigrationMediaDeviceKind,
sourceDevices: ReadonlyArray<DomainMigrationMediaDevice>,
targetDevices: ReadonlyArray<MediaDeviceInfo>,
): Map<string, string> {
const sourceByLabel = groupByLabel(
sourceDevices.filter((device) => device.kind === kind),
(device) => normalizeDeviceMatchLabel(kind, device.device_id, device.label),
(device) => device.device_id,
);
const targetByLabel = groupByLabel(
targetDevices,
(device) => normalizeDeviceMatchLabel(kind, device.deviceId, device.label),
(device) => device.deviceId,
);
const mapping = new Map<string, string>();
for (const [label, sourceIds] of sourceByLabel) {
const targetIds = targetByLabel.get(label);
if (sourceIds.length === 1 && targetIds?.length === 1 && sourceIds[0] !== targetIds[0]) {
mapping.set(sourceIds[0], targetIds[0]);
}
}
return mapping;
}
export function remapMigratedDeviceId(kind: DomainMigrationMediaDeviceKind, deviceId: string): string {
return remappedDeviceIds.get(kind)?.get(deviceId) ?? deviceId;
}
export function remapMigratedDeviceIds(
deviceIds: Array<string>,
kinds: ReadonlyArray<DomainMigrationMediaDeviceKind>,
): Array<string> {
let changed = false;
const remapped = deviceIds.flatMap((deviceId) => {
const targets = [...new Set(kinds.map((kind) => remapMigratedDeviceId(kind, deviceId)))].filter(
(target) => target !== deviceId,
);
if (targets.length === 0) {
return [deviceId];
}
changed = true;
return targets;
});
return changed ? [...new Set(remapped)] : deviceIds;
}
export function isPendingMigratedDeviceId(deviceId: string): boolean {
const map = pendingDeviceMap;
return map?.devices.some((device) => device.device_id === deviceId && !map.resolved.includes(device.kind)) ?? false;
}
function applyToVoiceSettings(): void {
const current = {
inputDeviceId: VoiceSettings.getInputDeviceId(),
outputDeviceId: VoiceSettings.getOutputDeviceId(),
videoDeviceId: VoiceSettings.getVideoDeviceId(),
screenShareAudioDeviceId: VoiceSettings.getScreenShareAudioDeviceId(),
};
const next = {
inputDeviceId: remapMigratedDeviceId('audioinput', current.inputDeviceId),
outputDeviceId: remapMigratedDeviceId('audiooutput', current.outputDeviceId),
videoDeviceId: remapMigratedDeviceId('videoinput', current.videoDeviceId),
screenShareAudioDeviceId: remapMigratedDeviceId('audioinput', current.screenShareAudioDeviceId),
};
if (
next.inputDeviceId !== current.inputDeviceId ||
next.outputDeviceId !== current.outputDeviceId ||
next.videoDeviceId !== current.videoDeviceId ||
next.screenShareAudioDeviceId !== current.screenShareAudioDeviceId
) {
VoiceSettings.updateSettings(next);
}
}
function reconcileMigratedDevices(state: VoiceDeviceState): void {
const map = pendingDeviceMap;
if (map === null) {
return;
}
const pendingKinds = [...new Set(map.devices.map((device) => device.kind))].filter(
(kind) => !map.resolved.includes(kind),
);
const resolvableKinds = pendingKinds.filter((kind) => hasLabelledDevices(state, kind));
if (pendingKinds.length > 0 && resolvableKinds.length === 0) {
return;
}
for (const kind of resolvableKinds) {
const mapping = mapDevicesOfKind(kind, map.devices, devicesOfKind(state, kind));
remappedDeviceIds.set(kind, new Map([...(remappedDeviceIds.get(kind) ?? []), ...mapping]));
}
applyToVoiceSettings();
const resolved = [...map.resolved, ...resolvableKinds];
const allResolved = pendingKinds.every((kind) => resolved.includes(kind));
pendingDeviceMap = allResolved ? null : {...map, resolved};
const storage = getProtectedLocalStorage();
const stored = readDomainMigrationDeviceMap(storage, Date.now());
if (stored === null) {
return;
}
const storedResolved = [...new Set([...stored.resolved, ...resolved])];
const storedKinds = new Set(stored.devices.map((device) => device.kind));
writeDomainMigrationDeviceMap(
storage,
[...storedKinds].every((kind) => storedResolved.includes(kind)) ? null : {...stored, resolved: storedResolved},
);
}
export async function installMigratedDeviceRemap(): Promise<void> {
if (installed) {
return;
}
const map = readDomainMigrationDeviceMap(getProtectedLocalStorage(), Date.now());
if (map === null) {
return;
}
installed = true;
pendingDeviceMap = {...map, resolved: []};
await awaitHydration('VoiceSettings');
VoiceDevicePermissionState.addDeviceStateReconciler(reconcileMigratedDevices);
}
@@ -1,10 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
desktopPasskeysSupported,
hasStoredAccount,
readActiveSessionToken,
readDomainMigrationDiscovery,
readDomainMigrationEnvironment,
readDomainMigrationGateInput,
startDomainMigrationFromSource,
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
import {
anonymousRolloutIsOpen,
@@ -15,24 +18,33 @@ import {
DOMAIN_MIGRATION_DEFAULT_NEXT_PATH,
DOMAIN_MIGRATION_DEVICE_KEY,
DOMAIN_MIGRATION_MARKER_KEY,
DOMAIN_MIGRATION_MAX_FAILED_ATTEMPTS,
DOMAIN_MIGRATION_NOTIFICATIONS_KEY,
DOMAIN_MIGRATION_PAYLOAD_VERSION,
DOMAIN_MIGRATION_PENDING_KEY,
DOMAIN_MIGRATION_PENDING_MAX_AGE_MS,
DOMAIN_MIGRATION_THEME_ASSETS_MAX_BYTES,
type DomainMigrationCustomSound,
type DomainMigrationMediaDevice,
type DomainMigrationPayload,
type DomainMigrationSide,
type DomainMigrationThemeLibrary,
deviceIsInAnonymousRollout,
environmentAllowsDomainMigration,
environmentMayForward,
importIsRunning,
intentConfirmsCompletion,
isCompletedDomainMigrationMarker,
isDomainMigrationOneShotRoute,
isExportableLocalStorageKey,
keepValidTargetSession,
markDomainMigrationCompleted,
markDomainMigrationFailed,
markDomainMigrationHandedOff,
parseDomainMigrationMarker,
parseDomainMigrationPayload,
readDomainMigrationEnrollment,
readDomainMigrationImport,
readDomainMigrationIntent,
readDomainMigrationMarker,
resolveDomainMigrationSide,
@@ -40,7 +52,10 @@ import {
type StorageLike,
sanitizeNextPath,
shouldForwardCompletedSource,
shouldStartDomainMigration,
withoutOptionalPayloadData,
writeDomainMigrationDeviceMap,
writeDomainMigrationImport,
writeDomainMigrationIntent,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import {
@@ -52,6 +67,11 @@ import {
sha256Hex,
} from '@app/features/app/domain_migration/DomainMigrationCrypto';
import type {SoundType} from '@app/features/notification/utils/SoundUtils';
import {
AuthSessionStorageKey,
parseStoredSessionValue,
readStoredSessionUserId,
} from '@app/features/platform/state/auth_session/AuthSessionStorage';
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {Logger} from '@app/features/platform/utils/AppLogger';
import type {
@@ -67,6 +87,11 @@ const NONCE_BYTES = 32;
const DEVICE_ID_BYTES = 16;
const BASE64URL_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
const MAX_HANDOFF_PAYLOAD_LENGTH = 8 * 1024 * 1024;
const IMPORT_WAIT_MS = 20_000;
const IMPORT_POLL_MS = 250;
const MEDIA_DEVICES_WAIT_MS = 1500;
type DomainMigrationLanding = 'target' | 'source';
type DomainMigrationFailureReason =
| 'disabled'
@@ -97,6 +122,11 @@ function navigate(url: string): true {
return true;
}
function mountInPlace(next: string): false {
window.history.replaceState(null, '', next);
return false;
}
function readCurrentPath(): {pathname: string; search: string; hash: string} {
return {pathname: window.location.pathname, search: window.location.search, hash: window.location.hash};
}
@@ -155,6 +185,27 @@ async function restoreCustomSounds(sounds: ReadonlyArray<DomainMigrationCustomSo
}
}
async function collectMediaDevices(): Promise<Array<DomainMigrationMediaDevice> | undefined> {
try {
const devices = await Promise.race([
navigator.mediaDevices?.enumerateDevices?.() ?? Promise.resolve([]),
new Promise<Array<MediaDeviceInfo>>((resolve) => setTimeout(() => resolve([]), MEDIA_DEVICES_WAIT_MS)),
]);
const labelled = devices.flatMap((device): Array<DomainMigrationMediaDevice> => {
const deviceId = device.deviceId.trim();
const label = device.label.trim();
if (!deviceId || !label || deviceId === 'default' || deviceId === 'communications') {
return [];
}
return [{kind: device.kind, device_id: deviceId, label}];
});
return labelled.length > 0 ? labelled : undefined;
} catch (err) {
logger.warn('Skipping media devices in the domain migration export:', err);
return undefined;
}
}
async function collectThemeLibrary(): Promise<DomainMigrationThemeLibrary | undefined> {
try {
const db = await import('@app/features/theme/utils/ThemeLibraryDb');
@@ -279,7 +330,7 @@ function discoveryAllowsMigration(): boolean {
}
function revokeCompletedMarker(storage: StorageLike | null): void {
if (readDomainMigrationMarker(storage)?.state === 'completed') {
if (isCompletedDomainMigrationMarker(readDomainMigrationMarker(storage))) {
markDomainMigrationFailed(storage, Date.now());
}
}
@@ -296,11 +347,20 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
if (nonce === null || !BASE64URL_TOKEN_PATTERN.test(nonce)) {
throw new Error('Missing or malformed nonce');
}
const [{default: accountStorage}, {default: RuntimeConfig}] = await Promise.all([
import('@app/features/auth/state/AccountStorage'),
import('@app/features/app/state/RuntimeConfig'),
]);
const accounts = (await accountStorage.getAllAccounts()).filter((account) => Boolean(account.token));
const previous = readDomainMigrationMarker(storage);
const attempts = previous?.state === 'handed-off' ? previous.attempts + 1 : Math.max(previous?.attempts ?? 0, 1);
if (attempts > DOMAIN_MIGRATION_MAX_FAILED_ATTEMPTS) {
throw new Error('Too many domain migration attempts');
}
const [{default: accountStorage}, {default: RuntimeConfig, runtimeConfigSnapshotsAreSameInstance}] =
await Promise.all([
import('@app/features/auth/state/AccountStorage'),
import('@app/features/app/state/RuntimeConfig'),
]);
const instance = RuntimeConfig.getSnapshot();
const accounts = (await accountStorage.getAllAccounts()).filter(
(account) => Boolean(account.token) && runtimeConfigSnapshotsAreSameInstance(account.instance, instance),
);
const token = readActiveSessionToken() ?? accounts.find((account) => account.isValid !== false)?.token ?? null;
if (!token) {
throw new Error('No stored account to export');
@@ -313,6 +373,7 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
accounts,
custom_sounds: await collectCustomSounds(),
theme_library: await collectThemeLibrary(),
media_devices: await collectMediaDevices(),
notification_permission: readNotificationPermission(),
};
let sealed = await encryptDomainMigrationPayload(payload);
@@ -321,7 +382,14 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
}
const handoffId = await createHandoff(RuntimeConfig.apiEndpoint, token, await sha256Hex(nonce), sealed.payload);
writeDomainMigrationIntent(sessionStorage, {at: intent.at, handoff_id: handoffId});
return navigate(`${side.target}/migrate/complete#h=${handoffId}&k=${sealed.key}`);
markDomainMigrationHandedOff(storage, side.target, Date.now(), attempts);
const landing: DomainMigrationLanding = environmentMayForward(
readDomainMigrationEnvironment(),
readDomainMigrationDiscovery(),
)
? 'target'
: 'source';
return navigate(`${side.target}/migrate/complete#h=${handoffId}&k=${sealed.key}&land=${landing}`);
} catch (err) {
logger.warn('Domain migration export failed:', err);
clearDomainMigrationIntent(sessionStorage);
@@ -330,20 +398,45 @@ async function exportFromSource(side: DomainMigrationSide, nonce: string | null)
}
}
async function forwardCompletedSource(side: DomainMigrationSide): Promise<true> {
const {pathname, search, hash} = readCurrentPath();
if (!(await hasStoredAccount())) {
return navigate(buildTargetUrl(side.target, pathname, search, hash));
}
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
const next = sanitizeNextPath(`${pathname}${search}${hash}`);
const imported = readDomainMigrationMarker(getProtectedLocalStorage())?.state === 'completed' ? '&imported=1' : '';
return navigate(`${side.target}/migrate/begin?resume=1${imported}&next=${encodeURIComponent(next)}`);
}
async function startEnrolledSource(side: DomainMigrationSide): Promise<boolean> {
if (
!readDomainMigrationEnrollment(getProtectedLocalStorage()) ||
!shouldStartDomainMigration(readDomainMigrationGateInput(true, false))
) {
return false;
}
if (!(await hasStoredAccount()) || !(await desktopPasskeysSupported())) {
return false;
}
return startDomainMigrationFromSource(side);
}
async function forwardFromSource(side: DomainMigrationSide): Promise<boolean> {
const {pathname, search, hash} = readCurrentPath();
if (isDomainMigrationOneShotRoute(pathname)) {
return false;
}
if (await startEnrolledSource(side)) {
return true;
}
const environment = readDomainMigrationEnvironment();
const discovery = readDomainMigrationDiscovery();
if (!environmentMayForward(environment, discovery)) {
return false;
}
const {pathname, search, hash} = readCurrentPath();
if (shouldForwardCompletedSource(discovery, readDomainMigrationMarker(getProtectedLocalStorage()), environment)) {
if (!(await hasStoredAccount())) {
return navigate(buildTargetUrl(side.target, pathname, search, hash));
}
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
const next = sanitizeNextPath(`${pathname}${search}${hash}`);
return navigate(`${side.target}/migrate/begin?resume=1&next=${encodeURIComponent(next)}`);
return forwardCompletedSource(side);
}
if (
discovery !== null &&
@@ -360,6 +453,7 @@ async function forwardWhenIdle(side: DomainMigrationSide): Promise<void> {
const {default: MediaEngine} = await import('@app/features/voice/engine/MediaEngineFacade');
await when(() => !MediaEngine.connected && !MediaEngine.connecting);
if (
isDomainMigrationOneShotRoute(window.location.pathname) ||
!shouldForwardCompletedSource(
readDomainMigrationDiscovery(),
readDomainMigrationMarker(getProtectedLocalStorage()),
@@ -368,8 +462,7 @@ async function forwardWhenIdle(side: DomainMigrationSide): Promise<void> {
) {
return;
}
const {pathname, search, hash} = readCurrentPath();
navigate(buildTargetUrl(side.target, pathname, search, hash));
await forwardCompletedSource(side);
}
function installSourceMarkerListener(side: DomainMigrationSide): void {
@@ -486,18 +579,35 @@ async function importHandoff(side: DomainMigrationSide, fragment: URLSearchParam
if (payload === null) {
throw new DomainMigrationImportError('invalid_payload');
}
const storage = getProtectedLocalStorage();
const previousImport = readDomainMigrationImport(storage);
writeDomainMigrationImport(storage, {state: 'running', at: Date.now()});
try {
const [{default: accountStorage}, {default: RuntimeConfig}] = await Promise.all([
import('@app/features/auth/state/AccountStorage'),
import('@app/features/app/state/RuntimeConfig'),
]);
const instance = RuntimeConfig.getSnapshot();
await accountStorage.importAccounts(payload.accounts.map((account) => rewriteImportedAccount(account, instance)));
const activeToken = readActiveSessionToken();
const activeUserId = storage ? readStoredSessionUserId(storage) : null;
const incomingToken = parseStoredSessionValue(payload.local_storage[AuthSessionStorageKey.Token] ?? null);
const incomingUserId = parseStoredSessionValue(payload.local_storage[AuthSessionStorageKey.UserId] ?? null);
const existing = new Map((await accountStorage.getAllAccounts()).map((account) => [account.userId, account]));
await accountStorage.importAccounts(
payload.accounts.map((account) =>
keepValidTargetSession(rewriteImportedAccount(account, instance), existing.get(account.userId)),
),
);
if (activeToken !== null && activeUserId !== null && incomingToken !== null && incomingUserId !== activeUserId) {
await accountStorage.stashAccountData(activeUserId, activeToken, undefined, instance).catch((err: unknown) => {
logger.warn('Failed to keep the replaced target session:', err);
});
}
} catch (err) {
writeDomainMigrationImport(storage, previousImport);
logger.warn('Failed to import migrated accounts:', err);
throw new DomainMigrationImportError('import_failed');
}
const storage = getProtectedLocalStorage();
for (const [storageKey, value] of Object.entries(payload.local_storage)) {
if (!isExportableLocalStorageKey(storageKey)) {
continue;
@@ -508,13 +618,17 @@ async function importHandoff(side: DomainMigrationSide, fragment: URLSearchParam
logger.warn(`Failed to import localStorage key ${storageKey}:`, err);
}
}
await restoreCustomSounds(payload.custom_sounds);
await restoreThemeLibrary(payload.theme_library);
if (payload.media_devices) {
writeDomainMigrationDeviceMap(storage, {at: Date.now(), devices: payload.media_devices, resolved: []});
}
if (payload.notification_permission === 'granted') {
try {
storage?.setItem(DOMAIN_MIGRATION_NOTIFICATIONS_KEY, 'granted');
} catch {}
}
writeDomainMigrationImport(storage, {state: 'done', at: Date.now()});
await restoreCustomSounds(payload.custom_sounds);
await restoreThemeLibrary(payload.theme_library);
await persistDesktopAppOrigin();
}
@@ -531,23 +645,50 @@ function doneUrl(side: DomainMigrationSide, next: string, handoffId: string | nu
return `${side.source}/migrate/done?${handoff}next=${encodeURIComponent(next)}`;
}
function landingUrl(side: DomainMigrationSide, fragment: URLSearchParams, next: string): string {
switch (fragment.get('land')) {
case 'target':
return `${side.target}${next}`;
case 'source':
return sourceUrl(side, next);
}
return doneUrl(side, next, fragment.get('h'));
}
function failedUrl(side: DomainMigrationSide, reason: DomainMigrationFailureReason, next: string): string {
return `${side.source}/migrate/failed?reason=${reason}&next=${encodeURIComponent(next)}`;
}
async function waitForRunningImport(): Promise<void> {
const deadline = Date.now() + IMPORT_WAIT_MS;
while (importIsRunning(readDomainMigrationImport(getProtectedLocalStorage()), Date.now()) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_MS));
}
}
async function beginOnTarget(side: DomainMigrationSide, params: URLSearchParams): Promise<boolean> {
const next = sanitizeNextPath(params.get('next'));
if (readDomainMigrationDiscovery()?.enabled !== true) {
return navigate(failedUrl(side, 'disabled', next));
}
const storage = getProtectedLocalStorage();
const started = params.get('start') === '1';
if (await hasStoredAccount()) {
const resumed = params.get('resume') === '1';
if (resumed) {
await waitForRunningImport();
}
const imported = readDomainMigrationImport(storage)?.state === 'done' || (resumed && params.get('imported') === '1');
if ((resumed || started) && (imported || (started && (await hasStoredAccount())))) {
await persistDesktopAppOrigin();
return navigate(params.get('resume') === '1' || started ? `${side.target}${next}` : doneUrl(side, next, null));
return mountInPlace(next);
}
if (started) {
return navigate(`${side.source}/migrate/start?next=${encodeURIComponent(next)}`);
}
if (imported) {
return navigate(doneUrl(side, next, null));
}
writeDomainMigrationImport(storage, {state: 'running', at: Date.now()});
const nonce = randomBase64Url(NONCE_BYTES);
const pending: PendingHandoff = {nonce, next, at: Date.now()};
getProtectedSessionStorage()?.setItem(DOMAIN_MIGRATION_PENDING_KEY, JSON.stringify(pending));
@@ -559,22 +700,29 @@ async function completeOnTarget(side: DomainMigrationSide): Promise<boolean> {
window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}`);
const pending = takePendingHandoff();
if (pending === null || Date.now() - pending.at > DOMAIN_MIGRATION_PENDING_MAX_AGE_MS) {
const lastImport = readDomainMigrationImport(getProtectedLocalStorage());
if (lastImport?.state === 'done' && Date.now() - lastImport.at < DOMAIN_MIGRATION_PENDING_MAX_AGE_MS) {
return navigate(
fragment.get('land') === 'source'
? sourceUrl(side, DOMAIN_MIGRATION_DEFAULT_NEXT_PATH)
: `${side.target}${DOMAIN_MIGRATION_DEFAULT_NEXT_PATH}`,
);
}
return navigate(failedUrl(side, 'no_pending', DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
}
const next = sanitizeNextPath(pending.next);
const handoffId = fragment.get('h');
if (await hasStoredAccount()) {
await persistDesktopAppOrigin();
return navigate(doneUrl(side, next, handoffId));
}
try {
await importHandoff(side, fragment, pending.nonce);
} catch (err) {
const reason = err instanceof DomainMigrationImportError ? err.reason : 'import_failed';
logger.warn('Domain migration import failed:', err);
const storage = getProtectedLocalStorage();
if (readDomainMigrationImport(storage)?.state === 'running') {
writeDomainMigrationImport(storage, null);
}
return navigate(failedUrl(side, reason, next));
}
return navigate(doneUrl(side, next, handoffId));
return navigate(landingUrl(side, fragment, next));
}
async function handleTarget(side: DomainMigrationSide): Promise<boolean> {
@@ -1,13 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
markDomainMigrationEnrolled,
readDomainMigrationEnrollment,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import SessionManager from '@app/features/platform/state/AuthSession';
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {readDomainMigrationAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
readDomainMigrationAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {makeAutoObservable} from 'mobx';
const logger = new Logger('DomainMigrationRollout');
class DomainMigrationRolloutSelector {
get enabled(): boolean {
deviceEnrolled = readDomainMigrationEnrollment(getProtectedLocalStorage());
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
get assignmentReady(): boolean {
return (
ExperimentAssignments.response !== INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE &&
ExperimentAssignments.ownerId === SessionManager.userId
);
}
get assignmentEnabled(): boolean {
if (!this.assignmentReady) {
return false;
}
try {
return readDomainMigrationAssignment(ExperimentAssignments.response).enabled;
} catch (err) {
@@ -15,6 +41,15 @@ class DomainMigrationRolloutSelector {
return false;
}
}
get enabled(): boolean {
return this.assignmentEnabled || this.deviceEnrolled;
}
markDeviceEnrolled(): void {
markDomainMigrationEnrolled(getProtectedLocalStorage(), Date.now());
this.deviceEnrolled = true;
}
}
export const DomainMigrationRollout = new DomainMigrationRolloutSelector();
@@ -1,80 +1,158 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import {
desktopPasskeysSupported,
readDomainMigrationDiscovery,
readDomainMigrationEnvironment,
readDomainMigrationGateInput,
startDomainMigrationFromSource,
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
import {
DOMAIN_MIGRATION_IMPORT_KEY,
DOMAIN_MIGRATION_NOTIFICATIONS_KEY,
type DomainMigrationGateInput,
type DomainMigrationSide,
markDomainMigrationFailed,
readDomainMigrationMarker,
domainMigrationProbeIsDue,
environmentAllowsDomainMigration,
markDomainMigrationProbed,
markerAllowsDomainMigration,
readDomainMigrationImport,
resolveDomainMigrationSide,
shouldStartDomainMigration,
writeDomainMigrationIntent,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import DomainMigrationRollout from '@app/features/app/domain_migration/DomainMigrationRollout';
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import AccountManager from '@app/features/auth/state/AccountManager';
import {EXPERIMENT_ASSIGNMENTS_PATH} from '@app/features/experiment/state/ExperimentAssignments';
import SessionManager from '@app/features/platform/state/AuthSession';
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {when} from 'mobx';
import {
ExperimentAssignmentsResponse,
readDomainMigrationAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {compareShallow, reaction} from 'mobx';
const logger = new Logger('DomainMigrationTrigger');
const ONE_SHOT_ROUTE_PREFIXES: ReadonlyArray<string> = [
Routes.RESET_PASSWORD,
Routes.VERIFY_EMAIL,
Routes.AUTHORIZE_IP,
Routes.EMAIL_REVERT,
Routes.OAUTH_AUTHORIZE,
Routes.SSO_CALLBACK,
Routes.PREMIUM_CALLBACK,
Routes.AGE_VERIFICATION_CALLBACK,
Routes.CONNECTION_CALLBACK,
];
let started = false;
let navigating = false;
let probing = false;
interface SourceGateState {
assignmentReady: boolean;
assignmentEnabled: boolean;
deviceEnrolled: boolean;
userId: string | null;
sessionSettled: boolean;
voiceActive: boolean;
}
function isVoiceActive(): boolean {
return MediaEngine.connected || MediaEngine.connecting;
}
function isOneShotRoute(pathname: string): boolean {
return ONE_SHOT_ROUTE_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
function isSessionSettled(): boolean {
return SessionManager.isAuthenticated && SessionManager.userId !== null && !AccountManager.transitioning;
}
function readGateInput(assignmentEnabled: boolean): DomainMigrationGateInput {
return {
environment: readDomainMigrationEnvironment(),
assignmentEnabled,
discovery: readDomainMigrationDiscovery(),
marker: readDomainMigrationMarker(getProtectedLocalStorage()),
now: Date.now(),
voiceActive: isVoiceActive(),
oneShotRoute: isOneShotRoute(window.location.pathname),
};
function sourceMayStart(): boolean {
return (
!navigating &&
isSessionSettled() &&
shouldStartDomainMigration(readDomainMigrationGateInput(DomainMigrationRollout.enabled, isVoiceActive()))
);
}
async function evaluateSource(side: DomainMigrationSide, assignmentEnabled: boolean): Promise<void> {
if (navigating || !shouldStartDomainMigration(readGateInput(assignmentEnabled))) {
async function evaluateSource(side: DomainMigrationSide): Promise<void> {
if (!sourceMayStart()) {
return;
}
const passkeysSupported = await desktopPasskeysSupported();
if (navigating || !passkeysSupported || !shouldStartDomainMigration(readGateInput(DomainMigrationRollout.enabled))) {
if (!passkeysSupported || !sourceMayStart()) {
return;
}
navigating = true;
markDomainMigrationFailed(getProtectedLocalStorage(), Date.now());
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.location.replace(`${side.target}/migrate/begin?next=${encodeURIComponent(next)}`);
startDomainMigrationFromSource(side);
}
async function accountIsEnrolled(token: string): Promise<boolean> {
try {
const response = await fetch(
`${RuntimeConfig.apiEndpoint}/v${RuntimeConfig.apiCodeVersion}${EXPERIMENT_ASSIGNMENTS_PATH}`,
{credentials: 'omit', headers: {Authorization: token}},
);
if (!response.ok) {
return false;
}
const parsed = ExperimentAssignmentsResponse.safeParse(await response.json());
return parsed.success && readDomainMigrationAssignment(parsed.data).enabled;
} catch {
return false;
}
}
async function probeStoredAccounts(): Promise<void> {
const storage = getProtectedLocalStorage();
const gate = readDomainMigrationGateInput(false, false);
if (
probing ||
DomainMigrationRollout.deviceEnrolled ||
gate.discovery?.enabled !== true ||
!environmentAllowsDomainMigration(gate.environment) ||
!markerAllowsDomainMigration(gate.marker, gate.now) ||
!domainMigrationProbeIsDue(storage, gate.now)
) {
return;
}
const activeUserId = SessionManager.userId;
const tokens = SessionManager.accounts
.filter((account) => account.userId !== activeUserId && account.isValid !== false && Boolean(account.token))
.map((account) => account.token);
if (tokens.length === 0) {
return;
}
probing = true;
markDomainMigrationProbed(storage, gate.now);
try {
for (const token of tokens) {
if (await accountIsEnrolled(token)) {
DomainMigrationRollout.markDeviceEnrolled();
return;
}
}
} finally {
probing = false;
}
}
function readSourceGateState(): SourceGateState {
return {
assignmentReady: DomainMigrationRollout.assignmentReady,
assignmentEnabled: DomainMigrationRollout.assignmentEnabled,
deviceEnrolled: DomainMigrationRollout.deviceEnrolled,
userId: SessionManager.userId,
sessionSettled: isSessionSettled(),
voiceActive: isVoiceActive(),
};
}
function handleSourceGateState(side: DomainMigrationSide, state: SourceGateState): void {
if (state.assignmentEnabled && !state.deviceEnrolled) {
DomainMigrationRollout.markDeviceEnrolled();
return;
}
if (navigating || !state.sessionSettled || (!state.assignmentReady && !state.deviceEnrolled)) {
return;
}
if (!state.deviceEnrolled) {
probeStoredAccounts().catch((err) => {
logger.warn('Domain migration enrollment probe failed:', err);
});
return;
}
evaluateSource(side).catch((err) => {
logger.warn('Domain migration trigger failed:', err);
});
}
function offerNotificationReenable(): void {
@@ -92,6 +170,27 @@ function offerNotificationReenable(): void {
}
}
function reloadAfterSiblingImport(event: StorageEvent): void {
if (
event.key === DOMAIN_MIGRATION_IMPORT_KEY &&
readDomainMigrationImport(getProtectedLocalStorage())?.state === 'done'
) {
window.location.reload();
}
}
export function probeSignedOutDeviceEnrollment(): void {
if (typeof window === 'undefined' || SessionManager.isAuthenticated) {
return;
}
if (resolveDomainMigrationSide(window.location.origin)?.role !== 'source') {
return;
}
probeStoredAccounts().catch((err) => {
logger.warn('Domain migration enrollment probe failed:', err);
});
}
export function startDomainMigrationTrigger(): void {
if (started || typeof window === 'undefined') {
return;
@@ -103,14 +202,11 @@ export function startDomainMigrationTrigger(): void {
started = true;
if (side.role === 'target') {
setTimeout(offerNotificationReenable, 0);
window.addEventListener('storage', reloadAfterSiblingImport);
return;
}
when(
() => ExperimentAssignments.response !== INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
() => {
evaluateSource(side, DomainMigrationRollout.enabled).catch((err) => {
logger.warn('Domain migration trigger failed:', err);
});
},
);
reaction(readSourceGateState, (state) => handleSourceGateState(side, state), {
fireImmediately: true,
equals: compareShallow,
});
}
@@ -1156,6 +1156,11 @@ class KeybindManager {
return false;
}
this.globalKeyHookStarted = true;
if (NativePermission.isLinuxWaylandDesktop) {
void NativePermission.recheckLinuxInputAccess().then((status) => {
if (status === 'blocked') NativePermission.requestLinuxInputAccessNagbar(reason);
});
}
const keyEventUnsub = electronApi.onGlobalKeyEvent?.((event) => {
this.handleGlobalKeyEvent(
event as {
@@ -100,6 +100,8 @@ export function runtimeConfigSnapshotsAreSameInstance(
const DEFAULT_INSTANCE_FEATURES: InstanceFeatures = {
voice_enabled: false,
stripe_enabled: false,
premium_enabled: false,
stripe_serviceable: false,
self_hosted: false,
presigned_attachment_uploads: false,
emails_enabled: false,
@@ -147,6 +149,8 @@ export const DEFAULT_APP_PUBLIC_CONFIG: InstanceAppPublic = {
theme_color: null,
status_page_url: null,
status_page_incident_history_url: null,
premium_product_name: 'Plutonium',
premium_info_url: null,
},
setup: {
configured: false,
@@ -398,6 +402,9 @@ class RuntimeConfig {
this.features = {
...this.features,
self_hosted: config.self_hosted,
premium_enabled: !config.self_hosted || config.policy.premium_mode === 'mirror',
stripe_enabled: config.billing.billing_active,
stripe_serviceable: config.billing.stripe_serviceable,
};
this.registration = normalizeInstanceRegistration(config.registration);
this.community = normalizeInstanceCommunity({
@@ -495,6 +502,28 @@ class RuntimeConfig {
return DeveloperOptions.selfHostedModeOverride || this.features.self_hosted;
}
get premiumEnabled(): boolean {
return this.features.premium_enabled;
}
get stripeEnabled(): boolean {
return this.features.stripe_enabled;
}
get stripeServiceable(): boolean {
return this.features.stripe_serviceable;
}
get premiumProductName(): string {
return (
this.appPublic.branding.premium_product_name?.trim() || DEFAULT_APP_PUBLIC_CONFIG.branding.premium_product_name
);
}
get premiumInfoUrl(): string | null {
return this.appPublic.branding.premium_info_url ?? null;
}
get emailsEnabled(): boolean {
return this.features.emails_enabled;
}
@@ -23,6 +23,7 @@
min-width: 0;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
text-align: start;
background-color: var(--background-secondary);
border: 0.0625rem solid var(--background-modifier-accent);
}
@@ -71,7 +71,7 @@ export const AccountRow = observer(
const variantClassName = variant === 'manage' ? styles.manage : variant === 'compact' ? styles.compact : undefined;
const isClickable = typeof onClick === 'function';
const MainButtonComponent = isClickable ? 'button' : 'div';
const showMenuButton = Boolean(onMenuClick && variant !== 'compact' && !showCaretIndicator);
const showMenuButton = Boolean(onMenuClick && variant !== 'compact');
return (
<div
className={clsx(styles.row, variantClassName, showMenuButton && styles.withMenu, className)}
@@ -32,10 +32,6 @@
text-align: center;
}
.accountListWrapper {
min-height: 7.5rem;
}
.scroller {
max-height: 17.5rem;
}

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