Compare commits

...
Author SHA1 Message Date
Weblate 444dc2b7d4 Merge remote-tracking branch 'origin/main' 2026-09-27 21:29:29 +00:00
HampusandGitHub e2d05a44a8 fix(push): stop retrying relay rate limit refusals (#2993) 2026-09-27 23:29:27 +02:00
Weblate 669bd3c581 Merge remote-tracking branch 'origin/main' 2026-09-27 19:22:45 +00:00
HampusandGitHub 30ba55bd4d fix(gateway): parse push relay hosts as binaries (#2989) 2026-09-27 21:22:45 +02:00
Weblate d4e93d3e84 Merge remote-tracking branch 'origin/main' 2026-09-27 19:19:37 +00:00
HampusandGitHub 9def9fbef6 feat(api): accept CIDR ranges in FLUXER_API_IP_BAN_EXEMPT_IPS (#2988) 2026-09-27 21:19:35 +02:00
Weblate 7c82ca1102 Merge remote-tracking branch 'origin/main' 2026-09-27 19:15:35 +00:00
HampusandGitHub fa3fd0027c fix(i18n): translate the push relay notice strings (#2987) 2026-09-27 21:15:33 +02:00
Weblate 83c1710b47 Merge remote-tracking branch 'origin/main' 2026-09-27 19:03:44 +00:00
HampusandGitHub 7e1b934637 feat(captcha): add ALTCHA proof-of-work captcha experiment (#2986) 2026-09-27 21:02:55 +02:00
Weblate 48cf56e732 Merge remote-tracking branch 'origin/main' 2026-09-27 18:50:09 +00:00
HampusandGitHub 33a118d12a docs(readme): list the Google Play beta first for Android (#2985) 2026-09-27 20:49:39 +02:00
Weblate 6b52de6354 Merge remote-tracking branch 'origin/main' 2026-09-27 18:33:11 +00:00
HampusandGitHub 01f53a168d feat(push): gate relay delivery on operator consent (#2984) 2026-09-27 20:33:10 +02:00
Weblate 59840af1bf Merge remote-tracking branch 'origin/main' 2026-09-27 18:13:15 +00:00
HampusandGitHub 336b8b7dcd fix(forward): make an @silent comment silence the forward too (#2983) 2026-09-27 20:13:14 +02:00
Weblate 2df37450ae Merge remote-tracking branch 'origin/main' 2026-09-27 17:37:41 +00:00
HampusandGitHub 48d0034239 fix(app-proxy): trust the Play app signing certificate (#2982) 2026-09-27 19:37:40 +02:00
Weblate 2fc97f4544 Merge remote-tracking branch 'origin/main' 2026-09-27 14:18:06 +00:00
HampusandGitHub 677ef8491e fix(desktop): back off failed app loads and offer a retry (#2980) 2026-09-27 16:18:01 +02:00
Weblate 2bf3a610f4 Merge remote-tracking branch 'origin/main' 2026-09-27 11:33:25 +00:00
HampusandGitHub 6a6119ed1e fix(push): preview forwarded message content (#2979) 2026-09-27 13:33:22 +02:00
Weblate 86d92564c0 Merge remote-tracking branch 'origin/main' 2026-09-27 11:33:20 +00:00
HampusandGitHub 931327d1dc fix(push): stop sending notifications for system messages (#2978) 2026-09-27 13:33:18 +02:00
Weblate 2edd0f188f Merge remote-tracking branch 'origin/main' 2026-09-26 11:48:31 +00:00
HampusandGitHub 858a2d9e2b fix(oauth): stop granting scopes the user turned off (#2968) 2026-09-26 13:48:23 +02:00
Weblate cb55e62bd9 Merge remote-tracking branch 'origin/main' 2026-09-25 20:35:45 +00:00
HampusandGitHub 841fb7af41 feat(auth): migrate passkeys to fluxer.com (#2964) 2026-09-25 22:33:50 +02:00
Weblate ed579aaeec Merge remote-tracking branch 'origin/main' 2026-09-25 18:13:02 +00:00
HampusandGitHub 08e65d41c0 fix(api): clear the perks-sanitized latch when premium returns (#2963) 2026-09-25 20:13:00 +02:00
Weblate 0808bf680f Merge remote-tracking branch 'origin/main' 2026-09-25 18:10:57 +00:00
HampusandGitHub f76c4dc041 fix(api): cancel only the subscription the refund belongs to (#2962) 2026-09-25 20:10:54 +02:00
Weblate e75ed31a4c Merge remote-tracking branch 'origin/main' 2026-09-25 16:16:27 +00:00
HampusandGitHub f1f8ba2031 fix(app): add copy link to link channel context menus (#2959) 2026-09-25 18:16:20 +02:00
Weblate b6e3fa47a8 Merge remote-tracking branch 'origin/main' 2026-09-25 15:46:14 +00:00
HampusandGitHub 5ab8d745c0 fix(i18n): correct the fluxer.com migration translations (#2958) 2026-09-25 17:46:07 +02:00
Weblate 690cca6edb Merge remote-tracking branch 'origin/main' 2026-09-25 15:43:23 +00:00
HampusandGitHub ff62bc89a4 feat(app): add passkey popup bridge for password managers (#2957) 2026-09-25 17:43:19 +02:00
Weblate f28937d86f Merge remote-tracking branch 'origin/main' 2026-09-25 14:45:02 +00:00
HampusandGitHub 838bbdb5ec fix(app): only start the domain migration when the app opens (#2956) 2026-09-25 16:44:58 +02:00
Weblate 6b04ad25b1 Merge remote-tracking branch 'origin/main' 2026-09-25 11:59:35 +00:00
HampusandGitHub 1c36a59b2c feat(app): rework quick switcher ranking and show origin icons (#2953) 2026-09-25 13:59:25 +02:00
Weblate 63980fca11 Merge remote-tracking branch 'origin/main' 2026-09-25 11:43:39 +00:00
HampusandGitHub 6730a242db feat(web): prepare the fluxer.com domain migration (#2952) 2026-09-25 13:43:34 +02:00
Weblate 0d92584431 Merge remote-tracking branch 'origin/main' 2026-09-25 11:42:06 +00:00
HampusandGitHub e62ae77643 refactor(config): trim the default passkey origin list (#2951) 2026-09-25 13:42:02 +02:00
Weblate 0e2b7a1a2b Merge remote-tracking branch 'origin/main' 2026-09-25 11:12:21 +00:00
HampusandGitHub f4f39e6a89 feat(app): show where forward destinations come from (#2950) 2026-09-25 13:12:17 +02:00
Weblate bbe55397c3 Merge remote-tracking branch 'origin/main' 2026-09-24 21:38:07 +00:00
HampusandGitHub 00bf74cef5 fix(app): handle swapped overwrites when comparing channels (#2949) 2026-09-24 23:38:04 +02:00
Weblate 0b5514f663 Merge remote-tracking branch 'origin/main' 2026-09-24 20:50:38 +00:00
HampusandGitHub c1c45d835f fix(app): only parse markdown in rich embeds (#2948) 2026-09-24 22:50:34 +02:00
Weblate 380995cc19 Merge remote-tracking branch 'origin/main' 2026-09-24 20:45:58 +00:00
Weblate e3522ec7be Merge remote-tracking branch 'origin/main' 2026-09-24 15:52:37 +00:00
Weblate 56bc0e5612 Merge remote-tracking branch 'origin/main' 2026-09-24 15:09:53 +00:00
Weblate d501a1ea68 Merge remote-tracking branch 'origin/main' 2026-09-24 15:09:40 +00:00
Weblate 6e3739bb9b Merge remote-tracking branch 'origin/main' 2026-09-24 14:25:29 +00:00
Weblate b4f789a5ba Merge remote-tracking branch 'origin/main' 2026-09-24 14:25:10 +00:00
Weblate 49761959b1 Merge remote-tracking branch 'origin/main' 2026-09-24 14:21:46 +00:00
Weblate 34e03c9732 Merge remote-tracking branch 'origin/main' 2026-09-24 14:07:06 +00:00
Weblate 58d6e2d4bf Merge remote-tracking branch 'origin/main' 2026-09-24 13:15:50 +00:00
Weblate 4766ce7974 Merge remote-tracking branch 'origin/main' 2026-09-24 13:04:28 +00:00
Weblate 9e6aa67834 Merge remote-tracking branch 'origin/main' 2026-09-24 13:04:06 +00:00
Weblate 57832208d5 Merge remote-tracking branch 'origin/main' 2026-09-24 13:03:37 +00:00
Weblate b35c85fc54 Merge remote-tracking branch 'origin/main' 2026-09-24 12:57:45 +00:00
Weblate 7f58fba66d Merge remote-tracking branch 'origin/main' 2026-09-24 12:51:09 +00:00
Weblate 5b35d6da7b Merge remote-tracking branch 'origin/main' 2026-09-24 12:04:12 +00:00
Weblate 53b9f14f35 Merge remote-tracking branch 'origin/main' 2026-09-24 01:41:30 +00:00
Weblate bb83a6c042 Merge remote-tracking branch 'origin/main' 2026-09-24 00:14:11 +00:00
WeblateandHampus e7125e4e62 Translated using Weblate (Ukrainian)
Currently translated at 98.5% (470 of 477 strings)

Co-authored-by: Hampus <[email protected]>
Translate-URL: https://weblate.fluxer.tools/projects/fluxer/errors/uk/
Translation: Fluxer/Error messages
2026-09-24 00:06:54 +00:00
363 changed files with 43497 additions and 18765 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
+154 -5
View File
@@ -10525,6 +10525,8 @@
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"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",
@@ -10953,6 +10955,8 @@
"gateway_rollout",
"voice_noise_suppression",
"push_service_delivery",
"domain_migration",
"altcha_captcha",
"experiment_delivery",
"registration",
"self_hosted",
@@ -11091,6 +11095,14 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
},
"domain_migration": {
"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"}]
@@ -15184,6 +15196,47 @@
"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": {
"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_rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"standalone_forwarding": {"type": "boolean"}
}
},
"PushServiceDeliveryConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15199,7 +15252,8 @@
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
},
"relay_consent_accepted": {"type": "boolean"}
}
},
"VoiceNoiseSuppressionConfigUpdateRequest": {
@@ -15267,6 +15321,88 @@
"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": {
"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}$"}
},
"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",
"excluded_user_ids",
"anonymous_rollout_basis_points",
"standalone_forwarding"
],
"additionalProperties": false
},
"PushServiceDeliveryConfigResponse": {
"type": "object",
"properties": {
@@ -15291,7 +15427,16 @@
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
},
"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": [
"enabled",
@@ -15299,7 +15444,10 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"excluded_user_ids"
"excluded_user_ids",
"relay_consent_accepted",
"relay_consent_accepted_at",
"relay_consent_accepted_by"
],
"additionalProperties": false
},
@@ -15645,9 +15793,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": {
@@ -25,6 +25,10 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub push_service_delivery: PushServiceDeliveryConfigResponse,
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub altcha_captcha: AltchaCaptchaConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
}
@@ -450,6 +454,10 @@ 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 {
@@ -549,6 +557,9 @@ pub struct PushServiceDeliveryConfigResponse {
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
pub relay_consent_accepted: bool,
pub relay_consent_accepted_at: Option<String>,
pub relay_consent_accepted_by: Option<String>,
}
impl Default for PushServiceDeliveryConfigResponse {
@@ -560,6 +571,9 @@ impl Default for PushServiceDeliveryConfigResponse {
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
relay_consent_accepted: false,
relay_consent_accepted_at: None,
relay_consent_accepted_by: None,
}
}
}
@@ -576,6 +590,104 @@ pub struct PushServiceDeliveryConfigUpdateRequest {
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 relay_consent_accepted: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct DomainMigrationConfigResponse {
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_rollout_basis_points: u32,
pub standalone_forwarding: bool,
}
impl Default for DomainMigrationConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
anonymous_rollout_basis_points: 0,
standalone_forwarding: false,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct DomainMigrationConfigUpdateRequest {
#[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_rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
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)]
@@ -696,6 +808,10 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
#[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>,
}
@@ -1033,18 +1149,42 @@ mod tests {
.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 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!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
);
assert_eq!(
serde_json::to_value(generated_domain_migration)
.expect("serializable generated domain migration config"),
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"),
@@ -1052,6 +1192,8 @@ 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") {
@@ -1087,4 +1229,27 @@ mod tests {
json!({})
);
}
#[test]
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()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::DomainMigrationConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(DomainMigrationConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
}
+276 -6
View File
@@ -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,
@@ -211,6 +213,14 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"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_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),
@@ -496,7 +506,7 @@ fn parse_experiment_rollout_salt(
Ok(Some(salt.to_owned()))
}
fn parse_push_service_delivery_rollout_salt(
fn parse_ascii_experiment_rollout_salt(
form: &MultiValueForm,
key: &str,
) -> Result<Option<String>, String> {
@@ -661,7 +671,7 @@ fn build_push_service_delivery_update(
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_push_service_delivery_rollout_salt(
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"push_service_delivery_rollout_salt",
)?,
@@ -675,6 +685,93 @@ fn build_push_service_delivery_update(
.unwrap_or_default(),
"Excluded user IDs",
)?),
relay_consent_accepted: Some(
form.bool_value("push_service_delivery_relay_consent_accepted"),
),
}),
..Default::default()
})
}
fn build_domain_migration_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
domain_migration: Some(DomainMigrationConfigUpdateRequest {
enabled: Some(form.bool_value("domain_migration_enabled")),
rollout_basis_points: parse_form_number(
form,
"domain_migration_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"domain_migration_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
anonymous_rollout_basis_points: parse_form_number(
form,
"domain_migration_anonymous_rollout_basis_points",
"Anonymous rollout basis points",
0,
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_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()
})
@@ -1601,6 +1698,179 @@ mod tests {
}
}
#[test]
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")
.domain_migration
.expect("domain migration update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(250));
assert_eq!(update.rollout_salt, Some("domain-migration-v2".to_owned()));
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()
])
);
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": [],
"excluded_user_ids": [],
"standalone_forwarding": false,
}})
);
}
#[test]
fn build_domain_migration_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"domain_migration_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"domain_migration_anonymous_rollout_basis_points=10001",
"Anonymous rollout basis points must be a whole number between 0 and 10000",
),
(
"domain_migration_anonymous_rollout_basis_points=abc",
"Anonymous rollout basis points must be a whole number between 0 and 10000",
),
(
"domain_migration_rollout_salt=%20%20",
"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",
),
(
"domain_migration_excluded_user_ids=123%2Cinvalid",
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_domain_migration_update(&form).expect_err("invalid rollout field"),
message
);
}
}
#[test]
fn build_push_service_delivery_update_reads_the_relay_consent_checkbox() {
let unchecked = MultiValueForm::parse(b"_csrf=token");
assert_eq!(
build_push_service_delivery_update(&unchecked)
.expect("valid form")
.push_service_delivery
.expect("push service delivery update")
.relay_consent_accepted,
Some(false)
);
let checked =
MultiValueForm::parse(b"_csrf=token&push_service_delivery_relay_consent_accepted=true");
assert_eq!(
build_push_service_delivery_update(&checked)
.expect("valid form")
.push_service_delivery
.expect("push service delivery update")
.relay_consent_accepted,
Some(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": [],
"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,12 +2,15 @@
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, NoiseSuppressionBackend,
PUSH_SERVICE_DELIVERY_DEFAULT_SALT, PendingRegistrationResponse,
PushServiceDeliveryConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -149,6 +152,8 @@ pub fn instance_config_page(
(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))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -1189,6 +1194,14 @@ fn push_service_delivery_section(
};
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 relay_consent_stamp = match (
push_service_delivery.relay_consent_accepted_at.as_deref(),
push_service_delivery.relay_consent_accepted_by.as_deref(),
) {
(Some(at), Some(by)) => Some(format!("Accepted {at} by user {by}")),
(Some(at), None) => Some(format!("Accepted {at}")),
_ => None,
};
section_card_with_description(
"Push Service Delivery",
"Routes push notification delivery for the selected accounts through the push service. \
@@ -1217,6 +1230,28 @@ fn push_service_delivery_section(
effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Managed relay consent" }
(checkbox(
"push_service_delivery_relay_consent_accepted",
"true",
"Accept the push relay supplemental privacy notice",
push_service_delivery.relay_consent_accepted,
true,
))
p class="text-xs text-neutral-500" {
"Required only for the official mobile apps, whose notifications travel \
through Fluxer's relay to Apple and Google. Until this is accepted those \
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"
}
}
@if let Some(stamp) = relay_consent_stamp {
p class="text-xs text-neutral-500" { (stamp) }
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"push_service_delivery_rollout_basis_points",
@@ -1286,6 +1321,278 @@ fn push_service_delivery_section(
)
}
fn domain_migration_section(
base: &str,
csrf_token: &str,
domain_migration: &DomainMigrationConfigResponse,
) -> Markup {
let status = if domain_migration.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
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(
"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_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 " (domain_migration.config_version)
}
}
(checkbox(
"domain_migration_enabled",
"true",
"Move selected web clients to the new origin",
domain_migration.enabled,
true,
))
p class="text-xs text-neutral-500" {
"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(
"domain_migration_rollout_basis_points",
"Rollout (basis points)",
&domain_migration.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
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(
"domain_migration_rollout_salt",
"Rollout Salt",
&domain_migration.rollout_salt,
DOMAIN_MIGRATION_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"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(
"domain_migration_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
domain_migration.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(
"domain_migration_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
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. It stops new migrations only. A user who already moved \
stays on the new origin."
}
}
(form_actions(html! {
(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" {
(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,
@@ -1929,6 +2236,51 @@ mod tests {
assert!(!markup.contains("at the cap"));
}
#[test]
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(),
],
..DomainMigrationConfigResponse::default()
};
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("at the cap"));
}
#[test]
fn push_service_delivery_section_shows_the_relay_consent_toggle() {
let accepted = PushServiceDeliveryConfigResponse {
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()),
..PushServiceDeliveryConfigResponse::default()
};
let markup = push_service_delivery_section("/admin", "csrf", &accepted).into_string();
assert!(markup.contains("name=\"push_service_delivery_relay_consent_accepted\""));
assert!(markup.contains("https://fluxer.com/push-relay"));
assert!(markup.contains("Accepted 2026-09-27T10:11:12.000Z by user 1130650140672000000"));
let unaccepted = push_service_delivery_section(
"/admin",
"csrf",
&PushServiceDeliveryConfigResponse::default(),
)
.into_string();
assert!(unaccepted.contains("name=\"push_service_delivery_relay_consent_accepted\""));
assert!(!unaccepted.contains("Accepted "));
}
#[test]
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
+108 -2
View File
@@ -415,7 +415,33 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1",
"included_user_ids": ["1500000000000000002"],
"excluded_user_ids": []
"excluded_user_ids": [],
"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": 2,
"rollout_basis_points": 2500,
"rollout_salt": "domain-migration-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"future_migration_knob": 9,
"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": {
@@ -546,6 +572,19 @@ fn deserialize_instance_config_response_with_unknown_keys() {
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_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));
@@ -556,6 +595,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
.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(
@@ -577,6 +617,71 @@ fn deserialize_instance_config_response_with_unknown_keys() {
);
}
#[test]
fn deserialize_push_service_delivery_relay_consent() {
let accepted: types::PushServiceDeliveryConfigResponse = serde_json::from_str(
r#"{
"enabled": true,
"config_version": 3,
"rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"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 legacy: types::PushServiceDeliveryConfigResponse = serde_json::from_str(
r#"{
"enabled": true,
"config_version": 3,
"rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1",
"included_user_ids": [],
"excluded_user_ids": []
}"#,
)
.expect("a response written before relay consent must still deserialize");
assert!(!legacy.relay_consent_accepted);
assert!(legacy.relay_consent_accepted_at.is_none());
assert!(legacy.relay_consent_accepted_by.is_none());
}
#[test]
fn serialize_push_service_delivery_update_omits_an_unset_relay_consent() {
let without = types::PushServiceDeliveryConfigUpdateRequest {
enabled: Some(true),
..Default::default()
};
assert_eq!(
serde_json::to_value(&without).unwrap(),
serde_json::json!({"enabled": true})
);
let with = types::PushServiceDeliveryConfigUpdateRequest {
relay_consent_accepted: Some(true),
..Default::default()
};
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 +956,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",
+11
View File
@@ -465,6 +465,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",
][..],
),
@@ -1199,6 +1200,16 @@ fn instance_config() -> Value {
"guild_overrides": [],
"suppression_strength": 80
},
"domain_migration": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "domain-migration-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"anonymous_rollout_basis_points": 0,
"standalone_forwarding": false
},
"experiment_delivery": {
"poll_interval_seconds": 300,
"poll_jitter_percent": 15
+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);
}
}
+1 -1
View File
@@ -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,
+10
View File
@@ -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,
@@ -34,8 +34,14 @@ 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 {
type PushServiceDeliveryConfig,
PushServiceDeliveryConfigSchema,
type PushServiceDeliveryConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -65,6 +71,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gatewayRollout,
voiceNoiseSuppression,
pushServiceDelivery,
domainMigration,
altchaCaptcha,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -74,6 +82,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getPushServiceDeliveryConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -106,6 +116,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
push_service_delivery: pushServiceDelivery,
domain_migration: domainMigration,
altcha_captcha: altchaCaptcha,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -190,6 +202,20 @@ async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boole
return true;
}
function relayConsentStamp(
current: PushServiceDeliveryConfig,
patch: Partial<PushServiceDeliveryConfigUpdateRequest>,
adminUserId: string,
): Partial<PushServiceDeliveryConfig> {
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 listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined {
const sections = Object.entries(data)
.filter(([, value]) => value != null)
@@ -272,14 +298,40 @@ export function InstanceConfigAdminController(app: HonoApp) {
if (data.push_service_delivery) {
const patch = omitUndefinedFields(data.push_service_delivery);
if (Object.keys(patch).length > 0) {
const adminUserId = ctx.get('adminUserId').toString();
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
PushServiceDeliveryConfigSchema.parse({
...current,
...patch,
...relayConsentStamp(current, patch, adminUserId),
config_version: current.config_version + 1,
}),
);
await getPushServiceDeliveryConfigPublisher().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,
}),
);
}
}
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,
}),
);
await getPushServiceDeliveryConfigPublisher().publish(landed);
}
}
if (data.experiment_delivery) {
@@ -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(
@@ -69,6 +69,20 @@ describe('instance config admin PATCH under concurrent writes', () => {
return logs.filter((log) => log.action === 'update_instance_config');
}
it('merges a standalone forwarding patch into the stored domain migration config', async () => {
const admin = await createAdmin();
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 = spyOnPushDeliveryPublishes();
const admin = await createAdmin();
@@ -0,0 +1,132 @@
// 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 {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
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 {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
describe('push relay supplemental notice consent', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
vi.spyOn(PushServiceDeliveryConfigPublisher.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');
it('reads back as unaccepted before an operator agrees', async () => {
const admin = await createAdmin();
const config = await readConfig(admin).execute();
expect(config.push_service_delivery).toMatchObject({
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
});
it('stamps the acting admin and the acceptance time when consent is given', async () => {
const admin = await createAdmin();
const updated = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
expect(updated.push_service_delivery.relay_consent_accepted).toBe(true);
expect(updated.push_service_delivery.relay_consent_accepted_by).toBe(admin.userId);
expect(Date.parse(updated.push_service_delivery.relay_consent_accepted_at ?? '')).not.toBeNaN();
});
it('keeps the first acceptance stamp when a later patch changes only the rollout', async () => {
const admin = await createAdmin();
const accepted = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
const rolledOut = await patchConfig(admin, {
push_service_delivery: {enabled: true, rollout_basis_points: 2500},
}).execute();
expect(rolledOut.push_service_delivery).toMatchObject({
enabled: true,
rollout_basis_points: 2500,
relay_consent_accepted: true,
relay_consent_accepted_at: accepted.push_service_delivery.relay_consent_accepted_at,
relay_consent_accepted_by: admin.userId,
});
});
it('keeps the stamp untouched when consent is re-sent unchanged', async () => {
const admin = await createAdmin();
const accepted = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
const resent = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
expect(resent.push_service_delivery.relay_consent_accepted_at).toBe(
accepted.push_service_delivery.relay_consent_accepted_at,
);
});
it('clears the stamp when an operator withdraws consent', async () => {
const admin = await createAdmin();
await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
const withdrawn = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: false}}).execute();
expect(withdrawn.push_service_delivery).toMatchObject({
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_service_delivery: {
relay_consent_accepted: true,
relay_consent_accepted_at: '2020-01-01T00:00:00.000Z',
relay_consent_accepted_by: '1500000000000000009',
},
}).execute();
expect(updated.push_service_delivery.relay_consent_accepted_at).not.toBe('2020-01-01T00:00:00.000Z');
expect(updated.push_service_delivery.relay_consent_accepted_by).toBe(admin.userId);
});
it('publishes the consent to the delivery services', async () => {
const admin = await createAdmin();
const publish = vi.mocked(PushServiceDeliveryConfigPublisher.prototype.publish);
await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
expect(publish).toHaveBeenCalledWith(expect.objectContaining({relay_consent_accepted: true}));
});
});
@@ -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);
+5 -2
View File
@@ -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);
},
+25 -15
View File
@@ -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
View File
@@ -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();
+54 -14
View File
@@ -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> {
@@ -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();
}
+1
View File
@@ -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 {
@@ -8,6 +8,8 @@ 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';
@@ -28,9 +30,11 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, voiceConfig] = 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 = {
@@ -38,6 +42,8 @@ export function ExperimentController(app: HonoApp) {
poll_jitter_percent: delivery.poll_jitter_percent,
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,14 @@ 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,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
@@ -15,6 +23,7 @@ import {
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
type ExperimentAssignmentsResponse,
type ExperimentDeliveryConfigResponse,
readDomainMigrationAssignment,
readVoiceNoiseSuppressionAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
@@ -51,6 +60,8 @@ describe('GET /experiments', () => {
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,
},
});
});
@@ -82,6 +93,108 @@ describe('GET /experiments', () => {
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
});
it('populates the domain migration assignment key even when the rollout is disabled', async () => {
const account = await createTestAccount(harness);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(Object.hasOwn(body.assignments, 'domain_migration')).toBe(true);
expect(readDomainMigrationAssignment(body).enabled).toBe(false);
});
it('resolves the domain migration caller through the allowlist', async () => {
const targeted = await createTestAccount(harness);
const untargeted = await createTestAccount(harness);
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
config_version: 4,
rollout_basis_points: 0,
included_user_ids: [targeted.userId],
});
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.domain_migration).toEqual({enabled: true});
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
.get(ENDPOINT)
.execute();
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,
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('serves the delivery cadence from the delivery config and not from the voice config', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setExperimentDeliveryConfig({
@@ -196,6 +309,30 @@ describe('GET /experiments', () => {
});
});
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)
.get(ENDPOINT)
.executeWithResponse();
const staleEtag = first.response.headers.get('etag') as string;
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
config_version: 1,
rollout_basis_points: 10000,
});
const refreshed = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
.get(ENDPOINT)
.header('If-None-Match', staleEtag)
.executeWithResponse();
expect(refreshed.response.status).toBe(HTTP_STATUS.OK);
expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag);
expect(refreshed.json?.assignments.domain_migration).toEqual({enabled: true});
});
it('serves a fresh body once the delivery config changes', async () => {
const account = await createTestAccount(harness);
@@ -252,6 +389,47 @@ describe('GET /experiments', () => {
});
});
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<{domain_migration: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({domain_migration: {enabled: true, rollout_basis_points: 10000}})
.execute();
expect(afterFirst.domain_migration).toMatchObject({config_version: 1, enabled: true});
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({domain_migration: {}})
.execute();
expect(afterEmpty.domain_migration).toMatchObject({config_version: 2, enabled: true});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(body.assignments.domain_migration).toEqual({enabled: true});
});
it('leaves the config version alone for an admin update that sets no field', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
@@ -18,6 +18,10 @@ import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceC
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {
DEFAULT_DOMAIN_MIGRATION_CONFIG,
type DomainMigrationConfig,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
type VoiceNoiseSuppressionConfig,
@@ -35,6 +39,7 @@ import {
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';
@@ -351,6 +356,92 @@ describe('InstanceConfigRepository', () => {
});
});
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.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: '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(DOMAIN_MIGRATION_CONFIG_KEY, stored);
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
});
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: DomainMigrationConfig = {
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
config_version: 5,
rollout_basis_points: 2500,
rollout_salt: 'domain-migration-v2',
included_user_ids: ['1400000000000000001'],
excluded_user_ids: ['1400000000000000002'],
anonymous_rollout_basis_points: 300,
standalone_forwarding: true,
};
await repository.setDomainMigrationConfig(config);
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(config);
});
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(
DOMAIN_MIGRATION_CONFIG_KEY,
JSON.stringify({enabled: true, config_version: 2, rollout_basis_points: 1000}),
);
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);
@@ -28,6 +28,14 @@ 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,
@@ -63,6 +71,8 @@ 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 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';
@@ -370,6 +380,8 @@ type StoredConfigSection =
| 'gateway rollout'
| 'voice noise suppression'
| 'push service delivery'
| 'domain migration'
| 'altcha captcha'
| 'experiment delivery'
| 'instance policy'
| 'integrations'
@@ -512,6 +524,14 @@ function parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDe
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
}
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 parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
}
@@ -1160,6 +1180,8 @@ 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', () =>
@@ -1273,6 +1295,44 @@ export class InstanceConfigRepository {
);
}
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(
DomainMigrationConfigSchema,
update(parseStoredDomainMigrationConfig(raw)),
'domain migration',
),
);
}
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);
@@ -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,
});
});
});
@@ -16,6 +16,7 @@ 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';
@@ -102,15 +103,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 +135,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());
@@ -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,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) => {
@@ -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')},
+49 -14
View File
@@ -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);
});
});
+5 -6
View File
@@ -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';
@@ -1199,12 +1201,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,
};
@@ -39,7 +39,6 @@ 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';
type CheckoutSessionCreateParams = Stripe.Checkout.SessionCreateParams;
@@ -226,7 +225,7 @@ export class StripeCheckoutService {
message: getContentMessage('billing.eu_withdrawal_waiver_checkout', user.locale, {
product_name: PRODUCT_NAME,
premium_tier_name: PREMIUM_TIER_NAME,
terms_url: TERMS_URL,
terms_url: `${Config.endpoints.marketing}/terms`,
}),
},
},
@@ -14,7 +14,7 @@ import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {addGiftCodeDuration} from '@app/api/models/GiftCode';
import type {User} from '@app/api/models/User';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
@@ -62,6 +62,7 @@ export class StripePremiumService {
premium_will_cancel: false,
premium_billing_cycle: billingCycle,
premium_grace_ends_at: null,
premium_flags: clearPerksSanitizedFlag(user.premiumFlags),
},
user.toRow(),
);
@@ -90,6 +91,7 @@ export class StripePremiumService {
premium_since: this.resolvePremiumSince(user.premiumSince, premiumSinceAnchor, now),
premium_until: null,
premium_lifetime_sequence: visionarySequence,
premium_flags: clearPerksSanitizedFlag(user.premiumFlags),
has_ever_purchased: hasEverPurchased,
premium_will_cancel: false,
premium_billing_cycle: null,
@@ -127,6 +129,7 @@ export class StripePremiumService {
};
if ((user.premiumType ?? 0) <= 0) {
patch.premium_type = premiumType;
patch.premium_flags = clearPerksSanitizedFlag(user.premiumFlags);
patch.premium_since = this.resolvePremiumSince(user.premiumSince, null, now);
}
if (hasEverPurchased && !user.hasEverPurchased) {
@@ -261,7 +261,7 @@ export class StripeRefundService {
const subscriptionId = refund.metadata.subscription_id;
if (subscriptionId) {
try {
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund', subscriptionId);
} catch (error) {
Logger.error(
{error, userId: user.id.toString(), subscriptionId},
@@ -174,7 +174,7 @@ export class StripeSubscriptionService {
}
}
async cancelSubscriptionImmediately(userId: UserID, reason?: string): Promise<void> {
async cancelSubscriptionImmediately(userId: UserID, reason?: string, expectedSubscriptionId?: string): Promise<void> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
}
@@ -185,6 +185,18 @@ export class StripeSubscriptionService {
if (!user.stripeSubscriptionId) {
throw new StripeNoActiveSubscriptionError();
}
if (expectedSubscriptionId && user.stripeSubscriptionId !== expectedSubscriptionId) {
Logger.info(
{
userId: user.id.toString(),
expectedSubscriptionId,
currentSubscriptionId: user.stripeSubscriptionId,
reason: reason ?? null,
},
'Skipping immediate cancellation because the target subscription is no longer the current one',
);
return;
}
try {
const canceledSubscription = await this.stripe.subscriptions.cancel(
user.stripeSubscriptionId,
@@ -487,4 +487,90 @@ describe('StripeRefundService self-serve refund', () => {
expect(idempotencyKeys[1]).toContain('retry-1');
});
});
describe('self-serve refund teardown targeting', () => {
function trackingSubscriptionDeleteHandler(deleted: Array<string>) {
return http.delete(`${STRIPE_API_BASE}/v1/subscriptions/:id`, ({params}) => {
deleted.push(String(params.id));
return HttpResponse.json({id: params.id, object: 'subscription', status: 'canceled'});
});
}
function buildRefundUpdatedEvent(opts: {
eventId: string;
refundId: string;
userId: string;
invoiceId: string;
subscriptionId: string;
}): StripeWebhookEventData {
return {
id: opts.eventId,
type: 'refund.updated',
data: {
object: {
id: opts.refundId,
object: 'refund',
status: 'succeeded',
amount: 2500,
currency: 'usd',
metadata: {
refund_kind: 'self_serve',
user_id: opts.userId,
invoice_id: opts.invoiceId,
subscription_id: opts.subscriptionId,
},
},
},
};
}
test('leaves a newer subscription alone when the refunded one is no longer current', async () => {
server.use(...createStripeApiHandlers().handlers);
const deleted: Array<string> = [];
server.use(trackingSubscriptionDeleteHandler(deleted));
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
await setStripeIds(harness, account, {
stripe_customer_id: MOCK_CUSTOMER_ID,
stripe_subscription_id: 'sub_bought_after_the_refund',
});
await sendWebhook(
buildRefundUpdatedEvent({
eventId: 'evt_stale_teardown',
refundId: 're_stale_teardown',
userId: account.userId,
invoiceId: 'in_stale_teardown',
subscriptionId: 'sub_refunded_and_already_gone',
}),
);
expect(deleted).toEqual([]);
const userRepository = new UserRepository();
const user = await userRepository.findUnique(userId);
expect(user!.stripeSubscriptionId).toBe('sub_bought_after_the_refund');
});
test('cancels the subscription when the refunded one is still current', async () => {
server.use(...createStripeApiHandlers().handlers);
const deleted: Array<string> = [];
server.use(trackingSubscriptionDeleteHandler(deleted));
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
await setStripeIds(harness, account, {
stripe_customer_id: MOCK_CUSTOMER_ID,
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
});
await sendWebhook(
buildRefundUpdatedEvent({
eventId: 'evt_current_teardown',
refundId: 're_current_teardown',
userId: account.userId,
invoiceId: 'in_current_teardown',
subscriptionId: MOCK_SUBSCRIPTION_ID,
}),
);
expect(deleted).toEqual([MOCK_SUBSCRIPTION_ID]);
const userRepository = new UserRepository();
const user = await userRepository.findUnique(userId);
expect(user!.stripeSubscriptionId).toBeNull();
});
});
});
+4
View File
@@ -275,6 +275,10 @@ export function createPremiumClearPatch(): Partial<UserRow> {
return mapExpiredPremiumFields(() => null) as Partial<UserRow>;
}
export function clearPerksSanitizedFlag(premiumFlags: number): number {
return premiumFlags & ~PremiumFlags.PERKS_SANITIZED;
}
const PROFILE_SUBSTRING_EXEMPT_FLAGS = UserFlags.STAFF;
export function isProfileSubstringExempt(user: Pick<PremiumCheckable, 'flags'>): boolean {
+15
View File
@@ -9,6 +9,7 @@ import type {Relationship} from '@app/api/models/Relationship';
import type {User} from '@app/api/models/User';
import type {UserGuildSettings} from '@app/api/models/UserGuildSettings';
import type {UserSettings} from '@app/api/models/UserSettings';
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
import {canUseProfileTimezone, getRequiredActions} from '@app/api/user/UserHelpers';
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
import type {ChannelMessageNotifications} from '@fluxer/constants/src/NotificationConstants';
@@ -26,6 +27,7 @@ import {
UserFlags,
UserPremiumTypes,
} from '@fluxer/constants/src/UserConstants';
import type {WebAuthnCredentialResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import type {
RelationshipResponse,
UserGuildSettingsResponse,
@@ -420,3 +422,16 @@ export function mapUserGuildSettingsToResponse(settings: UserGuildSettings): Use
version: settings.version,
};
}
export function mapWebAuthnCredentialToResponse(
credential: WebAuthnCredential,
legacyRpId: string,
): WebAuthnCredentialResponse {
return {
id: credential.credentialId,
name: credential.name,
created_at: credential.createdAt.toISOString(),
last_used_at: credential.lastUsedAt?.toISOString() ?? null,
rp_id: credential.rpId ?? legacyRpId,
};
}
@@ -3,6 +3,7 @@
import * as AuthSession from '@app/api/auth/AuthSession';
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '@app/api/middleware/AuthMiddleware';
import {requireOAuth2ScopeForBearer} from '@app/api/middleware/OAuth2ScopeMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
@@ -10,6 +11,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {classifyWebPushOrigin} from '@app/api/user/services/WebPushOriginReplacement';
import {getCachedUserPartialResponse} from '@app/api/user/UserCacheHelpers';
import {
mapUserGuildSettingsToResponse,
@@ -854,7 +856,7 @@ export function UserAccountController(app: HonoApp) {
'Registers a new push notification subscription for the current user. Takes push endpoint and encryption keys from a Web Push API subscription. Returns subscription ID for future reference.',
}),
async (ctx) => {
const {endpoint, keys, user_agent} = ctx.req.valid('json');
const {endpoint, keys, user_agent, installed_app} = ctx.req.valid('json');
const authSession = ctx.get('authSession');
const subscription = await ctx.get('userService').contentService.registerPushSubscription({
userId: ctx.get('user').id,
@@ -862,6 +864,8 @@ export function UserAccountController(app: HonoApp) {
endpoint,
keys,
userAgent: user_agent,
originKind: classifyWebPushOrigin(ctx.req.header('origin'), Config.instance.selfHosted),
installedApp: installed_app,
});
return ctx.json({subscription_id: subscription.subscriptionId});
},
@@ -883,7 +887,7 @@ export function UserAccountController(app: HonoApp) {
'Replaces an existing push subscription whose endpoint has been rotated by the browser (pushsubscriptionchange). Deletes the row keyed by the old endpoint and inserts a new one for the new endpoint.',
}),
async (ctx) => {
const {old_endpoint, endpoint, keys, user_agent} = ctx.req.valid('json');
const {old_endpoint, endpoint, keys, user_agent, installed_app} = ctx.req.valid('json');
const authSession = ctx.get('authSession');
const subscription = await ctx.get('userService').contentService.rotatePushSubscription({
userId: ctx.get('user').id,
@@ -892,6 +896,8 @@ export function UserAccountController(app: HonoApp) {
endpoint,
keys,
userAgent: user_agent,
originKind: classifyWebPushOrigin(ctx.req.header('origin'), Config.instance.selfHosted),
installedApp: installed_app,
});
return ctx.json({subscription_id: subscription.subscriptionId});
},
@@ -1,5 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
completePasskeyMigration,
getPasskeyMigration,
getPasskeyMigrationRegistrationOptions,
} from '@app/api/auth/services/PasskeyMigrationService';
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
import {Config} from '@app/api/Config';
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '@app/api/middleware/AuthMiddleware';
@@ -34,6 +39,10 @@ import {
WebAuthnTwoFactorRequest,
WebAuthnTwoFactorResponse,
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {
PasskeyMigrationCompleteRequest,
PasskeyMigrationResponse,
} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
import {CredentialIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {EmptyBodyRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
@@ -347,7 +356,9 @@ export function UserAuthController(app: HonoApp) {
await requireSudoMode(ctx, user, body, {
issueSudoToken: false,
});
return ctx.json(await ctx.get('userAuthRequestService').generateWebAuthnRegistrationOptions(user));
return ctx.json(
await ctx.get('userAuthRequestService').generateWebAuthnRegistrationOptions(user, ctx.req.header('origin')),
);
},
);
app.post(
@@ -433,6 +444,78 @@ export function UserAuthController(app: HonoApp) {
return ctx.body(null, 204);
},
);
app.get(
'/users/@me/mfa/webauthn/migration',
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_MIGRATION),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'get_webauthn_migration',
summary: 'Get pending passkey update',
responseSchema: PasskeyMigrationResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Return the passkey this session can update to the new domain after using it within the last five minutes, or null.',
}),
async (ctx) => {
return ctx.json(await getPasskeyMigration(ctx.get('apiContext'), ctx.get('user').id, ctx.get('authSession')));
},
);
app.post(
'/users/@me/mfa/webauthn/migration/registration-options',
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_MIGRATION),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'get_webauthn_migration_registration_options',
summary: 'Get passkey update registration options',
responseSchema: WebAuthnChallengeResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Generate registration options for the passkey that replaces the pending one. Requires a pending passkey update for this session.',
}),
async (ctx) => {
return ctx.json(
await getPasskeyMigrationRegistrationOptions(
ctx.get('apiContext'),
ctx.get('user').id,
ctx.get('authSession'),
ctx.req.header('origin'),
),
);
},
);
app.post(
'/users/@me/mfa/webauthn/migration',
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_MIGRATION),
LoginRequired,
DefaultUserOnly,
Validator('json', PasskeyMigrationCompleteRequest),
OpenAPI({
operationId: 'complete_webauthn_migration',
summary: 'Complete passkey update',
responseSchema: null,
statusCode: 204,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Register the replacement passkey under the name of the pending one. The old passkey stops appearing in lists and is removed together with its replacement.',
}),
async (ctx) => {
await completePasskeyMigration(
ctx.get('apiContext'),
ctx.get('user').id,
ctx.get('authSession'),
ctx.req.header('origin'),
ctx.req.valid('json'),
);
return ctx.body(null, 204);
},
);
app.put(
'/users/@me/mfa/webauthn/two-factor',
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_TWO_FACTOR),
@@ -492,7 +575,9 @@ export function UserAuthController(app: HonoApp) {
'Generate WebAuthn challenge for sudo mode verification using a registered security key or biometric device.',
}),
async (ctx) => {
return ctx.json(await ctx.get('userAuthRequestService').getSudoWebAuthnOptions(ctx.get('user')));
return ctx.json(
await ctx.get('userAuthRequestService').getSudoWebAuthnOptions(ctx.get('user'), ctx.req.header('origin')),
);
},
);
}
@@ -65,10 +65,12 @@ export interface IUserAuthRepository {
counter: bigint,
transports: Set<string> | null,
name: string,
rpId: string | null,
): Promise<void>;
updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void>;
updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void>;
updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void>;
setWebAuthnCredentialSupersededBy(userId: UserID, credentialId: string, supersededBy: string): Promise<void>;
deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void>;
getUserIdByCredentialId(credentialId: string): Promise<UserID | null>;
deleteAllWebAuthnCredentials(userId: UserID): Promise<void>;
@@ -193,8 +193,17 @@ export class UserAuthRepository implements IUserAuthRepository {
counter: bigint,
transports: Set<string> | null,
name: string,
rpId: string | null,
): Promise<void> {
return this.webAuthnRepository.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
return this.webAuthnRepository.createWebAuthnCredential(
userId,
credentialId,
publicKey,
counter,
transports,
name,
rpId,
);
}
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
@@ -209,6 +218,10 @@ export class UserAuthRepository implements IUserAuthRepository {
return this.webAuthnRepository.updateWebAuthnCredentialName(userId, credentialId, name);
}
async setWebAuthnCredentialSupersededBy(userId: UserID, credentialId: string, supersededBy: string): Promise<void> {
return this.webAuthnRepository.setWebAuthnCredentialSupersededBy(userId, credentialId, supersededBy);
}
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
return this.webAuthnRepository.deleteWebAuthnCredential(userId, credentialId);
}
@@ -407,8 +407,9 @@ export class UserRepository implements IUserRepositoryAggregate {
counter: bigint,
transports: Set<string> | null,
name: string,
rpId: string | null,
): Promise<void> {
return this.authRepo.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
return this.authRepo.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name, rpId);
}
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
@@ -423,6 +424,10 @@ export class UserRepository implements IUserRepositoryAggregate {
return this.authRepo.updateWebAuthnCredentialName(userId, credentialId, name);
}
async setWebAuthnCredentialSupersededBy(userId: UserID, credentialId: string, supersededBy: string): Promise<void> {
return this.authRepo.setWebAuthnCredentialSupersededBy(userId, credentialId, supersededBy);
}
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
return this.authRepo.deleteWebAuthnCredential(userId, credentialId);
}
@@ -26,7 +26,7 @@ const FETCH_WEBAUTHN_CREDENTIALS_FOR_USER_CQL = WebAuthnCredentials.selectCql({
export class WebAuthnRepository {
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
const credentials = await fetchMany<WebAuthnCredentialRow>(FETCH_WEBAUTHN_CREDENTIALS_CQL, {user_id: userId});
return credentials.map((cred) => new WebAuthnCredential(cred));
return credentials.filter((cred) => cred.public_key).map((cred) => new WebAuthnCredential(cred));
}
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
@@ -34,7 +34,7 @@ export class WebAuthnRepository {
user_id: userId,
credential_id: credentialId,
});
if (!cred) {
if (!cred?.public_key) {
return null;
}
return new WebAuthnCredential(cred);
@@ -47,6 +47,7 @@ export class WebAuthnRepository {
counter: bigint,
transports: Set<string> | null,
name: string,
rpId: string | null,
): Promise<void> {
const credentialData = {
user_id: userId,
@@ -58,6 +59,8 @@ export class WebAuthnRepository {
created_at: new Date(),
last_used_at: null,
version: 1 as const,
rp_id: rpId,
superseded_by: null,
};
await upsertOne(WebAuthnCredentials.insert(credentialData));
await upsertOne(
@@ -101,6 +104,17 @@ export class WebAuthnRepository {
);
}
async setWebAuthnCredentialSupersededBy(userId: UserID, credentialId: string, supersededBy: string): Promise<void> {
await upsertOne(
WebAuthnCredentials.patchByPk(
{user_id: userId, credential_id: credentialId},
{
superseded_by: Db.set(supersededBy),
},
),
);
}
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
await deleteOneOrMany(
WebAuthnCredentials.deleteByPk({
@@ -4,12 +4,13 @@ import type {ApiContext} from '@app/api/ApiContext';
import * as AuthMfa from '@app/api/auth/AuthMfa';
import * as AuthPhone from '@app/api/auth/AuthPhone';
import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
import type {SudoVerificationResult} from '@app/api/auth/services/SudoVerificationService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {User} from '@app/api/models/User';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import * as UserAuth from '@app/api/user/services/UserAuth';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {mapUserToPrivateResponse, mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {PhoneAddNotEligibleError} from '@fluxer/errors/src/domains/auth/PhoneAddNotEligibleError';
@@ -170,17 +171,16 @@ export class UserAuthRequestService {
async listWebAuthnCredentials(user: User): Promise<WebAuthnCredentialListResponse> {
const credentials = await this.userRepository.listWebAuthnCredentials(user.id);
return credentials.map((cred) => ({
id: cred.credentialId,
name: cred.name,
created_at: cred.createdAt.toISOString(),
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
}));
const legacyRpId = this.apiContext.services.config.auth.passkeys.rpId;
return visibleWebAuthnCredentials(credentials).map((cred) => mapWebAuthnCredentialToResponse(cred, legacyRpId));
}
async generateWebAuthnRegistrationOptions(user: User): Promise<WebAuthnChallengeResponse> {
async generateWebAuthnRegistrationOptions(
user: User,
origin: string | undefined,
): Promise<WebAuthnChallengeResponse> {
requireEmailVerified(user, 'mfa');
const options = await AuthMfa.generateWebAuthnRegistrationOptions(this.apiContext, user.id);
const options = await AuthMfa.generateWebAuthnRegistrationOptions(this.apiContext, user.id, origin);
return this.toWebAuthnChallengeResponse(options);
}
@@ -217,8 +217,8 @@ export class UserAuthRequestService {
return AuthMfa.getAvailableMfaMethods(this.apiContext, user.id);
}
async getSudoWebAuthnOptions(user: User): Promise<WebAuthnChallengeResponse> {
const options = await AuthMfa.generateWebAuthnOptionsForSudo(this.apiContext, user.id);
async getSudoWebAuthnOptions(user: User, origin: string | undefined): Promise<WebAuthnChallengeResponse> {
const options = await AuthMfa.generateWebAuthnOptionsForSudo(this.apiContext, user.id, origin);
return this.toWebAuthnChallengeResponse(options);
}
@@ -20,12 +20,23 @@ import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Message} from '@app/api/models/Message';
import type {PushSubscription} from '@app/api/models/PushSubscription';
import {PushSubscription} from '@app/api/models/PushSubscription';
import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository';
import type {IUserContentRepository} from '@app/api/user/repositories/IUserContentRepository';
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
import {verifyHarvestDownloadToken} from '@app/api/user/services/HarvestDownloadToken';
import {buildHarvestDownloadUrl} from '@app/api/user/services/HarvestDownloadUrl';
import {
findInstalledLegacyPushSubscriptionIds,
findTargetPushSubscriptionIds,
getPushOriginReplacement,
getPushSessionPredecessor,
markInstalledLegacyPushSubscription,
markPushOriginReplaced,
markTargetPushSubscription,
sameUserAgentFamily,
type WebPushOriginKind,
} from '@app/api/user/services/WebPushOriginReplacement';
import {UserHarvest} from '@app/api/user/UserHarvestModel';
import {UserHarvestRepository} from '@app/api/user/UserHarvestRepository';
import {serializeSelfMessageFilter} from '@app/api/worker/utils/SelfMessageFilterPayload';
@@ -56,6 +67,7 @@ import type {
import type {SavedMessageStatus} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import {isPubliclyRoutableUrlShape} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {ms} from 'itty-time';
@@ -159,6 +171,7 @@ export class UserContentService {
private readonly gatewayService: IGatewayService;
private readonly workerService: IWorkerService<WorkerTaskName>;
private readonly snowflakeService: ISnowflakeService;
private readonly kv: IKVProvider;
constructor(
apiContext: ApiContext,
@@ -168,11 +181,12 @@ export class UserContentService {
private bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
private limitConfigService: LimitConfigService,
) {
const {users, gateway, worker, snowflake} = apiContext.services;
const {users, gateway, worker, snowflake, kv} = apiContext.services;
this.userRepository = users;
this.gatewayService = gateway;
this.workerService = worker;
this.snowflakeService = snowflake;
this.kv = kv;
this.updatePropagator = new BaseUserUpdatePropagator({
userCacheService,
gatewayService: this.gatewayService,
@@ -359,8 +373,10 @@ export class UserContentService {
auth: string;
};
userAgent?: string;
originKind?: WebPushOriginKind | null;
installedApp?: boolean;
}): Promise<PushSubscription> {
const {userId, authSessionIdHash, endpoint, keys, userAgent} = params;
const {userId, authSessionIdHash, endpoint, keys, userAgent, originKind, installedApp} = params;
assertPublicPushEndpoint(endpoint, 'endpoint');
const subscriptionId = createWebPushSubscriptionId(endpoint);
const data: PushSubscriptionRow = {
@@ -375,11 +391,76 @@ export class UserContentService {
app_id: null,
provider_environment: null,
};
const subscription = await this.userRepository.createPushSubscription(data);
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
await this.gatewayService.invalidatePushSubscriptions({userId});
return subscription;
}
private async storeWebPushSubscription(
data: PushSubscriptionRow,
originKind: WebPushOriginKind | null,
installedApp: boolean,
): Promise<PushSubscription> {
if (originKind === 'legacy' && (await this.isLegacyWebPushReplaced(data, installedApp))) {
return new PushSubscription(data);
}
const subscription = await this.userRepository.createPushSubscription(data);
if (originKind === 'legacy' && installedApp) {
await this.bestEffortPushOriginWrite(() => markInstalledLegacyPushSubscription(this.kv, data.subscription_id));
}
if (originKind === 'target') {
await this.bestEffortPushOriginWrite(() => this.replaceLegacyWebPushSubscriptions(data, installedApp));
}
return subscription;
}
private async isLegacyWebPushReplaced(data: PushSubscriptionRow, installedApp: boolean): Promise<boolean> {
const sessionIdHash = data.auth_session_id_hash;
if (!sessionIdHash) return false;
try {
const replacement = await getPushOriginReplacement(this.kv, sessionIdHash);
return replacement === 'installed' || (replacement === 'browser' && !installedApp);
} catch (error) {
Logger.warn({error}, 'Failed to read the web push origin replacement');
return false;
}
}
private async bestEffortPushOriginWrite(write: () => Promise<void>): Promise<void> {
try {
await write();
} catch (error) {
Logger.warn({error}, 'Failed to apply the web push origin replacement');
}
}
private async replaceLegacyWebPushSubscriptions(data: PushSubscriptionRow, installedApp: boolean): Promise<void> {
await markTargetPushSubscription(this.kv, data.subscription_id);
const sessionIdHash = data.auth_session_id_hash;
if (!sessionIdHash) return;
await markPushOriginReplaced(this.kv, sessionIdHash, installedApp ? 'installed' : 'browser');
const predecessor = await getPushSessionPredecessor(this.kv, sessionIdHash);
const candidates = (await this.userRepository.listPushSubscriptions(data.user_id)).filter(
(subscription) =>
subscription.platform === WEB_PUSH_PLATFORM &&
subscription.endpoint !== data.endpoint &&
(subscription.authSessionIdHash === sessionIdHash ||
(predecessor !== null &&
subscription.authSessionIdHash === predecessor &&
sameUserAgentFamily(subscription.userAgent, data.user_agent))),
);
const candidateIds = candidates.map((subscription) => subscription.subscriptionId);
const [targetSubscriptionIds, installedLegacySubscriptionIds] = await Promise.all([
findTargetPushSubscriptionIds(this.kv, candidateIds),
installedApp ? Promise.resolve(new Set<string>()) : findInstalledLegacyPushSubscriptionIds(this.kv, candidateIds),
]);
for (const subscription of candidates) {
if (targetSubscriptionIds.has(subscription.subscriptionId)) continue;
if (installedLegacySubscriptionIds.has(subscription.subscriptionId)) continue;
await this.userRepository.deletePushSubscription(data.user_id, subscription.subscriptionId);
}
}
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
const subscriptions = await this.userRepository.listPushSubscriptions(userId);
return subscriptions.filter((subscription) => subscription.platform === WEB_PUSH_PLATFORM);
@@ -400,8 +481,10 @@ export class UserContentService {
auth: string;
};
userAgent?: string;
originKind?: WebPushOriginKind | null;
installedApp?: boolean;
}): Promise<PushSubscription> {
const {userId, authSessionIdHash, oldEndpoint, endpoint, keys, userAgent} = params;
const {userId, authSessionIdHash, oldEndpoint, endpoint, keys, userAgent, originKind, installedApp} = params;
assertPublicPushEndpoint(endpoint, 'endpoint');
const oldSubscriptionId = createWebPushSubscriptionId(oldEndpoint);
const newSubscriptionId = createWebPushSubscriptionId(endpoint);
@@ -420,7 +503,7 @@ export class UserContentService {
app_id: null,
provider_environment: null,
};
const subscription = await this.userRepository.createPushSubscription(data);
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
await this.gatewayService.invalidatePushSubscriptions({userId});
return subscription;
}
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {seconds} from 'itty-time';
import {uint8ArrayToBase64} from 'uint8array-extras';
export type WebPushOriginKind = 'legacy' | 'target';
export type WebPushOriginReplacement = 'installed' | 'browser';
const WEB_PUSH_ORIGIN_KINDS: ReadonlyMap<string, WebPushOriginKind> = new Map([
['https://web.fluxer.app', 'legacy'],
['https://web.canary.fluxer.app', 'legacy'],
['https://fluxer.com', 'target'],
['https://canary.fluxer.com', 'target'],
]);
const PUSH_ORIGIN_REPLACED_PREFIX = 'push_origin_replaced:';
const PUSH_SESSION_PREDECESSOR_PREFIX = 'push_session_predecessor:';
const PUSH_TARGET_SUBSCRIPTION_PREFIX = 'push_target_subscription:';
const PUSH_INSTALLED_LEGACY_SUBSCRIPTION_PREFIX = 'push_installed_legacy_subscription:';
const USER_AGENT_VERSION_PATTERN = /\d+(?:[._]\d+)*/g;
export const WEB_PUSH_ORIGIN_RECORD_TTL_SECONDS = seconds('400 days');
export function classifyWebPushOrigin(
origin: string | null | undefined,
selfHosted: boolean,
): WebPushOriginKind | null {
if (selfHosted || !origin) return null;
return WEB_PUSH_ORIGIN_KINDS.get(origin) ?? null;
}
export function encodePushSessionIdHash(sessionIdHash: Uint8Array): string {
return uint8ArrayToBase64(sessionIdHash, {urlSafe: true});
}
export function sameUserAgentFamily(a: string | null | undefined, b: string | null | undefined): boolean {
if (!a || !b) return false;
return a.replace(USER_AGENT_VERSION_PATTERN, '') === b.replace(USER_AGENT_VERSION_PATTERN, '');
}
export async function recordPushSessionPredecessor(
kv: IKVProvider,
sessionIdHash: string,
predecessorSessionIdHash: string,
): Promise<void> {
if (sessionIdHash === predecessorSessionIdHash) return;
await kv.setex(
`${PUSH_SESSION_PREDECESSOR_PREFIX}${sessionIdHash}`,
WEB_PUSH_ORIGIN_RECORD_TTL_SECONDS,
predecessorSessionIdHash,
);
}
export async function getPushSessionPredecessor(kv: IKVProvider, sessionIdHash: string): Promise<string | null> {
return kv.get(`${PUSH_SESSION_PREDECESSOR_PREFIX}${sessionIdHash}`);
}
export async function markPushOriginReplaced(
kv: IKVProvider,
sessionIdHash: string,
replacement: WebPushOriginReplacement,
): Promise<void> {
const key = `${PUSH_ORIGIN_REPLACED_PREFIX}${sessionIdHash}`;
if (replacement === 'browser' && (await kv.get(key)) === 'installed') return;
await kv.setex(key, WEB_PUSH_ORIGIN_RECORD_TTL_SECONDS, replacement);
}
export async function getPushOriginReplacement(
kv: IKVProvider,
sessionIdHash: string,
): Promise<WebPushOriginReplacement | null> {
const value = await kv.get(`${PUSH_ORIGIN_REPLACED_PREFIX}${sessionIdHash}`);
if (value === null) return null;
return value === 'browser' ? 'browser' : 'installed';
}
async function markSubscription(kv: IKVProvider, prefix: string, subscriptionId: string): Promise<void> {
await kv.setex(`${prefix}${subscriptionId}`, WEB_PUSH_ORIGIN_RECORD_TTL_SECONDS, '1');
}
async function findMarkedSubscriptionIds(
kv: IKVProvider,
prefix: string,
subscriptionIds: Array<string>,
): Promise<Set<string>> {
if (subscriptionIds.length === 0) return new Set();
const markers = await kv.mget(...subscriptionIds.map((id) => `${prefix}${id}`));
return new Set(subscriptionIds.filter((_, index) => markers[index] !== null));
}
export async function markTargetPushSubscription(kv: IKVProvider, subscriptionId: string): Promise<void> {
await markSubscription(kv, PUSH_TARGET_SUBSCRIPTION_PREFIX, subscriptionId);
}
export async function findTargetPushSubscriptionIds(
kv: IKVProvider,
subscriptionIds: Array<string>,
): Promise<Set<string>> {
return findMarkedSubscriptionIds(kv, PUSH_TARGET_SUBSCRIPTION_PREFIX, subscriptionIds);
}
export async function markInstalledLegacyPushSubscription(kv: IKVProvider, subscriptionId: string): Promise<void> {
await markSubscription(kv, PUSH_INSTALLED_LEGACY_SUBSCRIPTION_PREFIX, subscriptionId);
}
export async function findInstalledLegacyPushSubscriptionIds(
kv: IKVProvider,
subscriptionIds: Array<string>,
): Promise<Set<string>> {
return findMarkedSubscriptionIds(kv, PUSH_INSTALLED_LEGACY_SUBSCRIPTION_PREFIX, subscriptionIds);
}
@@ -0,0 +1,383 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAuthHarness, createTestAccount, loginAccount} from '@app/api/auth/tests/AuthTestUtils';
import {getConfig} from '@app/api/Config';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {classifyWebPushOrigin, sameUserAgentFamily} from '@app/api/user/services/WebPushOriginReplacement';
import {listPushSubscriptions} from '@app/api/user/tests/UserTestUtils';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const LEGACY_ORIGIN = 'https://web.fluxer.app';
const TARGET_ORIGIN = 'https://fluxer.com';
const IPHONE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1';
const IPHONE_UPDATED_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1';
const DESKTOP_CHROME_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
const ANDROID_CHROME_UA =
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36';
interface SubscribeOptions {
userAgent?: string;
installedApp?: boolean;
}
interface PushSubscribeResponse {
subscription_id: string;
}
interface HandoffInitiateResponse {
code: string;
poll_secret: string;
}
interface HandoffStatusResponse {
status: 'pending' | 'completed' | 'expired';
token?: string;
}
describe('classifyWebPushOrigin', () => {
it.each([
{origin: 'https://web.fluxer.app', kind: 'legacy'},
{origin: 'https://web.canary.fluxer.app', kind: 'legacy'},
{origin: 'https://fluxer.com', kind: 'target'},
{origin: 'https://canary.fluxer.com', kind: 'target'},
{origin: 'https://fluxer.app', kind: null},
{origin: 'https://example.com', kind: null},
{origin: undefined, kind: null},
{origin: null, kind: null},
])('classifies $origin as $kind on the official instance', ({origin, kind}) => {
expect(classifyWebPushOrigin(origin, false)).toBe(kind);
});
it('never classifies an origin on a self-hosted instance', () => {
expect(classifyWebPushOrigin(LEGACY_ORIGIN, true)).toBeNull();
expect(classifyWebPushOrigin(TARGET_ORIGIN, true)).toBeNull();
});
});
describe('sameUserAgentFamily', () => {
it('matches the same browser across version updates', () => {
expect(sameUserAgentFamily(IPHONE_UA, IPHONE_UPDATED_UA)).toBe(true);
});
it('tells devices and browsers apart', () => {
expect(sameUserAgentFamily(DESKTOP_CHROME_UA, ANDROID_CHROME_UA)).toBe(false);
expect(sameUserAgentFamily(IPHONE_UA, DESKTOP_CHROME_UA)).toBe(false);
});
it('never matches a missing user agent', () => {
expect(sameUserAgentFamily(null, null)).toBe(false);
expect(sameUserAgentFamily(IPHONE_UA, undefined)).toBe(false);
});
});
describe('web push origin replacement', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
afterEach(() => {
vi.restoreAllMocks();
});
async function subscribeFrom(
token: string,
origin: string | null,
endpoint: string,
options: SubscribeOptions = {},
): Promise<string> {
const builder = createBuilder<PushSubscribeResponse>(harness, token).post('/users/@me/push/subscribe');
if (origin) builder.header('Origin', origin);
const response = await builder
.body({
endpoint,
keys: {p256dh: 'test-p256dh-key', auth: 'test-auth-key'},
user_agent: options.userAgent,
installed_app: options.installedApp,
})
.execute();
return response.subscription_id;
}
async function rotateFrom(
token: string,
origin: string,
oldEndpoint: string,
endpoint: string,
installedApp?: boolean,
): Promise<string> {
const response = await createBuilder<PushSubscribeResponse>(harness, token)
.post('/users/@me/push/rotate')
.header('Origin', origin)
.body({
old_endpoint: oldEndpoint,
endpoint,
keys: {p256dh: 'test-p256dh-key', auth: 'test-auth-key'},
installed_app: installedApp,
})
.execute();
return response.subscription_id;
}
async function listSubscriptionIds(token: string): Promise<Array<string>> {
const result = await listPushSubscriptions(harness, token);
return result.subscriptions.map((subscription) => subscription.subscription_id).sort();
}
async function pairNewSession(
approverToken: string,
approverUserId: string,
approverOrigin: string,
initiatorOrigin: string | null = TARGET_ORIGIN,
) {
const initiate = createBuilderWithoutAuth<HandoffInitiateResponse>(harness).post('/auth/handoff/initiate');
if (initiatorOrigin) initiate.header('Origin', initiatorOrigin);
const initiated = await initiate.body(null).execute();
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initiated.code}/info`).execute();
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.header('Origin', approverOrigin)
.body({code: initiated.code, token: approverToken, user_id: approverUserId})
.expect(204)
.execute();
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.post(`/auth/handoff/${initiated.code}/status`)
.body({poll_secret: initiated.poll_secret})
.execute();
expect(completed.status).toBe('completed');
return completed.token!;
}
async function withSelfHosted(callback: () => Promise<void>): Promise<void> {
const config = getConfig();
const original = config.instance.selfHosted;
try {
config.instance.selfHosted = true;
await callback();
} finally {
config.instance.selfHosted = original;
}
}
it('replaces the legacy subscription of the same session when the new origin subscribes', async () => {
const account = await createTestAccount(harness);
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy');
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
expect(await listSubscriptionIds(account.token)).toEqual([target]);
});
it('turns a later legacy subscribe for the replaced session into a no-op', async () => {
const account = await createTestAccount(harness);
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
const legacy = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy');
expect(legacy).toMatch(/^[a-f0-9]{32}$/);
expect(legacy).not.toBe(target);
expect(await listSubscriptionIds(account.token)).toEqual([target]);
});
it('does not store a legacy rotation for a replaced session', async () => {
const account = await createTestAccount(harness);
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-old');
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
await rotateFrom(
account.token,
LEGACY_ORIGIN,
'https://push.example.com/legacy-old',
'https://push.example.com/legacy-new',
);
expect(await listSubscriptionIds(account.token)).toEqual([target]);
});
it('keeps legacy subscriptions working until the new origin subscribes', async () => {
const account = await createTestAccount(harness);
const first = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-a');
const second = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-b');
expect(await listSubscriptionIds(account.token)).toEqual([first, second].sort());
});
it('leaves subscriptions from other sessions alone', async () => {
const account = await createTestAccount(harness);
const other = await loginAccount(harness, account);
const otherLegacy = await subscribeFrom(other.token, LEGACY_ORIGIN, 'https://push.example.com/other-legacy');
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
expect(await listSubscriptionIds(account.token)).toEqual([otherLegacy, target].sort());
});
it('never removes another new-origin subscription of the same session', async () => {
const account = await createTestAccount(harness);
const first = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target-a');
const second = await subscribeFrom(account.token, 'https://canary.fluxer.com', 'https://push.example.com/target-b');
expect(await listSubscriptionIds(account.token)).toEqual([first, second].sort());
});
it('treats unclassified rows as legacy without ever skipping an unclassified subscribe', async () => {
const account = await createTestAccount(harness);
const unknown = await subscribeFrom(account.token, null, 'https://push.example.com/no-origin');
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
expect(await listSubscriptionIds(account.token)).toEqual([target]);
const legacyAfter = await subscribeFrom(account.token, null, 'https://push.example.com/no-origin');
expect(legacyAfter).toBe(unknown);
expect(await listSubscriptionIds(account.token)).toEqual([unknown, target].sort());
});
it('replaces the approving legacy session on the same device once a paired session subscribes', async () => {
const account = await createTestAccount(harness);
const approver = await loginAccount(harness, account);
const approverLegacy = 'https://push.example.com/approver-legacy';
await subscribeFrom(approver.token, LEGACY_ORIGIN, approverLegacy, {userAgent: IPHONE_UA, installedApp: true});
const pairedToken = await pairNewSession(approver.token, approver.userId, LEGACY_ORIGIN);
const paired = await subscribeFrom(pairedToken, TARGET_ORIGIN, 'https://push.example.com/paired', {
userAgent: IPHONE_UPDATED_UA,
installedApp: true,
});
expect(await listSubscriptionIds(approver.token)).toEqual([paired]);
});
it('never silences the approving session for good', async () => {
const account = await createTestAccount(harness);
const approver = await loginAccount(harness, account);
const approverLegacy = 'https://push.example.com/approver-legacy';
await subscribeFrom(approver.token, LEGACY_ORIGIN, approverLegacy, {userAgent: IPHONE_UA});
const pairedToken = await pairNewSession(approver.token, approver.userId, LEGACY_ORIGIN);
const paired = await subscribeFrom(pairedToken, TARGET_ORIGIN, 'https://push.example.com/paired', {
userAgent: IPHONE_UA,
});
const restored = await subscribeFrom(approver.token, LEGACY_ORIGIN, approverLegacy, {userAgent: IPHONE_UA});
expect(await listSubscriptionIds(approver.token)).toEqual([paired, restored].sort());
});
it('leaves the approving session alone when it runs on another device', async () => {
const account = await createTestAccount(harness);
const approver = await loginAccount(harness, account);
const approverLegacy = await subscribeFrom(approver.token, LEGACY_ORIGIN, 'https://push.example.com/desktop', {
userAgent: DESKTOP_CHROME_UA,
installedApp: true,
});
const pairedToken = await pairNewSession(approver.token, approver.userId, LEGACY_ORIGIN);
const paired = await subscribeFrom(pairedToken, TARGET_ORIGIN, 'https://push.example.com/phone', {
userAgent: ANDROID_CHROME_UA,
installedApp: true,
});
expect(await listSubscriptionIds(approver.token)).toEqual([approverLegacy, paired].sort());
const desktopAgain = await subscribeFrom(approver.token, LEGACY_ORIGIN, 'https://push.example.com/desktop', {
userAgent: DESKTOP_CHROME_UA,
installedApp: true,
});
expect(desktopAgain).toBe(approverLegacy);
expect(await listSubscriptionIds(approver.token)).toEqual([approverLegacy, paired].sort());
});
it.each([
{label: 'the approval came from the new origin', approverOrigin: TARGET_ORIGIN, initiatorOrigin: TARGET_ORIGIN},
{label: 'the new session did not start on the new origin', approverOrigin: LEGACY_ORIGIN, initiatorOrigin: null},
{
label: 'the new session started on the old origin',
approverOrigin: LEGACY_ORIGIN,
initiatorOrigin: LEGACY_ORIGIN,
},
])('does not link sessions when $label', async ({approverOrigin, initiatorOrigin}) => {
const account = await createTestAccount(harness);
const approver = await loginAccount(harness, account);
const approverSubscription = await subscribeFrom(
approver.token,
LEGACY_ORIGIN,
'https://push.example.com/approver-legacy',
{userAgent: IPHONE_UA},
);
const pairedToken = await pairNewSession(approver.token, approver.userId, approverOrigin, initiatorOrigin);
const paired = await subscribeFrom(pairedToken, TARGET_ORIGIN, 'https://push.example.com/paired', {
userAgent: IPHONE_UA,
});
expect(await listSubscriptionIds(approver.token)).toEqual([approverSubscription, paired].sort());
});
it('completes the approval when the predecessor link cannot be written', async () => {
const account = await createTestAccount(harness);
const approver = await loginAccount(harness, account);
const setex = harness.kvProvider.setex.bind(harness.kvProvider);
vi.spyOn(harness.kvProvider, 'setex').mockImplementation(async (key, ttl, value) => {
if (key.startsWith('push_session_predecessor:')) throw new Error('kv down');
return setex(key, ttl, value);
});
const pairedToken = await pairNewSession(approver.token, approver.userId, LEGACY_ORIGIN);
expect(pairedToken).toBeTruthy();
});
it('stores a subscribe when the replacement marker cannot be read or written', async () => {
const account = await createTestAccount(harness);
const get = harness.kvProvider.get.bind(harness.kvProvider);
vi.spyOn(harness.kvProvider, 'get').mockImplementation(async (key) => {
if (key.startsWith('push_origin_replaced:')) throw new Error('kv down');
return get(key);
});
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
const legacy = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy');
expect(await listSubscriptionIds(account.token)).toEqual([legacy, target].sort());
});
it('keeps an installed legacy app subscribed when only a browser tab moved', async () => {
const account = await createTestAccount(harness);
const installed = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-app', {
userAgent: DESKTOP_CHROME_UA,
installedApp: true,
});
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target-tab', {
userAgent: DESKTOP_CHROME_UA,
});
expect(await listSubscriptionIds(account.token)).toEqual([installed, target].sort());
const rotated = await rotateFrom(
account.token,
LEGACY_ORIGIN,
'https://push.example.com/legacy-app',
'https://push.example.com/legacy-app-2',
true,
);
expect(await listSubscriptionIds(account.token)).toEqual([rotated, target].sort());
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-tab', {
userAgent: DESKTOP_CHROME_UA,
});
expect(await listSubscriptionIds(account.token)).toEqual([rotated, target].sort());
});
it('replaces an installed legacy app once the new app is installed', async () => {
const account = await createTestAccount(harness);
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-app', {
userAgent: DESKTOP_CHROME_UA,
installedApp: true,
});
await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target-tab', {
userAgent: DESKTOP_CHROME_UA,
});
const targetApp = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target-app', {
userAgent: DESKTOP_CHROME_UA,
installedApp: true,
});
const ids = await listSubscriptionIds(account.token);
expect(ids).toContain(targetApp);
expect(ids).toHaveLength(2);
await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-app', {
userAgent: DESKTOP_CHROME_UA,
installedApp: true,
});
expect(await listSubscriptionIds(account.token)).toEqual(ids);
});
it('does nothing new on a self-hosted instance', async () => {
await withSelfHosted(async () => {
const account = await createTestAccount(harness);
const legacy = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy');
const target = await subscribeFrom(account.token, TARGET_ORIGIN, 'https://push.example.com/target');
const legacyAgain = await subscribeFrom(account.token, LEGACY_ORIGIN, 'https://push.example.com/legacy-2');
expect(await listSubscriptionIds(account.token)).toEqual([legacy, target, legacyAgain].sort());
});
});
});
+6 -1
View File
@@ -10,6 +10,11 @@ function getInviteEndpointBase(): string {
return `${url.hostname}${url.pathname.replace(/\/+$/, '')}`;
}
function getWebAppHostsPattern(): string {
const hostnames = new Set(Config.endpoints.webAppOrigins.map((origin) => new URL(origin).hostname));
return [...hostnames].map((hostname) => RegexUtils.escapeRegex(hostname)).join('|');
}
function getInvitePattern(): RegExp {
if (!_invitePattern) {
_invitePattern = new RegExp(
@@ -18,7 +23,7 @@ function getInvitePattern(): RegExp {
'(?:',
`${RegexUtils.escapeRegex(getInviteEndpointBase())}(?:\\/#)?\\/(?!invite\\/)([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
'|',
`${RegexUtils.escapeRegex(new URL(Config.endpoints.webApp).hostname)}(?:\\/#)?\\/invite\\/([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
`(?:${getWebAppHostsPattern()})(?:\\/#)?\\/invite\\/([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
')',
].join(''),
'gi',
+10 -8
View File
@@ -8,7 +8,7 @@ import * as InviteUtils from '@app/api/utils/InviteUtils';
import {URL_REGEX} from '@fluxer/constants/src/Core';
import * as idna from 'idna-uts46-hx';
const CLIENT_ROUTE_PATH_PREFIXES = ['/channels/', '/theme/'];
const CLIENT_ROUTE_PATH_PREFIXES = ['/channels/', '/theme/', '/invite/', '/gift/', '/oauth2/', '/users/'];
interface ExcludedLinkBase {
hostname: string;
@@ -19,12 +19,14 @@ function normalizeHostname(hostname: string | undefined) {
return hostname?.trim().toLowerCase() || '';
}
function getWebAppHostname() {
try {
return new URL(Config.endpoints.webApp).hostname;
} catch {
return '';
}
function getWebAppHostnames(): Array<string> {
return Config.endpoints.webAppOrigins.flatMap((origin) => {
try {
return [new URL(origin).hostname];
} catch {
return [];
}
});
}
function endpointLinkBase(endpoint: string): ExcludedLinkBase | null {
@@ -45,7 +47,7 @@ function getExcludedLinkBases(): Array<ExcludedLinkBase> {
endpointLinkBase(Config.endpoints.invite),
endpointLinkBase(Config.endpoints.gift),
];
for (const hostname of [getWebAppHostname(), Config.hosts.marketing]) {
for (const hostname of [...getWebAppHostnames(), Config.hosts.marketing]) {
for (const pathPrefix of CLIENT_ROUTE_PATH_PREFIXES) {
bases.push({hostname: normalizeHostname(hostname), pathPrefix});
}
@@ -13,7 +13,7 @@ import {
getSubscriptionPremiumPeriodEnd,
getSubscriptionStartDate,
} from '@app/api/stripe/StripeSubscriptionPeriod';
import {createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
@@ -73,6 +73,10 @@ function buildStripePremiumRepairPatch(user: User, subscription: Stripe.Subscrip
if (user.stripeSubscriptionId !== subscription.id) {
patch.stripe_subscription_id = subscription.id;
}
const clearedPremiumFlags = clearPerksSanitizedFlag(user.premiumFlags);
if (user.premiumFlags !== clearedPremiumFlags) {
patch.premium_flags = clearedPremiumFlags;
}
if (subscriptionCustomerId && user.stripeCustomerId !== subscriptionCustomerId) {
patch.stripe_customer_id = subscriptionCustomerId;
}
@@ -10,6 +10,7 @@ import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import processPremiumStateReconciliationQueue from '@app/api/worker/tasks/ProcessPremiumStateReconciliationQueue';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
import {PremiumFlags} from '@fluxer/constants/src/UserConstants';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import type Stripe from 'stripe';
import {afterEach, describe, expect, test} from 'vitest';
@@ -48,6 +49,28 @@ function createCancelledSubscription(endedAtMs: number): Stripe.Subscription {
} as unknown as Stripe.Subscription;
}
function createActiveSubscription(periodEndMs: number): Stripe.Subscription {
return {
id: 'sub_test',
status: 'active',
customer: 'cus_test',
ended_at: null,
canceled_at: null,
cancel_at: null,
cancel_at_period_end: false,
trial_end: null,
start_date: Math.floor((Date.now() - 200 * ONE_DAY_MS) / 1000),
items: {
data: [
{
current_period_end: Math.floor(periodEndMs / 1000),
price: {recurring: {interval: 'month'}},
},
],
},
} as unknown as Stripe.Subscription;
}
function createPaidInvoice(periodEndMs: number): Stripe.Invoice {
return {
id: 'in_test',
@@ -299,4 +322,27 @@ describe('processPremiumStateReconciliationQueue', () => {
expect(patches[0].premium_until).toBeNull();
expect(patches[0].premium_since).toBeNull();
});
test('clears the perks-sanitized latch once the subscription is active again', async () => {
const queueService = createQueueService();
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
const periodEndMs = Math.floor((Date.now() + 20 * ONE_DAY_MS) / 1000) * 1000;
const user = createPremiumUser({
premium_until: new Date(periodEndMs),
premium_flags: PremiumFlags.PERKS_SANITIZED,
});
const {userRepository, patches, extras} = createCapturingDeps(user);
setWorkerDependenciesForTest({
premiumStateReconciliationQueueService: queueService,
stripe: createStripeStub(createActiveSubscription(periodEndMs), []),
userRepository,
...extras,
});
await processPremiumStateReconciliationQueue({}, createHelpers());
expect(patches).toHaveLength(1);
expect(patches[0].premium_flags).toBe(0);
});
});
+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:",
@@ -35,7 +35,8 @@ function generateManifest(staticCdnEndpoint) {
short_name: 'Fluxer',
description:
'Fluxer is a free and open source instant messaging and VoIP platform built for friends, groups, and communities.',
start_url: '/',
id: '/',
start_url: '/app',
display: 'standalone',
orientation: 'portrait-primary',
theme_color: '#4641D9',
@@ -43,6 +44,7 @@ function generateManifest(staticCdnEndpoint) {
categories: ['social', 'communication'],
lang: 'en',
scope: '/',
scope_extensions: [],
icons: [
{
src: `${cdn}/web/android-chrome-192x192.png`,
+1
View File
@@ -4,6 +4,7 @@ import {marketingUrl} from '@app/features/messaging/utils/MessagingUrlUtils';
export const Routes = {
HOME: '/',
APP: '/app',
LOGIN: '/login',
REGISTER: '/register',
FORGOT_PASSWORD: '/forgot',
@@ -214,7 +214,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
) {
return;
}
if (location.pathname === Routes.HOME) {
if (location.pathname === Routes.HOME || location.pathname === Routes.APP) {
return;
}
hasStartedRestoreRef.current = true;
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import {RootComponent} from '@app/app/router/components/RootComponent';
import {NotFoundPage} from '@app/features/app/components/pages/NotFoundPage';
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
@@ -22,3 +23,9 @@ export const homeRoute = createRoute({
path: '/',
onEnter: () => new Redirect(getDefaultLandingPath()),
});
export const appRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'app',
path: Routes.APP,
onEnter: () => new Redirect(getDefaultLandingPath()),
});
@@ -7,11 +7,12 @@ import {
premiumCallbackRoute,
} from '@app/app/router/routes/AppRoutes';
import {authRouteTree} from '@app/app/router/routes/AuthRoutes';
import {homeRoute, notFoundRoute, rootRoute} from '@app/app/router/routes/RootRoutes';
import {appRoute, homeRoute, notFoundRoute, rootRoute} from '@app/app/router/routes/RootRoutes';
import type {RouteConfig} from '@app/features/platform/components/router/RouterTypes';
const routeTree = rootRoute.addChildren([
homeRoute,
appRoute,
notFoundRoute,
premiumCallbackRoute,
matureContentCheckCallbackRoute,
@@ -29,7 +29,7 @@ interface ForwardOriginChannel {
interface BuildForwardDefaultDestinationsRequest {
readonly frequentIds: ReadonlyArray<string>;
readonly history: ReadonlyArray<string>;
readonly isValid: (row: ForwardRowIdentity) => boolean;
readonly accepts: (row: ForwardRowIdentity) => boolean;
readonly mode: ForwardResultType | null;
readonly origin: ForwardDestination | null;
readonly pinned: ReadonlyArray<ForwardDestination>;
@@ -51,7 +51,7 @@ export function resolveForwardOrigin(
export function buildForwardDefaultDestinations({
frequentIds,
history,
isValid,
accepts,
mode,
origin,
pinned,
@@ -64,7 +64,7 @@ export function buildForwardDefaultDestinations({
...history.slice(0, HISTORY_LIMIT).map((channelId) => resolveChannel(channelId)),
...frequentIds.slice(0, FREQUENT_LIMIT).map((channelId) => resolveChannel(channelId)),
];
const rows = candidates.filter((row): row is ForwardRowIdentity => row != null && isValid(row));
const rows = candidates.filter((row): row is ForwardRowIdentity => row != null && accepts(row));
const originSelected =
origin != null && selected.some((destination) => destination.type === origin.type && destination.id === origin.id);
const hiddenIds = origin == null || originSelected ? [] : [origin.id];
@@ -79,10 +79,10 @@ export function buildForwardDefaultDestinations({
export function filterForwardSearchRows(
results: ReadonlyArray<ForwardRowIdentity>,
isValid: (row: ForwardRowIdentity) => boolean,
accepts: (row: ForwardRowIdentity) => boolean,
): ReadonlyArray<ForwardDestinationRow> {
return dedupeRows(
results.filter((result) => isValid(result)),
results.filter((result) => accepts(result)),
[],
);
}
@@ -5,7 +5,7 @@ export type ForwardResultType = 'user' | 'text_channel' | 'voice_channel' | 'gro
export interface ForwardDestinationQuery {
readonly mode: ForwardResultType | null;
readonly query: string;
readonly resultTypes: ReadonlyArray<ForwardResultType>;
readonly kinds: ReadonlyArray<ForwardResultType>;
}
const FORWARD_RESULT_TYPES: ReadonlyArray<ForwardResultType> = Object.freeze([
@@ -24,10 +24,10 @@ const SIGIL_PATTERN = /^@|#|!|\*|\$/;
export function parseForwardDestinationQuery(text: string): ForwardDestinationQuery {
if (text.startsWith(GLOBAL_USER_SIGIL)) {
return Object.freeze({mode: null, query: text.slice(GLOBAL_USER_SIGIL.length), resultTypes: FORWARD_RESULT_TYPES});
return Object.freeze({mode: null, query: text.slice(GLOBAL_USER_SIGIL.length), kinds: FORWARD_RESULT_TYPES});
}
const query = text.replace(SIGIL_PATTERN, '');
const mode = SIGIL_RESULT_TYPES.get(text.charAt(0));
if (mode === undefined) return Object.freeze({mode: null, query, resultTypes: FORWARD_RESULT_TYPES});
return Object.freeze({mode, query, resultTypes: Object.freeze([mode])});
if (mode === undefined) return Object.freeze({mode: null, query, kinds: FORWARD_RESULT_TYPES});
return Object.freeze({mode, query, kinds: Object.freeze([mode])});
}
@@ -21,11 +21,12 @@ const CHANNEL_CONTEXT_SCORE_CAP = 6;
const VOICE_IN_TEXT_SEARCH_PENALTY = 1;
const VOICE_IN_TEXT_SEARCH_FLOOR = 0.5;
const CHANNEL_FRECENCY_BONUS = 3;
const SNOWFLAKE_SCORE = 10;
type ForwardChannelKind = 'text' | 'voice';
export type ForwardChannelKind = 'text' | 'voice';
export interface ForwardUserCandidate {
readonly friendNickname: string | null;
readonly friendAlias: string | null;
readonly globalName: string | null;
readonly id: string;
readonly nicknames: ReadonlyArray<string>;
@@ -48,69 +49,79 @@ export interface ForwardChannelCandidate {
readonly parentName: string | null;
}
export interface ForwardGuildCandidate {
readonly id: string;
readonly name: string;
}
export interface ForwardFrequentItem {
readonly id: string;
readonly kind: 'dm' | 'group_dm' | 'text' | 'voice' | 'other';
readonly kind: 'dm' | 'group_dm' | 'guild' | 'text' | 'voice' | 'other';
readonly recipientId?: string | null;
readonly score: number;
}
export interface ForwardSearchBoosters {
export interface ForwardSearchWeights {
readonly groupDMs: ReadonlyMap<string, number>;
readonly textChannels: ReadonlyMap<string, number>;
readonly guilds: ReadonlyMap<string, number>;
readonly textChannel: ReadonlyMap<string, number>;
readonly users: ReadonlyMap<string, number>;
readonly voiceChannels: ReadonlyMap<string, number>;
readonly voiceChannel: ReadonlyMap<string, number>;
}
export interface ForwardSearchResult {
readonly comparator: string;
readonly matchedText: string;
readonly id: string;
readonly score: number;
readonly type: ForwardResultType;
}
interface BuildForwardSearchBoostersRequest {
interface BuildForwardSearchWeightsRequest {
readonly dmUserIds: Iterable<string>;
readonly frequent: ReadonlyArray<ForwardFrequentItem>;
readonly friendIds: Iterable<string>;
}
interface SearchForwardDestinationsRequest {
readonly blacklist: ReadonlySet<string>;
readonly boosters: ForwardSearchBoosters;
readonly excludedIds: ReadonlySet<string>;
readonly weights: ForwardSearchWeights;
readonly channels: ReadonlyArray<ForwardChannelCandidate>;
readonly confusables: ReadonlyMap<string, string>;
readonly groupDMs: ReadonlyArray<ForwardGroupDMCandidate>;
readonly limit: number;
readonly query: string;
readonly resultTypes: ReadonlyArray<ForwardResultType>;
readonly kinds: ReadonlyArray<ForwardResultType>;
readonly users: ReadonlyArray<ForwardUserCandidate>;
}
export function buildForwardSearchBoosters({
export function buildForwardSearchWeights({
dmUserIds,
frequent,
friendIds,
}: BuildForwardSearchBoostersRequest): ForwardSearchBoosters {
}: BuildForwardSearchWeightsRequest): ForwardSearchWeights {
const maxScore = frequent.reduce((max, item) => Math.max(max, item.score), 0);
const users = new Map<string, number>();
const groupDMs = new Map<string, number>();
const textChannels = new Map<string, number>();
const voiceChannels = new Map<string, number>();
const guilds = new Map<string, number>();
const textChannel = new Map<string, number>();
const voiceChannel = new Map<string, number>();
for (const item of frequent) {
const boost = maxScore > 0 ? 1 + item.score / maxScore : 1;
const weight = maxScore > 0 ? 1 + item.score / maxScore : 1;
switch (item.kind) {
case 'dm':
if (item.recipientId != null) users.set(item.recipientId, boost);
if (item.recipientId != null) users.set(item.recipientId, weight);
break;
case 'group_dm':
groupDMs.set(item.id, boost);
groupDMs.set(item.id, weight);
break;
case 'guild':
guilds.set(item.id, weight);
break;
case 'text':
textChannels.set(item.id, boost);
textChannel.set(item.id, weight);
break;
case 'voice':
voiceChannels.set(item.id, boost);
voiceChannel.set(item.id, weight);
break;
case 'other':
break;
@@ -118,31 +129,27 @@ export function buildForwardSearchBoosters({
}
for (const friendId of friendIds) users.set(friendId, (users.get(friendId) ?? 1) + FRIEND_BOOST);
for (const userId of dmUserIds) users.set(userId, (users.get(userId) ?? 1) + OPEN_DM_BOOST);
return Object.freeze({groupDMs, textChannels, users, voiceChannels});
return Object.freeze({groupDMs, guilds, textChannel, users, voiceChannel});
}
export function searchForwardDestinations(
request: SearchForwardDestinationsRequest,
): ReadonlyArray<ForwardSearchResult> {
const {boosters, channels, confusables, limit, query, resultTypes} = request;
const {weights, channels, confusables, limit, query, kinds} = request;
if (query.trim() === '') return [];
const results = [
...(resultTypes.includes('user')
? searchUsers(query, request.users, boosters.users, request.blacklist, confusables, limit)
...(kinds.includes('user')
? searchUsers(query, request.users, weights.users, request.excludedIds, confusables, limit)
: []),
...(resultTypes.includes('group_dm')
? searchGroupDMs(query, request.groupDMs, boosters.groupDMs, confusables, limit)
: []),
...(resultTypes.includes('text_channel')
? searchChannels(query, channels, 'text', boosters.textChannels, limit)
: []),
...(resultTypes.includes('voice_channel')
? searchChannels(query, channels, 'voice', boosters.voiceChannels, limit)
...(kinds.includes('group_dm')
? searchGroupDMs(query, request.groupDMs, weights.groupDMs, confusables, limit)
: []),
...(kinds.includes('text_channel') ? searchChannels(query, channels, 'text', weights.textChannel, limit) : []),
...(kinds.includes('voice_channel') ? searchChannels(query, channels, 'voice', weights.voiceChannel, limit) : []),
];
const seenKeys = new Set<string>();
const merged = results.filter((result) => {
const key = `${result.type}-${result.id}`;
const key = `${result.type}|${result.id}`;
if (seenKeys.has(key)) return false;
seenKeys.add(key);
return true;
@@ -150,10 +157,10 @@ export function searchForwardDestinations(
return merged.sort(compareSearchResults);
}
function compareSearchResults(left: ForwardSearchResult, right: ForwardSearchResult): number {
export function compareSearchResults(left: ForwardSearchResult, right: ForwardSearchResult): number {
if (left.score === right.score && left.type === 'user') {
const leftName = left.comparator.toLocaleLowerCase();
const rightName = right.comparator.toLocaleLowerCase();
const leftName = left.matchedText.toLocaleLowerCase();
const rightName = right.matchedText.toLocaleLowerCase();
if (leftName < rightName) return -1;
if (leftName > rightName) return 1;
}
@@ -164,69 +171,69 @@ function sortAndLimit(results: Array<ForwardSearchResult>, limit: number): Array
return results.sort(compareSearchResults).slice(0, limit);
}
function searchUsers(
export function searchUsers(
query: string,
users: ReadonlyArray<ForwardUserCandidate>,
boosters: ReadonlyMap<string, number>,
blacklist: ReadonlySet<string>,
weights: ReadonlyMap<string, number>,
excludedIds: ReadonlySet<string>,
confusables: ReadonlyMap<string, string>,
limit: number,
): Array<ForwardSearchResult> {
const escapedQuery = escapeSearchPattern(query);
const prefixQuery = new RegExp(`^${escapedQuery}`, 'i');
const containQuery = new RegExp(escapedQuery, 'i');
const queryLower = query.toLocaleLowerCase();
const querySkeleton = toConfusableSkeleton(queryLower, confusables);
const substringPattern = new RegExp(escapedQuery, 'i');
const loweredText = query.toLocaleLowerCase();
const querySkeleton = toConfusableSkeleton(loweredText, confusables);
const scoreField = (field: string): number => {
if (prefixQuery.test(field)) return 10;
if (containQuery.test(field)) return 5;
if (substringPattern.test(field)) return 5;
const stripped = stripCombiningMarks(field.toLocaleLowerCase());
if (fuzzySearch(queryLower, stripped)) return 1;
if (fuzzySearch(loweredText, stripped)) return 1;
if (fuzzySearch(querySkeleton, toConfusableSkeleton(stripped, confusables))) return 1;
return 0;
};
const matches: Array<ForwardSearchResult> = [];
for (const user of users) {
if (blacklist.has(user.id)) continue;
const booster = boosters.get(user.id) ?? 1;
if (excludedIds.has(user.id)) continue;
const booster = weights.get(user.id) ?? 1;
if (user.id === query) {
matches.push({comparator: user.id, id: user.id, score: 10 * booster, type: 'user'});
matches.push({matchedText: user.id, id: user.id, score: 10 * booster, type: 'user'});
continue;
}
let best: ForwardSearchResult | null = null;
for (const field of [user.username, user.friendNickname, user.globalName, ...user.nicknames]) {
for (const field of [user.username, user.friendAlias, user.globalName, ...user.nicknames]) {
if (field == null) continue;
const score = scoreField(field) * booster;
if (score === 0 || (best != null && best.score >= score)) continue;
best = {comparator: field, id: user.id, score, type: 'user'};
best = {matchedText: field, id: user.id, score, type: 'user'};
}
if (best != null) matches.push(best);
}
return sortAndLimit(matches, limit).map((match) => Object.freeze({...match, score: SCORE_SCALE * match.score}));
}
function searchGroupDMs(
export function searchGroupDMs(
query: string,
groupDMs: ReadonlyArray<ForwardGroupDMCandidate>,
boosters: ReadonlyMap<string, number>,
weights: ReadonlyMap<string, number>,
confusables: ReadonlyMap<string, string>,
limit: number,
): Array<ForwardSearchResult> {
const normalize = (text: string): string =>
const foldText = (text: string): string =>
stripCombiningMarks(toConfusableSkeleton(text.toLocaleLowerCase(), confusables));
const term = createSearchTerm(normalize(query));
const term = createSearchTerm(foldText(query));
const results: Array<ForwardSearchResult> = [];
for (const groupDM of groupDMs) {
let score = scoreSearchTerm(normalize(groupDM.name), term);
let score = scoreSearchTerm(foldText(groupDM.name), term);
for (const field of groupDM.memberFields) {
score = Math.max(score, Math.min(GROUP_DM_MEMBER_SCORE_CAP, scoreSearchTerm(normalize(field), term)));
score = Math.max(score, Math.min(GROUP_DM_MEMBER_SCORE_CAP, scoreSearchTerm(foldText(field), term)));
}
if (score === 0) continue;
results.push(
Object.freeze({
comparator: groupDM.name,
matchedText: groupDM.name,
id: groupDM.id,
score: SCORE_SCALE * score * (boosters.get(groupDM.id) ?? 1),
score: SCORE_SCALE * score * (weights.get(groupDM.id) ?? 1),
type: 'group_dm',
}),
);
@@ -234,12 +241,13 @@ function searchGroupDMs(
return sortAndLimit(results, limit);
}
function searchChannels(
export function searchChannels(
query: string,
channels: ReadonlyArray<ForwardChannelCandidate>,
searchKind: ForwardChannelKind,
boosters: ReadonlyMap<string, number>,
weights: ReadonlyMap<string, number>,
limit: number,
matchExactIds = false,
): Array<ForwardSearchResult> {
const terms = buildChannelSearchTerms(query);
const results: Array<ForwardSearchResult> = [];
@@ -247,29 +255,64 @@ function searchChannels(
if (searchKind === 'voice' && channel.kind !== 'voice') continue;
if (!channel.canAccess) continue;
const remainingTerms = [...terms];
let score = consumeBestSearchTerm(channel.name.toLocaleLowerCase(), remainingTerms, true);
const isSnowflakeMatch = matchExactIds && channel.id === query;
let score = isSnowflakeMatch
? SNOWFLAKE_SCORE
: consumeBestSearchTerm(channel.name.toLocaleLowerCase(), remainingTerms, true);
if (score === 0) continue;
if (remainingTerms.length > 0) {
if (!isSnowflakeMatch && remainingTerms.length > 0) {
for (const context of [channel.guildName, channel.parentName]) {
if (context == null || context === '') continue;
score += CHANNEL_CONTEXT_WEIGHT * consumeBestSearchTerm(context.toLocaleLowerCase(), remainingTerms, false);
}
score = Math.min(CHANNEL_CONTEXT_SCORE_CAP, score);
}
if (remainingTerms.length > 1) continue;
if (remainingTerms.length === 1 && !remainingTerms[0].isFullMatch) continue;
if (!isSnowflakeMatch && remainingTerms.length > 1) continue;
if (!isSnowflakeMatch && remainingTerms.length === 1 && !remainingTerms[0].spansWholeQuery) continue;
if (searchKind === 'text' && channel.kind === 'voice') {
score = Math.max(score - VOICE_IN_TEXT_SEARCH_PENALTY, VOICE_IN_TEXT_SEARCH_FLOOR);
}
score = Math.min(score + (channel.hasFrecency ? CHANNEL_FRECENCY_BONUS : 0), score >= 7 ? 10 : 7);
results.push(
Object.freeze({
comparator: channel.name,
matchedText: channel.name,
id: channel.id,
score: SCORE_SCALE * score * (boosters.get(channel.id) ?? 1),
score: SCORE_SCALE * score * (weights.get(channel.id) ?? 1),
type: channel.kind === 'voice' ? 'voice_channel' : 'text_channel',
}),
);
}
return sortAndLimit(results, limit);
}
export interface GuildSearchResult {
readonly matchedText: string;
readonly id: string;
readonly score: number;
}
export function searchGuilds(
query: string,
guilds: ReadonlyArray<ForwardGuildCandidate>,
weights: ReadonlyMap<string, number>,
excludedIds: ReadonlySet<string>,
limit: number,
matchExactIds = false,
): Array<GuildSearchResult> {
const term = createSearchTerm(query.toLocaleLowerCase());
const results: Array<GuildSearchResult> = [];
for (const guild of guilds) {
if (excludedIds.has(guild.id)) continue;
const score =
matchExactIds && guild.id === query ? SNOWFLAKE_SCORE : scoreSearchTerm(guild.name.toLocaleLowerCase(), term);
if (score === 0) continue;
results.push(
Object.freeze({
matchedText: guild.name,
id: guild.id,
score: SCORE_SCALE * score * (weights.get(guild.id) ?? 1),
}),
);
}
return results.sort((left, right) => right.score - left.score).slice(0, limit);
}
@@ -10,7 +10,7 @@ export interface ForwardPinnedDestinations {
}
export function forwardDestinationKey(destination: ForwardDestination): string {
return `${destination.type}:${destination.id}`;
return `${destination.type}/${destination.id}`;
}
export function toggleForwardDestination(
@@ -9,13 +9,7 @@ import {
} from '@app/features/app/components/dialogs/shared/ForwardDefaultDestinations';
import {parseForwardDestinationQuery} from '@app/features/app/components/dialogs/shared/ForwardDestinationQuery';
import {
buildForwardSearchBoosters,
type ForwardChannelCandidate,
type ForwardFrequentItem,
type ForwardGroupDMCandidate,
type ForwardSearchBoosters,
type ForwardSearchResult,
type ForwardUserCandidate,
searchForwardDestinations,
} from '@app/features/app/components/dialogs/shared/ForwardDestinationSearch';
import {
@@ -33,7 +27,6 @@ import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import type {Guild} from '@app/features/guild/models/Guild';
import Guilds from '@app/features/guild/state/Guilds';
import {PERSONAL_NOTES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import GuildMembers from '@app/features/member/state/GuildMembers';
import type {ForwardMediaSelection} from '@app/features/messaging/commands/MessageCommands';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
@@ -41,7 +34,7 @@ import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import Permission from '@app/features/permissions/state/Permission';
import {formatPermissionLabel} from '@app/features/permissions/utils/PermissionUtils';
import {Logger} from '@app/features/platform/utils/AppLogger';
import Relationships from '@app/features/relationship/state/Relationships';
import {createForwardSearchCandidates} from '@app/features/search/utils/DestinationSearchSources';
import {getLoadedUnicodeConfusables, loadUnicodeConfusables} from '@app/features/search/utils/SearchTextMatching';
import Slowmode from '@app/features/slowmode/state/Slowmode';
import {useNow} from '@app/features/ui/state/Tick';
@@ -51,13 +44,11 @@ import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildNSFWLevel, GuildOperations} from '@fluxer/constants/src/GuildConstants';
import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConstants';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import type {MessageEmbed} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {compareStructural, computed, type IComputedValue} from 'mobx';
import {useEffect, useMemo, useState} from 'react';
const GUILD_MESSAGES_DISABLED_DESCRIPTOR = msg({
@@ -110,13 +101,6 @@ interface ForwardMediaNeeds {
readonly hasEmbeds: boolean;
}
interface ForwardSearchCandidates {
readonly boosters: ForwardSearchBoosters;
readonly channels: ReadonlyArray<ForwardChannelCandidate>;
readonly groupDMs: ReadonlyArray<ForwardGroupDMCandidate>;
readonly users: ReadonlyArray<ForwardUserCandidate>;
}
export interface ForwardDestinationOption {
readonly channel: Channel | null;
readonly destination: ForwardDestination;
@@ -137,10 +121,10 @@ interface UseForwardDestinationsOptions {
interface ForwardDestinationsState {
readonly composerChannel: Channel | null;
readonly options: ReadonlyArray<ForwardDestinationOption>;
readonly searchQuery: string;
readonly filterText: string;
readonly selected: ReadonlyArray<ForwardDestination>;
readonly selectedKeys: ReadonlySet<string>;
readonly setSearchQuery: (query: string) => void;
readonly setFilterText: (query: string) => void;
readonly slowmodeActiveSelectedOptions: ReadonlyArray<ForwardDestinationOption>;
readonly slowmodeEnabledSelectedOptions: ReadonlyArray<ForwardDestinationOption>;
readonly toggleDestination: (destination: ForwardDestination) => void;
@@ -197,144 +181,6 @@ function isForwardRowValid(row: ForwardRowIdentity): boolean {
}
}
function collectGuildNicknames(): ReadonlyMap<string, Array<string>> {
const nicknames = new Map<string, Array<string>>();
for (const guild of Guilds.getGuilds()) {
for (const member of GuildMembers.getMembers(guild.id)) {
if (member.nick == null) continue;
const userNicknames = nicknames.get(member.user.id);
if (userNicknames === undefined) {
nicknames.set(member.user.id, [member.nick]);
} else {
userNicknames.push(member.nick);
}
}
}
return nicknames;
}
function buildForwardUserCandidates(): ReadonlyArray<ForwardUserCandidate> {
const nicknames = collectGuildNicknames();
const candidates: Array<ForwardUserCandidate> = [];
for (const user of Users.getUsers()) {
const relationship = Relationships.getRelationship(user.id);
if (relationship?.type === RelationshipTypes.BLOCKED) continue;
let friendNickname: string | null = null;
if (relationship?.type === RelationshipTypes.FRIEND) friendNickname = relationship.nickname;
candidates.push(
Object.freeze({
friendNickname,
globalName: user.globalName,
id: user.id,
nicknames: nicknames.get(user.id) ?? NO_STRINGS,
username: user.discriminator === '0' ? user.username : `${user.username}#${user.discriminator}`,
}),
);
}
return Object.freeze(candidates);
}
function collectRecipientSearchFields(recipientIds: ReadonlyArray<string>): ReadonlyArray<string> {
const fields: Array<string> = [];
for (const recipientId of recipientIds) {
const recipient = Users.getUser(recipientId);
if (recipient == null) continue;
fields.push(recipient.username);
if (recipient.globalName != null) fields.push(recipient.globalName);
const relationshipNickname = Relationships.getRelationship(recipientId)?.nickname;
if (relationshipNickname != null) fields.push(relationshipNickname);
}
return Object.freeze(fields);
}
function buildForwardGroupDMCandidates(i18n: I18n): ReadonlyArray<ForwardGroupDMCandidate> {
const candidates: Array<ForwardGroupDMCandidate> = [];
for (const channel of Channels.getPrivateChannels()) {
if (channel.type !== ChannelTypes.GROUP_DM) continue;
candidates.push(
Object.freeze({
id: channel.id,
memberFields: collectRecipientSearchFields(channel.recipientIds),
name: ChannelUtils.getDMDisplayName(channel),
}),
);
}
const currentUserId = Users.currentUserId;
const personalNotes = currentUserId == null ? undefined : Channels.getChannel(currentUserId);
if (personalNotes?.type === ChannelTypes.DM_PERSONAL_NOTES) {
candidates.push(
Object.freeze({id: personalNotes.id, memberFields: NO_STRINGS, name: i18n._(PERSONAL_NOTES_DESCRIPTOR)}),
);
}
return Object.freeze(candidates);
}
function buildForwardChannelCandidates(): ReadonlyArray<ForwardChannelCandidate> {
const candidates: Array<ForwardChannelCandidate> = [];
for (const channel of Channels.allChannels) {
if (channel.type !== ChannelTypes.GUILD_TEXT && channel.type !== ChannelTypes.GUILD_VOICE) continue;
const isVoice = channel.type === ChannelTypes.GUILD_VOICE;
const accessPermissions = isVoice ? Permissions.VIEW_CHANNEL | Permissions.CONNECT : Permissions.VIEW_CHANNEL;
candidates.push(
Object.freeze({
canAccess: Permission.can(accessPermissions, channel),
guildName: channel.guildId == null ? null : (Guilds.getGuild(channel.guildId)?.name ?? null),
hasFrecency: ChannelFrecency.getScore(channel.id) > 0,
id: channel.id,
kind: isVoice ? 'voice' : 'text',
name: channel.name ?? '',
parentName: channel.parentId == null ? null : (Channels.getChannel(channel.parentId)?.name ?? null),
}),
);
}
return Object.freeze(candidates);
}
function resolveForwardFrequentItem(id: string): ForwardFrequentItem {
const score = ChannelFrecency.getScore(id);
const channel = Guilds.getGuild(id) == null ? Channels.getChannel(id) : undefined;
switch (channel?.type) {
case ChannelTypes.DM:
return {id, kind: 'dm', recipientId: channel.recipientIds.length > 0 ? channel.recipientIds[0] : null, score};
case ChannelTypes.GROUP_DM:
case ChannelTypes.DM_PERSONAL_NOTES:
return {id, kind: 'group_dm', score};
case ChannelTypes.GUILD_TEXT:
return {id, kind: 'text', score};
case ChannelTypes.GUILD_VOICE:
return {id, kind: 'voice', score};
default:
return {id, kind: 'other', score};
}
}
function buildForwardSearchBoostersFromStores(): ForwardSearchBoosters {
const friendIds: Array<string> = [];
for (const relationship of Relationships.getRelationships()) {
if (relationship.type === RelationshipTypes.FRIEND) friendIds.push(relationship.userId);
}
const dmUserIds: Array<string> = [];
for (const channel of Channels.getPrivateChannels()) {
if (channel.type === ChannelTypes.DM && channel.recipientIds.length > 0) dmUserIds.push(channel.recipientIds[0]);
}
return buildForwardSearchBoosters({
dmUserIds,
frequent: ChannelFrecency.frequentIds.map(resolveForwardFrequentItem),
friendIds,
});
}
function createForwardSearchCandidates(i18n: I18n): IComputedValue<ForwardSearchCandidates> {
const options = {equals: compareStructural};
const users = computed(buildForwardUserCandidates, options);
const groupDMs = computed(() => buildForwardGroupDMCandidates(i18n), options);
const channels = computed(buildForwardChannelCandidates, options);
const boosters = computed(buildForwardSearchBoostersFromStores, options);
return computed(() =>
Object.freeze({boosters: boosters.get(), channels: channels.get(), groupDMs: groupDMs.get(), users: users.get()}),
);
}
function selectForwardedAttachments(
message: Message,
mediaSelection: ForwardMediaSelection | undefined,
@@ -418,6 +264,16 @@ function formatGuildChannelDetail(guild: Guild | undefined, channel: Channel): s
return detail === '' ? null : detail;
}
function formatGroupDMDetail(channel: Channel): string | null {
if (channel.type !== ChannelTypes.GROUP_DM || (channel.name?.trim() ?? '') === '') return null;
const names: Array<string> = [];
for (const recipientId of channel.recipientIds) {
const recipient = Users.getUser(recipientId);
if (recipient != null) names.push(NicknameUtils.getNickname(recipient, null, channel.id));
}
return names.length === 0 ? null : names.join(', ');
}
function resolveGuildChannelDisableReason(
channel: Channel,
guild: Guild | undefined,
@@ -469,7 +325,7 @@ function resolveForwardDestinationOption(
return Object.freeze({
channel,
destination,
detail: null,
detail: formatGroupDMDetail(channel),
disableReason: resolveAgeRestrictedDisableReason(channel, mediaNeeds, i18n),
displayName: ChannelUtils.getDMDisplayName(channel),
key,
@@ -521,16 +377,16 @@ export function useForwardDestinations({
message,
}: UseForwardDestinationsOptions): ForwardDestinationsState {
const {i18n} = useLingui();
const [searchQuery, setSearchQuery] = useState('');
const [filterText, setFilterText] = useState('');
const [selected, setSelected] = useState(NO_DESTINATIONS);
const [pinnedDestinations, setPinnedDestinations] = useState(INITIAL_PINNED_DESTINATIONS);
const [stickyPicks, setStickyPicks] = useState(INITIAL_PINNED_DESTINATIONS);
const [confusables, setConfusables] = useState(() => getLoadedUnicodeConfusables() ?? NO_CONFUSABLES);
const searchCandidates = useMemo(() => createForwardSearchCandidates(i18n), [i18n, i18n.locale]);
const mediaNeeds = useMemo(() => resolveForwardMediaNeeds(message, mediaSelection), [message, mediaSelection]);
const parsedQuery = useMemo(() => parseForwardDestinationQuery(searchQuery), [searchQuery]);
const parsedQuery = useMemo(() => parseForwardDestinationQuery(filterText), [filterText]);
const engineQuery = parsedQuery.query.trim() === '' ? '' : parsedQuery.query;
const currentPinned = pinForwardDestinations(pinnedDestinations, engineQuery, selected);
if (currentPinned !== pinnedDestinations) setPinnedDestinations(currentPinned);
const currentPinned = pinForwardDestinations(stickyPicks, engineQuery, selected);
if (currentPinned !== stickyPicks) setStickyPicks(currentPinned);
useEffect(() => {
let isMounted = true;
loadUnicodeConfusables().then(
@@ -549,11 +405,11 @@ export function useForwardDestinations({
if (candidates == null) return NO_SEARCH_RESULTS;
return searchForwardDestinations({
...candidates,
blacklist: new Set(currentUserId == null ? NO_STRINGS : [currentUserId]),
excludedIds: new Set(currentUserId == null ? NO_STRINGS : [currentUserId]),
confusables,
limit: parsedQuery.resultTypes.length === 1 ? SINGLE_TYPE_SEARCH_LIMIT : SEARCH_LIMIT,
limit: parsedQuery.kinds.length === 1 ? SINGLE_TYPE_SEARCH_LIMIT : SEARCH_LIMIT,
query: engineQuery,
resultTypes: parsedQuery.resultTypes,
kinds: parsedQuery.kinds,
});
}, [candidates, confusables, currentUserId, engineQuery, parsedQuery]);
const rows =
@@ -561,7 +417,7 @@ export function useForwardDestinations({
? buildForwardDefaultDestinations({
frequentIds: ChannelFrecency.frequentIds,
history: [...new Set(SelectedChannel.sortedRecentVisits.map((visit) => visit.channelId))],
isValid: isForwardRowValid,
accepts: isForwardRowValid,
mode: parsedQuery.mode,
origin: resolveForwardOrigin(message.channelId, Channels.getChannel(message.channelId)),
pinned: currentPinned.pinned,
@@ -580,16 +436,16 @@ export function useForwardDestinations({
const toggleDestination = (destination: ForwardDestination) => {
const next = toggleForwardDestination(selected, destination);
if (next === selected) return;
if (next.length > selected.length) setSearchQuery('');
if (next.length > selected.length) setFilterText('');
setSelected(next);
};
return {
composerChannel: resolveForwardComposerChannel(selected),
options,
searchQuery,
filterText,
selected,
selectedKeys: new Set(selected.map(forwardDestinationKey)),
setSearchQuery,
setFilterText,
slowmodeActiveSelectedOptions: selectedOptions.filter(isSlowmodeActive),
slowmodeEnabledSelectedOptions: selectedOptions.filter((option) => option.slowmodeEnabled),
toggleDestination,
@@ -1287,6 +1287,7 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
[NagbarType.LINUX_INPUT_ACCESS]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
[NagbarType.SOFTWARE_ENCODER]: {tone: SkeletonNagbarTone.ENCODER, hasActions: true},
[NagbarType.STREAMER_MODE]: {tone: SkeletonNagbarTone.STREAMER, hasActions: true},
[NagbarType.DOMAIN_MOVED]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
};
const CONNECTION_SKELETON_NAGBAR_TONES: Record<ConnectionNoticeTone, SkeletonNagbarTone> = {
@@ -8,6 +8,7 @@ import {
} from '@app/features/app/components/layout/app_layout/AppLayoutTypes';
import {isScheduledMaintenanceNagbarDismissed} from '@app/features/app/components/layout/app_layout/ScheduledMaintenanceDismissal';
import Config from '@app/features/app/config/Config';
import DomainMovedNotice from '@app/features/app/domain_migration/DomainMovedNotice';
import {isClientReconnecting} from '@app/features/app/state/ClientReadiness';
import Initialization from '@app/features/app/state/Initialization';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
@@ -255,6 +256,11 @@ export const useNagbarConditions = (): NagbarConditions => {
const canShowSoftwareEncoder = SoftwareEncoderWarning.showWarning;
const canShowStreamerMode = StreamerMode.shouldShowNagbar;
const canShowDesktopUpdateReady = Updater.shouldShowUpdateReadyNagbar;
const canShowDomainMoved = nagbarState.forceHideDomainMoved
? false
: nagbarState.forceDomainMoved
? true
: DomainMovedNotice.shouldShow(Date.now());
const canShowBuildEnvironment =
!BUILD_ENVIRONMENT_HIDDEN_RELEASE_CHANNELS.has(Config.PUBLIC_RELEASE_CHANNEL) &&
!nagbarState.buildEnvironmentDismissedThisSession;
@@ -316,6 +322,7 @@ export const useNagbarConditions = (): NagbarConditions => {
canShowSoftwareEncoder,
canShowStreamerMode,
canShowDesktopUpdateReady,
canShowDomainMoved,
};
};
export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarState> => {
@@ -453,6 +460,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
visible: conditions.canShowDesktopUpdateReady,
dismissible: true,
},
{
type: NagbarType.DOMAIN_MOVED,
priority: 3,
visible: conditions.canShowDomainMoved,
dismissible: true,
},
];
return selectVisibleNagbars(nagbars);
}, [conditions]);
@@ -25,6 +25,7 @@ export const NagbarType = {
LINUX_INPUT_ACCESS: 'linux-input-access',
SOFTWARE_ENCODER: 'software-encoder',
STREAMER_MODE: 'streamer-mode',
DOMAIN_MOVED: 'domain-moved',
} as const;
export type NagbarType = ValueOf<typeof NagbarType>;
@@ -63,4 +64,5 @@ export interface NagbarConditions {
canShowLinuxInputAccess: boolean;
canShowSoftwareEncoder: boolean;
canShowStreamerMode: boolean;
canShowDomainMoved: boolean;
}
@@ -8,6 +8,7 @@ import {CorruptedInstallationNagbar} from '@app/features/app/components/layout/a
import {DesktopDownloadNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopDownloadNagbar';
import {DesktopNotificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopNotificationNagbar';
import {DesktopUpdateReadyNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopUpdateReadyNagbar';
import {DomainMovedNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DomainMovedNagbar';
import {EmailVerificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/EmailVerificationNagbar';
import {GiftInventoryNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GiftInventoryNagbar';
import {GuildMembershipCtaNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GuildMembershipCtaNagbar';
@@ -230,6 +231,14 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
data-flx="app.app-layout.nagbar-container.streamer-mode-nagbar"
/>
);
case NagbarType.DOMAIN_MOVED:
return (
<DomainMovedNagbar
key={nagbar.type}
isMobile={mobileLayout.enabled}
data-flx="app.app-layout.nagbar-container.domain-moved-nagbar"
/>
);
default:
throw new UnexpectedNagbarTypeError(nagbar.type);
}
@@ -0,0 +1,176 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {
installDomainMovedApp,
openDomainMovedBrowserMigration,
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
import type {DomainMigrationInstallKind} from '@app/features/app/domain_migration/DomainMigrationCore';
import DomainMovedNotice from '@app/features/app/domain_migration/DomainMovedNotice';
import {
type DomainMovedStepsPlatform,
showDomainMovedStepsModal,
} from '@app/features/app/domain_migration/DomainMovedStepsModal';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import {isIOSMobileOrTabletUserAgent} from '@app/features/platform/notifications/NotificationAlertOptions';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback} from 'react';
type DomainMovedPresentation = 'install' | 'browser' | 'apple' | 'generic';
const INSTALL_MESSAGE_DESCRIPTOR = msg({
message: '{productName} has moved to {host}. Install the new app and you will already be signed in.',
comment:
'Banner in an installed desktop web app after the account moved to the new domain. productName is the app name. host is the new domain, for example fluxer.com.',
});
const BROWSER_MESSAGE_DESCRIPTOR = msg({
message: '{productName} has moved to {host}. Open it in your browser to install the new app.',
comment:
'Banner in an installed Android web app. productName is the app name. host is the new domain, for example fluxer.com.',
});
const APPLE_MESSAGE_DESCRIPTOR = msg({
message: '{productName} has moved to {host}. Add it to your Home Screen or Dock, then sign in with this app.',
comment:
'Banner in a web app installed on iPhone, iPad or Mac. productName is the app name. host is the new domain, for example fluxer.com. Home Screen and Dock are Apple names.',
});
const GENERIC_MESSAGE_DESCRIPTOR = msg({
message: '{productName} has moved to {host}. Install it from your browser, then sign in with this app.',
comment:
'Banner in an installed web app on other browsers. productName is the app name. host is the new domain, for example fluxer.com.',
});
const INSTALL_NEW_APP_DESCRIPTOR = msg({
message: 'Install the new app',
comment: 'Button on the domain moved banner that installs the app from the new domain.',
});
const OPEN_IN_BROWSER_DESCRIPTOR = msg({
message: 'Open {productName} in your browser',
comment: 'Button on the domain moved banner that opens the new domain in the browser. productName is the app name.',
});
const SHOW_ME_HOW_DESCRIPTOR = msg({
message: 'Show me how',
comment: 'Button on the domain moved banner that opens the install steps.',
});
const LINK_NEW_DEVICE_DESCRIPTOR = msg({
message: 'Link a new device',
comment:
'Button on the domain moved banner that opens the code entry used to sign in a new app. Must match the translation used in the "Sign in with your old {productName} app" instructions.',
});
const MESSAGE_DESCRIPTORS = {
install: INSTALL_MESSAGE_DESCRIPTOR,
browser: BROWSER_MESSAGE_DESCRIPTOR,
apple: APPLE_MESSAGE_DESCRIPTOR,
generic: GENERIC_MESSAGE_DESCRIPTOR,
} as const;
function presentationFor(installKind: DomainMigrationInstallKind): DomainMovedPresentation {
switch (installKind) {
case 'chromium-desktop':
return 'install';
case 'chromium-android':
return 'browser';
case 'webkit':
case 'none':
return 'apple';
case 'firefox':
case 'other':
return 'generic';
}
}
function stepsPlatform(presentation: DomainMovedPresentation): DomainMovedStepsPlatform {
if (presentation !== 'apple') {
return 'generic';
}
return isIOSMobileOrTabletUserAgent(navigator.userAgent, navigator.maxTouchPoints) ? 'ios' : 'mac';
}
export const DomainMovedNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const {i18n} = useLingui();
const target = DomainMovedNotice.target;
const presentation = presentationFor(DomainMovedNotice.installKind);
const handleDismiss = useCallback(() => {
DomainMovedNotice.dismiss(Date.now());
}, []);
const handleInstall = useCallback(() => {
installDomainMovedApp(target, () => showDomainMovedStepsModal(target, 'install'));
}, [target]);
const handleOpenInBrowser = useCallback(() => {
openDomainMovedBrowserMigration(target);
}, [target]);
const handleShowSteps = useCallback(() => {
showDomainMovedStepsModal(target, stepsPlatform(presentation));
}, [presentation, target]);
const handleLinkDevice = useCallback(() => {
RouterUtils.transitionTo(`${Routes.LOGIN}?handoff=1`);
}, []);
const tone = NAGBAR_TONES[NagbarToneKind.BRAND];
const values = {productName: PRODUCT_NAME, host: DomainMovedNotice.targetHost};
const linkDeviceButton = (
<NagbarButton
isMobile={isMobile}
variant="inverted-outline"
onClick={handleLinkDevice}
data-flx="app.app-layout.nagbars.domain-moved-nagbar.nagbar-button.link-device"
>
{i18n._(LINK_NEW_DEVICE_DESCRIPTOR)}
</NagbarButton>
);
return (
<Nagbar
isMobile={isMobile}
backgroundColor={tone.backgroundColor}
textColor={tone.textColor}
dismissible
onDismiss={handleDismiss}
data-flx="app.app-layout.nagbars.domain-moved-nagbar.nagbar"
>
<NagbarContent
isMobile={isMobile}
onDismiss={handleDismiss}
message={i18n._(MESSAGE_DESCRIPTORS[presentation], values)}
actions={
presentation === 'install' ? (
<NagbarButton
isMobile={isMobile}
onClick={handleInstall}
data-flx="app.app-layout.nagbars.domain-moved-nagbar.nagbar-button.install"
>
{i18n._(INSTALL_NEW_APP_DESCRIPTOR)}
</NagbarButton>
) : presentation === 'browser' ? (
<>
{linkDeviceButton}
<NagbarButton
isMobile={isMobile}
onClick={handleOpenInBrowser}
data-flx="app.app-layout.nagbars.domain-moved-nagbar.nagbar-button.open-in-browser"
>
{i18n._(OPEN_IN_BROWSER_DESCRIPTOR, values)}
</NagbarButton>
</>
) : (
<>
{linkDeviceButton}
<NagbarButton
isMobile={isMobile}
onClick={handleShowSteps}
data-flx="app.app-layout.nagbars.domain-moved-nagbar.nagbar-button.show-steps"
>
{i18n._(SHOW_ME_HOW_DESCRIPTOR)}
</NagbarButton>
</>
)
}
data-flx="app.app-layout.nagbars.domain-moved-nagbar.nagbar-content"
/>
</Nagbar>
);
});
@@ -233,6 +233,15 @@
text-align: center;
}
.noticeLink {
align-self: flex-start;
border-radius: 0.25rem;
color: var(--text-link);
font-size: 0.875rem;
line-height: 1.45;
text-decoration: underline;
}
.integrationFields {
display: flex;
flex-direction: column;
@@ -30,6 +30,7 @@ import {
MediaExpiryStep,
type PremiumMode,
PremiumStep,
PushRelayConsentStep,
type RegistrationMode,
RegistrationStep,
type ServiceAvailability,
@@ -459,6 +460,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
youtube: false,
bluesky: false,
});
const [pushRelayConsentAccepted, setPushRelayConsentAccepted] = useState(false);
const [premiumMode, setPremiumMode] = useState<PremiumMode>('mirror');
const [assets, setAssets] = useState<ReadonlyArray<BrandingAssetState>>(() =>
BRANDING_ASSET_KINDS.map((kind) => ({kind, url: null, preview: null})),
@@ -549,6 +551,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
clearStepNavigationLock();
setIntegrationDraft({...DEFAULT_INTEGRATION_DRAFT});
setMediaExpiryDraft({...DEFAULT_MEDIA_EXPIRY_DRAFT});
setPushRelayConsentAccepted(false);
setSmtpTesting(false);
setSmtpTestResult(null);
try {
@@ -569,6 +572,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
setSingleCommunityEnabled(next.policy.single_community_enabled);
setDirectMessagesDisabled(next.policy.direct_messages_disabled);
setPremiumMode(next.policy.premium_mode);
setPushRelayConsentAccepted(next.push_service_delivery.relay_consent_accepted);
setServiceSelection({
gif: next.policy.services_resolved.gif_enabled,
youtube: next.policy.services_resolved.youtube_enabled,
@@ -677,6 +681,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
if (step === 'branding') return !productNameError;
if (step === 'community') return !singleCommunityNameError;
if (step === 'media_expiry') return isMediaExpiryStepValid(mediaExpiryDraft);
if (step === 'push_relay_consent') return true;
const integrationKind = wizardStepToIntegrationKind(step);
if (integrationKind) return isIntegrationStepValid(integrationKind, integrationDraft);
return true;
@@ -766,6 +771,10 @@ export const SelfHostedSetupWizardGate = observer(() => {
const nextConfig = await updateInstanceConfig({
integrations: buildIntegrationsPatch(integrationDraft),
media: buildMediaPatch(mediaExpiryDraft),
push_service_delivery:
config.push_service_delivery.relay_consent_accepted === pushRelayConsentAccepted
? undefined
: {relay_consent_accepted: pushRelayConsentAccepted},
registration: {mode: registrationMode},
app_public: {
branding: {
@@ -804,6 +813,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
singleCommunityEnabled,
singleCommunityNameTrimmed,
directMessagesDisabled,
pushRelayConsentAccepted,
premiumMode,
serviceAvailability,
serviceSelection,
@@ -972,6 +982,14 @@ export const SelfHostedSetupWizardGate = observer(() => {
data-flx="app.setup.self-hosted-setup-wizard-gate.integration-step"
/>
)}
{step === 'push_relay_consent' && (
<PushRelayConsentStep
accepted={pushRelayConsentAccepted}
disabled={submitting}
onChange={setPushRelayConsentAccepted}
data-flx="app.setup.self-hosted-setup-wizard-gate.push-relay-consent-step"
/>
)}
{step === 'services' && (
<ServicesStep
available={serviceAvailability}
@@ -996,6 +1014,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
singleCommunityEnabled={singleCommunityEnabled}
directMessagesDisabled={directMessagesDisabled}
attachmentExpiryEnabled={mediaExpiryDraft.enabled}
pushRelayConsentAccepted={pushRelayConsentAccepted}
premiumMode={premiumMode}
submitError={submitError}
data-flx="app.setup.self-hosted-setup-wizard-gate.finish-step"
@@ -17,6 +17,7 @@ export type WizardStep =
| 'integration_captcha'
| 'integration_email'
| 'integration_bluesky'
| 'push_relay_consent'
| 'services'
| 'premium'
| 'finish';
@@ -36,6 +37,7 @@ export const CONFIGURE_STEPS: ReadonlyArray<WizardStep> = [
'integration_captcha',
'integration_email',
'integration_bluesky',
'push_relay_consent',
'services',
'premium',
'finish',
@@ -13,6 +13,7 @@ import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerFiel
import {Input} from '@app/features/ui/components/form/FormInput';
import {Switch} from '@app/features/ui/components/form/FormSwitch';
import {Spinner} from '@app/features/ui/components/Spinner';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {RadioGroup, type RadioOption} from '@app/features/ui/radio_group/RadioGroup';
import {ThemeSelector} from '@app/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent';
import {LanguageSelector} from '@app/features/user/components/modals/tabs/LanguageTab';
@@ -27,6 +28,8 @@ import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
const PUSH_RELAY_NOTICE_URL = 'https://fluxer.com/push-relay';
export type RegistrationMode = 'open' | 'approval' | 'closed';
export type PremiumMode = 'mirror' | 'everyone';
@@ -263,6 +266,29 @@ const MEDIA_RENEW_WINDOW_LABEL_DESCRIPTOR = msg({
comment: 'Label for attachment decay renewal window.',
});
const PUSH_RELAY_TITLE_DESCRIPTOR = msg({
message: 'Mobile push notifications',
comment: 'Setup wizard push relay consent step title.',
});
const PUSH_RELAY_BODY_DESCRIPTOR = msg({
message:
"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.",
comment: 'Setup wizard push relay consent step body.',
});
const PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR = msg({
message: 'Accept the push relay supplemental privacy notice',
comment: 'Label for the push relay consent switch during setup.',
});
const PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR = msg({
message:
'Leaving this off keeps the relay unused and drops notifications to the official mobile apps. You can accept it later in the admin panel.',
comment: 'Description for the push relay consent switch during setup.',
});
const PUSH_RELAY_NOTICE_LINK_DESCRIPTOR = msg({
message: 'Read the supplemental privacy notice',
comment: 'Link to the push relay supplemental privacy notice shown during setup.',
});
const SERVICES_TITLE_DESCRIPTOR = msg({
message: 'Optional services',
comment: 'Setup wizard optional services step title.',
@@ -353,10 +379,22 @@ const SUMMARY_ATTACHMENT_EXPIRY_DESCRIPTOR = msg({
message: 'Attachment expiration',
comment: 'Summary row label for the attachment expiry choice in the setup wizard.',
});
const SUMMARY_PUSH_RELAY_DESCRIPTOR = msg({
message: 'Push relay notice',
comment: 'Summary row label for the push relay consent on the setup wizard finish step.',
});
const SUMMARY_PREMIUM_DESCRIPTOR = msg({
message: 'Premium model',
comment: 'Summary row label for the premium model in the setup wizard.',
});
const SUMMARY_ACCEPTED_DESCRIPTOR = msg({
message: 'Accepted',
comment: 'Summary value when the operator accepted the push relay notice.',
});
const SUMMARY_NOT_ACCEPTED_DESCRIPTOR = msg({
message: 'Not accepted',
comment: 'Summary value when the operator left the push relay notice unaccepted.',
});
const SUMMARY_ON_DESCRIPTOR = msg({
message: 'Enabled',
comment: 'Summary value when a setup option is enabled.',
@@ -1531,6 +1569,40 @@ export const IntegrationStep = observer(
},
);
export const PushRelayConsentStep = observer(
({accepted, disabled, onChange}: {accepted: boolean; disabled: boolean; onChange: (value: boolean) => void}) => {
const {i18n} = useLingui();
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-step">
<StepHeader
title={i18n._(PUSH_RELAY_TITLE_DESCRIPTOR)}
body={i18n._(PUSH_RELAY_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.step-header"
/>
<Switch
label={i18n._(PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR)}
description={i18n._(PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR)}
value={accepted}
onChange={onChange}
disabled={disabled}
data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-switch"
/>
<FocusRing data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.focus-ring">
<a
className={styles.noticeLink}
href={PUSH_RELAY_NOTICE_URL}
target="_blank"
rel="noreferrer"
data-flx="app.self-hosted-setup-wizard-gate.push-relay-notice-link"
>
{i18n._(PUSH_RELAY_NOTICE_LINK_DESCRIPTOR)}
</a>
</FocusRing>
</section>
);
},
);
export const ServicesStep = observer(
({
available,
@@ -1650,6 +1722,7 @@ export const FinishStep = observer(
singleCommunityEnabled,
directMessagesDisabled,
attachmentExpiryEnabled,
pushRelayConsentAccepted,
premiumMode,
submitError,
}: {
@@ -1658,6 +1731,7 @@ export const FinishStep = observer(
singleCommunityEnabled: boolean;
directMessagesDisabled: boolean;
attachmentExpiryEnabled: boolean;
pushRelayConsentAccepted: boolean;
premiumMode: PremiumMode;
submitError: string | null;
}) => {
@@ -1705,10 +1779,17 @@ export const FinishStep = observer(
value={attachmentExpiryEnabled ? onLabel : offLabel}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--5"
/>
<SummaryRow
label={i18n._(SUMMARY_PUSH_RELAY_DESCRIPTOR)}
value={
pushRelayConsentAccepted ? i18n._(SUMMARY_ACCEPTED_DESCRIPTOR) : i18n._(SUMMARY_NOT_ACCEPTED_DESCRIPTOR)
}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--6"
/>
<SummaryRow
label={i18n._(SUMMARY_PREMIUM_DESCRIPTOR)}
value={premiumLabel}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--6"
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--7"
/>
</div>
{submitError && (
@@ -21,6 +21,11 @@ export const Endpoints = {
AUTH_HANDOFF_INFO: (code: string) => `/auth/handoff/${code}/info`,
AUTH_HANDOFF_STATUS: (code: string) => `/auth/handoff/${code}/status`,
AUTH_HANDOFF_CANCEL: (code: string) => `/auth/handoff/${code}`,
AUTH_PASSKEY_BRIDGE: '/auth/passkey-bridge',
AUTH_PASSKEY_BRIDGE_OPTIONS: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/options`,
AUTH_PASSKEY_BRIDGE_COMPLETE: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/complete`,
AUTH_PASSKEY_BRIDGE_CANCEL: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/cancel`,
AUTH_PASSKEY_BRIDGE_REDEEM: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/redeem`,
AUTH_FORGOT_PASSWORD: '/auth/forgot',
AUTH_RESET_PASSWORD: '/auth/reset',
AUTH_VALIDATE_RESET_PASSWORD_TOKEN: (token: string) => `/auth/reset/${encodeURIComponent(token)}`,
@@ -173,6 +178,10 @@ export const Endpoints = {
USER_MFA_WEBAUTHN_REGISTRATION_OPTIONS: '/users/@me/mfa/webauthn/credentials/registration-options',
USER_MFA_WEBAUTHN_CREDENTIAL: (credentialId: string) => `/users/@me/mfa/webauthn/credentials/${credentialId}`,
USER_MFA_WEBAUTHN_TWO_FACTOR: '/users/@me/mfa/webauthn/two-factor',
USER_MFA_WEBAUTHN_MIGRATION: '/users/@me/mfa/webauthn/migration',
USER_MFA_WEBAUTHN_MIGRATION_REGISTRATION_OPTIONS: '/users/@me/mfa/webauthn/migration/registration-options',
USER_PASSKEY_BRIDGE: '/users/@me/passkey-bridge',
USER_PASSKEY_BRIDGE_REDEEM: (ceremonyId: string) => `/users/@me/passkey-bridge/${ceremonyId}/redeem`,
USER_PHONE_SEND_VERIFICATION: '/users/@me/phone/send-verification',
USER_PHONE_INBOUND_CHALLENGE: '/users/@me/phone/inbound-challenge',
USER_PHONE_VERIFY: '/users/@me/phone/verify',
@@ -0,0 +1,832 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
detectDomainMigrationInstallKind,
installDomainMovedApp,
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
import * as core from '@app/features/app/domain_migration/DomainMigrationCore';
import {
decryptDomainMigrationPayload,
encryptDomainMigrationPayload,
} from '@app/features/app/domain_migration/DomainMigrationCrypto';
import {runDomainMigrationPreMount} from '@app/features/app/domain_migration/DomainMigrationPreMount';
import type {RuntimeConfigSnapshot} from '@app/features/app/state/RuntimeConfig';
import type {StoredAccount} from '@app/features/auth/state/AccountStorage';
import type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/platform/utils/AppLogger', () => ({
Logger: class {
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
}));
vi.mock('@app/features/auth/state/AccountStorage', () => ({
default: {getAllAccounts: async () => []},
}));
const ENABLED_DISCOVERY: DomainMigrationDiscoveryResponse = {
enabled: true,
anonymous_rollout_basis_points: 0,
rollout_salt: 'domain-migration-v1',
standalone_forwarding: false,
};
const NOW = 1_800_000_000_000;
function memoryStorage(initial: Record<string, string> = {}): core.StorageLike {
const entries = new Map(Object.entries(initial));
return {
getItem: (key) => entries.get(key) ?? null,
setItem: (key, value) => {
entries.set(key, value);
},
removeItem: (key) => {
entries.delete(key);
},
key: (index) => [...entries.keys()][index] ?? null,
get length() {
return entries.size;
},
};
}
function environment(
installKind: core.DomainMigrationInstallKind,
overrides: Partial<core.DomainMigrationEnvironment> = {},
): core.DomainMigrationEnvironment {
return {installKind, electron: false, electronMigrationVersion: null, electronPasskeyRpIds: [], ...overrides};
}
function gateInput(overrides: Partial<core.DomainMigrationGateInput> = {}): core.DomainMigrationGateInput {
return {
environment: environment('none'),
assignmentEnabled: true,
discovery: ENABLED_DISCOVERY,
marker: null,
now: NOW,
voiceActive: false,
oneShotRoute: false,
...overrides,
};
}
describe('sanitizeNextPath', () => {
it.each([
['/channels/123/456?x=1#y', '/channels/123/456?x=1#y'],
['/reset#token=abc', '/reset#token=abc'],
['/', '/app'],
['/?a=1', '/app?a=1'],
['//evil.example', '/channels/@me'],
['/\\evil.example', '/channels/@me'],
['/migrate/begin', '/channels/@me'],
['/migrate', '/channels/@me'],
['/\t/evil.example', '/channels/@me'],
['/\n/evil.example', '/channels/@me'],
['/\r/evil.example', '/channels/@me'],
['/\tmigrate/done', '/channels/@me'],
['/channels/\u0000', '/channels/@me'],
['/a/../migrate/done', '/channels/@me'],
['/%09/evil.example', '/%09/evil.example'],
['https://evil.example/', '/channels/@me'],
['channels/@me', '/channels/@me'],
[null, '/channels/@me'],
[42, '/channels/@me'],
])('maps %j to %j', (input, expected) => {
expect(core.sanitizeNextPath(input)).toBe(expected);
});
it('never leaves the origin it is resolved against', () => {
for (const input of ['/\t/evil.example', '/\n/evil.example', '/%09/evil.example', '/@evil.example']) {
expect(new URL(core.sanitizeNextPath(input), 'https://web.fluxer.app/x').origin).toBe('https://web.fluxer.app');
}
});
});
describe('resolveDomainMigrationSide', () => {
it('recognises only the four official origins', () => {
expect(core.resolveDomainMigrationSide('https://web.fluxer.app')).toEqual({
role: 'source',
source: 'https://web.fluxer.app',
target: 'https://fluxer.com',
});
expect(core.resolveDomainMigrationSide('https://canary.fluxer.com')).toEqual({
role: 'target',
source: 'https://web.canary.fluxer.app',
target: 'https://canary.fluxer.com',
});
for (const origin of [
'http://localhost:3000',
'https://chat.example.com',
'http://web.fluxer.app',
'https://fluxer.app',
'https://web.fluxer.com',
]) {
expect(core.resolveDomainMigrationSide(origin)).toBeNull();
}
});
});
describe('payload encryption', () => {
it('round trips through encrypt and decrypt', async () => {
const payload = {
version: 1,
source_origin: 'https://web.fluxer.app',
local_storage: {token: 'abc', big: 'x'.repeat(200_000)},
};
const sealed = await encryptDomainMigrationPayload(payload);
expect(sealed.payload).toMatch(/^[A-Za-z0-9_-]+$/u);
expect(sealed.key).toMatch(/^[A-Za-z0-9_-]{43}$/u);
expect(sealed.payload.length).toBeLessThan(200_000);
expect(await decryptDomainMigrationPayload(sealed.payload, sealed.key)).toEqual(payload);
});
it('rejects a payload opened with another key', async () => {
const sealed = await encryptDomainMigrationPayload({version: 1});
const other = await encryptDomainMigrationPayload({version: 1});
await expect(decryptDomainMigrationPayload(sealed.payload, other.key)).rejects.toThrow();
});
it('rejects payloads from another source or version', () => {
const payload = {
version: 1,
source_origin: 'https://web.fluxer.app',
exported_at: NOW,
local_storage: {},
accounts: [],
notification_permission: 'granted',
};
expect(core.parseDomainMigrationPayload(payload, 'https://web.fluxer.app')).not.toBeNull();
expect(core.parseDomainMigrationPayload(payload, 'https://web.canary.fluxer.app')).toBeNull();
expect(core.parseDomainMigrationPayload({...payload, version: 2}, 'https://web.fluxer.app')).toBeNull();
expect(
core.parseDomainMigrationPayload({...payload, accounts: [{userId: 1}]}, 'https://web.fluxer.app'),
).toBeNull();
});
it('accepts a theme library and rejects a malformed one', () => {
const payload = {
version: 1,
source_origin: 'https://web.fluxer.app',
exported_at: NOW,
local_storage: {},
accounts: [],
notification_permission: 'default',
};
const themeLibrary: core.DomainMigrationThemeLibrary = {
themes: [{id: 'quick-css', css: 'body{}'}],
assets: [
{
id: 'asset',
name: 'bg.png',
mime_type: 'image/png',
size: 3,
data: 'AQID',
created_at: NOW,
updated_at: NOW,
},
],
local_files: [],
enabled_theme_ids: ['quick-css'],
};
expect(
core.parseDomainMigrationPayload({...payload, theme_library: themeLibrary}, 'https://web.fluxer.app'),
).not.toBeNull();
expect(
core.parseDomainMigrationPayload(
{...payload, theme_library: {...themeLibrary, enabled_theme_ids: [1]}},
'https://web.fluxer.app',
),
).toBeNull();
const trimmed = core.withoutOptionalPayloadData({
...payload,
version: 1,
custom_sounds: [],
theme_library: themeLibrary,
});
expect(trimmed.custom_sounds).toBeUndefined();
expect(trimmed.theme_library).toEqual({...themeLibrary, assets: []});
});
});
describe('rewriteImportedAccount', () => {
it('points the account at the current instance and drops runtimeConfig', () => {
const current = {apiEndpoint: 'https://fluxer.com/api'} as RuntimeConfigSnapshot;
const record: StoredAccount = {
userId: '1',
token: 'token',
localStorageData: {runtimeConfig: '{}', token: 'token'},
managedStorageData: {runtimeConfig: '{}', token: 'token', 'fluxer.theme': 'dark'},
lastActive: NOW,
instance: {apiEndpoint: 'https://web.fluxer.app/api'} as RuntimeConfigSnapshot,
};
const rewritten = core.rewriteImportedAccount(record, current);
expect(rewritten.instance).toBe(current);
expect(rewritten.managedStorageData).toEqual({token: 'token', 'fluxer.theme': 'dark'});
expect(rewritten.localStorageData).toEqual({token: 'token', 'fluxer.theme': 'dark'});
expect(rewritten.token).toBe('token');
});
});
describe('local storage export', () => {
it('skips push, test, runtime config and migration keys', () => {
const storage = memoryStorage({
token: 't',
'fluxer.lastPushEndpoint': 'https://push',
'fluxer.pushSubscription': '{}',
__test__: '1',
runtimeConfig: '{}',
[core.DOMAIN_MIGRATION_MARKER_KEY]: '{}',
[core.DOMAIN_MIGRATION_DEVICE_KEY]: 'device',
'mobx-persist:Theme': '{}',
});
expect(core.collectExportableLocalStorage(storage)).toEqual({token: 't', 'mobx-persist:Theme': '{}'});
});
});
describe('migration gate', () => {
it('passes when every condition holds', () => {
expect(core.shouldStartDomainMigration(gateInput())).toBe(true);
expect(
core.shouldStartDomainMigration(
gateInput({
environment: environment('none', {
electron: true,
electronMigrationVersion: 1,
electronPasskeyRpIds: ['fluxer.app', 'fluxer.com'],
}),
}),
),
).toBe(true);
});
it.each<[string, Partial<core.DomainMigrationGateInput>]>([
['assignment off', {assignmentEnabled: false}],
['kill switch', {discovery: {...ENABLED_DISCOVERY, enabled: false}}],
['no discovery', {discovery: null}],
['already completed', {marker: {state: 'completed', target: 'https://fluxer.com', at: NOW}}],
['failed recently', {marker: {state: 'failed', at: NOW - 60_000, attempts: 1}}],
['failed too often', {marker: {state: 'failed', at: NOW - 3 * 24 * 60 * 60 * 1000, attempts: 3}}],
['Android web app', {environment: environment('chromium-android')}],
['Apple web app', {environment: environment('webkit')}],
['Firefox web app', {environment: environment('firefox')}],
['other web app', {environment: environment('other')}],
['old desktop', {environment: environment('none', {electron: true})}],
[
'desktop that cannot create fluxer.com passkeys',
{
environment: environment('none', {
electron: true,
electronMigrationVersion: 1,
electronPasskeyRpIds: ['fluxer.app'],
}),
},
],
['in a voice call', {voiceActive: true}],
['on a one-shot token route', {oneShotRoute: true}],
])('blocks when %s', (_label, overrides) => {
expect(core.shouldStartDomainMigration(gateInput(overrides))).toBe(false);
});
it('runs the handoff inside a Chromium desktop web app', () => {
expect(core.shouldStartDomainMigration(gateInput({environment: environment('chromium-desktop')}))).toBe(true);
});
it('retries a failure after a day', () => {
const marker: core.DomainMigrationMarker = {state: 'failed', at: NOW - 25 * 60 * 60 * 1000, attempts: 2};
expect(core.shouldStartDomainMigration(gateInput({marker}))).toBe(true);
});
});
describe('marker state', () => {
it('counts failures and records completion', () => {
const storage = memoryStorage();
expect(core.readDomainMigrationMarker(storage)).toBeNull();
core.markDomainMigrationFailed(storage, NOW);
core.markDomainMigrationFailed(storage, NOW + 1);
expect(core.readDomainMigrationMarker(storage)).toEqual({state: 'failed', at: NOW + 1, attempts: 2});
core.markDomainMigrationCompleted(storage, 'https://fluxer.com', NOW + 2);
expect(core.readDomainMigrationMarker(storage)).toEqual({
state: 'completed',
target: 'https://fluxer.com',
at: NOW + 2,
});
core.markDomainMigrationFailed(storage, NOW + 3);
expect(core.readDomainMigrationMarker(storage)).toEqual({state: 'failed', at: NOW + 3, attempts: 1});
});
it('ignores malformed markers', () => {
expect(core.parseDomainMigrationMarker('not json')).toBeNull();
expect(core.parseDomainMigrationMarker('{"state":"completed","at":1}')).toBeNull();
});
});
describe('migration intent', () => {
it('expires and ties completion to the exported handoff', () => {
const storage = memoryStorage();
expect(core.readDomainMigrationIntent(storage, NOW)).toBeNull();
core.writeDomainMigrationIntent(storage, {at: NOW});
expect(core.readDomainMigrationIntent(storage, NOW + 1000)).toEqual({at: NOW});
expect(core.readDomainMigrationIntent(storage, NOW + core.DOMAIN_MIGRATION_PENDING_MAX_AGE_MS + 1)).toBeNull();
expect(core.intentConfirmsCompletion(null, null)).toBe(false);
expect(core.intentConfirmsCompletion({at: NOW}, null)).toBe(true);
expect(core.intentConfirmsCompletion({at: NOW, handoff_id: 'abc'}, 'abc')).toBe(true);
expect(core.intentConfirmsCompletion({at: NOW, handoff_id: 'abc'}, 'xyz')).toBe(false);
expect(core.intentConfirmsCompletion({at: NOW, handoff_id: 'abc'}, null)).toBe(false);
core.clearDomainMigrationIntent(storage);
expect(core.readDomainMigrationIntent(storage, NOW)).toBeNull();
});
});
describe('forwarding', () => {
it('keeps the path, query and hash', () => {
expect(core.buildTargetUrl('https://fluxer.com', '/reset', '?a=1', '#token=abc')).toBe(
'https://fluxer.com/reset?a=1#token=abc',
);
expect(core.buildTargetUrl('https://fluxer.com', '/', '', '')).toBe('https://fluxer.com/app');
});
it('honours the kill switch', () => {
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
const browser = environment('none');
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, completed, browser)).toBe(true);
expect(core.shouldForwardCompletedSource({...ENABLED_DISCOVERY, enabled: false}, completed, browser)).toBe(false);
expect(core.shouldForwardCompletedSource(null, completed, browser)).toBe(false);
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, null, browser)).toBe(false);
});
it('forwards installed apps only when standalone forwarding is on', () => {
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
const forwarding = {...ENABLED_DISCOVERY, standalone_forwarding: true};
const desktop = environment('chromium-desktop');
expect(core.shouldForwardCompletedSource(ENABLED_DISCOVERY, completed, desktop)).toBe(false);
expect(core.shouldForwardCompletedSource(forwarding, completed, desktop)).toBe(true);
const legacyDiscovery = {...ENABLED_DISCOVERY} as Partial<DomainMigrationDiscoveryResponse>;
delete legacyDiscovery.standalone_forwarding;
expect(
core.shouldForwardCompletedSource(legacyDiscovery as DomainMigrationDiscoveryResponse, completed, desktop),
).toBe(false);
for (const kind of ['chromium-android', 'webkit', 'firefox', 'other'] as const) {
expect(core.shouldForwardCompletedSource(forwarding, completed, environment(kind))).toBe(false);
expect(core.environmentMayForward(environment(kind), forwarding)).toBe(false);
}
expect(core.environmentMayForward(environment('none'), ENABLED_DISCOVERY)).toBe(true);
expect(core.environmentMayForward(environment('none', {electron: true}), ENABLED_DISCOVERY)).toBe(false);
});
it('buckets anonymous devices by basis points', () => {
expect(core.anonymousRolloutIsOpen(ENABLED_DISCOVERY)).toBe(false);
expect(
core.anonymousRolloutIsOpen({...ENABLED_DISCOVERY, enabled: false, anonymous_rollout_basis_points: 10000}),
).toBe(false);
const all = {...ENABLED_DISCOVERY, anonymous_rollout_basis_points: 10000};
expect(core.anonymousRolloutIsOpen(all)).toBe(true);
expect(core.deviceIsInAnonymousRollout(all, 'device-a')).toBe(true);
const half = {...ENABLED_DISCOVERY, anonymous_rollout_basis_points: 5000};
for (const deviceId of ['device-a', 'device-b', 'device-c', 'device-d']) {
expect(core.deviceIsInAnonymousRollout(half, deviceId)).toBe(
experimentBucket(deviceId, half.rollout_salt) < 5000,
);
}
});
});
const CHROME_DESKTOP_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
const EDGE_DESKTOP_UA = `${CHROME_DESKTOP_UA} Edg/140.0.0.0`;
const CHROME_ANDROID_UA =
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36';
const CHROME_ANDROID_TABLET_UA =
'Mozilla/5.0 (Linux; Android 14; SM-X910) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
const SAMSUNG_UA =
'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/125.0.0.0 Mobile Safari/537.36';
const IPHONE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1';
const IPAD_DESKTOP_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15';
const MAC_SAFARI_UA = IPAD_DESKTOP_UA;
const MAC_CHROME_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
const FIREFOX_DESKTOP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0';
const FIREFOX_ANDROID_UA = 'Mozilla/5.0 (Android 14; Mobile; rv:143.0) Gecko/143.0 Firefox/143.0';
const UNKNOWN_UA = 'SomeBrowser/1.0';
const CHROMIUM_BRANDS = [{brand: 'Chromium'}, {brand: 'Google Chrome'}, {brand: 'Not=A?Brand'}];
function signals(overrides: Partial<core.DomainMigrationInstallSignals>): core.DomainMigrationInstallSignals {
return {
displayMode: 'standalone',
navigatorStandalone: false,
userAgent: CHROME_DESKTOP_UA,
userAgentData: null,
maxTouchPoints: 0,
electron: false,
...overrides,
};
}
describe('classifyDomainMigrationInstallKind', () => {
it.each<[string, Partial<core.DomainMigrationInstallSignals>, core.DomainMigrationInstallKind]>([
['a browser tab', {displayMode: 'browser'}, 'none'],
['a Firefox taskbar tab', {displayMode: 'minimal-ui', userAgent: FIREFOX_DESKTOP_UA}, 'none'],
['Electron', {electron: true}, 'none'],
['Safari on iPhone in a tab', {displayMode: 'browser', userAgent: IPHONE_UA}, 'none'],
['Chrome desktop by brand', {userAgentData: {brands: CHROMIUM_BRANDS, mobile: false}}, 'chromium-desktop'],
['Chrome desktop by user agent', {}, 'chromium-desktop'],
['Edge desktop', {userAgent: EDGE_DESKTOP_UA}, 'chromium-desktop'],
['Chrome on a Mac', {userAgent: MAC_CHROME_UA}, 'chromium-desktop'],
['window controls overlay', {displayMode: 'window-controls-overlay'}, 'chromium-desktop'],
[
'Chrome Android by brand',
{userAgent: CHROME_ANDROID_UA, userAgentData: {brands: CHROMIUM_BRANDS, mobile: true}},
'chromium-android',
],
['Chrome Android by user agent', {userAgent: CHROME_ANDROID_UA}, 'chromium-android'],
[
'Chrome on an Android tablet by brand',
{
userAgent: CHROME_ANDROID_TABLET_UA,
userAgentData: {brands: CHROMIUM_BRANDS, mobile: false, platform: 'Android'},
},
'chromium-android',
],
['Chrome on an Android tablet by user agent', {userAgent: CHROME_ANDROID_TABLET_UA}, 'chromium-android'],
['Samsung Internet', {userAgent: SAMSUNG_UA}, 'chromium-android'],
['an iPhone home screen app', {displayMode: 'browser', navigatorStandalone: true, userAgent: IPHONE_UA}, 'webkit'],
['an iPad home screen app', {userAgent: IPAD_DESKTOP_UA, maxTouchPoints: 5}, 'webkit'],
['a Safari Dock app', {userAgent: MAC_SAFARI_UA}, 'webkit'],
['a Firefox desktop app', {userAgent: FIREFOX_DESKTOP_UA}, 'firefox'],
['a Firefox Android app', {userAgent: FIREFOX_ANDROID_UA}, 'firefox'],
['an unknown browser', {userAgent: UNKNOWN_UA}, 'other'],
])('classifies %s', (_label, overrides, expected) => {
expect(core.classifyDomainMigrationInstallKind(signals(overrides))).toBe(expected);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it.each([CHROME_DESKTOP_UA, FIREFOX_DESKTOP_UA, MAC_SAFARI_UA])(
'treats a full screen browser window as a tab',
(userAgent) => {
vi.stubGlobal('matchMedia', (query: string) => ({matches: query === '(display-mode: fullscreen)'}));
vi.stubGlobal('navigator', {userAgent, maxTouchPoints: 0});
expect(detectDomainMigrationInstallKind()).toBe('none');
},
);
it('reads an installed app window from the display mode', () => {
vi.stubGlobal('matchMedia', (query: string) => ({matches: query === '(display-mode: standalone)'}));
vi.stubGlobal('navigator', {userAgent: CHROME_DESKTOP_UA, maxTouchPoints: 0});
expect(detectDomainMigrationInstallKind()).toBe('chromium-desktop');
});
});
describe('shouldShowDomainMovedNotice', () => {
const source = core.resolveDomainMigrationSide('https://web.fluxer.app');
const completed: core.DomainMigrationMarker = {state: 'completed', target: 'https://fluxer.com', at: NOW};
function notice(overrides: Partial<core.DomainMovedNoticeInput>): core.DomainMovedNoticeInput {
return {
side: source,
installKind: 'webkit',
discovery: ENABLED_DISCOVERY,
assignmentEnabled: true,
marker: null,
dismissedAt: null,
now: NOW,
...overrides,
};
}
it('shows in installed apps on the source once the user is in the rollout', () => {
for (const installKind of ['chromium-android', 'webkit', 'firefox', 'other'] as const) {
expect(core.shouldShowDomainMovedNotice(notice({installKind}))).toBe(true);
expect(core.shouldShowDomainMovedNotice(notice({installKind, assignmentEnabled: false}))).toBe(false);
expect(core.shouldShowDomainMovedNotice(notice({installKind, assignmentEnabled: false, marker: completed}))).toBe(
true,
);
}
});
it('waits for the handoff in Chromium desktop apps', () => {
expect(core.shouldShowDomainMovedNotice(notice({installKind: 'chromium-desktop'}))).toBe(false);
expect(core.shouldShowDomainMovedNotice(notice({installKind: 'chromium-desktop', marker: completed}))).toBe(true);
});
it.each<[string, Partial<core.DomainMovedNoticeInput>]>([
['a browser tab', {installKind: 'none', marker: completed}],
['the target', {side: core.resolveDomainMigrationSide('https://fluxer.com')}],
['a self-hosted origin', {side: core.resolveDomainMigrationSide('https://chat.example.com')}],
['the kill switch', {discovery: {...ENABLED_DISCOVERY, enabled: false}}],
['missing discovery', {discovery: null}],
])('stays hidden for %s', (_label, overrides) => {
expect(core.shouldShowDomainMovedNotice(notice(overrides))).toBe(false);
});
it('comes back seven days after a dismissal', () => {
expect(core.shouldShowDomainMovedNotice(notice({dismissedAt: NOW - 1000}))).toBe(false);
expect(
core.shouldShowDomainMovedNotice(notice({dismissedAt: NOW - core.DOMAIN_MIGRATION_MOVED_DISMISS_MS + 1})),
).toBe(false);
expect(core.shouldShowDomainMovedNotice(notice({dismissedAt: NOW - core.DOMAIN_MIGRATION_MOVED_DISMISS_MS}))).toBe(
true,
);
});
it('builds the new app links from the side target', () => {
expect(core.domainMovedInstallUrl('https://canary.fluxer.com')).toBe('https://canary.fluxer.com/app');
expect(core.domainMovedManifestId('https://fluxer.com')).toBe('https://fluxer.com/');
expect(core.domainMovedBrowserMigrationUrl('https://fluxer.com')).toBe(
'https://fluxer.com/migrate/begin?start=1&next=%2Fapp',
);
});
});
describe('installDomainMovedApp', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('opens the new app in the browser straight from the click without the install API', () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const onUnavailable = vi.fn();
vi.stubGlobal('navigator', {userAgent: CHROME_DESKTOP_UA});
installDomainMovedApp('https://fluxer.com', onUnavailable);
expect(open).toHaveBeenCalledWith('https://fluxer.com/app', '_blank', 'noopener');
expect(onUnavailable).not.toHaveBeenCalled();
});
it('shows the fallback instead of a late popup when the install fails', async () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const onUnavailable = vi.fn();
const install = vi.fn().mockRejectedValue(new DOMException('denied', 'NotAllowedError'));
vi.stubGlobal('navigator', {userAgent: CHROME_DESKTOP_UA, install});
installDomainMovedApp('https://fluxer.com', onUnavailable);
await vi.waitFor(() => expect(onUnavailable).toHaveBeenCalledOnce());
expect(install).toHaveBeenCalledWith('https://fluxer.com/app', 'https://fluxer.com/');
expect(open).not.toHaveBeenCalled();
});
it('does nothing more when the user cancels the install', async () => {
const onUnavailable = vi.fn();
const install = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
vi.stubGlobal('navigator', {userAgent: CHROME_DESKTOP_UA, install});
installDomainMovedApp('https://fluxer.com', onUnavailable);
await Promise.resolve();
await Promise.resolve();
expect(onUnavailable).not.toHaveBeenCalled();
});
});
describe('runDomainMigrationPreMount', () => {
let replace: ReturnType<typeof vi.fn>;
function visit(url: string, discovery: DomainMigrationDiscoveryResponse | undefined): void {
const parsed = new URL(url);
replace = vi.fn();
vi.stubGlobal('location', {
origin: parsed.origin,
pathname: parsed.pathname,
search: parsed.search,
hash: parsed.hash,
replace,
});
(window as unknown as Record<string, unknown>).__FLUXER_BOOTSTRAP__ = {instance: {domain_migration: discovery}};
}
function readMarker(): core.DomainMigrationMarker | null {
return core.parseDomainMigrationMarker(window.localStorage.getItem(core.DOMAIN_MIGRATION_MARKER_KEY));
}
function writeIntent(intent: core.DomainMigrationIntent): void {
window.sessionStorage.setItem(core.DOMAIN_MIGRATION_INTENT_KEY, JSON.stringify(intent));
}
function writeCompletedMarker(): void {
window.localStorage.setItem(
core.DOMAIN_MIGRATION_MARKER_KEY,
JSON.stringify({state: 'completed', target: 'https://fluxer.com', at: NOW}),
);
}
beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('does nothing on a self-hosted origin', async () => {
visit('https://chat.example.com/migrate/done?next=/channels/@me', ENABLED_DISCOVERY);
writeCompletedMarker();
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
});
it('forwards a migrated source tab with its hash', async () => {
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
writeCompletedMarker();
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/reset#token=abc');
});
it('stays put when the kill switch is off', async () => {
visit('https://web.fluxer.app/reset#token=abc', {...ENABLED_DISCOVERY, enabled: false});
writeCompletedMarker();
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
});
it('marks the source completed when the intent matches the handoff', async () => {
writeIntent({at: Date.now(), handoff_id: 'handoff'});
visit('https://web.fluxer.app/migrate/done?h=handoff&next=%2Fchannels%2F1%2F2', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1/2');
expect(readMarker()).toMatchObject({state: 'completed', target: 'https://fluxer.com'});
expect(window.sessionStorage.getItem(core.DOMAIN_MIGRATION_INTENT_KEY)).toBeNull();
});
it('ignores a done link without a matching intent', async () => {
visit('https://web.fluxer.app/migrate/done?next=%2Fchannels%2F1%2F2', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/1/2');
expect(readMarker()).toBeNull();
writeIntent({at: Date.now(), handoff_id: 'handoff'});
visit('https://web.fluxer.app/migrate/done?h=other&next=%2Fchannels%2F1%2F2', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/1/2');
expect(readMarker()).toBeNull();
});
it('ignores migrate links while the kill switch is off', async () => {
writeIntent({at: Date.now()});
visit('https://web.fluxer.app/migrate/done?next=%2Fchannels%2F1%2F2', {...ENABLED_DISCOVERY, enabled: false});
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/1/2');
expect(readMarker()).toBeNull();
});
it('refuses to export without an intent from the source trigger', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
visit(`https://web.fluxer.app/migrate/export?n=${'a'.repeat(43)}`, ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/@me');
expect(fetchSpy).not.toHaveBeenCalled();
});
it('leaves the marker alone on a failed link and refuses an open redirect', async () => {
visit('https://web.fluxer.app/migrate/failed?reason=nonce_mismatch&next=%2F%09%2Fevil.example', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/@me');
expect(readMarker()).toBeNull();
});
it('reopens a completed source when a resumed migration fails', async () => {
writeCompletedMarker();
writeIntent({at: Date.now()});
visit('https://web.fluxer.app/migrate/failed?reason=redeem_failed&next=%2Fchannels%2F%40me', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/@me');
expect(readMarker()).toMatchObject({state: 'failed', attempts: 1});
});
it('resumes through the target when a migrated source still holds a session', async () => {
writeCompletedMarker();
window.localStorage.setItem('token', 'session-token');
visit('https://web.fluxer.app/reset#token=abc', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith(
`https://fluxer.com/migrate/begin?resume=1&next=${encodeURIComponent('/reset#token=abc')}`,
);
expect(core.readDomainMigrationIntent(window.sessionStorage, Date.now())).not.toBeNull();
});
function installApp(userAgent: string): void {
vi.stubGlobal('matchMedia', (query: string) => ({matches: query === '(display-mode: standalone)'}));
vi.stubGlobal('navigator', {userAgent, maxTouchPoints: 0});
}
it('returns a Chromium desktop app to the source after the handoff', async () => {
installApp(CHROME_DESKTOP_UA);
writeIntent({at: Date.now(), handoff_id: 'handoff'});
visit('https://web.fluxer.app/migrate/done?h=handoff&next=%2Fchannels%2F1%2F2', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/1/2');
expect(readMarker()).toMatchObject({state: 'completed', target: 'https://fluxer.com'});
});
it('forwards a Chromium desktop app when standalone forwarding is on', async () => {
installApp(CHROME_DESKTOP_UA);
writeIntent({at: Date.now(), handoff_id: 'handoff'});
visit('https://web.fluxer.app/migrate/done?h=handoff&next=%2Fchannels%2F1%2F2', {
...ENABLED_DISCOVERY,
standalone_forwarding: true,
});
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1/2');
});
it('keeps a migrated Chromium desktop app on the source', async () => {
installApp(CHROME_DESKTOP_UA);
writeCompletedMarker();
visit('https://web.fluxer.app/channels/1/2', {...ENABLED_DISCOVERY, anonymous_rollout_basis_points: 10000});
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
visit('https://web.fluxer.app/channels/1/2', {...ENABLED_DISCOVERY, standalone_forwarding: true});
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1/2');
});
it('does not forward logged-out installed apps in the anonymous rollout', async () => {
installApp(CHROME_DESKTOP_UA);
visit('https://web.fluxer.app/login', {...ENABLED_DISCOVERY, anonymous_rollout_basis_points: 10000});
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
});
it('never runs the handoff or forwards in an Apple web app', async () => {
installApp(IPHONE_UA);
writeCompletedMarker();
visit('https://web.fluxer.app/channels/1/2', {...ENABLED_DISCOVERY, standalone_forwarding: true});
expect(await runDomainMigrationPreMount()).toBe(false);
expect(replace).not.toHaveBeenCalled();
window.localStorage.clear();
writeIntent({at: Date.now(), handoff_id: 'handoff'});
visit('https://web.fluxer.app/migrate/done?h=handoff&next=%2Fchannels%2F1%2F2', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/1/2');
expect(readMarker()).toBeNull();
});
it('never runs the handoff in an Android web app', async () => {
installApp(CHROME_ANDROID_UA);
writeIntent({at: Date.now()});
visit('https://web.fluxer.app/migrate/done?next=%2Fchannels%2F1%2F2', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/channels/1/2');
expect(readMarker()).toBeNull();
});
it('starts a browser migration opened from an installed Android app on the source', async () => {
visit('https://fluxer.com/migrate/begin?start=1&next=%2Fapp', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/migrate/start?next=%2Fapp');
expect(window.sessionStorage.getItem(core.DOMAIN_MIGRATION_PENDING_KEY)).toBeNull();
visit('https://web.fluxer.app/migrate/start?next=%2Fapp', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/migrate/begin?next=%2Fapp');
expect(core.readDomainMigrationIntent(window.sessionStorage, Date.now())).toMatchObject({at: expect.any(Number)});
visit('https://fluxer.com/migrate/begin?next=%2Fapp', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace.mock.calls[0]?.[0]).toMatch(/^https:\/\/web\.fluxer\.app\/migrate\/export\?n=[\w-]+$/u);
});
it('opens the target directly when the browser already migrated', async () => {
window.localStorage.setItem('token', 'session-token');
visit('https://fluxer.com/migrate/begin?start=1&next=%2Fapp', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/app');
});
it('does not start a migration inside an installed Android app', async () => {
installApp(CHROME_ANDROID_UA);
visit('https://web.fluxer.app/migrate/start?next=%2Fapp', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/app');
expect(core.readDomainMigrationIntent(window.sessionStorage, Date.now())).toBeNull();
});
it('sends a target completion without a pending nonce back as failed', async () => {
visit('https://fluxer.com/migrate/complete#h=abc&k=def', ENABLED_DISCOVERY);
vi.stubGlobal('history', {state: null, replaceState: vi.fn()});
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith(
'https://web.fluxer.app/migrate/failed?reason=no_pending&next=%2Fchannels%2F%40me',
);
});
it('reports back to the source when the target already holds a session', async () => {
window.localStorage.setItem('token', 'session-token');
visit('https://fluxer.com/migrate/begin?next=%2Fchannels%2F1', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://web.fluxer.app/migrate/done?next=%2Fchannels%2F1');
visit('https://fluxer.com/migrate/begin?resume=1&next=%2Fchannels%2F1', ENABLED_DISCOVERY);
expect(await runDomainMigrationPreMount()).toBe(true);
expect(replace).toHaveBeenCalledWith('https://fluxer.com/channels/1');
});
});
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
classifyDomainMigrationInstallKind,
type DomainMigrationDisplayMode,
type DomainMigrationEnvironment,
type DomainMigrationInstallKind,
domainMovedBrowserMigrationUrl,
domainMovedInstallUrl,
domainMovedManifestId,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import {
AuthSessionStorageKey,
parseStoredSessionValue,
} from '@app/features/platform/state/auth_session/AuthSessionStorage';
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {hasUnavailableElectronNativeContext, isElectron} from '@app/features/ui/utils/NativeUtils';
import type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
interface NavigatorWithStandalone extends Navigator {
standalone?: boolean;
}
const DISPLAY_MODES: ReadonlyArray<DomainMigrationDisplayMode> = [
'window-controls-overlay',
'standalone',
'minimal-ui',
];
export function readDomainMigrationDiscovery(): DomainMigrationDiscoveryResponse | null {
return window.__FLUXER_BOOTSTRAP__?.instance.domain_migration ?? null;
}
function readDisplayMode(): DomainMigrationDisplayMode {
for (const mode of DISPLAY_MODES) {
if (window.matchMedia?.(`(display-mode: ${mode})`).matches) {
return mode;
}
}
return 'browser';
}
function isElectronEnvironment(): boolean {
return isElectron() || hasUnavailableElectronNativeContext();
}
export function detectDomainMigrationInstallKind(): DomainMigrationInstallKind {
if (typeof window === 'undefined') {
return 'none';
}
const navigator = window.navigator as NavigatorWithStandalone;
return classifyDomainMigrationInstallKind({
displayMode: readDisplayMode(),
navigatorStandalone: navigator.standalone === true,
userAgent: navigator.userAgent,
userAgentData: navigator.userAgentData ?? null,
maxTouchPoints: navigator.maxTouchPoints ?? 0,
electron: isElectronEnvironment(),
});
}
export function readDomainMigrationEnvironment(): DomainMigrationEnvironment {
return {
installKind: detectDomainMigrationInstallKind(),
electron: isElectronEnvironment(),
electronMigrationVersion: window.electron?.domainMigration?.version ?? null,
electronPasskeyRpIds: window.electron?.passkeyRpIds ?? [],
};
}
export async function desktopPasskeysSupported(): Promise<boolean> {
if (!isElectronEnvironment()) {
return true;
}
try {
return (await window.electron?.passkeyIsSupported?.()) === true;
} catch {
return false;
}
}
export function readActiveSessionToken(): string | null {
try {
return parseStoredSessionValue(getProtectedLocalStorage()?.getItem(AuthSessionStorageKey.Token) ?? null);
} catch {
return null;
}
}
export async function hasStoredAccount(): Promise<boolean> {
if (readActiveSessionToken() !== null) {
return true;
}
const {default: accountStorage} = await import('@app/features/auth/state/AccountStorage');
const accounts = await accountStorage.getAllAccounts();
return accounts.some((account) => Boolean(account.token));
}
function openInBrowser(url: string): void {
window.open(url, '_blank', 'noopener');
}
export function installDomainMovedApp(target: string, onUnavailable: () => void): void {
const installUrl = domainMovedInstallUrl(target);
if (typeof navigator.install !== 'function') {
openInBrowser(installUrl);
return;
}
navigator.install(installUrl, domainMovedManifestId(target)).catch((err: unknown) => {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
onUnavailable();
});
}
export function openDomainMovedBrowserMigration(target: string): void {
openInBrowser(domainMovedBrowserMigrationUrl(target));
}
@@ -0,0 +1,527 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RuntimeConfigSnapshot} from '@app/features/app/state/RuntimeConfig';
import type {StoredAccount} from '@app/features/auth/state/AccountStorage';
import {isIOSMobileOrTabletUserAgent} from '@app/features/platform/notifications/NotificationAlertOptions';
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
import type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
export const DOMAIN_MIGRATION_SOURCE_TO_TARGET: Readonly<Record<string, string>> = {
'https://web.fluxer.app': 'https://fluxer.com',
'https://web.canary.fluxer.app': 'https://canary.fluxer.com',
};
export const DOMAIN_MIGRATION_TARGET_TO_SOURCE: Readonly<Record<string, string>> = Object.fromEntries(
Object.entries(DOMAIN_MIGRATION_SOURCE_TO_TARGET).map(([source, target]) => [target, source]),
);
export const DOMAIN_MIGRATION_MARKER_KEY = 'fluxer:domain-migration';
export const DOMAIN_MIGRATION_DEVICE_KEY = 'fluxer:domain-migration:device';
export const DOMAIN_MIGRATION_PENDING_KEY = 'fluxer:domain-migration:pending';
export const DOMAIN_MIGRATION_INTENT_KEY = 'fluxer:domain-migration:intent';
export const DOMAIN_MIGRATION_NOTIFICATIONS_KEY = 'fluxer:domain-migration:notifications';
export const DOMAIN_MIGRATION_MOVED_DISMISSED_KEY = 'fluxer:domain-migration:moved-dismissed-at';
export const DOMAIN_MIGRATION_PAYLOAD_VERSION = 1;
export const DOMAIN_MIGRATION_DEFAULT_NEXT_PATH = '/channels/@me';
export const DOMAIN_MIGRATION_MAX_FAILED_ATTEMPTS = 3;
export const DOMAIN_MIGRATION_FAILED_RETRY_DELAY_MS = 24 * 60 * 60 * 1000;
export const DOMAIN_MIGRATION_PENDING_MAX_AGE_MS = 10 * 60 * 1000;
export const DOMAIN_MIGRATION_CUSTOM_SOUNDS_MAX_BYTES = 4 * 1024 * 1024;
export const DOMAIN_MIGRATION_THEME_ASSETS_MAX_BYTES = 2 * 1024 * 1024;
export const DOMAIN_MIGRATION_MOVED_DISMISS_MS = 7 * 24 * 60 * 60 * 1000;
const NEXT_PATH_BASE = 'https://next.invalid';
function hasUnsafeNextPathCharacter(value: string): boolean {
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f || code === 0x5c) {
return true;
}
}
return false;
}
const DENIED_LOCAL_STORAGE_KEYS: ReadonlySet<string> = new Set([
'fluxer.lastPushEndpoint',
'__test__',
'runtimeConfig',
]);
const PUSH_SUBSCRIPTION_KEY_PATTERN = /push[-_.:]?(?:subscription|endpoint)/iu;
export interface StorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
key(index: number): string | null;
readonly length: number;
}
export type DomainMigrationSide =
| {role: 'source'; source: string; target: string}
| {role: 'target'; source: string; target: string};
export function resolveDomainMigrationSide(origin: string): DomainMigrationSide | null {
const target = DOMAIN_MIGRATION_SOURCE_TO_TARGET[origin];
if (target !== undefined) {
return {role: 'source', source: origin, target};
}
const source = DOMAIN_MIGRATION_TARGET_TO_SOURCE[origin];
if (source !== undefined) {
return {role: 'target', source, target: origin};
}
return null;
}
export function sanitizeNextPath(value: unknown): string {
if (typeof value !== 'string' || !value.startsWith('/') || hasUnsafeNextPathCharacter(value)) {
return DOMAIN_MIGRATION_DEFAULT_NEXT_PATH;
}
let url: URL;
try {
url = new URL(value, NEXT_PATH_BASE);
} catch {
return DOMAIN_MIGRATION_DEFAULT_NEXT_PATH;
}
if (url.origin !== NEXT_PATH_BASE || url.pathname.startsWith('/migrate')) {
return DOMAIN_MIGRATION_DEFAULT_NEXT_PATH;
}
const pathname = url.pathname === '/' ? '/app' : url.pathname;
return `${pathname}${url.search}${url.hash}`;
}
export function buildTargetUrl(target: string, pathname: string, search: string, hash: string): string {
return `${target}${sanitizeNextPath(`${pathname}${search}${hash}`)}`;
}
export type DomainMigrationMarker =
| {state: 'completed'; target: string; at: number}
| {state: 'failed'; at: number; attempts: number};
export function parseDomainMigrationMarker(raw: string | null): DomainMigrationMarker | null {
if (!raw) {
return null;
}
try {
const value = JSON.parse(raw) as Record<string, unknown>;
if (typeof value !== 'object' || value === null || typeof value.at !== 'number') {
return null;
}
if (value.state === 'completed' && typeof value.target === 'string') {
return {state: 'completed', target: value.target, at: value.at};
}
if (value.state === 'failed') {
const attempts = typeof value.attempts === 'number' && value.attempts > 0 ? value.attempts : 1;
return {state: 'failed', at: value.at, attempts};
}
return null;
} catch {
return null;
}
}
export function readDomainMigrationMarker(storage: StorageLike | null): DomainMigrationMarker | null {
try {
return parseDomainMigrationMarker(storage?.getItem(DOMAIN_MIGRATION_MARKER_KEY) ?? null);
} catch {
return null;
}
}
function writeDomainMigrationMarker(storage: StorageLike | null, marker: DomainMigrationMarker): void {
try {
storage?.setItem(DOMAIN_MIGRATION_MARKER_KEY, JSON.stringify(marker));
} catch {}
}
export function markDomainMigrationCompleted(storage: StorageLike | null, target: string, now: number): void {
writeDomainMigrationMarker(storage, {state: 'completed', target, at: now});
}
export function markDomainMigrationFailed(storage: StorageLike | null, now: number): void {
const previous = readDomainMigrationMarker(storage);
const attempts = previous?.state === 'failed' ? previous.attempts + 1 : 1;
writeDomainMigrationMarker(storage, {state: 'failed', at: now, attempts});
}
export function markerAllowsDomainMigration(marker: DomainMigrationMarker | null, now: number): boolean {
if (marker === null) {
return true;
}
if (marker.state === 'completed') {
return false;
}
return (
marker.attempts < DOMAIN_MIGRATION_MAX_FAILED_ATTEMPTS && now - marker.at >= DOMAIN_MIGRATION_FAILED_RETRY_DELAY_MS
);
}
export interface DomainMigrationIntent {
at: number;
handoff_id?: string;
}
export function readDomainMigrationIntent(storage: StorageLike | null, now: number): DomainMigrationIntent | null {
try {
const raw = storage?.getItem(DOMAIN_MIGRATION_INTENT_KEY) ?? null;
if (!raw) {
return null;
}
const value = JSON.parse(raw) as unknown;
if (!isRecord(value) || typeof value.at !== 'number' || now - value.at > DOMAIN_MIGRATION_PENDING_MAX_AGE_MS) {
return null;
}
return typeof value.handoff_id === 'string' ? {at: value.at, handoff_id: value.handoff_id} : {at: value.at};
} catch {
return null;
}
}
export function writeDomainMigrationIntent(storage: StorageLike | null, intent: DomainMigrationIntent): void {
storage?.setItem(DOMAIN_MIGRATION_INTENT_KEY, JSON.stringify(intent));
}
export function clearDomainMigrationIntent(storage: StorageLike | null): void {
try {
storage?.removeItem(DOMAIN_MIGRATION_INTENT_KEY);
} catch {}
}
export function intentConfirmsCompletion(intent: DomainMigrationIntent | null, handoffId: string | null): boolean {
if (intent === null) {
return false;
}
return intent.handoff_id === undefined || intent.handoff_id === handoffId;
}
export type DomainMigrationInstallKind =
| 'none'
| 'chromium-desktop'
| 'chromium-android'
| 'webkit'
| 'firefox'
| 'other';
export type DomainMigrationDisplayMode = 'browser' | 'minimal-ui' | 'standalone' | 'window-controls-overlay';
export interface DomainMigrationInstallSignals {
displayMode: DomainMigrationDisplayMode;
navigatorStandalone: boolean;
userAgent: string;
userAgentData: {brands?: ReadonlyArray<{brand: string}>; mobile?: boolean; platform?: string} | null;
maxTouchPoints: number;
electron: boolean;
}
const INSTALLED_DISPLAY_MODES: ReadonlySet<DomainMigrationDisplayMode> = new Set([
'standalone',
'window-controls-overlay',
]);
const CHROMIUM_USER_AGENT_PATTERN = /\b(?:Chrome|Chromium|CriOS|EdgA|Edg|OPR|SamsungBrowser)\//u;
const ANDROID_USER_AGENT_PATTERN = /\bAndroid\b/u;
export function classifyDomainMigrationInstallKind(signals: DomainMigrationInstallSignals): DomainMigrationInstallKind {
if (signals.electron) {
return 'none';
}
const installed = signals.navigatorStandalone || INSTALLED_DISPLAY_MODES.has(signals.displayMode);
if (!installed) {
return 'none';
}
const {userAgent} = signals;
if (isIOSMobileOrTabletUserAgent(userAgent, signals.maxTouchPoints)) {
return 'webkit';
}
const android =
signals.userAgentData?.platform === 'Android' ||
signals.userAgentData?.mobile === true ||
ANDROID_USER_AGENT_PATTERN.test(userAgent);
if (signals.userAgentData?.brands?.some((entry) => entry.brand === 'Chromium')) {
return android ? 'chromium-android' : 'chromium-desktop';
}
if (/\bFirefox\//u.test(userAgent)) {
return 'firefox';
}
if (CHROMIUM_USER_AGENT_PATTERN.test(userAgent)) {
return android ? 'chromium-android' : 'chromium-desktop';
}
if (/\bMacintosh\b/u.test(userAgent) && /\bSafari\//u.test(userAgent)) {
return 'webkit';
}
return 'other';
}
export interface DomainMigrationEnvironment {
installKind: DomainMigrationInstallKind;
electron: boolean;
electronMigrationVersion: number | null;
electronPasskeyRpIds: ReadonlyArray<string>;
}
export function environmentAllowsDomainMigration(environment: DomainMigrationEnvironment): boolean {
if (environment.installKind !== 'none' && environment.installKind !== 'chromium-desktop') {
return false;
}
if (environment.electron) {
return (
environment.electronMigrationVersion !== null &&
environment.electronMigrationVersion >= 1 &&
environment.electronPasskeyRpIds.includes(PASSKEY_MIGRATION_RP_ID)
);
}
return true;
}
export function environmentMayForward(
environment: DomainMigrationEnvironment,
discovery: DomainMigrationDiscoveryResponse | null,
): boolean {
if (!environmentAllowsDomainMigration(environment)) {
return false;
}
return environment.installKind === 'none' || discovery?.standalone_forwarding === true;
}
export interface DomainMigrationGateInput {
environment: DomainMigrationEnvironment;
assignmentEnabled: boolean;
discovery: DomainMigrationDiscoveryResponse | null;
marker: DomainMigrationMarker | null;
now: number;
voiceActive: boolean;
oneShotRoute: boolean;
}
export function shouldStartDomainMigration(input: DomainMigrationGateInput): boolean {
return (
input.assignmentEnabled &&
input.discovery?.enabled === true &&
markerAllowsDomainMigration(input.marker, input.now) &&
environmentAllowsDomainMigration(input.environment) &&
!input.voiceActive &&
!input.oneShotRoute
);
}
export function shouldForwardCompletedSource(
discovery: DomainMigrationDiscoveryResponse | null,
marker: DomainMigrationMarker | null,
environment: DomainMigrationEnvironment,
): boolean {
return discovery?.enabled === true && marker?.state === 'completed' && environmentMayForward(environment, discovery);
}
export interface DomainMovedNoticeInput {
side: DomainMigrationSide | null;
installKind: DomainMigrationInstallKind;
discovery: DomainMigrationDiscoveryResponse | null;
assignmentEnabled: boolean;
marker: DomainMigrationMarker | null;
dismissedAt: number | null;
now: number;
}
export function shouldShowDomainMovedNotice(input: DomainMovedNoticeInput): boolean {
if (input.side?.role !== 'source' || input.installKind === 'none' || input.discovery?.enabled !== true) {
return false;
}
if (input.dismissedAt !== null && input.now - input.dismissedAt < DOMAIN_MIGRATION_MOVED_DISMISS_MS) {
return false;
}
const completed = input.marker?.state === 'completed';
if (input.installKind === 'chromium-desktop') {
return completed;
}
return completed || input.assignmentEnabled;
}
export function domainMovedInstallUrl(target: string): string {
return `${target}/app`;
}
export function domainMovedManifestId(target: string): string {
return `${target}/`;
}
export function domainMovedBrowserMigrationUrl(target: string): string {
return `${target}/migrate/begin?start=1&next=${encodeURIComponent('/app')}`;
}
export function anonymousRolloutIsOpen(discovery: DomainMigrationDiscoveryResponse | null): boolean {
return discovery?.enabled === true && discovery.anonymous_rollout_basis_points > 0;
}
export function deviceIsInAnonymousRollout(discovery: DomainMigrationDiscoveryResponse, deviceId: string): boolean {
return experimentBucket(deviceId, discovery.rollout_salt) < discovery.anonymous_rollout_basis_points;
}
export function isExportableLocalStorageKey(key: string): boolean {
return (
!DENIED_LOCAL_STORAGE_KEYS.has(key) &&
!key.startsWith(DOMAIN_MIGRATION_MARKER_KEY) &&
!PUSH_SUBSCRIPTION_KEY_PATTERN.test(key)
);
}
export function collectExportableLocalStorage(storage: StorageLike | null): Record<string, string> {
const entries: Record<string, string> = {};
if (!storage) {
return entries;
}
for (let index = 0; index < storage.length; index++) {
const key = storage.key(index);
if (key === null || !isExportableLocalStorageKey(key)) {
continue;
}
const value = storage.getItem(key);
if (value !== null) {
entries[key] = value;
}
}
return entries;
}
export interface DomainMigrationCustomSound {
sound_type: string;
file_name: string;
mime_type: string;
data: string;
}
export interface DomainMigrationThemeAsset {
id: string;
name: string;
mime_type: string;
size: number;
data?: string;
desktop_path?: string;
created_at: number;
updated_at: number;
}
export interface DomainMigrationThemeLibrary {
themes: Array<Record<string, unknown>>;
assets: Array<DomainMigrationThemeAsset>;
local_files: Array<Record<string, unknown>>;
enabled_theme_ids: Array<string>;
}
export interface DomainMigrationPayload {
version: typeof DOMAIN_MIGRATION_PAYLOAD_VERSION;
source_origin: string;
exported_at: number;
local_storage: Record<string, string>;
accounts: Array<StoredAccount>;
custom_sounds?: Array<DomainMigrationCustomSound>;
theme_library?: DomainMigrationThemeLibrary;
notification_permission: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string');
}
function isStoredAccount(value: unknown): value is StoredAccount {
return (
isRecord(value) &&
typeof value.userId === 'string' &&
value.userId.length > 0 &&
(typeof value.token === 'string' || value.token === null) &&
typeof value.lastActive === 'number'
);
}
function isCustomSound(value: unknown): value is DomainMigrationCustomSound {
return (
isRecord(value) &&
typeof value.sound_type === 'string' &&
typeof value.file_name === 'string' &&
typeof value.mime_type === 'string' &&
typeof value.data === 'string'
);
}
function isIdentifiedRecord(value: unknown): value is Record<string, unknown> {
return isRecord(value) && typeof value.id === 'string';
}
function isThemeAsset(value: unknown): value is DomainMigrationThemeAsset {
return (
isIdentifiedRecord(value) &&
typeof value.name === 'string' &&
typeof value.mime_type === 'string' &&
typeof value.size === 'number' &&
(value.data === undefined || typeof value.data === 'string') &&
(value.desktop_path === undefined || typeof value.desktop_path === 'string') &&
typeof value.created_at === 'number' &&
typeof value.updated_at === 'number'
);
}
function isThemeLibrary(value: unknown): value is DomainMigrationThemeLibrary {
return (
isRecord(value) &&
Array.isArray(value.themes) &&
value.themes.every(isIdentifiedRecord) &&
Array.isArray(value.assets) &&
value.assets.every(isThemeAsset) &&
Array.isArray(value.local_files) &&
value.local_files.every(isIdentifiedRecord) &&
Array.isArray(value.enabled_theme_ids) &&
value.enabled_theme_ids.every((id) => typeof id === 'string')
);
}
export function withoutOptionalPayloadData(payload: DomainMigrationPayload): DomainMigrationPayload {
return {
...payload,
custom_sounds: undefined,
theme_library: payload.theme_library && {...payload.theme_library, assets: []},
};
}
export function parseDomainMigrationPayload(value: unknown, expectedSource: string): DomainMigrationPayload | null {
if (
!isRecord(value) ||
value.version !== DOMAIN_MIGRATION_PAYLOAD_VERSION ||
value.source_origin !== expectedSource ||
typeof value.exported_at !== 'number' ||
!isStringRecord(value.local_storage) ||
!Array.isArray(value.accounts) ||
!value.accounts.every(isStoredAccount) ||
typeof value.notification_permission !== 'string'
) {
return null;
}
if (
value.custom_sounds !== undefined &&
!(Array.isArray(value.custom_sounds) && value.custom_sounds.every(isCustomSound))
) {
return null;
}
if (value.theme_library !== undefined && !isThemeLibrary(value.theme_library)) {
return null;
}
return value as unknown as DomainMigrationPayload;
}
function withoutRuntimeConfig(snapshot: Record<string, string> | undefined): Record<string, string> {
const {runtimeConfig: _runtimeConfig, ...rest} = snapshot ?? {};
return rest;
}
export function rewriteImportedAccount(record: StoredAccount, instance: RuntimeConfigSnapshot): StoredAccount {
const managed = withoutRuntimeConfig(record.managedStorageData ?? record.localStorageData);
return {
...record,
localStorageData: managed,
managedStorageData: managed,
instance,
};
}
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const IV_BYTES = 12;
const KEY_BYTES = 32;
const GZIP_MAGIC_FIRST = 0x1f;
const GZIP_MAGIC_SECOND = 0x8b;
const BASE64_CHUNK = 0x8000;
export function bytesToBase64Url(bytes: Uint8Array): string {
let binary = '';
for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK));
}
return btoa(binary).replace(/\+/gu, '-').replace(/\//gu, '_').replace(/=+$/u, '');
}
export function base64UrlToBytes(value: string): Uint8Array<ArrayBuffer> {
const base64 = value.replace(/-/gu, '+').replace(/_/gu, '/');
const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='));
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
export function randomBase64Url(byteLength: number): string {
return bytesToBase64Url(crypto.getRandomValues(new Uint8Array(byteLength)));
}
export async function sha256Hex(value: string): Promise<string> {
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)));
return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
async function pipeBytes(
bytes: Uint8Array<ArrayBuffer>,
transform: GenericTransformStream,
): Promise<Uint8Array<ArrayBuffer>> {
const stream = new Blob([bytes]).stream().pipeThrough(transform as TransformStream<Uint8Array, Uint8Array>);
return new Uint8Array(await new Response(stream).arrayBuffer());
}
async function compress(bytes: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>> {
if (typeof CompressionStream === 'undefined') {
return bytes;
}
return pipeBytes(bytes, new CompressionStream('gzip'));
}
async function decompress(bytes: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>> {
if (bytes[0] !== GZIP_MAGIC_FIRST || bytes[1] !== GZIP_MAGIC_SECOND) {
return bytes;
}
if (typeof DecompressionStream === 'undefined') {
throw new Error('DecompressionStream unavailable');
}
return pipeBytes(bytes, new DecompressionStream('gzip'));
}
export async function encryptDomainMigrationPayload(value: unknown): Promise<{payload: string; key: string}> {
const plaintext = await compress(new TextEncoder().encode(JSON.stringify(value)));
const rawKey = crypto.getRandomValues(new Uint8Array(KEY_BYTES));
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
const key = await crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['encrypt']);
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, plaintext));
const sealed = new Uint8Array(IV_BYTES + ciphertext.length);
sealed.set(iv, 0);
sealed.set(ciphertext, IV_BYTES);
return {payload: bytesToBase64Url(sealed), key: bytesToBase64Url(rawKey)};
}
export async function decryptDomainMigrationPayload(payload: string, encodedKey: string): Promise<unknown> {
const sealed = base64UrlToBytes(payload);
const rawKey = base64UrlToBytes(encodedKey);
if (rawKey.length !== KEY_BYTES || sealed.length <= IV_BYTES) {
throw new Error('Malformed handoff payload');
}
const key = await crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['decrypt']);
const plaintext = new Uint8Array(
await crypto.subtle.decrypt({name: 'AES-GCM', iv: sealed.subarray(0, IV_BYTES)}, key, sealed.subarray(IV_BYTES)),
);
return JSON.parse(new TextDecoder().decode(await decompress(plaintext)));
}
@@ -0,0 +1,607 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
hasStoredAccount,
readActiveSessionToken,
readDomainMigrationDiscovery,
readDomainMigrationEnvironment,
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
import {
anonymousRolloutIsOpen,
buildTargetUrl,
clearDomainMigrationIntent,
collectExportableLocalStorage,
DOMAIN_MIGRATION_CUSTOM_SOUNDS_MAX_BYTES,
DOMAIN_MIGRATION_DEFAULT_NEXT_PATH,
DOMAIN_MIGRATION_DEVICE_KEY,
DOMAIN_MIGRATION_MARKER_KEY,
DOMAIN_MIGRATION_NOTIFICATIONS_KEY,
DOMAIN_MIGRATION_PAYLOAD_VERSION,
DOMAIN_MIGRATION_PENDING_KEY,
DOMAIN_MIGRATION_PENDING_MAX_AGE_MS,
DOMAIN_MIGRATION_THEME_ASSETS_MAX_BYTES,
type DomainMigrationCustomSound,
type DomainMigrationPayload,
type DomainMigrationSide,
type DomainMigrationThemeLibrary,
deviceIsInAnonymousRollout,
environmentAllowsDomainMigration,
environmentMayForward,
intentConfirmsCompletion,
isExportableLocalStorageKey,
markDomainMigrationCompleted,
markDomainMigrationFailed,
parseDomainMigrationMarker,
parseDomainMigrationPayload,
readDomainMigrationIntent,
readDomainMigrationMarker,
resolveDomainMigrationSide,
rewriteImportedAccount,
type StorageLike,
sanitizeNextPath,
shouldForwardCompletedSource,
withoutOptionalPayloadData,
writeDomainMigrationIntent,
} from '@app/features/app/domain_migration/DomainMigrationCore';
import {
base64UrlToBytes,
bytesToBase64Url,
decryptDomainMigrationPayload,
encryptDomainMigrationPayload,
randomBase64Url,
sha256Hex,
} from '@app/features/app/domain_migration/DomainMigrationCrypto';
import type {SoundType} from '@app/features/notification/utils/SoundUtils';
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
import {Logger} from '@app/features/platform/utils/AppLogger';
import type {
ThemeLibraryAsset,
ThemeLibraryLocalFileReference,
ThemeLibraryTheme,
} from '@app/features/theme/state/ThemeLibrary';
import {when} from 'mobx';
const logger = new Logger('DomainMigration');
const NONCE_BYTES = 32;
const DEVICE_ID_BYTES = 16;
const BASE64URL_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
const MAX_HANDOFF_PAYLOAD_LENGTH = 8 * 1024 * 1024;
type DomainMigrationFailureReason =
| 'disabled'
| 'no_pending'
| 'missing_handoff'
| 'handoff_expired'
| 'nonce_mismatch'
| 'redeem_failed'
| 'invalid_payload'
| 'import_failed'
| 'target_error';
class DomainMigrationImportError extends Error {
constructor(readonly reason: DomainMigrationFailureReason) {
super(`Domain migration import failed: ${reason}`);
this.name = 'DomainMigrationImportError';
}
}
interface PendingHandoff {
nonce: string;
next: string;
at: number;
}
function navigate(url: string): true {
window.location.replace(url);
return true;
}
function readCurrentPath(): {pathname: string; search: string; hash: string} {
return {pathname: window.location.pathname, search: window.location.search, hash: window.location.hash};
}
function readNotificationPermission(): string {
return typeof Notification === 'undefined' ? 'unsupported' : Notification.permission;
}
function readOrCreateDeviceId(): string {
const storage = getProtectedLocalStorage();
const existing = storage?.getItem(DOMAIN_MIGRATION_DEVICE_KEY);
if (existing) {
return existing;
}
const deviceId = randomBase64Url(DEVICE_ID_BYTES);
try {
storage?.setItem(DOMAIN_MIGRATION_DEVICE_KEY, deviceId);
} catch {}
return deviceId;
}
async function collectCustomSounds(): Promise<Array<DomainMigrationCustomSound> | undefined> {
try {
const {getAllCustomSounds} = await import('@app/features/notification/utils/CustomSoundDB');
const sounds = await getAllCustomSounds();
const totalBytes = sounds.reduce((sum, sound) => sum + sound.blob.size, 0);
if (totalBytes > DOMAIN_MIGRATION_CUSTOM_SOUNDS_MAX_BYTES) {
return undefined;
}
return await Promise.all(
sounds.map(async (sound) => ({
sound_type: sound.soundType,
file_name: sound.fileName,
mime_type: sound.blob.type,
data: bytesToBase64Url(new Uint8Array(await sound.blob.arrayBuffer())),
})),
);
} catch (err) {
logger.warn('Skipping custom sounds in the domain migration export:', err);
return undefined;
}
}
async function restoreCustomSounds(sounds: ReadonlyArray<DomainMigrationCustomSound> | undefined): Promise<void> {
if (!sounds || sounds.length === 0) {
return;
}
const {saveCustomSound} = await import('@app/features/notification/utils/CustomSoundDB');
for (const sound of sounds) {
try {
const blob = new Blob([base64UrlToBytes(sound.data)], {type: sound.mime_type});
await saveCustomSound(sound.sound_type as SoundType, blob, sound.file_name);
} catch (err) {
logger.warn(`Failed to restore custom sound ${sound.sound_type}:`, err);
}
}
}
async function collectThemeLibrary(): Promise<DomainMigrationThemeLibrary | undefined> {
try {
const db = await import('@app/features/theme/utils/ThemeLibraryDb');
const [themes, assets, localFiles, enabledThemeIds] = await Promise.all([
db.listThemeLibraryThemes(),
db.listThemeLibraryAssets(),
db.listThemeLibraryLocalFiles(),
db.getEnabledThemeIds(),
]);
const assetBytes = assets.reduce((sum, asset) => sum + (asset.data?.size ?? 0), 0);
const portableAssets = assetBytes > DOMAIN_MIGRATION_THEME_ASSETS_MAX_BYTES ? [] : assets;
return {
themes: themes.map((theme) => ({...theme})),
assets: await Promise.all(
portableAssets.map(async (asset) => ({
id: asset.id,
name: asset.name,
mime_type: asset.mimeType,
size: asset.size,
data: asset.data ? bytesToBase64Url(new Uint8Array(await asset.data.arrayBuffer())) : undefined,
desktop_path: asset.desktopPath,
created_at: asset.createdAt,
updated_at: asset.updatedAt,
})),
),
local_files: localFiles.map((file) => ({...file})),
enabled_theme_ids: enabledThemeIds,
};
} catch (err) {
logger.warn('Skipping the theme library in the domain migration export:', err);
return undefined;
}
}
async function restoreThemeLibrary(library: DomainMigrationThemeLibrary | undefined): Promise<void> {
if (!library) {
return;
}
try {
const db = await import('@app/features/theme/utils/ThemeLibraryDb');
for (const theme of library.themes) {
await db.saveThemeLibraryTheme(theme as unknown as ThemeLibraryTheme);
}
for (const asset of library.assets) {
const restored: ThemeLibraryAsset = {
id: asset.id,
name: asset.name,
mimeType: asset.mime_type,
size: asset.size,
data: asset.data === undefined ? undefined : new Blob([base64UrlToBytes(asset.data)], {type: asset.mime_type}),
desktopPath: asset.desktop_path,
createdAt: asset.created_at,
updatedAt: asset.updated_at,
};
await db.saveThemeLibraryAsset(restored);
}
for (const file of library.local_files) {
await db.saveThemeLibraryLocalFile(file as unknown as ThemeLibraryLocalFileReference);
}
if (library.enabled_theme_ids.length > 0) {
await db.setEnabledThemeIds(library.enabled_theme_ids);
}
} catch (err) {
logger.warn('Failed to restore the theme library:', err);
}
}
async function createHandoff(apiEndpoint: string, token: string, nonceHash: string, payload: string): Promise<string> {
const response = await fetch(`${apiEndpoint}/v1/auth/origin-handoff`, {
method: 'POST',
credentials: 'omit',
headers: {'Content-Type': 'application/json', Authorization: token},
body: JSON.stringify({nonce_hash: nonceHash, payload}),
});
if (!response.ok) {
throw new Error(`Origin handoff was rejected with status ${response.status}`);
}
const body = (await response.json()) as {handoff_id?: unknown};
if (typeof body.handoff_id !== 'string' || !BASE64URL_TOKEN_PATTERN.test(body.handoff_id)) {
throw new Error('Origin handoff response is malformed');
}
return body.handoff_id;
}
async function redeemHandoff(target: string, handoffId: string, nonce: string): Promise<string> {
let response: Response;
try {
response = await fetch(`${target}/api/v1/auth/origin-handoff/redeem`, {
method: 'POST',
credentials: 'omit',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({handoff_id: handoffId, nonce}),
});
} catch {
throw new DomainMigrationImportError('redeem_failed');
}
if (response.status === 404) {
throw new DomainMigrationImportError('handoff_expired');
}
if (response.status === 400) {
throw new DomainMigrationImportError('nonce_mismatch');
}
if (!response.ok) {
throw new DomainMigrationImportError('redeem_failed');
}
const body = (await response.json()) as {payload?: unknown};
if (typeof body.payload !== 'string') {
throw new DomainMigrationImportError('redeem_failed');
}
return body.payload;
}
function sourceUrl(side: DomainMigrationSide, next: unknown): string {
return `${side.source}${sanitizeNextPath(next)}`;
}
function discoveryAllowsMigration(): boolean {
return (
readDomainMigrationDiscovery()?.enabled === true &&
environmentAllowsDomainMigration(readDomainMigrationEnvironment())
);
}
function revokeCompletedMarker(storage: StorageLike | null): void {
if (readDomainMigrationMarker(storage)?.state === 'completed') {
markDomainMigrationFailed(storage, Date.now());
}
}
async function exportFromSource(side: DomainMigrationSide, nonce: string | null): Promise<boolean> {
const storage = getProtectedLocalStorage();
const sessionStorage = getProtectedSessionStorage();
const intent = readDomainMigrationIntent(sessionStorage, Date.now());
if (intent === null || intent.handoff_id !== undefined) {
clearDomainMigrationIntent(sessionStorage);
return navigate(sourceUrl(side, DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
}
try {
if (nonce === null || !BASE64URL_TOKEN_PATTERN.test(nonce)) {
throw new Error('Missing or malformed nonce');
}
const [{default: accountStorage}, {default: RuntimeConfig}] = await Promise.all([
import('@app/features/auth/state/AccountStorage'),
import('@app/features/app/state/RuntimeConfig'),
]);
const accounts = (await accountStorage.getAllAccounts()).filter((account) => Boolean(account.token));
const token = readActiveSessionToken() ?? accounts.find((account) => account.isValid !== false)?.token ?? null;
if (!token) {
throw new Error('No stored account to export');
}
const payload: DomainMigrationPayload = {
version: DOMAIN_MIGRATION_PAYLOAD_VERSION,
source_origin: side.source,
exported_at: Date.now(),
local_storage: collectExportableLocalStorage(storage),
accounts,
custom_sounds: await collectCustomSounds(),
theme_library: await collectThemeLibrary(),
notification_permission: readNotificationPermission(),
};
let sealed = await encryptDomainMigrationPayload(payload);
if (sealed.payload.length > MAX_HANDOFF_PAYLOAD_LENGTH) {
sealed = await encryptDomainMigrationPayload(withoutOptionalPayloadData(payload));
}
const handoffId = await createHandoff(RuntimeConfig.apiEndpoint, token, await sha256Hex(nonce), sealed.payload);
writeDomainMigrationIntent(sessionStorage, {at: intent.at, handoff_id: handoffId});
return navigate(`${side.target}/migrate/complete#h=${handoffId}&k=${sealed.key}`);
} catch (err) {
logger.warn('Domain migration export failed:', err);
clearDomainMigrationIntent(sessionStorage);
revokeCompletedMarker(storage);
return navigate(sourceUrl(side, DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
}
}
async function forwardFromSource(side: DomainMigrationSide): Promise<boolean> {
const environment = readDomainMigrationEnvironment();
const discovery = readDomainMigrationDiscovery();
if (!environmentMayForward(environment, discovery)) {
return false;
}
const {pathname, search, hash} = readCurrentPath();
if (shouldForwardCompletedSource(discovery, readDomainMigrationMarker(getProtectedLocalStorage()), environment)) {
if (!(await hasStoredAccount())) {
return navigate(buildTargetUrl(side.target, pathname, search, hash));
}
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
const next = sanitizeNextPath(`${pathname}${search}${hash}`);
return navigate(`${side.target}/migrate/begin?resume=1&next=${encodeURIComponent(next)}`);
}
if (
discovery !== null &&
anonymousRolloutIsOpen(discovery) &&
!(await hasStoredAccount()) &&
deviceIsInAnonymousRollout(discovery, readOrCreateDeviceId())
) {
return navigate(buildTargetUrl(side.target, pathname, search, hash));
}
return false;
}
async function forwardWhenIdle(side: DomainMigrationSide): Promise<void> {
const {default: MediaEngine} = await import('@app/features/voice/engine/MediaEngineFacade');
await when(() => !MediaEngine.connected && !MediaEngine.connecting);
if (
!shouldForwardCompletedSource(
readDomainMigrationDiscovery(),
readDomainMigrationMarker(getProtectedLocalStorage()),
readDomainMigrationEnvironment(),
)
) {
return;
}
const {pathname, search, hash} = readCurrentPath();
navigate(buildTargetUrl(side.target, pathname, search, hash));
}
function installSourceMarkerListener(side: DomainMigrationSide): void {
let waiting = false;
window.addEventListener('storage', (event) => {
if (event.key !== DOMAIN_MIGRATION_MARKER_KEY || waiting) {
return;
}
if (
!shouldForwardCompletedSource(
readDomainMigrationDiscovery(),
parseDomainMigrationMarker(event.newValue),
readDomainMigrationEnvironment(),
)
) {
return;
}
waiting = true;
forwardWhenIdle(side)
.catch((err) => {
logger.warn('Failed to forward a migrated source tab:', err);
})
.finally(() => {
waiting = false;
});
});
}
function completeOnSource(side: DomainMigrationSide, params: URLSearchParams): boolean {
const sessionStorage = getProtectedSessionStorage();
const intent = readDomainMigrationIntent(sessionStorage, Date.now());
clearDomainMigrationIntent(sessionStorage);
if (!intentConfirmsCompletion(intent, params.get('h'))) {
return navigate(sourceUrl(side, params.get('next')));
}
markDomainMigrationCompleted(getProtectedLocalStorage(), side.target, Date.now());
if (!environmentMayForward(readDomainMigrationEnvironment(), readDomainMigrationDiscovery())) {
return navigate(sourceUrl(side, params.get('next')));
}
return navigate(`${side.target}${sanitizeNextPath(params.get('next'))}`);
}
function startOnSource(side: DomainMigrationSide, params: URLSearchParams): boolean {
writeDomainMigrationIntent(getProtectedSessionStorage(), {at: Date.now()});
return navigate(`${side.target}/migrate/begin?next=${encodeURIComponent(sanitizeNextPath(params.get('next')))}`);
}
function failOnSource(side: DomainMigrationSide, params: URLSearchParams): boolean {
const sessionStorage = getProtectedSessionStorage();
const intent = readDomainMigrationIntent(sessionStorage, Date.now());
clearDomainMigrationIntent(sessionStorage);
if (intent !== null) {
logger.warn(`Domain migration failed on ${side.target}: ${params.get('reason') ?? 'unknown'}`);
revokeCompletedMarker(getProtectedLocalStorage());
}
return navigate(sourceUrl(side, params.get('next')));
}
async function handleSource(side: DomainMigrationSide): Promise<boolean> {
const params = new URLSearchParams(window.location.search);
const {pathname} = window.location;
if (pathname.startsWith('/migrate/') && !discoveryAllowsMigration()) {
clearDomainMigrationIntent(getProtectedSessionStorage());
return navigate(sourceUrl(side, params.get('next')));
}
switch (pathname) {
case '/migrate/export':
return exportFromSource(side, params.get('n'));
case '/migrate/start':
return startOnSource(side, params);
case '/migrate/done':
return completeOnSource(side, params);
case '/migrate/failed':
return failOnSource(side, params);
}
if (await forwardFromSource(side)) {
return true;
}
installSourceMarkerListener(side);
return false;
}
function takePendingHandoff(): PendingHandoff | null {
const storage = getProtectedSessionStorage();
try {
const raw = storage?.getItem(DOMAIN_MIGRATION_PENDING_KEY) ?? null;
storage?.removeItem(DOMAIN_MIGRATION_PENDING_KEY);
if (!raw) {
return null;
}
const value = JSON.parse(raw) as Partial<PendingHandoff>;
if (typeof value.nonce !== 'string' || typeof value.next !== 'string' || typeof value.at !== 'number') {
return null;
}
return {nonce: value.nonce, next: value.next, at: value.at};
} catch {
return null;
}
}
async function importHandoff(side: DomainMigrationSide, fragment: URLSearchParams, nonce: string): Promise<void> {
const handoffId = fragment.get('h');
const key = fragment.get('k');
if (!handoffId || !key || !BASE64URL_TOKEN_PATTERN.test(handoffId)) {
throw new DomainMigrationImportError('missing_handoff');
}
const sealed = await redeemHandoff(side.target, handoffId, nonce);
let payload: DomainMigrationPayload | null;
try {
payload = parseDomainMigrationPayload(await decryptDomainMigrationPayload(sealed, key), side.source);
} catch {
payload = null;
}
if (payload === null) {
throw new DomainMigrationImportError('invalid_payload');
}
try {
const [{default: accountStorage}, {default: RuntimeConfig}] = await Promise.all([
import('@app/features/auth/state/AccountStorage'),
import('@app/features/app/state/RuntimeConfig'),
]);
const instance = RuntimeConfig.getSnapshot();
await accountStorage.importAccounts(payload.accounts.map((account) => rewriteImportedAccount(account, instance)));
} catch (err) {
logger.warn('Failed to import migrated accounts:', err);
throw new DomainMigrationImportError('import_failed');
}
const storage = getProtectedLocalStorage();
for (const [storageKey, value] of Object.entries(payload.local_storage)) {
if (!isExportableLocalStorageKey(storageKey)) {
continue;
}
try {
storage?.setItem(storageKey, value);
} catch (err) {
logger.warn(`Failed to import localStorage key ${storageKey}:`, err);
}
}
await restoreCustomSounds(payload.custom_sounds);
await restoreThemeLibrary(payload.theme_library);
if (payload.notification_permission === 'granted') {
try {
storage?.setItem(DOMAIN_MIGRATION_NOTIFICATIONS_KEY, 'granted');
} catch {}
}
await persistDesktopAppOrigin();
}
async function persistDesktopAppOrigin(): Promise<void> {
try {
await window.electron?.domainMigration?.setAppOrigin(window.location.origin);
} catch (err) {
logger.warn('Failed to persist the desktop app origin:', err);
}
}
function doneUrl(side: DomainMigrationSide, next: string, handoffId: string | null): string {
const handoff = handoffId === null ? '' : `h=${encodeURIComponent(handoffId)}&`;
return `${side.source}/migrate/done?${handoff}next=${encodeURIComponent(next)}`;
}
function failedUrl(side: DomainMigrationSide, reason: DomainMigrationFailureReason, next: string): string {
return `${side.source}/migrate/failed?reason=${reason}&next=${encodeURIComponent(next)}`;
}
async function beginOnTarget(side: DomainMigrationSide, params: URLSearchParams): Promise<boolean> {
const next = sanitizeNextPath(params.get('next'));
if (readDomainMigrationDiscovery()?.enabled !== true) {
return navigate(failedUrl(side, 'disabled', next));
}
const started = params.get('start') === '1';
if (await hasStoredAccount()) {
await persistDesktopAppOrigin();
return navigate(params.get('resume') === '1' || started ? `${side.target}${next}` : doneUrl(side, next, null));
}
if (started) {
return navigate(`${side.source}/migrate/start?next=${encodeURIComponent(next)}`);
}
const nonce = randomBase64Url(NONCE_BYTES);
const pending: PendingHandoff = {nonce, next, at: Date.now()};
getProtectedSessionStorage()?.setItem(DOMAIN_MIGRATION_PENDING_KEY, JSON.stringify(pending));
return navigate(`${side.source}/migrate/export?n=${nonce}`);
}
async function completeOnTarget(side: DomainMigrationSide): Promise<boolean> {
const fragment = new URLSearchParams(window.location.hash.slice(1));
window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}`);
const pending = takePendingHandoff();
if (pending === null || Date.now() - pending.at > DOMAIN_MIGRATION_PENDING_MAX_AGE_MS) {
return navigate(failedUrl(side, 'no_pending', DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
}
const next = sanitizeNextPath(pending.next);
const handoffId = fragment.get('h');
if (await hasStoredAccount()) {
await persistDesktopAppOrigin();
return navigate(doneUrl(side, next, handoffId));
}
try {
await importHandoff(side, fragment, pending.nonce);
} catch (err) {
const reason = err instanceof DomainMigrationImportError ? err.reason : 'import_failed';
logger.warn('Domain migration import failed:', err);
return navigate(failedUrl(side, reason, next));
}
return navigate(doneUrl(side, next, handoffId));
}
async function handleTarget(side: DomainMigrationSide): Promise<boolean> {
switch (window.location.pathname) {
case '/migrate/begin':
return beginOnTarget(side, new URLSearchParams(window.location.search));
case '/migrate/complete':
return completeOnTarget(side);
}
return false;
}
export async function runDomainMigrationPreMount(): Promise<boolean> {
if (typeof window === 'undefined') {
return false;
}
const side = resolveDomainMigrationSide(window.location.origin);
if (side === null) {
return false;
}
try {
return side.role === 'source' ? await handleSource(side) : await handleTarget(side);
} catch (err) {
logger.error('Domain migration pre-mount step failed:', err);
if (side.role === 'target' && window.location.pathname.startsWith('/migrate/')) {
return navigate(failedUrl(side, 'target_error', DOMAIN_MIGRATION_DEFAULT_NEXT_PATH));
}
return false;
}
}
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {readDomainMigrationAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
const logger = new Logger('DomainMigrationRollout');
class DomainMigrationRolloutSelector {
get enabled(): boolean {
try {
return readDomainMigrationAssignment(ExperimentAssignments.response).enabled;
} catch (err) {
logger.warn('Failed to resolve domain migration assignment:', err);
return false;
}
}
}
export const DomainMigrationRollout = new DomainMigrationRolloutSelector();
export default DomainMigrationRollout;

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