Compare commits

...
Author SHA1 Message Date
HampusandGitHub 01f53a168d feat(push): gate relay delivery on operator consent (#2984) 2026-09-27 20:33:10 +02:00
HampusandGitHub 336b8b7dcd fix(forward): make an @silent comment silence the forward too (#2983) 2026-09-27 20:13:14 +02:00
HampusandGitHub 48d0034239 fix(app-proxy): trust the Play app signing certificate (#2982) 2026-09-27 19:37:40 +02:00
HampusandGitHub 677ef8491e fix(desktop): back off failed app loads and offer a retry (#2980) 2026-09-27 16:18:01 +02:00
HampusandGitHub 6a6119ed1e fix(push): preview forwarded message content (#2979) 2026-09-27 13:33:22 +02:00
HampusandGitHub 931327d1dc fix(push): stop sending notifications for system messages (#2978) 2026-09-27 13:33:18 +02:00
HampusandGitHub 858a2d9e2b fix(oauth): stop granting scopes the user turned off (#2968) 2026-09-26 13:48:23 +02:00
HampusandGitHub 841fb7af41 feat(auth): migrate passkeys to fluxer.com (#2964) 2026-09-25 22:33:50 +02:00
HampusandGitHub 08e65d41c0 fix(api): clear the perks-sanitized latch when premium returns (#2963) 2026-09-25 20:13:00 +02:00
HampusandGitHub f76c4dc041 fix(api): cancel only the subscription the refund belongs to (#2962) 2026-09-25 20:10:54 +02:00
HampusandGitHub f1f8ba2031 fix(app): add copy link to link channel context menus (#2959) 2026-09-25 18:16:20 +02:00
HampusandGitHub 5ab8d745c0 fix(i18n): correct the fluxer.com migration translations (#2958) 2026-09-25 17:46:07 +02:00
HampusandGitHub ff62bc89a4 feat(app): add passkey popup bridge for password managers (#2957) 2026-09-25 17:43:19 +02:00
HampusandGitHub 838bbdb5ec fix(app): only start the domain migration when the app opens (#2956) 2026-09-25 16:44:58 +02:00
HampusandGitHub 1c36a59b2c feat(app): rework quick switcher ranking and show origin icons (#2953) 2026-09-25 13:59:25 +02:00
HampusandGitHub 6730a242db feat(web): prepare the fluxer.com domain migration (#2952) 2026-09-25 13:43:34 +02:00
HampusandGitHub e62ae77643 refactor(config): trim the default passkey origin list (#2951) 2026-09-25 13:42:02 +02:00
HampusandGitHub f4f39e6a89 feat(app): show where forward destinations come from (#2950) 2026-09-25 13:12:17 +02:00
HampusandGitHub 00bf74cef5 fix(app): handle swapped overwrites when comparing channels (#2949) 2026-09-24 23:38:04 +02:00
HampusandGitHub c1c45d835f fix(app): only parse markdown in rich embeds (#2948) 2026-09-24 22:50:34 +02:00
HampusandGitHub bbfe809bef fix(app): crop animated images on web with libwebp (#2947) 2026-09-24 22:45:53 +02:00
HampusandGitHub e0843ac4f5 fix(app): keep guild folder expansion state local (#2944) 2026-09-24 17:52:33 +02:00
HampusandGitHub 43741cdad8 fix(gateway): always trim the connect snapshot for guild connects (#2943) 2026-09-24 17:09:48 +02:00
HampusandGitHub b8e3807262 Revert "fix(push): deliver direct messages without holding them" (#2942) 2026-09-24 17:09:44 +02:00
HampusandGitHub 3304f01a84 chore(i18n): recompile uk error catalog (#2941) 2026-09-24 17:09:36 +02:00
fluxer-weblate[bot]andGitHub 2ba463235b chore(i18n): update translations from Weblate (#2909) 2026-09-24 16:25:26 +02:00
fluxer-weblate[bot]andGitHub 15136fed59 chore(i18n): update translations from Weblate (#2923) 2026-09-24 16:25:05 +02:00
HampusandGitHub 6013581dd9 fix(push): deliver direct messages without holding them (#2938) 2026-09-24 16:21:42 +02:00
HampusandGitHub 7a91f128e9 fix(app-proxy): drop link preview metadata on self-hosted (#2936) 2026-09-24 16:07:00 +02:00
HampusandGitHub 963ffc5550 feat(push): scope read clears to the enrolled cohort (#2935) 2026-09-24 15:15:45 +02:00
412 changed files with 46953 additions and 21626 deletions
+1
View File
@@ -32,6 +32,7 @@
/fluxer_docs/.astro/
/fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/
+22
View File
@@ -123,12 +123,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -142,12 +146,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -190,6 +198,9 @@ jobs:
- name: Check Rust dependencies
run: cargo deny --locked check -D warnings
- name: Check libfluxwebp dependencies
run: cargo deny --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml --config deny.toml --locked check licenses bans sources
- name: Check desktop native dependencies
run: tools/ci/check-desktop-native-workspaces.sh dependencies
@@ -242,6 +253,9 @@ jobs:
- name: Check formatting
run: cargo fmt --all -- --check
- name: Check formatting (libfluxwebp)
run: cargo fmt --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml -- --check
- name: Check formatting (desktop native workspaces)
run: tools/ci/check-desktop-native-workspaces.sh fmt
@@ -398,12 +412,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -417,12 +435,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
+1
View File
@@ -26,6 +26,7 @@
/fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/
+85 -5
View File
@@ -10525,6 +10525,7 @@
"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"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
"type": "object",
@@ -10953,6 +10954,7 @@
"gateway_rollout",
"voice_noise_suppression",
"push_service_delivery",
"domain_migration",
"experiment_delivery",
"registration",
"self_hosted",
@@ -11091,6 +11093,10 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
},
"domain_migration": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
},
"experiment_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
@@ -15184,6 +15190,26 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"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 +15225,8 @@
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
},
"relay_consent_accepted": {"type": "boolean"}
}
},
"VoiceNoiseSuppressionConfigUpdateRequest": {
@@ -15267,6 +15294,46 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"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 +15358,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 +15375,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 +15724,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,8 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub push_service_delivery: PushServiceDeliveryConfigResponse,
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
}
@@ -450,6 +452,7 @@ 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 VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
@@ -549,6 +552,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 +566,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 +585,54 @@ 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)]
@@ -696,6 +753,8 @@ 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 experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
}
@@ -1033,18 +1092,30 @@ 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 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 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_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_delivery)
.expect("serializable generated delivery config"),
@@ -1052,6 +1123,7 @@ mod tests {
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("DomainMigrationConfigResponse", domain_migration),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
@@ -1087,4 +1159,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!({})
);
}
}
+173 -16
View File
@@ -7,20 +7,21 @@ use crate::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
PremiumMode, PushServiceDeliveryConfigUpdateRequest, RegistrationMode,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
DeferredPhoneGateUpdateRequest, DomainMigrationConfigUpdateRequest,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode,
PushServiceDeliveryConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
},
},
config::AdminConfig,
@@ -211,6 +212,10 @@ 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_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 +501,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 +666,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 +680,49 @@ 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()
})
@@ -1601,6 +1649,115 @@ 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_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
@@ -2,7 +2,8 @@
use crate::{
api::types::{
AppPublicConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
@@ -149,6 +150,7 @@ 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))
(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 +1191,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 +1227,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 +1318,139 @@ 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 experiment_delivery_section(
base: &str,
csrf_token: &str,
@@ -1929,6 +2094,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 {
+91 -2
View File
@@ -415,7 +415,21 @@ 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
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
@@ -546,6 +560,14 @@ 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_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 +578,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 +600,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 +939,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 -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,
+1
View File
@@ -258,6 +258,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,13 @@ import {
PendingRegistrationActionRequest,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
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 +70,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gatewayRollout,
voiceNoiseSuppression,
pushServiceDelivery,
domainMigration,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -74,6 +80,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getPushServiceDeliveryConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -106,6 +113,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
push_service_delivery: pushServiceDelivery,
domain_migration: domainMigration,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -190,6 +198,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 +294,28 @@ 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,
}),
);
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,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;
@@ -1,18 +1,18 @@
{
"auth.unknown_location": "Ubicación desconocida",
"billing.donation_description_monthly": "Donación mensual para apoyar a {product_name}",
"billing.donation_description_one_time": "Donación única para apoyar a {product_name}",
"billing.donation_description_yearly": "Donación anual para apoyar a {product_name}",
"billing.donation_name_one_time": "Donación a {product_name}",
"billing.donation_name_recurring": "Donación recurrente a {product_name}",
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
"guild.bulk_create.unknown_error": "Error desconocido.",
"guild.default_category_text": "Canales de texto",
"guild.default_category_voice": "Canales de voz",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
"auth.unknown_location": "Ubicación desconocida",
"billing.donation_description_monthly": "Donación mensual para apoyar a {product_name}",
"billing.donation_description_one_time": "Donación única para apoyar a {product_name}",
"billing.donation_description_yearly": "Donación anual para apoyar a {product_name}",
"billing.donation_name_one_time": "Donación a {product_name}",
"billing.donation_name_recurring": "Donación recurrente a {product_name}",
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
"guild.bulk_create.unknown_error": "Error desconocido.",
"guild.default_category_text": "Canales de texto",
"guild.default_category_voice": "Canales de voz",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
}
@@ -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,7 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {entityTagMatches} from '@app/api/utils/EntityTag';
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
import {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 +29,10 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, voiceConfig] = await Promise.all([
const [delivery, voiceConfig, domainMigrationConfig] = await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
]);
const userId = ctx.get('user').id.toString();
const body: ExperimentAssignmentsResponse = {
@@ -38,6 +40,7 @@ export function ExperimentController(app: HonoApp) {
poll_jitter_percent: delivery.poll_jitter_percent,
assignments: {
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId),
},
};
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
@@ -6,6 +6,10 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {
DEFAULT_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 +19,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 +56,7 @@ 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,
},
});
});
@@ -82,6 +88,52 @@ 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('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 +248,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 +328,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,
@@ -84,6 +84,41 @@ describe('stripNonJpegImageMetadataForUpload', () => {
});
});
function riffChunk(type: string, data: Uint8Array): Uint8Array {
const out = new Uint8Array(8 + data.length + (data.length & 1));
out.set(textBytes(type), 0);
new DataView(out.buffer).setUint32(4, data.length, true);
out.set(data, 8);
return out;
}
function webp(chunks: ReadonlyArray<Uint8Array>): Uint8Array {
const body = concatBytes(chunks);
const header = concatBytes([textBytes('RIFF'), new Uint8Array(4), textBytes('WEBP')]);
new DataView(header.buffer).setUint32(4, 4 + body.length, true);
return concatBytes([header, body]);
}
describe('stripNonJpegImageMetadataForUpload for WebP', () => {
it('drops EXIF and XMP chunks without re-encoding frames', async () => {
const vp8x = new Uint8Array(10);
vp8x[0] = 0x02 | 0x08 | 0x04;
const anmf = riffChunk('ANMF', new Uint8Array([9, 8, 7]));
const input = webp([
riffChunk('VP8X', vp8x),
riffChunk('ANIM', new Uint8Array(6)),
anmf,
riffChunk('EXIF', textBytes('GPS=1,2')),
riffChunk('XMP ', textBytes('private metadata')),
]);
const stripped = await stripNonJpegImageMetadataForUpload(input, 'image/webp');
const expectedVp8x = new Uint8Array(10);
expectedVp8x[0] = 0x02;
expect(stripped.contentType).toBe('image/webp');
expect(stripped.body).toEqual(webp([riffChunk('VP8X', expectedVp8x), riffChunk('ANIM', new Uint8Array(6)), anmf]));
});
});
describe('buildProcessedMediaObject', () => {
it('leaves non-media objects for plain copy', async () => {
await expect(buildProcessedMediaObject(textBytes('plain text'), 'text/plain')).resolves.toBeNull();
@@ -162,6 +162,8 @@ export async function stripNonJpegImageMetadataForUpload(
contentType: normalizedContentType === 'image/apng' ? 'image/apng' : 'image/png',
};
}
const strippedWebp = isWebp(data) ? stripWebpMetadataChunks(data) : null;
if (strippedWebp) return {body: strippedWebp, contentType: 'image/webp'};
const image = sharp(data, {animated: true});
const metadata = await image.metadata();
switch (metadata.format) {
@@ -242,6 +244,50 @@ function stripPngMetadataChunks(data: Uint8Array): Uint8Array {
return output;
}
const WEBP_CHUNKS_TO_KEEP = new Set(['VP8 ', 'VP8L', 'VP8X', 'ALPH', 'ANIM', 'ANMF', 'ICCP']);
const WEBP_VP8X_EXIF_FLAG = 0x08;
const WEBP_VP8X_XMP_FLAG = 0x04;
function readFourCc(data: Uint8Array, offset: number): string {
return String.fromCharCode(data[offset]!, data[offset + 1]!, data[offset + 2]!, data[offset + 3]!);
}
function readU32LE(data: Uint8Array, offset: number): number {
return (data[offset]! | (data[offset + 1]! << 8) | (data[offset + 2]! << 16) | (data[offset + 3]! << 24)) >>> 0;
}
function isWebp(data: Uint8Array): boolean {
return data.length >= 12 && readFourCc(data, 0) === 'RIFF' && readFourCc(data, 8) === 'WEBP';
}
function stripWebpMetadataChunks(data: Uint8Array): Uint8Array | null {
const riffEnd = Math.min(data.length, 8 + readU32LE(data, 4));
const chunks: Array<Uint8Array> = [];
let offset = 12;
while (offset + 8 <= riffEnd) {
const length = readU32LE(data, offset + 4);
const chunkEnd = offset + 8 + length + (length & 1);
if (offset + 8 + length > riffEnd) return null;
const type = readFourCc(data, offset);
if (WEBP_CHUNKS_TO_KEEP.has(type)) {
const chunk = data.slice(offset, Math.min(chunkEnd, riffEnd));
if (type === 'VP8X' && length > 0) chunk[8] = (chunk[8] ?? 0) & ~(WEBP_VP8X_EXIF_FLAG | WEBP_VP8X_XMP_FLAG);
chunks.push(chunk);
}
offset = chunkEnd;
}
const bodyLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const output = new Uint8Array(12 + bodyLength);
output.set(data.subarray(0, 12));
new DataView(output.buffer).setUint32(4, 4 + bodyLength, true);
let cursor = 12;
for (const chunk of chunks) {
output.set(chunk, cursor);
cursor += chunk.length;
}
return output;
}
function imageExtensionForContentType(contentType: string): string {
if (contentType.includes('svg')) return 'svg';
if (contentType.includes('tiff')) return 'tiff';
@@ -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,10 @@ import {
type PendingRegistrationResponse,
type RegistrationUrlResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
type DomainMigrationConfig,
DomainMigrationConfigSchema,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
type GatewayRolloutConfig,
GatewayRolloutConfigSchema,
@@ -63,6 +67,7 @@ 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 EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
@@ -370,6 +375,7 @@ type StoredConfigSection =
| 'gateway rollout'
| 'voice noise suppression'
| 'push service delivery'
| 'domain migration'
| 'experiment delivery'
| 'instance policy'
| 'integrations'
@@ -512,6 +518,10 @@ 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 parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
}
@@ -1160,6 +1170,7 @@ export class InstanceConfigRepository {
);
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null);
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
checkStoredConfig('registration', () =>
@@ -1273,6 +1284,27 @@ 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 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();
@@ -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')},
+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
@@ -433,6 +433,7 @@ export default () => {
staticFilesPlugin({
staticCdnEndpoint: normalizedStaticCdnEndpoint,
fontsDir: path.join(MONOREPO_ROOT, 'packages', 'fonts'),
wasmCratesDir: path.join(ROOT_DIR, 'rust'),
}),
new DefinePlugin({
__FLUXER_PRECACHE_MANIFEST__: JSON.stringify([]),
@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, Alexandre Bury
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,32 @@
The auto-generated bindings are under the 3-clause BSD license:
BSD License
For Zstandard software
Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name Facebook, nor Meta, nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,30 @@
BSD License
For Zstandard software
Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name Facebook, nor Meta, nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+12
View File
@@ -0,0 +1,12 @@
# libfluxcore licenses
`libfluxcore_bg.wasm` is built from this crate and bundled into the app. It
contains third-party code that keeps its upstream license.
| Component | Source | License |
| --- | --- | --- |
| Zstandard 1.5.7 | Vendored by the `zstd-sys` 2.1.0 crate | BSD-3-Clause, see `LICENSE-ZSTD.txt` (Zstandard is dual licensed, Fluxer uses it under BSD-3-Clause) |
| `zstd-sys` 2.1.0 | Rust bindings, build script and WebAssembly libc shim | BSD-3-Clause, see `LICENSE-ZSTD-SYS.txt` |
| `zstd` 0.14.0 | Rust wrapper | BSD-3-Clause, see `LICENSE-ZSTD-RS.txt` |
No Fluxer license notice grants rights to third-party trademarks or brand names.
+68 -3
View File
@@ -16,6 +16,22 @@ pub fn is_animated_image_bytes(input: &[u8]) -> bool {
false
}
pub fn sniff_image_format_bytes(input: &[u8]) -> u8 {
if is_png(input) {
1
} else if is_gif(input) {
2
} else if is_webp(input) {
3
} else if is_avif_file(input) {
4
} else if input.starts_with(&[0xff, 0xd8, 0xff]) {
5
} else {
0
}
}
fn is_gif(input: &[u8]) -> bool {
input.starts_with(b"GIF89a") || input.starts_with(b"GIF87a")
}
@@ -35,7 +51,16 @@ fn is_avif_file(input: &[u8]) -> bool {
}
fn has_avif_anim(input: &[u8]) -> bool {
is_avif_file(input) && &input[8..12] == b"avis"
if !is_avif_file(input) {
return false;
}
if &input[8..12] == b"avis" {
return true;
}
let box_end = read_u32_be(input, 0).map_or(0, |size| (size as usize).min(input.len()));
input
.get(16..box_end)
.is_some_and(|brands| brands.as_chunks::<4>().0.contains(b"avis"))
}
fn has_apng_actl(input: &[u8]) -> bool {
@@ -243,13 +268,53 @@ mod tests {
#[test]
fn detects_avif_sequence_brand() {
let avif = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avis";
assert!(!is_animated_image_bytes(avif));
let still = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00mif1miaf";
assert!(!is_animated_image_bytes(still));
let avis = b"\x00\x00\x00\x18ftypavis\x00\x00\x00\x00avif";
assert!(is_animated_image_bytes(avis));
}
#[test]
fn detects_avis_among_compatible_brands() {
let compat = b"\x00\x00\x00\x1cftypavif\x00\x00\x00\x00mif1avismsf1";
assert!(is_animated_image_bytes(compat));
let outside_ftyp = b"\x00\x00\x00\x14ftypavif\x00\x00\x00\x00mif1avis";
assert!(!is_animated_image_bytes(outside_ftyp));
let truncated = b"\x00\x00\x00\x40ftypavif\x00\x00\x00\x00mif1av";
assert!(!is_animated_image_bytes(truncated));
}
#[test]
fn sniffs_image_formats_from_magic_bytes() {
assert_eq!(sniff_image_format_bytes(b""), 0);
assert_eq!(sniff_image_format_bytes(b"<svg xmlns"), 0);
assert_eq!(
sniff_image_format_bytes(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"),
1
);
assert_eq!(sniff_image_format_bytes(b"GIF89a\x01\x00"), 2);
assert_eq!(sniff_image_format_bytes(b"GIF87a\x01\x00"), 2);
assert_eq!(sniff_image_format_bytes(b"RIFF\x04\0\0\0WEBPVP8 "), 3);
assert_eq!(sniff_image_format_bytes(b"RIFF\x04\0\0\0WAVEfmt "), 0);
assert_eq!(
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00"),
4
);
assert_eq!(
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypavis\x00\x00\x00\x00"),
4
);
assert_eq!(
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00"),
0
);
assert_eq!(sniff_image_format_bytes(b"\xff\xd8\xff\xe0\x00\x10JFIF"), 5);
assert_eq!(sniff_image_format_bytes(b"\xff\xd8"), 0);
}
#[test]
fn rejects_truncated_chunks_without_panicking() {
assert!(!is_animated_image_bytes(
+40 -2
View File
@@ -5,8 +5,8 @@ mod rgba;
mod zstd_frame;
mod zstd_stream;
use formats::is_animated_image_bytes;
use rgba::{TransformRequest, crop_rotate_rgba_alloc};
use formats::{is_animated_image_bytes, sniff_image_format_bytes};
use rgba::{TransformRequest, crop_rotate_rgba_alloc, crop_rotate_rgba_into};
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
@@ -40,6 +40,39 @@ pub fn crop_rotate_rgba_raw(
.map_err(|error| JsValue::from_str(error.message()))
}
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn crop_rotate_rgba_into_buffer(
input: &[u8],
output: &mut [u8],
src_width: u32,
src_height: u32,
x: u32,
y: u32,
width: u32,
height: u32,
rotation_deg: u32,
output_width: u32,
output_height: u32,
) -> Result<(), JsValue> {
crop_rotate_rgba_into(
input,
output,
TransformRequest {
src_width,
src_height,
x,
y,
width,
height,
rotation_deg,
resize_width: output_width,
resize_height: output_height,
},
)
.map_err(|error| JsValue::from_str(error.message()))
}
#[wasm_bindgen]
pub fn decompress_zstd_frame(input: &[u8]) -> Result<Vec<u8>, JsValue> {
zstd_frame::decompress(input).map_err(zstd_error_to_js)
@@ -106,6 +139,11 @@ pub fn is_animated_image(input: &[u8]) -> bool {
is_animated_image_bytes(input)
}
#[wasm_bindgen]
pub fn sniff_image_format(input: &[u8]) -> u8 {
sniff_image_format_bytes(input)
}
fn optional_dimension_to_abi(value: Option<u32>) -> u32 {
value.filter(|dimension| *dimension > 0).unwrap_or(u32::MAX)
}
+65 -9
View File
@@ -22,6 +22,7 @@ pub struct TransformRequest {
pub enum TransformError {
InvalidDimensions,
InvalidRgbaLength,
InvalidOutputLength,
EmptyCrop,
EmptyTarget,
ImageTooLarge,
@@ -33,6 +34,7 @@ impl TransformError {
match self {
Self::InvalidDimensions => "invalid RGBA dimensions",
Self::InvalidRgbaLength => "RGBA input length does not match dimensions",
Self::InvalidOutputLength => "RGBA output length does not match target dimensions",
Self::EmptyCrop => "Crop area is empty",
Self::EmptyTarget => "Target dimensions are empty",
Self::ImageTooLarge => "Image is too large to crop",
@@ -75,12 +77,7 @@ pub fn crop_rotate_rgba_alloc(
input: &[u8],
request: TransformRequest,
) -> Result<Vec<u8>, TransformError> {
let geometry = output_geometry(request)?;
let expected_len = rgba_byte_len(request.src_width, request.src_height, 0)?;
if input.len() != expected_len {
return Err(TransformError::InvalidRgbaLength);
}
let geometry = checked_geometry(input, request)?;
let output_len = rgba_byte_len(
geometry.target_width,
geometry.target_height,
@@ -89,8 +86,45 @@ pub fn crop_rotate_rgba_alloc(
let mut output = try_zeroed_vec(output_len)?;
write_u32_le(&mut output, 0, geometry.target_width);
write_u32_le(&mut output, 4, geometry.target_height);
write_transformed(
input,
&mut output[RGBA_RESULT_HEADER_BYTES..],
request,
geometry,
);
Ok(output)
}
let dst = &mut output[RGBA_RESULT_HEADER_BYTES..];
pub fn crop_rotate_rgba_into(
input: &[u8],
output: &mut [u8],
request: TransformRequest,
) -> Result<(), TransformError> {
let geometry = checked_geometry(input, request)?;
if output.len() != rgba_byte_len(geometry.target_width, geometry.target_height, 0)? {
return Err(TransformError::InvalidOutputLength);
}
write_transformed(input, output, request, geometry);
Ok(())
}
fn checked_geometry(
input: &[u8],
request: TransformRequest,
) -> Result<OutputGeometry, TransformError> {
let geometry = output_geometry(request)?;
if input.len() != rgba_byte_len(request.src_width, request.src_height, 0)? {
return Err(TransformError::InvalidRgbaLength);
}
Ok(geometry)
}
fn write_transformed(
input: &[u8],
dst: &mut [u8],
request: TransformRequest,
geometry: OutputGeometry,
) {
if geometry.target_width == geometry.base_width
&& geometry.target_height == geometry.base_height
{
@@ -98,8 +132,6 @@ pub fn crop_rotate_rgba_alloc(
} else {
copy_rotated_with_nearest_resize(input, dst, request, geometry);
}
Ok(output)
}
fn output_geometry(request: TransformRequest) -> Result<OutputGeometry, TransformError> {
@@ -463,6 +495,30 @@ mod tests {
assert!(crop_rotate_rgba_alloc(&rgba(&[1, 2, 3, 4]), empty_target).is_ok());
}
#[test]
fn into_writes_the_same_pixels_as_alloc() {
let input = rgba(&[1, 2, 3, 4, 5, 6]);
for rotation_deg in [0, 90, 180, 270] {
let mut transform = request(2, 3);
transform.rotation_deg = rotation_deg;
transform.resize_width = 5;
transform.resize_height = 4;
let expected = crop_rotate_rgba_alloc(&input, transform).unwrap();
let mut output = vec![0xaa; 5 * 4 * RGBA_BYTES_PER_PIXEL];
crop_rotate_rgba_into(&input, &mut output, transform).unwrap();
assert_eq!(output, payload(&expected));
}
}
#[test]
fn into_rejects_an_output_of_the_wrong_length() {
let mut output = vec![0; 3 * RGBA_BYTES_PER_PIXEL];
assert_eq!(
crop_rotate_rgba_into(&rgba(&[1, 2, 3, 4]), &mut output, request(2, 2)).unwrap_err(),
TransformError::InvalidOutputLength
);
}
proptest! {
#[test]
fn identity_transform_preserves_pixels(width in 1u32..16, height in 1u32..16) {
+160
View File
@@ -0,0 +1,160 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "find-msvc-tools"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "libfluxwebp"
version = "0.1.0"
dependencies = [
"libwebp-sys",
"wasm-bindgen",
]
[[package]]
name = "libwebp-sys"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b3a87b44e34d17161e4f17d92a463d596cb13825dcd1758ed18fd3a721e189c"
dependencies = [
"cc",
"glob",
"pkg-config",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
dependencies = [
"unicode-ident",
]
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "libfluxwebp"
version = "0.1.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "=0.2.128"
libwebp-sys = {version = "=0.14.4", default-features = false}
[profile.release]
codegen-units = 1
lto = true
opt-level = "z"
panic = "abort"
strip = true
[workspace]
@@ -0,0 +1,21 @@
MIT License
Copyright (c) the libwebp-sys authors (XianYou, Kornel Lesiński and contributors)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,30 @@
Copyright (c) 2010, Google Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of Google nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+16
View File
@@ -0,0 +1,16 @@
# libfluxwebp licenses
`libfluxwebp_bg.wasm` and `libfluxwebp_simd_bg.wasm` are built from this crate
and bundled into the app. They contain third-party code that keeps its upstream
license.
| Component | Source | License |
| --- | --- | --- |
| libwebp 1.6.0 | Vendored by the `libwebp-sys` 0.14.4 crate | BSD-3-Clause, see `LICENSE-LIBWEBP.txt`, with the additional patent grant in `PATENTS-LIBWEBP.txt` |
| `libwebp-sys` 0.14.4 | Rust bindings and build script | MIT, see `LICENSE-LIBWEBP-SYS.txt` |
| `simd/xmmintrin.h`, `simd/emmintrin.h` | Emscripten 4.0.15 SSE compatibility headers | MIT or University of Illinois/NCSA, see `simd/LICENSE` |
The SIMD build compiles libwebp's SSE2 code paths through the Emscripten
headers, unmodified. The headers in `shim/` and `src/shim.rs` are Fluxer code.
No Fluxer license notice grants rights to third-party trademarks or brand names.
@@ -0,0 +1,23 @@
Additional IP Rights Grant (Patents)
------------------------------------
"These implementations" means the copyrightable works that implement the WebM
codecs distributed by Google as part of the WebM Project.
Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable (except as stated in this section) patent license to
make, have made, use, offer to sell, sell, import, transfer, and otherwise
run, modify and propagate the contents of these implementations of WebM, where
such license applies only to those patent claims, both currently owned by
Google and acquired in the future, licensable by Google that are necessarily
infringed by these implementations of WebM. This grant does not include claims
that would be infringed only as a consequence of further modification of these
implementations. If you or your agent or exclusive licensee institute or order
or agree to the institution of patent litigation or any other patent
enforcement activity against any entity (including a cross-claim or
counterclaim in a lawsuit) alleging that any of these implementations of WebM
or any code incorporated within any of these implementations of WebM
constitute direct or contributory patent infringement, or inducement of
patent infringement, then any patent rights granted to you under this License
for these implementations of WebM shall terminate as of the date such
litigation is filed.
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#ifndef FLUXWEBP_ASSERT_H
#define FLUXWEBP_ASSERT_H
#define assert(expr) ((void)0)
#endif
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#ifndef FLUXWEBP_INTTYPES_H
#define FLUXWEBP_INTTYPES_H
#include <stdint.h>
#endif
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#ifndef FLUXWEBP_MATH_H
#define FLUXWEBP_MATH_H
double fluxwebp_shim_pow(double x, double y);
double fluxwebp_shim_log(double x);
float fluxwebp_shim_expf(float x);
double fluxwebp_shim_log10(double x);
float fluxwebp_shim_logf(float x);
double fluxwebp_shim_round(double x);
#define pow(x, y) fluxwebp_shim_pow(x, y)
#define log(x) fluxwebp_shim_log(x)
#define expf(x) fluxwebp_shim_expf(x)
#define log10(x) fluxwebp_shim_log10(x)
#define logf(x) fluxwebp_shim_logf(x)
#define round(x) fluxwebp_shim_round(x)
#define fabs(x) __builtin_fabs(x)
#define floor(x) __builtin_floor(x)
#define ceil(x) __builtin_ceil(x)
#define sqrt(x) __builtin_sqrt(x)
#define sqrtf(x) __builtin_sqrtf(x)
#define rint(x) __builtin_rint(x)
#define rintf(x) __builtin_rintf(x)
#define fabsf(x) __builtin_fabsf(x)
#define floorf(x) __builtin_floorf(x)
#define ceilf(x) __builtin_ceilf(x)
#define isnan(x) __builtin_isnan(x)
#define isinf(x) __builtin_isinf(x)
#define lrint(x) ((long)__builtin_rint(x))
#define llrint(x) ((long long)__builtin_rint(x))
#define lrintf(x) ((long)__builtin_rintf(x))
#define llrintf(x) ((long long)__builtin_rintf(x))
#endif
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#ifndef FLUXWEBP_STDIO_H
#define FLUXWEBP_STDIO_H
#include <stddef.h>
#include <stdarg.h>
typedef struct FluxWebpFile FILE;
#define stderr ((FILE*)0)
#define stdout ((FILE*)0)
#define fprintf(f, ...) ((void)0)
#define printf(...) ((void)0)
#define fflush(f) ((void)0)
static inline void fluxwebp_shim_put(char* buf, size_t cap, size_t* n, char c) {
if (*n + 1 < cap) buf[*n] = c;
(*n)++;
}
static inline int snprintf(char* buf, size_t cap, const char* fmt, ...) {
va_list ap;
size_t n = 0;
va_start(ap, fmt);
for (; *fmt; fmt++) {
if (*fmt != '%') {
fluxwebp_shim_put(buf, cap, &n, *fmt);
continue;
}
fmt++;
if (*fmt == 's') {
const char* s = va_arg(ap, const char*);
while (s && *s) fluxwebp_shim_put(buf, cap, &n, *s++);
} else if (*fmt == 'd') {
int v = va_arg(ap, int);
char tmp[12];
int i = 0;
unsigned int u = v < 0 ? 0u - (unsigned int)v : (unsigned int)v;
if (v < 0) fluxwebp_shim_put(buf, cap, &n, '-');
do { tmp[i++] = (char)('0' + u % 10); u /= 10; } while (u);
while (i) fluxwebp_shim_put(buf, cap, &n, tmp[--i]);
} else if (*fmt == '%') {
fluxwebp_shim_put(buf, cap, &n, '%');
} else {
break;
}
}
va_end(ap);
if (cap) buf[n < cap ? n : cap - 1] = '\0';
return (int)n;
}
#endif
+16
View File
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#ifndef FLUXWEBP_STDLIB_H
#define FLUXWEBP_STDLIB_H
#include <stddef.h>
void* fluxwebp_shim_malloc(size_t size);
void* fluxwebp_shim_calloc(size_t nmemb, size_t size);
void fluxwebp_shim_free(void* ptr);
void fluxwebp_shim_qsort(void* base, size_t nitems, size_t size, int (*compar)(const void*, const void*));
#define malloc(size) fluxwebp_shim_malloc(size)
#define calloc(nmemb, size) fluxwebp_shim_calloc(nmemb, size)
#define free(ptr) fluxwebp_shim_free(ptr)
#define qsort(base, nitems, size, compar) fluxwebp_shim_qsort(base, nitems, size, compar)
#define abort() __builtin_trap()
static inline int abs(int x) { return x < 0 ? -x : x; }
#endif
+11
View File
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#ifndef FLUXWEBP_STRING_H
#define FLUXWEBP_STRING_H
#include <stddef.h>
#define memcpy(d, s, n) __builtin_memcpy(d, s, n)
#define memmove(d, s, n) __builtin_memmove(d, s, n)
#define memset(d, c, n) __builtin_memset(d, c, n)
#define memcmp(a, b, n) __builtin_memcmp(a, b, n)
#define strlen(s) __builtin_strlen(s)
#endif
+102
View File
@@ -0,0 +1,102 @@
Emscripten is available under 2 licenses, the MIT license and the
University of Illinois/NCSA Open Source License.
Both are permissive open source licenses, with little if any
practical difference between them.
The reason for offering both is that (1) the MIT license is
well-known, while (2) the University of Illinois/NCSA Open Source
License allows Emscripten's code to be integrated upstream into
LLVM, which uses that license, should the opportunity arise.
The full text of both licenses follows.
==============================================================================
Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
==============================================================================
Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file.
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal with the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimers.
Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimers
in the documentation and/or other materials provided with the
distribution.
Neither the names of Mozilla,
nor the names of its contributors may be used to endorse
or promote products derived from this Software without specific prior
written permission.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR
ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE.
==============================================================================
This program uses portions of Node.js source code located in src/library_path.js,
in accordance with the terms of the MIT license. Node's license follows:
"""
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
"""
The musl libc project is bundled in this repo, and it has the MIT license, see
system/lib/libc/musl/COPYRIGHT
The third_party/ subdirectory contains code with other licenses. None of it is
used by default, but certain options use it (e.g., the optional closure compiler
flag will run closure compiler from third_party/).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,714 @@
/*
* Copyright 2020 The Emscripten Authors. All rights reserved.
* Emscripten is available under two separate licenses, the MIT license and the
* University of Illinois/NCSA Open Source License. Both these licenses can be
* found in the LICENSE file.
*/
#ifndef __emscripten_xmmintrin_h__
#define __emscripten_xmmintrin_h__
#include <wasm_simd128.h>
#include <limits.h>
#include <math.h>
#include <string.h>
#ifndef __SSE__
#error "SSE instruction set not enabled"
#endif
#ifdef WASM_SIMD_COMPAT_SLOW
#define DIAGNOSE_SLOW diagnose_if(1, "Instruction emulated via slow path.", "warning")
#else
#define DIAGNOSE_SLOW
#endif
// Emscripten SIMD support doesn't support MMX/float32x2/__m64.
// However, we support loading and storing 2-vectors, so
// recognize the type at least.
typedef float __m64 __attribute__((__vector_size__(8), __aligned__(8)));
typedef __f32x4 __m128;
typedef v128_t __m128i;
#define __f32x4_shuffle(__a, __b, __c0, __c1, __c2, __c3) \
((v128_t)(__builtin_shufflevector((__f32x4)(__a), (__f32x4)(__b), __c0, \
__c1, __c2, __c3)))
// This is defined as a macro because __builtin_shufflevector requires its
// mask argument to be a compile-time constant.
#define _mm_shuffle_ps(__a, __b, __mask) __extension__ ({ \
((__m128)__f32x4_shuffle(__a, __b, \
(((__mask) >> 0) & 0x3) + 0, \
(((__mask) >> 2) & 0x3) + 0, \
(((__mask) >> 4) & 0x3) + 4, \
(((__mask) >> 6) & 0x3) + 4)); })
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_set_ps(float __z, float __y, float __x, float __w)
{
return (__m128)wasm_f32x4_make(__w, __x, __y, __z);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_setr_ps(float __z, float __y, float __x, float __w)
{
return (__m128)wasm_f32x4_make(__z, __y, __x, __w);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_set_ss(float __w)
{
return (__m128)wasm_f32x4_make(__w, 0, 0, 0);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_set_ps1(float __w)
{
return (__m128)wasm_f32x4_splat(__w);
}
#define _mm_set1_ps _mm_set_ps1
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_setzero_ps(void)
{
return (__m128)wasm_f32x4_const(0.f, 0.f, 0.f, 0.f);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_load_ps(const float *__p)
{
return *(__m128*)__p;
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_loadl_pi(__m128 __a, const void /*__m64*/ *__p)
{
return (__m128)wasm_v128_load64_lane(__p, (v128_t)__a, 0);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_loadh_pi(__m128 __a, const void /*__m64*/ *__p)
{
return (__m128)wasm_v128_load64_lane(__p, (v128_t)__a, 1);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_loadr_ps(const float *__p)
{
__m128 __v = _mm_load_ps(__p);
return (__m128)__f32x4_shuffle(__v, __v, 3, 2, 1, 0);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_loadu_ps(const float *__p)
{
return (__m128)wasm_v128_load(__p);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_load_ps1(const float *__p)
{
return (__m128)wasm_v32x4_load_splat(__p);
}
#define _mm_load1_ps _mm_load_ps1
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_load_ss(const float *__p)
{
return (__m128)wasm_v128_load32_zero(__p);
}
static __inline__ void __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_storel_pi(__m64 *__p, __m128 __a)
{
wasm_v128_store64_lane((void*)__p, (v128_t)__a, 0);
}
static __inline__ void __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_storeh_pi(__m64 *__p, __m128 __a)
{
wasm_v128_store64_lane((void*)__p, (v128_t)__a, 1);
}
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_store_ps(float *__p, __m128 __a)
{
*(__m128 *)__p = __a;
}
// No NTA cache hint available.
#define _mm_stream_ps _mm_store_ps
#define _MM_HINT_T0 3
#define _MM_HINT_T1 2
#define _MM_HINT_T2 1
#define _MM_HINT_NTA 0
// No prefetch available, dummy it out.
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_prefetch(const void *__p, int __i)
{
((void)__p);
((void)__i);
}
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_sfence(void)
{
// Wasm/SharedArrayBuffer memory model is sequentially consistent.
// Perhaps a future version of the spec can provide a related fence.
__sync_synchronize();
}
#define _MM_SHUFFLE(w, z, y, x) (((w) << 6) | ((z) << 4) | ((y) << 2) | (x))
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_storer_ps(float *__p, __m128 __a)
{
_mm_store_ps(__p, _mm_shuffle_ps(__a, __a, _MM_SHUFFLE(0, 1, 2, 3)));
}
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_store_ps1(float *__p, __m128 __a)
{
_mm_store_ps(__p, _mm_shuffle_ps(__a, __a, _MM_SHUFFLE(0, 0, 0, 0)));
}
#define _mm_store1_ps _mm_store_ps1
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_store_ss(float *__p, __m128 __a)
{
wasm_v128_store32_lane((void*)__p, (v128_t)__a, 0);
}
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_storeu_ps(float *__p, __m128 __a)
{
struct __unaligned {
__m128 __v;
} __attribute__((__packed__, __may_alias__));
((struct __unaligned *)__p)->__v = __a;
}
static __inline__ int __attribute__((__always_inline__, __nodebug__))
_mm_movemask_ps(__m128 __a)
{
return (int)wasm_i32x4_bitmask((v128_t)__a);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_move_ss(__m128 __a, __m128 __b)
{
return (__m128)__f32x4_shuffle(__a, __b, 4, 1, 2, 3);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_add_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_add((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_add_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_add_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_sub_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_sub((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_sub_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_sub_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_mul_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_mul((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_mul_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_mul_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_div_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_div((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_div_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_div_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_min_ps(__m128 __a, __m128 __b)
{
// return (__m128)wasm_f32x4_pmin((v128_t)__a, (v128_t)__b); // TODO: Migrate to this, once it works in VMs
return (__m128)wasm_v128_bitselect((v128_t)__a, (v128_t)__b, (v128_t)wasm_f32x4_lt((v128_t)__a, (v128_t)__b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_min_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_min_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_max_ps(__m128 __a, __m128 __b)
{
// return (__m128)wasm_f32x4_pmax((v128_t)__a, (v128_t)__b); // TODO: Migrate to this, once it works in VMs
return (__m128)wasm_v128_bitselect((v128_t)__a, (v128_t)__b, (v128_t)wasm_f32x4_gt((v128_t)__a, (v128_t)__b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_max_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_max_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_rcp_ps(__m128 __a)
{
return (__m128)wasm_f32x4_div((v128_t)_mm_set1_ps(1.0f), (v128_t)__a);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_rcp_ss(__m128 __a)
{
return _mm_move_ss(__a, _mm_rcp_ps(__a));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_sqrt_ps(__m128 __a)
{
return (__m128)wasm_f32x4_sqrt((v128_t)__a);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_sqrt_ss(__m128 __a)
{
return _mm_move_ss(__a, _mm_sqrt_ps(__a));
}
#define _mm_rsqrt_ps(__a) _mm_rcp_ps(_mm_sqrt_ps((__a)))
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_rsqrt_ss(__m128 __a)
{
return _mm_move_ss(__a, _mm_rsqrt_ps(__a));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_unpackhi_ps(__m128 __a, __m128 __b)
{
return (__m128)__f32x4_shuffle(__a, __b, 2, 6, 3, 7);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_unpacklo_ps(__m128 __a, __m128 __b)
{
return (__m128)__f32x4_shuffle(__a, __b, 0, 4, 1, 5);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_movehl_ps(__m128 __a, __m128 __b)
{
return (__m128)__f32x4_shuffle(__a, __b, 6, 7, 2, 3);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_movelh_ps(__m128 __a, __m128 __b)
{
return (__m128)__f32x4_shuffle(__a, __b, 0, 1, 4, 5);
}
#define _MM_TRANSPOSE4_PS(row0, row1, row2, row3) \
do { \
__m128 __row0 = (row0); \
__m128 __row1 = (row1); \
__m128 __row2 = (row2); \
__m128 __row3 = (row3); \
__m128 __tmp0 = _mm_unpacklo_ps(__row0, __row1); \
__m128 __tmp1 = _mm_unpackhi_ps(__row0, __row1); \
__m128 __tmp2 = _mm_unpacklo_ps(__row2, __row3); \
__m128 __tmp3 = _mm_unpackhi_ps(__row2, __row3); \
(row0) = _mm_movelh_ps(__tmp0, __tmp2); \
(row1) = _mm_movehl_ps(__tmp2, __tmp0); \
(row2) = _mm_movelh_ps(__tmp1, __tmp3); \
(row3) = _mm_movehl_ps(__tmp3, __tmp1); \
} while (0)
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmplt_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_lt((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmplt_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmplt_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmple_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_le((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmple_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmple_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpeq_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_eq((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpeq_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpeq_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpge_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_ge((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpge_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpge_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpgt_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_gt((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpgt_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpgt_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cmpord_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_and(wasm_f32x4_eq((v128_t)__a, (v128_t)__a),
wasm_f32x4_eq((v128_t)__b, (v128_t)__b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cmpord_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpord_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cmpunord_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_or(wasm_f32x4_ne((v128_t)__a, (v128_t)__a),
wasm_f32x4_ne((v128_t)__b, (v128_t)__b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cmpunord_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpunord_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_and_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_and((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_andnot_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_andnot((v128_t)__b, (v128_t)__a);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_or_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_or((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_xor_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_xor((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpneq_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_ne((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpneq_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpneq_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpnge_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_not((v128_t)_mm_cmpge_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpnge_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpnge_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpngt_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_not((v128_t)_mm_cmpgt_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpngt_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpngt_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpnle_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_not((v128_t)_mm_cmple_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpnle_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpnle_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpnlt_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_not((v128_t)_mm_cmplt_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpnlt_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpnlt_ps(__a, __b));
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_comieq_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) == wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_comige_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) >= wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_comigt_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) > wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_comile_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) <= wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_comilt_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) < wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_comineq_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) != wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_ucomieq_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) == wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_ucomige_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) >= wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_ucomigt_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) > wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_ucomile_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) <= wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_ucomilt_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) < wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_ucomineq_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) != wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_cvtsi32_ss(__m128 __a, int __b)
{
__f32x4 __v = (__f32x4)__a;
__v[0] = (float)__b;
return (__m128)__v;
}
#define _mm_cvt_si2ss _mm_cvtsi32_ss
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cvtss_si32(__m128 __a)
{
float e = ((__f32x4)__a)[0];
if (e < 2147483648.0f && e >= -2147483648.0f && (lrint(e) != 0 || fabsf(e) < 2.f))
return lrint(e);
else
return (int)0x80000000;
}
#define _mm_cvt_ss2si _mm_cvtss_si32
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cvttss_si32(__m128 __a)
{
float e = ((__f32x4)__a)[0];
if (e < 2147483648.0f && e >= -2147483648.0f && (lrint(e) != 0 || fabsf(e) < 2.f))
return (int)e;
else
return (int)0x80000000;
}
#define _mm_cvtt_ss2si _mm_cvttss_si32
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_cvtsi64_ss(__m128 __a, long long __b)
{
__f32x4 __v = (__f32x4)__a;
__v[0] = (float)__b;
return (__m128)__v;
}
static __inline__ long long __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_cvtss_si64(__m128 __a)
{
float e = ((__f32x4)__a)[0];
long long x = llrintf(e);
if (e <= LLONG_MAX && e >= LLONG_MIN && (x != 0 || fabsf(e) < 2.f))
return x;
else
return 0x8000000000000000LL;
}
static __inline__ long long __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_cvttss_si64(__m128 __a)
{
float e = ((__f32x4)__a)[0];
long long x = llrintf(e);
if (e <= LLONG_MAX && e >= LLONG_MIN && (x != 0 || fabsf(e) < 2.f))
return (long long)e;
else
return 0x8000000000000000LL;
}
static __inline__ float __attribute__((__always_inline__, __nodebug__))
_mm_cvtss_f32(__m128 __a)
{
return (float)((__f32x4)__a)[0];
}
#define _mm_malloc(__size, __align) memalign((__align), (__size))
#define _mm_free free
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_undefined()
{
__m128 val;
return val;
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_undefined_ps()
{
__m128 val;
return val;
}
#define _MM_EXCEPT_MASK 0x003f
#define _MM_EXCEPT_INVALID 0x0001
#define _MM_EXCEPT_DENORM 0x0002
#define _MM_EXCEPT_DIV_ZERO 0x0004
#define _MM_EXCEPT_OVERFLOW 0x0008
#define _MM_EXCEPT_UNDERFLOW 0x0010
#define _MM_EXCEPT_INEXACT 0x0020
#define _MM_MASK_MASK 0x1f80
#define _MM_MASK_INVALID 0x0080
#define _MM_MASK_DENORM 0x0100
#define _MM_MASK_DIV_ZERO 0x0200
#define _MM_MASK_OVERFLOW 0x0400
#define _MM_MASK_UNDERFLOW 0x0800
#define _MM_MASK_INEXACT 0x1000
#define _MM_ROUND_MASK 0x6000
#define _MM_ROUND_NEAREST 0x0000
#define _MM_ROUND_DOWN 0x2000
#define _MM_ROUND_UP 0x4000
#define _MM_ROUND_TOWARD_ZERO 0x6000
#define _MM_FLUSH_ZERO_MASK 0x8000
#define _MM_FLUSH_ZERO_ON 0x8000
#define _MM_FLUSH_ZERO_OFF 0x0000
static __inline__ int __attribute__((__always_inline__, __nodebug__))
_mm_getcsr()
{
return _MM_MASK_INEXACT | _MM_MASK_DENORM | _MM_MASK_DIV_ZERO | _MM_MASK_OVERFLOW | _MM_MASK_UNDERFLOW | _MM_MASK_INVALID
| _MM_ROUND_NEAREST | _MM_FLUSH_ZERO_OFF;
}
#define _MM_GET_EXCEPTION_STATE() (_mm_getcsr() & _MM_EXCEPT_MASK)
#define _MM_GET_EXCEPTION_MASK() (_mm_getcsr() & _MM_MASK_MASK)
#define _MM_GET_ROUNDING_MODE() (_mm_getcsr() & _MM_ROUND_MASK)
#define _MM_GET_FLUSH_ZERO_MODE() (_mm_getcsr() & _MM_FLUSH_ZERO_MASK)
// Unavailable functions:
// void _MM_SET_EXCEPTION_STATE(unsigned int __a);
// void _MM_SET_EXCEPTION_MASK(unsigned int __a);
// void _MM_GET_ROUNDING_MODE(unsigned int __a);
// void _MM_GET_FLUSH_ZERO_MODE(unsigned int __a);
#endif /* __emscripten_xmmintrin_h__ */
+327
View File
@@ -0,0 +1,327 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
mod shim;
use libwebp_sys as ffi;
use std::ffi::CStr;
use std::ptr;
use wasm_bindgen::prelude::*;
const DEMUX_ABI: i32 = ffi::WEBP_DEMUX_ABI_VERSION as i32;
const MUX_ABI: i32 = ffi::WEBP_MUX_ABI_VERSION as i32;
const ENCODER_ABI: i32 = ffi::WEBP_ENCODER_ABI_VERSION as i32;
const DECODER_ABI: i32 = ffi::WEBP_DECODER_ABI_VERSION as i32;
const LOSSLESS_FORMAT: i32 = 2;
fn webp_data(bytes: &[u8]) -> ffi::WebPData {
ffi::WebPData {
bytes: bytes.as_ptr(),
size: bytes.len(),
}
}
#[wasm_bindgen]
pub fn webp_probe(bytes: &[u8]) -> Result<Box<[u32]>, JsError> {
let data = webp_data(bytes);
let dmux = unsafe { ffi::WebPDemuxInternal(&data, 0, ptr::null_mut(), DEMUX_ABI) };
if dmux.is_null() {
return Err(JsError::new("webp demux failed"));
}
let get = |feature| unsafe { ffi::WebPDemuxGetI(dmux, feature) };
let width = get(ffi::WebPFormatFeature::WEBP_FF_CANVAS_WIDTH);
let height = get(ffi::WebPFormatFeature::WEBP_FF_CANVAS_HEIGHT);
let frame_count = get(ffi::WebPFormatFeature::WEBP_FF_FRAME_COUNT);
let loop_count = get(ffi::WebPFormatFeature::WEBP_FF_LOOP_COUNT);
let flags = get(ffi::WebPFormatFeature::WEBP_FF_FORMAT_FLAGS);
let has_alpha = flags & ffi::WebPFeatureFlags::ALPHA_FLAG as u32 != 0;
let all_lossless = unsafe { all_frames_lossless(dmux) };
unsafe { ffi::WebPDemuxDelete(dmux) };
Ok(Box::new([
width,
height,
frame_count,
loop_count,
has_alpha as u32,
all_lossless as u32,
]))
}
unsafe fn all_frames_lossless(dmux: *const ffi::WebPDemuxer) -> bool {
unsafe {
let mut iter: ffi::WebPIterator = std::mem::zeroed();
if ffi::WebPDemuxGetFrame(dmux, 1, &mut iter) == 0 {
return false;
}
let mut lossless = true;
loop {
let mut features: ffi::WebPBitstreamFeatures = std::mem::zeroed();
let status = ffi::WebPGetFeaturesInternal(
iter.fragment.bytes,
iter.fragment.size,
&mut features,
DECODER_ABI,
);
if status != ffi::VP8StatusCode::VP8_STATUS_OK || features.format != LOSSLESS_FORMAT {
lossless = false;
break;
}
if ffi::WebPDemuxNextFrame(&mut iter) == 0 {
break;
}
}
ffi::WebPDemuxReleaseIterator(&mut iter);
lossless
}
}
#[wasm_bindgen]
pub struct AnimDecoder {
dec: *mut ffi::WebPAnimDecoder,
_bytes: Box<[u8]>,
frame: *mut u8,
timestamp: i32,
}
#[wasm_bindgen]
impl AnimDecoder {
#[wasm_bindgen(constructor)]
pub fn new(bytes: Box<[u8]>) -> Result<AnimDecoder, JsError> {
let dec = unsafe {
let mut options: ffi::WebPAnimDecoderOptions = std::mem::zeroed();
if ffi::WebPAnimDecoderOptionsInitInternal(&mut options, DEMUX_ABI) == 0 {
return Err(JsError::new("webp decoder options init failed"));
}
options.color_mode = ffi::WEBP_CSP_MODE::MODE_RGBA;
options.use_threads = 0;
let data = webp_data(&bytes);
ffi::WebPAnimDecoderNewInternal(&data, &options, DEMUX_ABI)
};
if dec.is_null() {
return Err(JsError::new("webp decoder init failed"));
}
Ok(AnimDecoder {
dec,
_bytes: bytes,
frame: ptr::null_mut(),
timestamp: 0,
})
}
#[wasm_bindgen(js_name = next)]
pub fn next_frame(&mut self) -> Result<bool, JsError> {
if unsafe { ffi::WebPAnimDecoderHasMoreFrames(self.dec) } == 0 {
return Ok(false);
}
let mut frame = ptr::null_mut();
let mut timestamp = 0;
if unsafe { ffi::WebPAnimDecoderGetNext(self.dec, &mut frame, &mut timestamp) } == 0 {
return Err(JsError::new("webp frame decode failed"));
}
self.frame = frame;
self.timestamp = timestamp;
Ok(true)
}
#[wasm_bindgen(getter)]
pub fn frame_ptr(&self) -> u32 {
self.frame as usize as u32
}
#[wasm_bindgen(getter)]
pub fn end_timestamp(&self) -> i32 {
self.timestamp
}
}
impl Drop for AnimDecoder {
fn drop(&mut self) {
unsafe { ffi::WebPAnimDecoderDelete(self.dec) };
}
}
#[wasm_bindgen]
pub struct AnimEncoder {
enc: *mut ffi::WebPAnimEncoder,
width: u32,
height: u32,
lossless: bool,
}
#[wasm_bindgen]
impl AnimEncoder {
#[wasm_bindgen(constructor)]
pub fn new(
width: u32,
height: u32,
loop_count: u32,
lossless: bool,
) -> Result<AnimEncoder, JsError> {
let (kmin, kmax) = if lossless { (9, 17) } else { (4, 5) };
let enc = unsafe {
let mut options: ffi::WebPAnimEncoderOptions = std::mem::zeroed();
if ffi::WebPAnimEncoderOptionsInitInternal(&mut options, MUX_ABI) == 0 {
return Err(JsError::new("webp encoder options init failed"));
}
options.anim_params.loop_count = loop_count.min(i32::MAX as u32) as i32;
options.anim_params.bgcolor = 0;
options.minimize_size = 0;
options.allow_mixed = 0;
options.kmin = kmin;
options.kmax = kmax;
ffi::WebPAnimEncoderNewInternal(width as i32, height as i32, &options, MUX_ABI)
};
if enc.is_null() {
return Err(JsError::new("webp encoder init failed"));
}
Ok(AnimEncoder {
enc,
width,
height,
lossless,
})
}
pub fn add(
&mut self,
rgba: &[u8],
start_ms: i32,
quality: f32,
method: i32,
) -> Result<(), JsError> {
if rgba.len() as u64 != u64::from(self.width) * u64::from(self.height) * 4 {
return Err(JsError::new("webp frame size mismatch"));
}
let added = unsafe {
let mut config: ffi::WebPConfig = std::mem::zeroed();
if ffi::WebPConfigInitInternal(
&mut config,
ffi::WebPPreset::WEBP_PRESET_DEFAULT,
quality,
ENCODER_ABI,
) == 0
{
return Err(JsError::new("webp config init failed"));
}
config.quality = quality;
config.method = method;
config.lossless = self.lossless as i32;
config.thread_level = 0;
config.alpha_quality = 100;
config.exact = 0;
config.use_sharp_yuv = 0;
let mut picture: ffi::WebPPicture = std::mem::zeroed();
if ffi::WebPPictureInitInternal(&mut picture, ENCODER_ABI) == 0 {
return Err(JsError::new("webp picture init failed"));
}
picture.width = self.width as i32;
picture.height = self.height as i32;
picture.use_argb = 1;
if ffi::WebPPictureImportRGBA(&mut picture, rgba.as_ptr(), self.width as i32 * 4) == 0 {
ffi::WebPPictureFree(&mut picture);
return Err(JsError::new("webp picture import failed"));
}
let added = ffi::WebPAnimEncoderAdd(self.enc, &mut picture, start_ms, &config);
ffi::WebPPictureFree(&mut picture);
added
};
if added == 0 {
return Err(self.error("webp frame encode failed"));
}
Ok(())
}
pub fn finish(&mut self, end_ms: i32) -> Result<Vec<u8>, JsError> {
if unsafe { ffi::WebPAnimEncoderAdd(self.enc, ptr::null_mut(), end_ms, ptr::null()) } == 0 {
return Err(self.error("webp encoder flush failed"));
}
let mut data = ffi::WebPData::default();
if unsafe { ffi::WebPAnimEncoderAssemble(self.enc, &mut data) } == 0 {
return Err(self.error("webp assemble failed"));
}
let out = unsafe { std::slice::from_raw_parts(data.bytes, data.size) }.to_vec();
unsafe { ffi::WebPFree(data.bytes as *mut _) };
Ok(out)
}
fn error(&self, what: &str) -> JsError {
let message = unsafe { ffi::WebPAnimEncoderGetError(self.enc) };
if message.is_null() {
return JsError::new(what);
}
let detail = unsafe { CStr::from_ptr(message) }.to_string_lossy();
JsError::new(&format!("{what}: {detail}"))
}
}
impl Drop for AnimEncoder {
fn drop(&mut self) {
unsafe { ffi::WebPAnimEncoderDelete(self.enc) };
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(width: u32, height: u32, seed: u8) -> Vec<u8> {
(0..width * height)
.flat_map(|index| {
let value = (index as u8).wrapping_mul(seed);
[
value,
seed,
value ^ seed,
if index % 3 == 0 { 0 } else { 255 },
]
})
.collect()
}
fn encode(lossless: bool) -> Vec<u8> {
let mut encoder = AnimEncoder::new(16, 8, 3, lossless).expect("encoder");
let delays = [40, 70, 10];
let mut start = 0;
for (index, delay) in delays.iter().enumerate() {
encoder
.add(&frame(16, 8, index as u8 + 1), start, 75.0, 4)
.expect("add");
start += delay;
}
encoder.finish(start).expect("finish")
}
#[test]
fn probe_reports_canvas_frames_loop_alpha_and_lossless() {
let lossless = encode(true);
assert_eq!(
&*webp_probe(&lossless).expect("probe"),
&[16, 8, 3, 3, 1, 1]
);
let lossy = encode(false);
let probe = webp_probe(&lossy).expect("probe");
assert_eq!(&probe[..5], &[16, 8, 3, 3, 1]);
assert_eq!(probe[5], 0);
}
#[test]
fn lossless_round_trip_keeps_pixels_and_end_timestamps() {
let bytes = encode(true);
let mut decoder = AnimDecoder::new(bytes.into_boxed_slice()).expect("decoder");
let mut ends = Vec::new();
let mut index = 0u8;
while decoder.next_frame().expect("next") {
let pixels = unsafe { std::slice::from_raw_parts(decoder.frame, 16 * 8 * 4) };
let expected = frame(16, 8, index + 1);
for (got, want) in pixels.chunks(4).zip(expected.chunks(4)) {
assert_eq!(got[3], want[3]);
if want[3] != 0 {
assert_eq!(got, want);
}
}
ends.push(decoder.end_timestamp());
index += 1;
}
assert_eq!(ends, [40, 110, 120]);
}
}
+110
View File
@@ -0,0 +1,110 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::alloc::{Layout, alloc, alloc_zeroed, dealloc};
use std::ffi::{c_int, c_void};
const ALIGN: usize = 16;
unsafe fn shim_alloc(size: usize, zeroed: bool) -> *mut c_void {
let Some(full) = size.checked_add(ALIGN) else {
return std::ptr::null_mut();
};
let Ok(layout) = Layout::from_size_align(full, ALIGN) else {
return std::ptr::null_mut();
};
unsafe {
let ptr = if zeroed {
alloc_zeroed(layout)
} else {
alloc(layout)
};
if ptr.is_null() {
return std::ptr::null_mut();
}
ptr.cast::<usize>().write(full);
ptr.add(ALIGN).cast()
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fluxwebp_shim_malloc(size: usize) -> *mut c_void {
unsafe { shim_alloc(size, false) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fluxwebp_shim_calloc(nmemb: usize, size: usize) -> *mut c_void {
match nmemb.checked_mul(size) {
Some(total) => unsafe { shim_alloc(total, true) },
None => std::ptr::null_mut(),
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fluxwebp_shim_free(ptr: *mut c_void) {
if ptr.is_null() {
return;
}
unsafe {
let base = ptr.cast::<u8>().sub(ALIGN);
let full = base.cast::<usize>().read();
dealloc(base, Layout::from_size_align_unchecked(full, ALIGN));
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fluxwebp_shim_qsort(
base: *mut c_void,
n: usize,
size: usize,
compar: unsafe extern "C" fn(*const c_void, *const c_void) -> c_int,
) {
if n < 2 || size == 0 {
return;
}
unsafe {
let bytes = std::slice::from_raw_parts_mut(base.cast::<u8>(), n * size);
let copy = bytes.to_vec();
let mut idx: Vec<usize> = (0..n).collect();
idx.sort_by(|&a, &b| {
let r = compar(
copy.as_ptr().add(a * size).cast(),
copy.as_ptr().add(b * size).cast(),
);
r.cmp(&0)
});
for (dst, &src) in idx.iter().enumerate() {
bytes[dst * size..dst * size + size]
.copy_from_slice(&copy[src * size..src * size + size]);
}
}
}
#[unsafe(no_mangle)]
pub extern "C" fn fluxwebp_shim_pow(x: f64, y: f64) -> f64 {
x.powf(y)
}
#[unsafe(no_mangle)]
pub extern "C" fn fluxwebp_shim_log(x: f64) -> f64 {
x.ln()
}
#[unsafe(no_mangle)]
pub extern "C" fn fluxwebp_shim_log10(x: f64) -> f64 {
x.log10()
}
#[unsafe(no_mangle)]
pub extern "C" fn fluxwebp_shim_expf(x: f32) -> f32 {
x.exp()
}
#[unsafe(no_mangle)]
pub extern "C" fn fluxwebp_shim_logf(x: f32) -> f32 {
x.ln()
}
#[unsafe(no_mangle)]
pub extern "C" fn fluxwebp_shim_round(x: f64) -> f64 {
x.round()
}
@@ -11,6 +11,18 @@ const FONT_LICENSE_FILES = [
{source: 'LICENSE-IBM-PLEX.txt', asset: 'assets/fonts-LICENSE-IBM-PLEX.txt'},
];
const WASM_LICENSE_FILES = [
{source: 'libfluxcore/NOTICE.md', asset: 'assets/libfluxcore-NOTICE.txt'},
{source: 'libfluxcore/LICENSE-ZSTD.txt', asset: 'assets/libfluxcore-LICENSE-ZSTD.txt'},
{source: 'libfluxcore/LICENSE-ZSTD-SYS.txt', asset: 'assets/libfluxcore-LICENSE-ZSTD-SYS.txt'},
{source: 'libfluxcore/LICENSE-ZSTD-RS.txt', asset: 'assets/libfluxcore-LICENSE-ZSTD-RS.txt'},
{source: 'libfluxwebp/NOTICE.md', asset: 'assets/libfluxwebp-NOTICE.txt'},
{source: 'libfluxwebp/LICENSE-LIBWEBP.txt', asset: 'assets/libfluxwebp-LICENSE-LIBWEBP.txt'},
{source: 'libfluxwebp/PATENTS-LIBWEBP.txt', asset: 'assets/libfluxwebp-PATENTS-LIBWEBP.txt'},
{source: 'libfluxwebp/LICENSE-LIBWEBP-SYS.txt', asset: 'assets/libfluxwebp-LICENSE-LIBWEBP-SYS.txt'},
{source: 'libfluxwebp/simd/LICENSE', asset: 'assets/libfluxwebp-LICENSE-EMSCRIPTEN.txt'},
];
function resolveStaticCdnEndpoint(staticCdnEndpoint) {
const value = staticCdnEndpoint?.trim().replace(/\/+$/, '');
return value || STATIC_CDN_ENDPOINT_PLACEHOLDER;
@@ -23,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',
@@ -31,6 +44,7 @@ function generateManifest(staticCdnEndpoint) {
categories: ['social', 'communication'],
lang: 'en',
scope: '/',
scope_extensions: [],
icons: [
{
src: `${cdn}/web/android-chrome-192x192.png`,
@@ -86,17 +100,18 @@ export class StaticFilesPlugin {
constructor(options = {}) {
this.staticCdnEndpoint = options.staticCdnEndpoint;
this.fontsDir = options.fontsDir;
this.wasmCratesDir = options.wasmCratesDir;
}
emitFontLicenses(compilation) {
if (!this.fontsDir) {
emitLicenseFiles(compilation, dir, files, subject) {
if (!dir) {
return;
}
for (const {source, asset} of FONT_LICENSE_FILES) {
const sourcePath = path.join(this.fontsDir, source);
for (const {source, asset} of files) {
const sourcePath = path.join(dir, source);
if (!fs.existsSync(sourcePath)) {
throw new Error(
`StaticFilesPlugin: ${sourcePath} is missing. The bundled fonts may not be redistributed without it.`,
`StaticFilesPlugin: ${sourcePath} is missing. The bundled ${subject} may not be redistributed without it.`,
);
}
compilation.emitAsset(asset, new sources.RawSource(fs.readFileSync(sourcePath)));
@@ -117,7 +132,8 @@ export class StaticFilesPlugin {
new sources.RawSource(generateBrowserConfig(this.staticCdnEndpoint)),
);
compilation.emitAsset('robots.txt', new sources.RawSource(generateRobotsTxt()));
this.emitFontLicenses(compilation);
this.emitLicenseFiles(compilation, this.fontsDir, FONT_LICENSE_FILES, 'fonts');
this.emitLicenseFiles(compilation, this.wasmCratesDir, WASM_LICENSE_FILES, 'WebAssembly modules');
},
);
});
+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)),
[],
);
}

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