mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c488906131 | ||
|
|
39c72f0fb0 | ||
|
|
3736d94d73 | ||
|
|
192cec689a | ||
|
|
e895c41bf0 | ||
|
|
997d98c65c | ||
|
|
c9ae5b6ee8 | ||
|
|
a728be4062 | ||
|
|
fce81367fb | ||
|
|
5a4edc0b59 | ||
|
|
713ae5f7f5 | ||
|
|
eaee820216 | ||
|
|
564c5ae164 | ||
|
|
dd8ed6f205 | ||
|
|
ed8c412415 | ||
|
|
12417a6942 | ||
|
|
d05f6c9aaa | ||
|
|
0ca035c547 | ||
|
|
dfd46ccc2c | ||
|
|
f6df3169ca | ||
|
|
5b280898c5 | ||
|
|
2a9e25c788 | ||
|
|
463c03fb6d | ||
|
|
153dad11e1 | ||
|
|
e2d05a44a8 | ||
|
|
30ba55bd4d | ||
|
|
9def9fbef6 | ||
|
|
fa3fd0027c | ||
|
|
7e1b934637 | ||
|
|
33a118d12a | ||
|
|
01f53a168d | ||
|
|
336b8b7dcd | ||
|
|
48d0034239 | ||
|
|
677ef8491e | ||
|
|
6a6119ed1e | ||
|
|
931327d1dc | ||
|
|
858a2d9e2b | ||
|
|
841fb7af41 | ||
|
|
08e65d41c0 | ||
|
|
f76c4dc041 | ||
|
|
f1f8ba2031 | ||
|
|
5ab8d745c0 | ||
|
|
ff62bc89a4 | ||
|
|
838bbdb5ec | ||
|
|
1c36a59b2c | ||
|
|
6730a242db | ||
|
|
e62ae77643 | ||
|
|
f4f39e6a89 |
@@ -336,6 +336,9 @@ jobs:
|
||||
restore-keys: |
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.27.0-
|
||||
|
||||
- name: Drop restored gateway build output
|
||||
run: rm -rf fluxer_gateway/_build/default/lib/fluxer_gateway fluxer_gateway/_build/test/lib/fluxer_gateway
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
"$FLUXER_CI_BIN" ci --step gateway_fmt
|
||||
|
||||
@@ -33,9 +33,9 @@ Fluxer is a free and open source instant messaging and VoIP chat app built for f
|
||||
|
||||
| Windows | macOS | Linux | Android | iOS |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [APK][android-apk] | [TestFlight][ios-testflight] |
|
||||
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [Obtainium][obtainium] | |
|
||||
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | | |
|
||||
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [Google Play (beta)][android-play] | [TestFlight][ios-testflight] |
|
||||
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [APK (beta)][android-apk] | |
|
||||
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | [Obtainium (beta)][obtainium] | |
|
||||
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
|
||||
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
|
||||
| | | [AppImage (x64)][linux-appimage-x64] | | |
|
||||
@@ -168,6 +168,7 @@ endorsement rights.
|
||||
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||
[flatpak-canary-ref]: https://pkgs.fluxer.com/flatpak/fluxer-canary.flatpakref
|
||||
[flathub]: https://flathub.org/apps/app.fluxer.Fluxer
|
||||
[android-play]: https://play.google.com/store/apps/details?id=com.fluxer
|
||||
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
|
||||
[obtainium]: https://obtainium.imranr.dev/
|
||||
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
|
||||
|
||||
+292
-78
@@ -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,8 +10523,10 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
|
||||
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
|
||||
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
|
||||
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
@@ -10636,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",
|
||||
@@ -10647,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
|
||||
},
|
||||
@@ -10946,20 +10952,24 @@
|
||||
},
|
||||
"required": ["attachment_decay"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"billing": {"$ref": "#/components/schemas/InstanceBillingResponse"}
|
||||
},
|
||||
"required": [
|
||||
"sso",
|
||||
"gateway_rollout",
|
||||
"voice_noise_suppression",
|
||||
"push_service_delivery",
|
||||
"push_relay",
|
||||
"domain_migration",
|
||||
"altcha_captcha",
|
||||
"profile_timezone",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
"app_public",
|
||||
"policy",
|
||||
"integrations",
|
||||
"media"
|
||||
"media",
|
||||
"billing"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -11083,13 +11093,18 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/GatewayRolloutConfigUpdateRequest"}]
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"push_relay": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PushRelayConfigUpdateRequest"}]},
|
||||
"domain_migration": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
|
||||
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
|
||||
},
|
||||
"push_service_delivery": {
|
||||
"altcha_captcha": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
|
||||
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
|
||||
},
|
||||
"profile_timezone": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ProfileTimezoneConfigUpdateRequest"}]
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
@@ -11138,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"}}},
|
||||
@@ -11306,7 +11323,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"billing": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/InstanceBillingUpdateRequest"}]}
|
||||
}
|
||||
},
|
||||
"ListGuildStickersResponse": {
|
||||
@@ -15165,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",
|
||||
@@ -15184,7 +15224,7 @@
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
|
||||
}
|
||||
},
|
||||
"PushServiceDeliveryConfigUpdateRequest": {
|
||||
"ProfileTimezoneConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
@@ -15195,6 +15235,12 @@
|
||||
"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",
|
||||
@@ -15202,44 +15248,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigUpdateRequest": {
|
||||
"AltchaCaptchaConfigUpdateRequest": {
|
||||
"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},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"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}
|
||||
"anonymous_enabled": {"type": "boolean"},
|
||||
"cost": {"type": "integer", "minimum": 1000, "maximum": 100000},
|
||||
"max_counter": {"type": "integer", "minimum": 100, "maximum": 1000000}
|
||||
}
|
||||
},
|
||||
"DomainMigrationConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"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}$"}
|
||||
},
|
||||
"anonymous_rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"standalone_forwarding": {"type": "boolean"}
|
||||
}
|
||||
},
|
||||
"PushRelayConfigUpdateRequest": {"type": "object", "properties": {"relay_consent_accepted": {"type": "boolean"}}},
|
||||
"GatewayRolloutConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15254,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",
|
||||
@@ -15267,14 +15404,14 @@
|
||||
"required": ["poll_interval_seconds", "poll_jitter_percent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PushServiceDeliveryConfigResponse": {
|
||||
"ProfileTimezoneConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {
|
||||
"default": "push-service-delivery-v1",
|
||||
"default": "profile-timezone-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
@@ -15286,6 +15423,13 @@
|
||||
"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,
|
||||
@@ -15299,71 +15443,128 @@
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigResponse": {
|
||||
"AltchaCaptchaConfigResponse": {
|
||||
"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},
|
||||
"rollout_salt": {
|
||||
"default": "altcha-captcha-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
},
|
||||
"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}
|
||||
"anonymous_enabled": {"default": false, "type": "boolean"},
|
||||
"cost": {"default": 5000, "type": "integer", "minimum": 1000, "maximum": 100000},
|
||||
"max_counter": {"default": 10000, "type": "integer", "minimum": 100, "maximum": 1000000}
|
||||
},
|
||||
"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"
|
||||
"anonymous_enabled",
|
||||
"cost",
|
||||
"max_counter"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {
|
||||
"default": "domain-migration-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
},
|
||||
"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}$"}
|
||||
},
|
||||
"anonymous_rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"standalone_forwarding": {"default": false, "type": "boolean"}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids",
|
||||
"anonymous_rollout_basis_points",
|
||||
"standalone_forwarding"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PushRelayConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"relay_consent_accepted": {"default": false, "type": "boolean"},
|
||||
"relay_consent_accepted_at": {
|
||||
"default": null,
|
||||
"nullable": true,
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
},
|
||||
"relay_consent_accepted_by": {"default": null, "nullable": true, "type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"required": ["relay_consent_accepted", "relay_consent_accepted_at", "relay_consent_accepted_by"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"GatewayRolloutConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15394,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": {
|
||||
@@ -15645,9 +15858,10 @@
|
||||
"id": {"type": "string", "description": "The credential ID"},
|
||||
"name": {"type": "string", "description": "User-assigned name for the credential"},
|
||||
"created_at": {"type": "string", "description": "When the credential was registered"},
|
||||
"last_used_at": {"nullable": true, "description": "When the credential was last used", "type": "string"}
|
||||
"last_used_at": {"nullable": true, "description": "When the credential was last used", "type": "string"},
|
||||
"rp_id": {"type": "string", "description": "Relying party ID the passkey belongs to"}
|
||||
},
|
||||
"required": ["id", "name", "created_at", "last_used_at"],
|
||||
"required": ["id", "name", "created_at", "last_used_at", "rp_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceServerAdminResponse": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,11 +21,17 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub media: InstanceMediaResponse,
|
||||
#[serde(default)]
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
pub push_relay: PushRelayConfigResponse,
|
||||
#[serde(default)]
|
||||
pub push_service_delivery: PushServiceDeliveryConfigResponse,
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub altcha_captcha: AltchaCaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub profile_timezone: ProfileTimezoneConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub billing: InstanceBillingResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -330,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 {
|
||||
@@ -344,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,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)]
|
||||
@@ -449,123 +464,116 @@ impl VoiceE2eeScope {
|
||||
}
|
||||
|
||||
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1";
|
||||
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
|
||||
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";
|
||||
|
||||
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, Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct PushRelayConfigResponse {
|
||||
pub relay_consent_accepted: bool,
|
||||
pub relay_consent_accepted_at: Option<String>,
|
||||
pub relay_consent_accepted_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionGuildOverride {
|
||||
pub guild_id: String,
|
||||
pub backend: NoiseSuppressionBackend,
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct PushRelayConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub relay_consent_accepted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct VoiceNoiseSuppressionConfigResponse {
|
||||
pub struct DomainMigrationConfigResponse {
|
||||
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,
|
||||
pub anonymous_rollout_basis_points: u32,
|
||||
pub standalone_forwarding: bool,
|
||||
}
|
||||
|
||||
impl Default for VoiceNoiseSuppressionConfigResponse {
|
||||
impl Default for DomainMigrationConfigResponse {
|
||||
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(),
|
||||
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.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,
|
||||
anonymous_rollout_basis_points: 0,
|
||||
standalone_forwarding: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
pub struct DomainMigrationConfigUpdateRequest {
|
||||
#[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>>,
|
||||
pub anonymous_rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppression_strength: Option<u32>,
|
||||
pub standalone_forwarding: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct PushServiceDeliveryConfigResponse {
|
||||
pub struct AltchaCaptchaConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
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 anonymous_enabled: bool,
|
||||
pub cost: u32,
|
||||
pub max_counter: u32,
|
||||
}
|
||||
|
||||
impl Default for PushServiceDeliveryConfigResponse {
|
||||
impl Default for AltchaCaptchaConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
|
||||
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
anonymous_enabled: false,
|
||||
cost: 5_000,
|
||||
max_counter: 10_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct PushServiceDeliveryConfigUpdateRequest {
|
||||
pub struct AltchaCaptchaConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -575,6 +583,62 @@ pub struct PushServiceDeliveryConfigUpdateRequest {
|
||||
#[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 anonymous_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_counter: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ProfileTimezoneConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
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>,
|
||||
}
|
||||
|
||||
impl Default for ProfileTimezoneConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ProfileTimezoneConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: 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>>,
|
||||
}
|
||||
|
||||
@@ -692,11 +756,17 @@ 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>,
|
||||
pub push_relay: Option<PushRelayConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
|
||||
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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)]
|
||||
@@ -905,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)]
|
||||
@@ -1009,41 +1083,51 @@ 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!({}))
|
||||
.expect("default altcha captcha config");
|
||||
let profile_timezone = serde_json::from_value::<ProfileTimezoneConfigResponse>(json!({}))
|
||||
.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 =
|
||||
serde_json::to_value(altcha_captcha).expect("serializable altcha captcha config");
|
||||
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");
|
||||
let generated_altcha_captcha: generated_types::AltchaCaptchaConfigResponse =
|
||||
serde_json::from_value(altcha_captcha.clone())
|
||||
.expect("generated altcha captcha config contract");
|
||||
let generated_profile_timezone: generated_types::ProfileTimezoneConfigResponse =
|
||||
serde_json::from_value(profile_timezone.clone())
|
||||
.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
|
||||
serde_json::to_value(generated_domain_migration)
|
||||
.expect("serializable generated domain migration config"),
|
||||
domain_migration
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_altcha_captcha)
|
||||
.expect("serializable generated altcha captcha config"),
|
||||
altcha_captcha
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_profile_timezone)
|
||||
.expect("serializable generated profile timezone config"),
|
||||
profile_timezone
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_delivery)
|
||||
@@ -1051,7 +1135,9 @@ mod tests {
|
||||
delivery
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("AltchaCaptchaConfigResponse", altcha_captcha),
|
||||
("ProfileTimezoneConfigResponse", profile_timezone),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
@@ -1064,25 +1150,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
enabled_backends: Some(Vec::new()),
|
||||
fn domain_migration_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = DomainMigrationConfigUpdateRequest {
|
||||
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>(
|
||||
serde_json::from_value::<generated_types::DomainMigrationConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
|
||||
serde_json::to_value(DomainMigrationConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,10 +4,12 @@ use crate::{
|
||||
api::{
|
||||
client::AdminApiClient,
|
||||
types::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DeferredPhoneGateUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
|
||||
AltchaCaptchaConfigUpdateRequest, AppBrandingConfigUpdateRequest,
|
||||
AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest,
|
||||
AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest,
|
||||
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
|
||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
@@ -17,10 +19,9 @@ use crate::{
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
|
||||
PremiumMode, PushServiceDeliveryConfigUpdateRequest, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
|
||||
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -193,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);
|
||||
@@ -203,11 +206,27 @@ 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) {
|
||||
"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" => {
|
||||
let update = build_push_relay_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"update_domain_migration" => match build_domain_migration_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_push_service_delivery" => match build_push_service_delivery_update(&form) {
|
||||
"update_altcha_captcha" => match build_altcha_captcha_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_profile_timezone" => match build_profile_timezone_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
@@ -338,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,
|
||||
@@ -452,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;
|
||||
@@ -493,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_push_service_delivery_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 {
|
||||
@@ -543,135 +563,135 @@ 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,
|
||||
});
|
||||
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
push_relay: Some(PushRelayConfigUpdateRequest {
|
||||
relay_consent_accepted: Some(form.bool_value("push_relay_consent_accepted")),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
fn build_voice_noise_suppression_update(
|
||||
fn build_domain_migration_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")),
|
||||
domain_migration: Some(DomainMigrationConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("domain_migration_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"voice_ns_rollout_basis_points",
|
||||
"domain_migration_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "domain_migration_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_included_user_ids").unwrap_or_default(),
|
||||
form.first("domain_migration_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("domain_migration_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
|
||||
form.first("domain_migration_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(
|
||||
anonymous_rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression strength",
|
||||
"domain_migration_anonymous_rollout_basis_points",
|
||||
"Anonymous rollout basis points",
|
||||
0,
|
||||
VOICE_NS_SUPPRESSION_STRENGTH_MAX,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
standalone_forwarding: Some(form.bool_value("domain_migration_standalone_forwarding")),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_altcha_captcha_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
altcha_captcha: Some(AltchaCaptchaConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("altcha_captcha_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
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(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("altcha_captcha_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("altcha_captcha_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("altcha_captcha_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
anonymous_enabled: Some(form.bool_value("altcha_captcha_anonymous_enabled")),
|
||||
cost: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_cost",
|
||||
"Cost",
|
||||
*ALTCHA_CAPTCHA_COST_RANGE.start(),
|
||||
*ALTCHA_CAPTCHA_COST_RANGE.end(),
|
||||
)?,
|
||||
max_counter: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_max_counter",
|
||||
"Maximum counter",
|
||||
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start(),
|
||||
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end(),
|
||||
)?,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_push_service_delivery_update(
|
||||
fn build_profile_timezone_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
push_service_delivery: Some(PushServiceDeliveryConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("push_service_delivery_enabled")),
|
||||
profile_timezone: Some(ProfileTimezoneConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("profile_timezone_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"push_service_delivery_rollout_basis_points",
|
||||
"profile_timezone_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_push_service_delivery_rollout_salt(
|
||||
form,
|
||||
"push_service_delivery_rollout_salt",
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "profile_timezone_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("push_service_delivery_included_user_ids")
|
||||
form.first("profile_timezone_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("profile_timezone_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("profile_timezone_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("push_service_delivery_excluded_user_ids")
|
||||
form.first("profile_timezone_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
@@ -736,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")),
|
||||
@@ -1286,75 +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": [],
|
||||
"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!(
|
||||
@@ -1415,192 +1367,256 @@ 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)
|
||||
fn build_domain_migration_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true",
|
||||
);
|
||||
let update = build_domain_migration_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
.domain_migration
|
||||
.expect("domain migration update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
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!(update.rollout_salt, Some("domain-migration-v2".to_owned()));
|
||||
assert_eq!(
|
||||
overrides,
|
||||
vec![
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000001".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Rnnoise,
|
||||
},
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000002".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Speex,
|
||||
},
|
||||
]
|
||||
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()
|
||||
])
|
||||
);
|
||||
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
|
||||
assert_eq!(update.standalone_forwarding, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_domain_migration_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_domain_migration_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"domain_migration": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
"standalone_forwarding": false,
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[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() {
|
||||
fn build_domain_migration_update_rejects_invalid_rollout_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"voice_ns_default_backend=unknown",
|
||||
"Default backend must name a supported noise suppression backend",
|
||||
"domain_migration_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"voice_ns_default_backend=",
|
||||
"Default backend must name a supported noise suppression backend",
|
||||
"domain_migration_anonymous_rollout_basis_points=10001",
|
||||
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"voice_ns_enabled_backends%5B%5D=rnnoise&voice_ns_enabled_backends%5B%5D=unknown",
|
||||
"Enabled backends must name supported noise suppression backends",
|
||||
"domain_migration_anonymous_rollout_basis_points=abc",
|
||||
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"voice_ns_included_user_ids=123%2Cinvalid",
|
||||
"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",
|
||||
),
|
||||
(
|
||||
"domain_migration_included_user_ids=123%2Cinvalid",
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"voice_ns_excluded_user_ids=123%2Cinvalid",
|
||||
"domain_migration_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"),
|
||||
build_domain_migration_update(&form).expect_err("invalid rollout field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_push_relay_update_reads_the_consent_checkbox() {
|
||||
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&unchecked).expect("serialize update"),
|
||||
serde_json::json!({"push_relay": {"relay_consent_accepted": false}})
|
||||
);
|
||||
|
||||
let checked = build_push_relay_update(&MultiValueForm::parse(
|
||||
b"_csrf=token&push_relay_consent_accepted=true",
|
||||
));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&checked).expect("serialize update"),
|
||||
serde_json::json!({"push_relay": {"relay_consent_accepted": true}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_altcha_captcha_update_reads_the_rollout_and_difficulty_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"altcha_captcha_enabled=true&altcha_captcha_rollout_basis_points=%20500%20&altcha_captcha_rollout_salt=%20altcha-captcha-v2%20&altcha_captcha_included_user_ids=1500000000000000001&altcha_captcha_excluded_user_ids=1500000000000000002&altcha_captcha_anonymous_enabled=true&altcha_captcha_cost=2000&altcha_captcha_max_counter=%20400%20",
|
||||
);
|
||||
let update = build_altcha_captcha_update(&form)
|
||||
.expect("valid form")
|
||||
.altcha_captcha
|
||||
.expect("altcha captcha update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(500));
|
||||
assert_eq!(update.rollout_salt, Some("altcha-captcha-v2".to_owned()));
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000002".to_owned()])
|
||||
);
|
||||
assert_eq!(update.anonymous_enabled, Some(true));
|
||||
assert_eq!(update.cost, Some(2000));
|
||||
assert_eq!(update.max_counter, Some(400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_altcha_captcha_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_altcha_captcha_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"altcha_captcha": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
"anonymous_enabled": false,
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_altcha_captcha_update_rejects_difficulty_outside_the_supported_range() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"altcha_captcha_cost=999",
|
||||
"Cost must be a whole number between 1000 and 100000",
|
||||
),
|
||||
(
|
||||
"altcha_captcha_max_counter=1000001",
|
||||
"Maximum counter must be a whole number between 100 and 1000000",
|
||||
),
|
||||
(
|
||||
"altcha_captcha_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_altcha_captcha_update(&form).expect_err("invalid field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"profile_timezone_enabled=true&profile_timezone_rollout_basis_points=%20500%20&profile_timezone_rollout_salt=%20profile-timezone-v2%20&profile_timezone_included_user_ids=1500000000000000001&profile_timezone_excluded_user_ids=1500000000000000002&profile_timezone_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&profile_timezone_include_premium_users=true",
|
||||
);
|
||||
let update = build_profile_timezone_update(&form)
|
||||
.expect("valid form")
|
||||
.profile_timezone
|
||||
.expect("profile timezone update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(500));
|
||||
assert_eq!(update.rollout_salt, Some("profile-timezone-v2".to_owned()));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
"1500000000000000005".to_owned(),
|
||||
"1500000000000000006".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000002".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_profile_timezone_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"profile_timezone": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_experiment_update_rejects_an_invalid_included_guild_id() {
|
||||
for (prefix, build) in [
|
||||
(
|
||||
"domain_migration",
|
||||
build_domain_migration_update
|
||||
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
|
||||
),
|
||||
("altcha_captcha", build_altcha_captcha_update),
|
||||
("profile_timezone", build_profile_timezone_update),
|
||||
] {
|
||||
let form = MultiValueForm::parse(
|
||||
format!("{prefix}_included_guild_ids=1500000000000000005%0Anot-a-guild").as_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
build(&form).expect_err("invalid guild id"),
|
||||
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{prefix}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_rejects_a_rollout_above_everybody() {
|
||||
let form = MultiValueForm::parse(b"profile_timezone_rollout_basis_points=10001");
|
||||
assert_eq!(
|
||||
build_profile_timezone_update(&form).expect_err("invalid field"),
|
||||
"Rollout basis points must be a whole number between 0 and 10000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -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, ¶ms.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, ¶ms.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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
)],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
|
||||
PendingRegistrationResponse, PushServiceDeliveryConfigResponse, RegistrationUrlResponse,
|
||||
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
|
||||
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_DEFAULT_SALT, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
|
||||
AltchaCaptchaConfigResponse, AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT,
|
||||
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PROFILE_TIMEZONE_DEFAULT_SALT,
|
||||
PendingRegistrationResponse, ProfileTimezoneConfigResponse, PushRelayConfigResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -23,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,
|
||||
};
|
||||
@@ -124,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,
|
||||
))
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -135,6 +156,13 @@ pub fn instance_config_page(
|
||||
(integrations_config_section(base, csrf_token, &instance_config.integrations))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
"Push notifications",
|
||||
"Consent for the relay that delivers official mobile app notifications.",
|
||||
html! {
|
||||
(push_relay_section(base, csrf_token, &instance_config.push_relay))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
"Media & retention",
|
||||
"Attachment expiry rules that can be changed without editing environment variables.",
|
||||
@@ -147,8 +175,9 @@ 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))
|
||||
(push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery))
|
||||
(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))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -197,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 \
|
||||
@@ -207,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))
|
||||
}
|
||||
},
|
||||
@@ -351,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" }
|
||||
@@ -359,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"))
|
||||
@@ -981,195 +1022,69 @@ fn gateway_rollout_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn voice_noise_suppression_section(
|
||||
fn push_relay_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||
push_relay: &PushRelayConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if voice_noise_suppression.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
let status = if push_relay.relay_consent_accepted {
|
||||
("Accepted", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
("Not accepted", 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");
|
||||
let accepted_at =
|
||||
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
|
||||
let accepted_by = push_relay
|
||||
.relay_consent_accepted_by
|
||||
.as_deref()
|
||||
.unwrap_or("Nobody");
|
||||
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.",
|
||||
"Push Relay",
|
||||
"Official mobile app notifications travel through Fluxer's relay to Apple and Google. \
|
||||
The relay delivers them only after an operator accepts its privacy notice.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} {
|
||||
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
|
||||
(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" }
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Relay consent" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (voice_noise_suppression.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"voice_ns_enabled",
|
||||
"push_relay_consent_accepted",
|
||||
"true",
|
||||
"Serve noise suppression assignments to clients",
|
||||
voice_noise_suppression.enabled,
|
||||
"Accept the push relay supplemental privacy notice",
|
||||
push_relay.relay_consent_accepted,
|
||||
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,
|
||||
))
|
||||
"Until this is accepted official mobile app notifications are dropped. \
|
||||
Self-hosted UnifiedPush and ntfy endpoints never reach the relay and are \
|
||||
unaffected. "
|
||||
a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer"
|
||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||
"Read the notice"
|
||||
}
|
||||
}
|
||||
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" {
|
||||
(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_field_group("Accepted at", "push_relay_consent_accepted_at", false, None, None,
|
||||
html! {
|
||||
input type="text" id="push_relay_consent_accepted_at"
|
||||
value=(accepted_at)
|
||||
disabled class=(FORM_INPUT_CLASS);
|
||||
},
|
||||
))
|
||||
(form_field_group("Accepted by user ID", "push_relay_consent_accepted_by", false, None, None,
|
||||
html! {
|
||||
input type="text" id="push_relay_consent_accepted_by"
|
||||
value=(accepted_by)
|
||||
disabled class=(FORM_INPUT_CLASS);
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Voice Noise Suppression Configuration"))
|
||||
(submit_button("Save Push Relay Settings"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1177,70 +1092,93 @@ fn voice_noise_suppression_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn push_service_delivery_section(
|
||||
fn domain_migration_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
push_service_delivery: &PushServiceDeliveryConfigResponse,
|
||||
domain_migration: &DomainMigrationConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if push_service_delivery.enabled {
|
||||
let status = if domain_migration.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = push_service_delivery.included_user_ids.join("\n");
|
||||
let excluded_user_ids = push_service_delivery.excluded_user_ids.join("\n");
|
||||
let included_user_ids = domain_migration.included_user_ids.join("\n");
|
||||
let excluded_user_ids = domain_migration.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Push Service Delivery",
|
||||
"Routes push notification delivery for the selected accounts through the push service. \
|
||||
Accounts the rollout does not select keep the current path.",
|
||||
"Domain Migration",
|
||||
"Moves web clients of the official instance from the legacy web app origin to the new \
|
||||
one. Selected accounts copy their local data across and continue on the new origin. \
|
||||
Clients of other instances read this configuration and ignore it.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_push_service_delivery"} {
|
||||
form method="post" action={(base) "/instance-config?action=update_domain_migration"} {
|
||||
(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 " (push_service_delivery.config_version)
|
||||
"Config version " (domain_migration.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"push_service_delivery_enabled",
|
||||
"domain_migration_enabled",
|
||||
"true",
|
||||
"Hand push notifications to the push service",
|
||||
push_service_delivery.enabled,
|
||||
"Move selected web clients to the new origin",
|
||||
domain_migration.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every notification keeps the \
|
||||
current delivery path, so the rollout and targeting fields below have no \
|
||||
effect at all."
|
||||
"Off is the safe state and the kill switch. With this unchecked no client \
|
||||
starts a migration and clients that already migrated stop forwarding the \
|
||||
legacy origin, so the rollout and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Installed apps" }
|
||||
(checkbox(
|
||||
"domain_migration_standalone_forwarding",
|
||||
"true",
|
||||
"Forward installed desktop web apps to the new origin",
|
||||
domain_migration.standalone_forwarding,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Leave this off until the manifest scope extension and the association file \
|
||||
are live and verified. While it is off, installed Chromium desktop apps copy \
|
||||
their data across but stay on the legacy origin and offer to install the new \
|
||||
app. Installed mobile and Safari apps never forward either way."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"push_service_delivery_rollout_basis_points",
|
||||
"domain_migration_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&push_service_delivery.rollout_basis_points.to_string(),
|
||||
&domain_migration.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."),
|
||||
Some("Share of logged-in users bucketed into the migration, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
(number_field(
|
||||
"domain_migration_anonymous_rollout_basis_points",
|
||||
"Anonymous rollout (basis points)",
|
||||
&domain_migration.anonymous_rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"push_service_delivery_rollout_salt",
|
||||
"domain_migration_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&push_service_delivery.rollout_salt,
|
||||
PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
|
||||
&domain_migration.rollout_salt,
|
||||
DOMAIN_MIGRATION_DEFAULT_SALT,
|
||||
))
|
||||
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."
|
||||
"Seeds the bucketing hash for users and devices. Changing it reshuffles \
|
||||
which users and devices fall inside the percentages above. Leave it \
|
||||
alone to keep the current cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"push_service_delivery_included_user_ids",
|
||||
"domain_migration_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
@@ -1248,7 +1186,7 @@ fn push_service_delivery_section(
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
push_service_delivery.included_user_ids.len(),
|
||||
domain_migration.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
@@ -1258,9 +1196,41 @@ fn push_service_delivery_section(
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"domain_migration_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
domain_migration.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(
|
||||
"push_service_delivery_excluded_user_ids",
|
||||
"domain_migration_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&domain_migration.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
domain_migration.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(
|
||||
"domain_migration_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
@@ -1268,17 +1238,326 @@ fn push_service_delivery_section(
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
push_service_delivery.excluded_user_ids.len(),
|
||||
domain_migration.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."
|
||||
percentage. It stops new migrations only. A user who already moved \
|
||||
stays on the new origin."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Push Service Delivery Configuration"))
|
||||
(submit_button("Save Domain Migration Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn altcha_captcha_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
altcha_captcha: &AltchaCaptchaConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if altcha_captcha.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = altcha_captcha.included_user_ids.join("\n");
|
||||
let excluded_user_ids = altcha_captcha.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"ALTCHA Captcha",
|
||||
"Replaces the configured captcha provider with an ALTCHA proof-of-work check for the \
|
||||
selected requesters. The API issues and verifies every challenge itself, so no third \
|
||||
party is involved. Requests only need a captcha where one is already required, so this \
|
||||
does nothing while captcha is off for the instance.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_altcha_captcha"} {
|
||||
(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 " (altcha_captcha.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"altcha_captcha_enabled",
|
||||
"true",
|
||||
"Serve ALTCHA to the selected requesters",
|
||||
altcha_captcha.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked every \
|
||||
requester gets the configured provider and ALTCHA answers are rejected."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Logged-out requests" }
|
||||
(checkbox(
|
||||
"altcha_captcha_anonymous_enabled",
|
||||
"true",
|
||||
"Serve ALTCHA to logged-out requests",
|
||||
altcha_captcha.anonymous_enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Covers registration, login and password reset. These requests have no \
|
||||
account to bucket, so this switch applies to all of them at once."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"altcha_captcha_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&altcha_captcha.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of logged-in users bucketed into ALTCHA, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"altcha_captcha_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&altcha_captcha.rollout_salt,
|
||||
ALTCHA_CAPTCHA_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"altcha_captcha_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
altcha_captcha.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users get ALTCHA \
|
||||
regardless of the percentage above. Invalid entries prevent the save."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"altcha_captcha_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
altcha_captcha.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(
|
||||
"altcha_captcha_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&altcha_captcha.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
altcha_captcha.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(
|
||||
"altcha_captcha_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
altcha_captcha.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."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Difficulty" }
|
||||
(number_field(
|
||||
"altcha_captcha_cost",
|
||||
"Cost (PBKDF2 iterations per attempt)",
|
||||
&altcha_captcha.cost.to_string(),
|
||||
Some(*ALTCHA_CAPTCHA_COST_RANGE.start()),
|
||||
Some(*ALTCHA_CAPTCHA_COST_RANGE.end()),
|
||||
"1",
|
||||
Some("The API spends one attempt at this cost to issue each challenge."),
|
||||
))
|
||||
(number_field(
|
||||
"altcha_captcha_max_counter",
|
||||
"Maximum counter",
|
||||
&altcha_captcha.max_counter.to_string(),
|
||||
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start()),
|
||||
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end()),
|
||||
"1",
|
||||
Some("Each challenge hides its answer between half this value and this value. The client tries counters from 0 until it finds it, so solve time grows with cost times this value. At the defaults a recent laptop takes about 3 seconds."),
|
||||
))
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save ALTCHA Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn profile_timezone_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
profile_timezone: &ProfileTimezoneConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if profile_timezone.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = profile_timezone.included_user_ids.join("\n");
|
||||
let excluded_user_ids = profile_timezone.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Profile Timezone",
|
||||
"Lets the selected users save a time zone in profile settings and show their local time \
|
||||
on their profile. Users outside the rollout cannot change it, and a saved time zone \
|
||||
stays hidden from everyone while its owner is outside the rollout.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_profile_timezone"} {
|
||||
(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 " (profile_timezone.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"profile_timezone_enabled",
|
||||
"true",
|
||||
"Serve profile timezone to the selected users",
|
||||
profile_timezone.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked nobody \
|
||||
sees the setting and every saved time zone is hidden."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"profile_timezone_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&profile_timezone.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into profile timezone, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"profile_timezone_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&profile_timezone.rollout_salt,
|
||||
PROFILE_TIMEZONE_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"profile_timezone_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users get profile \
|
||||
timezone regardless of the percentage above. Invalid entries prevent the save."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"profile_timezone_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
profile_timezone.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(
|
||||
"profile_timezone_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&profile_timezone.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.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(
|
||||
"profile_timezone_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.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."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Profile Timezone Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1878,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."
|
||||
};
|
||||
@@ -1900,44 +2179,73 @@ 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 {
|
||||
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
|
||||
let domain_migration = DomainMigrationConfigResponse {
|
||||
anonymous_rollout_basis_points: 250,
|
||||
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()
|
||||
..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("action=update_domain_migration"));
|
||||
assert!(markup.contains("domain_migration_enabled"));
|
||||
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
|
||||
assert!(markup.contains("value=\"250\""));
|
||||
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
|
||||
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 voice_noise_suppression_section_flags_a_list_at_its_cap() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
fn push_relay_section_shows_the_consent_toggle() {
|
||||
let accepted = PushRelayConfigResponse {
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
|
||||
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
|
||||
};
|
||||
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
|
||||
assert!(markup.contains("action=update_push_relay"));
|
||||
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
|
||||
assert!(markup.contains("https://fluxer.com/push-relay"));
|
||||
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
|
||||
assert!(markup.contains("value=\"1130650140672000000\""));
|
||||
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
|
||||
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
|
||||
assert!(!markup.to_lowercase().contains("rollout"));
|
||||
|
||||
let unaccepted =
|
||||
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
|
||||
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
|
||||
assert!(unaccepted.contains("Not accepted"));
|
||||
assert!(unaccepted.contains("value=\"Never\""));
|
||||
assert!(unaccepted.contains("value=\"Nobody\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -392,30 +392,52 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"voice_e2ee_scope": "guild_feature_only",
|
||||
"future_rollout_knob": 3
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"push_relay": {
|
||||
"relay_consent_accepted": true,
|
||||
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
|
||||
"relay_consent_accepted_by": "1130650140672000000"
|
||||
},
|
||||
"domain_migration": {
|
||||
"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": [],
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 2500,
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"suppression_strength": 80,
|
||||
"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"]
|
||||
"future_list_knob": ["a", "b"],
|
||||
"anonymous_rollout_basis_points": 100,
|
||||
"standalone_forwarding": true
|
||||
},
|
||||
"push_service_delivery": {
|
||||
"altcha_captcha": {
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 5000,
|
||||
"rollout_salt": "push-service-delivery-v1",
|
||||
"included_user_ids": ["1500000000000000002"],
|
||||
"excluded_user_ids": []
|
||||
"rollout_basis_points": 500,
|
||||
"rollout_salt": "altcha-captcha-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": ["1500000000000000003"],
|
||||
"anonymous_enabled": true,
|
||||
"cost": 5000,
|
||||
"max_counter": 10000,
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"future_altcha_knob": "argon2id"
|
||||
},
|
||||
"profile_timezone": {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "profile-timezone-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": [],
|
||||
"included_guild_ids": ["1500000000000000005"],
|
||||
"include_premium_users": true,
|
||||
"future_profile_timezone_knob": true
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"registration": {
|
||||
@@ -434,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": {
|
||||
@@ -532,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
|
||||
}"##;
|
||||
@@ -541,21 +595,63 @@ 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);
|
||||
assert_eq!(*resp.domain_migration.rollout_salt, "domain-migration-v1");
|
||||
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
|
||||
assert!(resp.domain_migration.standalone_forwarding);
|
||||
assert!(resp.push_relay.relay_consent_accepted);
|
||||
assert!(resp.altcha_captcha.enabled);
|
||||
assert_eq!(resp.altcha_captcha.config_version, 3);
|
||||
assert!(resp.altcha_captcha.anonymous_enabled);
|
||||
assert_eq!(resp.altcha_captcha.excluded_user_ids.len(), 1);
|
||||
assert_eq!(resp.altcha_captcha.max_counter, 10000);
|
||||
assert!(resp.profile_timezone.enabled);
|
||||
assert_eq!(resp.profile_timezone.config_version, 2);
|
||||
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_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\",", "")
|
||||
.replace("\"future_knob\": 7,", "")
|
||||
.replace("\"future_migration_knob\": 9,", "")
|
||||
.replace("\"future_object_knob\": {\"nested\": true},", "")
|
||||
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
|
||||
.replace(
|
||||
@@ -564,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},",
|
||||
"",
|
||||
@@ -577,6 +674,51 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_push_relay_config() {
|
||||
let accepted: types::PushRelayConfigResponse = serde_json::from_str(
|
||||
r#"{
|
||||
"relay_consent_accepted": true,
|
||||
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
|
||||
"relay_consent_accepted_by": "1130650140672000000"
|
||||
}"#,
|
||||
)
|
||||
.expect("an accepted relay consent must deserialize");
|
||||
|
||||
assert!(accepted.relay_consent_accepted);
|
||||
assert_eq!(
|
||||
accepted.relay_consent_accepted_at.as_deref(),
|
||||
Some("2026-09-27T10:11:12.000Z")
|
||||
);
|
||||
assert_eq!(
|
||||
accepted.relay_consent_accepted_by.as_deref(),
|
||||
Some("1130650140672000000")
|
||||
);
|
||||
|
||||
let empty: types::PushRelayConfigResponse =
|
||||
serde_json::from_str("{}").expect("an empty push relay config must deserialize");
|
||||
|
||||
assert!(!empty.relay_consent_accepted);
|
||||
assert!(empty.relay_consent_accepted_at.is_none());
|
||||
assert!(empty.relay_consent_accepted_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_push_relay_update_omits_an_unset_consent() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(types::PushRelayConfigUpdateRequest::default()).unwrap(),
|
||||
serde_json::json!({})
|
||||
);
|
||||
|
||||
let with = types::PushRelayConfigUpdateRequest {
|
||||
relay_consent_accepted: Some(true),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&with).unwrap(),
|
||||
serde_json::json!({"relay_consent_accepted": true})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_search_reports_response() {
|
||||
let json = r#"{
|
||||
@@ -851,7 +993,8 @@ fn deserialize_webauthn_credentials_response() {
|
||||
"id": "credential-a",
|
||||
"name": "YubiKey",
|
||||
"created_at": "2026-05-26T12:00:00.000Z",
|
||||
"last_used_at": null
|
||||
"last_used_at": null,
|
||||
"rp_id": "fluxer.com"
|
||||
},
|
||||
{
|
||||
"id": "credential-b",
|
||||
|
||||
@@ -464,7 +464,7 @@ 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",
|
||||
][..],
|
||||
),
|
||||
@@ -1178,26 +1178,15 @@ fn instance_config() -> Value {
|
||||
"max_concurrent_guild_starts": 16,
|
||||
"voice_e2ee_scope": "guild_feature_only"
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"domain_migration": {
|
||||
"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",
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"suppression_strength": 80
|
||||
"anonymous_rollout_basis_points": 0,
|
||||
"standalone_forwarding": false
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"poll_interval_seconds": 300,
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@simplewebauthn/server": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@vvo/tzdb": "catalog:",
|
||||
"altcha-lib": "catalog:",
|
||||
"archiver": "catalog:",
|
||||
"argon2": "catalog:",
|
||||
"bowser": "catalog:",
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/logger": "workspace:*",
|
||||
"itty-time": "catalog:"
|
||||
"altcha-lib": "catalog:",
|
||||
"itty-time": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface VerifyCaptchaParams {
|
||||
remoteIp?: string;
|
||||
}
|
||||
|
||||
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'test' | 'unavailable';
|
||||
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'altcha' | 'test' | 'unavailable';
|
||||
|
||||
export interface ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {createChallenge, randomInt, verifySolution} from 'altcha-lib';
|
||||
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
import {ms} from 'itty-time';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
|
||||
const ALTCHA_CHALLENGE_TTL_MS = ms('10 minutes');
|
||||
const ALTCHA_MAX_TOKEN_LENGTH = 4096;
|
||||
const HEX_PATTERN = /^[0-9a-f]+$/u;
|
||||
|
||||
const AltchaPayloadSchema = z.object({
|
||||
challenge: z.object({
|
||||
parameters: z.looseObject({
|
||||
algorithm: z.literal(ALTCHA_ALGORITHM),
|
||||
nonce: z.string().regex(HEX_PATTERN),
|
||||
salt: z.string().regex(HEX_PATTERN),
|
||||
cost: z.number().int().positive(),
|
||||
keyLength: z.number().int().positive(),
|
||||
keyPrefix: z.string().regex(HEX_PATTERN),
|
||||
keySignature: z.string().regex(HEX_PATTERN),
|
||||
expiresAt: z.number().int().positive(),
|
||||
}),
|
||||
signature: z.string().regex(HEX_PATTERN),
|
||||
}),
|
||||
solution: z.object({
|
||||
counter: z.number().int().min(0),
|
||||
derivedKey: z.string().regex(HEX_PATTERN),
|
||||
time: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
type AltchaPayload = z.infer<typeof AltchaPayloadSchema>;
|
||||
|
||||
export interface AltchaProviderOptions {
|
||||
hmacSignatureSecret: string;
|
||||
hmacKeySignatureSecret: string;
|
||||
cost: number;
|
||||
maxCounter: number;
|
||||
claimChallenge: (signature: string, ttlSeconds: number) => Promise<boolean>;
|
||||
logger?: LoggerInterface;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function decodePayload(token: string): AltchaPayload | null {
|
||||
if (token.length > ALTCHA_MAX_TOKEN_LENGTH) return null;
|
||||
try {
|
||||
const parsed = AltchaPayloadSchema.safeParse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class AltchaProvider implements ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'altcha';
|
||||
private readonly options: AltchaProviderOptions;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: AltchaProviderOptions) {
|
||||
this.options = options;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async createChallenge(): Promise<Challenge> {
|
||||
const {cost, maxCounter, hmacSignatureSecret, hmacKeySignatureSecret} = this.options;
|
||||
return await createChallenge({
|
||||
algorithm: ALTCHA_ALGORITHM,
|
||||
cost,
|
||||
counter: randomInt(maxCounter, Math.ceil(maxCounter / 2)),
|
||||
deriveKey,
|
||||
expiresAt: new Date(this.now() + ALTCHA_CHALLENGE_TTL_MS),
|
||||
hmacSignatureSecret,
|
||||
hmacKeySignatureSecret,
|
||||
});
|
||||
}
|
||||
|
||||
async verify({token}: VerifyCaptchaParams): Promise<boolean> {
|
||||
const payload = decodePayload(token);
|
||||
if (!payload) return false;
|
||||
try {
|
||||
const result = await verifySolution({
|
||||
challenge: payload.challenge,
|
||||
solution: payload.solution,
|
||||
deriveKey,
|
||||
hmacSignatureSecret: this.options.hmacSignatureSecret,
|
||||
hmacKeySignatureSecret: this.options.hmacKeySignatureSecret,
|
||||
});
|
||||
if (!result.verified) {
|
||||
this.options.logger?.warn(
|
||||
{expired: result.expired, invalidSignature: result.invalidSignature, invalidSolution: result.invalidSolution},
|
||||
'ALTCHA verification failed',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger?.error({error}, 'Error verifying ALTCHA payload');
|
||||
return false;
|
||||
}
|
||||
const ttlSeconds = Math.max(1, payload.challenge.parameters.expiresAt - Math.floor(this.now() / 1000));
|
||||
return await this.options.claimChallenge(payload.challenge.signature, ttlSeconds);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
|
||||
configureMiddleware(routes, {
|
||||
logger,
|
||||
nodeEnv: config.nodeEnv,
|
||||
corsOrigins: [config.endpoints.webApp, config.endpoints.marketing],
|
||||
corsOrigins: [...config.endpoints.webAppOrigins, config.endpoints.marketing],
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
maxInflightRequests: config.maxInflightRequests,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
|
||||
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
@@ -82,6 +83,14 @@ function resolveTrustClientIpHeader(proxyConfig: object): boolean {
|
||||
function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
|
||||
const normalized = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (value.includes('/')) {
|
||||
const range = parseIpBanEntry(value);
|
||||
if (range?.type !== 'range') {
|
||||
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid CIDR range: ${value}`);
|
||||
}
|
||||
normalized.add(range.canonical);
|
||||
continue;
|
||||
}
|
||||
const parsed = parseIpAddress(value);
|
||||
if (!parsed) {
|
||||
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid IP address: ${value}`);
|
||||
@@ -258,6 +267,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
apiPublic: master.endpoints.api,
|
||||
apiClient: master.endpoints.api_client,
|
||||
webApp: master.endpoints.app,
|
||||
webAppOrigins: [...new Set([new URL(master.endpoints.app).origin, ...master.services.api.app_origin_aliases])],
|
||||
gateway: master.endpoints.gateway,
|
||||
media: master.endpoints.media,
|
||||
marketing: master.endpoints.marketing,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -16,12 +16,13 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {
|
||||
getGatewayRolloutConfigPublisher,
|
||||
getInstanceConfigRepository,
|
||||
getPushServiceDeliveryConfigPublisher,
|
||||
getPushRelayConfigPublisher,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
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,
|
||||
@@ -34,9 +35,11 @@ import {
|
||||
PendingRegistrationActionRequest,
|
||||
RegistrationUrlIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {AltchaCaptchaConfigSchema} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {PushServiceDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {ProfileTimezoneConfigSchema} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
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';
|
||||
@@ -63,8 +66,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
const [
|
||||
ssoConfig,
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
pushServiceDelivery,
|
||||
pushRelay,
|
||||
domainMigration,
|
||||
altchaCaptcha,
|
||||
profileTimezone,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -72,19 +77,22 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
] = await Promise.all([
|
||||
instanceConfigRepository.getSsoConfig(),
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getPushServiceDeliveryConfig(),
|
||||
instanceConfigRepository.getPushRelayConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getProfileTimezoneConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
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: {
|
||||
@@ -104,8 +112,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
redirect_uri: deriveSsoRedirectUri(Config.endpoints.webApp),
|
||||
},
|
||||
gateway_rollout: gatewayRollout,
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
push_service_delivery: pushServiceDelivery,
|
||||
push_relay: pushRelay,
|
||||
domain_migration: domainMigration,
|
||||
altcha_captcha: altchaCaptcha,
|
||||
profile_timezone: profileTimezone,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -139,6 +149,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
},
|
||||
integrations,
|
||||
media,
|
||||
billing,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,6 +201,109 @@ async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boole
|
||||
return true;
|
||||
}
|
||||
|
||||
function relayConsentStamp(
|
||||
current: PushRelayConfig,
|
||||
patch: PushRelayConfigUpdateRequest,
|
||||
adminUserId: string,
|
||||
): Partial<PushRelayConfig> {
|
||||
const accepted = patch.relay_consent_accepted;
|
||||
if (accepted === undefined || accepted === current.relay_consent_accepted) {
|
||||
return {};
|
||||
}
|
||||
return accepted
|
||||
? {relay_consent_accepted_at: new Date().toISOString(), relay_consent_accepted_by: adminUserId}
|
||||
: {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)
|
||||
@@ -245,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;
|
||||
@@ -257,11 +373,23 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
);
|
||||
await getGatewayRolloutConfigPublisher().publish(landed);
|
||||
}
|
||||
if (data.voice_noise_suppression) {
|
||||
const patch = omitUndefinedFields(data.voice_noise_suppression);
|
||||
if (data.push_relay) {
|
||||
const patch = omitUndefinedFields(data.push_relay);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateVoiceNoiseSuppressionConfig((current) =>
|
||||
VoiceNoiseSuppressionConfigSchema.parse({
|
||||
const adminUserId = ctx.get('adminUserId').toString();
|
||||
const landed = await instanceConfigRepository.updatePushRelayConfig((current) => ({
|
||||
...current,
|
||||
...patch,
|
||||
...relayConsentStamp(current, patch, adminUserId),
|
||||
}));
|
||||
await getPushRelayConfigPublisher().publish(landed);
|
||||
}
|
||||
}
|
||||
if (data.domain_migration) {
|
||||
const patch = omitUndefinedFields(data.domain_migration);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateDomainMigrationConfig((current) =>
|
||||
DomainMigrationConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
@@ -269,17 +397,28 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.push_service_delivery) {
|
||||
const patch = omitUndefinedFields(data.push_service_delivery);
|
||||
if (data.altcha_captcha) {
|
||||
const patch = omitUndefinedFields(data.altcha_captcha);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
|
||||
PushServiceDeliveryConfigSchema.parse({
|
||||
await instanceConfigRepository.updateAltchaCaptchaConfig((current) =>
|
||||
AltchaCaptchaConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.profile_timezone) {
|
||||
const patch = omitUndefinedFields(data.profile_timezone);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateProfileTimezoneConfig((current) =>
|
||||
ProfileTimezoneConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
await getPushServiceDeliveryConfigPublisher().publish(landed);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
@@ -351,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
|
||||
@@ -443,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({
|
||||
@@ -639,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();
|
||||
@@ -646,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, {
|
||||
|
||||
@@ -8,12 +8,14 @@ import * as AuthEmail from '@app/api/auth/AuthEmail';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import {createPasswordResetToken, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
|
||||
import type {HistoricalOutcomeCode} from '@app/api/risk/RiskHistoryTypes';
|
||||
import {mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
|
||||
import {resolveAssignedTraits} from '@app/api/user/UserTraits';
|
||||
import {getIpAddressReverse, getLocationLabelFromIp} from '@app/api/utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '@app/api/utils/SessionClientIdentity';
|
||||
@@ -545,7 +547,7 @@ export class AdminUserSecurityService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const credentials = await userRepository.listWebAuthnCredentials(userId);
|
||||
const credentials = visibleWebAuthnCredentials(await userRepository.listWebAuthnCredentials(userId));
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
@@ -554,12 +556,9 @@ export class AdminUserSecurityService {
|
||||
auditLogReason,
|
||||
metadata: new Map([['credential_count', credentials.length.toString()]]),
|
||||
});
|
||||
return credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
}));
|
||||
return credentials.map((cred) =>
|
||||
mapWebAuthnCredentialToResponse(cred, this.deps.apiContext.services.config.auth.passkeys.rpId),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
|
||||
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
|
||||
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
@@ -16,12 +16,12 @@ 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 {
|
||||
DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
|
||||
type PushServiceDeliveryConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
type LegacyPushServiceDeliveryWire,
|
||||
toLegacyPushServiceDeliveryWire,
|
||||
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
|
||||
describe('instance config admin PATCH under concurrent writes', () => {
|
||||
let harness: ApiTestHarness;
|
||||
@@ -55,13 +55,13 @@ describe('instance config admin PATCH under concurrent writes', () => {
|
||||
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
|
||||
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
|
||||
|
||||
const spyOnPushDeliveryPublishes = () =>
|
||||
vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
const spyOnPushRelayPublishes = () =>
|
||||
vi.spyOn(PushRelayConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
|
||||
async function readStoredPushServiceDelivery(): Promise<PushServiceDeliveryConfig> {
|
||||
const raw = await executor.readDirectly(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
|
||||
if (raw === null) throw new Error('push service delivery config was never stored');
|
||||
return JSON.parse(raw) as PushServiceDeliveryConfig;
|
||||
async function readStoredPushRelay(): Promise<LegacyPushServiceDeliveryWire> {
|
||||
const raw = await executor.readDirectly(PUSH_RELAY_CONFIG_KEY);
|
||||
if (raw === null) throw new Error('push relay config was never stored');
|
||||
return JSON.parse(raw) as LegacyPushServiceDeliveryWire;
|
||||
}
|
||||
|
||||
async function listConfigUpdateAudits(): Promise<Array<AdminAuditLog>> {
|
||||
@@ -69,38 +69,47 @@ describe('instance config admin PATCH under concurrent writes', () => {
|
||||
return logs.filter((log) => log.action === 'update_instance_config');
|
||||
}
|
||||
|
||||
it('answers with a conflict and neither writes, publishes nor audits once every attempt has lost the race', async () => {
|
||||
const publish = spyOnPushDeliveryPublishes();
|
||||
it('merges a standalone forwarding patch into the stored domain migration config', async () => {
|
||||
const admin = await createAdmin();
|
||||
await patchConfig(admin, {push_service_delivery: {enabled: true, rollout_basis_points: 1000}}).execute();
|
||||
await patchConfig(admin, {domain_migration: {enabled: true, rollout_basis_points: 250}}).execute();
|
||||
|
||||
const updated = await patchConfig(admin, {domain_migration: {standalone_forwarding: true}}).execute();
|
||||
|
||||
expect(updated.domain_migration).toMatchObject({
|
||||
enabled: true,
|
||||
rollout_basis_points: 250,
|
||||
standalone_forwarding: true,
|
||||
config_version: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers with a conflict and neither writes, publishes nor audits once every attempt has lost the race', async () => {
|
||||
const publish = spyOnPushRelayPublishes();
|
||||
const admin = await createAdmin();
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
|
||||
publish.mockClear();
|
||||
const auditsBefore = await listConfigUpdateAudits();
|
||||
executor.watch(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
|
||||
executor.watch(PUSH_RELAY_CONFIG_KEY);
|
||||
const unaccepted = {
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
};
|
||||
let competingWrites = 0;
|
||||
executor.competeBeforeEachWrite(async () => {
|
||||
competingWrites++;
|
||||
await executor.writeDirectly(
|
||||
PUSH_SERVICE_DELIVERY_CONFIG_KEY,
|
||||
JSON.stringify({
|
||||
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
|
||||
enabled: false,
|
||||
rollout_basis_points: 1000,
|
||||
config_version: 100 + competingWrites,
|
||||
}),
|
||||
PUSH_RELAY_CONFIG_KEY,
|
||||
JSON.stringify(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites)),
|
||||
);
|
||||
});
|
||||
|
||||
await patchConfig(admin, {push_service_delivery: {rollout_basis_points: 5000}})
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: true}})
|
||||
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.CONFLICT)
|
||||
.execute();
|
||||
|
||||
expect(executor.events).not.toContain('write');
|
||||
expect(await readStoredPushServiceDelivery()).toEqual({
|
||||
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
|
||||
enabled: false,
|
||||
rollout_basis_points: 1000,
|
||||
config_version: 100 + competingWrites,
|
||||
});
|
||||
expect(await readStoredPushRelay()).toEqual(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites));
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
expect(await listConfigUpdateAudits()).toHaveLength(auditsBefore.length);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
// 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 {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
|
||||
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const ACCEPTED_AT = '2026-09-20T08:00:00.000Z';
|
||||
const ACCEPTED_BY = '1500000000000000007';
|
||||
|
||||
const PROD_ROW = {
|
||||
enabled: true,
|
||||
config_version: 41,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: ACCEPTED_AT,
|
||||
relay_consent_accepted_by: ACCEPTED_BY,
|
||||
};
|
||||
|
||||
interface PushServiceDeliveryRpcResponse {
|
||||
type: 'get_push_service_delivery_config';
|
||||
data: {config: LegacyPushServiceDeliveryWire};
|
||||
}
|
||||
|
||||
describe('push relay supplemental notice consent', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let executor: InstanceConfigWriteRaceExecutor;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
vi.spyOn(PushRelayConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
const createAdmin = async (): Promise<TestAccount> =>
|
||||
await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
|
||||
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
|
||||
|
||||
const readConfig = (admin: TestAccount) =>
|
||||
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config');
|
||||
|
||||
const readRpcConfig = async (): Promise<LegacyPushServiceDeliveryWire> => {
|
||||
const response = await createBuilder<PushServiceDeliveryRpcResponse>(harness, '')
|
||||
.post('/test/rpc-session-init')
|
||||
.body({type: 'get_push_service_delivery_config'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.type).toBe('get_push_service_delivery_config');
|
||||
return response.data.config;
|
||||
};
|
||||
|
||||
async function storeRow(row: Record<string, unknown>): Promise<void> {
|
||||
await executor.writeDirectly(PUSH_RELAY_CONFIG_KEY, JSON.stringify(row));
|
||||
getInstanceConfigRepository().clearCacheForTesting();
|
||||
}
|
||||
|
||||
async function readStoredRow(): Promise<unknown> {
|
||||
const raw = await executor.readDirectly(PUSH_RELAY_CONFIG_KEY);
|
||||
if (raw === null) throw new Error('push relay config was never stored');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
it('reads back as unaccepted before an operator agrees', async () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const config = await readConfig(admin).execute();
|
||||
|
||||
expect(config.push_relay).toEqual({
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the consent of a stored push service delivery row', async () => {
|
||||
const admin = await createAdmin();
|
||||
await storeRow(PROD_ROW);
|
||||
|
||||
const config = await readConfig(admin).execute();
|
||||
|
||||
expect(config.push_relay).toEqual({
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: ACCEPTED_AT,
|
||||
relay_consent_accepted_by: ACCEPTED_BY,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a stored row without consent fields as unaccepted', async () => {
|
||||
const admin = await createAdmin();
|
||||
await storeRow({enabled: true, config_version: 3, rollout_basis_points: 10000});
|
||||
|
||||
const config = await readConfig(admin).execute();
|
||||
|
||||
expect(config.push_relay.relay_consent_accepted).toBe(false);
|
||||
expect(await readRpcConfig()).toMatchObject({config_version: 3, relay_consent_accepted: false});
|
||||
});
|
||||
|
||||
it('stamps the acting admin and the acceptance time when consent is given', async () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(updated.push_relay.relay_consent_accepted).toBe(true);
|
||||
expect(updated.push_relay.relay_consent_accepted_by).toBe(admin.userId);
|
||||
expect(Date.parse(updated.push_relay.relay_consent_accepted_at ?? '')).not.toBeNaN();
|
||||
});
|
||||
|
||||
it('keeps the stamp untouched when consent is re-sent unchanged', async () => {
|
||||
const admin = await createAdmin();
|
||||
const accepted = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
const resent = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(resent.push_relay).toEqual(accepted.push_relay);
|
||||
});
|
||||
|
||||
it('clears the stamp when an operator withdraws consent', async () => {
|
||||
const admin = await createAdmin();
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
const withdrawn = await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
|
||||
|
||||
expect(withdrawn.push_relay).toEqual({
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores an acceptance stamp supplied by the caller', async () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const updated = await patchConfig(admin, {
|
||||
push_relay: {
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: '2020-01-01T00:00:00.000Z',
|
||||
relay_consent_accepted_by: '1500000000000000009',
|
||||
},
|
||||
}).execute();
|
||||
|
||||
expect(updated.push_relay.relay_consent_accepted_at).not.toBe('2020-01-01T00:00:00.000Z');
|
||||
expect(updated.push_relay.relay_consent_accepted_by).toBe(admin.userId);
|
||||
});
|
||||
|
||||
it('writes the full legacy document and bumps the stored config version', async () => {
|
||||
const admin = await createAdmin();
|
||||
await storeRow({
|
||||
...PROD_ROW,
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
|
||||
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(await readStoredRow()).toEqual({
|
||||
enabled: true,
|
||||
config_version: 42,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: admin.userId,
|
||||
});
|
||||
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
|
||||
|
||||
expect(await readStoredRow()).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 43,
|
||||
rollout_basis_points: 10000,
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rewrites a partially enrolled stored row as full enrolment', async () => {
|
||||
const admin = await createAdmin();
|
||||
await storeRow({
|
||||
...PROD_ROW,
|
||||
enabled: false,
|
||||
rollout_basis_points: 250,
|
||||
rollout_salt: 'custom-salt',
|
||||
included_user_ids: ['1500000000000000003'],
|
||||
excluded_user_ids: ['1500000000000000004'],
|
||||
});
|
||||
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
|
||||
|
||||
expect(await readStoredRow()).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 42,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes the legacy delivery document with the consent', async () => {
|
||||
const admin = await createAdmin();
|
||||
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
|
||||
|
||||
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
expect(publish).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: admin.userId,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write or publish for an empty push relay patch', async () => {
|
||||
const admin = await createAdmin();
|
||||
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
|
||||
|
||||
await patchConfig(admin, {push_relay: {}}).execute();
|
||||
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
expect(await executor.readDirectly(PUSH_RELAY_CONFIG_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores the retired push_service_delivery section', async () => {
|
||||
const admin = await createAdmin();
|
||||
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
|
||||
|
||||
const updated = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(updated.push_relay.relay_consent_accepted).toBe(false);
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers the legacy delivery RPC with full enrolment and the stored consent', async () => {
|
||||
await storeRow(PROD_ROW);
|
||||
|
||||
expect(await readRpcConfig()).toEqual(PROD_ROW);
|
||||
});
|
||||
|
||||
it('answers the legacy delivery RPC with defaults before anything is stored', async () => {
|
||||
expect(await readRpcConfig()).toEqual({
|
||||
enabled: true,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers the legacy delivery RPC with consent given through the admin API', async () => {
|
||||
const admin = await createAdmin();
|
||||
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(await readRpcConfig()).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: admin.userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
import {registerAdminControllers} from '@app/api/admin/controllers/index';
|
||||
import {AttachmentController} from '@app/api/attachment/AttachmentController';
|
||||
import {AuthController} from '@app/api/auth/AuthController';
|
||||
import {OriginHandoffController} from '@app/api/auth/OriginHandoffController';
|
||||
import {PasskeyBridgeController} from '@app/api/auth/PasskeyBridgeController';
|
||||
import {BlueskyOAuthController} from '@app/api/bluesky/BlueskyOAuthController';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ChannelController} from '@app/api/channel/ChannelController';
|
||||
@@ -46,6 +48,8 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
GeolocationController(routes);
|
||||
registerAdminControllers(routes);
|
||||
AuthController(routes);
|
||||
OriginHandoffController(routes);
|
||||
PasskeyBridgeController(routes);
|
||||
AttachmentController(routes);
|
||||
ChannelController(routes);
|
||||
ConnectionController(routes);
|
||||
@@ -77,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 {
|
||||
|
||||
@@ -447,7 +447,7 @@ export function AuthController(app: HonoApp) {
|
||||
'Retrieve WebAuthn authentication challenge and options for passwordless login with biometrics or security keys.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnAuthenticationOptions());
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnAuthenticationOptions(ctx.req.header('origin')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
@@ -490,7 +490,9 @@ export function AuthController(app: HonoApp) {
|
||||
'Retrieve WebAuthn challenge and options for multi-factor authentication. Requires the MFA ticket from initial login.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnMfaOptions(ctx.req.valid('json')));
|
||||
return ctx.json(
|
||||
await ctx.get('authRequestService').getWebAuthnMfaOptions(ctx.req.valid('json'), ctx.req.header('origin')),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
@@ -602,6 +604,7 @@ export function AuthController(app: HonoApp) {
|
||||
data: ctx.req.valid('json'),
|
||||
clientIp,
|
||||
authToken: ctx.get('authToken') ?? undefined,
|
||||
approverOrigin: ctx.req.header('origin'),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {InviteService} from '@app/api/invite/InviteService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {AuthSession as AuthSessionModel} from '@app/api/models/AuthSession';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
|
||||
@@ -353,7 +354,7 @@ export async function login(
|
||||
const MFA_TICKET_MAX_ATTEMPTS = 5;
|
||||
const MFA_USER_MAX_ATTEMPTS = 10;
|
||||
|
||||
async function consumeMfaAttempt(
|
||||
export async function consumeMfaAttempt(
|
||||
ctx: ApiContext,
|
||||
{userId, ticket, field}: {userId: string; ticket: string; field: string},
|
||||
): Promise<void> {
|
||||
@@ -381,7 +382,7 @@ export async function loginMfaTotp(
|
||||
ctx: ApiContext,
|
||||
{code, ticket, request}: LoginMfaTotpParams,
|
||||
): Promise<LoginTokenResult> {
|
||||
const {users, cache, rateLimit} = ctx.services;
|
||||
const {users, cache} = ctx.services;
|
||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
@@ -405,21 +406,36 @@ export async function loginMfaTotp(
|
||||
if (!isValid) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
const [token] = await completeMfaLogin(ctx, user, ticket, request);
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
export async function createLoginSession(
|
||||
ctx: ApiContext,
|
||||
user: User,
|
||||
request: Request,
|
||||
): Promise<[token: string, AuthSessionModel]> {
|
||||
return AuthSession.createAuthSession(ctx, {user, origin: AuthSession.resolveSessionOrigin(ctx, request)});
|
||||
}
|
||||
|
||||
export async function completeMfaLogin(
|
||||
ctx: ApiContext,
|
||||
user: User,
|
||||
ticket: string,
|
||||
request: Request,
|
||||
): Promise<[token: string, AuthSessionModel]> {
|
||||
const {cache, rateLimit} = ctx.services;
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:user:${user.id}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {user_id: user.id.toString(), token};
|
||||
return createLoginSession(ctx, user, request);
|
||||
}
|
||||
|
||||
export async function loginMfaWebAuthn(
|
||||
ctx: ApiContext,
|
||||
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
|
||||
): Promise<LoginTokenResult> {
|
||||
const {users, cache, rateLimit} = ctx.services;
|
||||
const {users, cache} = ctx.services;
|
||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
@@ -434,13 +450,7 @@ export async function loginMfaWebAuthn(
|
||||
}
|
||||
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:user:${user.id}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
const [token] = await completeMfaLogin(ctx, user, ticket, request);
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
|
||||
+269
-155
@@ -3,13 +3,20 @@
|
||||
import {timingSafeEqual} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {
|
||||
type CredentialRpSelection,
|
||||
effectiveRpId,
|
||||
originRpId,
|
||||
selectCredentialRp,
|
||||
visibleWebAuthnCredentials,
|
||||
} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import {deriveSudoMethods, userHasMfa, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {MfaBackupCode} from '@app/api/models/MfaBackupCode';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {mapUserToPrivateResponse, mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
|
||||
import {TotpGenerator} from '@app/api/utils/TotpGenerator';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -22,7 +29,12 @@ import {PasskeyAuthenticationFailedError} from '@fluxer/errors/src/domains/auth/
|
||||
import {UnknownWebAuthnCredentialError} from '@fluxer/errors/src/domains/auth/UnknownWebAuthnCredentialError';
|
||||
import {WebAuthnCredentialLimitReachedError} from '@fluxer/errors/src/domains/auth/WebAuthnCredentialLimitReachedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {AuthenticationResponseJSON, RegistrationResponseJSON} from '@simplewebauthn/server';
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/server';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
@@ -33,7 +45,41 @@ import {
|
||||
} from '@simplewebauthn/server';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
|
||||
type WebAuthnChallengeContext = 'registration' | 'discoverable' | 'mfa' | 'sudo';
|
||||
type WebAuthnChallengeContext = 'registration' | 'discoverable' | 'mfa' | 'sudo' | 'bridge' | 'migration_registration';
|
||||
|
||||
interface WebAuthnChallengeEntry {
|
||||
context: WebAuthnChallengeContext;
|
||||
userId?: string;
|
||||
ticket?: string;
|
||||
rpId?: string;
|
||||
credentialIds?: Array<string> | null;
|
||||
}
|
||||
|
||||
interface WebAuthnChallengeScope {
|
||||
rpId: string;
|
||||
credentialIds: Array<string> | null;
|
||||
}
|
||||
|
||||
interface WebAuthnAuthenticationOptionsParams {
|
||||
selection: CredentialRpSelection | {rpId: string; credentials: null};
|
||||
context: WebAuthnChallengeContext;
|
||||
userId?: UserID;
|
||||
ticket?: string;
|
||||
}
|
||||
|
||||
interface WebAuthnRegistrationOptionsParams {
|
||||
rpId: string;
|
||||
context: WebAuthnChallengeContext;
|
||||
excludeCredentials: Array<WebAuthnCredential>;
|
||||
}
|
||||
|
||||
interface VerifiedWebAuthnRegistration {
|
||||
credentialId: string;
|
||||
publicKey: Buffer;
|
||||
counter: bigint;
|
||||
transports: Set<string> | null;
|
||||
rpId: string;
|
||||
}
|
||||
|
||||
interface SudoMfaVerificationParams {
|
||||
userId: UserID;
|
||||
@@ -122,37 +168,117 @@ export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnRegistrationOptions(ctx: ApiContext, userId: UserID) {
|
||||
function toCredentialDescriptor(credential: WebAuthnCredential) {
|
||||
return {
|
||||
id: credential.credentialId,
|
||||
transports: credential.transports
|
||||
? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function storedRpId(ctx: ApiContext, rpId: string): string | null {
|
||||
return rpId === ctx.services.config.auth.passkeys.rpId ? null : rpId;
|
||||
}
|
||||
|
||||
export async function createWebAuthnRegistrationOptions(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
{rpId, context, excludeCredentials}: WebAuthnRegistrationOptionsParams,
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const {users, config} = ctx.services;
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
const existingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
if (existingCredentials.length >= 10) {
|
||||
throw new WebAuthnCredentialLimitReachedError();
|
||||
}
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: config.auth.passkeys.rpName,
|
||||
rpID: config.auth.passkeys.rpId,
|
||||
rpID: rpId,
|
||||
userID: new TextEncoder().encode(user.id.toString()),
|
||||
userName: user.username!,
|
||||
userDisplayName: user.username!,
|
||||
attestationType: 'none',
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
excludeCredentials: existingCredentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
excludeCredentials: excludeCredentials.map(toCredentialDescriptor),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
requireResidentKey: false,
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'registration', userId});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context, userId, rpId, credentialIds: null});
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnRegistrationOptions(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const existingCredentials = await ctx.services.users.listWebAuthnCredentials(userId);
|
||||
if (visibleWebAuthnCredentials(existingCredentials).length >= 10) {
|
||||
throw new WebAuthnCredentialLimitReachedError();
|
||||
}
|
||||
return createWebAuthnRegistrationOptions(ctx, userId, {
|
||||
rpId: originRpId(ctx, origin),
|
||||
context: 'registration',
|
||||
excludeCredentials: existingCredentials,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnRegistrationResponse(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
response: RegistrationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
context: WebAuthnChallengeContext,
|
||||
expectedOrigin: Array<string> = ctx.services.config.auth.passkeys.allowedOrigins,
|
||||
): Promise<VerifiedWebAuthnRegistration> {
|
||||
const {config} = ctx.services;
|
||||
const {rpId} = await consumeWebAuthnChallenge(ctx, expectedChallenge, context, {userId});
|
||||
const responseObj = response as {id?: string; response?: {transports?: Array<string>}};
|
||||
const transports = responseObj.response?.transports ? new Set(responseObj.response.transports) : null;
|
||||
if (config.dev.testModeEnabled) {
|
||||
const credentialId = responseObj.id ?? `test-credential:${userId.toString()}:${Date.now()}`;
|
||||
return {credentialId, publicKey: Buffer.from(`test-public-key:${credentialId}`), counter: 0n, transports, rpId};
|
||||
}
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpId,
|
||||
requireUserVerification: false,
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, expectedChallenge, rpId, expectedOrigin}, 'WebAuthn verification failed');
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
Logger.error(
|
||||
{userId, verified: verification.verified, hasRegistrationInfo: !!verification.registrationInfo},
|
||||
'WebAuthn verification result invalid',
|
||||
);
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
const {credential} = verification.registrationInfo;
|
||||
let publicKeyBuffer: Buffer;
|
||||
let counterBigInt: bigint;
|
||||
try {
|
||||
publicKeyBuffer = Buffer.from(credential.publicKey);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnPublicKeyFormatError();
|
||||
}
|
||||
try {
|
||||
if (credential.counter === undefined || credential.counter === null) {
|
||||
throw new Error('Counter value is undefined or null');
|
||||
}
|
||||
counterBigInt = BigInt(credential.counter);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnCredentialCounterError();
|
||||
}
|
||||
return {credentialId: credential.id, publicKey: publicKeyBuffer, counter: counterBigInt, transports, rpId};
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnRegistration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
@@ -160,85 +286,39 @@ export async function verifyWebAuthnRegistration(
|
||||
expectedChallenge: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const {users, config} = ctx.services;
|
||||
const {users} = ctx.services;
|
||||
const existingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
await consumeWebAuthnChallenge(ctx, expectedChallenge, 'registration', {userId});
|
||||
if (existingCredentials.length >= 10) {
|
||||
if (visibleWebAuthnCredentials(existingCredentials).length >= 10) {
|
||||
throw new WebAuthnCredentialLimitReachedError();
|
||||
}
|
||||
if (config.dev.testModeEnabled) {
|
||||
const responseObj = response as {id?: string; response?: {transports?: Array<string>}};
|
||||
const credentialId = responseObj.id ?? `test-credential:${userId.toString()}:${Date.now()}`;
|
||||
const publicKeyBuffer = Buffer.from(`test-public-key:${credentialId}`);
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
credentialId,
|
||||
publicKeyBuffer,
|
||||
0n,
|
||||
responseObj.response?.transports ? new Set(responseObj.response.transports) : null,
|
||||
name,
|
||||
);
|
||||
} else {
|
||||
const expectedOrigin = config.auth.passkeys.allowedOrigins;
|
||||
const rpID = config.auth.passkeys.rpId;
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, expectedChallenge, rpID, expectedOrigin}, 'WebAuthn verification failed');
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
Logger.error(
|
||||
{userId, verified: verification.verified, hasRegistrationInfo: !!verification.registrationInfo},
|
||||
'WebAuthn verification result invalid',
|
||||
);
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
const {credential} = verification.registrationInfo;
|
||||
let publicKeyBuffer: Buffer;
|
||||
let counterBigInt: bigint;
|
||||
try {
|
||||
publicKeyBuffer = Buffer.from(credential.publicKey);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnPublicKeyFormatError();
|
||||
}
|
||||
try {
|
||||
if (credential.counter === undefined || credential.counter === null) {
|
||||
throw new Error('Counter value is undefined or null');
|
||||
}
|
||||
counterBigInt = BigInt(credential.counter);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnCredentialCounterError();
|
||||
}
|
||||
const responseObj = response as {response?: {transports?: Array<string>}};
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
credential.id,
|
||||
publicKeyBuffer,
|
||||
counterBigInt,
|
||||
responseObj.response?.transports ? new Set(responseObj.response.transports) : null,
|
||||
name,
|
||||
);
|
||||
}
|
||||
const verified = await verifyWebAuthnRegistrationResponse(ctx, userId, response, expectedChallenge, 'registration');
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
verified.credentialId,
|
||||
verified.publicKey,
|
||||
verified.counter,
|
||||
verified.transports,
|
||||
name,
|
||||
storedRpId(ctx, verified.rpId),
|
||||
);
|
||||
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
|
||||
export async function deleteWebAuthnCredential(ctx: ApiContext, userId: UserID, credentialId: string): Promise<void> {
|
||||
const {users, gateway, botMfaMirror} = ctx.services;
|
||||
const credential = await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
if (!credential || credential.supersededBy !== null) {
|
||||
throw new UnknownWebAuthnCredentialError();
|
||||
}
|
||||
await users.deleteWebAuthnCredential(userId, credentialId);
|
||||
const remainingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
const remaining = await users.listWebAuthnCredentials(userId);
|
||||
const remainingCredentials = visibleWebAuthnCredentials(remaining);
|
||||
const orphanedTwins = remaining.filter(
|
||||
(cred) => cred.supersededBy === credentialId || (cred.supersededBy !== null && remainingCredentials.length === 0),
|
||||
);
|
||||
for (const twin of orphanedTwins) {
|
||||
await users.deleteWebAuthnCredential(userId, twin.credentialId);
|
||||
}
|
||||
if (remainingCredentials.length === 0) {
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
if (user.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
@@ -298,37 +378,66 @@ export async function renameWebAuthnCredential(
|
||||
): Promise<void> {
|
||||
const {users} = ctx.services;
|
||||
const credential = await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
if (!credential || credential.supersededBy !== null) {
|
||||
throw new UnknownWebAuthnCredentialError();
|
||||
}
|
||||
await users.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
|
||||
async function dispatchWebAuthnCredentialsUpdate(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {users, gateway} = ctx.services;
|
||||
export async function dispatchWebAuthnCredentialsUpdate(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {users, gateway, config} = ctx.services;
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
await gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'WEBAUTHN_CREDENTIALS_UPDATE',
|
||||
data: credentials.map((cred: WebAuthnCredential) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
})),
|
||||
data: visibleWebAuthnCredentials(credentials).map((cred) =>
|
||||
mapWebAuthnCredentialToResponse(cred, config.auth.passkeys.rpId),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateWebAuthnAuthenticationOptionsDiscoverable(ctx: ApiContext) {
|
||||
export async function generateWebAuthnAuthenticationOptions(
|
||||
ctx: ApiContext,
|
||||
{selection, context, userId, ticket}: WebAuthnAuthenticationOptionsParams,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: ctx.services.config.auth.passkeys.rpId,
|
||||
userVerification: 'required',
|
||||
rpID: selection.rpId,
|
||||
allowCredentials: selection.credentials?.map(toCredentialDescriptor),
|
||||
userVerification: selection.credentials === null ? 'required' : 'discouraged',
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {
|
||||
context,
|
||||
userId,
|
||||
ticket,
|
||||
rpId: selection.rpId,
|
||||
credentialIds: selection.credentials?.map((cred) => cred.credentialId) ?? null,
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'discoverable'});
|
||||
return options;
|
||||
}
|
||||
|
||||
function selectCredentialRpOrThrow(
|
||||
ctx: ApiContext,
|
||||
origin: string | null | undefined,
|
||||
credentials: Array<WebAuthnCredential>,
|
||||
): CredentialRpSelection {
|
||||
const selection = selectCredentialRp(ctx, origin, credentials);
|
||||
if (selection.credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnAuthenticationOptionsDiscoverable(
|
||||
ctx: ApiContext,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
return generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: {rpId: originRpId(ctx, origin), credentials: null},
|
||||
context: 'discoverable',
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnAuthenticationDiscoverable(
|
||||
ctx: ApiContext,
|
||||
response: AuthenticationResponseJSON,
|
||||
@@ -344,29 +453,24 @@ export async function verifyWebAuthnAuthenticationDiscoverable(
|
||||
return users.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
export async function generateWebAuthnAuthenticationOptionsForMfa(ctx: ApiContext, ticket: string) {
|
||||
const {users, cache, config} = ctx.services;
|
||||
export async function generateWebAuthnAuthenticationOptionsForMfa(
|
||||
ctx: ApiContext,
|
||||
ticket: string,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const {users, cache} = ctx.services;
|
||||
const userIdStr = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userIdStr) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
}
|
||||
const userId = createUserID(BigInt(userIdStr));
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
if (credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.auth.passkeys.rpId,
|
||||
allowCredentials: credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
userVerification: 'discouraged',
|
||||
return generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: selectCredentialRpOrThrow(ctx, origin, credentials),
|
||||
context: 'mfa',
|
||||
userId,
|
||||
ticket,
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'mfa', userId, ticket});
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnAuthentication(
|
||||
@@ -376,21 +480,26 @@ export async function verifyWebAuthnAuthentication(
|
||||
expectedChallenge: string,
|
||||
context: WebAuthnChallengeContext = 'mfa',
|
||||
ticket?: string,
|
||||
): Promise<void> {
|
||||
expectedOrigin: Array<string> = ctx.services.config.auth.passkeys.allowedOrigins,
|
||||
): Promise<WebAuthnCredential> {
|
||||
const {users, config} = ctx.services;
|
||||
await consumeWebAuthnChallenge(ctx, expectedChallenge, context, {userId, ticket});
|
||||
const scope = await consumeWebAuthnChallenge(ctx, expectedChallenge, context, {userId, ticket});
|
||||
const credentialId = (response as {id: string}).id;
|
||||
const credential = await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
throw new PasskeyAuthenticationFailedError();
|
||||
}
|
||||
if (
|
||||
effectiveRpId(ctx, credential) !== scope.rpId ||
|
||||
(scope.credentialIds !== null && !scope.credentialIds.includes(credentialId))
|
||||
) {
|
||||
throw new PasskeyAuthenticationFailedError();
|
||||
}
|
||||
if (config.dev.testModeEnabled) {
|
||||
await users.updateWebAuthnCredentialCounter(userId, credentialId, credential.counter + 1n);
|
||||
await users.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
return;
|
||||
return credential;
|
||||
}
|
||||
const expectedOrigin = config.auth.passkeys.allowedOrigins;
|
||||
const rpID = config.auth.passkeys.rpId;
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
let publicKeyUint8Array: Uint8Array<ArrayBuffer>;
|
||||
@@ -405,15 +514,12 @@ export async function verifyWebAuthnAuthentication(
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: requiresWebAuthnUserVerification(context),
|
||||
expectedRPID: scope.rpId,
|
||||
requireUserVerification: requiresWebAuthnUserVerification(context, scope),
|
||||
credential: {
|
||||
id: credential.credentialId,
|
||||
...toCredentialDescriptor(credential),
|
||||
publicKey: publicKeyUint8Array,
|
||||
counter: Number(credential.counter),
|
||||
transports: credential.transports
|
||||
? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (_error) {
|
||||
@@ -434,31 +540,25 @@ export async function verifyWebAuthnAuthentication(
|
||||
}
|
||||
await users.updateWebAuthnCredentialCounter(userId, credentialId, newCounter);
|
||||
await users.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
return credential;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnOptionsForSudo(ctx: ApiContext, userId: UserID) {
|
||||
const {users, config} = ctx.services;
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
if (credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.auth.passkeys.rpId,
|
||||
allowCredentials: credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
userVerification: 'discouraged',
|
||||
export async function generateWebAuthnOptionsForSudo(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const credentials = await ctx.services.users.listWebAuthnCredentials(userId);
|
||||
return generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: selectCredentialRpOrThrow(ctx, origin, credentials),
|
||||
context: 'sudo',
|
||||
userId,
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'sudo', userId});
|
||||
return options;
|
||||
}
|
||||
|
||||
const SUDO_MFA_USER_MAX_ATTEMPTS = 10;
|
||||
|
||||
async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
export async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {rateLimit} = ctx.services;
|
||||
const userLimit = await rateLimit.checkLimit({
|
||||
identifier: `sudo-mfa:user:${userId}`,
|
||||
@@ -535,20 +635,33 @@ function webAuthnChallengeCacheKey(challenge: string): string {
|
||||
return `webauthn:challenge:${challenge}`;
|
||||
}
|
||||
|
||||
function requiresWebAuthnUserVerification(context: WebAuthnChallengeContext): boolean {
|
||||
return context === 'discoverable';
|
||||
function requiresWebAuthnUserVerification(context: WebAuthnChallengeContext, scope: WebAuthnChallengeScope): boolean {
|
||||
return context === 'discoverable' || (context === 'bridge' && scope.credentialIds === null);
|
||||
}
|
||||
|
||||
async function saveWebAuthnChallenge(
|
||||
ctx: ApiContext,
|
||||
challenge: string,
|
||||
entry: {context: WebAuthnChallengeContext; userId?: UserID; ticket?: string},
|
||||
entry: {
|
||||
context: WebAuthnChallengeContext;
|
||||
userId?: UserID;
|
||||
ticket?: string;
|
||||
rpId: string;
|
||||
credentialIds: Array<string> | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
await ctx.services.cache.set(
|
||||
webAuthnChallengeCacheKey(challenge),
|
||||
{context: entry.context, userId: entry.userId?.toString(), ticket: entry.ticket},
|
||||
seconds('5 minutes'),
|
||||
);
|
||||
const value: WebAuthnChallengeEntry = {
|
||||
context: entry.context,
|
||||
userId: entry.userId?.toString(),
|
||||
ticket: entry.ticket,
|
||||
rpId: entry.rpId,
|
||||
credentialIds: entry.credentialIds,
|
||||
};
|
||||
await ctx.services.cache.set(webAuthnChallengeCacheKey(challenge), value, seconds('5 minutes'));
|
||||
}
|
||||
|
||||
export async function deleteWebAuthnChallenge(ctx: ApiContext, challenge: string): Promise<void> {
|
||||
await ctx.services.cache.delete(webAuthnChallengeCacheKey(challenge));
|
||||
}
|
||||
|
||||
async function consumeWebAuthnChallenge(
|
||||
@@ -556,10 +669,8 @@ async function consumeWebAuthnChallenge(
|
||||
challenge: string,
|
||||
expectedContext: WebAuthnChallengeContext,
|
||||
{userId, ticket}: {userId?: UserID; ticket?: string} = {},
|
||||
): Promise<void> {
|
||||
const {cache} = ctx.services;
|
||||
const key = webAuthnChallengeCacheKey(challenge);
|
||||
const cached = await cache.get<{context: WebAuthnChallengeContext; userId?: string; ticket?: string}>(key);
|
||||
): Promise<WebAuthnChallengeScope> {
|
||||
const cached = await ctx.services.cache.getAndDelete<WebAuthnChallengeEntry>(webAuthnChallengeCacheKey(challenge));
|
||||
const challengeMatches =
|
||||
cached &&
|
||||
cached.context === expectedContext &&
|
||||
@@ -581,11 +692,14 @@ async function consumeWebAuthnChallenge(
|
||||
);
|
||||
throw createChallengeError(expectedContext);
|
||||
}
|
||||
await cache.delete(key);
|
||||
return {
|
||||
rpId: cached.rpId ?? ctx.services.config.auth.passkeys.rpId,
|
||||
credentialIds: cached.credentialIds ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function createChallengeError(context: WebAuthnChallengeContext) {
|
||||
if (context === 'registration') {
|
||||
if (context === 'registration' || context === 'migration_registration') {
|
||||
return new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
return new PasskeyAuthenticationFailedError();
|
||||
|
||||
@@ -8,12 +8,19 @@ import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthPassword from '@app/api/auth/AuthPassword';
|
||||
import * as AuthRegistration from '@app/api/auth/AuthRegistration';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {getTokenIdHash} from '@app/api/auth/AuthUtility';
|
||||
import type {DesktopHandoffService} from '@app/api/auth/services/DesktopHandoffService';
|
||||
import type {SsoService} from '@app/api/auth/services/SsoService';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {
|
||||
classifyWebPushOrigin,
|
||||
encodePushSessionIdHash,
|
||||
recordPushSessionPredecessor,
|
||||
} from '@app/api/user/services/WebPushOriginReplacement';
|
||||
import {mapUserToPartialResponse} from '@app/api/user/UserMappers';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {parseJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
|
||||
@@ -91,6 +98,7 @@ interface AuthHandoffCompleteRequest {
|
||||
data: HandoffCompleteRequest;
|
||||
clientIp: string;
|
||||
authToken?: string;
|
||||
approverOrigin?: string | null;
|
||||
}
|
||||
|
||||
interface AuthAuthorizeIpRequest {
|
||||
@@ -272,21 +280,18 @@ export class AuthRequestService {
|
||||
return {completed: false};
|
||||
}
|
||||
|
||||
async getWebAuthnAuthenticationOptions() {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsDiscoverable(this.apiContext);
|
||||
async getWebAuthnAuthenticationOptions(origin: string | undefined) {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsDiscoverable(this.apiContext, origin);
|
||||
}
|
||||
|
||||
async authenticateWebAuthnDiscoverable({data, request}: AuthWebAuthnAuthenticateRequest) {
|
||||
const user = await AuthMfa.verifyWebAuthnAuthenticationDiscoverable(this.apiContext, data.response, data.challenge);
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
|
||||
});
|
||||
const [token] = await AuthLogin.createLoginSession(this.apiContext, user, request);
|
||||
return {token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
|
||||
}
|
||||
|
||||
async getWebAuthnMfaOptions({ticket}: MfaTicketRequest) {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsForMfa(this.apiContext, ticket);
|
||||
async getWebAuthnMfaOptions({ticket}: MfaTicketRequest, origin: string | undefined) {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsForMfa(this.apiContext, ticket, origin);
|
||||
}
|
||||
|
||||
async loginMfaWebAuthn({data, request}: AuthWebAuthnMfaRequest): Promise<AuthTokenWithUserIdResponse> {
|
||||
@@ -305,7 +310,10 @@ export class AuthRequestService {
|
||||
|
||||
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
|
||||
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
|
||||
const result = await this.desktopHandoffService.initiateHandoff({origin});
|
||||
const result = await this.desktopHandoffService.initiateHandoff({
|
||||
origin,
|
||||
initiatorOrigin: request.headers.get('origin'),
|
||||
});
|
||||
return {
|
||||
code: result.code,
|
||||
expires_at: result.expiresAt.toISOString(),
|
||||
@@ -340,21 +348,53 @@ export class AuthRequestService {
|
||||
};
|
||||
}
|
||||
|
||||
async completeHandoff({data, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
async completeHandoff({data, clientIp, authToken, approverOrigin}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
const sessionToken = data.token ?? authToken;
|
||||
if (!sessionToken) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
await this.desktopHandoffService.completeHandoff(
|
||||
let createdToken: string | null = null;
|
||||
const {initiatorOrigin} = await this.desktopHandoffService.completeHandoff(
|
||||
data.code,
|
||||
(origin) =>
|
||||
AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
|
||||
async (origin) => {
|
||||
const created = await AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
|
||||
token: sessionToken,
|
||||
expectedUserId: data.user_id,
|
||||
origin,
|
||||
}),
|
||||
});
|
||||
createdToken = created.token;
|
||||
return created;
|
||||
},
|
||||
clientIp,
|
||||
);
|
||||
if (createdToken !== null) {
|
||||
await this.recordPushSessionPredecessor(createdToken, sessionToken, initiatorOrigin, approverOrigin);
|
||||
}
|
||||
}
|
||||
|
||||
private async recordPushSessionPredecessor(
|
||||
createdToken: string,
|
||||
approverToken: string,
|
||||
initiatorOrigin: string | null,
|
||||
approverOrigin: string | null | undefined,
|
||||
): Promise<void> {
|
||||
const {config, kv} = this.apiContext.services;
|
||||
const {selfHosted} = config.instance;
|
||||
if (
|
||||
classifyWebPushOrigin(initiatorOrigin, selfHosted) !== 'target' ||
|
||||
classifyWebPushOrigin(approverOrigin, selfHosted) !== 'legacy'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await recordPushSessionPredecessor(
|
||||
kv,
|
||||
encodePushSessionIdHash(getTokenIdHash(this.apiContext, createdToken)),
|
||||
encodePushSessionIdHash(getTokenIdHash(this.apiContext, approverToken)),
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to record the push session predecessor');
|
||||
}
|
||||
}
|
||||
|
||||
async getHandoffStatus({code, clientIp, pollSecret}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
|
||||
|
||||
@@ -43,7 +43,6 @@ async function revokeSessionTargets(
|
||||
scope === 'all'
|
||||
? users.deleteAllPushSubscriptions(userId)
|
||||
: users.deletePushSubscriptionsForAuthSessions(userId, sessionIdHashes, {deleteUnboundSubscriptions: true}),
|
||||
() => gateway.invalidatePushSubscriptions({userId}),
|
||||
];
|
||||
if (scope === 'selected' || targets.length > 0) {
|
||||
steps.push(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createOriginHandoff, redeemOriginHandoff} from '@app/api/auth/services/OriginHandoffService';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {FileSizeTooLargeError} from '@fluxer/errors/src/domains/core/FileSizeTooLargeError';
|
||||
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
|
||||
import {
|
||||
ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH,
|
||||
OriginHandoffCreateRequest,
|
||||
OriginHandoffCreateResponse,
|
||||
OriginHandoffRedeemRequest,
|
||||
OriginHandoffRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/OriginHandoffSchemas';
|
||||
import {bodyLimit} from 'hono/body-limit';
|
||||
|
||||
const ORIGIN_HANDOFF_CREATE_MAX_BODY_BYTES = ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH + 1024;
|
||||
|
||||
export function OriginHandoffController(app: HonoApp) {
|
||||
app.post(
|
||||
'/auth/origin-handoff',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_ORIGIN_HANDOFF_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
bodyLimit({
|
||||
maxSize: ORIGIN_HANDOFF_CREATE_MAX_BODY_BYTES,
|
||||
onError: () => {
|
||||
throw new FileSizeTooLargeError(ORIGIN_HANDOFF_CREATE_MAX_BODY_BYTES);
|
||||
},
|
||||
}),
|
||||
Validator('json', OriginHandoffCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_origin_handoff',
|
||||
summary: 'Create origin handoff',
|
||||
responseSchema: OriginHandoffCreateResponse,
|
||||
statusCode: 200,
|
||||
security: ['sessionToken'],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Store encrypted client state for up to two minutes so another first-party web origin can redeem it once. The receiving origin must present the nonce whose SHA-256 digest is sent here.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const body = ctx.req.valid('json');
|
||||
const handoffId = await createOriginHandoff(ctx.get('cacheService'), {
|
||||
userId: ctx.get('user').id,
|
||||
nonceHash: body.nonce_hash,
|
||||
payload: body.payload,
|
||||
});
|
||||
const response: OriginHandoffCreateResponse = {handoff_id: handoffId};
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/origin-handoff/redeem',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_ORIGIN_HANDOFF_REDEEM),
|
||||
Validator('json', OriginHandoffRedeemRequest),
|
||||
OpenAPI({
|
||||
operationId: 'redeem_origin_handoff',
|
||||
summary: 'Redeem origin handoff',
|
||||
responseSchema: OriginHandoffRedeemResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Return the encrypted client state stored by create origin handoff and delete it in the same step. A wrong nonce also consumes the handoff. On the official instance the request must come from a first-party web origin.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (!Config.instance.selfHosted) {
|
||||
const origin = ctx.req.header('origin');
|
||||
if (origin === undefined || !Config.endpoints.webAppOrigins.includes(origin)) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
}
|
||||
const body = ctx.req.valid('json');
|
||||
const payload = await redeemOriginHandoff(ctx.get('cacheService'), {
|
||||
handoffId: body.handoff_id,
|
||||
nonce: body.nonce,
|
||||
});
|
||||
const response: OriginHandoffRedeemResponse = {payload};
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
cancelPasskeyBridge,
|
||||
completePasskeyBridge,
|
||||
getPasskeyBridgeOptions,
|
||||
redeemPasskeyBridgeLogin,
|
||||
redeemPasskeyBridgeSudo,
|
||||
startPasskeyBridgeLogin,
|
||||
startPasskeyBridgeSudo,
|
||||
} from '@app/api/auth/services/PasskeyBridgeService';
|
||||
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {LocalAuthMiddleware} from '@app/api/middleware/LocalAuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {
|
||||
PasskeyBridgeCeremonyIdParam,
|
||||
PasskeyBridgeCompleteRequest,
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeLoginRedeemResponse,
|
||||
PasskeyBridgeLoginStartRequest,
|
||||
PasskeyBridgeOptionsResponse,
|
||||
PasskeyBridgeRedeemRequest,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
PasskeyBridgeSudoStartRequest,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
|
||||
export function PasskeyBridgeController(app: HonoApp) {
|
||||
app.post(
|
||||
'/auth/passkey-bridge',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_START),
|
||||
Validator('json', PasskeyBridgeLoginStartRequest),
|
||||
OpenAPI({
|
||||
operationId: 'start_passkey_bridge_login',
|
||||
summary: 'Start passkey bridge sign in',
|
||||
responseSchema: PasskeyBridgeStartResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Start a sign in or two-factor ceremony for a passkey that belongs to the paired first-party origin. Only available on the official instance from the new origin.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await startPasskeyBridgeLogin(ctx.get('apiContext'), ctx.req.header('origin'), ctx.req.valid('json')),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/passkey-bridge',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PASSKEY_BRIDGE_START),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', PasskeyBridgeSudoStartRequest),
|
||||
OpenAPI({
|
||||
operationId: 'start_passkey_bridge_sudo',
|
||||
summary: 'Start passkey bridge sudo verification',
|
||||
responseSchema: PasskeyBridgeStartResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Start a sudo verification ceremony for a passkey that belongs to the paired first-party origin. Only available on the official instance from the new origin.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await startPasskeyBridgeSudo(
|
||||
ctx.get('apiContext'),
|
||||
ctx.req.header('origin'),
|
||||
ctx.get('user').id,
|
||||
ctx.req.valid('json'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/passkey-bridge/:ceremony_id/options',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_CEREMONY),
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_passkey_bridge_options',
|
||||
summary: 'Get passkey bridge options',
|
||||
responseSchema: PasskeyBridgeOptionsResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Issue WebAuthn authentication options for a pending passkey bridge ceremony. The request must come from the origin that runs the ceremony.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(await getPasskeyBridgeOptions(ctx.get('apiContext'), ceremony_id, ctx.req.header('origin')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/passkey-bridge/:ceremony_id/complete',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_CEREMONY),
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
Validator('json', PasskeyBridgeCompleteRequest),
|
||||
OpenAPI({
|
||||
operationId: 'complete_passkey_bridge',
|
||||
summary: 'Complete passkey bridge',
|
||||
responseSchema: PasskeyBridgeFinishResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Verify the WebAuthn response for a pending passkey bridge ceremony. A failed verification leaves the ceremony pending so it can be retried.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await completePasskeyBridge(
|
||||
ctx.get('apiContext'),
|
||||
ceremony_id,
|
||||
ctx.req.header('origin'),
|
||||
ctx.req.valid('json'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/passkey-bridge/:ceremony_id/cancel',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_CEREMONY),
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'cancel_passkey_bridge',
|
||||
summary: 'Cancel passkey bridge',
|
||||
responseSchema: PasskeyBridgeFinishResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description: 'Cancel a passkey bridge ceremony that has not completed.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(await cancelPasskeyBridge(ctx.get('apiContext'), ceremony_id, ctx.req.header('origin')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/passkey-bridge/:ceremony_id/redeem',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_REDEEM),
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
Validator('json', PasskeyBridgeRedeemRequest),
|
||||
OpenAPI({
|
||||
operationId: 'redeem_passkey_bridge_login',
|
||||
summary: 'Redeem passkey bridge sign in',
|
||||
responseSchema: PasskeyBridgeLoginRedeemResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Redeem a finished sign in or two-factor passkey bridge ceremony once. Requires the nonce kept by the starting page and the completion code handed back when the ceremony finished.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await redeemPasskeyBridgeLogin(
|
||||
ctx.get('apiContext'),
|
||||
ceremony_id,
|
||||
ctx.req.header('origin'),
|
||||
ctx.req.valid('json'),
|
||||
ctx.req.raw,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/passkey-bridge/:ceremony_id/redeem',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PASSKEY_BRIDGE_REDEEM),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
Validator('json', PasskeyBridgeRedeemRequest),
|
||||
OpenAPI({
|
||||
operationId: 'redeem_passkey_bridge_sudo',
|
||||
summary: 'Redeem passkey bridge sudo verification',
|
||||
responseSchema: PasskeyBridgeSudoRedeemResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Redeem a finished sudo passkey bridge ceremony once for a sudo mode token. Requires the nonce kept by the starting page and the completion code handed back when the ceremony finished.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await redeemPasskeyBridgeSudo(
|
||||
ctx.get('apiContext'),
|
||||
ceremony_id,
|
||||
ctx.req.header('origin'),
|
||||
ctx.req.valid('json'),
|
||||
ctx.get('user').id,
|
||||
ctx.get('authSession'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const POLL_SECRET_BYTES = 32;
|
||||
interface HandoffData {
|
||||
createdAt: number;
|
||||
origin: SessionOrigin;
|
||||
initiatorOrigin?: string | null;
|
||||
infoLookupCount: number;
|
||||
pollSecretHash: string;
|
||||
}
|
||||
@@ -84,7 +85,7 @@ function pollSecretMatches(presented: string | undefined, storedHash: string | u
|
||||
export class DesktopHandoffService {
|
||||
constructor(private readonly apiContext: ApiContext) {}
|
||||
|
||||
async initiateHandoff(args: {origin: SessionOrigin}): Promise<{
|
||||
async initiateHandoff(args: {origin: SessionOrigin; initiatorOrigin?: string | null}): Promise<{
|
||||
code: string;
|
||||
expiresAt: Date;
|
||||
pollSecret: string;
|
||||
@@ -95,6 +96,7 @@ export class DesktopHandoffService {
|
||||
const handoffData: HandoffData = {
|
||||
createdAt: Date.now(),
|
||||
origin: args.origin,
|
||||
initiatorOrigin: args.initiatorOrigin ?? null,
|
||||
infoLookupCount: 0,
|
||||
pollSecretHash: hashPollSecret(pollSecret),
|
||||
};
|
||||
@@ -108,7 +110,7 @@ export class DesktopHandoffService {
|
||||
code: string,
|
||||
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
|
||||
approverIp: string,
|
||||
): Promise<void> {
|
||||
): Promise<{initiatorOrigin: string | null}> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = requireNormalizedHandoffCode(code);
|
||||
await this.checkAttemptLimit(approverIp);
|
||||
@@ -138,6 +140,7 @@ export class DesktopHandoffService {
|
||||
await cache.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
|
||||
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
|
||||
await cache.delete(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
|
||||
return {initiatorOrigin: handoffData.initiatorOrigin ?? null};
|
||||
}
|
||||
|
||||
async getHandoffInfo(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import {InvalidOriginHandoffNonceError} from '@fluxer/errors/src/domains/auth/InvalidOriginHandoffNonceError';
|
||||
import {UnknownOriginHandoffError} from '@fluxer/errors/src/domains/auth/UnknownOriginHandoffError';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
const ORIGIN_HANDOFF_KEY_PREFIX = 'origin_handoff:';
|
||||
const ORIGIN_HANDOFF_ID_BYTES = 32;
|
||||
|
||||
interface OriginHandoffRecord {
|
||||
nonce_hash: string;
|
||||
payload: string;
|
||||
user_id: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function originHandoffKey(handoffId: string): string {
|
||||
return `${ORIGIN_HANDOFF_KEY_PREFIX}${sha256Hex(handoffId)}`;
|
||||
}
|
||||
|
||||
export async function createOriginHandoff(
|
||||
cache: ICacheService,
|
||||
args: {userId: UserID; nonceHash: string; payload: string},
|
||||
): Promise<string> {
|
||||
const handoffId = randomBytes(ORIGIN_HANDOFF_ID_BYTES).toString('base64url');
|
||||
const record: OriginHandoffRecord = {
|
||||
nonce_hash: args.nonceHash,
|
||||
payload: args.payload,
|
||||
user_id: args.userId.toString(),
|
||||
created_at: Date.now(),
|
||||
};
|
||||
await cache.set(originHandoffKey(handoffId), record, seconds('2 minutes'));
|
||||
return handoffId;
|
||||
}
|
||||
|
||||
export async function redeemOriginHandoff(
|
||||
cache: ICacheService,
|
||||
args: {handoffId: string; nonce: string},
|
||||
): Promise<string> {
|
||||
const record = await cache.getAndDelete<OriginHandoffRecord>(originHandoffKey(args.handoffId));
|
||||
if (!record) {
|
||||
throw new UnknownOriginHandoffError();
|
||||
}
|
||||
const presented = Buffer.from(sha256Hex(args.nonce), 'hex');
|
||||
const stored = Buffer.from(record.nonce_hash, 'hex');
|
||||
if (presented.length !== stored.length || !timingSafeEqual(presented, stored)) {
|
||||
throw new InvalidOriginHandoffNonceError();
|
||||
}
|
||||
return record.payload;
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthLogin from '@app/api/auth/AuthLogin';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {recordPendingPasskeyMigration} from '@app/api/auth/services/PasskeyMigrationService';
|
||||
import {
|
||||
effectiveRpId,
|
||||
isPasskeyMigrationActive,
|
||||
isPasskeyTargetOrigin,
|
||||
passkeyLegacyOriginFor,
|
||||
visibleWebAuthnCredentials,
|
||||
} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import {getSudoModeService} from '@app/api/auth/services/SudoModeService';
|
||||
import {resolveWebAuthnSecondFactor} from '@app/api/auth/services/WebAuthnSecondFactor';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {AuthSession} from '@app/api/models/AuthSession';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {mapUserToPartialResponse} from '@app/api/user/UserMappers';
|
||||
import {PASSKEY_BRIDGE_PATH, PASSKEY_BRIDGE_RETURN_FRAGMENT_KEY} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InvalidPasskeyBridgeNonceError} from '@fluxer/errors/src/domains/auth/InvalidPasskeyBridgeNonceError';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import {NoPasskeysRegisteredError} from '@fluxer/errors/src/domains/auth/NoPasskeysRegisteredError';
|
||||
import {PasskeyAuthenticationFailedError} from '@fluxer/errors/src/domains/auth/PasskeyAuthenticationFailedError';
|
||||
import {UnknownPasskeyBridgeError} from '@fluxer/errors/src/domains/auth/UnknownPasskeyBridgeError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
|
||||
import type {
|
||||
PasskeyBridgeCompleteRequest,
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeLoginRedeemResponse,
|
||||
PasskeyBridgeLoginStartRequest,
|
||||
PasskeyBridgeRedeemRequest,
|
||||
PasskeyBridgeRunner,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
PasskeyBridgeSudoStartRequest,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
import type {PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/server';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
|
||||
type PasskeyBridgePurpose = 'login' | 'login_mfa' | 'sudo';
|
||||
|
||||
interface PasskeyBridgeRecord {
|
||||
purpose: PasskeyBridgePurpose;
|
||||
runner: PasskeyBridgeRunner;
|
||||
target_origin: string;
|
||||
ceremony_origin: string;
|
||||
nonce_hash: string;
|
||||
user_id: string | null;
|
||||
ticket: string | null;
|
||||
challenge: string | null;
|
||||
credential_id: string | null;
|
||||
cross_device: boolean;
|
||||
completion_code_hash: string | null;
|
||||
status: 'pending' | 'completed' | 'cancelled';
|
||||
created_at: number;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
interface CompletedPasskeyBridge {
|
||||
record: PasskeyBridgeRecord;
|
||||
userId: UserID;
|
||||
}
|
||||
|
||||
const PASSKEY_BRIDGE_KEY_PREFIX = 'passkey_bridge:';
|
||||
const PASSKEY_BRIDGE_LOCK_PREFIX = 'passkey_bridge_lock:';
|
||||
const PASSKEY_BRIDGE_SECRET_BYTES = 32;
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function hashMatches(value: string, storedHash: string | null): boolean {
|
||||
if (storedHash === null) return false;
|
||||
const presented = Buffer.from(sha256Hex(value), 'hex');
|
||||
const stored = Buffer.from(storedHash, 'hex');
|
||||
return presented.length === stored.length && timingSafeEqual(presented, stored);
|
||||
}
|
||||
|
||||
function createSecret(): string {
|
||||
return randomBytes(PASSKEY_BRIDGE_SECRET_BYTES).toString('base64url');
|
||||
}
|
||||
|
||||
function passkeyBridgeKey(ceremonyId: string): string {
|
||||
return `${PASSKEY_BRIDGE_KEY_PREFIX}${sha256Hex(ceremonyId)}`;
|
||||
}
|
||||
|
||||
async function writeRecord(ctx: ApiContext, ceremonyId: string, record: PasskeyBridgeRecord): Promise<void> {
|
||||
const ttlSeconds = Math.floor((record.expires_at - Date.now()) / 1000);
|
||||
if (ttlSeconds <= 0) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
await ctx.services.cache.set(passkeyBridgeKey(ceremonyId), record, ttlSeconds);
|
||||
}
|
||||
|
||||
function assertCeremonyOrigin(
|
||||
ctx: ApiContext,
|
||||
record: PasskeyBridgeRecord,
|
||||
origin: string | undefined,
|
||||
expectedOrigin: string,
|
||||
): void {
|
||||
if (origin !== expectedOrigin || !isPasskeyTargetOrigin(ctx, record.target_origin)) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateRecord<T>(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
mutate: (record: PasskeyBridgeRecord) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const {cache} = ctx.services;
|
||||
const lockKey = `${PASSKEY_BRIDGE_LOCK_PREFIX}${sha256Hex(ceremonyId)}`;
|
||||
const lockToken = await cache.acquireLock(lockKey, seconds('10 seconds'));
|
||||
if (!lockToken) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
try {
|
||||
const record = await cache.get<PasskeyBridgeRecord>(passkeyBridgeKey(ceremonyId));
|
||||
if (!record) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
assertCeremonyOrigin(ctx, record, origin, record.ceremony_origin);
|
||||
return await mutate(record);
|
||||
} finally {
|
||||
await cache.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
async function requireMfaTicketUser(ctx: ApiContext, ticket: string, expectedUserId?: string): Promise<User> {
|
||||
const userId = await ctx.services.cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId || (expectedUserId !== undefined && userId !== expectedUserId)) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
}
|
||||
const user = await ctx.services.users.findUniqueAssert(createUserID(BigInt(userId)));
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function requireLegacyCredentials(ctx: ApiContext, userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
const legacyRpId = ctx.services.config.auth.passkeys.rpId;
|
||||
const credentials = visibleWebAuthnCredentials(await ctx.services.users.listWebAuthnCredentials(userId)).filter(
|
||||
(credential) => effectiveRpId(ctx, credential) === legacyRpId,
|
||||
);
|
||||
if (credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
function assertBridgeStartOrigin(ctx: ApiContext, origin: string | undefined): string {
|
||||
if (!origin || !isPasskeyTargetOrigin(ctx, origin)) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
async function startPasskeyBridge(
|
||||
ctx: ApiContext,
|
||||
origin: string,
|
||||
fields: Pick<PasskeyBridgeRecord, 'purpose' | 'runner' | 'nonce_hash' | 'user_id' | 'ticket'>,
|
||||
): Promise<PasskeyBridgeStartResponse> {
|
||||
const ceremonyId = createSecret();
|
||||
const createdAt = Date.now();
|
||||
const ceremonyOrigin = fields.runner === 'page' ? passkeyLegacyOriginFor(origin) : origin;
|
||||
await writeRecord(ctx, ceremonyId, {
|
||||
...fields,
|
||||
target_origin: origin,
|
||||
ceremony_origin: ceremonyOrigin,
|
||||
challenge: null,
|
||||
credential_id: null,
|
||||
cross_device: false,
|
||||
completion_code_hash: null,
|
||||
status: 'pending',
|
||||
created_at: createdAt,
|
||||
expires_at: createdAt + (fields.purpose === 'login_mfa' ? ms('5 minutes') : ms('10 minutes')),
|
||||
});
|
||||
return {
|
||||
ceremony_id: ceremonyId,
|
||||
bridge_url: fields.runner === 'page' ? `${ceremonyOrigin}${PASSKEY_BRIDGE_PATH}#${ceremonyId}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startPasskeyBridgeLogin(
|
||||
ctx: ApiContext,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeLoginStartRequest,
|
||||
): Promise<PasskeyBridgeStartResponse> {
|
||||
const targetOrigin = assertBridgeStartOrigin(ctx, origin);
|
||||
let userId: string | null = null;
|
||||
if (data.purpose === 'login_mfa') {
|
||||
const user = await requireMfaTicketUser(ctx, data.ticket!);
|
||||
if (!(await resolveWebAuthnSecondFactor(ctx, user))) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
await requireLegacyCredentials(ctx, user.id);
|
||||
userId = user.id.toString();
|
||||
}
|
||||
return startPasskeyBridge(ctx, targetOrigin, {
|
||||
purpose: data.purpose,
|
||||
runner: data.runner,
|
||||
nonce_hash: data.nonce_hash,
|
||||
user_id: userId,
|
||||
ticket: data.ticket ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function startPasskeyBridgeSudo(
|
||||
ctx: ApiContext,
|
||||
origin: string | undefined,
|
||||
userId: UserID,
|
||||
data: PasskeyBridgeSudoStartRequest,
|
||||
): Promise<PasskeyBridgeStartResponse> {
|
||||
const targetOrigin = assertBridgeStartOrigin(ctx, origin);
|
||||
await requireLegacyCredentials(ctx, userId);
|
||||
return startPasskeyBridge(ctx, targetOrigin, {
|
||||
purpose: 'sudo',
|
||||
runner: data.runner,
|
||||
nonce_hash: data.nonce_hash,
|
||||
user_id: userId.toString(),
|
||||
ticket: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPasskeyBridgeOptions(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
): Promise<{options: PublicKeyCredentialRequestOptionsJSON}> {
|
||||
return mutateRecord(ctx, ceremonyId, origin, async (record) => {
|
||||
if (record.status !== 'pending') {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
const legacyRpId = ctx.services.config.auth.passkeys.rpId;
|
||||
const userId = record.user_id === null ? undefined : createUserID(BigInt(record.user_id));
|
||||
const options = await AuthMfa.generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: {
|
||||
rpId: legacyRpId,
|
||||
credentials: userId === undefined ? null : await requireLegacyCredentials(ctx, userId),
|
||||
},
|
||||
context: 'bridge',
|
||||
userId,
|
||||
});
|
||||
if (record.challenge !== null) {
|
||||
await AuthMfa.deleteWebAuthnChallenge(ctx, record.challenge);
|
||||
}
|
||||
await writeRecord(ctx, ceremonyId, {...record, challenge: options.challenge});
|
||||
return {options};
|
||||
});
|
||||
}
|
||||
|
||||
function buildReturnUrl(record: PasskeyBridgeRecord, ceremonyId: string, completionCode: string): string {
|
||||
return `${record.target_origin}${PASSKEY_BRIDGE_PATH}#${PASSKEY_BRIDGE_RETURN_FRAGMENT_KEY}=${ceremonyId}.${completionCode}`;
|
||||
}
|
||||
|
||||
async function finishRecord(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
record: PasskeyBridgeRecord,
|
||||
): Promise<PasskeyBridgeFinishResponse> {
|
||||
const completionCode = createSecret();
|
||||
await writeRecord(ctx, ceremonyId, {...record, completion_code_hash: sha256Hex(completionCode)});
|
||||
if (record.runner === 'native') {
|
||||
return {return_url: null, completion_code: completionCode};
|
||||
}
|
||||
return {return_url: buildReturnUrl(record, ceremonyId, completionCode), completion_code: null};
|
||||
}
|
||||
|
||||
export async function completePasskeyBridge(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeCompleteRequest,
|
||||
): Promise<PasskeyBridgeFinishResponse> {
|
||||
return mutateRecord(ctx, ceremonyId, origin, async (record) => {
|
||||
if (record.status !== 'pending') {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
const {users} = ctx.services;
|
||||
const credentialId = data.response.id;
|
||||
const userId =
|
||||
record.user_id === null
|
||||
? await users.getUserIdByCredentialId(credentialId)
|
||||
: createUserID(BigInt(record.user_id));
|
||||
const credential = userId === null ? null : await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (
|
||||
userId === null ||
|
||||
record.challenge === null ||
|
||||
credential === null ||
|
||||
credential.supersededBy !== null ||
|
||||
effectiveRpId(ctx, credential) !== ctx.services.config.auth.passkeys.rpId
|
||||
) {
|
||||
throw new PasskeyAuthenticationFailedError();
|
||||
}
|
||||
if (record.purpose === 'login_mfa') {
|
||||
await requireMfaTicketUser(ctx, record.ticket!, record.user_id!);
|
||||
await AuthLogin.consumeMfaAttempt(ctx, {userId: record.user_id!, ticket: record.ticket!, field: 'ticket'});
|
||||
} else if (record.purpose === 'sudo') {
|
||||
await AuthMfa.consumeSudoMfaAttempt(ctx, userId);
|
||||
}
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, userId, data.response, record.challenge, 'bridge', undefined, [
|
||||
record.ceremony_origin,
|
||||
]);
|
||||
return finishRecord(ctx, ceremonyId, {
|
||||
...record,
|
||||
status: 'completed',
|
||||
user_id: userId.toString(),
|
||||
credential_id: credentialId,
|
||||
cross_device: data.response.authenticatorAttachment === 'cross-platform',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelPasskeyBridge(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
): Promise<PasskeyBridgeFinishResponse> {
|
||||
return mutateRecord(ctx, ceremonyId, origin, async (record) => {
|
||||
if (record.status === 'completed') {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
return finishRecord(ctx, ceremonyId, {...record, status: 'cancelled'});
|
||||
});
|
||||
}
|
||||
|
||||
function assertRedeemable(
|
||||
record: PasskeyBridgeRecord | null,
|
||||
purposes: ReadonlyArray<PasskeyBridgePurpose>,
|
||||
expectedUserId: UserID | null,
|
||||
): asserts record is PasskeyBridgeRecord {
|
||||
if (
|
||||
!record ||
|
||||
!purposes.includes(record.purpose) ||
|
||||
(expectedUserId !== null && record.user_id !== expectedUserId.toString()) ||
|
||||
record.status === 'pending'
|
||||
) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
}
|
||||
|
||||
async function redeemPasskeyBridge(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeRedeemRequest,
|
||||
purposes: ReadonlyArray<PasskeyBridgePurpose>,
|
||||
expectedUserId: UserID | null,
|
||||
): Promise<CompletedPasskeyBridge | null> {
|
||||
const {cache} = ctx.services;
|
||||
const key = passkeyBridgeKey(ceremonyId);
|
||||
const record = await cache.get<PasskeyBridgeRecord>(key);
|
||||
if (!record) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
assertCeremonyOrigin(ctx, record, origin, record.target_origin);
|
||||
assertRedeemable(record, purposes, expectedUserId);
|
||||
if (!hashMatches(data.nonce, record.nonce_hash) || !hashMatches(data.completion_code, record.completion_code_hash)) {
|
||||
await cache.delete(key);
|
||||
throw new InvalidPasskeyBridgeNonceError();
|
||||
}
|
||||
const taken = await cache.getAndDelete<PasskeyBridgeRecord>(key);
|
||||
assertRedeemable(taken, purposes, expectedUserId);
|
||||
if (!hashMatches(data.nonce, taken.nonce_hash) || !hashMatches(data.completion_code, taken.completion_code_hash)) {
|
||||
throw new InvalidPasskeyBridgeNonceError();
|
||||
}
|
||||
if (taken.status === 'cancelled') {
|
||||
return null;
|
||||
}
|
||||
return {record: taken, userId: createUserID(BigInt(taken.user_id!))};
|
||||
}
|
||||
|
||||
async function recordMigrationIfActive(
|
||||
ctx: ApiContext,
|
||||
origin: string | undefined,
|
||||
completed: CompletedPasskeyBridge,
|
||||
authSession: AuthSession | undefined,
|
||||
): Promise<void> {
|
||||
if (!authSession || !(await isPasskeyMigrationActive(ctx, origin))) return;
|
||||
await recordPendingPasskeyMigration(ctx, authSession, {
|
||||
user_id: completed.userId.toString(),
|
||||
credential_id: completed.record.credential_id!,
|
||||
cross_device: completed.record.cross_device,
|
||||
});
|
||||
}
|
||||
|
||||
export async function redeemPasskeyBridgeLogin(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeRedeemRequest,
|
||||
request: Request,
|
||||
): Promise<PasskeyBridgeLoginRedeemResponse> {
|
||||
const completed = await redeemPasskeyBridge(ctx, ceremonyId, origin, data, ['login', 'login_mfa'], null);
|
||||
if (!completed) {
|
||||
return {status: 'cancelled'};
|
||||
}
|
||||
let token: string;
|
||||
let authSession: AuthSession;
|
||||
let user: User;
|
||||
if (completed.record.purpose === 'login_mfa') {
|
||||
user = await requireMfaTicketUser(ctx, completed.record.ticket!, completed.record.user_id!);
|
||||
if (!(await resolveWebAuthnSecondFactor(ctx, user))) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
[token, authSession] = await AuthLogin.completeMfaLogin(ctx, user, completed.record.ticket!, request);
|
||||
} else {
|
||||
user = await ctx.services.users.findUniqueAssert(completed.userId);
|
||||
[token, authSession] = await AuthLogin.createLoginSession(ctx, user, request);
|
||||
}
|
||||
await recordMigrationIfActive(ctx, origin, completed, authSession);
|
||||
return {status: 'completed', token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
|
||||
}
|
||||
|
||||
export async function redeemPasskeyBridgeSudo(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeRedeemRequest,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
): Promise<PasskeyBridgeSudoRedeemResponse> {
|
||||
const completed = await redeemPasskeyBridge(ctx, ceremonyId, origin, data, ['sudo'], userId);
|
||||
if (!completed) {
|
||||
return {status: 'cancelled'};
|
||||
}
|
||||
const sudoToken = await getSudoModeService().generateSudoToken(userId);
|
||||
await recordMigrationIfActive(ctx, origin, completed, authSession);
|
||||
return {status: 'completed', sudo_token: sudoToken};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import {
|
||||
effectiveRpId,
|
||||
isPasskeyTargetOrigin,
|
||||
visibleWebAuthnCredentials,
|
||||
} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import type {AuthSession} from '@app/api/models/AuthSession';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import {UnknownPasskeyMigrationError} from '@fluxer/errors/src/domains/auth/UnknownPasskeyMigrationError';
|
||||
import type {
|
||||
PasskeyMigrationCompleteRequest,
|
||||
PasskeyMigrationResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
|
||||
import type {PublicKeyCredentialCreationOptionsJSON} from '@simplewebauthn/server';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
const PASSKEY_MIGRATION_KEY_PREFIX = 'passkey_migration:';
|
||||
|
||||
interface PendingPasskeyMigration {
|
||||
user_id: string;
|
||||
credential_id: string;
|
||||
cross_device: boolean;
|
||||
}
|
||||
|
||||
interface LivePasskeyMigration {
|
||||
key: string;
|
||||
pending: PendingPasskeyMigration;
|
||||
credential: WebAuthnCredential;
|
||||
}
|
||||
|
||||
function passkeyMigrationKey(authSession: AuthSession): string {
|
||||
return `${PASSKEY_MIGRATION_KEY_PREFIX}${authSession.sessionIdHash.toString('base64url')}`;
|
||||
}
|
||||
|
||||
function isLegacyVisibleCredential(ctx: ApiContext, credential: WebAuthnCredential): boolean {
|
||||
return credential.supersededBy === null && effectiveRpId(ctx, credential) === ctx.services.config.auth.passkeys.rpId;
|
||||
}
|
||||
|
||||
export async function recordPendingPasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
authSession: AuthSession,
|
||||
pending: PendingPasskeyMigration,
|
||||
): Promise<void> {
|
||||
await ctx.services.cache.set(passkeyMigrationKey(authSession), pending, seconds('5 minutes'));
|
||||
}
|
||||
|
||||
async function loadLivePasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
): Promise<LivePasskeyMigration | null> {
|
||||
if (!authSession) return null;
|
||||
const {cache, users} = ctx.services;
|
||||
const key = passkeyMigrationKey(authSession);
|
||||
const pending = await cache.get<PendingPasskeyMigration>(key);
|
||||
if (!pending) return null;
|
||||
const credential =
|
||||
pending.user_id === userId.toString() ? await users.getWebAuthnCredential(userId, pending.credential_id) : null;
|
||||
if (credential === null || !isLegacyVisibleCredential(ctx, credential)) {
|
||||
await cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return {key, pending, credential};
|
||||
}
|
||||
|
||||
async function requireLivePasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
origin: string | undefined,
|
||||
): Promise<LivePasskeyMigration> {
|
||||
const live = isPasskeyTargetOrigin(ctx, origin) ? await loadLivePasskeyMigration(ctx, userId, authSession) : null;
|
||||
if (!live) {
|
||||
throw new UnknownPasskeyMigrationError();
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
async function takeLivePasskeyMigration(ctx: ApiContext, userId: UserID, key: string): Promise<WebAuthnCredential> {
|
||||
const pending = await ctx.services.cache.getAndDelete<PendingPasskeyMigration>(key);
|
||||
if (!pending || pending.user_id !== userId.toString()) {
|
||||
throw new UnknownPasskeyMigrationError();
|
||||
}
|
||||
const credential = await ctx.services.users.getWebAuthnCredential(userId, pending.credential_id);
|
||||
if (credential === null || !isLegacyVisibleCredential(ctx, credential)) {
|
||||
throw new UnknownPasskeyMigrationError();
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
function visibleTargetCredentials(ctx: ApiContext, credentials: Array<WebAuthnCredential>): Array<WebAuthnCredential> {
|
||||
return visibleWebAuthnCredentials(credentials).filter(
|
||||
(credential) => effectiveRpId(ctx, credential) === PASSKEY_MIGRATION_RP_ID,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
): Promise<PasskeyMigrationResponse> {
|
||||
const live = await loadLivePasskeyMigration(ctx, userId, authSession);
|
||||
if (!live) return {pending: null};
|
||||
return {
|
||||
pending: {
|
||||
credential_id: live.credential.credentialId,
|
||||
name: live.credential.name,
|
||||
cross_device: live.pending.cross_device,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPasskeyMigrationRegistrationOptions(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
origin: string | undefined,
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const live = await requireLivePasskeyMigration(ctx, userId, authSession, origin);
|
||||
const credentials = await ctx.services.users.listWebAuthnCredentials(userId);
|
||||
const options = await AuthMfa.createWebAuthnRegistrationOptions(ctx, userId, {
|
||||
rpId: PASSKEY_MIGRATION_RP_ID,
|
||||
context: 'migration_registration',
|
||||
excludeCredentials: visibleTargetCredentials(ctx, credentials),
|
||||
});
|
||||
if (live.pending.cross_device) {
|
||||
options.hints = ['hybrid', 'security-key'];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function completePasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
origin: string | undefined,
|
||||
data: PasskeyMigrationCompleteRequest,
|
||||
): Promise<void> {
|
||||
const {users} = ctx.services;
|
||||
const live = await requireLivePasskeyMigration(ctx, userId, authSession, origin);
|
||||
const verified = await AuthMfa.verifyWebAuthnRegistrationResponse(
|
||||
ctx,
|
||||
userId,
|
||||
data.response,
|
||||
data.challenge,
|
||||
'migration_registration',
|
||||
[origin!],
|
||||
);
|
||||
const legacy = await takeLivePasskeyMigration(ctx, userId, live.key);
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
verified.credentialId,
|
||||
verified.publicKey,
|
||||
verified.counter,
|
||||
verified.transports,
|
||||
legacy.name,
|
||||
AuthMfa.storedRpId(ctx, verified.rpId),
|
||||
);
|
||||
await users.setWebAuthnCredentialSupersededBy(userId, legacy.credentialId, verified.credentialId);
|
||||
await AuthMfa.dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
|
||||
const PASSKEY_TARGET_TO_LEGACY_ORIGIN: ReadonlyMap<string, string> = new Map([
|
||||
['https://fluxer.com', 'https://web.fluxer.app'],
|
||||
['https://canary.fluxer.com', 'https://web.canary.fluxer.app'],
|
||||
]);
|
||||
|
||||
export interface CredentialRpSelection {
|
||||
rpId: string;
|
||||
credentials: Array<WebAuthnCredential>;
|
||||
}
|
||||
|
||||
export function isPasskeyTargetOrigin(ctx: ApiContext, origin: string | null | undefined): boolean {
|
||||
if (ctx.services.config.instance.selfHosted || !origin) return false;
|
||||
return PASSKEY_TARGET_TO_LEGACY_ORIGIN.has(origin);
|
||||
}
|
||||
|
||||
export function passkeyLegacyOriginFor(targetOrigin: string): string {
|
||||
return PASSKEY_TARGET_TO_LEGACY_ORIGIN.get(targetOrigin)!;
|
||||
}
|
||||
|
||||
export function effectiveRpId(ctx: ApiContext, credential: WebAuthnCredential): string {
|
||||
return credential.rpId ?? ctx.services.config.auth.passkeys.rpId;
|
||||
}
|
||||
|
||||
export function visibleWebAuthnCredentials(credentials: Array<WebAuthnCredential>): Array<WebAuthnCredential> {
|
||||
return credentials.filter((credential) => credential.supersededBy === null);
|
||||
}
|
||||
|
||||
export function originRpId(ctx: ApiContext, origin: string | null | undefined): string {
|
||||
return isPasskeyTargetOrigin(ctx, origin) ? PASSKEY_MIGRATION_RP_ID : ctx.services.config.auth.passkeys.rpId;
|
||||
}
|
||||
|
||||
export async function isPasskeyMigrationActive(ctx: ApiContext, origin: string | null | undefined): Promise<boolean> {
|
||||
if (!isPasskeyTargetOrigin(ctx, origin)) return false;
|
||||
const config = await getInstanceConfigRepository().getDomainMigrationConfig();
|
||||
return config.enabled;
|
||||
}
|
||||
|
||||
function credentialGroup(ctx: ApiContext, credentials: Array<WebAuthnCredential>, rpId: string): CredentialRpSelection {
|
||||
return {rpId, credentials: credentials.filter((credential) => effectiveRpId(ctx, credential) === rpId)};
|
||||
}
|
||||
|
||||
export function selectCredentialRp(
|
||||
ctx: ApiContext,
|
||||
origin: string | null | undefined,
|
||||
credentials: Array<WebAuthnCredential>,
|
||||
): CredentialRpSelection {
|
||||
const legacyRpId = ctx.services.config.auth.passkeys.rpId;
|
||||
const visible = visibleWebAuthnCredentials(credentials);
|
||||
if (isPasskeyTargetOrigin(ctx, origin)) {
|
||||
const target = credentialGroup(ctx, visible, PASSKEY_MIGRATION_RP_ID);
|
||||
return target.credentials.length > 0 ? target : credentialGroup(ctx, visible, legacyRpId);
|
||||
}
|
||||
const legacy = credentialGroup(ctx, credentials, legacyRpId);
|
||||
return legacy.credentials.length > 0 ? legacy : credentialGroup(ctx, visible, PASSKEY_MIGRATION_RP_ID);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {
|
||||
type AltchaCaptchaConfig,
|
||||
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {solveChallenge} from 'altcha-lib';
|
||||
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface CaptchaErrorBody {
|
||||
code: string;
|
||||
captcha_provider?: string;
|
||||
altcha_challenge?: Challenge;
|
||||
}
|
||||
|
||||
const FORGOT_PATH = '/auth/forgot';
|
||||
const FORGOT_BODY = {email: '[email protected]'};
|
||||
|
||||
async function setAltchaConfig(overrides: Partial<AltchaCaptchaConfig>): Promise<void> {
|
||||
await getInstanceConfigRepository().setAltchaCaptchaConfig({
|
||||
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
enabled: true,
|
||||
cost: 1000,
|
||||
max_counter: 100,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function solve(challenge: Challenge): Promise<string> {
|
||||
const solution = await solveChallenge({challenge, deriveKey, timeout: 0});
|
||||
if (!solution) throw new Error('ALTCHA challenge was not solved');
|
||||
return Buffer.from(JSON.stringify({challenge, solution}), 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
async function rejectWith(builder: TestRequestBuilder<CaptchaErrorBody>, code: string): Promise<CaptchaErrorBody> {
|
||||
const {json} = await builder.expect(HTTP_STATUS.BAD_REQUEST, code).executeWithResponse();
|
||||
expect(json.code).toBe(code);
|
||||
return json;
|
||||
}
|
||||
|
||||
function forgot(harness: ApiTestHarness): TestRequestBuilder<CaptchaErrorBody> {
|
||||
return createBuilderWithoutAuth<CaptchaErrorBody>(harness).post(FORGOT_PATH).body(FORGOT_BODY);
|
||||
}
|
||||
|
||||
describe('ALTCHA captcha experiment', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let previousCaptchaEnabled: boolean;
|
||||
let previousTestModeEnabled: boolean;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
previousCaptchaEnabled = Config.captcha.enabled;
|
||||
previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.captcha.enabled = true;
|
||||
Config.dev.testModeEnabled = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.captcha.enabled = previousCaptchaEnabled;
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
it('keeps the configured provider while the experiment is off', async () => {
|
||||
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(body).not.toHaveProperty('captcha_provider');
|
||||
expect(body).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('leaves anonymous requests on the configured provider unless anonymous_enabled is set', async () => {
|
||||
await setAltchaConfig({rollout_basis_points: 10000});
|
||||
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(body).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('serves anonymous requests a challenge and accepts the solved payload once', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(required.captcha_provider).toBe('altcha');
|
||||
expect(required.altcha_challenge?.parameters).toMatchObject({algorithm: 'PBKDF2/SHA-256', cost: 1000});
|
||||
const token = await solve(required.altcha_challenge as Challenge);
|
||||
|
||||
await forgot(harness)
|
||||
.header('X-Captcha-Token', token)
|
||||
.header('X-Captcha-Type', 'altcha')
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const replayed = await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
expect(replayed.captcha_provider).toBe('altcha');
|
||||
expect(replayed.altcha_challenge?.signature).not.toBe(required.altcha_challenge?.signature);
|
||||
});
|
||||
|
||||
it('rejects a payload whose derived key does not match the challenge', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const challenge = required.altcha_challenge as Challenge;
|
||||
const forged = Buffer.from(
|
||||
JSON.stringify({challenge, solution: {counter: 1, derivedKey: '00'.repeat(32)}}),
|
||||
'utf8',
|
||||
).toString('base64');
|
||||
|
||||
await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', forged).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an ALTCHA payload from a requester outside the experiment', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const token = await solve(required.altcha_challenge as Challenge);
|
||||
await setAltchaConfig({anonymous_enabled: false});
|
||||
|
||||
const rejected = await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
expect(rejected).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('buckets signed-in users by their own rollout and still accepts the configured provider', async () => {
|
||||
Config.captcha.enabled = false;
|
||||
const included = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
Config.captcha.enabled = true;
|
||||
await setAltchaConfig({
|
||||
anonymous_enabled: true,
|
||||
included_user_ids: [included.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
const redeemPath = '/gifts/altcha-gift-code/redeem';
|
||||
|
||||
const excludedBody = await rejectWith(
|
||||
createBuilder<CaptchaErrorBody>(harness, excluded.token).post(redeemPath),
|
||||
APIErrorCodes.CAPTCHA_REQUIRED,
|
||||
);
|
||||
expect(excludedBody).not.toHaveProperty('altcha_challenge');
|
||||
|
||||
const includedBody = await rejectWith(
|
||||
createBuilder<CaptchaErrorBody>(harness, included.token).post(redeemPath),
|
||||
APIErrorCodes.CAPTCHA_REQUIRED,
|
||||
);
|
||||
const token = await solve(includedBody.altcha_challenge as Challenge);
|
||||
const solved = await createBuilder<CaptchaErrorBody>(harness, included.token)
|
||||
.post(redeemPath)
|
||||
.header('X-Captcha-Token', token)
|
||||
.header('X-Captcha-Type', 'altcha')
|
||||
.executeRaw();
|
||||
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(solved.json?.code);
|
||||
|
||||
const classic = await createBuilder<CaptchaErrorBody>(harness, included.token)
|
||||
.post(redeemPath)
|
||||
.header('X-Captcha-Token', 'hcaptcha-token')
|
||||
.header('X-Captcha-Type', 'hcaptcha')
|
||||
.executeRaw();
|
||||
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(classic.json?.code);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash, randomBytes} from 'node:crypto';
|
||||
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH,
|
||||
type OriginHandoffCreateResponse,
|
||||
type OriginHandoffRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/OriginHandoffSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const CREATE_PATH = '/auth/origin-handoff';
|
||||
const REDEEM_PATH = '/auth/origin-handoff/redeem';
|
||||
const PAYLOAD = randomBytes(96).toString('base64url');
|
||||
|
||||
function createNonce(): {nonce: string; nonceHash: string} {
|
||||
const nonce = randomBytes(32).toString('base64url');
|
||||
return {nonce, nonceHash: createHash('sha256').update(nonce).digest('hex')};
|
||||
}
|
||||
|
||||
describe('Origin handoff', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let webAppOrigin: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
webAppOrigin = getConfig().endpoints.webAppOrigins[0];
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().instance.selfHosted = false;
|
||||
getConfig().endpoints.webAppOrigins = [webAppOrigin];
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function createHandoff(token: string, nonceHash: string): Promise<string> {
|
||||
const response = await createBuilder<OriginHandoffCreateResponse>(harness, token)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: nonceHash, payload: PAYLOAD})
|
||||
.execute();
|
||||
expect(response.handoff_id).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
return response.handoff_id;
|
||||
}
|
||||
|
||||
it('hands the payload over once to the origin that holds the nonce', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.execute();
|
||||
expect(redeemed).toEqual({payload: PAYLOAD});
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_ORIGIN_HANDOFF)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('consumes the handoff when the nonce does not match', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce: createNonce().nonce})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_ORIGIN_HANDOFF_NONCE)
|
||||
.execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_ORIGIN_HANDOFF)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('answers an unknown handoff id with its own error code', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: randomBytes(32).toString('base64url'), nonce: createNonce().nonce})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_ORIGIN_HANDOFF)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('requires a logged-in user to create a handoff', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: createNonce().nonceHash, payload: PAYLOAD})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses to create a handoff for an account flagged as suspicious', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${account.userId}/security-flags`)
|
||||
.body({suspicious_activity_flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE})
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: createNonce().nonceHash, payload: PAYLOAD})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.ACCOUNT_SUSPICIOUS_ACTIVITY)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses a create body larger than the payload ceiling before parsing it', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: createNonce().nonceHash, payload: 'a'.repeat(ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH + 2048)})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.FILE_SIZE_TOO_LARGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses to create a handoff for a bot', async () => {
|
||||
const bot = await createTestBotAccount(harness);
|
||||
await createBuilder(harness, `Bot ${bot.botToken}`)
|
||||
.post(CREATE_PATH)
|
||||
.body({nonce_hash: createNonce().nonceHash, payload: PAYLOAD})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{name: 'an uppercase nonce hash', body: {nonce_hash: 'A'.repeat(64), payload: PAYLOAD}},
|
||||
{name: 'a short nonce hash', body: {nonce_hash: 'a'.repeat(63), payload: PAYLOAD}},
|
||||
{name: 'a payload outside base64url', body: {nonce_hash: 'a'.repeat(64), payload: 'not+base64/url='}},
|
||||
{name: 'an empty payload', body: {nonce_hash: 'a'.repeat(64), payload: ''}},
|
||||
])('rejects $name', async ({body}) => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(CREATE_PATH)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses a redeem from an origin outside the first-party web origins', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', 'https://evil.example')
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
|
||||
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', webAppOrigin)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.execute();
|
||||
expect(redeemed.payload).toBe(PAYLOAD);
|
||||
});
|
||||
|
||||
it('accepts a redeem from a configured web app origin alias', async () => {
|
||||
getConfig().endpoints.webAppOrigins = [webAppOrigin, 'https://fluxer.com'];
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.header('origin', 'https://fluxer.com')
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.execute();
|
||||
expect(redeemed.payload).toBe(PAYLOAD);
|
||||
});
|
||||
|
||||
it('skips the origin check on a self-hosted instance', async () => {
|
||||
getConfig().instance.selfHosted = true;
|
||||
const account = await createTestAccount(harness);
|
||||
const {nonce, nonceHash} = createNonce();
|
||||
const handoffId = await createHandoff(account.token, nonceHash);
|
||||
|
||||
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
|
||||
.post(REDEEM_PATH)
|
||||
.body({handoff_id: handoffId, nonce})
|
||||
.execute();
|
||||
expect(redeemed.payload).toBe(PAYLOAD);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
type LoginMfaResponse,
|
||||
loginUser,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
type BridgeNonce,
|
||||
createBridgeNonce,
|
||||
LEGACY_ORIGIN,
|
||||
LEGACY_RP_ID,
|
||||
registerPasskey,
|
||||
runNativeSudoBridge,
|
||||
setDomainMigration,
|
||||
TARGET_ORIGIN,
|
||||
} from '@app/api/auth/tests/PasskeyTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createTotpSecret,
|
||||
createWebAuthnDevice,
|
||||
generateTotpCode,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {getCacheService} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeLoginRedeemResponse,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const SUDO_MODE_HEADER = 'X-Fluxer-Sudo-Mode-JWT';
|
||||
|
||||
interface StartedBridge {
|
||||
ceremonyId: string;
|
||||
bridgeUrl: string | null;
|
||||
nonce: BridgeNonce;
|
||||
}
|
||||
|
||||
describe('Passkey bridge', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await setDomainMigration(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().instance.selfHosted = false;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function createLegacyAccount(): Promise<{account: TestAccount; device: WebAuthnDevice}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, device, {password: account.password}, 'Old');
|
||||
return {account, device};
|
||||
}
|
||||
|
||||
async function startLogin(body: Record<string, unknown> = {}, origin = TARGET_ORIGIN): Promise<StartedBridge> {
|
||||
const nonce = createBridgeNonce();
|
||||
const start = await createBuilderWithoutAuth<PasskeyBridgeStartResponse>(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.header('origin', origin)
|
||||
.body({purpose: 'login', runner: 'page', nonce_hash: nonce.nonceHash, ...body})
|
||||
.execute();
|
||||
return {ceremonyId: start.ceremony_id, bridgeUrl: start.bridge_url, nonce};
|
||||
}
|
||||
|
||||
async function startSudo(token: string, runner: 'page' | 'native' = 'page'): Promise<StartedBridge> {
|
||||
const nonce = createBridgeNonce();
|
||||
const start = await createBuilder<PasskeyBridgeStartResponse>(harness, token)
|
||||
.post('/users/@me/passkey-bridge')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({runner, nonce_hash: nonce.nonceHash})
|
||||
.execute();
|
||||
return {ceremonyId: start.ceremony_id, bridgeUrl: start.bridge_url, nonce};
|
||||
}
|
||||
|
||||
async function fetchOptions(ceremonyId: string, origin = LEGACY_ORIGIN): Promise<WebAuthnAuthenticationOptions> {
|
||||
const {options} = await createBuilderWithoutAuth<{options: WebAuthnAuthenticationOptions}>(harness)
|
||||
.post(`/auth/passkey-bridge/${ceremonyId}/options`)
|
||||
.header('origin', origin)
|
||||
.execute();
|
||||
return options;
|
||||
}
|
||||
|
||||
async function complete(
|
||||
ceremonyId: string,
|
||||
device: WebAuthnDevice,
|
||||
origin = LEGACY_ORIGIN,
|
||||
): Promise<PasskeyBridgeFinishResponse> {
|
||||
const options = await fetchOptions(ceremonyId, origin);
|
||||
return createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${ceremonyId}/complete`)
|
||||
.header('origin', origin)
|
||||
.body({response: createAuthenticationResponse(device, options)})
|
||||
.execute();
|
||||
}
|
||||
|
||||
function completionCodeFrom(finish: PasskeyBridgeFinishResponse, ceremonyId: string): string {
|
||||
const url = new URL(finish.return_url!);
|
||||
const [id, code] = url.hash.slice('#passkey-bridge='.length).split('.');
|
||||
expect(id).toBe(ceremonyId);
|
||||
return code;
|
||||
}
|
||||
|
||||
function redeemLogin(ceremonyId: string, nonce: string, completionCode: string) {
|
||||
return createBuilderWithoutAuth<PasskeyBridgeLoginRedeemResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${ceremonyId}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({nonce, completion_code: completionCode});
|
||||
}
|
||||
|
||||
it('refuses to start outside the new origin and on a self-hosted instance, whatever the switch', async () => {
|
||||
const nonce = createBridgeNonce();
|
||||
const body = {purpose: 'login', runner: 'native', nonce_hash: nonce.nonceHash};
|
||||
for (const origin of [LEGACY_ORIGIN, 'https://evil.example']) {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.header('origin', origin)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
}
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
getConfig().instance.selfHosted = true;
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
getConfig().instance.selfHosted = false;
|
||||
await setDomainMigration(false);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('runs the ceremony only on the paired origin and keeps going when the switch goes off', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
expect(started.bridgeUrl).toBe(`${LEGACY_ORIGIN}/passkey-bridge#${started.ceremonyId}`);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/options`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
const options = await fetchOptions(started.ceremonyId);
|
||||
expect(options.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(options.allowCredentials).toBeUndefined();
|
||||
expect(options.userVerification).toBe('required');
|
||||
await setDomainMigration(false);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(device, options)})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('signs in through a page ceremony and always returns to the bridge page', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const started = await startLogin({
|
||||
return_path: '/api/v1/oauth2/authorize?prompt=none&redirect_uri=https://evil.example/cb',
|
||||
});
|
||||
const finish = await complete(started.ceremonyId, device);
|
||||
expect(finish.completion_code).toBeNull();
|
||||
const returnUrl = new URL(finish.return_url!);
|
||||
expect(`${returnUrl.origin}${returnUrl.pathname}${returnUrl.search}`).toBe(`${TARGET_ORIGIN}/passkey-bridge`);
|
||||
const code = completionCodeFrom(finish, started.ceremonyId);
|
||||
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
if (redeemed.status !== 'completed') return;
|
||||
expect(redeemed.user_id).toBe(account.userId);
|
||||
const me = await createBuilder<{id: string}>(harness, redeemed.token).get('/users/@me').execute();
|
||||
expect(me.id).toBe(account.userId);
|
||||
await redeemLogin(started.ceremonyId, started.nonce.nonce, code)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('needs both the nonce and the completion code', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
|
||||
const attacker = createBridgeNonce();
|
||||
await redeemLogin(started.ceremonyId, attacker.nonce, code)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_PASSKEY_BRIDGE_NONCE)
|
||||
.execute();
|
||||
await redeemLogin(started.ceremonyId, started.nonce.nonce, code)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
|
||||
const second = await startLogin();
|
||||
completionCodeFrom(await complete(second.ceremonyId, device), second.ceremonyId);
|
||||
await redeemLogin(second.ceremonyId, second.nonce.nonce, 'A'.repeat(43))
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_PASSKEY_BRIDGE_NONCE)
|
||||
.execute();
|
||||
await redeemLogin(second.ceremonyId, second.nonce.nonce, 'A'.repeat(43))
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('keeps a pending ceremony when redeemed early or verification fails', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
|
||||
const started = await startLogin();
|
||||
await redeemLogin(started.ceremonyId, started.nonce.nonce, 'A'.repeat(43))
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
const options = await fetchOptions(started.ceremonyId);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(target, options)})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
|
||||
.execute();
|
||||
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
|
||||
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('never lets a bridge challenge through the normal endpoints', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
const options = await fetchOptions(started.ceremonyId);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(device, options), challenge: options.challenge})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('reports a cancelled ceremony and refuses to cancel a completed one', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
const cancelled = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/cancel`)
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.execute();
|
||||
const code = completionCodeFrom(cancelled, started.ceremonyId);
|
||||
expect(await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute()).toEqual({status: 'cancelled'});
|
||||
|
||||
const second = await startLogin();
|
||||
await complete(second.ceremonyId, device);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/auth/passkey-bridge/${second.ceremonyId}/cancel`)
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('completes two-factor sign in for the ticket holder', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerPasskey(
|
||||
harness,
|
||||
account.token,
|
||||
device,
|
||||
{mfa_method: 'totp', mfa_code: generateTotpCode(secret)},
|
||||
'Old',
|
||||
);
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {mfa_method: 'totp', mfa_code: generateTotpCode(secret)});
|
||||
const login = (await loginUser(harness, {email: account.email, password: account.password})) as LoginMfaResponse;
|
||||
const started = await startLogin({purpose: 'login_mfa', ticket: login.ticket});
|
||||
const options = await fetchOptions(started.ceremonyId);
|
||||
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([device.credentialId.toString('base64url')]);
|
||||
expect(options.userVerification).toBe('discouraged');
|
||||
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
|
||||
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({code: generateTotpCode(secret), ticket: login.ticket})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('issues a sudo token that passes a sudo-protected route', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const credentialId = device.credentialId.toString('base64url');
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
|
||||
.body({name: 'Renamed'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
const redeemed = await runNativeSudoBridge(harness, account.token, device);
|
||||
expect(redeemed.status).toBe('completed');
|
||||
if (redeemed.status !== 'completed') return;
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
|
||||
.header(SUDO_MODE_HEADER, redeemed.sudo_token)
|
||||
.body({name: 'Renamed'})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns sudo page ceremonies to the bridge page on the new origin', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const started = await startSudo(account.token);
|
||||
const finish = await complete(started.ceremonyId, device);
|
||||
const returnUrl = new URL(finish.return_url!);
|
||||
expect(`${returnUrl.origin}${returnUrl.pathname}`).toBe(`${TARGET_ORIGIN}/passkey-bridge`);
|
||||
const code = completionCodeFrom(finish, started.ceremonyId);
|
||||
const redeemed = await createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, account.token)
|
||||
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({nonce: started.nonce.nonce, completion_code: code})
|
||||
.execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('does not consume a ceremony redeemed on the wrong route or by another user', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const other = await createTestAccount(harness);
|
||||
const started = await startSudo(account.token, 'native');
|
||||
const options = await fetchOptions(started.ceremonyId, TARGET_ORIGIN);
|
||||
const finish = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(device, options)})
|
||||
.execute();
|
||||
const body = {nonce: started.nonce.nonce, completion_code: finish.completion_code};
|
||||
await redeemLogin(started.ceremonyId, body.nonce, body.completion_code!)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
await createBuilder(harness, other.token)
|
||||
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
const redeemed = await createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, account.token)
|
||||
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body(body)
|
||||
.execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('always stores the ceremony with an expiry', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
const key = `passkey_bridge:${createHash('sha256').update(started.ceremonyId).digest('hex')}`;
|
||||
const cache = getCacheService();
|
||||
const ttls = [await cache.ttl(key)];
|
||||
await fetchOptions(started.ceremonyId);
|
||||
ttls.push(await cache.ttl(key));
|
||||
await complete(started.ceremonyId, device);
|
||||
ttls.push(await cache.ttl(key));
|
||||
for (const ttl of ttls) {
|
||||
expect(ttl).toBeGreaterThan(0);
|
||||
expect(ttl).toBeLessThanOrEqual(600);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
LEGACY_RP_ID,
|
||||
listPasskeys,
|
||||
registerPasskey,
|
||||
runNativeSudoBridge,
|
||||
setDomainMigration,
|
||||
TARGET_ORIGIN,
|
||||
TARGET_RP_ID,
|
||||
} from '@app/api/auth/tests/PasskeyTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createRegistrationResponse,
|
||||
createWebAuthnDevice,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {PasskeyMigrationResponse} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const MIGRATION_PATH = '/users/@me/mfa/webauthn/migration';
|
||||
const MIGRATION_OPTIONS_PATH = '/users/@me/mfa/webauthn/migration/registration-options';
|
||||
|
||||
function credentialIdOf(device: WebAuthnDevice): string {
|
||||
return device.credentialId.toString('base64url');
|
||||
}
|
||||
|
||||
interface RpcSessionResponse {
|
||||
data: {webauthn_credentials: Array<{id: string; rp_id: string}>};
|
||||
}
|
||||
|
||||
describe('Passkey migration', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function createAssignedAccount(): Promise<{account: TestAccount; legacy: WebAuthnDevice}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const legacy = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Laptop');
|
||||
await setDomainMigration(true, [account.userId]);
|
||||
return {account, legacy};
|
||||
}
|
||||
|
||||
async function getPending(token: string): Promise<PasskeyMigrationResponse['pending']> {
|
||||
const response = await createBuilder<PasskeyMigrationResponse>(harness, token).get(MIGRATION_PATH).execute();
|
||||
return response.pending;
|
||||
}
|
||||
|
||||
async function migrationOptions(token: string): Promise<WebAuthnRegistrationOptions> {
|
||||
return createBuilder<WebAuthnRegistrationOptions>(harness, token)
|
||||
.post(MIGRATION_OPTIONS_PATH)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.execute();
|
||||
}
|
||||
|
||||
function completeMigration(token: string, device: WebAuthnDevice, options: WebAuthnRegistrationOptions) {
|
||||
return createBuilder(harness, token)
|
||||
.post(MIGRATION_PATH)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createRegistrationResponse(device, options, 'Laptop'), challenge: options.challenge});
|
||||
}
|
||||
|
||||
async function migrate(account: TestAccount, legacy: WebAuthnDevice): Promise<WebAuthnDevice> {
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
const target = createWebAuthnDevice();
|
||||
await completeMigration(account.token, target, await migrationOptions(account.token))
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
return target;
|
||||
}
|
||||
|
||||
async function discoverableLogin(device: WebAuthnDevice, origin?: string, status: number = HTTP_STATUS.OK) {
|
||||
const optionsBuilder = createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/webauthn/authentication-options')
|
||||
.body(null);
|
||||
if (origin) optionsBuilder.header('origin', origin);
|
||||
const options = await optionsBuilder.execute();
|
||||
const builder = createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.body({response: createAuthenticationResponse(device, options), challenge: options.challenge})
|
||||
.expect(status);
|
||||
if (origin) builder.header('origin', origin);
|
||||
await builder.execute();
|
||||
}
|
||||
|
||||
it('records a pending update for any account on the new origin while the switch is on', async () => {
|
||||
const unassigned = await createTestAccount(harness);
|
||||
const unassignedDevice = createWebAuthnDevice();
|
||||
await registerPasskey(harness, unassigned.token, unassignedDevice, {password: unassigned.password}, 'Laptop');
|
||||
await setDomainMigration(false);
|
||||
expect((await runNativeSudoBridge(harness, unassigned.token, unassignedDevice)).status).toBe('completed');
|
||||
expect(await getPending(unassigned.token)).toBeNull();
|
||||
|
||||
await setDomainMigration(true);
|
||||
expect((await runNativeSudoBridge(harness, unassigned.token, unassignedDevice)).status).toBe('completed');
|
||||
expect(await getPending(unassigned.token)).toEqual({
|
||||
credential_id: credentialIdOf(unassignedDevice),
|
||||
name: 'Laptop',
|
||||
cross_device: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('needs a pending update and the new origin for registration options', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
await createBuilder(harness, account.token)
|
||||
.post(MIGRATION_OPTIONS_PATH)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_MIGRATION)
|
||||
.execute();
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(MIGRATION_OPTIONS_PATH)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_MIGRATION)
|
||||
.execute();
|
||||
const options = await migrationOptions(account.token);
|
||||
expect(options.rp.id).toBe(TARGET_RP_ID);
|
||||
});
|
||||
|
||||
it('replaces the passkey under the same name and hides the old one', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
const target = await migrate(account, legacy);
|
||||
const credentials = await listPasskeys(harness, account.token);
|
||||
expect(credentials).toEqual([
|
||||
expect.objectContaining({id: credentialIdOf(target), name: 'Laptop', rp_id: TARGET_RP_ID}),
|
||||
]);
|
||||
const old = await getUserRepository().getWebAuthnCredential(
|
||||
createUserID(BigInt(account.userId)),
|
||||
credentialIdOf(legacy),
|
||||
);
|
||||
expect(old?.supersededBy).toBe(credentialIdOf(target));
|
||||
expect(await getPending(account.token)).toBeNull();
|
||||
const ready = await createBuilder<RpcSessionResponse>(harness, '')
|
||||
.post('/test/rpc-session-init')
|
||||
.body({type: 'session', token: account.token, version: 1, ip: '127.0.0.1'})
|
||||
.execute();
|
||||
expect(ready.data.webauthn_credentials.map(({id, rp_id}) => ({id, rp_id}))).toEqual([
|
||||
{id: credentialIdOf(target), rp_id: TARGET_RP_ID},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the old passkey working off the new origin', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
const target = await migrate(account, legacy);
|
||||
await discoverableLogin(legacy);
|
||||
await discoverableLogin(legacy, TARGET_ORIGIN, HTTP_STATUS.UNAUTHORIZED);
|
||||
await discoverableLogin(target, TARGET_ORIGIN);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(legacy)}`)
|
||||
.body({name: 'Renamed', password: account.password})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_WEBAUTHN_CREDENTIAL)
|
||||
.execute();
|
||||
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, account.token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
expect(sudoOptions.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(sudoOptions.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
|
||||
});
|
||||
|
||||
it('removes the old passkey together with its replacement', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
const target = await migrate(account, legacy);
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(target)}`)
|
||||
.body({password: account.password})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
expect(await getUserRepository().listWebAuthnCredentials(createUserID(BigInt(account.userId)))).toEqual([]);
|
||||
await discoverableLogin(legacy, undefined, HTTP_STATUS.UNAUTHORIZED);
|
||||
});
|
||||
|
||||
it('removes every remaining superseded passkey with the last visible one', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const orphan = createWebAuthnDevice();
|
||||
const visible = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, orphan, {password: account.password}, 'Orphan');
|
||||
await registerPasskey(harness, account.token, visible, {password: account.password}, 'Visible');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await getUserRepository().setWebAuthnCredentialSupersededBy(userId, credentialIdOf(orphan), 'gone');
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
|
||||
.body({password: account.password})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
expect(await getUserRepository().listWebAuthnCredentials(userId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('has no way to attach the old passkey to another one', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, target, {password: account.password}, 'Phone', TARGET_ORIGIN);
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(MIGRATION_PATH)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
expect((await listPasskeys(harness, account.token)).map((cred) => cred.id).sort()).toEqual(
|
||||
[credentialIdOf(legacy), credentialIdOf(target)].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('never lets a migration challenge through the normal registration route', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
const options = await migrationOptions(account.token);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({
|
||||
response: createRegistrationResponse(createWebAuthnDevice(), options, 'Sneaky'),
|
||||
challenge: options.challenge,
|
||||
name: 'Sneaky',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('creates one credential when two updates race', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
const first = await migrationOptions(account.token);
|
||||
const second = await migrationOptions(account.token);
|
||||
const results = await Promise.all(
|
||||
[first, second].map((options) =>
|
||||
completeMigration(account.token, createWebAuthnDevice(), options)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.executeWithResponse()
|
||||
.then(
|
||||
() => 'ok',
|
||||
() => 'failed',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(results.sort()).toEqual(['failed', 'ok']);
|
||||
const credentials = await listPasskeys(harness, account.token);
|
||||
expect(credentials).toHaveLength(1);
|
||||
expect(credentials[0].rp_id).toBe(TARGET_RP_ID);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
LEGACY_ORIGIN,
|
||||
LEGACY_RP_ID,
|
||||
listPasskeys,
|
||||
registerPasskey,
|
||||
TARGET_ORIGIN,
|
||||
TARGET_RP_ID,
|
||||
} from '@app/api/auth/tests/PasskeyTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createWebAuthnDevice,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
function credentialIdOf(device: WebAuthnDevice): string {
|
||||
return device.credentialId.toString('base64url');
|
||||
}
|
||||
|
||||
describe('Passkey relying party selection', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().instance.selfHosted = false;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function registrationRpId(account: TestAccount, origin?: string): Promise<string> {
|
||||
const builder = createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({password: account.password});
|
||||
if (origin) builder.header('origin', origin);
|
||||
return (await builder.execute()).rp.id;
|
||||
}
|
||||
|
||||
async function discoverableOptions(origin?: string): Promise<WebAuthnAuthenticationOptions> {
|
||||
const builder = createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/webauthn/authentication-options')
|
||||
.body(null);
|
||||
if (origin) builder.header('origin', origin);
|
||||
return builder.execute();
|
||||
}
|
||||
|
||||
async function sudoOptions(token: string, origin?: string): Promise<WebAuthnAuthenticationOptions> {
|
||||
const builder = createBuilder<WebAuthnAuthenticationOptions>(harness, token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null);
|
||||
if (origin) builder.header('origin', origin);
|
||||
return builder.execute();
|
||||
}
|
||||
|
||||
async function createMixedAccount(): Promise<{account: TestAccount; legacy: WebAuthnDevice; target: WebAuthnDevice}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const legacy = createWebAuthnDevice();
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Old');
|
||||
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
|
||||
return {account, legacy, target};
|
||||
}
|
||||
|
||||
it('uses the new relying party only for requests from the new origin', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
expect(await registrationRpId(account)).toBe(LEGACY_RP_ID);
|
||||
expect(await registrationRpId(account, LEGACY_ORIGIN)).toBe(LEGACY_RP_ID);
|
||||
expect(await registrationRpId(account, TARGET_ORIGIN)).toBe(TARGET_RP_ID);
|
||||
expect((await discoverableOptions()).rpId).toBe(LEGACY_RP_ID);
|
||||
expect((await discoverableOptions(LEGACY_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
|
||||
expect((await discoverableOptions(TARGET_ORIGIN)).rpId).toBe(TARGET_RP_ID);
|
||||
});
|
||||
|
||||
it('keeps the legacy relying party everywhere on a self-hosted instance', async () => {
|
||||
getConfig().instance.selfHosted = true;
|
||||
const account = await createTestAccount(harness);
|
||||
expect(await registrationRpId(account, TARGET_ORIGIN)).toBe(LEGACY_RP_ID);
|
||||
expect((await discoverableOptions(TARGET_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
|
||||
});
|
||||
|
||||
it('stores and exposes the relying party of each passkey', async () => {
|
||||
const {account, legacy, target} = await createMixedAccount();
|
||||
const credentials = await listPasskeys(harness, account.token);
|
||||
expect(credentials.map(({id, rp_id}) => ({id, rp_id}))).toEqual(
|
||||
expect.arrayContaining([
|
||||
{id: credentialIdOf(legacy), rp_id: LEGACY_RP_ID},
|
||||
{id: credentialIdOf(target), rp_id: TARGET_RP_ID},
|
||||
]),
|
||||
);
|
||||
const legacyRow = await getUserRepository().getWebAuthnCredential(
|
||||
createUserID(BigInt(account.userId)),
|
||||
credentialIdOf(legacy),
|
||||
);
|
||||
expect(legacyRow?.rpId).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the legacy options unchanged for a legacy-only account off the new origin', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, device, {password: account.password}, 'Old');
|
||||
for (const origin of [undefined, LEGACY_ORIGIN]) {
|
||||
const options = await sudoOptions(account.token, origin);
|
||||
expect(options.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(device)]);
|
||||
expect(options.userVerification).toBe('discouraged');
|
||||
}
|
||||
});
|
||||
|
||||
it('offers one relying party group per request', async () => {
|
||||
const {account, legacy, target} = await createMixedAccount();
|
||||
const onTarget = await sudoOptions(account.token, TARGET_ORIGIN);
|
||||
expect(onTarget.rpId).toBe(TARGET_RP_ID);
|
||||
expect(onTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
|
||||
const offTarget = await sudoOptions(account.token);
|
||||
expect(offTarget.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(offTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
|
||||
});
|
||||
|
||||
it('falls back to the other group when the preferred one is empty', async () => {
|
||||
const legacyOnly = await createTestAccount(harness);
|
||||
const legacy = createWebAuthnDevice();
|
||||
await registerPasskey(harness, legacyOnly.token, legacy, {password: legacyOnly.password}, 'Old');
|
||||
expect((await sudoOptions(legacyOnly.token, TARGET_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
|
||||
const targetOnly = await createTestAccount(harness);
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, targetOnly.token, target, {password: targetOnly.password}, 'New', TARGET_ORIGIN);
|
||||
expect((await sudoOptions(targetOnly.token)).rpId).toBe(TARGET_RP_ID);
|
||||
});
|
||||
|
||||
it('rejects a passkey from another relying party before the test mode shortcut', async () => {
|
||||
const {legacy} = await createMixedAccount();
|
||||
const options = await discoverableOptions(TARGET_ORIGIN);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(legacy, options), challenge: options.challenge})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects a passkey outside the offered list before the test mode shortcut', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const visible = createWebAuthnDevice();
|
||||
const superseded = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, visible, {password: account.password}, 'Visible');
|
||||
await registerPasskey(harness, account.token, superseded, {password: account.password}, 'Superseded');
|
||||
await getUserRepository().setWebAuthnCredentialSupersededBy(
|
||||
createUserID(BigInt(account.userId)),
|
||||
credentialIdOf(superseded),
|
||||
credentialIdOf(visible),
|
||||
);
|
||||
const options = await sudoOptions(account.token, TARGET_ORIGIN);
|
||||
expect(options.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(visible)]);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({
|
||||
name: 'Renamed',
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: createAuthenticationResponse(superseded, options),
|
||||
webauthn_challenge: options.challenge,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
const retry = await sudoOptions(account.token, TARGET_ORIGIN);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({
|
||||
name: 'Renamed',
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: createAuthenticationResponse(visible, retry),
|
||||
webauthn_challenge: retry.challenge,
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('accepts a superseded passkey only off the new origin', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const legacy = createWebAuthnDevice();
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Old');
|
||||
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
|
||||
await getUserRepository().setWebAuthnCredentialSupersededBy(
|
||||
createUserID(BigInt(account.userId)),
|
||||
credentialIdOf(legacy),
|
||||
credentialIdOf(target),
|
||||
);
|
||||
expect((await listPasskeys(harness, account.token)).map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
|
||||
|
||||
const offTarget = await discoverableOptions();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.body({response: createAuthenticationResponse(legacy, offTarget), challenge: offTarget.challenge})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const sudoOffTarget = await sudoOptions(account.token);
|
||||
expect(sudoOffTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
|
||||
|
||||
const onTarget = await discoverableOptions(TARGET_ORIGIN);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(legacy, onTarget), challenge: onTarget.challenge})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
|
||||
.execute();
|
||||
const sudoOnTarget = await sudoOptions(account.token, TARGET_ORIGIN);
|
||||
expect(sudoOnTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash, randomBytes} from 'node:crypto';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createRegistrationResponse,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {DEFAULT_DOMAIN_MIGRATION_CONFIG} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import type {
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
|
||||
export const TARGET_ORIGIN = 'https://fluxer.com';
|
||||
export const LEGACY_ORIGIN = 'https://web.fluxer.app';
|
||||
export const LEGACY_RP_ID = 'localhost';
|
||||
export const TARGET_RP_ID = 'fluxer.com';
|
||||
|
||||
export interface PasskeyCredentialListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
rp_id: string;
|
||||
}
|
||||
|
||||
export interface BridgeNonce {
|
||||
nonce: string;
|
||||
nonceHash: string;
|
||||
}
|
||||
|
||||
export function createBridgeNonce(): BridgeNonce {
|
||||
const nonce = randomBytes(32).toString('base64url');
|
||||
return {nonce, nonceHash: createHash('sha256').update(nonce).digest('hex')};
|
||||
}
|
||||
|
||||
export async function setDomainMigration(enabled: boolean, includedUserIds: Array<string> = []): Promise<void> {
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled,
|
||||
included_user_ids: includedUserIds,
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerPasskey(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
device: WebAuthnDevice,
|
||||
sudo: Record<string, unknown>,
|
||||
name: string,
|
||||
origin?: string,
|
||||
): Promise<void> {
|
||||
const optionsBuilder = createBuilder<WebAuthnRegistrationOptions>(harness, token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body(sudo);
|
||||
if (origin) optionsBuilder.header('origin', origin);
|
||||
const options = await optionsBuilder.execute();
|
||||
const registerBuilder = createBuilder(harness, token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({response: createRegistrationResponse(device, options, name), challenge: options.challenge, name})
|
||||
.expect(204);
|
||||
if (origin) registerBuilder.header('origin', origin);
|
||||
await registerBuilder.execute();
|
||||
}
|
||||
|
||||
export async function listPasskeys(harness: ApiTestHarness, token: string): Promise<Array<PasskeyCredentialListItem>> {
|
||||
return createBuilder<Array<PasskeyCredentialListItem>>(harness, token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function runNativeSudoBridge(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
device: WebAuthnDevice,
|
||||
nonce: BridgeNonce = createBridgeNonce(),
|
||||
): Promise<PasskeyBridgeSudoRedeemResponse> {
|
||||
const start = await createBuilder<PasskeyBridgeStartResponse>(harness, token)
|
||||
.post('/users/@me/passkey-bridge')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({runner: 'native', nonce_hash: nonce.nonceHash})
|
||||
.execute();
|
||||
const {options} = await createBuilderWithoutAuth<{options: WebAuthnAuthenticationOptions}>(harness)
|
||||
.post(`/auth/passkey-bridge/${start.ceremony_id}/options`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.execute();
|
||||
const finish = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${start.ceremony_id}/complete`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(device, options)})
|
||||
.execute();
|
||||
return createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, token)
|
||||
.post(`/users/@me/passkey-bridge/${start.ceremony_id}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({nonce: nonce.nonce, completion_code: finish.completion_code})
|
||||
.execute();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface APIConfig {
|
||||
apiPublic: string;
|
||||
apiClient: string;
|
||||
webApp: string;
|
||||
webAppOrigins: Array<string>;
|
||||
gateway: string;
|
||||
media: string;
|
||||
staticCdn: string;
|
||||
|
||||
@@ -100,6 +100,8 @@ export interface WebAuthnCredentialRow {
|
||||
created_at: Date;
|
||||
last_used_at: Nullish<Date>;
|
||||
version: number;
|
||||
rp_id: Nullish<string>;
|
||||
superseded_by: Nullish<string>;
|
||||
}
|
||||
|
||||
export interface EmailChangeTicketRow {
|
||||
@@ -193,6 +195,8 @@ export const WEBAUTHN_CREDENTIAL_COLUMNS = [
|
||||
'created_at',
|
||||
'last_used_at',
|
||||
'version',
|
||||
'rp_id',
|
||||
'superseded_by',
|
||||
] as const satisfies ReadonlyArray<keyof WebAuthnCredentialRow>;
|
||||
|
||||
export interface PhoneTokenRow {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
|
||||
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
@@ -8,7 +9,9 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {entityTagMatches} from '@app/api/utils/EntityTag';
|
||||
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
|
||||
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
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 {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
|
||||
export function ExperimentController(app: HonoApp) {
|
||||
@@ -28,16 +31,26 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, voiceConfig] = await Promise.all([
|
||||
const [delivery, domainMigrationConfig, altchaCaptchaConfig, profileTimezoneConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getProfileTimezoneConfig(),
|
||||
]);
|
||||
const user = ctx.get('user');
|
||||
const userId = user.id.toString();
|
||||
const targeting = await resolveExperimentTargeting(user, [
|
||||
domainMigrationConfig,
|
||||
altchaCaptchaConfig,
|
||||
profileTimezoneConfig,
|
||||
]);
|
||||
const userId = ctx.get('user').id.toString();
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
|
||||
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId, targeting),
|
||||
profile_timezone: resolveProfileTimezoneAssignment(profileTimezoneConfig, userId, targeting),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {ExperimentTargeting} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
|
||||
interface TargetableExperimentConfig {
|
||||
readonly enabled: boolean;
|
||||
readonly included_guild_ids: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
const NO_GUILDS: ReadonlySet<string> = new Set();
|
||||
|
||||
export const ANONYMOUS_EXPERIMENT_TARGETING: ExperimentTargeting = {
|
||||
memberGuildIds: NO_GUILDS,
|
||||
premium: false,
|
||||
};
|
||||
|
||||
export async function resolveExperimentTargeting(
|
||||
user: User,
|
||||
configs: ReadonlyArray<TargetableExperimentConfig>,
|
||||
): Promise<ExperimentTargeting> {
|
||||
const needsGuilds = configs.some((config) => config.enabled && config.included_guild_ids.length > 0);
|
||||
const memberGuildIds = needsGuilds
|
||||
? new Set((await getUserRepository().getUserGuildIds(user.id)).map((guildId) => guildId.toString()))
|
||||
: NO_GUILDS;
|
||||
return {memberGuildIds, premium: !user.isBot && user.isPremium()};
|
||||
}
|
||||
@@ -1,21 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {grantPremium} from '@app/api/user/tests/UserTestUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
INERT_PROFILE_TIMEZONE_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
type ExperimentAssignmentsResponse,
|
||||
type ExperimentDeliveryConfigResponse,
|
||||
readVoiceNoiseSuppressionAssignment,
|
||||
readDomainMigrationAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -50,68 +61,29 @@ 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it('populates the domain migration 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);
|
||||
expect(Object.hasOwn(body.assignments, 'domain_migration')).toBe(true);
|
||||
expect(readDomainMigrationAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it('resolves the domain migration caller through the allowlist', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const untargeted = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 14,
|
||||
default_backend: 'rnnoise',
|
||||
config_version: 4,
|
||||
rollout_basis_points: 0,
|
||||
included_user_ids: [targeted.userId],
|
||||
});
|
||||
@@ -119,24 +91,222 @@ describe('GET /experiments', () => {
|
||||
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',
|
||||
});
|
||||
expect(targetedBody.assignments.domain_migration).toEqual({enabled: true});
|
||||
|
||||
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(untargetedBody.assignments.voice_noise_suppression).toMatchObject({
|
||||
expect(untargetedBody.assignments.domain_migration).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('keeps the domain migration exclusion ahead of a full rollout', async () => {
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 14,
|
||||
user_targeted: false,
|
||||
backend: null,
|
||||
source: null,
|
||||
rollout_basis_points: 10000,
|
||||
included_user_ids: [excluded.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('resolves the altcha captcha caller through the allowlist and the exclusion list', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setAltchaCaptchaConfig({
|
||||
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
anonymous_enabled: true,
|
||||
included_user_ids: [targeted.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
|
||||
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(excludedBody.assignments.altcha_captcha).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('bumps the altcha captcha 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<{altcha_captcha: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({altcha_captcha: {enabled: true, included_user_ids: [admin.userId]}})
|
||||
.execute();
|
||||
expect(afterFirst.altcha_captcha).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{
|
||||
altcha_captcha: {config_version: number; anonymous_enabled: boolean; cost: number; max_counter: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({altcha_captcha: {anonymous_enabled: true, cost: 2000, max_counter: 400}})
|
||||
.execute();
|
||||
expect(afterSecond.altcha_captcha).toMatchObject({
|
||||
config_version: 2,
|
||||
anonymous_enabled: true,
|
||||
cost: 2000,
|
||||
max_counter: 400,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('resolves the profile timezone caller through the allowlist and the exclusion list', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
included_user_ids: [targeted.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.profile_timezone).toEqual({enabled: true});
|
||||
|
||||
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(excludedBody.assignments.profile_timezone).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('bumps the profile timezone 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<{profile_timezone: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({profile_timezone: {enabled: true, included_user_ids: [admin.userId]}})
|
||||
.execute();
|
||||
expect(afterFirst.profile_timezone).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{
|
||||
profile_timezone: {config_version: number; rollout_basis_points: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({profile_timezone: {rollout_basis_points: 2500}})
|
||||
.execute();
|
||||
expect(afterSecond.profile_timezone).toMatchObject({config_version: 2, rollout_basis_points: 2500});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.profile_timezone).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('enrols members of an included guild in every experiment and leaves everyone else out', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const outsider = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Experiment Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
const repository = getInstanceConfigRepository();
|
||||
await repository.setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
await repository.setAltchaCaptchaConfig({
|
||||
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
await repository.setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
|
||||
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
|
||||
expect(memberBody.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
expect(memberBody.assignments.profile_timezone).toEqual({enabled: true});
|
||||
|
||||
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
|
||||
expect(outsiderBody.assignments.altcha_captcha).toEqual({enabled: false});
|
||||
expect(outsiderBody.assignments.profile_timezone).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
|
||||
const subscriber = await createTestAccount(harness);
|
||||
const visionary = await createTestAccount(harness);
|
||||
const free = await createTestAccount(harness);
|
||||
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
|
||||
await grantPremium(harness, visionary.userId, UserPremiumTypes.LIFETIME);
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
include_premium_users: true,
|
||||
});
|
||||
for (const [account, expected] of [
|
||||
[subscriber, true],
|
||||
[visionary, true],
|
||||
[free, false],
|
||||
] as const) {
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.profile_timezone).toEqual({enabled: expected});
|
||||
}
|
||||
});
|
||||
|
||||
it('stores the guild ids and premium switch an admin sets for each experiment', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
const guildIds = ['1500000000000000001', '1500000000000000002'];
|
||||
const body = await createBuilder<
|
||||
Record<
|
||||
'domain_migration' | 'altcha_captcha' | 'profile_timezone',
|
||||
{included_guild_ids: Array<string>; include_premium_users: boolean}
|
||||
>
|
||||
>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({
|
||||
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.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.domain_migration, body.altcha_captcha, body.profile_timezone]) {
|
||||
expect(section.include_premium_users).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('revalidates with a strong etag and answers 304 when nothing changed', async () => {
|
||||
@@ -168,7 +338,7 @@ describe('GET /experiments', () => {
|
||||
expect(preflight.headers.get('access-control-expose-headers')).toContain('ETag');
|
||||
});
|
||||
|
||||
it('serves a fresh body once the voice config changes', async () => {
|
||||
it('serves a fresh body once the domain migration config changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
@@ -176,8 +346,8 @@ describe('GET /experiments', () => {
|
||||
.executeWithResponse();
|
||||
const staleEtag = first.response.headers.get('etag') as string;
|
||||
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
@@ -189,11 +359,7 @@ describe('GET /experiments', () => {
|
||||
.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,
|
||||
});
|
||||
expect(refreshed.json?.assignments.domain_migration).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('serves a fresh body once the delivery config changes', async () => {
|
||||
@@ -219,72 +385,80 @@ 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 () => {
|
||||
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,
|
||||
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, rollout_basis_points: 10000}})
|
||||
.body({domain_migration: {enabled: true, rollout_basis_points: 10000}})
|
||||
.execute();
|
||||
expect(afterFirst.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
|
||||
expect(afterFirst.domain_migration).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
const afterSecond = await createBuilder<{
|
||||
domain_migration: {config_version: number; enabled: boolean; anonymous_rollout_basis_points: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({domain_migration: {anonymous_rollout_basis_points: 2500}})
|
||||
.execute();
|
||||
expect(afterSecond.domain_migration).toMatchObject({
|
||||
config_version: 2,
|
||||
enabled: true,
|
||||
anonymous_rollout_basis_points: 2500,
|
||||
});
|
||||
|
||||
const afterEmpty = await createBuilder<{domain_migration: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {suppression_strength: 42}})
|
||||
.body({domain_migration: {}})
|
||||
.execute();
|
||||
expect(afterSecond.voice_noise_suppression).toMatchObject({config_version: 2, enabled: true});
|
||||
expect(afterEmpty.domain_migration).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,
|
||||
});
|
||||
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) {
|
||||
|
||||
@@ -37,7 +37,6 @@ import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMe
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const PUSH_BADGE_COUNT_BATCH_SIZE = 100;
|
||||
const USER_PERMISSIONS_BATCH_SIZE = 100;
|
||||
|
||||
const GATEWAY_ERROR_TO_DOMAIN_ERROR: Record<string, () => Error> = {
|
||||
@@ -67,18 +66,6 @@ interface DispatchPresenceParams {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface InvalidatePushBadgeCountParams {
|
||||
userId: UserID;
|
||||
}
|
||||
|
||||
interface InvalidatePushBadgeCountsParams {
|
||||
userIds: Array<UserID>;
|
||||
}
|
||||
|
||||
interface InvalidatePushSubscriptionsParams {
|
||||
userId: UserID;
|
||||
}
|
||||
|
||||
interface ClearPushChannelNotificationsParams {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
@@ -287,8 +274,6 @@ export class GatewayService {
|
||||
private readonly MAX_BATCH_CONCURRENCY = 50;
|
||||
private readonly PENDING_REQUEST_TIMEOUT_MS = ms('30 seconds');
|
||||
private readonly AUTH_CONTEXT_FALLBACK_MS = ms('5 minutes');
|
||||
private readonly BADGE_COUNTS_FALLBACK_MS = ms('5 minutes');
|
||||
private badgeCountsUnsupportedUntil = 0;
|
||||
|
||||
constructor() {
|
||||
this.rpcClient = GatewayRpcClient.getInstance();
|
||||
@@ -704,48 +689,6 @@ export class GatewayService {
|
||||
});
|
||||
}
|
||||
|
||||
async invalidatePushBadgeCount({userId}: InvalidatePushBadgeCountParams): Promise<void> {
|
||||
await this.call('push.invalidate_badge_count', {
|
||||
user_id: userId.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
async invalidatePushBadgeCounts({userIds}: InvalidatePushBadgeCountsParams): Promise<void> {
|
||||
if (Date.now() < this.badgeCountsUnsupportedUntil) {
|
||||
await this.invalidatePushBadgeCountsIndividually(userIds);
|
||||
return;
|
||||
}
|
||||
const batches: Array<Array<UserID>> = [];
|
||||
for (let index = 0; index < userIds.length; index += PUSH_BADGE_COUNT_BATCH_SIZE) {
|
||||
batches.push(userIds.slice(index, index + PUSH_BADGE_COUNT_BATCH_SIZE));
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
batches.map((batch) =>
|
||||
this.call('push.invalidate_badge_counts', {user_ids: batch.map((userId) => userId.toString())}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const transformedError = this.transformGatewayError(error);
|
||||
if (!this.isAuthContextUnsupportedError(transformedError)) {
|
||||
throw transformedError;
|
||||
}
|
||||
this.badgeCountsUnsupportedUntil = Date.now() + this.BADGE_COUNTS_FALLBACK_MS;
|
||||
Logger.warn({error}, '[gateway-rpc] push.invalidate_badge_counts unavailable, falling back to per-user calls');
|
||||
await this.invalidatePushBadgeCountsIndividually(userIds);
|
||||
}
|
||||
}
|
||||
|
||||
private async invalidatePushBadgeCountsIndividually(userIds: ReadonlyArray<UserID>): Promise<void> {
|
||||
await Promise.all(userIds.map((userId) => this.invalidatePushBadgeCount({userId})));
|
||||
}
|
||||
|
||||
async invalidatePushSubscriptions({userId}: InvalidatePushSubscriptionsParams): Promise<void> {
|
||||
await this.call('push.invalidate_subscriptions', {
|
||||
user_id: userId.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
async clearPushChannelNotifications({
|
||||
userId,
|
||||
channelId,
|
||||
|
||||
@@ -292,12 +292,6 @@ export abstract class IGatewayService {
|
||||
|
||||
abstract dispatchPresence(params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void>;
|
||||
|
||||
abstract invalidatePushBadgeCount(params: {userId: UserID}): Promise<void>;
|
||||
|
||||
abstract invalidatePushBadgeCounts(params: {userIds: Array<UserID>}): Promise<void>;
|
||||
|
||||
abstract invalidatePushSubscriptions(params: {userId: UserID}): Promise<void>;
|
||||
|
||||
abstract clearPushChannelNotifications(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,9 @@ import {startDockerContainer} from '@app/api/test/DockerTestContainer';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
type DomainMigrationConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
|
||||
type ExperimentDeliveryConfig,
|
||||
@@ -34,7 +34,7 @@ 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';
|
||||
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
|
||||
@@ -282,75 +282,92 @@ describe('InstanceConfigRepository', () => {
|
||||
expect(domains).not.toContain('example.com');
|
||||
});
|
||||
|
||||
it('returns the default voice noise suppression config when the key is absent', async () => {
|
||||
it('returns the default domain migration 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);
|
||||
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(DEFAULT_DOMAIN_MIGRATION_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}) => {
|
||||
{name: 'a non-boolean enabled flag', stored: '{"enabled":"yes"}'},
|
||||
])('falls back to the default domain migration 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 repository.setConfig(DOMAIN_MIGRATION_CONFIG_KEY, stored);
|
||||
|
||||
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual(DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG);
|
||||
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
|
||||
});
|
||||
|
||||
it('round-trips a stored voice noise suppression config', async () => {
|
||||
it('round-trips a stored domain migration config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
const config: VoiceNoiseSuppressionConfig = {
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
const config: DomainMigrationConfig = {
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 3,
|
||||
default_backend: 'rnnoise',
|
||||
enabled_backends: ['none', 'standard', 'rnnoise'],
|
||||
allow_user_override: false,
|
||||
config_version: 5,
|
||||
rollout_basis_points: 2500,
|
||||
rollout_salt: 'voice-ns-v2',
|
||||
rollout_salt: 'domain-migration-v2',
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
excluded_user_ids: ['1400000000000000002'],
|
||||
guild_overrides: [{guild_id: '2400000000000000001', backend: 'rnnoise'}],
|
||||
suppression_strength: 55,
|
||||
anonymous_rollout_basis_points: 300,
|
||||
standalone_forwarding: true,
|
||||
};
|
||||
await repository.setVoiceNoiseSuppressionConfig(config);
|
||||
await repository.setDomainMigrationConfig(config);
|
||||
|
||||
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual(config);
|
||||
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it('fills newly added voice noise suppression fields from the schema defaults', async () => {
|
||||
it('fills newly added domain migration 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,
|
||||
DOMAIN_MIGRATION_CONFIG_KEY,
|
||||
JSON.stringify({enabled: true, config_version: 2, rollout_basis_points: 1000}),
|
||||
);
|
||||
|
||||
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
await expect(repository.getDomainMigrationConfig()).resolves.toEqual({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
rollout_basis_points: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes a refresh so another repository observes the domain migration config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const reader = createRepository(kvProvider);
|
||||
const writer = createRepository(kvProvider);
|
||||
|
||||
await expect(reader.getDomainMigrationConfig()).resolves.toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
|
||||
|
||||
await writer.setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
expect(await reader.getDomainMigrationConfig()).toMatchObject({enabled: true, config_version: 1});
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default experiment delivery config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
@@ -403,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';
|
||||
@@ -28,18 +34,33 @@ import {
|
||||
type PendingRegistrationResponse,
|
||||
type RegistrationUrlResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
type AltchaCaptchaConfig,
|
||||
AltchaCaptchaConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {
|
||||
type DomainMigrationConfig,
|
||||
DomainMigrationConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
type GatewayRolloutConfig,
|
||||
GatewayRolloutConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {
|
||||
type PushServiceDeliveryConfig,
|
||||
PushServiceDeliveryConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
type BillingCatalogMode,
|
||||
type StoredBillingConfig,
|
||||
StoredBillingConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
|
||||
import {
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
VoiceNoiseSuppressionConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
type ProfileTimezoneConfig,
|
||||
ProfileTimezoneConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {
|
||||
type LegacyPushServiceDeliveryWire,
|
||||
type PushRelayConfig,
|
||||
PushRelayConfigSchema,
|
||||
toLegacyPushServiceDeliveryWire,
|
||||
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {
|
||||
type ExperimentDeliveryConfig,
|
||||
ExperimentDeliveryConfigSchema,
|
||||
@@ -61,8 +82,10 @@ 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_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_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';
|
||||
const PROFILE_TIMEZONE_CONFIG_KEY = 'profile_timezone_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
@@ -73,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';
|
||||
@@ -99,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 {
|
||||
@@ -339,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: {
|
||||
@@ -351,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,
|
||||
@@ -368,12 +442,15 @@ function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
|
||||
type StoredConfigSection =
|
||||
| 'app public'
|
||||
| 'gateway rollout'
|
||||
| 'voice noise suppression'
|
||||
| 'push service delivery'
|
||||
| 'push relay'
|
||||
| 'domain migration'
|
||||
| 'altcha captcha'
|
||||
| 'profile timezone'
|
||||
| 'experiment delivery'
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
| 'media'
|
||||
| 'billing'
|
||||
| 'registration'
|
||||
| 'registration URLs'
|
||||
| 'pending registrations'
|
||||
@@ -504,12 +581,37 @@ 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),
|
||||
});
|
||||
|
||||
function parseStoredPushRelayConfig(raw: string | null): LegacyPushServiceDeliveryWire {
|
||||
const {config_version, ...config} = salvageStoredConfig(
|
||||
StoredPushRelayConfigSchema,
|
||||
readStoredConfigValue(raw, 'push relay'),
|
||||
'push relay',
|
||||
);
|
||||
return toLegacyPushServiceDeliveryWire(config, config_version);
|
||||
}
|
||||
|
||||
function parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
|
||||
function toPushRelayConfig(wire: LegacyPushServiceDeliveryWire): PushRelayConfig {
|
||||
return {
|
||||
relay_consent_accepted: wire.relay_consent_accepted,
|
||||
relay_consent_accepted_at: wire.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: wire.relay_consent_accepted_by,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationConfig {
|
||||
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
|
||||
}
|
||||
|
||||
function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig {
|
||||
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
|
||||
}
|
||||
|
||||
function parseStoredProfileTimezoneConfig(raw: string | null): ProfileTimezoneConfig {
|
||||
return parseStoredConfigOrDefault(ProfileTimezoneConfigSchema, raw, 'profile timezone');
|
||||
}
|
||||
|
||||
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
|
||||
@@ -528,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(),
|
||||
@@ -540,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;
|
||||
@@ -561,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,
|
||||
@@ -684,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;
|
||||
}
|
||||
@@ -1158,8 +1286,10 @@ export class InstanceConfigRepository {
|
||||
checkStoredConfig('gateway rollout', () =>
|
||||
parseStoredGatewayRolloutConfig(snapshot.get(GATEWAY_ROLLOUT_CONFIG_KEY) ?? null),
|
||||
);
|
||||
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
|
||||
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_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);
|
||||
parseStoredProfileTimezoneConfig(snapshot.get(PROFILE_TIMEZONE_CONFIG_KEY) ?? null);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
checkStoredConfig('registration', () =>
|
||||
@@ -1187,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);
|
||||
@@ -1235,40 +1366,78 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getVoiceNoiseSuppressionConfig(): Promise<VoiceNoiseSuppressionConfig> {
|
||||
const raw = await this.getConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY);
|
||||
return parseStoredVoiceNoiseSuppressionConfig(raw);
|
||||
async getLegacyPushServiceDeliveryWire(): Promise<LegacyPushServiceDeliveryWire> {
|
||||
const raw = await this.getConfig(PUSH_RELAY_CONFIG_KEY);
|
||||
return parseStoredPushRelayConfig(raw);
|
||||
}
|
||||
|
||||
async setVoiceNoiseSuppressionConfig(config: VoiceNoiseSuppressionConfig): Promise<void> {
|
||||
await this.updateVoiceNoiseSuppressionConfig(() => config);
|
||||
async getPushRelayConfig(): Promise<PushRelayConfig> {
|
||||
return toPushRelayConfig(await this.getLegacyPushServiceDeliveryWire());
|
||||
}
|
||||
|
||||
updateVoiceNoiseSuppressionConfig(
|
||||
update: (current: VoiceNoiseSuppressionConfig) => VoiceNoiseSuppressionConfig,
|
||||
): Promise<VoiceNoiseSuppressionConfig> {
|
||||
return this.updateStoredConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, (raw) =>
|
||||
updatePushRelayConfig(update: (current: PushRelayConfig) => PushRelayConfig): Promise<LegacyPushServiceDeliveryWire> {
|
||||
return this.updateStoredConfig(PUSH_RELAY_CONFIG_KEY, (raw) => {
|
||||
const current = parseStoredPushRelayConfig(raw);
|
||||
const next = validateStoredConfig(PushRelayConfigSchema, update(toPushRelayConfig(current)), 'push relay');
|
||||
return toLegacyPushServiceDeliveryWire(next, current.config_version + 1);
|
||||
});
|
||||
}
|
||||
|
||||
async getDomainMigrationConfig(): Promise<DomainMigrationConfig> {
|
||||
const raw = await this.getConfig(DOMAIN_MIGRATION_CONFIG_KEY);
|
||||
return parseStoredDomainMigrationConfig(raw);
|
||||
}
|
||||
|
||||
async setDomainMigrationConfig(config: DomainMigrationConfig): Promise<void> {
|
||||
await this.updateDomainMigrationConfig(() => config);
|
||||
}
|
||||
|
||||
updateDomainMigrationConfig(
|
||||
update: (current: DomainMigrationConfig) => DomainMigrationConfig,
|
||||
): Promise<DomainMigrationConfig> {
|
||||
return this.updateStoredConfig(DOMAIN_MIGRATION_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
VoiceNoiseSuppressionConfigSchema,
|
||||
update(parseStoredVoiceNoiseSuppressionConfig(raw)),
|
||||
'voice noise suppression',
|
||||
DomainMigrationConfigSchema,
|
||||
update(parseStoredDomainMigrationConfig(raw)),
|
||||
'domain migration',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getPushServiceDeliveryConfig(): Promise<PushServiceDeliveryConfig> {
|
||||
const raw = await this.getConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredPushServiceDeliveryConfig(raw);
|
||||
async getAltchaCaptchaConfig(): Promise<AltchaCaptchaConfig> {
|
||||
const raw = await this.getConfig(ALTCHA_CAPTCHA_CONFIG_KEY);
|
||||
return parseStoredAltchaCaptchaConfig(raw);
|
||||
}
|
||||
|
||||
updatePushServiceDeliveryConfig(
|
||||
update: (current: PushServiceDeliveryConfig) => PushServiceDeliveryConfig,
|
||||
): Promise<PushServiceDeliveryConfig> {
|
||||
return this.updateStoredConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY, (raw) =>
|
||||
async setAltchaCaptchaConfig(config: AltchaCaptchaConfig): Promise<void> {
|
||||
await this.updateAltchaCaptchaConfig(() => config);
|
||||
}
|
||||
|
||||
updateAltchaCaptchaConfig(
|
||||
update: (current: AltchaCaptchaConfig) => AltchaCaptchaConfig,
|
||||
): Promise<AltchaCaptchaConfig> {
|
||||
return this.updateStoredConfig(ALTCHA_CAPTCHA_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(AltchaCaptchaConfigSchema, update(parseStoredAltchaCaptchaConfig(raw)), 'altcha captcha'),
|
||||
);
|
||||
}
|
||||
|
||||
async getProfileTimezoneConfig(): Promise<ProfileTimezoneConfig> {
|
||||
const raw = await this.getConfig(PROFILE_TIMEZONE_CONFIG_KEY);
|
||||
return parseStoredProfileTimezoneConfig(raw);
|
||||
}
|
||||
|
||||
async setProfileTimezoneConfig(config: ProfileTimezoneConfig): Promise<void> {
|
||||
await this.updateProfileTimezoneConfig(() => config);
|
||||
}
|
||||
|
||||
updateProfileTimezoneConfig(
|
||||
update: (current: ProfileTimezoneConfig) => ProfileTimezoneConfig,
|
||||
): Promise<ProfileTimezoneConfig> {
|
||||
return this.updateStoredConfig(PROFILE_TIMEZONE_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
PushServiceDeliveryConfigSchema,
|
||||
update(parseStoredPushServiceDeliveryConfig(raw)),
|
||||
'push service delivery',
|
||||
ProfileTimezoneConfigSchema,
|
||||
update(parseStoredProfileTimezoneConfig(raw)),
|
||||
'profile timezone',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1322,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;
|
||||
}
|
||||
@@ -1647,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');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {DEFAULT_DOMAIN_MIGRATION_CONFIG} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -96,4 +97,36 @@ describe('InstanceController discovery captcha', () => {
|
||||
turnstile_site_key: 'turnstile-site-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes the domain migration kill switch and anonymous rollout without the targeting lists', async () => {
|
||||
const repository = createRepository();
|
||||
const app = createApp(repository);
|
||||
|
||||
const initial = await app.request('http://localhost/.well-known/fluxer');
|
||||
expect(((await initial.json()) as {domain_migration: unknown}).domain_migration).toEqual({
|
||||
enabled: false,
|
||||
anonymous_rollout_basis_points: 0,
|
||||
rollout_salt: 'domain-migration-v1',
|
||||
standalone_forwarding: false,
|
||||
});
|
||||
|
||||
await repository.setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
rollout_basis_points: 100,
|
||||
anonymous_rollout_basis_points: 1500,
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
standalone_forwarding: true,
|
||||
});
|
||||
|
||||
const updated = await app.request('http://localhost/.well-known/fluxer');
|
||||
expect(updated.headers.get('etag')).not.toBe(initial.headers.get('etag'));
|
||||
expect(((await updated.json()) as {domain_migration: unknown}).domain_migration).toEqual({
|
||||
enabled: true,
|
||||
anonymous_rollout_basis_points: 1500,
|
||||
rollout_salt: 'domain-migration-v1',
|
||||
standalone_forwarding: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,10 +12,12 @@ 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';
|
||||
import type {InstanceAppPublic} from '@fluxer/instance_bootstrap/src/Types';
|
||||
import {toDomainMigrationDiscovery} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {WellKnownFluxerResponse} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import type {Hono} from 'hono';
|
||||
|
||||
@@ -65,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,
|
||||
@@ -102,15 +106,16 @@ export function InstanceController(app: Hono<HonoEnv>) {
|
||||
const limits = ctx.get('limitConfigService').getConfigWireFormat();
|
||||
const sso = await ctx.get('ssoService').getPublicStatus();
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [registration, community, services, appPublicConfig, captcha, email] = await Promise.all([
|
||||
const [registration, community, services, appPublicConfig, captcha, email, domainMigration] = await Promise.all([
|
||||
instanceConfigRepository.getRegistrationPublicConfig(),
|
||||
instanceConfigRepository.getInstanceCommunityPublicConfig(),
|
||||
instanceConfigRepository.getResolvedServicesConfig(),
|
||||
instanceConfigRepository.getAppPublicConfig(),
|
||||
instanceConfigRepository.getEffectiveCaptchaConfig(),
|
||||
instanceConfigRepository.getEffectiveEmailConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
]);
|
||||
const response = buildDiscoveryResponse(
|
||||
const discovery = buildDiscoveryResponse(
|
||||
buildDiscoveryStaticInput(
|
||||
gifService,
|
||||
{
|
||||
@@ -133,6 +138,7 @@ export function InstanceController(app: Hono<HonoEnv>) {
|
||||
limits,
|
||||
},
|
||||
);
|
||||
const response = {...discovery, domain_migration: toDomainMigrationDiscovery(domainMigration)};
|
||||
discoveryValidators = nextDiscoveryValidators(response, discoveryValidators);
|
||||
ctx.header('ETag', discoveryValidators.etag);
|
||||
ctx.header('Last-Modified', discoveryValidators.lastModified.toUTCString());
|
||||
|
||||
+5
-5
@@ -1,21 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {PushServiceDeliveryConfig} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import type {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
|
||||
const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
|
||||
|
||||
interface PushServiceDeliveryConfigNatsMessage {
|
||||
type: 'push_service_delivery_config';
|
||||
config: PushServiceDeliveryConfig;
|
||||
config: LegacyPushServiceDeliveryWire;
|
||||
}
|
||||
|
||||
export class PushServiceDeliveryConfigPublisher {
|
||||
export class PushRelayConfigPublisher {
|
||||
constructor(private readonly connectionManager: INatsConnectionManager) {}
|
||||
|
||||
async publish(config: PushServiceDeliveryConfig): Promise<void> {
|
||||
async publish(config: LegacyPushServiceDeliveryWire): Promise<void> {
|
||||
if (this.connectionManager.isClosed()) {
|
||||
await this.connectionManager.connect();
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
|
||||
import type {Context, Next} from 'hono';
|
||||
|
||||
const LEGACY_APP_ORIGINS = ['https://web.fluxer.app', 'https://web.canary.fluxer.app'];
|
||||
|
||||
export async function BlockAppOriginMiddleware(ctx: Context, next: Next) {
|
||||
const origin = ctx.req.header('origin');
|
||||
if (origin === 'https://web.fluxer.app' || origin === 'https://web.canary.fluxer.app') {
|
||||
if (
|
||||
origin !== undefined &&
|
||||
(LEGACY_APP_ORIGINS.includes(origin) || Config.endpoints.webAppOrigins.includes(origin))
|
||||
) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
await next();
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHmac} from 'node:crypto';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ANONYMOUS_EXPERIMENT_TARGETING, resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
|
||||
import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {accountPolicyContactHasCapability} from '@app/api/risk/AccountPolicyService';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
@@ -9,12 +13,44 @@ import {Headers} from '@fluxer/constants/src/Headers';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors';
|
||||
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {type AltchaCaptchaConfig, altchaCaptchaAppliesTo} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import type {InstanceCaptchaProvider} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory';
|
||||
import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {AltchaProvider} from '@pkgs/captcha/src/providers/AltchaProvider';
|
||||
import type {Context} from 'hono';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
|
||||
const ALTCHA_SPENT_CHALLENGE_KEY_PREFIX = 'captcha:altcha:spent:';
|
||||
|
||||
function deriveAltchaSecret(label: string): string {
|
||||
return createHmac('sha256', Config.auth.sudoModeSecret).update(label).digest('hex');
|
||||
}
|
||||
|
||||
function createAltchaProvider(config: AltchaCaptchaConfig): AltchaProvider {
|
||||
return new AltchaProvider({
|
||||
hmacSignatureSecret: deriveAltchaSecret('fluxer-altcha-challenge-signature-v1'),
|
||||
hmacKeySignatureSecret: deriveAltchaSecret('fluxer-altcha-key-signature-v1'),
|
||||
cost: config.cost,
|
||||
maxCounter: config.max_counter,
|
||||
claimChallenge: (signature, ttlSeconds) =>
|
||||
getKVClient().setnx(`${ALTCHA_SPENT_CHALLENGE_KEY_PREFIX}${signature}`, '1', ttlSeconds),
|
||||
logger: Logger,
|
||||
});
|
||||
}
|
||||
|
||||
async function altchaChallengeData(altcha: AltchaProvider | null): Promise<Record<string, unknown> | undefined> {
|
||||
if (!altcha) return undefined;
|
||||
return {captcha_provider: 'altcha', altcha_challenge: await altcha.createChallenge()};
|
||||
}
|
||||
|
||||
async function resolveAltchaProvider(ctx: Context<HonoEnv>, user: User | undefined): Promise<AltchaProvider | null> {
|
||||
const config = await ctx.get('instanceConfigRepository').getAltchaCaptchaConfig();
|
||||
const targeting = user ? await resolveExperimentTargeting(user, [config]) : ANONYMOUS_EXPERIMENT_TARGETING;
|
||||
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null, targeting)) return null;
|
||||
return createAltchaProvider(config);
|
||||
}
|
||||
|
||||
function resolveProviderSecret(
|
||||
config: InstanceCaptchaEffectiveConfig,
|
||||
provider: InstanceCaptchaProvider,
|
||||
@@ -58,11 +94,19 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
|
||||
if (accountPolicyContactHasCapability(user?.email, 'captcha_exempt')) return;
|
||||
if (userHasCaptchaExemptFlag(user)) return;
|
||||
if (await requestUserHasCaptchaExemptFlag(ctx)) return;
|
||||
const altcha = await resolveAltchaProvider(ctx, user);
|
||||
const token = ctx.req.header(Headers.X_CAPTCHA_TOKEN);
|
||||
if (!token) {
|
||||
throw new CaptchaRequiredError();
|
||||
throw new CaptchaRequiredError(await altchaChallengeData(altcha));
|
||||
}
|
||||
const provider = resolveCaptchaProvider(captchaConfig, ctx.req.header(Headers.X_CAPTCHA_TYPE));
|
||||
const requestedType = ctx.req.header(Headers.X_CAPTCHA_TYPE);
|
||||
if (requestedType === 'altcha') {
|
||||
if (!altcha || !(await altcha.verify({token}))) {
|
||||
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const provider = resolveCaptchaProvider(captchaConfig, requestedType);
|
||||
const isValid = await provider.verify({
|
||||
token,
|
||||
remoteIp:
|
||||
@@ -72,7 +116,7 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
|
||||
}) ?? undefined,
|
||||
});
|
||||
if (!isValid) {
|
||||
throw new InvalidCaptchaError();
|
||||
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClie
|
||||
import {VirusScanService} from '@app/api/infrastructure/VirusScanService';
|
||||
import {GatewayRolloutConfigPublisher} from '@app/api/instance/GatewayRolloutConfigPublisher';
|
||||
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
|
||||
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
|
||||
import {InviteRepository} from '@app/api/invite/InviteRepository';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
@@ -157,13 +157,13 @@ export const getGatewayRolloutConfigPublisher = singleton(
|
||||
),
|
||||
);
|
||||
|
||||
export const getPushServiceDeliveryConfigPublisher = singleton(
|
||||
export const getPushRelayConfigPublisher = singleton(
|
||||
() =>
|
||||
new PushServiceDeliveryConfigPublisher(
|
||||
new PushRelayConfigPublisher(
|
||||
new NatsConnectionManager({
|
||||
url: Config.nats.coreUrl,
|
||||
token: Config.nats.authToken || undefined,
|
||||
name: 'fluxer-api-push-service-delivery-config',
|
||||
name: 'fluxer-api-push-relay-config',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import {DEFAULT_ALTCHA_CAPTCHA_CONFIG} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -25,6 +26,7 @@ function createHarness(
|
||||
): (headers: Record<string, string>) => Promise<Response> {
|
||||
const repository = {
|
||||
getEffectiveCaptchaConfig: async () => captcha,
|
||||
getAltchaCaptchaConfig: async () => DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
} as unknown as InstanceConfigRepository;
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(async (ctx, next) => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -12,6 +12,8 @@ export class WebAuthnCredential {
|
||||
readonly createdAt: Date;
|
||||
readonly lastUsedAt: Date | null;
|
||||
readonly version: number;
|
||||
readonly rpId: string | null;
|
||||
readonly supersededBy: string | null;
|
||||
|
||||
constructor(row: WebAuthnCredentialRow) {
|
||||
this.credentialId = row.credential_id;
|
||||
@@ -22,6 +24,8 @@ export class WebAuthnCredential {
|
||||
this.createdAt = row.created_at;
|
||||
this.lastUsedAt = row.last_used_at ?? null;
|
||||
this.version = row.version;
|
||||
this.rpId = row.rp_id ?? null;
|
||||
this.supersededBy = row.superseded_by ?? null;
|
||||
}
|
||||
|
||||
toRow(userId: UserID): WebAuthnCredentialRow {
|
||||
@@ -35,6 +39,8 @@ export class WebAuthnCredential {
|
||||
created_at: this.createdAt,
|
||||
last_used_at: this.lastUsedAt,
|
||||
version: this.version,
|
||||
rp_id: this.rpId,
|
||||
superseded_by: this.supersededBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,10 @@ export const AuthRateLimitConfigs = {
|
||||
bucket: 'mfa:webauthn:two_factor',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
MFA_WEBAUTHN_MIGRATION: {
|
||||
bucket: 'mfa:webauthn:migration',
|
||||
config: {limit: 20, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
PHONE_SEND_VERIFICATION: {
|
||||
bucket: 'phone:send_verification',
|
||||
config: {limit: 5, windowMs: ms('1 minute')},
|
||||
@@ -132,6 +136,34 @@ export const AuthRateLimitConfigs = {
|
||||
bucket: 'auth:handoff:cancel',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_ORIGIN_HANDOFF_CREATE: {
|
||||
bucket: 'auth:origin_handoff:create',
|
||||
config: {limit: 3, windowMs: ms('10 minutes')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_ORIGIN_HANDOFF_REDEEM: {
|
||||
bucket: 'auth:origin_handoff:redeem',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_PASSKEY_BRIDGE_START: {
|
||||
bucket: 'auth:passkey_bridge:start',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_PASSKEY_BRIDGE_CEREMONY: {
|
||||
bucket: 'auth:passkey_bridge:ceremony',
|
||||
config: {limit: 20, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_PASSKEY_BRIDGE_REDEEM: {
|
||||
bucket: 'auth:passkey_bridge:redeem',
|
||||
config: {limit: 60, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_PASSKEY_BRIDGE_START: {
|
||||
bucket: 'mfa:passkey_bridge:start',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_PASSKEY_BRIDGE_REDEEM: {
|
||||
bucket: 'mfa:passkey_bridge:redeem',
|
||||
config: {limit: 60, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
SUDO_WEBAUTHN_OPTIONS: {
|
||||
bucket: 'sudo:webauthn:options',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -15,53 +15,6 @@ import {ReadStateService} from '@app/api/read_state/ReadStateService';
|
||||
import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('ReadStateService.bulkIncrementMentionCounts', () => {
|
||||
it('invalidates badge counts for touched users in a single bulk call', async () => {
|
||||
const channelId = createChannelID(2n);
|
||||
const messageId = createMessageID(3n);
|
||||
const touched: Array<{userId: UserID; channelId: ChannelID}> = [
|
||||
{userId: createUserID(10n), channelId},
|
||||
{userId: createUserID(11n), channelId},
|
||||
{userId: createUserID(10n), channelId: createChannelID(4n)},
|
||||
];
|
||||
const repository = {
|
||||
bulkIncrementMentionCounts: vi.fn().mockResolvedValue(touched),
|
||||
} as unknown as IReadStateRepository;
|
||||
const invalidatePushBadgeCounts = vi.fn().mockResolvedValue(undefined);
|
||||
const invalidatePushBadgeCount = vi.fn().mockResolvedValue(undefined);
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCounts,
|
||||
invalidatePushBadgeCount,
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
await service.bulkIncrementMentionCounts([
|
||||
{userId: createUserID(10n), channelId, messageId},
|
||||
{userId: createUserID(11n), channelId, messageId},
|
||||
{userId: createUserID(12n), channelId, messageId},
|
||||
]);
|
||||
|
||||
expect(invalidatePushBadgeCount).not.toHaveBeenCalled();
|
||||
expect(invalidatePushBadgeCounts).toHaveBeenCalledTimes(1);
|
||||
expect(invalidatePushBadgeCounts).toHaveBeenCalledWith({userIds: [createUserID(10n), createUserID(11n)]});
|
||||
});
|
||||
|
||||
it('skips the bulk call when no read state was touched', async () => {
|
||||
const repository = {
|
||||
bulkIncrementMentionCounts: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as IReadStateRepository;
|
||||
const invalidatePushBadgeCounts = vi.fn().mockResolvedValue(undefined);
|
||||
const gatewayService = {invalidatePushBadgeCounts} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
await service.bulkIncrementMentionCounts([
|
||||
{userId: createUserID(10n), channelId: createChannelID(2n), messageId: createMessageID(3n)},
|
||||
]);
|
||||
|
||||
expect(invalidatePushBadgeCounts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const USER_ID = createUserID(20n);
|
||||
const CHANNEL_ID = createChannelID(21n);
|
||||
const MESSAGE_ID = createMessageID(22n);
|
||||
@@ -78,7 +31,7 @@ function makeReadState(channelId: ChannelID, messageId: MessageID, mentionCount
|
||||
}
|
||||
|
||||
describe('ReadStateService gateway side effects after the write', () => {
|
||||
it('returns the committed read state when the badge invalidation fails', async () => {
|
||||
it('returns the committed read state when clearing push notifications fails', async () => {
|
||||
const stored: Array<{channelId: ChannelID; messageId: MessageID}> = [];
|
||||
const repository = {
|
||||
upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => {
|
||||
@@ -87,8 +40,7 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
}),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
clearPushChannelNotifications: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
dispatchPresence: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
@@ -113,7 +65,6 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockResolvedValue(undefined),
|
||||
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
dispatchPresence: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
} as unknown as IGatewayService;
|
||||
@@ -138,7 +89,6 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
}),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockResolvedValue(undefined),
|
||||
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
dispatchPresence: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
} as unknown as IGatewayService;
|
||||
@@ -156,42 +106,13 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
expect(stored).toEqual(['21', '23']);
|
||||
});
|
||||
|
||||
it('deletes the read state when the badge invalidation fails', async () => {
|
||||
const deleteReadState = vi.fn().mockResolvedValue(undefined);
|
||||
const repository = {deleteReadState} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
await expect(service.deleteReadState({userId: USER_ID, channelId: CHANNEL_ID})).resolves.toBeUndefined();
|
||||
|
||||
expect(deleteReadState).toHaveBeenCalledWith(USER_ID, CHANNEL_ID);
|
||||
});
|
||||
|
||||
it('increments the mention count when the badge invalidation fails', async () => {
|
||||
const incrementReadStateMentions = vi.fn().mockResolvedValue(makeReadState(CHANNEL_ID, MESSAGE_ID, 1));
|
||||
const repository = {incrementReadStateMentions} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
await expect(
|
||||
service.incrementMentionCount({userId: USER_ID, channelId: CHANNEL_ID, messageId: MESSAGE_ID}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(incrementReadStateMentions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the bulk acknowledged states when the badge invalidation fails', async () => {
|
||||
it('returns the bulk acknowledged states when clearing push notifications fails', async () => {
|
||||
const updated = [makeReadState(CHANNEL_ID, MESSAGE_ID)];
|
||||
const repository = {
|
||||
bulkAckMessages: vi.fn().mockResolvedValue(updated),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
clearPushChannelNotifications: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
dispatchPresence: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
@@ -34,7 +34,6 @@ export class ReadStateService {
|
||||
undefined,
|
||||
manual ?? false,
|
||||
);
|
||||
await this.invalidatePushBadgeCount(userId);
|
||||
if (!silent) {
|
||||
await this.clearPushChannelNotifications({userId, channelId, messageId});
|
||||
}
|
||||
@@ -115,7 +114,6 @@ export class ReadStateService {
|
||||
try {
|
||||
const updatedReadStates = await this.repository.bulkAckMessages(userId, readStates);
|
||||
const readStatesByChannel = new Map(updatedReadStates.map((readState) => [readState.channelId, readState]));
|
||||
await this.invalidatePushBadgeCount(userId);
|
||||
await Promise.all(
|
||||
readStates.map(({channelId, messageId}) =>
|
||||
Promise.all([
|
||||
@@ -143,11 +141,6 @@ export class ReadStateService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteReadState({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
await this.repository.deleteReadState(userId, channelId);
|
||||
await this.invalidatePushBadgeCount(userId);
|
||||
}
|
||||
|
||||
async incrementMentionCount({
|
||||
userId,
|
||||
channelId,
|
||||
@@ -157,11 +150,7 @@ export class ReadStateService {
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}): Promise<void> {
|
||||
const readState = await this.repository.incrementReadStateMentions(userId, channelId, messageId, 1);
|
||||
if (readState == null) {
|
||||
return;
|
||||
}
|
||||
await this.invalidatePushBadgeCount(userId);
|
||||
await this.repository.incrementReadStateMentions(userId, channelId, messageId, 1);
|
||||
}
|
||||
|
||||
async bulkIncrementMentionCounts(
|
||||
@@ -175,15 +164,7 @@ export class ReadStateService {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const appliedUpdates = await this.repository.bulkIncrementMentionCounts(updates);
|
||||
const uniqueUserIds = Array.from(new Set(appliedUpdates.map((update) => update.userId)));
|
||||
if (uniqueUserIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.gatewayService.invalidatePushBadgeCounts({userIds: uniqueUserIds}).catch((error) => {
|
||||
Logger.error({userCount: uniqueUserIds.length, error}, 'Failed to invalidate push badge counts');
|
||||
return null;
|
||||
});
|
||||
await this.repository.bulkIncrementMentionCounts(updates);
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Bulk increment mention counts failed');
|
||||
throw error;
|
||||
@@ -196,13 +177,6 @@ export class ReadStateService {
|
||||
await this.dispatchPinsAck({userId, channelId, timestamp});
|
||||
}
|
||||
|
||||
private async invalidatePushBadgeCount(userId: UserID): Promise<void> {
|
||||
await this.gatewayService.invalidatePushBadgeCount({userId}).catch((error) => {
|
||||
Logger.error({userId: userId.toString(), error}, 'Failed to invalidate push badge count');
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchMessageAck(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
|
||||
@@ -1,34 +1,69 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {parseIpBanEntry, tryParseSingleIp} from '@app/api/utils/IpRangeUtils';
|
||||
import type {IpAddressFamily} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
|
||||
let exemptDecisionKeys: ReadonlySet<string> | null = null;
|
||||
interface ExemptRange {
|
||||
family: IpAddressFamily;
|
||||
start: bigint;
|
||||
end: bigint;
|
||||
}
|
||||
|
||||
function getExemptDecisionKeys(): ReadonlySet<string> {
|
||||
if (exemptDecisionKeys) {
|
||||
return exemptDecisionKeys;
|
||||
interface IpBanExemptions {
|
||||
decisionKeys: ReadonlySet<string>;
|
||||
ranges: ReadonlyArray<ExemptRange>;
|
||||
}
|
||||
|
||||
let exemptions: IpBanExemptions | null = null;
|
||||
|
||||
function getExemptions(): IpBanExemptions {
|
||||
if (exemptions) {
|
||||
return exemptions;
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
for (const ip of Config.ipBanExemptIps) {
|
||||
const key = getSameIpDecisionKey(ip);
|
||||
if (!key) {
|
||||
throw new Error(`Invalid IP ban exemption in API config: ${ip}`);
|
||||
const decisionKeys = new Set<string>();
|
||||
const ranges: Array<ExemptRange> = [];
|
||||
for (const entry of Config.ipBanExemptIps) {
|
||||
if (entry.includes('/')) {
|
||||
const range = parseIpBanEntry(entry);
|
||||
if (range?.type !== 'range') {
|
||||
throw new Error(`Invalid IP ban exemption in API config: ${entry}`);
|
||||
}
|
||||
ranges.push({family: range.family, start: range.start, end: range.end});
|
||||
continue;
|
||||
}
|
||||
keys.add(key);
|
||||
const key = getSameIpDecisionKey(entry);
|
||||
if (!key) {
|
||||
throw new Error(`Invalid IP ban exemption in API config: ${entry}`);
|
||||
}
|
||||
decisionKeys.add(key);
|
||||
}
|
||||
exemptDecisionKeys = keys;
|
||||
return keys;
|
||||
exemptions = {decisionKeys, ranges};
|
||||
return exemptions;
|
||||
}
|
||||
|
||||
export function isIpBanExempt(ip: string | null | undefined): boolean {
|
||||
if (!ip) {
|
||||
return false;
|
||||
}
|
||||
const {decisionKeys, ranges} = getExemptions();
|
||||
const key = getSameIpDecisionKey(ip);
|
||||
return key !== null && getExemptDecisionKeys().has(key);
|
||||
if (key !== null && decisionKeys.has(key)) {
|
||||
return true;
|
||||
}
|
||||
if (ranges.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const parsed = tryParseSingleIp(ip);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
return ranges.some(
|
||||
(range) => range.family === parsed.family && parsed.value >= range.start && parsed.value <= range.end,
|
||||
);
|
||||
}
|
||||
|
||||
export function resetIpBanExemptionsForTesting(): void {
|
||||
exemptDecisionKeys = null;
|
||||
exemptions = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {isIpBanExempt, resetIpBanExemptionsForTesting} from '@app/api/risk/IpBanExemptions';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('isIpBanExempt', () => {
|
||||
let originalExemptIps: Array<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
const config = getConfig();
|
||||
originalExemptIps = config.ipBanExemptIps;
|
||||
config.ipBanExemptIps = ['198.51.100.7', '2001:db8:6::', '2001:db8:1200:1000::/56', '203.0.113.0/24'];
|
||||
resetIpBanExemptionsForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().ipBanExemptIps = originalExemptIps;
|
||||
resetIpBanExemptionsForTesting();
|
||||
});
|
||||
|
||||
it('matches a bare IPv4 address exactly', () => {
|
||||
expect(isIpBanExempt('198.51.100.7')).toBe(true);
|
||||
expect(isIpBanExempt('198.51.100.8')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches a bare IPv6 address on its /64', () => {
|
||||
expect(isIpBanExempt('2001:db8:6::abcd')).toBe(true);
|
||||
expect(isIpBanExempt('2001:db8:7::1')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches every address inside a CIDR range', () => {
|
||||
expect(isIpBanExempt('2001:db8:1200:1000::1')).toBe(true);
|
||||
expect(isIpBanExempt('2001:db8:1200:10ff:ffff:ffff:ffff:ffff')).toBe(true);
|
||||
expect(isIpBanExempt('2001:db8:1200:1100::1')).toBe(false);
|
||||
expect(isIpBanExempt('2001:db8:1200:fff::1')).toBe(false);
|
||||
expect(isIpBanExempt('203.0.113.200')).toBe(true);
|
||||
expect(isIpBanExempt('::ffff:203.0.113.200')).toBe(true);
|
||||
expect(isIpBanExempt('203.0.114.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match empty or unparsable input', () => {
|
||||
expect(isIpBanExempt(null)).toBe(false);
|
||||
expect(isIpBanExempt('')).toBe(false);
|
||||
expect(isIpBanExempt('not-an-ip')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import {createHash} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {
|
||||
createChannelID,
|
||||
@@ -75,6 +76,7 @@ import {
|
||||
mapUserGuildSettingsToResponse,
|
||||
mapUserSettingsToResponse,
|
||||
mapUserToPrivateResponse,
|
||||
mapWebAuthnCredentialToResponse,
|
||||
} from '@app/api/user/UserMappers';
|
||||
import {isUserAdult} from '@app/api/utils/AgeUtils';
|
||||
import {deriveDominantAvatarColor} from '@app/api/utils/AvatarColorUtils';
|
||||
@@ -97,7 +99,6 @@ import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import {pushServiceDeliveryEnrols} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {VoiceStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
@@ -431,13 +432,6 @@ export class RpcService {
|
||||
}),
|
||||
};
|
||||
case 'send_apns_push': {
|
||||
const deliveryConfig = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
|
||||
if (pushServiceDeliveryEnrols(deliveryConfig, request.user_id.toString())) {
|
||||
Logger.warn(
|
||||
{userId: request.user_id.toString(), configVersion: deliveryConfig.config_version},
|
||||
'push service delivery path mismatch',
|
||||
);
|
||||
}
|
||||
const result = await sendApnsPush({
|
||||
userId: request.user_id.toString(),
|
||||
subscriptionId: request.subscription_id,
|
||||
@@ -644,7 +638,7 @@ export class RpcService {
|
||||
};
|
||||
}
|
||||
case 'get_push_service_delivery_config': {
|
||||
const config = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
|
||||
const config = await this.instanceConfigRepository.getLegacyPushServiceDeliveryWire();
|
||||
return {
|
||||
type: 'get_push_service_delivery_config',
|
||||
data: {config},
|
||||
@@ -1199,12 +1193,9 @@ export class RpcService {
|
||||
longitude: geoipLongitude,
|
||||
rtc_regions: rtcRegions,
|
||||
webauthn_credentials: timeRpcStepSync(responseBuildSteps, 'map_webauthn_credentials', () =>
|
||||
userData.webAuthnCredentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
})),
|
||||
visibleWebAuthnCredentials(userData.webAuthnCredentials).map((cred) =>
|
||||
mapWebAuthnCredentialToResponse(cred, Config.auth.passkeys.rpId),
|
||||
),
|
||||
),
|
||||
version,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,11 +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';
|
||||
const TERMS_URL = 'https://fluxer.app/terms';
|
||||
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];
|
||||
@@ -83,7 +92,6 @@ export interface CreateCheckoutSessionParams {
|
||||
isBusiness?: boolean;
|
||||
}
|
||||
|
||||
const UPI_MANDATE_DESCRIPTION = 'Fluxer Premium';
|
||||
const PIX_UPI_MANDATE_HEADROOM_MULTIPLIER = 1.25;
|
||||
|
||||
interface ResolvedPriceIds {
|
||||
@@ -92,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 {
|
||||
@@ -102,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;
|
||||
@@ -200,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,
|
||||
@@ -218,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: TERMS_URL,
|
||||
}),
|
||||
},
|
||||
},
|
||||
...(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,
|
||||
@@ -247,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',
|
||||
@@ -354,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: {
|
||||
@@ -560,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);
|
||||
@@ -724,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(
|
||||
@@ -989,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);
|
||||
}
|
||||
@@ -1038,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);
|
||||
@@ -1079,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({
|
||||
@@ -1312,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();
|
||||
@@ -1345,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',
|
||||
@@ -1369,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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1391,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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user