Compare commits

...
70 changed files with 7479 additions and 4571 deletions
+4 -3
View File
@@ -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
+69
View File
@@ -10526,6 +10526,7 @@
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
"type": "object",
@@ -10955,6 +10956,7 @@
"voice_noise_suppression",
"push_service_delivery",
"domain_migration",
"altcha_captcha",
"experiment_delivery",
"registration",
"self_hosted",
@@ -11097,6 +11099,10 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
},
"altcha_captcha": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
},
"experiment_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
@@ -15190,6 +15196,27 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"AltchaCaptchaConfigUpdateRequest": {
"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}$"}
},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_enabled": {"type": "boolean"},
"cost": {"type": "integer", "minimum": 1000, "maximum": 100000},
"max_counter": {"type": "integer", "minimum": 100, "maximum": 1000000}
}
},
"DomainMigrationConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15294,6 +15321,48 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"additionalProperties": false
},
"AltchaCaptchaConfigResponse": {
"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": "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}$"}
},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"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",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"excluded_user_ids",
"anonymous_enabled",
"cost",
"max_counter"
],
"additionalProperties": false
},
"DomainMigrationConfigResponse": {
"type": "object",
"properties": {
@@ -27,6 +27,8 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub altcha_captcha: AltchaCaptchaConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
}
@@ -453,6 +455,9 @@ 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 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 VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
@@ -635,6 +640,56 @@ pub struct DomainMigrationConfigUpdateRequest {
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
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 excluded_user_ids: Vec<String>,
pub anonymous_enabled: bool,
pub cost: u32,
pub max_counter: u32,
}
impl Default for AltchaCaptchaConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
anonymous_enabled: false,
cost: 5_000,
max_counter: 10_000,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct AltchaCaptchaConfigUpdateRequest {
#[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 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 ExperimentDeliveryConfigResponse {
@@ -755,6 +810,8 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
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 experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
}
@@ -1094,17 +1151,24 @@ mod tests {
.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 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 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_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
assert_eq!(
@@ -1116,6 +1180,11 @@ mod tests {
.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_delivery)
.expect("serializable generated delivery config"),
@@ -1124,6 +1193,7 @@ mod tests {
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("DomainMigrationConfigResponse", domain_migration),
("AltchaCaptchaConfigResponse", altcha_captcha),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
+131 -18
View File
@@ -4,24 +4,25 @@ use crate::{
api::{
client::AdminApiClient,
types::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, DomainMigrationConfigUpdateRequest,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode,
PushServiceDeliveryConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
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,
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
PremiumMode, PushServiceDeliveryConfigUpdateRequest, RegistrationMode,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
},
},
config::AdminConfig,
@@ -216,6 +217,10 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"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_experiment_delivery" => match build_experiment_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
@@ -728,6 +733,50 @@ fn build_domain_migration_update(
})
}
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_ascii_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",
)?),
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_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -1758,6 +1807,70 @@ mod tests {
);
}
#[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": [],
"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_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
@@ -2,13 +2,15 @@
use crate::{
api::types::{
AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
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, NoiseSuppressionBackend,
PUSH_SERVICE_DELIVERY_DEFAULT_SALT, PendingRegistrationResponse,
PushServiceDeliveryConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -151,6 +153,7 @@ pub fn instance_config_page(
(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))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -1451,6 +1454,145 @@ fn domain_migration_section(
)
}
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" {
(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 experiment_delivery_section(
base: &str,
csrf_token: &str,
+17
View File
@@ -431,6 +431,18 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
},
"altcha_captcha": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 500,
"rollout_salt": "altcha-captcha-v1",
"included_user_ids": [],
"excluded_user_ids": ["1500000000000000003"],
"anonymous_enabled": true,
"cost": 5000,
"max_counter": 10000,
"future_altcha_knob": "argon2id"
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
"mode": "open",
@@ -568,6 +580,11 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
assert!(resp.domain_migration.standalone_forwarding);
assert!(resp.push_service_delivery.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_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));
+1
View File
@@ -56,6 +56,7 @@
"@simplewebauthn/server": "catalog:",
"@types/node": "catalog:",
"@vvo/tzdb": "catalog:",
"altcha-lib": "catalog:",
"archiver": "catalog:",
"argon2": "catalog:",
"bowser": "catalog:",
+3 -1
View File
@@ -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);
}
}
@@ -34,6 +34,7 @@ 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 {
@@ -71,6 +72,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
voiceNoiseSuppression,
pushServiceDelivery,
domainMigration,
altchaCaptcha,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -81,6 +83,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getPushServiceDeliveryConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -114,6 +117,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
voice_noise_suppression: voiceNoiseSuppression,
push_service_delivery: pushServiceDelivery,
domain_migration: domainMigration,
altcha_captcha: altchaCaptcha,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -318,6 +322,18 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.altcha_captcha) {
const patch = omitUndefinedFields(data.altcha_captcha);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateAltchaCaptchaConfig((current) =>
AltchaCaptchaConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.experiment_delivery) {
const patch = data.experiment_delivery;
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
@@ -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);
});
});
@@ -8,6 +8,7 @@ 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 {resolveAltchaCaptchaAssignment} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -29,10 +30,11 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, voiceConfig, domainMigrationConfig] = await Promise.all([
const [delivery, voiceConfig, domainMigrationConfig, altchaCaptchaConfig] = await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
]);
const userId = ctx.get('user').id.toString();
const body: ExperimentAssignmentsResponse = {
@@ -41,6 +43,7 @@ export function ExperimentController(app: HonoApp) {
assignments: {
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId),
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId),
},
};
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
@@ -6,6 +6,10 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {
DEFAULT_DOMAIN_MIGRATION_CONFIG,
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
@@ -57,6 +61,7 @@ describe('GET /experiments', () => {
assignments: {
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
},
});
});
@@ -134,6 +139,62 @@ describe('GET /experiments', () => {
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('serves the delivery cadence from the delivery config and not from the voice config', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setExperimentDeliveryConfig({
@@ -28,6 +28,10 @@ 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,
@@ -68,6 +72,7 @@ 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 DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
const ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
@@ -376,6 +381,7 @@ type StoredConfigSection =
| 'voice noise suppression'
| 'push service delivery'
| 'domain migration'
| 'altcha captcha'
| 'experiment delivery'
| 'instance policy'
| 'integrations'
@@ -522,6 +528,10 @@ function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationCo
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
}
function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig {
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
}
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
}
@@ -1171,6 +1181,7 @@ export class InstanceConfigRepository {
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null);
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null);
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
checkStoredConfig('registration', () =>
@@ -1305,6 +1316,23 @@ export class InstanceConfigRepository {
);
}
async getAltchaCaptchaConfig(): Promise<AltchaCaptchaConfig> {
const raw = await this.getConfig(ALTCHA_CAPTCHA_CONFIG_KEY);
return parseStoredAltchaCaptchaConfig(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 getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
return parseStoredExperimentDeliveryConfig(raw);
@@ -1,7 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHmac} from 'node:crypto';
import {Config} from '@app/api/Config';
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 +12,43 @@ 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();
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null)) return null;
return createAltchaProvider(config);
}
function resolveProviderSecret(
config: InstanceCaptchaEffectiveConfig,
provider: InstanceCaptchaProvider,
@@ -58,11 +92,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 +114,7 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
}) ?? undefined,
});
if (!isValid) {
throw new InvalidCaptchaError();
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
}
}
@@ -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) => {
+8 -1
View File
@@ -27953,7 +27953,8 @@
"type": "object",
"properties": {
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"}
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"},
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"}
},
"additionalProperties": false
}
@@ -31629,6 +31630,12 @@
"additionalProperties": false
},
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
"AltchaCaptchaAssignmentResponse": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
"required": ["enabled"],
"additionalProperties": false
},
"DomainMigrationAssignmentResponse": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
+1
View File
@@ -201,6 +201,7 @@
"@sapphi-red/web-noise-suppressor": "catalog:",
"@simplewebauthn/browser": "catalog:",
"@tanstack/react-virtual": "^3.14.13",
"altcha-lib": "catalog:",
"animejs": "4.5.0",
"bowser": "catalog:",
"clsx": "catalog:",
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {solveChallengeWorkers} from 'altcha-lib';
import type {Challenge} from 'altcha-lib/types';
export type AltchaChallenge = Challenge;
const MAX_SOLVER_WORKERS = 8;
const SOLVE_TIMEOUT_MS = 120_000;
function createSolverWorker(): Worker {
return new Worker(
new URL(/* webpackChunkName: "altcha-solver.worker" */ './AltchaSolverWorker.ts', import.meta.url),
{
type: 'module',
},
);
}
export function readAltchaChallenge(body: unknown): AltchaChallenge | null {
if (typeof body !== 'object' || body === null) return null;
const {captcha_provider: provider, altcha_challenge: challenge} = body as Record<string, unknown>;
if (provider !== 'altcha' || typeof challenge !== 'object' || challenge === null) return null;
const {parameters, signature} = challenge as Record<string, unknown>;
if (typeof parameters !== 'object' || parameters === null || typeof signature !== 'string') return null;
return challenge as AltchaChallenge;
}
export async function solveAltchaChallenge(
challenge: AltchaChallenge,
controller: AbortController,
): Promise<string | null> {
const solution = await solveChallengeWorkers({
challenge,
concurrency: Math.min(MAX_SOLVER_WORKERS, navigator.hardwareConcurrency || 2),
controller,
createWorker: createSolverWorker,
timeout: SOLVE_TIMEOUT_MS,
});
if (!solution) return null;
return btoa(
JSON.stringify({challenge: {parameters: challenge.parameters, signature: challenge.signature}, solution}),
);
}
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {deriveKey} from 'altcha-lib/algorithms/web/pbkdf2';
import {handler} from 'altcha-lib/workers/shared';
handler({deriveKey});
@@ -0,0 +1,16 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0;
}
.text {
font-size: 0.875rem;
line-height: 1.25rem;
text-align: center;
color: var(--text-secondary);
}
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type AltchaChallenge, solveAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
import styles from '@app/features/auth/components/AltchaVerification.module.css';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {Button} from '@app/features/ui/button/Button';
import {Spinner} from '@app/features/ui/components/Spinner';
import {Trans} from '@lingui/react/macro';
import {useCallback, useEffect, useRef, useState} from 'react';
const logger = new Logger('AltchaVerification');
interface AltchaVerificationProps {
challenge: AltchaChallenge;
onVerify: (token: string) => void;
}
export function AltchaVerification({challenge, onVerify}: AltchaVerificationProps) {
const onVerifyRef = useRef(onVerify);
const [attempt, setAttempt] = useState(0);
const [failed, setFailed] = useState(false);
useEffect(() => {
onVerifyRef.current = onVerify;
}, [onVerify]);
useEffect(() => {
const controller = new AbortController();
setFailed(false);
solveAltchaChallenge(challenge, controller).then(
(token) => {
if (controller.signal.aborted) return;
if (token) {
onVerifyRef.current(token);
} else {
setFailed(true);
}
},
(error: unknown) => {
if (controller.signal.aborted) return;
logger.error('ALTCHA solve failed:', error);
setFailed(true);
},
);
return () => controller.abort();
}, [challenge, attempt]);
const handleRetry = useCallback(() => setAttempt((value) => value + 1), []);
if (failed) {
return (
<div className={styles.container} data-flx="auth.altcha-verification.failed">
<p className={styles.text} data-flx="auth.altcha-verification.failed-text">
<Trans>Your browser couldn't finish the check.</Trans>
</p>
<Button small variant="secondary" onClick={handleRetry} data-flx="auth.altcha-verification.retry-button">
<Trans>Try again</Trans>
</Button>
</div>
);
}
return (
<div className={styles.container} role="status" aria-live="polite" data-flx="auth.altcha-verification.solving">
<Spinner data-flx="auth.altcha-verification.spinner" />
<p className={styles.text} data-flx="auth.altcha-verification.solving-text">
<Trans>Checking your browser. This takes a few seconds.</Trans>
</p>
</div>
);
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type AltchaChallenge, readAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
import {CaptchaModal, type CaptchaType} from '@app/features/auth/components/modals/CaptchaModal';
import {http} from '@app/features/platform/transport/RestTransport';
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
@@ -69,7 +70,7 @@ class CaptchaInterceptorState {
return code === 'CAPTCHA_REQUIRED' || code === 'INVALID_CAPTCHA';
}
private showCaptchaModal(): Promise<CaptchaResult> {
private showCaptchaModal(altchaChallenge: AltchaChallenge | null): Promise<CaptchaResult> {
if (this.pendingPromise) {
this.pendingPromise.reject(new Error('Captcha cancelled'));
this.pendingPromise = null;
@@ -95,6 +96,7 @@ class CaptchaInterceptorState {
};
const CaptchaModalWrapper = observer(() => (
<CaptchaModal
altchaChallenge={altchaChallenge}
onVerify={handleVerify}
onCancel={handleCancel}
error={this.state.error}
@@ -119,7 +121,7 @@ class CaptchaInterceptorState {
const errorMessage = replyMessage(reply.body) || i18n._(CAPTCHA_VERIFICATION_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR);
this.state.setError(errorMessage);
this.state.setIsVerifying(false);
const promise = this.showCaptchaModal()
const promise = this.showCaptchaModal(readAltchaChallenge(reply.body))
.then((captchaResult) => {
this.state.setError(null);
this.state.setIsVerifying(false);
@@ -2,6 +2,8 @@
import * as Modal from '@app/features/app/components/dialogs/Modal';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import type {AltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
import {AltchaVerification} from '@app/features/auth/components/AltchaVerification';
import styles from '@app/features/auth/components/modals/CaptchaModal.module.css';
import {TurnstileWidget} from '@app/features/auth/components/TurnstileWidget';
import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -18,7 +20,7 @@ const VERIFY_YOU_RE_HUMAN_DESCRIPTOR = msg({
});
const logger = new Logger('CaptchaModal');
export type CaptchaType = 'turnstile' | 'hcaptcha';
export type CaptchaType = 'turnstile' | 'hcaptcha' | 'altcha';
interface HCaptchaComponentProps {
sitekey: string;
@@ -35,16 +37,26 @@ interface CaptchaModalProps {
onVerify: (token: string, captchaType: CaptchaType) => void;
onCancel?: () => void;
preferredType?: CaptchaType;
altchaChallenge?: AltchaChallenge | null;
error?: string | null;
isVerifying?: boolean;
closeOnVerify?: boolean;
}
export const CaptchaModal = observer(
({onVerify, onCancel, preferredType, error, isVerifying, closeOnVerify = true}: CaptchaModalProps) => {
({
onVerify,
onCancel,
preferredType,
altchaChallenge,
error,
isVerifying,
closeOnVerify = true,
}: CaptchaModalProps) => {
const {i18n} = useLingui();
const hcaptchaRef = useRef<HCaptcha>(null);
const [captchaType, setCaptchaType] = useState<CaptchaType>(() => {
if (altchaChallenge) return 'altcha';
if (preferredType) return preferredType;
if (RuntimeConfig.captchaProvider === 'turnstile' && RuntimeConfig.turnstileSiteKey) {
return 'turnstile';
@@ -123,7 +135,13 @@ export const CaptchaModal = observer(
</div>
)}
<div className={styles.captchaContainer} data-flx="auth.captcha-modal.captcha-container">
{captchaType === 'turnstile' ? (
{captchaType === 'altcha' && altchaChallenge ? (
<AltchaVerification
challenge={altchaChallenge}
onVerify={handleVerify}
data-flx="auth.captcha-modal.altcha-verification"
/>
) : captchaType === 'turnstile' ? (
<TurnstileWidget
sitekey={RuntimeConfig.turnstileSiteKey ?? ''}
onVerify={handleVerify}
File diff suppressed because it is too large Load Diff
@@ -1419,6 +1419,12 @@
{
"msgid": "About me is too long"
},
{
"msgid": "Accept the push relay supplemental privacy notice"
},
{
"msgid": "Accepted"
},
{
"msgid": "Add a Klipy API key to enable GIF search at runtime."
},
@@ -1719,6 +1725,9 @@
{
"msgid": "Changed the voice region from {oldRegion} to {newRegion}."
},
{
"msgid": "Checking your browser. This takes a few seconds."
},
{
"msgid": "Choices"
},
@@ -2235,6 +2244,9 @@
{
"msgid": "No part of {query} could be applied. Fix the underlined value or add something to search for."
},
{
"msgid": "Not accepted"
},
{
"msgid": "Nothing to search for"
},
@@ -2379,12 +2391,18 @@
{
"msgid": "Public registration is closed."
},
{
"msgid": "Push relay notice"
},
{
"msgid": "Read messages sent before they opened a channel. Without it, they only see messages that arrive while it is open."
},
{
"msgid": "Read messages sent in this channel before they opened it. Without it, they only see messages that arrive while it is open."
},
{
"msgid": "Read the supplemental privacy notice"
},
{
"msgid": "Reason (optional)."
},
@@ -3057,6 +3075,9 @@
{
"msgid": "The new price is already scheduled for {effectiveDate}."
},
{
"msgid": "The official Fluxer mobile apps receive notifications through Fluxer's push relay, which hands them to Apple and Google. Self-hosted UnifiedPush and ntfy endpoints never reach the relay and need no agreement."
},
{
"msgid": "The override allowed {permissions}."
},
@@ -3327,6 +3348,9 @@
{
"msgid": "Your account works normally again straight away and nothing is removed. We can ask for this check again later."
},
{
"msgid": "Your browser couldn't finish the check."
},
{
"msgid": "Your capture device is sending {deliveredResolution} instead of {resolution}."
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -37,6 +37,7 @@ Missing settings use the defaults documented below. Invalid stored configuration
| voice_noise_suppression | [voice noise suppression configuration](#voice-noise-suppression-configuration-object) object | Client-side noise suppression rollout |
| push_service_delivery | [push service delivery configuration](#push-service-delivery-configuration-object) object | Push service delivery rollout |
| domain_migration | [domain migration configuration](#domain-migration-configuration-object) object | Web domain migration rollout |
| altcha_captcha | [ALTCHA captcha configuration](#altcha-captcha-configuration-object) object | ALTCHA proof-of-work captcha rollout |
| experiment_delivery | [experiment delivery configuration](#experiment-delivery-configuration-object) object | Cadence every client polls the experiments route on |
| registration | [registration configuration](#registration-configuration-object) object | Registration policy, issued URLs, and pending registrations |
| self_hosted | boolean | Whether the deployment runs in self-hosted mode |
@@ -173,6 +174,32 @@ Only the official web client acts on this configuration. On any other instance i
Setting `enabled` to false stops new migrations and also stops forwarding from the legacy origin for clients that already moved. Excluding an account only stops a migration that has not started yet.
:::
## ALTCHA captcha configuration object
The instance rollout that replaces the configured captcha provider with an ALTCHA proof-of-work challenge the API issues and verifies itself. [Experiments](/http-api/experiments/#altcha-captcha-assignment-object) defines what a signed-in client resolves from it, and [CAPTCHA handling](/topics/captcha/#altcha-proof-of-work) defines the challenge exchange.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| enabled | boolean | Whether the rollout runs at all (default false) |
| config_version | integer | Revision counter, raised by Fluxer and never accepted from a request |
| rollout_basis_points | integer | Share of accounts the rollout selects, in basis points (0-10000, default 0) |
| rollout_salt | string | Salt of the sampling hash (1-64 printable ASCII characters, default `altcha-captcha-v1`) |
| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) |
| excluded_user_ids | array[snowflake] | Accounts the rollout never selects, up to 1000 entries (default empty) |
| anonymous_enabled | boolean | Whether logged-out requests get ALTCHA (default false) |
| cost | integer | PBKDF2 iterations per solving attempt (1000-100000, default 5000) |
| max_counter | integer | Upper bound of the hidden counter a client searches for (100-1000000, default 10000) |
Every field is present on read. An absent document or missing field uses the defaults above.
`excluded_user_ids` is applied before `included_user_ids`, so the rollout never selects an account in both. `anonymous_enabled` has no effect on signed-in requests, and the account rules have no effect on logged-out ones.
Each challenge hides its counter between half of `max_counter` and `max_counter`, and a client tries counters from 0 upward. Solve time grows with `cost` times `max_counter`. Fluxer spends one attempt at `cost` to issue each challenge.
The rollout only changes which provider answers a captcha that is already required. While the instance captcha provider is `none`, it has no effect.
## Experiment delivery configuration object
How often a client polls [Get experiment assignments](/http-api/experiments/#get-experiment-assignments), and how widely those polls are spread. The setting is instance-wide and applies to every experiment at once, so adding an experiment adds no second cadence to tune.
@@ -583,6 +610,7 @@ The body has one optional object for each section. Fluxer leaves an absent secti
| voice_noise_suppression? | object | Any subset of the [noise suppression](#voice-noise-suppression-configuration-object) fields |
| push_service_delivery? | object | Any subset of the [push service delivery](#push-service-delivery-configuration-object) fields |
| domain_migration? | object | Any subset of the [domain migration](#domain-migration-configuration-object) fields |
| altcha_captcha? | object | Any subset of the [ALTCHA captcha](#altcha-captcha-configuration-object) fields |
| experiment_delivery? | object | Any subset of the [experiment delivery](#experiment-delivery-configuration-object) fields |
| registration? | object | `mode` and `admin_registration_urls_enabled` |
| app_public?<sup>2</sup> | object | `branding`, `setup`, `legal`, and `registration` sub-objects, each merged field by field |
@@ -600,6 +628,8 @@ The body has one optional object for each section. Fluxer leaves an absent secti
`domain_migration` works the same way, over the [domain migration configuration](#domain-migration-configuration-object) fields and its own `config_version`.
`altcha_captcha` works the same way, over the [ALTCHA captcha configuration](#altcha-captcha-configuration-object) fields and its own `config_version`.
`experiment_delivery` takes both [experiment delivery configuration](#experiment-delivery-configuration-object) fields, each bound as documented there. It is a section of its own, so a write to it changes no `config_version` and changes no assignment, only the cadence on which clients ask for one.
<sup>3</sup> A secret such as `klipy_api_key`, `api_key`, `hcaptcha_secret_key`, `turnstile_secret_key`, or the SMTP `password` is written when supplied and left alone when absent. `integrations.bluesky.keys` is the only way to write the Bluesky signing keys counted as `bluesky.key_count`. It takes up to 8 entries of `kid` (1-255 characters) and nullable `private_key` (up to 10000 characters), and replaces the stored key set outright
@@ -636,7 +666,7 @@ Fluxer skips URL validation while the merged configuration leaves single sign-on
| 400 | [error response](/admin-api/#error-response) | A policy transition is refused, returned as `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED` |
:::caution[Sections are applied one after another]
The order is `gateway_rollout`, `voice_noise_suppression`, `push_service_delivery`, `domain_migration`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written.
The order is `gateway_rollout`, `voice_noise_suppression`, `push_service_delivery`, `domain_migration`, `altcha_captcha`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written.
:::
### Side effects
@@ -6,7 +6,7 @@ description: The experiment assignments envelope, the revalidation and polling c
import RouteHeader from '@/components/RouteHeader.astro';
An experiment is an instance-wide rollout that an operator configures. For each account, Fluxer works out from that configuration whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. This server defines `voice_noise_suppression`, whose placement protocol [Voice](/voice/) defines, and `domain_migration`.
An experiment is an instance-wide rollout that an operator configures. For each account, Fluxer works out from that configuration whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. This server defines `voice_noise_suppression`, whose placement protocol [Voice](/voice/) defines, `domain_migration` and `altcha_captcha`.
Every assignment is advice. A client that ignores one behaves as it does with the rollout off, and no route and no Gateway event reports what a client actually ran.
@@ -34,6 +34,7 @@ One entry per experiment. The envelope reports this object even when it is empty
| --- | --- | --- |
| voice_noise_suppression? | [noise suppression assignment](#noise-suppression-assignment-object) object | The caller's noise suppression assignment |
| domain_migration? | [domain migration assignment](#domain-migration-assignment-object) object | The caller's web domain migration assignment |
| altcha_captcha? | [ALTCHA captcha assignment](#altcha-captcha-assignment-object) object | The caller's captcha provider assignment |
Ignore unknown experiments and treat a missing experiment as off.
@@ -116,6 +117,20 @@ A caller is drawn either by the operator's allowlist or by the sampled share of
Only the official web client acts on this assignment, and only on its legacy origins. Every other client ignores it. The logged-out share and the instance-wide switch are published in the [instance discovery document](/http-api/instance/#domain-migration-object) instead, because a client that holds no credential cannot read this route.
## ALTCHA captcha assignment object
One resolution of the instance ALTCHA captcha rollout against one account. This server version writes the key on every response.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| enabled | boolean | Whether the caller's captcha challenges are ALTCHA proof-of-work challenges |
A caller is drawn either by the operator's allowlist or by the sampled share of the account population. `enabled` is false in every other case, the rollout being off included.
The assignment is informational. The server applies the same resolution to every request that needs a captcha and names the provider in the [captcha error](/topics/captcha/#altcha-proof-of-work), so a client needs no copy of this value to answer a challenge.
## Get experiment assignments
<RouteHeader method="GET" path="/v1/experiments" bot />
+17 -2
View File
@@ -37,11 +37,11 @@ Fluxer skips the check in three cases, and the operation then proceeds with no C
| Field | Type | Description |
| --- | --- | --- |
| X-Captcha-Token?<sup>1</sup> | string | The solution issued by the provider widget |
| X-Captcha-Type?<sup>2</sup> | string | The provider that produced the solution, accepting `hcaptcha` or `turnstile` |
| X-Captcha-Type?<sup>2</sup> | string | The provider that produced the solution, accepting `hcaptcha`, `turnstile` or `altcha` |
<sup>1</sup> An absent or empty value on a gated operation returns 400 `CAPTCHA_REQUIRED`
<sup>2</sup> An absent value selects the instance's configured provider, and so does any value other than `hcaptcha` or `turnstile`. Naming a provider the instance holds no secret key for returns 400 `INVALID_CAPTCHA`.
<sup>2</sup> An absent value selects the instance's configured provider, and so does any value other than `hcaptcha`, `turnstile` or `altcha`. Naming a provider the instance holds no secret key for returns 400 `INVALID_CAPTCHA`. The value `altcha` is accepted only from a requester the [ALTCHA rollout](#altcha-proof-of-work) selects.
## The retry handshake
@@ -53,6 +53,21 @@ An accepted solution allows the operation to proceed. A rejected solution return
The provider treats an already redeemed solution as invalid. A client obtains a new solution before retrying after `INVALID_CAPTCHA` and MUST NOT replay the previous `X-Captcha-Token` value.
:::
## ALTCHA proof-of-work
An operator can move selected requesters from the configured provider to an [ALTCHA](https://altcha.org) proof-of-work challenge that the API issues and verifies itself. The [ALTCHA captcha configuration](/admin-api/instance/#altcha-captcha-configuration-object) selects signed-in accounts by rollout share and allowlist, and logged-out requests with one switch. The check applies only where a captcha is already required, so an instance whose `provider` is `none` never serves it.
For a selected requester, the `CAPTCHA_REQUIRED` and `INVALID_CAPTCHA` bodies have two more fields.
| Field | Type | Description |
| --- | --- | --- |
| captcha_provider | string | Always `altcha` |
| altcha_challenge | object | An ALTCHA v2 challenge, with `parameters` and `signature` |
The challenge uses `PBKDF2/SHA-256` and expires 10 minutes after it is issued. Solve it with an ALTCHA v2 solver, then retry with `X-Captcha-Type` set to `altcha` and `X-Captcha-Token` set to the base64 encoding of the JSON object `{"challenge": <the challenge>, "solution": <the solution>}`. Each challenge is accepted once. A replayed, expired or wrong solution returns 400 `INVALID_CAPTCHA` with a new challenge.
A selected requester can still answer with the configured provider, so a client that does not read these fields keeps working.
## Provider verification
A rejected solution or unavailable provider returns 400 `INVALID_CAPTCHA`. The response does not distinguish between these causes.
+5 -4
View File
@@ -2,17 +2,18 @@
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import type {FluxerErrorData} from '@fluxer/errors/src/FluxerError';
export class CaptchaRequiredError extends BadRequestError {
constructor() {
super({code: APIErrorCodes.CAPTCHA_REQUIRED});
constructor(data?: FluxerErrorData) {
super({code: APIErrorCodes.CAPTCHA_REQUIRED, data});
this.name = 'CaptchaRequiredError';
}
}
export class InvalidCaptchaError extends BadRequestError {
constructor() {
super({code: APIErrorCodes.INVALID_CAPTCHA});
constructor(data?: FluxerErrorData) {
super({code: APIErrorCodes.INVALID_CAPTCHA, data});
this.name = 'InvalidCaptchaError';
}
}
@@ -11,6 +11,10 @@ import {ADMIN_ACL_COUNT, AdminAclType} from '@fluxer/schema/src/domains/admin/Ad
import {AdminArchiveResponseSchema} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
import {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
import {UserAdminResponseSchema} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {
AltchaCaptchaConfigResponse,
AltchaCaptchaConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {
DomainMigrationConfigResponse,
DomainMigrationConfigUpdateRequest,
@@ -654,6 +658,7 @@ export const InstanceConfigResponse = z.object({
voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
push_service_delivery: PushServiceDeliveryConfigResponse,
domain_migration: DomainMigrationConfigResponse,
altcha_captcha: AltchaCaptchaConfigResponse,
experiment_delivery: ExperimentDeliveryConfigResponse,
registration: InstanceRegistrationResponse,
self_hosted: z.boolean(),
@@ -692,6 +697,7 @@ export const InstanceConfigUpdateRequest = z.object({
voice_noise_suppression: VoiceNoiseSuppressionConfigUpdateRequest.nullish(),
push_service_delivery: PushServiceDeliveryConfigUpdateRequest.nullish(),
domain_migration: DomainMigrationConfigUpdateRequest.nullish(),
altcha_captcha: AltchaCaptchaConfigUpdateRequest.nullish(),
experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(),
registration: z
.object({
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type AltchaCaptchaConfig,
AltchaCaptchaConfigSchema,
AltchaCaptchaConfigUpdateRequest,
altchaCaptchaAppliesTo,
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
resolveAltchaCaptchaAssignment,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {describe, expect, test} from 'vitest';
const TARGETED_USER_ID = '1000000000000000001';
function createConfig(overrides: Partial<AltchaCaptchaConfig> = {}): AltchaCaptchaConfig {
return {...DEFAULT_ALTCHA_CAPTCHA_CONFIG, included_user_ids: [], excluded_user_ids: [], ...overrides};
}
function syntheticUserIds(count: number): Array<string> {
return Array.from({length: count}, (_, index) => (1400000000000000000n + BigInt(index)).toString());
}
describe('altcha captcha configuration', () => {
test('defaults to disabled with no anonymous traffic', () => {
expect(AltchaCaptchaConfigSchema.parse({})).toEqual({
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: 'altcha-captcha-v1',
included_user_ids: [],
excluded_user_ids: [],
anonymous_enabled: false,
cost: 5000,
max_counter: 10000,
});
});
test('rejects difficulty outside the supported range', () => {
expect(AltchaCaptchaConfigUpdateRequest.safeParse({cost: 999}).success).toBe(false);
expect(AltchaCaptchaConfigUpdateRequest.safeParse({cost: 100001}).success).toBe(false);
expect(AltchaCaptchaConfigUpdateRequest.safeParse({max_counter: 99}).success).toBe(false);
expect(AltchaCaptchaConfigUpdateRequest.safeParse({max_counter: 1000001}).success).toBe(false);
expect(AltchaCaptchaConfigUpdateRequest.safeParse({config_version: 3}).data).toEqual({});
});
});
describe('resolveAltchaCaptchaAssignment', () => {
test('serves nobody while disabled, even included users', () => {
const config = createConfig({rollout_basis_points: 10000, included_user_ids: [TARGETED_USER_ID]});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
});
test('applies exclusions before inclusions', () => {
const config = createConfig({
enabled: true,
included_user_ids: [TARGETED_USER_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
});
test('serves included users at zero rollout', () => {
const config = createConfig({enabled: true, included_user_ids: [TARGETED_USER_ID]});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: true});
});
test('buckets the rollout by salt and user id', () => {
const config = createConfig({enabled: true, rollout_basis_points: 2500});
for (const userId of syntheticUserIds(200)) {
expect(resolveAltchaCaptchaAssignment(config, userId).enabled).toBe(
experimentBucket(userId, config.rollout_salt) < 2500,
);
}
});
});
describe('altchaCaptchaAppliesTo', () => {
test('serves anonymous requests only when anonymous_enabled is set', () => {
expect(altchaCaptchaAppliesTo(createConfig({enabled: true}), null)).toBe(false);
expect(altchaCaptchaAppliesTo(createConfig({enabled: true, anonymous_enabled: true}), null)).toBe(true);
expect(altchaCaptchaAppliesTo(createConfig({anonymous_enabled: true}), null)).toBe(false);
});
test('keeps signed-in users on their own bucket regardless of the anonymous switch', () => {
const config = createConfig({enabled: true, anonymous_enabled: true, excluded_user_ids: [TARGETED_USER_ID]});
expect(altchaCaptchaAppliesTo(config, TARGETED_USER_ID)).toBe(false);
});
});
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
const ALTCHA_CAPTCHA_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
const ALTCHA_CAPTCHA_MAX_TARGETED_USERS = 1000;
const DEFAULT_ALTCHA_CAPTCHA_SALT = 'altcha-captcha-v1';
export const ALTCHA_CAPTCHA_MIN_COST = 1000;
export const ALTCHA_CAPTCHA_MAX_COST = 100000;
export const ALTCHA_CAPTCHA_MIN_MAX_COUNTER = 100;
export const ALTCHA_CAPTCHA_MAX_MAX_COUNTER = 1000000;
const ALTCHA_CAPTCHA_SALT_PATTERN = /^[\x20-\x7e]+$/u;
const AltchaCaptchaTargetIdSchema = z.string().regex(/^\d{1,20}$/u);
const AltchaCaptchaTargetedUserIdsSchema = z.array(AltchaCaptchaTargetIdSchema).max(ALTCHA_CAPTCHA_MAX_TARGETED_USERS);
const altchaCaptchaConfigFields = {
enabled: z.boolean(),
config_version: z.number().int().min(0),
rollout_basis_points: z.number().int().min(0).max(ALTCHA_CAPTCHA_ROLLOUT_BASIS_POINTS_MAX),
rollout_salt: z.string().trim().min(1).max(64).regex(ALTCHA_CAPTCHA_SALT_PATTERN),
included_user_ids: AltchaCaptchaTargetedUserIdsSchema,
excluded_user_ids: AltchaCaptchaTargetedUserIdsSchema,
anonymous_enabled: z.boolean(),
cost: z.number().int().min(ALTCHA_CAPTCHA_MIN_COST).max(ALTCHA_CAPTCHA_MAX_COST),
max_counter: z.number().int().min(ALTCHA_CAPTCHA_MIN_MAX_COUNTER).max(ALTCHA_CAPTCHA_MAX_MAX_COUNTER),
};
export const AltchaCaptchaConfigSchema = z.object({
enabled: altchaCaptchaConfigFields.enabled.default(false),
config_version: altchaCaptchaConfigFields.config_version.default(0),
rollout_basis_points: altchaCaptchaConfigFields.rollout_basis_points.default(0),
rollout_salt: altchaCaptchaConfigFields.rollout_salt.default(DEFAULT_ALTCHA_CAPTCHA_SALT),
included_user_ids: altchaCaptchaConfigFields.included_user_ids.default([]),
excluded_user_ids: altchaCaptchaConfigFields.excluded_user_ids.default([]),
anonymous_enabled: altchaCaptchaConfigFields.anonymous_enabled.default(false),
cost: altchaCaptchaConfigFields.cost.default(5000),
max_counter: altchaCaptchaConfigFields.max_counter.default(10000),
});
export type AltchaCaptchaConfig = z.infer<typeof AltchaCaptchaConfigSchema>;
export const DEFAULT_ALTCHA_CAPTCHA_CONFIG: AltchaCaptchaConfig = AltchaCaptchaConfigSchema.parse({});
export const AltchaCaptchaConfigUpdateRequest = z
.object(altchaCaptchaConfigFields)
.omit({config_version: true})
.partial();
export type AltchaCaptchaConfigUpdateRequest = z.infer<typeof AltchaCaptchaConfigUpdateRequest>;
export const AltchaCaptchaConfigResponse = AltchaCaptchaConfigSchema;
export type AltchaCaptchaConfigResponse = z.infer<typeof AltchaCaptchaConfigResponse>;
export const AltchaCaptchaAssignmentResponse = z.object({
enabled: altchaCaptchaConfigFields.enabled,
});
export type AltchaCaptchaAssignmentResponse = z.infer<typeof AltchaCaptchaAssignmentResponse>;
export const INERT_ALTCHA_CAPTCHA_ASSIGNMENT: AltchaCaptchaAssignmentResponse = {
enabled: false,
};
export function resolveAltchaCaptchaAssignment(
config: AltchaCaptchaConfig,
userId: string,
): AltchaCaptchaAssignmentResponse {
if (!config.enabled) return {...INERT_ALTCHA_CAPTCHA_ASSIGNMENT};
if (config.excluded_user_ids.includes(userId)) return {...INERT_ALTCHA_CAPTCHA_ASSIGNMENT};
if (config.included_user_ids.includes(userId)) return {enabled: true};
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
}
export function altchaCaptchaAppliesTo(config: AltchaCaptchaConfig, userId: string | null): boolean {
if (userId === null) return config.enabled && config.anonymous_enabled;
return resolveAltchaCaptchaAssignment(config, userId).enabled;
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AltchaCaptchaAssignmentResponse} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {
DomainMigrationAssignmentResponse,
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
@@ -47,6 +48,7 @@ export type ExperimentDeliveryConfigResponse = z.infer<typeof ExperimentDelivery
const ExperimentAssignmentsSchema = z.object({
voice_noise_suppression: VoiceNoiseSuppressionAssignmentResponse.optional(),
domain_migration: DomainMigrationAssignmentResponse.optional(),
altcha_captcha: AltchaCaptchaAssignmentResponse.optional(),
});
export const ExperimentAssignmentsResponse = z.object({
+67
View File
@@ -186,6 +186,9 @@ catalogs:
'@webgpu/types':
specifier: 0.1.72
version: 0.1.72
altcha-lib:
specifier: 2.5.0
version: 2.5.0
archiver:
specifier: 8.0.0
version: 8.0.0
@@ -650,6 +653,9 @@ importers:
'@vvo/tzdb':
specifier: 'catalog:'
version: 6.198.0
altcha-lib:
specifier: 'catalog:'
version: 2.5.0([email protected])
archiver:
specifier: 'catalog:'
version: 8.0.0
@@ -778,9 +784,15 @@ importers:
'@fluxer/logger':
specifier: workspace:*
version: link:../../../packages/logger
altcha-lib:
specifier: 'catalog:'
version: 2.5.0([email protected])
itty-time:
specifier: 'catalog:'
version: 2.0.2
zod:
specifier: 'catalog:'
version: 4.6.5
devDependencies:
'@types/node':
specifier: 'catalog:'
@@ -1484,6 +1496,9 @@ importers:
'@tanstack/react-virtual':
specifier: ^3.14.13
version: 3.14.13([email protected]([email protected]))([email protected])
altcha-lib:
specifier: 'catalog:'
version: 2.5.0([email protected])
animejs:
specifier: 4.5.0
version: 4.5.0
@@ -6923,6 +6938,54 @@ packages:
[email protected]:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
[email protected]:
resolution: {integrity: sha512-MiqlwnImoooVTmFzZ8odhpqtCsSl7M/6cmgaX42Tkuvd41e5IVveg6hiw/e9rXSb6dm1RvniPmeURwO64wWTuQ==}
hasBin: true
peerDependencies:
'@fastify/cookie': ^11.0.0
'@fastify/cors': ^11.0.0
'@fastify/formbody': ^8.0.0 || ^9.0.0
'@nestjs/common': ^10.0.0 || ^11.0.0 || ^12.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0 || ^12.0.0
'@nestjs/platform-express': ^10.0.0 || ^11.0.0 || ^12.0.0
'@nestjs/platform-fastify': ^10.0.0 || ^11.0.0 || ^12.0.0
'@sveltejs/kit': ^2.0.0
cookie-parser: ^1.0.0
cors: ^2.0.0
express: ^4.0.0 || ^5.0.0
fastify: ^5.0.0
h3: ^2.0.0
hono: ^4.0.0
peerDependenciesMeta:
'@fastify/cookie':
optional: true
'@fastify/cors':
optional: true
'@fastify/formbody':
optional: true
'@nestjs/common':
optional: true
'@nestjs/core':
optional: true
'@nestjs/platform-express':
optional: true
'@nestjs/platform-fastify':
optional: true
'@sveltejs/kit':
optional: true
cookie-parser:
optional: true
cors:
optional: true
express:
optional: true
fastify:
optional: true
h3:
optional: true
hono:
optional: true
[email protected]:
resolution: {integrity: sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==}
hasBin: true
@@ -15808,6 +15871,10 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
[email protected]([email protected]):
optionalDependencies:
hono: 4.13.8
[email protected]:
dependencies:
process-ancestry: 0.1.0
+1
View File
@@ -97,6 +97,7 @@ catalog:
'@vvo/tzdb': 6.198.0
'@webgpu/types': 0.1.72
ajv: 8.20.0
altcha-lib: 2.5.0
archiver: 8.0.0
argon2: 0.44.0
astro: 7.3.2