Compare commits

...
Author SHA1 Message Date
HampusandGitHub eaee820216 feat(experiments): target rollouts by guild and premium status (#3012) 2026-09-28 14:34:19 +02:00
HampusandGitHub 564c5ae164 feat(profile): move profile timezone from staff to an experiment (#3011) 2026-09-28 12:57:26 +02:00
HampusandGitHub dd8ed6f205 fix(app): react at once when picking a +: autocomplete emoji (#3010) 2026-09-28 12:30:45 +02:00
HampusandGitHub ed8c412415 perf(gateway): make channel moves cheap in large guilds (#3008) 2026-09-28 02:07:47 +02:00
HampusandGitHub 12417a6942 fix(app): keep the caret after inserted emoji (#3007) 2026-09-28 01:56:08 +02:00
HampusandGitHub d05f6c9aaa fix(gateway): push held users whose sessions end during grace (#3006) 2026-09-28 01:47:55 +02:00
HampusandGitHub 0ca035c547 fix(messages): accept null version on legacy message rows (#3004) 2026-09-28 01:10:52 +02:00
HampusandGitHub dfd46ccc2c ci(gateway): drop cached gateway build output before compiling (#3003) 2026-09-28 01:08:52 +02:00
HampusandGitHub f6df3169ca fix(app): use +:shortcode: for reactions, no space before emoji (#3001) 2026-09-28 00:48:23 +02:00
HampusandGitHub 5b280898c5 refactor(push): retire the push service delivery experiment (#3000) 2026-09-28 00:45:22 +02:00
HampusandGitHub 2a9e25c788 fix(dev): drop the stray -- from the tunnel public URL hint (#2999) 2026-09-28 00:43:32 +02:00
HampusandGitHub 463c03fb6d feat(app): make +emoji react on send and target replies (#2998) 2026-09-28 00:08:40 +02:00
HampusandGitHub 153dad11e1 feat(installer): let upgrades copy the uploads uncompressed (#2995) 2026-09-27 23:51:24 +02:00
HampusandGitHub e2d05a44a8 fix(push): stop retrying relay rate limit refusals (#2993) 2026-09-27 23:29:27 +02:00
HampusandGitHub 30ba55bd4d fix(gateway): parse push relay hosts as binaries (#2989) 2026-09-27 21:22:45 +02:00
HampusandGitHub 9def9fbef6 feat(api): accept CIDR ranges in FLUXER_API_IP_BAN_EXEMPT_IPS (#2988) 2026-09-27 21:19:35 +02:00
HampusandGitHub fa3fd0027c fix(i18n): translate the push relay notice strings (#2987) 2026-09-27 21:15:33 +02:00
HampusandGitHub 7e1b934637 feat(captcha): add ALTCHA proof-of-work captcha experiment (#2986) 2026-09-27 21:02:55 +02:00
HampusandGitHub 33a118d12a docs(readme): list the Google Play beta first for Android (#2985) 2026-09-27 20:49:39 +02:00
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
HampusandGitHub a90991612c fix(gateway): truncate reads on an expired outbox entry (#2934) 2026-09-24 15:04:24 +02:00
HampusandGitHub 50ad23b760 fix(api): run the notification extension on every iOS alert (#2933) 2026-09-24 15:04:01 +02:00
HampusandGitHub 425dab983b fix(push): restore iOS avatars and stop misrouting relay endpoints (#2932) 2026-09-24 15:03:32 +02:00
539 changed files with 60271 additions and 36164 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/
+25
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
@@ -322,6 +336,9 @@ jobs:
restore-keys: |
rebar3-${{ runner.os }}-otp28-rebar3.27.0-
- name: Drop restored gateway build output
run: rm -rf fluxer_gateway/_build/default/lib/fluxer_gateway fluxer_gateway/_build/test/lib/fluxer_gateway
- name: Check formatting
run: |
"$FLUXER_CI_BIN" ci --step gateway_fmt
@@ -398,12 +415,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 +438,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/
+4 -3
View File
@@ -33,9 +33,9 @@ Fluxer is a free and open source instant messaging and VoIP chat app built for f
| Windows | macOS | Linux | Android | iOS |
| --- | --- | --- | --- | --- |
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [APK][android-apk] | [TestFlight][ios-testflight] |
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [Obtainium][obtainium] | |
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | | |
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [Google Play (beta)][android-play] | [TestFlight][ios-testflight] |
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [APK (beta)][android-apk] | |
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | [Obtainium (beta)][obtainium] | |
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
| | | [AppImage (x64)][linux-appimage-x64] | | |
@@ -168,6 +168,7 @@ endorsement rights.
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
[flatpak-canary-ref]: https://pkgs.fluxer.com/flatpak/fluxer-canary.flatpakref
[flathub]: https://flathub.org/apps/app.fluxer.Fluxer
[android-play]: https://play.google.com/store/apps/details?id=com.fluxer
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
[obtainium]: https://obtainium.imranr.dev/
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
+225 -9
View File
@@ -10524,7 +10524,10 @@
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
"type": "object",
@@ -10952,7 +10955,10 @@
"sso",
"gateway_rollout",
"voice_noise_suppression",
"push_service_delivery",
"push_relay",
"domain_migration",
"altcha_captcha",
"profile_timezone",
"experiment_delivery",
"registration",
"self_hosted",
@@ -11087,9 +11093,18 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
},
"push_service_delivery": {
"push_relay": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PushRelayConfigUpdateRequest"}]},
"domain_migration": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
},
"altcha_captcha": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
},
"profile_timezone": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ProfileTimezoneConfigUpdateRequest"}]
},
"experiment_delivery": {
"nullable": true,
@@ -15184,7 +15199,7 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"PushServiceDeliveryConfigUpdateRequest": {
"ProfileTimezoneConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
@@ -15195,6 +15210,12 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
@@ -15202,6 +15223,60 @@
}
}
},
"AltchaCaptchaConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_enabled": {"type": "boolean"},
"cost": {"type": "integer", "minimum": 1000, "maximum": 100000},
"max_counter": {"type": "integer", "minimum": 100, "maximum": 1000000}
}
},
"DomainMigrationConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"standalone_forwarding": {"type": "boolean"}
}
},
"PushRelayConfigUpdateRequest": {"type": "object", "properties": {"relay_consent_accepted": {"type": "boolean"}}},
"VoiceNoiseSuppressionConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15220,6 +15295,12 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
@@ -15267,14 +15348,14 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"additionalProperties": false
},
"PushServiceDeliveryConfigResponse": {
"ProfileTimezoneConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "push-service-delivery-v1",
"default": "profile-timezone-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
@@ -15286,6 +15367,13 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
@@ -15299,10 +15387,128 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids"
],
"additionalProperties": false
},
"AltchaCaptchaConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "altcha-captcha-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_enabled": {"default": false, "type": "boolean"},
"cost": {"default": 5000, "type": "integer", "minimum": 1000, "maximum": 100000},
"max_counter": {"default": 10000, "type": "integer", "minimum": 100, "maximum": 1000000}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"anonymous_enabled",
"cost",
"max_counter"
],
"additionalProperties": false
},
"DomainMigrationConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "domain-migration-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"standalone_forwarding": {"default": false, "type": "boolean"}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"anonymous_rollout_basis_points",
"standalone_forwarding"
],
"additionalProperties": false
},
"PushRelayConfigResponse": {
"type": "object",
"properties": {
"relay_consent_accepted": {"default": false, "type": "boolean"},
"relay_consent_accepted_at": {
"default": null,
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"relay_consent_accepted_by": {"default": null, "nullable": true, "type": "string", "pattern": "^\\d{1,20}$"}
},
"required": ["relay_consent_accepted", "relay_consent_accepted_at", "relay_consent_accepted_by"],
"additionalProperties": false
},
"VoiceNoiseSuppressionConfigResponse": {
"type": "object",
"properties": {
@@ -15327,6 +15533,13 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
@@ -15358,6 +15571,8 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"guild_overrides",
"suppression_strength"
@@ -15645,9 +15860,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": {
+227 -7
View File
@@ -23,7 +23,13 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
#[serde(default)]
pub push_service_delivery: PushServiceDeliveryConfigResponse,
pub push_relay: PushRelayConfigResponse,
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub altcha_captcha: AltchaCaptchaConfigResponse,
#[serde(default)]
pub profile_timezone: ProfileTimezoneConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
}
@@ -449,7 +455,11 @@ impl VoiceE2eeScope {
}
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1";
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const ALTCHA_CAPTCHA_DEFAULT_SALT: &str = "altcha-captcha-v1";
pub const ALTCHA_CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=100_000;
pub const ALTCHA_CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=1_000_000;
pub const PROFILE_TIMEZONE_DEFAULT_SALT: &str = "profile-timezone-v1";
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
@@ -493,6 +503,8 @@ pub struct VoiceNoiseSuppressionConfigResponse {
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub suppression_strength: u32,
@@ -509,6 +521,8 @@ impl Default for VoiceNoiseSuppressionConfigResponse {
rollout_basis_points: 0,
rollout_salt: "voice-ns-v1".to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
suppression_strength: 80,
@@ -533,6 +547,10 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
@@ -540,32 +558,54 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
pub suppression_strength: Option<u32>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct PushRelayConfigResponse {
pub relay_consent_accepted: bool,
pub relay_consent_accepted_at: Option<String>,
pub relay_consent_accepted_by: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PushRelayConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub relay_consent_accepted: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct PushServiceDeliveryConfigResponse {
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 included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub anonymous_rollout_basis_points: u32,
pub standalone_forwarding: bool,
}
impl Default for PushServiceDeliveryConfigResponse {
impl Default for DomainMigrationConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_rollout_basis_points: 0,
standalone_forwarding: false,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PushServiceDeliveryConfigUpdateRequest {
pub struct DomainMigrationConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -575,6 +615,118 @@ pub struct PushServiceDeliveryConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct AltchaCaptchaConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub anonymous_enabled: bool,
pub cost: u32,
pub max_counter: u32,
}
impl Default for AltchaCaptchaConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_enabled: false,
cost: 5_000,
max_counter: 10_000,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct AltchaCaptchaConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_counter: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ProfileTimezoneConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
}
impl Default for ProfileTimezoneConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ProfileTimezoneConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
@@ -694,7 +846,13 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
pub push_relay: Option<PushRelayConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_timezone: Option<ProfileTimezoneConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
}
@@ -1033,18 +1191,54 @@ mod tests {
.expect("admin schema");
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
.expect("default noise config");
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
.expect("default domain migration config");
let altcha_captcha = serde_json::from_value::<AltchaCaptchaConfigResponse>(json!({}))
.expect("default altcha captcha config");
let profile_timezone = serde_json::from_value::<ProfileTimezoneConfigResponse>(json!({}))
.expect("default profile timezone config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let noise = serde_json::to_value(noise).expect("serializable noise config");
let domain_migration =
serde_json::to_value(domain_migration).expect("serializable domain migration config");
let altcha_captcha =
serde_json::to_value(altcha_captcha).expect("serializable altcha captcha config");
let profile_timezone =
serde_json::to_value(profile_timezone).expect("serializable profile timezone config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
serde_json::from_value(noise.clone()).expect("generated noise config contract");
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
serde_json::from_value(domain_migration.clone())
.expect("generated domain migration config contract");
let generated_altcha_captcha: generated_types::AltchaCaptchaConfigResponse =
serde_json::from_value(altcha_captcha.clone())
.expect("generated altcha captcha config contract");
let generated_profile_timezone: generated_types::ProfileTimezoneConfigResponse =
serde_json::from_value(profile_timezone.clone())
.expect("generated profile timezone config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
assert_eq!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
);
assert_eq!(
serde_json::to_value(generated_domain_migration)
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_altcha_captcha)
.expect("serializable generated altcha captcha config"),
altcha_captcha
);
assert_eq!(
serde_json::to_value(generated_profile_timezone)
.expect("serializable generated profile timezone config"),
profile_timezone
);
assert_eq!(
serde_json::to_value(generated_delivery)
.expect("serializable generated delivery config"),
@@ -1052,6 +1246,9 @@ mod tests {
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("DomainMigrationConfigResponse", domain_migration),
("AltchaCaptchaConfigResponse", altcha_captcha),
("ProfileTimezoneConfigResponse", profile_timezone),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
@@ -1087,4 +1284,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!({})
);
}
}
+397 -16
View File
@@ -4,10 +4,12 @@ use crate::{
api::{
client::AdminApiClient,
types::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
AltchaCaptchaConfigUpdateRequest, AppBrandingConfigUpdateRequest,
AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest,
AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest,
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
@@ -18,8 +20,8 @@ use crate::{
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
PremiumMode, PushServiceDeliveryConfigUpdateRequest, RegistrationMode,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
PremiumMode, ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest,
RegistrationMode, SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
},
},
@@ -207,7 +209,19 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_push_service_delivery" => match build_push_service_delivery_update(&form) {
"update_push_relay" => {
let update = build_push_relay_update(&form);
instance_config_result(client.update_instance_config(&update).await)
}
"update_domain_migration" => match build_domain_migration_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_altcha_captcha" => match build_altcha_captcha_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_profile_timezone" => match build_profile_timezone_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
@@ -496,7 +510,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> {
@@ -629,6 +643,12 @@ fn build_voice_noise_suppression_update(
form.first("voice_ns_included_user_ids").unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("voice_ns_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
"Excluded user IDs",
@@ -648,30 +668,141 @@ fn build_voice_noise_suppression_update(
})
}
fn build_push_service_delivery_update(
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
push_relay: Some(PushRelayConfigUpdateRequest {
relay_consent_accepted: Some(form.bool_value("push_relay_consent_accepted")),
}),
..Default::default()
}
}
fn build_domain_migration_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
push_service_delivery: Some(PushServiceDeliveryConfigUpdateRequest {
enabled: Some(form.bool_value("push_service_delivery_enabled")),
domain_migration: Some(DomainMigrationConfigUpdateRequest {
enabled: Some(form.bool_value("domain_migration_enabled")),
rollout_basis_points: parse_form_number(
form,
"push_service_delivery_rollout_basis_points",
"domain_migration_rollout_basis_points",
"Rollout basis points",
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",
"domain_migration_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_included_user_ids")
form.first("domain_migration_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("domain_migration_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_excluded_user_ids")
form.first("domain_migration_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
anonymous_rollout_basis_points: parse_form_number(
form,
"domain_migration_anonymous_rollout_basis_points",
"Anonymous rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
standalone_forwarding: Some(form.bool_value("domain_migration_standalone_forwarding")),
}),
..Default::default()
})
}
fn build_altcha_captcha_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
altcha_captcha: Some(AltchaCaptchaConfigUpdateRequest {
enabled: Some(form.bool_value("altcha_captcha_enabled")),
rollout_basis_points: parse_form_number(
form,
"altcha_captcha_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("altcha_captcha_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
anonymous_enabled: Some(form.bool_value("altcha_captcha_anonymous_enabled")),
cost: parse_form_number(
form,
"altcha_captcha_cost",
"Cost",
*ALTCHA_CAPTCHA_COST_RANGE.start(),
*ALTCHA_CAPTCHA_COST_RANGE.end(),
)?,
max_counter: parse_form_number(
form,
"altcha_captcha_max_counter",
"Maximum counter",
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start(),
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end(),
)?,
}),
..Default::default()
})
}
fn build_profile_timezone_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
profile_timezone: Some(ProfileTimezoneConfigUpdateRequest {
enabled: Some(form.bool_value("profile_timezone_enabled")),
rollout_basis_points: parse_form_number(
form,
"profile_timezone_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"profile_timezone_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("profile_timezone_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
@@ -1324,6 +1455,8 @@ mod tests {
"allow_user_override": false,
"enabled_backends": [],
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"guild_overrides": [],
}})
@@ -1601,6 +1734,254 @@ 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": [],
"included_guild_ids": [],
"include_premium_users": false,
"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_relay_update_reads_the_consent_checkbox() {
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
assert_eq!(
serde_json::to_value(&unchecked).expect("serialize update"),
serde_json::json!({"push_relay": {"relay_consent_accepted": false}})
);
let checked = build_push_relay_update(&MultiValueForm::parse(
b"_csrf=token&push_relay_consent_accepted=true",
));
assert_eq!(
serde_json::to_value(&checked).expect("serialize update"),
serde_json::json!({"push_relay": {"relay_consent_accepted": true}})
);
}
#[test]
fn build_altcha_captcha_update_reads_the_rollout_and_difficulty_fields() {
let form = MultiValueForm::parse(
b"altcha_captcha_enabled=true&altcha_captcha_rollout_basis_points=%20500%20&altcha_captcha_rollout_salt=%20altcha-captcha-v2%20&altcha_captcha_included_user_ids=1500000000000000001&altcha_captcha_excluded_user_ids=1500000000000000002&altcha_captcha_anonymous_enabled=true&altcha_captcha_cost=2000&altcha_captcha_max_counter=%20400%20",
);
let update = build_altcha_captcha_update(&form)
.expect("valid form")
.altcha_captcha
.expect("altcha captcha update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("altcha-captcha-v2".to_owned()));
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
assert_eq!(update.anonymous_enabled, Some(true));
assert_eq!(update.cost, Some(2000));
assert_eq!(update.max_counter, Some(400));
}
#[test]
fn build_altcha_captcha_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_altcha_captcha_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"altcha_captcha": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"anonymous_enabled": false,
}})
);
}
#[test]
fn build_altcha_captcha_update_rejects_difficulty_outside_the_supported_range() {
for (form, message) in [
(
"altcha_captcha_cost=999",
"Cost must be a whole number between 1000 and 100000",
),
(
"altcha_captcha_max_counter=1000001",
"Maximum counter must be a whole number between 100 and 1000000",
),
(
"altcha_captcha_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_altcha_captcha_update(&form).expect_err("invalid field"),
message
);
}
}
#[test]
fn build_profile_timezone_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"profile_timezone_enabled=true&profile_timezone_rollout_basis_points=%20500%20&profile_timezone_rollout_salt=%20profile-timezone-v2%20&profile_timezone_included_user_ids=1500000000000000001&profile_timezone_excluded_user_ids=1500000000000000002&profile_timezone_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&profile_timezone_include_premium_users=true",
);
let update = build_profile_timezone_update(&form)
.expect("valid form")
.profile_timezone
.expect("profile timezone update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("profile-timezone-v2".to_owned()));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(
update.included_guild_ids,
Some(vec![
"1500000000000000005".to_owned(),
"1500000000000000006".to_owned()
])
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
}
#[test]
fn build_profile_timezone_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_profile_timezone_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"profile_timezone": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
}})
);
}
#[test]
fn every_experiment_update_rejects_an_invalid_included_guild_id() {
for (prefix, build) in [
(
"voice_ns",
build_voice_noise_suppression_update
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
),
("domain_migration", build_domain_migration_update),
("altcha_captcha", build_altcha_captcha_update),
("profile_timezone", build_profile_timezone_update),
] {
let form = MultiValueForm::parse(
format!("{prefix}_included_guild_ids=1500000000000000005%0Anot-a-guild").as_bytes(),
);
assert_eq!(
build(&form).expect_err("invalid guild id"),
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
"{prefix}"
);
}
}
#[test]
fn build_profile_timezone_update_rejects_a_rollout_above_everybody() {
let form = MultiValueForm::parse(b"profile_timezone_rollout_basis_points=10001");
assert_eq!(
build_profile_timezone_update(&form).expect_err("invalid field"),
"Rollout basis points must be a whole number between 0 and 10000"
);
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
@@ -2,12 +2,15 @@
use crate::{
api::types::{
AppPublicConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
PendingRegistrationResponse, PushServiceDeliveryConfigResponse, RegistrationUrlResponse,
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_DEFAULT_SALT, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
AltchaCaptchaConfigResponse, AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT,
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
InstanceRegistrationResponse, LimitConfigResponse, NoiseSuppressionBackend,
PROFILE_TIMEZONE_DEFAULT_SALT, PendingRegistrationResponse, ProfileTimezoneConfigResponse,
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -135,6 +138,13 @@ pub fn instance_config_page(
(integrations_config_section(base, csrf_token, &instance_config.integrations))
},
))
(config_group(
"Push notifications",
"Consent for the relay that delivers official mobile app notifications.",
html! {
(push_relay_section(base, csrf_token, &instance_config.push_relay))
},
))
(config_group(
"Media & retention",
"Attachment expiry rules that can be changed without editing environment variables.",
@@ -148,7 +158,9 @@ pub fn instance_config_page(
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
(push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
(altcha_captcha_section(base, csrf_token, &instance_config.altcha_captcha))
(profile_timezone_section(base, csrf_token, &instance_config.profile_timezone))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -1115,6 +1127,38 @@ fn voice_noise_suppression_section(
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"voice_ns_include_premium_users",
"true",
"Include premium users",
voice_noise_suppression.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&voice_noise_suppression.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
voice_noise_suppression.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_excluded_user_ids",
@@ -1177,70 +1221,163 @@ fn voice_noise_suppression_section(
)
}
fn push_service_delivery_section(
fn push_relay_section(
base: &str,
csrf_token: &str,
push_service_delivery: &PushServiceDeliveryConfigResponse,
push_relay: &PushRelayConfigResponse,
) -> Markup {
let status = if push_service_delivery.enabled {
let status = if push_relay.relay_consent_accepted {
("Accepted", BadgeVariant::Success)
} else {
("Not accepted", BadgeVariant::Default)
};
let accepted_at =
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
let accepted_by = push_relay
.relay_consent_accepted_by
.as_deref()
.unwrap_or("Nobody");
section_card_with_description(
"Push Relay",
"Official mobile app notifications travel through Fluxer's relay to Apple and Google. \
The relay delivers them only after an operator accepts its privacy notice.",
html! {
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Relay consent" }
(badge(status.0, status.1))
}
(checkbox(
"push_relay_consent_accepted",
"true",
"Accept the push relay supplemental privacy notice",
push_relay.relay_consent_accepted,
true,
))
p class="text-xs text-neutral-500" {
"Until this is accepted official mobile app notifications are dropped. \
Self-hosted UnifiedPush and ntfy endpoints never reach the relay and are \
unaffected. "
a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer"
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
"Read the notice"
}
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(form_field_group("Accepted at", "push_relay_consent_accepted_at", false, None, None,
html! {
input type="text" id="push_relay_consent_accepted_at"
value=(accepted_at)
disabled class=(FORM_INPUT_CLASS);
},
))
(form_field_group("Accepted by user ID", "push_relay_consent_accepted_by", false, None, None,
html! {
input type="text" id="push_relay_consent_accepted_by"
value=(accepted_by)
disabled class=(FORM_INPUT_CLASS);
},
))
}
(form_actions(html! {
(submit_button("Save Push Relay Settings"))
}))
}
}
},
)
}
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 = push_service_delivery.included_user_ids.join("\n");
let excluded_user_ids = push_service_delivery.excluded_user_ids.join("\n");
let included_user_ids = domain_migration.included_user_ids.join("\n");
let excluded_user_ids = domain_migration.excluded_user_ids.join("\n");
section_card_with_description(
"Push Service Delivery",
"Routes push notification delivery for the selected accounts through the push service. \
Accounts the rollout does not select keep the current path.",
"Domain Migration",
"Moves web clients of the official instance from the legacy web app origin to the new \
one. Selected accounts copy their local data across and continue on the new origin. \
Clients of other instances read this configuration and ignore it.",
html! {
form method="post" action={(base) "/instance-config?action=update_push_service_delivery"} {
form method="post" action={(base) "/instance-config?action=update_domain_migration"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (push_service_delivery.config_version)
"Config version " (domain_migration.config_version)
}
}
(checkbox(
"push_service_delivery_enabled",
"domain_migration_enabled",
"true",
"Hand push notifications to the push service",
push_service_delivery.enabled,
"Move selected web clients to the new origin",
domain_migration.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every notification keeps the \
current delivery path, so the rollout and targeting fields below have no \
effect at all."
"Off is the safe state and the kill switch. With this unchecked no client \
starts a migration and clients that already migrated stop forwarding the \
legacy origin, so the rollout and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Installed apps" }
(checkbox(
"domain_migration_standalone_forwarding",
"true",
"Forward installed desktop web apps to the new origin",
domain_migration.standalone_forwarding,
true,
))
p class="text-xs text-neutral-500" {
"Leave this off until the manifest scope extension and the association file \
are live and verified. While it is off, installed Chromium desktop apps copy \
their data across but stay on the legacy origin and offer to install the new \
app. Installed mobile and Safari apps never forward either way."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"push_service_delivery_rollout_basis_points",
"domain_migration_rollout_basis_points",
"Rollout (basis points)",
&push_service_delivery.rollout_basis_points.to_string(),
&domain_migration.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
Some("Share of logged-in users bucketed into the migration, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
(number_field(
"domain_migration_anonymous_rollout_basis_points",
"Anonymous rollout (basis points)",
&domain_migration.anonymous_rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
))
div class="flex flex-col gap-2" {
(text_input(
"push_service_delivery_rollout_salt",
"domain_migration_rollout_salt",
"Rollout Salt",
&push_service_delivery.rollout_salt,
PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
&domain_migration.rollout_salt,
DOMAIN_MIGRATION_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
"Seeds the bucketing hash for users and devices. Changing it reshuffles \
which users and devices fall inside the percentages above. Leave it \
alone to keep the current cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"push_service_delivery_included_user_ids",
"domain_migration_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
@@ -1248,7 +1385,7 @@ fn push_service_delivery_section(
false,
))
(entry_count_hint(
push_service_delivery.included_user_ids.len(),
domain_migration.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
@@ -1258,9 +1395,41 @@ fn push_service_delivery_section(
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"domain_migration_include_premium_users",
"true",
"Include premium users",
domain_migration.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"push_service_delivery_excluded_user_ids",
"domain_migration_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&domain_migration.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
domain_migration.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"domain_migration_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
@@ -1268,17 +1437,326 @@ fn push_service_delivery_section(
false,
))
(entry_count_hint(
push_service_delivery.excluded_user_ids.len(),
domain_migration.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage. This is the per-user kill switch."
percentage. It stops new migrations only. A user who already moved \
stays on the new origin."
}
}
(form_actions(html! {
(submit_button("Save Push Service Delivery Configuration"))
(submit_button("Save Domain Migration Configuration"))
}))
}
}
},
)
}
fn altcha_captcha_section(
base: &str,
csrf_token: &str,
altcha_captcha: &AltchaCaptchaConfigResponse,
) -> Markup {
let status = if altcha_captcha.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = altcha_captcha.included_user_ids.join("\n");
let excluded_user_ids = altcha_captcha.excluded_user_ids.join("\n");
section_card_with_description(
"ALTCHA Captcha",
"Replaces the configured captcha provider with an ALTCHA proof-of-work check for the \
selected requesters. The API issues and verifies every challenge itself, so no third \
party is involved. Requests only need a captcha where one is already required, so this \
does nothing while captcha is off for the instance.",
html! {
form method="post" action={(base) "/instance-config?action=update_altcha_captcha"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (altcha_captcha.config_version)
}
}
(checkbox(
"altcha_captcha_enabled",
"true",
"Serve ALTCHA to the selected requesters",
altcha_captcha.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked every \
requester gets the configured provider and ALTCHA answers are rejected."
}
h3 class="text-sm font-semibold text-neutral-900" { "Logged-out requests" }
(checkbox(
"altcha_captcha_anonymous_enabled",
"true",
"Serve ALTCHA to logged-out requests",
altcha_captcha.anonymous_enabled,
true,
))
p class="text-xs text-neutral-500" {
"Covers registration, login and password reset. These requests have no \
account to bucket, so this switch applies to all of them at once."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"altcha_captcha_rollout_basis_points",
"Rollout (basis points)",
&altcha_captcha.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-in users bucketed into ALTCHA, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"altcha_captcha_rollout_salt",
"Rollout Salt",
&altcha_captcha.rollout_salt,
ALTCHA_CAPTCHA_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
altcha_captcha.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users get ALTCHA \
regardless of the percentage above. Invalid entries prevent the save."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"altcha_captcha_include_premium_users",
"true",
"Include premium users",
altcha_captcha.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&altcha_captcha.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
altcha_captcha.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
altcha_captcha.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the percentage."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Difficulty" }
(number_field(
"altcha_captcha_cost",
"Cost (PBKDF2 iterations per attempt)",
&altcha_captcha.cost.to_string(),
Some(*ALTCHA_CAPTCHA_COST_RANGE.start()),
Some(*ALTCHA_CAPTCHA_COST_RANGE.end()),
"1",
Some("The API spends one attempt at this cost to issue each challenge."),
))
(number_field(
"altcha_captcha_max_counter",
"Maximum counter",
&altcha_captcha.max_counter.to_string(),
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start()),
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end()),
"1",
Some("Each challenge hides its answer between half this value and this value. The client tries counters from 0 until it finds it, so solve time grows with cost times this value. At the defaults a recent laptop takes about 3 seconds."),
))
(form_actions(html! {
(submit_button("Save ALTCHA Configuration"))
}))
}
}
},
)
}
fn profile_timezone_section(
base: &str,
csrf_token: &str,
profile_timezone: &ProfileTimezoneConfigResponse,
) -> Markup {
let status = if profile_timezone.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = profile_timezone.included_user_ids.join("\n");
let excluded_user_ids = profile_timezone.excluded_user_ids.join("\n");
section_card_with_description(
"Profile Timezone",
"Lets the selected users save a time zone in profile settings and show their local time \
on their profile. Users outside the rollout cannot change it, and a saved time zone \
stays hidden from everyone while its owner is outside the rollout.",
html! {
form method="post" action={(base) "/instance-config?action=update_profile_timezone"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (profile_timezone.config_version)
}
}
(checkbox(
"profile_timezone_enabled",
"true",
"Serve profile timezone to the selected users",
profile_timezone.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked nobody \
sees the setting and every saved time zone is hidden."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"profile_timezone_rollout_basis_points",
"Rollout (basis points)",
&profile_timezone.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into profile timezone, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"profile_timezone_rollout_salt",
"Rollout Salt",
&profile_timezone.rollout_salt,
PROFILE_TIMEZONE_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
profile_timezone.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users get profile \
timezone regardless of the percentage above. Invalid entries prevent the save."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"profile_timezone_include_premium_users",
"true",
"Include premium users",
profile_timezone.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&profile_timezone.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
profile_timezone.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
profile_timezone.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the percentage."
}
}
(form_actions(html! {
(submit_button("Save Profile Timezone Configuration"))
}))
}
}
@@ -1929,6 +2407,53 @@ 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_relay_section_shows_the_consent_toggle() {
let accepted = PushRelayConfigResponse {
relay_consent_accepted: true,
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
};
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
assert!(markup.contains("action=update_push_relay"));
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
assert!(markup.contains("https://fluxer.com/push-relay"));
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
assert!(markup.contains("value=\"1130650140672000000\""));
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
assert!(!markup.to_lowercase().contains("rollout"));
let unaccepted =
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
assert!(unaccepted.contains("Not accepted"));
assert!(unaccepted.contains("value=\"Never\""));
assert!(unaccepted.contains("value=\"Nobody\""));
}
#[test]
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
+109 -6
View File
@@ -403,19 +403,56 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"suppression_strength": 80,
"future_presentation_knob": "verbose",
"future_knob": 7,
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"]
},
"push_service_delivery": {
"push_relay": {
"relay_consent_accepted": true,
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
"relay_consent_accepted_by": "1130650140672000000"
},
"domain_migration": {
"enabled": true,
"config_version": 2,
"rollout_basis_points": 2500,
"rollout_salt": "domain-migration-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"future_migration_knob": 9,
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
},
"altcha_captcha": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1",
"included_user_ids": ["1500000000000000002"],
"excluded_user_ids": []
"rollout_basis_points": 500,
"rollout_salt": "altcha-captcha-v1",
"included_user_ids": [],
"excluded_user_ids": ["1500000000000000003"],
"anonymous_enabled": true,
"cost": 5000,
"max_counter": 10000,
"included_guild_ids": [],
"include_premium_users": false,
"future_altcha_knob": "argon2id"
},
"profile_timezone": {
"enabled": true,
"config_version": 2,
"rollout_basis_points": 0,
"rollout_salt": "profile-timezone-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"future_profile_timezone_knob": true
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
@@ -546,6 +583,25 @@ 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_relay.relay_consent_accepted);
assert!(resp.altcha_captcha.enabled);
assert_eq!(resp.altcha_captcha.config_version, 3);
assert!(resp.altcha_captcha.anonymous_enabled);
assert_eq!(resp.altcha_captcha.excluded_user_ids.len(), 1);
assert_eq!(resp.altcha_captcha.max_counter, 10000);
assert!(resp.profile_timezone.enabled);
assert_eq!(resp.profile_timezone.config_version, 2);
assert_eq!(resp.profile_timezone.included_user_ids.len(), 1);
assert_eq!(resp.profile_timezone.included_guild_ids.len(), 1);
assert!(resp.profile_timezone.include_premium_users);
assert!(resp.voice_noise_suppression.include_premium_users);
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
assert!(resp.policy.single_community_guild_id.is_none());
assert_eq!(resp.policy.services.gif_enabled, Some(true));
@@ -556,6 +612,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 +634,51 @@ fn deserialize_instance_config_response_with_unknown_keys() {
);
}
#[test]
fn deserialize_push_relay_config() {
let accepted: types::PushRelayConfigResponse = serde_json::from_str(
r#"{
"relay_consent_accepted": true,
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
"relay_consent_accepted_by": "1130650140672000000"
}"#,
)
.expect("an accepted relay consent must deserialize");
assert!(accepted.relay_consent_accepted);
assert_eq!(
accepted.relay_consent_accepted_at.as_deref(),
Some("2026-09-27T10:11:12.000Z")
);
assert_eq!(
accepted.relay_consent_accepted_by.as_deref(),
Some("1130650140672000000")
);
let empty: types::PushRelayConfigResponse =
serde_json::from_str("{}").expect("an empty push relay config must deserialize");
assert!(!empty.relay_consent_accepted);
assert!(empty.relay_consent_accepted_at.is_none());
assert!(empty.relay_consent_accepted_by.is_none());
}
#[test]
fn serialize_push_relay_update_omits_an_unset_consent() {
assert_eq!(
serde_json::to_value(types::PushRelayConfigUpdateRequest::default()).unwrap(),
serde_json::json!({})
);
let with = types::PushRelayConfigUpdateRequest {
relay_consent_accepted: Some(true),
};
assert_eq!(
serde_json::to_value(&with).unwrap(),
serde_json::json!({"relay_consent_accepted": true})
);
}
#[test]
fn deserialize_search_reports_response() {
let json = r#"{
@@ -851,7 +953,8 @@ fn deserialize_webauthn_credentials_response() {
"id": "credential-a",
"name": "YubiKey",
"created_at": "2026-05-26T12:00:00.000Z",
"last_used_at": null
"last_used_at": null,
"rp_id": "fluxer.com"
},
{
"id": "credential-b",
+11
View File
@@ -465,6 +465,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
"/instance-config?action=update_gateway_rollout",
"/instance-config?action=update_sso",
"/instance-config?action=update_voice_noise_suppression",
"/instance-config?action=update_domain_migration",
"/instance-config?action=update_experiment_delivery",
][..],
),
@@ -1199,6 +1200,16 @@ fn instance_config() -> Value {
"guild_overrides": [],
"suppression_strength": 80
},
"domain_migration": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "domain-migration-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"anonymous_rollout_basis_points": 0,
"standalone_forwarding": false
},
"experiment_delivery": {
"poll_interval_seconds": 300,
"poll_jitter_percent": 15
+1
View File
@@ -56,6 +56,7 @@
"@simplewebauthn/server": "catalog:",
"@types/node": "catalog:",
"@vvo/tzdb": "catalog:",
"altcha-lib": "catalog:",
"archiver": "catalog:",
"argon2": "catalog:",
"bowser": "catalog:",
+3 -1
View File
@@ -11,7 +11,9 @@
},
"dependencies": {
"@fluxer/logger": "workspace:*",
"itty-time": "catalog:"
"altcha-lib": "catalog:",
"itty-time": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
@@ -5,7 +5,7 @@ export interface VerifyCaptchaParams {
remoteIp?: string;
}
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'test' | 'unavailable';
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'altcha' | 'test' | 'unavailable';
export interface ICaptchaProvider {
readonly type: CaptchaProviderType;
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
import {createChallenge, randomInt, verifySolution} from 'altcha-lib';
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
import type {Challenge} from 'altcha-lib/types';
import {ms} from 'itty-time';
import {z} from 'zod';
export const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
const ALTCHA_CHALLENGE_TTL_MS = ms('10 minutes');
const ALTCHA_MAX_TOKEN_LENGTH = 4096;
const HEX_PATTERN = /^[0-9a-f]+$/u;
const AltchaPayloadSchema = z.object({
challenge: z.object({
parameters: z.looseObject({
algorithm: z.literal(ALTCHA_ALGORITHM),
nonce: z.string().regex(HEX_PATTERN),
salt: z.string().regex(HEX_PATTERN),
cost: z.number().int().positive(),
keyLength: z.number().int().positive(),
keyPrefix: z.string().regex(HEX_PATTERN),
keySignature: z.string().regex(HEX_PATTERN),
expiresAt: z.number().int().positive(),
}),
signature: z.string().regex(HEX_PATTERN),
}),
solution: z.object({
counter: z.number().int().min(0),
derivedKey: z.string().regex(HEX_PATTERN),
time: z.number().optional(),
}),
});
type AltchaPayload = z.infer<typeof AltchaPayloadSchema>;
export interface AltchaProviderOptions {
hmacSignatureSecret: string;
hmacKeySignatureSecret: string;
cost: number;
maxCounter: number;
claimChallenge: (signature: string, ttlSeconds: number) => Promise<boolean>;
logger?: LoggerInterface;
now?: () => number;
}
function decodePayload(token: string): AltchaPayload | null {
if (token.length > ALTCHA_MAX_TOKEN_LENGTH) return null;
try {
const parsed = AltchaPayloadSchema.safeParse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
export class AltchaProvider implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'altcha';
private readonly options: AltchaProviderOptions;
private readonly now: () => number;
constructor(options: AltchaProviderOptions) {
this.options = options;
this.now = options.now ?? Date.now;
}
async createChallenge(): Promise<Challenge> {
const {cost, maxCounter, hmacSignatureSecret, hmacKeySignatureSecret} = this.options;
return await createChallenge({
algorithm: ALTCHA_ALGORITHM,
cost,
counter: randomInt(maxCounter, Math.ceil(maxCounter / 2)),
deriveKey,
expiresAt: new Date(this.now() + ALTCHA_CHALLENGE_TTL_MS),
hmacSignatureSecret,
hmacKeySignatureSecret,
});
}
async verify({token}: VerifyCaptchaParams): Promise<boolean> {
const payload = decodePayload(token);
if (!payload) return false;
try {
const result = await verifySolution({
challenge: payload.challenge,
solution: payload.solution,
deriveKey,
hmacSignatureSecret: this.options.hmacSignatureSecret,
hmacKeySignatureSecret: this.options.hmacKeySignatureSecret,
});
if (!result.verified) {
this.options.logger?.warn(
{expired: result.expired, invalidSignature: result.invalidSignature, invalidSolution: result.invalidSolution},
'ALTCHA verification failed',
);
return false;
}
} catch (error) {
this.options.logger?.error({error}, 'Error verifying ALTCHA payload');
return false;
}
const ttlSeconds = Math.max(1, payload.challenge.parameters.expiresAt - Math.floor(this.now() / 1000));
return await this.options.claimChallenge(payload.challenge.signature, ttlSeconds);
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
configureMiddleware(routes, {
logger,
nodeEnv: config.nodeEnv,
corsOrigins: [config.endpoints.webApp, config.endpoints.marketing],
corsOrigins: [...config.endpoints.webAppOrigins, config.endpoints.marketing],
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
maxInflightRequests: config.maxInflightRequests,
+10
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
@@ -82,6 +83,14 @@ function resolveTrustClientIpHeader(proxyConfig: object): boolean {
function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
const normalized = new Set<string>();
for (const value of values) {
if (value.includes('/')) {
const range = parseIpBanEntry(value);
if (range?.type !== 'range') {
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid CIDR range: ${value}`);
}
normalized.add(range.canonical);
continue;
}
const parsed = parseIpAddress(value);
if (!parsed) {
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid IP address: ${value}`);
@@ -258,6 +267,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
apiPublic: master.endpoints.api,
apiClient: master.endpoints.api_client,
webApp: master.endpoints.app,
webAppOrigins: [...new Set([new URL(master.endpoints.app).origin, ...master.services.api.app_origin_aliases])],
gateway: master.endpoints.gateway,
media: master.endpoints.media,
marketing: master.endpoints.marketing,
@@ -16,7 +16,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {
getGatewayRolloutConfigPublisher,
getInstanceConfigRepository,
getPushServiceDeliveryConfigPublisher,
getPushRelayConfigPublisher,
} from '@app/api/middleware/ServiceSingletons';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
@@ -34,8 +34,11 @@ import {
PendingRegistrationActionRequest,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {AltchaCaptchaConfigSchema} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {PushServiceDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {ProfileTimezoneConfigSchema} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -64,7 +67,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
pushServiceDelivery,
pushRelay,
domainMigration,
altchaCaptcha,
profileTimezone,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -73,7 +79,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getPushServiceDeliveryConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -105,7 +114,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
push_service_delivery: pushServiceDelivery,
push_relay: pushRelay,
domain_migration: domainMigration,
altcha_captcha: altchaCaptcha,
profile_timezone: profileTimezone,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -190,6 +202,20 @@ async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boole
return true;
}
function relayConsentStamp(
current: PushRelayConfig,
patch: PushRelayConfigUpdateRequest,
adminUserId: string,
): Partial<PushRelayConfig> {
const accepted = patch.relay_consent_accepted;
if (accepted === undefined || accepted === current.relay_consent_accepted) {
return {};
}
return accepted
? {relay_consent_accepted_at: new Date().toISOString(), relay_consent_accepted_by: adminUserId}
: {relay_consent_accepted_at: null, relay_consent_accepted_by: null};
}
function listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined {
const sections = Object.entries(data)
.filter(([, value]) => value != null)
@@ -269,17 +295,52 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.push_service_delivery) {
const patch = omitUndefinedFields(data.push_service_delivery);
if (data.push_relay) {
const patch = omitUndefinedFields(data.push_relay);
if (Object.keys(patch).length > 0) {
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
PushServiceDeliveryConfigSchema.parse({
const adminUserId = ctx.get('adminUserId').toString();
const landed = await instanceConfigRepository.updatePushRelayConfig((current) => ({
...current,
...patch,
...relayConsentStamp(current, patch, adminUserId),
}));
await getPushRelayConfigPublisher().publish(landed);
}
}
if (data.domain_migration) {
const patch = omitUndefinedFields(data.domain_migration);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateDomainMigrationConfig((current) =>
DomainMigrationConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.altcha_captcha) {
const patch = omitUndefinedFields(data.altcha_captcha);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateAltchaCaptchaConfig((current) =>
AltchaCaptchaConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.profile_timezone) {
const patch = omitUndefinedFields(data.profile_timezone);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateProfileTimezoneConfig((current) =>
ProfileTimezoneConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
await getPushServiceDeliveryConfigPublisher().publish(landed);
}
}
if (data.experiment_delivery) {
@@ -8,12 +8,14 @@ import * as AuthEmail from '@app/api/auth/AuthEmail';
import * as AuthMfa from '@app/api/auth/AuthMfa';
import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
import {createPasswordResetToken, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {Logger} from '@app/api/Logger';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
import type {HistoricalOutcomeCode} from '@app/api/risk/RiskHistoryTypes';
import {mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
import {resolveAssignedTraits} from '@app/api/user/UserTraits';
import {getIpAddressReverse, getLocationLabelFromIp} from '@app/api/utils/IpUtils';
import {resolveSessionClientInfo} from '@app/api/utils/SessionClientIdentity';
@@ -545,7 +547,7 @@ export class AdminUserSecurityService {
if (!user) {
throw new UnknownUserError();
}
const credentials = await userRepository.listWebAuthnCredentials(userId);
const credentials = visibleWebAuthnCredentials(await userRepository.listWebAuthnCredentials(userId));
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
@@ -554,12 +556,9 @@ export class AdminUserSecurityService {
auditLogReason,
metadata: new Map([['credential_count', credentials.length.toString()]]),
});
return credentials.map((cred) => ({
id: cred.credentialId,
name: cred.name,
created_at: cred.createdAt.toISOString(),
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
}));
return credentials.map((cred) =>
mapWebAuthnCredentialToResponse(cred, this.deps.apiContext.services.config.auth.passkeys.rpId),
);
}
async deleteWebAuthnCredential(
@@ -4,7 +4,7 @@ import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
@@ -16,12 +16,12 @@ import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
type PushServiceDeliveryConfig,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
type LegacyPushServiceDeliveryWire,
toLegacyPushServiceDeliveryWire,
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
describe('instance config admin PATCH under concurrent writes', () => {
let harness: ApiTestHarness;
@@ -55,13 +55,13 @@ describe('instance config admin PATCH under concurrent writes', () => {
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
const spyOnPushDeliveryPublishes = () =>
vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
const spyOnPushRelayPublishes = () =>
vi.spyOn(PushRelayConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
async function readStoredPushServiceDelivery(): Promise<PushServiceDeliveryConfig> {
const raw = await executor.readDirectly(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
if (raw === null) throw new Error('push service delivery config was never stored');
return JSON.parse(raw) as PushServiceDeliveryConfig;
async function readStoredPushRelay(): Promise<LegacyPushServiceDeliveryWire> {
const raw = await executor.readDirectly(PUSH_RELAY_CONFIG_KEY);
if (raw === null) throw new Error('push relay config was never stored');
return JSON.parse(raw) as LegacyPushServiceDeliveryWire;
}
async function listConfigUpdateAudits(): Promise<Array<AdminAuditLog>> {
@@ -69,38 +69,47 @@ describe('instance config admin PATCH under concurrent writes', () => {
return logs.filter((log) => log.action === 'update_instance_config');
}
it('answers with a conflict and neither writes, publishes nor audits once every attempt has lost the race', async () => {
const publish = spyOnPushDeliveryPublishes();
it('merges a standalone forwarding patch into the stored domain migration config', async () => {
const admin = await createAdmin();
await patchConfig(admin, {push_service_delivery: {enabled: true, rollout_basis_points: 1000}}).execute();
await patchConfig(admin, {domain_migration: {enabled: true, rollout_basis_points: 250}}).execute();
const updated = await patchConfig(admin, {domain_migration: {standalone_forwarding: true}}).execute();
expect(updated.domain_migration).toMatchObject({
enabled: true,
rollout_basis_points: 250,
standalone_forwarding: true,
config_version: 2,
});
});
it('answers with a conflict and neither writes, publishes nor audits once every attempt has lost the race', async () => {
const publish = spyOnPushRelayPublishes();
const admin = await createAdmin();
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
publish.mockClear();
const auditsBefore = await listConfigUpdateAudits();
executor.watch(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
executor.watch(PUSH_RELAY_CONFIG_KEY);
const unaccepted = {
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
};
let competingWrites = 0;
executor.competeBeforeEachWrite(async () => {
competingWrites++;
await executor.writeDirectly(
PUSH_SERVICE_DELIVERY_CONFIG_KEY,
JSON.stringify({
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
enabled: false,
rollout_basis_points: 1000,
config_version: 100 + competingWrites,
}),
PUSH_RELAY_CONFIG_KEY,
JSON.stringify(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites)),
);
});
await patchConfig(admin, {push_service_delivery: {rollout_basis_points: 5000}})
await patchConfig(admin, {push_relay: {relay_consent_accepted: true}})
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.CONFLICT)
.execute();
expect(executor.events).not.toContain('write');
expect(await readStoredPushServiceDelivery()).toEqual({
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
enabled: false,
rollout_basis_points: 1000,
config_version: 100 + competingWrites,
});
expect(await readStoredPushRelay()).toEqual(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites));
expect(publish).not.toHaveBeenCalled();
expect(await listConfigUpdateAudits()).toHaveLength(auditsBefore.length);
});
@@ -0,0 +1,310 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
const ACCEPTED_AT = '2026-09-20T08:00:00.000Z';
const ACCEPTED_BY = '1500000000000000007';
const PROD_ROW = {
enabled: true,
config_version: 41,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
relay_consent_accepted: true,
relay_consent_accepted_at: ACCEPTED_AT,
relay_consent_accepted_by: ACCEPTED_BY,
};
interface PushServiceDeliveryRpcResponse {
type: 'get_push_service_delivery_config';
data: {config: LegacyPushServiceDeliveryWire};
}
describe('push relay supplemental notice consent', () => {
let harness: ApiTestHarness;
let executor: InstanceConfigWriteRaceExecutor;
beforeAll(async () => {
harness = await createApiTestHarness();
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
setCassandraQueryExecutorForTesting(executor);
});
beforeEach(async () => {
await harness.reset();
vi.spyOn(PushRelayConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness.shutdown();
});
const createAdmin = async (): Promise<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
const readConfig = (admin: TestAccount) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config');
const readRpcConfig = async (): Promise<LegacyPushServiceDeliveryWire> => {
const response = await createBuilder<PushServiceDeliveryRpcResponse>(harness, '')
.post('/test/rpc-session-init')
.body({type: 'get_push_service_delivery_config'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.type).toBe('get_push_service_delivery_config');
return response.data.config;
};
async function storeRow(row: Record<string, unknown>): Promise<void> {
await executor.writeDirectly(PUSH_RELAY_CONFIG_KEY, JSON.stringify(row));
getInstanceConfigRepository().clearCacheForTesting();
}
async function readStoredRow(): Promise<unknown> {
const raw = await executor.readDirectly(PUSH_RELAY_CONFIG_KEY);
if (raw === null) throw new Error('push relay config was never stored');
return JSON.parse(raw);
}
it('reads back as unaccepted before an operator agrees', async () => {
const admin = await createAdmin();
const config = await readConfig(admin).execute();
expect(config.push_relay).toEqual({
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
});
it('keeps the consent of a stored push service delivery row', async () => {
const admin = await createAdmin();
await storeRow(PROD_ROW);
const config = await readConfig(admin).execute();
expect(config.push_relay).toEqual({
relay_consent_accepted: true,
relay_consent_accepted_at: ACCEPTED_AT,
relay_consent_accepted_by: ACCEPTED_BY,
});
});
it('reads a stored row without consent fields as unaccepted', async () => {
const admin = await createAdmin();
await storeRow({enabled: true, config_version: 3, rollout_basis_points: 10000});
const config = await readConfig(admin).execute();
expect(config.push_relay.relay_consent_accepted).toBe(false);
expect(await readRpcConfig()).toMatchObject({config_version: 3, relay_consent_accepted: false});
});
it('stamps the acting admin and the acceptance time when consent is given', async () => {
const admin = await createAdmin();
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(updated.push_relay.relay_consent_accepted).toBe(true);
expect(updated.push_relay.relay_consent_accepted_by).toBe(admin.userId);
expect(Date.parse(updated.push_relay.relay_consent_accepted_at ?? '')).not.toBeNaN();
});
it('keeps the stamp untouched when consent is re-sent unchanged', async () => {
const admin = await createAdmin();
const accepted = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
const resent = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(resent.push_relay).toEqual(accepted.push_relay);
});
it('clears the stamp when an operator withdraws consent', async () => {
const admin = await createAdmin();
await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
const withdrawn = await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
expect(withdrawn.push_relay).toEqual({
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
});
it('ignores an acceptance stamp supplied by the caller', async () => {
const admin = await createAdmin();
const updated = await patchConfig(admin, {
push_relay: {
relay_consent_accepted: true,
relay_consent_accepted_at: '2020-01-01T00:00:00.000Z',
relay_consent_accepted_by: '1500000000000000009',
},
}).execute();
expect(updated.push_relay.relay_consent_accepted_at).not.toBe('2020-01-01T00:00:00.000Z');
expect(updated.push_relay.relay_consent_accepted_by).toBe(admin.userId);
});
it('writes the full legacy document and bumps the stored config version', async () => {
const admin = await createAdmin();
await storeRow({
...PROD_ROW,
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(await readStoredRow()).toEqual({
enabled: true,
config_version: 42,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
relay_consent_accepted: true,
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
relay_consent_accepted_by: admin.userId,
});
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
expect(await readStoredRow()).toMatchObject({
enabled: true,
config_version: 43,
rollout_basis_points: 10000,
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
});
it('rewrites a partially enrolled stored row as full enrolment', async () => {
const admin = await createAdmin();
await storeRow({
...PROD_ROW,
enabled: false,
rollout_basis_points: 250,
rollout_salt: 'custom-salt',
included_user_ids: ['1500000000000000003'],
excluded_user_ids: ['1500000000000000004'],
});
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
expect(await readStoredRow()).toMatchObject({
enabled: true,
config_version: 42,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
});
});
it('publishes the legacy delivery document with the consent', async () => {
const admin = await createAdmin();
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(publish).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenCalledWith({
enabled: true,
config_version: 1,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
relay_consent_accepted: true,
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
relay_consent_accepted_by: admin.userId,
});
});
it('does not write or publish for an empty push relay patch', async () => {
const admin = await createAdmin();
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
await patchConfig(admin, {push_relay: {}}).execute();
expect(publish).not.toHaveBeenCalled();
expect(await executor.readDirectly(PUSH_RELAY_CONFIG_KEY)).toBeNull();
});
it('ignores the retired push_service_delivery section', async () => {
const admin = await createAdmin();
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
const updated = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
expect(updated.push_relay.relay_consent_accepted).toBe(false);
expect(publish).not.toHaveBeenCalled();
});
it('answers the legacy delivery RPC with full enrolment and the stored consent', async () => {
await storeRow(PROD_ROW);
expect(await readRpcConfig()).toEqual(PROD_ROW);
});
it('answers the legacy delivery RPC with defaults before anything is stored', async () => {
expect(await readRpcConfig()).toEqual({
enabled: true,
config_version: 0,
rollout_basis_points: 10000,
rollout_salt: 'push-service-delivery-v1',
included_user_ids: [],
excluded_user_ids: [],
relay_consent_accepted: false,
relay_consent_accepted_at: null,
relay_consent_accepted_by: null,
});
});
it('answers the legacy delivery RPC with consent given through the admin API', async () => {
const admin = await createAdmin();
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
expect(await readRpcConfig()).toMatchObject({
enabled: true,
config_version: 1,
rollout_basis_points: 10000,
relay_consent_accepted: true,
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
relay_consent_accepted_by: admin.userId,
});
});
});
@@ -3,6 +3,8 @@
import {registerAdminControllers} from '@app/api/admin/controllers/index';
import {AttachmentController} from '@app/api/attachment/AttachmentController';
import {AuthController} from '@app/api/auth/AuthController';
import {OriginHandoffController} from '@app/api/auth/OriginHandoffController';
import {PasskeyBridgeController} from '@app/api/auth/PasskeyBridgeController';
import {BlueskyOAuthController} from '@app/api/bluesky/BlueskyOAuthController';
import {Config} from '@app/api/Config';
import {ChannelController} from '@app/api/channel/ChannelController';
@@ -46,6 +48,8 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
GeolocationController(routes);
registerAdminControllers(routes);
AuthController(routes);
OriginHandoffController(routes);
PasskeyBridgeController(routes);
AttachmentController(routes);
ChannelController(routes);
ConnectionController(routes);
+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> {
@@ -43,7 +43,6 @@ async function revokeSessionTargets(
scope === 'all'
? users.deleteAllPushSubscriptions(userId)
: users.deletePushSubscriptionsForAuthSessions(userId, sessionIdHashes, {deleteUnboundSubscriptions: true}),
() => gateway.invalidatePushSubscriptions({userId}),
];
if (scope === 'selected' || targets.length > 0) {
steps.push(
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createOriginHandoff, redeemOriginHandoff} from '@app/api/auth/services/OriginHandoffService';
import {Config} from '@app/api/Config';
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {FileSizeTooLargeError} from '@fluxer/errors/src/domains/core/FileSizeTooLargeError';
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
import {
ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH,
OriginHandoffCreateRequest,
OriginHandoffCreateResponse,
OriginHandoffRedeemRequest,
OriginHandoffRedeemResponse,
} from '@fluxer/schema/src/domains/auth/OriginHandoffSchemas';
import {bodyLimit} from 'hono/body-limit';
const ORIGIN_HANDOFF_CREATE_MAX_BODY_BYTES = ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH + 1024;
export function OriginHandoffController(app: HonoApp) {
app.post(
'/auth/origin-handoff',
RateLimitMiddleware(RateLimitConfigs.AUTH_ORIGIN_HANDOFF_CREATE),
LoginRequired,
DefaultUserOnly,
bodyLimit({
maxSize: ORIGIN_HANDOFF_CREATE_MAX_BODY_BYTES,
onError: () => {
throw new FileSizeTooLargeError(ORIGIN_HANDOFF_CREATE_MAX_BODY_BYTES);
},
}),
Validator('json', OriginHandoffCreateRequest),
OpenAPI({
operationId: 'create_origin_handoff',
summary: 'Create origin handoff',
responseSchema: OriginHandoffCreateResponse,
statusCode: 200,
security: ['sessionToken'],
tags: ['Auth'],
description:
'Store encrypted client state for up to two minutes so another first-party web origin can redeem it once. The receiving origin must present the nonce whose SHA-256 digest is sent here.',
}),
async (ctx) => {
const body = ctx.req.valid('json');
const handoffId = await createOriginHandoff(ctx.get('cacheService'), {
userId: ctx.get('user').id,
nonceHash: body.nonce_hash,
payload: body.payload,
});
const response: OriginHandoffCreateResponse = {handoff_id: handoffId};
return ctx.json(response);
},
);
app.post(
'/auth/origin-handoff/redeem',
RateLimitMiddleware(RateLimitConfigs.AUTH_ORIGIN_HANDOFF_REDEEM),
Validator('json', OriginHandoffRedeemRequest),
OpenAPI({
operationId: 'redeem_origin_handoff',
summary: 'Redeem origin handoff',
responseSchema: OriginHandoffRedeemResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Return the encrypted client state stored by create origin handoff and delete it in the same step. A wrong nonce also consumes the handoff. On the official instance the request must come from a first-party web origin.',
}),
async (ctx) => {
if (!Config.instance.selfHosted) {
const origin = ctx.req.header('origin');
if (origin === undefined || !Config.endpoints.webAppOrigins.includes(origin)) {
throw new InvalidApiOriginError();
}
}
const body = ctx.req.valid('json');
const payload = await redeemOriginHandoff(ctx.get('cacheService'), {
handoffId: body.handoff_id,
nonce: body.nonce,
});
const response: OriginHandoffRedeemResponse = {payload};
return ctx.json(response);
},
);
}
@@ -0,0 +1,205 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
cancelPasskeyBridge,
completePasskeyBridge,
getPasskeyBridgeOptions,
redeemPasskeyBridgeLogin,
redeemPasskeyBridgeSudo,
startPasskeyBridgeLogin,
startPasskeyBridgeSudo,
} from '@app/api/auth/services/PasskeyBridgeService';
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {LocalAuthMiddleware} from '@app/api/middleware/LocalAuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {
PasskeyBridgeCeremonyIdParam,
PasskeyBridgeCompleteRequest,
PasskeyBridgeFinishResponse,
PasskeyBridgeLoginRedeemResponse,
PasskeyBridgeLoginStartRequest,
PasskeyBridgeOptionsResponse,
PasskeyBridgeRedeemRequest,
PasskeyBridgeStartResponse,
PasskeyBridgeSudoRedeemResponse,
PasskeyBridgeSudoStartRequest,
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
export function PasskeyBridgeController(app: HonoApp) {
app.post(
'/auth/passkey-bridge',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_START),
Validator('json', PasskeyBridgeLoginStartRequest),
OpenAPI({
operationId: 'start_passkey_bridge_login',
summary: 'Start passkey bridge sign in',
responseSchema: PasskeyBridgeStartResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Start a sign in or two-factor ceremony for a passkey that belongs to the paired first-party origin. Only available on the official instance from the new origin.',
}),
async (ctx) => {
return ctx.json(
await startPasskeyBridgeLogin(ctx.get('apiContext'), ctx.req.header('origin'), ctx.req.valid('json')),
);
},
);
app.post(
'/users/@me/passkey-bridge',
RateLimitMiddleware(RateLimitConfigs.USER_PASSKEY_BRIDGE_START),
LoginRequired,
DefaultUserOnly,
Validator('json', PasskeyBridgeSudoStartRequest),
OpenAPI({
operationId: 'start_passkey_bridge_sudo',
summary: 'Start passkey bridge sudo verification',
responseSchema: PasskeyBridgeStartResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Start a sudo verification ceremony for a passkey that belongs to the paired first-party origin. Only available on the official instance from the new origin.',
}),
async (ctx) => {
return ctx.json(
await startPasskeyBridgeSudo(
ctx.get('apiContext'),
ctx.req.header('origin'),
ctx.get('user').id,
ctx.req.valid('json'),
),
);
},
);
app.post(
'/auth/passkey-bridge/:ceremony_id/options',
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_CEREMONY),
Validator('param', PasskeyBridgeCeremonyIdParam),
OpenAPI({
operationId: 'get_passkey_bridge_options',
summary: 'Get passkey bridge options',
responseSchema: PasskeyBridgeOptionsResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Issue WebAuthn authentication options for a pending passkey bridge ceremony. The request must come from the origin that runs the ceremony.',
}),
async (ctx) => {
const {ceremony_id} = ctx.req.valid('param');
return ctx.json(await getPasskeyBridgeOptions(ctx.get('apiContext'), ceremony_id, ctx.req.header('origin')));
},
);
app.post(
'/auth/passkey-bridge/:ceremony_id/complete',
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_CEREMONY),
Validator('param', PasskeyBridgeCeremonyIdParam),
Validator('json', PasskeyBridgeCompleteRequest),
OpenAPI({
operationId: 'complete_passkey_bridge',
summary: 'Complete passkey bridge',
responseSchema: PasskeyBridgeFinishResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Verify the WebAuthn response for a pending passkey bridge ceremony. A failed verification leaves the ceremony pending so it can be retried.',
}),
async (ctx) => {
const {ceremony_id} = ctx.req.valid('param');
return ctx.json(
await completePasskeyBridge(
ctx.get('apiContext'),
ceremony_id,
ctx.req.header('origin'),
ctx.req.valid('json'),
),
);
},
);
app.post(
'/auth/passkey-bridge/:ceremony_id/cancel',
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_CEREMONY),
Validator('param', PasskeyBridgeCeremonyIdParam),
OpenAPI({
operationId: 'cancel_passkey_bridge',
summary: 'Cancel passkey bridge',
responseSchema: PasskeyBridgeFinishResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description: 'Cancel a passkey bridge ceremony that has not completed.',
}),
async (ctx) => {
const {ceremony_id} = ctx.req.valid('param');
return ctx.json(await cancelPasskeyBridge(ctx.get('apiContext'), ceremony_id, ctx.req.header('origin')));
},
);
app.post(
'/auth/passkey-bridge/:ceremony_id/redeem',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_REDEEM),
Validator('param', PasskeyBridgeCeremonyIdParam),
Validator('json', PasskeyBridgeRedeemRequest),
OpenAPI({
operationId: 'redeem_passkey_bridge_login',
summary: 'Redeem passkey bridge sign in',
responseSchema: PasskeyBridgeLoginRedeemResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Redeem a finished sign in or two-factor passkey bridge ceremony once. Requires the nonce kept by the starting page and the completion code handed back when the ceremony finished.',
}),
async (ctx) => {
const {ceremony_id} = ctx.req.valid('param');
return ctx.json(
await redeemPasskeyBridgeLogin(
ctx.get('apiContext'),
ceremony_id,
ctx.req.header('origin'),
ctx.req.valid('json'),
ctx.req.raw,
),
);
},
);
app.post(
'/users/@me/passkey-bridge/:ceremony_id/redeem',
RateLimitMiddleware(RateLimitConfigs.USER_PASSKEY_BRIDGE_REDEEM),
LoginRequired,
DefaultUserOnly,
Validator('param', PasskeyBridgeCeremonyIdParam),
Validator('json', PasskeyBridgeRedeemRequest),
OpenAPI({
operationId: 'redeem_passkey_bridge_sudo',
summary: 'Redeem passkey bridge sudo verification',
responseSchema: PasskeyBridgeSudoRedeemResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Redeem a finished sudo passkey bridge ceremony once for a sudo mode token. Requires the nonce kept by the starting page and the completion code handed back when the ceremony finished.',
}),
async (ctx) => {
const {ceremony_id} = ctx.req.valid('param');
return ctx.json(
await redeemPasskeyBridgeSudo(
ctx.get('apiContext'),
ceremony_id,
ctx.req.header('origin'),
ctx.req.valid('json'),
ctx.get('user').id,
ctx.get('authSession'),
),
);
},
);
}
@@ -25,6 +25,7 @@ const POLL_SECRET_BYTES = 32;
interface HandoffData {
createdAt: number;
origin: SessionOrigin;
initiatorOrigin?: string | null;
infoLookupCount: number;
pollSecretHash: string;
}
@@ -84,7 +85,7 @@ function pollSecretMatches(presented: string | undefined, storedHash: string | u
export class DesktopHandoffService {
constructor(private readonly apiContext: ApiContext) {}
async initiateHandoff(args: {origin: SessionOrigin}): Promise<{
async initiateHandoff(args: {origin: SessionOrigin; initiatorOrigin?: string | null}): Promise<{
code: string;
expiresAt: Date;
pollSecret: string;
@@ -95,6 +96,7 @@ export class DesktopHandoffService {
const handoffData: HandoffData = {
createdAt: Date.now(),
origin: args.origin,
initiatorOrigin: args.initiatorOrigin ?? null,
infoLookupCount: 0,
pollSecretHash: hashPollSecret(pollSecret),
};
@@ -108,7 +110,7 @@ export class DesktopHandoffService {
code: string,
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
approverIp: string,
): Promise<void> {
): Promise<{initiatorOrigin: string | null}> {
const {cache} = this.apiContext.services;
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
@@ -138,6 +140,7 @@ export class DesktopHandoffService {
await cache.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
await cache.delete(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
return {initiatorOrigin: handoffData.initiatorOrigin ?? null};
}
async getHandoffInfo(
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
import type {UserID} from '@app/api/BrandedTypes';
import {InvalidOriginHandoffNonceError} from '@fluxer/errors/src/domains/auth/InvalidOriginHandoffNonceError';
import {UnknownOriginHandoffError} from '@fluxer/errors/src/domains/auth/UnknownOriginHandoffError';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import {seconds} from 'itty-time';
const ORIGIN_HANDOFF_KEY_PREFIX = 'origin_handoff:';
const ORIGIN_HANDOFF_ID_BYTES = 32;
interface OriginHandoffRecord {
nonce_hash: string;
payload: string;
user_id: string;
created_at: number;
}
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function originHandoffKey(handoffId: string): string {
return `${ORIGIN_HANDOFF_KEY_PREFIX}${sha256Hex(handoffId)}`;
}
export async function createOriginHandoff(
cache: ICacheService,
args: {userId: UserID; nonceHash: string; payload: string},
): Promise<string> {
const handoffId = randomBytes(ORIGIN_HANDOFF_ID_BYTES).toString('base64url');
const record: OriginHandoffRecord = {
nonce_hash: args.nonceHash,
payload: args.payload,
user_id: args.userId.toString(),
created_at: Date.now(),
};
await cache.set(originHandoffKey(handoffId), record, seconds('2 minutes'));
return handoffId;
}
export async function redeemOriginHandoff(
cache: ICacheService,
args: {handoffId: string; nonce: string},
): Promise<string> {
const record = await cache.getAndDelete<OriginHandoffRecord>(originHandoffKey(args.handoffId));
if (!record) {
throw new UnknownOriginHandoffError();
}
const presented = Buffer.from(sha256Hex(args.nonce), 'hex');
const stored = Buffer.from(record.nonce_hash, 'hex');
if (presented.length !== stored.length || !timingSafeEqual(presented, stored)) {
throw new InvalidOriginHandoffNonceError();
}
return record.payload;
}
@@ -0,0 +1,436 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
import type {ApiContext} from '@app/api/ApiContext';
import * as AuthLogin from '@app/api/auth/AuthLogin';
import * as AuthMfa from '@app/api/auth/AuthMfa';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {recordPendingPasskeyMigration} from '@app/api/auth/services/PasskeyMigrationService';
import {
effectiveRpId,
isPasskeyMigrationActive,
isPasskeyTargetOrigin,
passkeyLegacyOriginFor,
visibleWebAuthnCredentials,
} from '@app/api/auth/services/PasskeyRelyingParty';
import {getSudoModeService} from '@app/api/auth/services/SudoModeService';
import {resolveWebAuthnSecondFactor} from '@app/api/auth/services/WebAuthnSecondFactor';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import type {AuthSession} from '@app/api/models/AuthSession';
import type {User} from '@app/api/models/User';
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
import {mapUserToPartialResponse} from '@app/api/user/UserMappers';
import {PASSKEY_BRIDGE_PATH, PASSKEY_BRIDGE_RETURN_FRAGMENT_KEY} from '@fluxer/constants/src/PasskeyConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InvalidPasskeyBridgeNonceError} from '@fluxer/errors/src/domains/auth/InvalidPasskeyBridgeNonceError';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import {NoPasskeysRegisteredError} from '@fluxer/errors/src/domains/auth/NoPasskeysRegisteredError';
import {PasskeyAuthenticationFailedError} from '@fluxer/errors/src/domains/auth/PasskeyAuthenticationFailedError';
import {UnknownPasskeyBridgeError} from '@fluxer/errors/src/domains/auth/UnknownPasskeyBridgeError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
import type {
PasskeyBridgeCompleteRequest,
PasskeyBridgeFinishResponse,
PasskeyBridgeLoginRedeemResponse,
PasskeyBridgeLoginStartRequest,
PasskeyBridgeRedeemRequest,
PasskeyBridgeRunner,
PasskeyBridgeStartResponse,
PasskeyBridgeSudoRedeemResponse,
PasskeyBridgeSudoStartRequest,
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
import type {PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/server';
import {ms, seconds} from 'itty-time';
type PasskeyBridgePurpose = 'login' | 'login_mfa' | 'sudo';
interface PasskeyBridgeRecord {
purpose: PasskeyBridgePurpose;
runner: PasskeyBridgeRunner;
target_origin: string;
ceremony_origin: string;
nonce_hash: string;
user_id: string | null;
ticket: string | null;
challenge: string | null;
credential_id: string | null;
cross_device: boolean;
completion_code_hash: string | null;
status: 'pending' | 'completed' | 'cancelled';
created_at: number;
expires_at: number;
}
interface CompletedPasskeyBridge {
record: PasskeyBridgeRecord;
userId: UserID;
}
const PASSKEY_BRIDGE_KEY_PREFIX = 'passkey_bridge:';
const PASSKEY_BRIDGE_LOCK_PREFIX = 'passkey_bridge_lock:';
const PASSKEY_BRIDGE_SECRET_BYTES = 32;
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function hashMatches(value: string, storedHash: string | null): boolean {
if (storedHash === null) return false;
const presented = Buffer.from(sha256Hex(value), 'hex');
const stored = Buffer.from(storedHash, 'hex');
return presented.length === stored.length && timingSafeEqual(presented, stored);
}
function createSecret(): string {
return randomBytes(PASSKEY_BRIDGE_SECRET_BYTES).toString('base64url');
}
function passkeyBridgeKey(ceremonyId: string): string {
return `${PASSKEY_BRIDGE_KEY_PREFIX}${sha256Hex(ceremonyId)}`;
}
async function writeRecord(ctx: ApiContext, ceremonyId: string, record: PasskeyBridgeRecord): Promise<void> {
const ttlSeconds = Math.floor((record.expires_at - Date.now()) / 1000);
if (ttlSeconds <= 0) {
throw new UnknownPasskeyBridgeError();
}
await ctx.services.cache.set(passkeyBridgeKey(ceremonyId), record, ttlSeconds);
}
function assertCeremonyOrigin(
ctx: ApiContext,
record: PasskeyBridgeRecord,
origin: string | undefined,
expectedOrigin: string,
): void {
if (origin !== expectedOrigin || !isPasskeyTargetOrigin(ctx, record.target_origin)) {
throw new InvalidApiOriginError();
}
}
async function mutateRecord<T>(
ctx: ApiContext,
ceremonyId: string,
origin: string | undefined,
mutate: (record: PasskeyBridgeRecord) => Promise<T>,
): Promise<T> {
const {cache} = ctx.services;
const lockKey = `${PASSKEY_BRIDGE_LOCK_PREFIX}${sha256Hex(ceremonyId)}`;
const lockToken = await cache.acquireLock(lockKey, seconds('10 seconds'));
if (!lockToken) {
throw new UnknownPasskeyBridgeError();
}
try {
const record = await cache.get<PasskeyBridgeRecord>(passkeyBridgeKey(ceremonyId));
if (!record) {
throw new UnknownPasskeyBridgeError();
}
assertCeremonyOrigin(ctx, record, origin, record.ceremony_origin);
return await mutate(record);
} finally {
await cache.releaseLock(lockKey, lockToken);
}
}
async function requireMfaTicketUser(ctx: ApiContext, ticket: string, expectedUserId?: string): Promise<User> {
const userId = await ctx.services.cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId || (expectedUserId !== undefined && userId !== expectedUserId)) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
}
const user = await ctx.services.users.findUniqueAssert(createUserID(BigInt(userId)));
AuthUtility.assertNonBotUser(ctx, user);
return user;
}
async function requireLegacyCredentials(ctx: ApiContext, userId: UserID): Promise<Array<WebAuthnCredential>> {
const legacyRpId = ctx.services.config.auth.passkeys.rpId;
const credentials = visibleWebAuthnCredentials(await ctx.services.users.listWebAuthnCredentials(userId)).filter(
(credential) => effectiveRpId(ctx, credential) === legacyRpId,
);
if (credentials.length === 0) {
throw new NoPasskeysRegisteredError();
}
return credentials;
}
function assertBridgeStartOrigin(ctx: ApiContext, origin: string | undefined): string {
if (!origin || !isPasskeyTargetOrigin(ctx, origin)) {
throw new InvalidApiOriginError();
}
return origin;
}
async function startPasskeyBridge(
ctx: ApiContext,
origin: string,
fields: Pick<PasskeyBridgeRecord, 'purpose' | 'runner' | 'nonce_hash' | 'user_id' | 'ticket'>,
): Promise<PasskeyBridgeStartResponse> {
const ceremonyId = createSecret();
const createdAt = Date.now();
const ceremonyOrigin = fields.runner === 'page' ? passkeyLegacyOriginFor(origin) : origin;
await writeRecord(ctx, ceremonyId, {
...fields,
target_origin: origin,
ceremony_origin: ceremonyOrigin,
challenge: null,
credential_id: null,
cross_device: false,
completion_code_hash: null,
status: 'pending',
created_at: createdAt,
expires_at: createdAt + (fields.purpose === 'login_mfa' ? ms('5 minutes') : ms('10 minutes')),
});
return {
ceremony_id: ceremonyId,
bridge_url: fields.runner === 'page' ? `${ceremonyOrigin}${PASSKEY_BRIDGE_PATH}#${ceremonyId}` : null,
};
}
export async function startPasskeyBridgeLogin(
ctx: ApiContext,
origin: string | undefined,
data: PasskeyBridgeLoginStartRequest,
): Promise<PasskeyBridgeStartResponse> {
const targetOrigin = assertBridgeStartOrigin(ctx, origin);
let userId: string | null = null;
if (data.purpose === 'login_mfa') {
const user = await requireMfaTicketUser(ctx, data.ticket!);
if (!(await resolveWebAuthnSecondFactor(ctx, user))) {
throw new MfaNotEnabledError();
}
await requireLegacyCredentials(ctx, user.id);
userId = user.id.toString();
}
return startPasskeyBridge(ctx, targetOrigin, {
purpose: data.purpose,
runner: data.runner,
nonce_hash: data.nonce_hash,
user_id: userId,
ticket: data.ticket ?? null,
});
}
export async function startPasskeyBridgeSudo(
ctx: ApiContext,
origin: string | undefined,
userId: UserID,
data: PasskeyBridgeSudoStartRequest,
): Promise<PasskeyBridgeStartResponse> {
const targetOrigin = assertBridgeStartOrigin(ctx, origin);
await requireLegacyCredentials(ctx, userId);
return startPasskeyBridge(ctx, targetOrigin, {
purpose: 'sudo',
runner: data.runner,
nonce_hash: data.nonce_hash,
user_id: userId.toString(),
ticket: null,
});
}
export async function getPasskeyBridgeOptions(
ctx: ApiContext,
ceremonyId: string,
origin: string | undefined,
): Promise<{options: PublicKeyCredentialRequestOptionsJSON}> {
return mutateRecord(ctx, ceremonyId, origin, async (record) => {
if (record.status !== 'pending') {
throw new UnknownPasskeyBridgeError();
}
const legacyRpId = ctx.services.config.auth.passkeys.rpId;
const userId = record.user_id === null ? undefined : createUserID(BigInt(record.user_id));
const options = await AuthMfa.generateWebAuthnAuthenticationOptions(ctx, {
selection: {
rpId: legacyRpId,
credentials: userId === undefined ? null : await requireLegacyCredentials(ctx, userId),
},
context: 'bridge',
userId,
});
if (record.challenge !== null) {
await AuthMfa.deleteWebAuthnChallenge(ctx, record.challenge);
}
await writeRecord(ctx, ceremonyId, {...record, challenge: options.challenge});
return {options};
});
}
function buildReturnUrl(record: PasskeyBridgeRecord, ceremonyId: string, completionCode: string): string {
return `${record.target_origin}${PASSKEY_BRIDGE_PATH}#${PASSKEY_BRIDGE_RETURN_FRAGMENT_KEY}=${ceremonyId}.${completionCode}`;
}
async function finishRecord(
ctx: ApiContext,
ceremonyId: string,
record: PasskeyBridgeRecord,
): Promise<PasskeyBridgeFinishResponse> {
const completionCode = createSecret();
await writeRecord(ctx, ceremonyId, {...record, completion_code_hash: sha256Hex(completionCode)});
if (record.runner === 'native') {
return {return_url: null, completion_code: completionCode};
}
return {return_url: buildReturnUrl(record, ceremonyId, completionCode), completion_code: null};
}
export async function completePasskeyBridge(
ctx: ApiContext,
ceremonyId: string,
origin: string | undefined,
data: PasskeyBridgeCompleteRequest,
): Promise<PasskeyBridgeFinishResponse> {
return mutateRecord(ctx, ceremonyId, origin, async (record) => {
if (record.status !== 'pending') {
throw new UnknownPasskeyBridgeError();
}
const {users} = ctx.services;
const credentialId = data.response.id;
const userId =
record.user_id === null
? await users.getUserIdByCredentialId(credentialId)
: createUserID(BigInt(record.user_id));
const credential = userId === null ? null : await users.getWebAuthnCredential(userId, credentialId);
if (
userId === null ||
record.challenge === null ||
credential === null ||
credential.supersededBy !== null ||
effectiveRpId(ctx, credential) !== ctx.services.config.auth.passkeys.rpId
) {
throw new PasskeyAuthenticationFailedError();
}
if (record.purpose === 'login_mfa') {
await requireMfaTicketUser(ctx, record.ticket!, record.user_id!);
await AuthLogin.consumeMfaAttempt(ctx, {userId: record.user_id!, ticket: record.ticket!, field: 'ticket'});
} else if (record.purpose === 'sudo') {
await AuthMfa.consumeSudoMfaAttempt(ctx, userId);
}
await AuthMfa.verifyWebAuthnAuthentication(ctx, userId, data.response, record.challenge, 'bridge', undefined, [
record.ceremony_origin,
]);
return finishRecord(ctx, ceremonyId, {
...record,
status: 'completed',
user_id: userId.toString(),
credential_id: credentialId,
cross_device: data.response.authenticatorAttachment === 'cross-platform',
});
});
}
export async function cancelPasskeyBridge(
ctx: ApiContext,
ceremonyId: string,
origin: string | undefined,
): Promise<PasskeyBridgeFinishResponse> {
return mutateRecord(ctx, ceremonyId, origin, async (record) => {
if (record.status === 'completed') {
throw new UnknownPasskeyBridgeError();
}
return finishRecord(ctx, ceremonyId, {...record, status: 'cancelled'});
});
}
function assertRedeemable(
record: PasskeyBridgeRecord | null,
purposes: ReadonlyArray<PasskeyBridgePurpose>,
expectedUserId: UserID | null,
): asserts record is PasskeyBridgeRecord {
if (
!record ||
!purposes.includes(record.purpose) ||
(expectedUserId !== null && record.user_id !== expectedUserId.toString()) ||
record.status === 'pending'
) {
throw new UnknownPasskeyBridgeError();
}
}
async function redeemPasskeyBridge(
ctx: ApiContext,
ceremonyId: string,
origin: string | undefined,
data: PasskeyBridgeRedeemRequest,
purposes: ReadonlyArray<PasskeyBridgePurpose>,
expectedUserId: UserID | null,
): Promise<CompletedPasskeyBridge | null> {
const {cache} = ctx.services;
const key = passkeyBridgeKey(ceremonyId);
const record = await cache.get<PasskeyBridgeRecord>(key);
if (!record) {
throw new UnknownPasskeyBridgeError();
}
assertCeremonyOrigin(ctx, record, origin, record.target_origin);
assertRedeemable(record, purposes, expectedUserId);
if (!hashMatches(data.nonce, record.nonce_hash) || !hashMatches(data.completion_code, record.completion_code_hash)) {
await cache.delete(key);
throw new InvalidPasskeyBridgeNonceError();
}
const taken = await cache.getAndDelete<PasskeyBridgeRecord>(key);
assertRedeemable(taken, purposes, expectedUserId);
if (!hashMatches(data.nonce, taken.nonce_hash) || !hashMatches(data.completion_code, taken.completion_code_hash)) {
throw new InvalidPasskeyBridgeNonceError();
}
if (taken.status === 'cancelled') {
return null;
}
return {record: taken, userId: createUserID(BigInt(taken.user_id!))};
}
async function recordMigrationIfActive(
ctx: ApiContext,
origin: string | undefined,
completed: CompletedPasskeyBridge,
authSession: AuthSession | undefined,
): Promise<void> {
if (!authSession || !(await isPasskeyMigrationActive(ctx, origin))) return;
await recordPendingPasskeyMigration(ctx, authSession, {
user_id: completed.userId.toString(),
credential_id: completed.record.credential_id!,
cross_device: completed.record.cross_device,
});
}
export async function redeemPasskeyBridgeLogin(
ctx: ApiContext,
ceremonyId: string,
origin: string | undefined,
data: PasskeyBridgeRedeemRequest,
request: Request,
): Promise<PasskeyBridgeLoginRedeemResponse> {
const completed = await redeemPasskeyBridge(ctx, ceremonyId, origin, data, ['login', 'login_mfa'], null);
if (!completed) {
return {status: 'cancelled'};
}
let token: string;
let authSession: AuthSession;
let user: User;
if (completed.record.purpose === 'login_mfa') {
user = await requireMfaTicketUser(ctx, completed.record.ticket!, completed.record.user_id!);
if (!(await resolveWebAuthnSecondFactor(ctx, user))) {
throw new MfaNotEnabledError();
}
[token, authSession] = await AuthLogin.completeMfaLogin(ctx, user, completed.record.ticket!, request);
} else {
user = await ctx.services.users.findUniqueAssert(completed.userId);
[token, authSession] = await AuthLogin.createLoginSession(ctx, user, request);
}
await recordMigrationIfActive(ctx, origin, completed, authSession);
return {status: 'completed', token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
}
export async function redeemPasskeyBridgeSudo(
ctx: ApiContext,
ceremonyId: string,
origin: string | undefined,
data: PasskeyBridgeRedeemRequest,
userId: UserID,
authSession: AuthSession | undefined,
): Promise<PasskeyBridgeSudoRedeemResponse> {
const completed = await redeemPasskeyBridge(ctx, ceremonyId, origin, data, ['sudo'], userId);
if (!completed) {
return {status: 'cancelled'};
}
const sudoToken = await getSudoModeService().generateSudoToken(userId);
await recordMigrationIfActive(ctx, origin, completed, authSession);
return {status: 'completed', sudo_token: sudoToken};
}
@@ -0,0 +1,166 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import * as AuthMfa from '@app/api/auth/AuthMfa';
import {
effectiveRpId,
isPasskeyTargetOrigin,
visibleWebAuthnCredentials,
} from '@app/api/auth/services/PasskeyRelyingParty';
import type {UserID} from '@app/api/BrandedTypes';
import type {AuthSession} from '@app/api/models/AuthSession';
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
import {UnknownPasskeyMigrationError} from '@fluxer/errors/src/domains/auth/UnknownPasskeyMigrationError';
import type {
PasskeyMigrationCompleteRequest,
PasskeyMigrationResponse,
} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
import type {PublicKeyCredentialCreationOptionsJSON} from '@simplewebauthn/server';
import {seconds} from 'itty-time';
const PASSKEY_MIGRATION_KEY_PREFIX = 'passkey_migration:';
interface PendingPasskeyMigration {
user_id: string;
credential_id: string;
cross_device: boolean;
}
interface LivePasskeyMigration {
key: string;
pending: PendingPasskeyMigration;
credential: WebAuthnCredential;
}
function passkeyMigrationKey(authSession: AuthSession): string {
return `${PASSKEY_MIGRATION_KEY_PREFIX}${authSession.sessionIdHash.toString('base64url')}`;
}
function isLegacyVisibleCredential(ctx: ApiContext, credential: WebAuthnCredential): boolean {
return credential.supersededBy === null && effectiveRpId(ctx, credential) === ctx.services.config.auth.passkeys.rpId;
}
export async function recordPendingPasskeyMigration(
ctx: ApiContext,
authSession: AuthSession,
pending: PendingPasskeyMigration,
): Promise<void> {
await ctx.services.cache.set(passkeyMigrationKey(authSession), pending, seconds('5 minutes'));
}
async function loadLivePasskeyMigration(
ctx: ApiContext,
userId: UserID,
authSession: AuthSession | undefined,
): Promise<LivePasskeyMigration | null> {
if (!authSession) return null;
const {cache, users} = ctx.services;
const key = passkeyMigrationKey(authSession);
const pending = await cache.get<PendingPasskeyMigration>(key);
if (!pending) return null;
const credential =
pending.user_id === userId.toString() ? await users.getWebAuthnCredential(userId, pending.credential_id) : null;
if (credential === null || !isLegacyVisibleCredential(ctx, credential)) {
await cache.delete(key);
return null;
}
return {key, pending, credential};
}
async function requireLivePasskeyMigration(
ctx: ApiContext,
userId: UserID,
authSession: AuthSession | undefined,
origin: string | undefined,
): Promise<LivePasskeyMigration> {
const live = isPasskeyTargetOrigin(ctx, origin) ? await loadLivePasskeyMigration(ctx, userId, authSession) : null;
if (!live) {
throw new UnknownPasskeyMigrationError();
}
return live;
}
async function takeLivePasskeyMigration(ctx: ApiContext, userId: UserID, key: string): Promise<WebAuthnCredential> {
const pending = await ctx.services.cache.getAndDelete<PendingPasskeyMigration>(key);
if (!pending || pending.user_id !== userId.toString()) {
throw new UnknownPasskeyMigrationError();
}
const credential = await ctx.services.users.getWebAuthnCredential(userId, pending.credential_id);
if (credential === null || !isLegacyVisibleCredential(ctx, credential)) {
throw new UnknownPasskeyMigrationError();
}
return credential;
}
function visibleTargetCredentials(ctx: ApiContext, credentials: Array<WebAuthnCredential>): Array<WebAuthnCredential> {
return visibleWebAuthnCredentials(credentials).filter(
(credential) => effectiveRpId(ctx, credential) === PASSKEY_MIGRATION_RP_ID,
);
}
export async function getPasskeyMigration(
ctx: ApiContext,
userId: UserID,
authSession: AuthSession | undefined,
): Promise<PasskeyMigrationResponse> {
const live = await loadLivePasskeyMigration(ctx, userId, authSession);
if (!live) return {pending: null};
return {
pending: {
credential_id: live.credential.credentialId,
name: live.credential.name,
cross_device: live.pending.cross_device,
},
};
}
export async function getPasskeyMigrationRegistrationOptions(
ctx: ApiContext,
userId: UserID,
authSession: AuthSession | undefined,
origin: string | undefined,
): Promise<PublicKeyCredentialCreationOptionsJSON> {
const live = await requireLivePasskeyMigration(ctx, userId, authSession, origin);
const credentials = await ctx.services.users.listWebAuthnCredentials(userId);
const options = await AuthMfa.createWebAuthnRegistrationOptions(ctx, userId, {
rpId: PASSKEY_MIGRATION_RP_ID,
context: 'migration_registration',
excludeCredentials: visibleTargetCredentials(ctx, credentials),
});
if (live.pending.cross_device) {
options.hints = ['hybrid', 'security-key'];
}
return options;
}
export async function completePasskeyMigration(
ctx: ApiContext,
userId: UserID,
authSession: AuthSession | undefined,
origin: string | undefined,
data: PasskeyMigrationCompleteRequest,
): Promise<void> {
const {users} = ctx.services;
const live = await requireLivePasskeyMigration(ctx, userId, authSession, origin);
const verified = await AuthMfa.verifyWebAuthnRegistrationResponse(
ctx,
userId,
data.response,
data.challenge,
'migration_registration',
[origin!],
);
const legacy = await takeLivePasskeyMigration(ctx, userId, live.key);
await users.createWebAuthnCredential(
userId,
verified.credentialId,
verified.publicKey,
verified.counter,
verified.transports,
legacy.name,
AuthMfa.storedRpId(ctx, verified.rpId),
);
await users.setWebAuthnCredentialSupersededBy(userId, legacy.credentialId, verified.credentialId);
await AuthMfa.dispatchWebAuthnCredentialsUpdate(ctx, userId);
}
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
const PASSKEY_TARGET_TO_LEGACY_ORIGIN: ReadonlyMap<string, string> = new Map([
['https://fluxer.com', 'https://web.fluxer.app'],
['https://canary.fluxer.com', 'https://web.canary.fluxer.app'],
]);
export interface CredentialRpSelection {
rpId: string;
credentials: Array<WebAuthnCredential>;
}
export function isPasskeyTargetOrigin(ctx: ApiContext, origin: string | null | undefined): boolean {
if (ctx.services.config.instance.selfHosted || !origin) return false;
return PASSKEY_TARGET_TO_LEGACY_ORIGIN.has(origin);
}
export function passkeyLegacyOriginFor(targetOrigin: string): string {
return PASSKEY_TARGET_TO_LEGACY_ORIGIN.get(targetOrigin)!;
}
export function effectiveRpId(ctx: ApiContext, credential: WebAuthnCredential): string {
return credential.rpId ?? ctx.services.config.auth.passkeys.rpId;
}
export function visibleWebAuthnCredentials(credentials: Array<WebAuthnCredential>): Array<WebAuthnCredential> {
return credentials.filter((credential) => credential.supersededBy === null);
}
export function originRpId(ctx: ApiContext, origin: string | null | undefined): string {
return isPasskeyTargetOrigin(ctx, origin) ? PASSKEY_MIGRATION_RP_ID : ctx.services.config.auth.passkeys.rpId;
}
export async function isPasskeyMigrationActive(ctx: ApiContext, origin: string | null | undefined): Promise<boolean> {
if (!isPasskeyTargetOrigin(ctx, origin)) return false;
const config = await getInstanceConfigRepository().getDomainMigrationConfig();
return config.enabled;
}
function credentialGroup(ctx: ApiContext, credentials: Array<WebAuthnCredential>, rpId: string): CredentialRpSelection {
return {rpId, credentials: credentials.filter((credential) => effectiveRpId(ctx, credential) === rpId)};
}
export function selectCredentialRp(
ctx: ApiContext,
origin: string | null | undefined,
credentials: Array<WebAuthnCredential>,
): CredentialRpSelection {
const legacyRpId = ctx.services.config.auth.passkeys.rpId;
const visible = visibleWebAuthnCredentials(credentials);
if (isPasskeyTargetOrigin(ctx, origin)) {
const target = credentialGroup(ctx, visible, PASSKEY_MIGRATION_RP_ID);
return target.credentials.length > 0 ? target : credentialGroup(ctx, visible, legacyRpId);
}
const legacy = credentialGroup(ctx, credentials, legacyRpId);
return legacy.credentials.length > 0 ? legacy : credentialGroup(ctx, visible, PASSKEY_MIGRATION_RP_ID);
}
@@ -0,0 +1,178 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {Config} from '@app/api/Config';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {
type AltchaCaptchaConfig,
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {solveChallenge} from 'altcha-lib';
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
import type {Challenge} from 'altcha-lib/types';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface CaptchaErrorBody {
code: string;
captcha_provider?: string;
altcha_challenge?: Challenge;
}
const FORGOT_PATH = '/auth/forgot';
const FORGOT_BODY = {email: '[email protected]'};
async function setAltchaConfig(overrides: Partial<AltchaCaptchaConfig>): Promise<void> {
await getInstanceConfigRepository().setAltchaCaptchaConfig({
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
enabled: true,
cost: 1000,
max_counter: 100,
...overrides,
});
}
async function solve(challenge: Challenge): Promise<string> {
const solution = await solveChallenge({challenge, deriveKey, timeout: 0});
if (!solution) throw new Error('ALTCHA challenge was not solved');
return Buffer.from(JSON.stringify({challenge, solution}), 'utf8').toString('base64');
}
async function rejectWith(builder: TestRequestBuilder<CaptchaErrorBody>, code: string): Promise<CaptchaErrorBody> {
const {json} = await builder.expect(HTTP_STATUS.BAD_REQUEST, code).executeWithResponse();
expect(json.code).toBe(code);
return json;
}
function forgot(harness: ApiTestHarness): TestRequestBuilder<CaptchaErrorBody> {
return createBuilderWithoutAuth<CaptchaErrorBody>(harness).post(FORGOT_PATH).body(FORGOT_BODY);
}
describe('ALTCHA captcha experiment', () => {
let harness: ApiTestHarness;
let previousCaptchaEnabled: boolean;
let previousTestModeEnabled: boolean;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
previousCaptchaEnabled = Config.captcha.enabled;
previousTestModeEnabled = Config.dev.testModeEnabled;
Config.captcha.enabled = true;
Config.dev.testModeEnabled = true;
});
afterEach(() => {
Config.captcha.enabled = previousCaptchaEnabled;
Config.dev.testModeEnabled = previousTestModeEnabled;
});
afterAll(async () => {
await harness.shutdown();
});
it('keeps the configured provider while the experiment is off', async () => {
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
expect(body).not.toHaveProperty('captcha_provider');
expect(body).not.toHaveProperty('altcha_challenge');
});
it('leaves anonymous requests on the configured provider unless anonymous_enabled is set', async () => {
await setAltchaConfig({rollout_basis_points: 10000});
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
expect(body).not.toHaveProperty('altcha_challenge');
});
it('serves anonymous requests a challenge and accepts the solved payload once', async () => {
await setAltchaConfig({anonymous_enabled: true});
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
expect(required.captcha_provider).toBe('altcha');
expect(required.altcha_challenge?.parameters).toMatchObject({algorithm: 'PBKDF2/SHA-256', cost: 1000});
const token = await solve(required.altcha_challenge as Challenge);
await forgot(harness)
.header('X-Captcha-Token', token)
.header('X-Captcha-Type', 'altcha')
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const replayed = await rejectWith(
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
APIErrorCodes.INVALID_CAPTCHA,
);
expect(replayed.captcha_provider).toBe('altcha');
expect(replayed.altcha_challenge?.signature).not.toBe(required.altcha_challenge?.signature);
});
it('rejects a payload whose derived key does not match the challenge', async () => {
await setAltchaConfig({anonymous_enabled: true});
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
const challenge = required.altcha_challenge as Challenge;
const forged = Buffer.from(
JSON.stringify({challenge, solution: {counter: 1, derivedKey: '00'.repeat(32)}}),
'utf8',
).toString('base64');
await rejectWith(
forgot(harness).header('X-Captcha-Token', forged).header('X-Captcha-Type', 'altcha'),
APIErrorCodes.INVALID_CAPTCHA,
);
});
it('rejects an ALTCHA payload from a requester outside the experiment', async () => {
await setAltchaConfig({anonymous_enabled: true});
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
const token = await solve(required.altcha_challenge as Challenge);
await setAltchaConfig({anonymous_enabled: false});
const rejected = await rejectWith(
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
APIErrorCodes.INVALID_CAPTCHA,
);
expect(rejected).not.toHaveProperty('altcha_challenge');
});
it('buckets signed-in users by their own rollout and still accepts the configured provider', async () => {
Config.captcha.enabled = false;
const included = await createTestAccount(harness);
const excluded = await createTestAccount(harness);
Config.captcha.enabled = true;
await setAltchaConfig({
anonymous_enabled: true,
included_user_ids: [included.userId],
excluded_user_ids: [excluded.userId],
});
const redeemPath = '/gifts/altcha-gift-code/redeem';
const excludedBody = await rejectWith(
createBuilder<CaptchaErrorBody>(harness, excluded.token).post(redeemPath),
APIErrorCodes.CAPTCHA_REQUIRED,
);
expect(excludedBody).not.toHaveProperty('altcha_challenge');
const includedBody = await rejectWith(
createBuilder<CaptchaErrorBody>(harness, included.token).post(redeemPath),
APIErrorCodes.CAPTCHA_REQUIRED,
);
const token = await solve(includedBody.altcha_challenge as Challenge);
const solved = await createBuilder<CaptchaErrorBody>(harness, included.token)
.post(redeemPath)
.header('X-Captcha-Token', token)
.header('X-Captcha-Type', 'altcha')
.executeRaw();
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(solved.json?.code);
const classic = await createBuilder<CaptchaErrorBody>(harness, included.token)
.post(redeemPath)
.header('X-Captcha-Token', 'hcaptcha-token')
.header('X-Captcha-Type', 'hcaptcha')
.executeRaw();
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(classic.json?.code);
});
});
@@ -0,0 +1,213 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash, randomBytes} from 'node:crypto';
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
import {getConfig} from '@app/api/Config';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
import {
ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH,
type OriginHandoffCreateResponse,
type OriginHandoffRedeemResponse,
} from '@fluxer/schema/src/domains/auth/OriginHandoffSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const CREATE_PATH = '/auth/origin-handoff';
const REDEEM_PATH = '/auth/origin-handoff/redeem';
const PAYLOAD = randomBytes(96).toString('base64url');
function createNonce(): {nonce: string; nonceHash: string} {
const nonce = randomBytes(32).toString('base64url');
return {nonce, nonceHash: createHash('sha256').update(nonce).digest('hex')};
}
describe('Origin handoff', () => {
let harness: ApiTestHarness;
let webAppOrigin: string;
beforeAll(async () => {
harness = await createAuthHarness();
webAppOrigin = getConfig().endpoints.webAppOrigins[0];
});
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
getConfig().instance.selfHosted = false;
getConfig().endpoints.webAppOrigins = [webAppOrigin];
});
afterAll(async () => {
await harness?.shutdown();
});
async function createHandoff(token: string, nonceHash: string): Promise<string> {
const response = await createBuilder<OriginHandoffCreateResponse>(harness, token)
.post(CREATE_PATH)
.body({nonce_hash: nonceHash, payload: PAYLOAD})
.execute();
expect(response.handoff_id).toMatch(/^[A-Za-z0-9_-]{43}$/);
return response.handoff_id;
}
it('hands the payload over once to the origin that holds the nonce', async () => {
const account = await createTestAccount(harness);
const {nonce, nonceHash} = createNonce();
const handoffId = await createHandoff(account.token, nonceHash);
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
.post(REDEEM_PATH)
.header('origin', webAppOrigin)
.body({handoff_id: handoffId, nonce})
.execute();
expect(redeemed).toEqual({payload: PAYLOAD});
await createBuilderWithoutAuth(harness)
.post(REDEEM_PATH)
.header('origin', webAppOrigin)
.body({handoff_id: handoffId, nonce})
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_ORIGIN_HANDOFF)
.execute();
});
it('consumes the handoff when the nonce does not match', async () => {
const account = await createTestAccount(harness);
const {nonce, nonceHash} = createNonce();
const handoffId = await createHandoff(account.token, nonceHash);
await createBuilderWithoutAuth(harness)
.post(REDEEM_PATH)
.header('origin', webAppOrigin)
.body({handoff_id: handoffId, nonce: createNonce().nonce})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_ORIGIN_HANDOFF_NONCE)
.execute();
await createBuilderWithoutAuth(harness)
.post(REDEEM_PATH)
.header('origin', webAppOrigin)
.body({handoff_id: handoffId, nonce})
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_ORIGIN_HANDOFF)
.execute();
});
it('answers an unknown handoff id with its own error code', async () => {
await createBuilderWithoutAuth(harness)
.post(REDEEM_PATH)
.header('origin', webAppOrigin)
.body({handoff_id: randomBytes(32).toString('base64url'), nonce: createNonce().nonce})
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_ORIGIN_HANDOFF)
.execute();
});
it('requires a logged-in user to create a handoff', async () => {
await createBuilderWithoutAuth(harness)
.post(CREATE_PATH)
.body({nonce_hash: createNonce().nonceHash, payload: PAYLOAD})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
it('refuses to create a handoff for an account flagged as suspicious', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/security-flags`)
.body({suspicious_activity_flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE})
.execute();
await createBuilder(harness, account.token)
.post(CREATE_PATH)
.body({nonce_hash: createNonce().nonceHash, payload: PAYLOAD})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.ACCOUNT_SUSPICIOUS_ACTIVITY)
.execute();
});
it('refuses a create body larger than the payload ceiling before parsing it', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post(CREATE_PATH)
.body({nonce_hash: createNonce().nonceHash, payload: 'a'.repeat(ORIGIN_HANDOFF_MAX_PAYLOAD_LENGTH + 2048)})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.FILE_SIZE_TOO_LARGE)
.execute();
});
it('refuses to create a handoff for a bot', async () => {
const bot = await createTestBotAccount(harness);
await createBuilder(harness, `Bot ${bot.botToken}`)
.post(CREATE_PATH)
.body({nonce_hash: createNonce().nonceHash, payload: PAYLOAD})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
it.each([
{name: 'an uppercase nonce hash', body: {nonce_hash: 'A'.repeat(64), payload: PAYLOAD}},
{name: 'a short nonce hash', body: {nonce_hash: 'a'.repeat(63), payload: PAYLOAD}},
{name: 'a payload outside base64url', body: {nonce_hash: 'a'.repeat(64), payload: 'not+base64/url='}},
{name: 'an empty payload', body: {nonce_hash: 'a'.repeat(64), payload: ''}},
])('rejects $name', async ({body}) => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post(CREATE_PATH)
.body(body)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
.execute();
});
it('refuses a redeem from an origin outside the first-party web origins', async () => {
const account = await createTestAccount(harness);
const {nonce, nonceHash} = createNonce();
const handoffId = await createHandoff(account.token, nonceHash);
await createBuilderWithoutAuth(harness)
.post(REDEEM_PATH)
.header('origin', 'https://evil.example')
.body({handoff_id: handoffId, nonce})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
.execute();
await createBuilderWithoutAuth(harness)
.post(REDEEM_PATH)
.body({handoff_id: handoffId, nonce})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
.execute();
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
.post(REDEEM_PATH)
.header('origin', webAppOrigin)
.body({handoff_id: handoffId, nonce})
.execute();
expect(redeemed.payload).toBe(PAYLOAD);
});
it('accepts a redeem from a configured web app origin alias', async () => {
getConfig().endpoints.webAppOrigins = [webAppOrigin, 'https://fluxer.com'];
const account = await createTestAccount(harness);
const {nonce, nonceHash} = createNonce();
const handoffId = await createHandoff(account.token, nonceHash);
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
.post(REDEEM_PATH)
.header('origin', 'https://fluxer.com')
.body({handoff_id: handoffId, nonce})
.execute();
expect(redeemed.payload).toBe(PAYLOAD);
});
it('skips the origin check on a self-hosted instance', async () => {
getConfig().instance.selfHosted = true;
const account = await createTestAccount(harness);
const {nonce, nonceHash} = createNonce();
const handoffId = await createHandoff(account.token, nonceHash);
const redeemed = await createBuilderWithoutAuth<OriginHandoffRedeemResponse>(harness)
.post(REDEEM_PATH)
.body({handoff_id: handoffId, nonce})
.execute();
expect(redeemed.payload).toBe(PAYLOAD);
});
});
@@ -0,0 +1,391 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {
createAuthHarness,
createTestAccount,
type LoginMfaResponse,
loginUser,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {
type BridgeNonce,
createBridgeNonce,
LEGACY_ORIGIN,
LEGACY_RP_ID,
registerPasskey,
runNativeSudoBridge,
setDomainMigration,
TARGET_ORIGIN,
} from '@app/api/auth/tests/PasskeyTestUtils';
import {
createAuthenticationResponse,
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
setWebAuthnTwoFactor,
type WebAuthnAuthenticationOptions,
type WebAuthnDevice,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {getConfig} from '@app/api/Config';
import {getCacheService} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {
PasskeyBridgeFinishResponse,
PasskeyBridgeLoginRedeemResponse,
PasskeyBridgeStartResponse,
PasskeyBridgeSudoRedeemResponse,
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const SUDO_MODE_HEADER = 'X-Fluxer-Sudo-Mode-JWT';
interface StartedBridge {
ceremonyId: string;
bridgeUrl: string | null;
nonce: BridgeNonce;
}
describe('Passkey bridge', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await setDomainMigration(true);
});
afterEach(() => {
getConfig().instance.selfHosted = false;
});
afterAll(async () => {
await harness?.shutdown();
});
async function createLegacyAccount(): Promise<{account: TestAccount; device: WebAuthnDevice}> {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerPasskey(harness, account.token, device, {password: account.password}, 'Old');
return {account, device};
}
async function startLogin(body: Record<string, unknown> = {}, origin = TARGET_ORIGIN): Promise<StartedBridge> {
const nonce = createBridgeNonce();
const start = await createBuilderWithoutAuth<PasskeyBridgeStartResponse>(harness)
.post('/auth/passkey-bridge')
.header('origin', origin)
.body({purpose: 'login', runner: 'page', nonce_hash: nonce.nonceHash, ...body})
.execute();
return {ceremonyId: start.ceremony_id, bridgeUrl: start.bridge_url, nonce};
}
async function startSudo(token: string, runner: 'page' | 'native' = 'page'): Promise<StartedBridge> {
const nonce = createBridgeNonce();
const start = await createBuilder<PasskeyBridgeStartResponse>(harness, token)
.post('/users/@me/passkey-bridge')
.header('origin', TARGET_ORIGIN)
.body({runner, nonce_hash: nonce.nonceHash})
.execute();
return {ceremonyId: start.ceremony_id, bridgeUrl: start.bridge_url, nonce};
}
async function fetchOptions(ceremonyId: string, origin = LEGACY_ORIGIN): Promise<WebAuthnAuthenticationOptions> {
const {options} = await createBuilderWithoutAuth<{options: WebAuthnAuthenticationOptions}>(harness)
.post(`/auth/passkey-bridge/${ceremonyId}/options`)
.header('origin', origin)
.execute();
return options;
}
async function complete(
ceremonyId: string,
device: WebAuthnDevice,
origin = LEGACY_ORIGIN,
): Promise<PasskeyBridgeFinishResponse> {
const options = await fetchOptions(ceremonyId, origin);
return createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
.post(`/auth/passkey-bridge/${ceremonyId}/complete`)
.header('origin', origin)
.body({response: createAuthenticationResponse(device, options)})
.execute();
}
function completionCodeFrom(finish: PasskeyBridgeFinishResponse, ceremonyId: string): string {
const url = new URL(finish.return_url!);
const [id, code] = url.hash.slice('#passkey-bridge='.length).split('.');
expect(id).toBe(ceremonyId);
return code;
}
function redeemLogin(ceremonyId: string, nonce: string, completionCode: string) {
return createBuilderWithoutAuth<PasskeyBridgeLoginRedeemResponse>(harness)
.post(`/auth/passkey-bridge/${ceremonyId}/redeem`)
.header('origin', TARGET_ORIGIN)
.body({nonce, completion_code: completionCode});
}
it('refuses to start outside the new origin and on a self-hosted instance, whatever the switch', async () => {
const nonce = createBridgeNonce();
const body = {purpose: 'login', runner: 'native', nonce_hash: nonce.nonceHash};
for (const origin of [LEGACY_ORIGIN, 'https://evil.example']) {
await createBuilderWithoutAuth(harness)
.post('/auth/passkey-bridge')
.header('origin', origin)
.body(body)
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
.execute();
}
await createBuilderWithoutAuth(harness)
.post('/auth/passkey-bridge')
.body(body)
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
.execute();
getConfig().instance.selfHosted = true;
await createBuilderWithoutAuth(harness)
.post('/auth/passkey-bridge')
.header('origin', TARGET_ORIGIN)
.body(body)
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
.execute();
getConfig().instance.selfHosted = false;
await setDomainMigration(false);
await createBuilderWithoutAuth(harness)
.post('/auth/passkey-bridge')
.header('origin', TARGET_ORIGIN)
.body(body)
.expect(HTTP_STATUS.OK)
.execute();
});
it('runs the ceremony only on the paired origin and keeps going when the switch goes off', async () => {
const {device} = await createLegacyAccount();
const started = await startLogin();
expect(started.bridgeUrl).toBe(`${LEGACY_ORIGIN}/passkey-bridge#${started.ceremonyId}`);
await createBuilderWithoutAuth(harness)
.post(`/auth/passkey-bridge/${started.ceremonyId}/options`)
.header('origin', TARGET_ORIGIN)
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
.execute();
const options = await fetchOptions(started.ceremonyId);
expect(options.rpId).toBe(LEGACY_RP_ID);
expect(options.allowCredentials).toBeUndefined();
expect(options.userVerification).toBe('required');
await setDomainMigration(false);
await createBuilderWithoutAuth(harness)
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
.header('origin', LEGACY_ORIGIN)
.body({response: createAuthenticationResponse(device, options)})
.expect(HTTP_STATUS.OK)
.execute();
});
it('signs in through a page ceremony and always returns to the bridge page', async () => {
const {account, device} = await createLegacyAccount();
const started = await startLogin({
return_path: '/api/v1/oauth2/authorize?prompt=none&redirect_uri=https://evil.example/cb',
});
const finish = await complete(started.ceremonyId, device);
expect(finish.completion_code).toBeNull();
const returnUrl = new URL(finish.return_url!);
expect(`${returnUrl.origin}${returnUrl.pathname}${returnUrl.search}`).toBe(`${TARGET_ORIGIN}/passkey-bridge`);
const code = completionCodeFrom(finish, started.ceremonyId);
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
expect(redeemed.status).toBe('completed');
if (redeemed.status !== 'completed') return;
expect(redeemed.user_id).toBe(account.userId);
const me = await createBuilder<{id: string}>(harness, redeemed.token).get('/users/@me').execute();
expect(me.id).toBe(account.userId);
await redeemLogin(started.ceremonyId, started.nonce.nonce, code)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
});
it('needs both the nonce and the completion code', async () => {
const {device} = await createLegacyAccount();
const started = await startLogin();
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
const attacker = createBridgeNonce();
await redeemLogin(started.ceremonyId, attacker.nonce, code)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_PASSKEY_BRIDGE_NONCE)
.execute();
await redeemLogin(started.ceremonyId, started.nonce.nonce, code)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
const second = await startLogin();
completionCodeFrom(await complete(second.ceremonyId, device), second.ceremonyId);
await redeemLogin(second.ceremonyId, second.nonce.nonce, 'A'.repeat(43))
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_PASSKEY_BRIDGE_NONCE)
.execute();
await redeemLogin(second.ceremonyId, second.nonce.nonce, 'A'.repeat(43))
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
});
it('keeps a pending ceremony when redeemed early or verification fails', async () => {
const {account, device} = await createLegacyAccount();
const target = createWebAuthnDevice();
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
const started = await startLogin();
await redeemLogin(started.ceremonyId, started.nonce.nonce, 'A'.repeat(43))
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
const options = await fetchOptions(started.ceremonyId);
await createBuilderWithoutAuth(harness)
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
.header('origin', LEGACY_ORIGIN)
.body({response: createAuthenticationResponse(target, options)})
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
.execute();
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
expect(redeemed.status).toBe('completed');
});
it('never lets a bridge challenge through the normal endpoints', async () => {
const {device} = await createLegacyAccount();
const started = await startLogin();
const options = await fetchOptions(started.ceremonyId);
await createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.header('origin', LEGACY_ORIGIN)
.body({response: createAuthenticationResponse(device, options), challenge: options.challenge})
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
.execute();
});
it('reports a cancelled ceremony and refuses to cancel a completed one', async () => {
const {device} = await createLegacyAccount();
const started = await startLogin();
const cancelled = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
.post(`/auth/passkey-bridge/${started.ceremonyId}/cancel`)
.header('origin', LEGACY_ORIGIN)
.execute();
const code = completionCodeFrom(cancelled, started.ceremonyId);
expect(await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute()).toEqual({status: 'cancelled'});
const second = await startLogin();
await complete(second.ceremonyId, device);
await createBuilderWithoutAuth(harness)
.post(`/auth/passkey-bridge/${second.ceremonyId}/cancel`)
.header('origin', LEGACY_ORIGIN)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
});
it('completes two-factor sign in for the ticket holder', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
await registerPasskey(
harness,
account.token,
device,
{mfa_method: 'totp', mfa_code: generateTotpCode(secret)},
'Old',
);
await setWebAuthnTwoFactor(harness, account.token, true, {mfa_method: 'totp', mfa_code: generateTotpCode(secret)});
const login = (await loginUser(harness, {email: account.email, password: account.password})) as LoginMfaResponse;
const started = await startLogin({purpose: 'login_mfa', ticket: login.ticket});
const options = await fetchOptions(started.ceremonyId);
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([device.credentialId.toString('base64url')]);
expect(options.userVerification).toBe('discouraged');
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
expect(redeemed.status).toBe('completed');
await createBuilderWithoutAuth(harness)
.post('/auth/login/mfa/totp')
.body({code: generateTotpCode(secret), ticket: login.ticket})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
it('issues a sudo token that passes a sudo-protected route', async () => {
const {account, device} = await createLegacyAccount();
const credentialId = device.credentialId.toString('base64url');
await createBuilder(harness, account.token)
.patch(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
.body({name: 'Renamed'})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
const redeemed = await runNativeSudoBridge(harness, account.token, device);
expect(redeemed.status).toBe('completed');
if (redeemed.status !== 'completed') return;
await createBuilder(harness, account.token)
.patch(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
.header(SUDO_MODE_HEADER, redeemed.sudo_token)
.body({name: 'Renamed'})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
it('returns sudo page ceremonies to the bridge page on the new origin', async () => {
const {account, device} = await createLegacyAccount();
const started = await startSudo(account.token);
const finish = await complete(started.ceremonyId, device);
const returnUrl = new URL(finish.return_url!);
expect(`${returnUrl.origin}${returnUrl.pathname}`).toBe(`${TARGET_ORIGIN}/passkey-bridge`);
const code = completionCodeFrom(finish, started.ceremonyId);
const redeemed = await createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, account.token)
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
.header('origin', TARGET_ORIGIN)
.body({nonce: started.nonce.nonce, completion_code: code})
.execute();
expect(redeemed.status).toBe('completed');
});
it('does not consume a ceremony redeemed on the wrong route or by another user', async () => {
const {account, device} = await createLegacyAccount();
const other = await createTestAccount(harness);
const started = await startSudo(account.token, 'native');
const options = await fetchOptions(started.ceremonyId, TARGET_ORIGIN);
const finish = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
.header('origin', TARGET_ORIGIN)
.body({response: createAuthenticationResponse(device, options)})
.execute();
const body = {nonce: started.nonce.nonce, completion_code: finish.completion_code};
await redeemLogin(started.ceremonyId, body.nonce, body.completion_code!)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
await createBuilder(harness, other.token)
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
.header('origin', TARGET_ORIGIN)
.body(body)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
const redeemed = await createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, account.token)
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
.header('origin', TARGET_ORIGIN)
.body(body)
.execute();
expect(redeemed.status).toBe('completed');
});
it('always stores the ceremony with an expiry', async () => {
const {device} = await createLegacyAccount();
const started = await startLogin();
const key = `passkey_bridge:${createHash('sha256').update(started.ceremonyId).digest('hex')}`;
const cache = getCacheService();
const ttls = [await cache.ttl(key)];
await fetchOptions(started.ceremonyId);
ttls.push(await cache.ttl(key));
await complete(started.ceremonyId, device);
ttls.push(await cache.ttl(key));
for (const ttl of ttls) {
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(600);
}
});
});
@@ -0,0 +1,260 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAuthHarness, createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
LEGACY_RP_ID,
listPasskeys,
registerPasskey,
runNativeSudoBridge,
setDomainMigration,
TARGET_ORIGIN,
TARGET_RP_ID,
} from '@app/api/auth/tests/PasskeyTestUtils';
import {
createAuthenticationResponse,
createRegistrationResponse,
createWebAuthnDevice,
type WebAuthnAuthenticationOptions,
type WebAuthnDevice,
type WebAuthnRegistrationOptions,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {PasskeyMigrationResponse} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const MIGRATION_PATH = '/users/@me/mfa/webauthn/migration';
const MIGRATION_OPTIONS_PATH = '/users/@me/mfa/webauthn/migration/registration-options';
function credentialIdOf(device: WebAuthnDevice): string {
return device.credentialId.toString('base64url');
}
interface RpcSessionResponse {
data: {webauthn_credentials: Array<{id: string; rp_id: string}>};
}
describe('Passkey migration', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function createAssignedAccount(): Promise<{account: TestAccount; legacy: WebAuthnDevice}> {
const account = await createTestAccount(harness);
const legacy = createWebAuthnDevice();
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Laptop');
await setDomainMigration(true, [account.userId]);
return {account, legacy};
}
async function getPending(token: string): Promise<PasskeyMigrationResponse['pending']> {
const response = await createBuilder<PasskeyMigrationResponse>(harness, token).get(MIGRATION_PATH).execute();
return response.pending;
}
async function migrationOptions(token: string): Promise<WebAuthnRegistrationOptions> {
return createBuilder<WebAuthnRegistrationOptions>(harness, token)
.post(MIGRATION_OPTIONS_PATH)
.header('origin', TARGET_ORIGIN)
.execute();
}
function completeMigration(token: string, device: WebAuthnDevice, options: WebAuthnRegistrationOptions) {
return createBuilder(harness, token)
.post(MIGRATION_PATH)
.header('origin', TARGET_ORIGIN)
.body({response: createRegistrationResponse(device, options, 'Laptop'), challenge: options.challenge});
}
async function migrate(account: TestAccount, legacy: WebAuthnDevice): Promise<WebAuthnDevice> {
await runNativeSudoBridge(harness, account.token, legacy);
const target = createWebAuthnDevice();
await completeMigration(account.token, target, await migrationOptions(account.token))
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
return target;
}
async function discoverableLogin(device: WebAuthnDevice, origin?: string, status: number = HTTP_STATUS.OK) {
const optionsBuilder = createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null);
if (origin) optionsBuilder.header('origin', origin);
const options = await optionsBuilder.execute();
const builder = createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.body({response: createAuthenticationResponse(device, options), challenge: options.challenge})
.expect(status);
if (origin) builder.header('origin', origin);
await builder.execute();
}
it('records a pending update for any account on the new origin while the switch is on', async () => {
const unassigned = await createTestAccount(harness);
const unassignedDevice = createWebAuthnDevice();
await registerPasskey(harness, unassigned.token, unassignedDevice, {password: unassigned.password}, 'Laptop');
await setDomainMigration(false);
expect((await runNativeSudoBridge(harness, unassigned.token, unassignedDevice)).status).toBe('completed');
expect(await getPending(unassigned.token)).toBeNull();
await setDomainMigration(true);
expect((await runNativeSudoBridge(harness, unassigned.token, unassignedDevice)).status).toBe('completed');
expect(await getPending(unassigned.token)).toEqual({
credential_id: credentialIdOf(unassignedDevice),
name: 'Laptop',
cross_device: false,
});
});
it('needs a pending update and the new origin for registration options', async () => {
const {account, legacy} = await createAssignedAccount();
await createBuilder(harness, account.token)
.post(MIGRATION_OPTIONS_PATH)
.header('origin', TARGET_ORIGIN)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_MIGRATION)
.execute();
await runNativeSudoBridge(harness, account.token, legacy);
await createBuilder(harness, account.token)
.post(MIGRATION_OPTIONS_PATH)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_MIGRATION)
.execute();
const options = await migrationOptions(account.token);
expect(options.rp.id).toBe(TARGET_RP_ID);
});
it('replaces the passkey under the same name and hides the old one', async () => {
const {account, legacy} = await createAssignedAccount();
const target = await migrate(account, legacy);
const credentials = await listPasskeys(harness, account.token);
expect(credentials).toEqual([
expect.objectContaining({id: credentialIdOf(target), name: 'Laptop', rp_id: TARGET_RP_ID}),
]);
const old = await getUserRepository().getWebAuthnCredential(
createUserID(BigInt(account.userId)),
credentialIdOf(legacy),
);
expect(old?.supersededBy).toBe(credentialIdOf(target));
expect(await getPending(account.token)).toBeNull();
const ready = await createBuilder<RpcSessionResponse>(harness, '')
.post('/test/rpc-session-init')
.body({type: 'session', token: account.token, version: 1, ip: '127.0.0.1'})
.execute();
expect(ready.data.webauthn_credentials.map(({id, rp_id}) => ({id, rp_id}))).toEqual([
{id: credentialIdOf(target), rp_id: TARGET_RP_ID},
]);
});
it('keeps the old passkey working off the new origin', async () => {
const {account, legacy} = await createAssignedAccount();
const target = await migrate(account, legacy);
await discoverableLogin(legacy);
await discoverableLogin(legacy, TARGET_ORIGIN, HTTP_STATUS.UNAUTHORIZED);
await discoverableLogin(target, TARGET_ORIGIN);
await createBuilder(harness, account.token)
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(legacy)}`)
.body({name: 'Renamed', password: account.password})
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_WEBAUTHN_CREDENTIAL)
.execute();
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, account.token)
.post('/users/@me/sudo/webauthn/authentication-options')
.body(null)
.execute();
expect(sudoOptions.rpId).toBe(LEGACY_RP_ID);
expect(sudoOptions.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
});
it('removes the old passkey together with its replacement', async () => {
const {account, legacy} = await createAssignedAccount();
const target = await migrate(account, legacy);
await createBuilder(harness, account.token)
.delete(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(target)}`)
.body({password: account.password})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
expect(await getUserRepository().listWebAuthnCredentials(createUserID(BigInt(account.userId)))).toEqual([]);
await discoverableLogin(legacy, undefined, HTTP_STATUS.UNAUTHORIZED);
});
it('removes every remaining superseded passkey with the last visible one', async () => {
const account = await createTestAccount(harness);
const orphan = createWebAuthnDevice();
const visible = createWebAuthnDevice();
await registerPasskey(harness, account.token, orphan, {password: account.password}, 'Orphan');
await registerPasskey(harness, account.token, visible, {password: account.password}, 'Visible');
const userId = createUserID(BigInt(account.userId));
await getUserRepository().setWebAuthnCredentialSupersededBy(userId, credentialIdOf(orphan), 'gone');
await createBuilder(harness, account.token)
.delete(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
.body({password: account.password})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
expect(await getUserRepository().listWebAuthnCredentials(userId)).toEqual([]);
});
it('has no way to attach the old passkey to another one', async () => {
const {account, legacy} = await createAssignedAccount();
await runNativeSudoBridge(harness, account.token, legacy);
const target = createWebAuthnDevice();
await registerPasskey(harness, account.token, target, {password: account.password}, 'Phone', TARGET_ORIGIN);
await createBuilder(harness, account.token)
.delete(MIGRATION_PATH)
.header('origin', TARGET_ORIGIN)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
expect((await listPasskeys(harness, account.token)).map((cred) => cred.id).sort()).toEqual(
[credentialIdOf(legacy), credentialIdOf(target)].sort(),
);
});
it('never lets a migration challenge through the normal registration route', async () => {
const {account, legacy} = await createAssignedAccount();
await runNativeSudoBridge(harness, account.token, legacy);
const options = await migrationOptions(account.token);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.header('origin', TARGET_ORIGIN)
.body({
response: createRegistrationResponse(createWebAuthnDevice(), options, 'Sneaky'),
challenge: options.challenge,
name: 'Sneaky',
})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL)
.execute();
});
it('creates one credential when two updates race', async () => {
const {account, legacy} = await createAssignedAccount();
await runNativeSudoBridge(harness, account.token, legacy);
const first = await migrationOptions(account.token);
const second = await migrationOptions(account.token);
const results = await Promise.all(
[first, second].map((options) =>
completeMigration(account.token, createWebAuthnDevice(), options)
.expect(HTTP_STATUS.NO_CONTENT)
.executeWithResponse()
.then(
() => 'ok',
() => 'failed',
),
),
);
expect(results.sort()).toEqual(['failed', 'ok']);
const credentials = await listPasskeys(harness, account.token);
expect(credentials).toHaveLength(1);
expect(credentials[0].rp_id).toBe(TARGET_RP_ID);
});
});
@@ -0,0 +1,232 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAuthHarness, createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
LEGACY_ORIGIN,
LEGACY_RP_ID,
listPasskeys,
registerPasskey,
TARGET_ORIGIN,
TARGET_RP_ID,
} from '@app/api/auth/tests/PasskeyTestUtils';
import {
createAuthenticationResponse,
createWebAuthnDevice,
type WebAuthnAuthenticationOptions,
type WebAuthnDevice,
type WebAuthnRegistrationOptions,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {getConfig} from '@app/api/Config';
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
function credentialIdOf(device: WebAuthnDevice): string {
return device.credentialId.toString('base64url');
}
describe('Passkey relying party selection', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
getConfig().instance.selfHosted = false;
});
afterAll(async () => {
await harness?.shutdown();
});
async function registrationRpId(account: TestAccount, origin?: string): Promise<string> {
const builder = createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({password: account.password});
if (origin) builder.header('origin', origin);
return (await builder.execute()).rp.id;
}
async function discoverableOptions(origin?: string): Promise<WebAuthnAuthenticationOptions> {
const builder = createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null);
if (origin) builder.header('origin', origin);
return builder.execute();
}
async function sudoOptions(token: string, origin?: string): Promise<WebAuthnAuthenticationOptions> {
const builder = createBuilder<WebAuthnAuthenticationOptions>(harness, token)
.post('/users/@me/sudo/webauthn/authentication-options')
.body(null);
if (origin) builder.header('origin', origin);
return builder.execute();
}
async function createMixedAccount(): Promise<{account: TestAccount; legacy: WebAuthnDevice; target: WebAuthnDevice}> {
const account = await createTestAccount(harness);
const legacy = createWebAuthnDevice();
const target = createWebAuthnDevice();
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Old');
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
return {account, legacy, target};
}
it('uses the new relying party only for requests from the new origin', async () => {
const account = await createTestAccount(harness);
expect(await registrationRpId(account)).toBe(LEGACY_RP_ID);
expect(await registrationRpId(account, LEGACY_ORIGIN)).toBe(LEGACY_RP_ID);
expect(await registrationRpId(account, TARGET_ORIGIN)).toBe(TARGET_RP_ID);
expect((await discoverableOptions()).rpId).toBe(LEGACY_RP_ID);
expect((await discoverableOptions(LEGACY_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
expect((await discoverableOptions(TARGET_ORIGIN)).rpId).toBe(TARGET_RP_ID);
});
it('keeps the legacy relying party everywhere on a self-hosted instance', async () => {
getConfig().instance.selfHosted = true;
const account = await createTestAccount(harness);
expect(await registrationRpId(account, TARGET_ORIGIN)).toBe(LEGACY_RP_ID);
expect((await discoverableOptions(TARGET_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
});
it('stores and exposes the relying party of each passkey', async () => {
const {account, legacy, target} = await createMixedAccount();
const credentials = await listPasskeys(harness, account.token);
expect(credentials.map(({id, rp_id}) => ({id, rp_id}))).toEqual(
expect.arrayContaining([
{id: credentialIdOf(legacy), rp_id: LEGACY_RP_ID},
{id: credentialIdOf(target), rp_id: TARGET_RP_ID},
]),
);
const legacyRow = await getUserRepository().getWebAuthnCredential(
createUserID(BigInt(account.userId)),
credentialIdOf(legacy),
);
expect(legacyRow?.rpId).toBeNull();
});
it('keeps the legacy options unchanged for a legacy-only account off the new origin', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerPasskey(harness, account.token, device, {password: account.password}, 'Old');
for (const origin of [undefined, LEGACY_ORIGIN]) {
const options = await sudoOptions(account.token, origin);
expect(options.rpId).toBe(LEGACY_RP_ID);
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(device)]);
expect(options.userVerification).toBe('discouraged');
}
});
it('offers one relying party group per request', async () => {
const {account, legacy, target} = await createMixedAccount();
const onTarget = await sudoOptions(account.token, TARGET_ORIGIN);
expect(onTarget.rpId).toBe(TARGET_RP_ID);
expect(onTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
const offTarget = await sudoOptions(account.token);
expect(offTarget.rpId).toBe(LEGACY_RP_ID);
expect(offTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
});
it('falls back to the other group when the preferred one is empty', async () => {
const legacyOnly = await createTestAccount(harness);
const legacy = createWebAuthnDevice();
await registerPasskey(harness, legacyOnly.token, legacy, {password: legacyOnly.password}, 'Old');
expect((await sudoOptions(legacyOnly.token, TARGET_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
const targetOnly = await createTestAccount(harness);
const target = createWebAuthnDevice();
await registerPasskey(harness, targetOnly.token, target, {password: targetOnly.password}, 'New', TARGET_ORIGIN);
expect((await sudoOptions(targetOnly.token)).rpId).toBe(TARGET_RP_ID);
});
it('rejects a passkey from another relying party before the test mode shortcut', async () => {
const {legacy} = await createMixedAccount();
const options = await discoverableOptions(TARGET_ORIGIN);
await createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.header('origin', TARGET_ORIGIN)
.body({response: createAuthenticationResponse(legacy, options), challenge: options.challenge})
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
.execute();
});
it('rejects a passkey outside the offered list before the test mode shortcut', async () => {
const account = await createTestAccount(harness);
const visible = createWebAuthnDevice();
const superseded = createWebAuthnDevice();
await registerPasskey(harness, account.token, visible, {password: account.password}, 'Visible');
await registerPasskey(harness, account.token, superseded, {password: account.password}, 'Superseded');
await getUserRepository().setWebAuthnCredentialSupersededBy(
createUserID(BigInt(account.userId)),
credentialIdOf(superseded),
credentialIdOf(visible),
);
const options = await sudoOptions(account.token, TARGET_ORIGIN);
expect(options.rpId).toBe(LEGACY_RP_ID);
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(visible)]);
await createBuilder(harness, account.token)
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
.header('origin', TARGET_ORIGIN)
.body({
name: 'Renamed',
mfa_method: 'webauthn',
webauthn_response: createAuthenticationResponse(superseded, options),
webauthn_challenge: options.challenge,
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
const retry = await sudoOptions(account.token, TARGET_ORIGIN);
await createBuilder(harness, account.token)
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
.header('origin', TARGET_ORIGIN)
.body({
name: 'Renamed',
mfa_method: 'webauthn',
webauthn_response: createAuthenticationResponse(visible, retry),
webauthn_challenge: retry.challenge,
})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
it('accepts a superseded passkey only off the new origin', async () => {
const account = await createTestAccount(harness);
const legacy = createWebAuthnDevice();
const target = createWebAuthnDevice();
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Old');
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
await getUserRepository().setWebAuthnCredentialSupersededBy(
createUserID(BigInt(account.userId)),
credentialIdOf(legacy),
credentialIdOf(target),
);
expect((await listPasskeys(harness, account.token)).map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
const offTarget = await discoverableOptions();
await createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.body({response: createAuthenticationResponse(legacy, offTarget), challenge: offTarget.challenge})
.expect(HTTP_STATUS.OK)
.execute();
const sudoOffTarget = await sudoOptions(account.token);
expect(sudoOffTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
const onTarget = await discoverableOptions(TARGET_ORIGIN);
await createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.header('origin', TARGET_ORIGIN)
.body({response: createAuthenticationResponse(legacy, onTarget), challenge: onTarget.challenge})
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
.execute();
const sudoOnTarget = await sudoOptions(account.token, TARGET_ORIGIN);
expect(sudoOnTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
});
});
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash, randomBytes} from 'node:crypto';
import {
createAuthenticationResponse,
createRegistrationResponse,
type WebAuthnAuthenticationOptions,
type WebAuthnDevice,
type WebAuthnRegistrationOptions,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {DEFAULT_DOMAIN_MIGRATION_CONFIG} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import type {
PasskeyBridgeFinishResponse,
PasskeyBridgeStartResponse,
PasskeyBridgeSudoRedeemResponse,
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
export const TARGET_ORIGIN = 'https://fluxer.com';
export const LEGACY_ORIGIN = 'https://web.fluxer.app';
export const LEGACY_RP_ID = 'localhost';
export const TARGET_RP_ID = 'fluxer.com';
export interface PasskeyCredentialListItem {
id: string;
name: string;
rp_id: string;
}
export interface BridgeNonce {
nonce: string;
nonceHash: string;
}
export function createBridgeNonce(): BridgeNonce {
const nonce = randomBytes(32).toString('base64url');
return {nonce, nonceHash: createHash('sha256').update(nonce).digest('hex')};
}
export async function setDomainMigration(enabled: boolean, includedUserIds: Array<string> = []): Promise<void> {
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled,
included_user_ids: includedUserIds,
});
}
export async function registerPasskey(
harness: ApiTestHarness,
token: string,
device: WebAuthnDevice,
sudo: Record<string, unknown>,
name: string,
origin?: string,
): Promise<void> {
const optionsBuilder = createBuilder<WebAuthnRegistrationOptions>(harness, token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body(sudo);
if (origin) optionsBuilder.header('origin', origin);
const options = await optionsBuilder.execute();
const registerBuilder = createBuilder(harness, token)
.post('/users/@me/mfa/webauthn/credentials')
.body({response: createRegistrationResponse(device, options, name), challenge: options.challenge, name})
.expect(204);
if (origin) registerBuilder.header('origin', origin);
await registerBuilder.execute();
}
export async function listPasskeys(harness: ApiTestHarness, token: string): Promise<Array<PasskeyCredentialListItem>> {
return createBuilder<Array<PasskeyCredentialListItem>>(harness, token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
}
export async function runNativeSudoBridge(
harness: ApiTestHarness,
token: string,
device: WebAuthnDevice,
nonce: BridgeNonce = createBridgeNonce(),
): Promise<PasskeyBridgeSudoRedeemResponse> {
const start = await createBuilder<PasskeyBridgeStartResponse>(harness, token)
.post('/users/@me/passkey-bridge')
.header('origin', TARGET_ORIGIN)
.body({runner: 'native', nonce_hash: nonce.nonceHash})
.execute();
const {options} = await createBuilderWithoutAuth<{options: WebAuthnAuthenticationOptions}>(harness)
.post(`/auth/passkey-bridge/${start.ceremony_id}/options`)
.header('origin', TARGET_ORIGIN)
.execute();
const finish = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
.post(`/auth/passkey-bridge/${start.ceremony_id}/complete`)
.header('origin', TARGET_ORIGIN)
.body({response: createAuthenticationResponse(device, options)})
.execute();
return createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, token)
.post(`/users/@me/passkey-bridge/${start.ceremony_id}/redeem`)
.header('origin', TARGET_ORIGIN)
.body({nonce: nonce.nonce, completion_code: finish.completion_code})
.execute();
}
+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 {
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
@@ -8,6 +9,9 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {entityTagMatches} from '@app/api/utils/EntityTag';
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
import {resolveAltchaCaptchaAssignment} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -28,16 +32,30 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, voiceConfig] = await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
const [delivery, voiceConfig, domainMigrationConfig, altchaCaptchaConfig, profileTimezoneConfig] =
await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
]);
const user = ctx.get('user');
const userId = user.id.toString();
const targeting = await resolveExperimentTargeting(user, [
voiceConfig,
domainMigrationConfig,
altchaCaptchaConfig,
profileTimezoneConfig,
]);
const userId = ctx.get('user').id.toString();
const body: ExperimentAssignmentsResponse = {
poll_interval_seconds: delivery.poll_interval_seconds,
poll_jitter_percent: delivery.poll_jitter_percent,
assignments: {
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId, targeting),
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId, targeting),
profile_timezone: resolveProfileTimezoneAssignment(profileTimezoneConfig, userId, targeting),
},
};
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import type {ExperimentTargeting} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
interface TargetableExperimentConfig {
readonly enabled: boolean;
readonly included_guild_ids: ReadonlyArray<string>;
}
const NO_GUILDS: ReadonlySet<string> = new Set();
export const ANONYMOUS_EXPERIMENT_TARGETING: ExperimentTargeting = {
memberGuildIds: NO_GUILDS,
premium: false,
};
export async function resolveExperimentTargeting(
user: User,
configs: ReadonlyArray<TargetableExperimentConfig>,
): Promise<ExperimentTargeting> {
const needsGuilds = configs.some((config) => config.enabled && config.included_guild_ids.length > 0);
const memberGuildIds = needsGuilds
? new Set((await getUserRepository().getUserGuildIds(user.id)).map((guildId) => guildId.toString()))
: NO_GUILDS;
return {memberGuildIds, premium: !user.isBot && user.isPremium()};
}
@@ -1,11 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {grantPremium} from '@app/api/user/tests/UserTestUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {
DEFAULT_DOMAIN_MIGRATION_CONFIG,
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
DEFAULT_PROFILE_TIMEZONE_CONFIG,
INERT_PROFILE_TIMEZONE_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
@@ -15,6 +30,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 +67,9 @@ describe('GET /experiments', () => {
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
assignments: {
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
profile_timezone: INERT_PROFILE_TIMEZONE_ASSIGNMENT,
},
});
});
@@ -82,6 +101,261 @@ describe('GET /experiments', () => {
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
});
it('populates the domain migration assignment key even when the rollout is disabled', async () => {
const account = await createTestAccount(harness);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(Object.hasOwn(body.assignments, 'domain_migration')).toBe(true);
expect(readDomainMigrationAssignment(body).enabled).toBe(false);
});
it('resolves the domain migration caller through the allowlist', async () => {
const targeted = await createTestAccount(harness);
const untargeted = await createTestAccount(harness);
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
config_version: 4,
rollout_basis_points: 0,
included_user_ids: [targeted.userId],
});
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.domain_migration).toEqual({enabled: true});
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
.get(ENDPOINT)
.execute();
expect(untargetedBody.assignments.domain_migration).toEqual({enabled: false});
});
it('keeps the domain migration exclusion ahead of a full rollout', async () => {
const excluded = await createTestAccount(harness);
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
rollout_basis_points: 10000,
included_user_ids: [excluded.userId],
excluded_user_ids: [excluded.userId],
});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token).get(ENDPOINT).execute();
expect(body.assignments.domain_migration).toEqual({enabled: false});
});
it('resolves the altcha captcha caller through the allowlist and the exclusion list', async () => {
const targeted = await createTestAccount(harness);
const excluded = await createTestAccount(harness);
await getInstanceConfigRepository().setAltchaCaptchaConfig({
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
enabled: true,
rollout_basis_points: 10000,
anonymous_enabled: true,
included_user_ids: [targeted.userId],
excluded_user_ids: [excluded.userId],
});
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.altcha_captcha).toEqual({enabled: true});
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
.get(ENDPOINT)
.execute();
expect(excludedBody.assignments.altcha_captcha).toEqual({enabled: false});
});
it('bumps the altcha captcha config version on every admin update without the client sending one', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const afterFirst = await createBuilder<{altcha_captcha: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({altcha_captcha: {enabled: true, included_user_ids: [admin.userId]}})
.execute();
expect(afterFirst.altcha_captcha).toMatchObject({config_version: 1, enabled: true});
const afterSecond = await createBuilder<{
altcha_captcha: {config_version: number; anonymous_enabled: boolean; cost: number; max_counter: number};
}>(harness, admin.token)
.patch('/admin/instance/config')
.body({altcha_captcha: {anonymous_enabled: true, cost: 2000, max_counter: 400}})
.execute();
expect(afterSecond.altcha_captcha).toMatchObject({
config_version: 2,
anonymous_enabled: true,
cost: 2000,
max_counter: 400,
});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(body.assignments.altcha_captcha).toEqual({enabled: true});
});
it('resolves the profile timezone caller through the allowlist and the exclusion list', async () => {
const targeted = await createTestAccount(harness);
const excluded = await createTestAccount(harness);
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
rollout_basis_points: 10000,
included_user_ids: [targeted.userId],
excluded_user_ids: [excluded.userId],
});
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.profile_timezone).toEqual({enabled: true});
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
.get(ENDPOINT)
.execute();
expect(excludedBody.assignments.profile_timezone).toEqual({enabled: false});
});
it('bumps the profile timezone config version on every admin update without the client sending one', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const afterFirst = await createBuilder<{profile_timezone: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({profile_timezone: {enabled: true, included_user_ids: [admin.userId]}})
.execute();
expect(afterFirst.profile_timezone).toMatchObject({config_version: 1, enabled: true});
const afterSecond = await createBuilder<{
profile_timezone: {config_version: number; rollout_basis_points: number};
}>(harness, admin.token)
.patch('/admin/instance/config')
.body({profile_timezone: {rollout_basis_points: 2500}})
.execute();
expect(afterSecond.profile_timezone).toMatchObject({config_version: 2, rollout_basis_points: 2500});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(body.assignments.profile_timezone).toEqual({enabled: true});
});
it('enrols members of an included guild in every experiment and leaves everyone else out', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const outsider = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Experiment Guild');
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, member.token, invite.code);
const repository = getInstanceConfigRepository();
await repository.setVoiceNoiseSuppressionConfig({
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setAltchaCaptchaConfig({
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
.get(ENDPOINT)
.execute();
expect(memberBody.assignments.voice_noise_suppression).toMatchObject({user_targeted: true, source: 'user_rule'});
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
expect(memberBody.assignments.altcha_captcha).toEqual({enabled: true});
expect(memberBody.assignments.profile_timezone).toEqual({enabled: true});
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
.get(ENDPOINT)
.execute();
expect(outsiderBody.assignments.voice_noise_suppression).toMatchObject({user_targeted: false, source: null});
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
expect(outsiderBody.assignments.altcha_captcha).toEqual({enabled: false});
expect(outsiderBody.assignments.profile_timezone).toEqual({enabled: false});
});
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
const subscriber = await createTestAccount(harness);
const visionary = await createTestAccount(harness);
const free = await createTestAccount(harness);
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
await grantPremium(harness, visionary.userId, UserPremiumTypes.LIFETIME);
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
include_premium_users: true,
});
for (const [account, expected] of [
[subscriber, true],
[visionary, true],
[free, false],
] as const) {
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(body.assignments.profile_timezone).toEqual({enabled: expected});
}
});
it('stores the guild ids and premium switch an admin sets for each experiment', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const guildIds = ['1500000000000000001', '1500000000000000002'];
const body = await createBuilder<
Record<
'voice_noise_suppression' | 'domain_migration' | 'altcha_captcha' | 'profile_timezone',
{included_guild_ids: Array<string>; include_premium_users: boolean}
>
>(harness, admin.token)
.patch('/admin/instance/config')
.body({
voice_noise_suppression: {included_guild_ids: guildIds, include_premium_users: true},
domain_migration: {included_guild_ids: guildIds, include_premium_users: true},
altcha_captcha: {included_guild_ids: guildIds, include_premium_users: true},
profile_timezone: {included_guild_ids: guildIds, include_premium_users: true},
})
.execute();
expect(body.voice_noise_suppression.included_guild_ids).toEqual(guildIds);
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
expect(body.altcha_captcha.included_guild_ids).toEqual(guildIds);
expect(body.profile_timezone.included_guild_ids).toEqual(guildIds);
for (const section of [
body.voice_noise_suppression,
body.domain_migration,
body.altcha_captcha,
body.profile_timezone,
]) {
expect(section.include_premium_users).toBe(true);
}
});
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setExperimentDeliveryConfig({
@@ -196,6 +470,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 +550,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,
@@ -37,7 +37,6 @@ import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMe
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {ms} from 'itty-time';
const PUSH_BADGE_COUNT_BATCH_SIZE = 100;
const USER_PERMISSIONS_BATCH_SIZE = 100;
const GATEWAY_ERROR_TO_DOMAIN_ERROR: Record<string, () => Error> = {
@@ -67,18 +66,6 @@ interface DispatchPresenceParams {
data: unknown;
}
interface InvalidatePushBadgeCountParams {
userId: UserID;
}
interface InvalidatePushBadgeCountsParams {
userIds: Array<UserID>;
}
interface InvalidatePushSubscriptionsParams {
userId: UserID;
}
interface ClearPushChannelNotificationsParams {
userId: UserID;
channelId: ChannelID;
@@ -287,8 +274,6 @@ export class GatewayService {
private readonly MAX_BATCH_CONCURRENCY = 50;
private readonly PENDING_REQUEST_TIMEOUT_MS = ms('30 seconds');
private readonly AUTH_CONTEXT_FALLBACK_MS = ms('5 minutes');
private readonly BADGE_COUNTS_FALLBACK_MS = ms('5 minutes');
private badgeCountsUnsupportedUntil = 0;
constructor() {
this.rpcClient = GatewayRpcClient.getInstance();
@@ -704,48 +689,6 @@ export class GatewayService {
});
}
async invalidatePushBadgeCount({userId}: InvalidatePushBadgeCountParams): Promise<void> {
await this.call('push.invalidate_badge_count', {
user_id: userId.toString(),
});
}
async invalidatePushBadgeCounts({userIds}: InvalidatePushBadgeCountsParams): Promise<void> {
if (Date.now() < this.badgeCountsUnsupportedUntil) {
await this.invalidatePushBadgeCountsIndividually(userIds);
return;
}
const batches: Array<Array<UserID>> = [];
for (let index = 0; index < userIds.length; index += PUSH_BADGE_COUNT_BATCH_SIZE) {
batches.push(userIds.slice(index, index + PUSH_BADGE_COUNT_BATCH_SIZE));
}
try {
await Promise.all(
batches.map((batch) =>
this.call('push.invalidate_badge_counts', {user_ids: batch.map((userId) => userId.toString())}),
),
);
} catch (error) {
const transformedError = this.transformGatewayError(error);
if (!this.isAuthContextUnsupportedError(transformedError)) {
throw transformedError;
}
this.badgeCountsUnsupportedUntil = Date.now() + this.BADGE_COUNTS_FALLBACK_MS;
Logger.warn({error}, '[gateway-rpc] push.invalidate_badge_counts unavailable, falling back to per-user calls');
await this.invalidatePushBadgeCountsIndividually(userIds);
}
}
private async invalidatePushBadgeCountsIndividually(userIds: ReadonlyArray<UserID>): Promise<void> {
await Promise.all(userIds.map((userId) => this.invalidatePushBadgeCount({userId})));
}
async invalidatePushSubscriptions({userId}: InvalidatePushSubscriptionsParams): Promise<void> {
await this.call('push.invalidate_subscriptions', {
user_id: userId.toString(),
});
}
async clearPushChannelNotifications({
userId,
channelId,
@@ -292,12 +292,6 @@ export abstract class IGatewayService {
abstract dispatchPresence(params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void>;
abstract invalidatePushBadgeCount(params: {userId: UserID}): Promise<void>;
abstract invalidatePushBadgeCounts(params: {userIds: Array<UserID>}): Promise<void>;
abstract invalidatePushSubscriptions(params: {userId: UserID}): Promise<void>;
abstract clearPushChannelNotifications(params: {
userId: UserID;
channelId: ChannelID;
@@ -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,14 +28,28 @@ import {
type PendingRegistrationResponse,
type RegistrationUrlResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
type AltchaCaptchaConfig,
AltchaCaptchaConfigSchema,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {
type DomainMigrationConfig,
DomainMigrationConfigSchema,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
type GatewayRolloutConfig,
GatewayRolloutConfigSchema,
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {
type PushServiceDeliveryConfig,
PushServiceDeliveryConfigSchema,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
type ProfileTimezoneConfig,
ProfileTimezoneConfigSchema,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {
type LegacyPushServiceDeliveryWire,
type PushRelayConfig,
PushRelayConfigSchema,
toLegacyPushServiceDeliveryWire,
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {
type VoiceNoiseSuppressionConfig,
VoiceNoiseSuppressionConfigSchema,
@@ -62,7 +76,10 @@ import {z} from 'zod';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
const ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
const PROFILE_TIMEZONE_CONFIG_KEY = 'profile_timezone_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
@@ -369,7 +386,10 @@ type StoredConfigSection =
| 'app public'
| 'gateway rollout'
| 'voice noise suppression'
| 'push service delivery'
| 'push relay'
| 'domain migration'
| 'altcha captcha'
| 'profile timezone'
| 'experiment delivery'
| 'instance policy'
| 'integrations'
@@ -508,8 +528,37 @@ function parseStoredVoiceNoiseSuppressionConfig(raw: string | null): VoiceNoiseS
return parseStoredConfigOrDefault(VoiceNoiseSuppressionConfigSchema, raw, 'voice noise suppression');
}
function parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDeliveryConfig {
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
const StoredPushRelayConfigSchema = PushRelayConfigSchema.extend({
config_version: z.number().int().min(0).default(0),
});
function parseStoredPushRelayConfig(raw: string | null): LegacyPushServiceDeliveryWire {
const {config_version, ...config} = salvageStoredConfig(
StoredPushRelayConfigSchema,
readStoredConfigValue(raw, 'push relay'),
'push relay',
);
return toLegacyPushServiceDeliveryWire(config, config_version);
}
function toPushRelayConfig(wire: LegacyPushServiceDeliveryWire): PushRelayConfig {
return {
relay_consent_accepted: wire.relay_consent_accepted,
relay_consent_accepted_at: wire.relay_consent_accepted_at,
relay_consent_accepted_by: wire.relay_consent_accepted_by,
};
}
function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationConfig {
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
}
function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig {
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
}
function parseStoredProfileTimezoneConfig(raw: string | null): ProfileTimezoneConfig {
return parseStoredConfigOrDefault(ProfileTimezoneConfigSchema, raw, 'profile timezone');
}
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
@@ -1159,7 +1208,10 @@ export class InstanceConfigRepository {
parseStoredGatewayRolloutConfig(snapshot.get(GATEWAY_ROLLOUT_CONFIG_KEY) ?? null),
);
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null);
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null);
parseStoredProfileTimezoneConfig(snapshot.get(PROFILE_TIMEZONE_CONFIG_KEY) ?? null);
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
checkStoredConfig('registration', () =>
@@ -1256,19 +1308,78 @@ export class InstanceConfigRepository {
);
}
async getPushServiceDeliveryConfig(): Promise<PushServiceDeliveryConfig> {
const raw = await this.getConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
return parseStoredPushServiceDeliveryConfig(raw);
async getLegacyPushServiceDeliveryWire(): Promise<LegacyPushServiceDeliveryWire> {
const raw = await this.getConfig(PUSH_RELAY_CONFIG_KEY);
return parseStoredPushRelayConfig(raw);
}
updatePushServiceDeliveryConfig(
update: (current: PushServiceDeliveryConfig) => PushServiceDeliveryConfig,
): Promise<PushServiceDeliveryConfig> {
return this.updateStoredConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY, (raw) =>
async getPushRelayConfig(): Promise<PushRelayConfig> {
return toPushRelayConfig(await this.getLegacyPushServiceDeliveryWire());
}
updatePushRelayConfig(update: (current: PushRelayConfig) => PushRelayConfig): Promise<LegacyPushServiceDeliveryWire> {
return this.updateStoredConfig(PUSH_RELAY_CONFIG_KEY, (raw) => {
const current = parseStoredPushRelayConfig(raw);
const next = validateStoredConfig(PushRelayConfigSchema, update(toPushRelayConfig(current)), 'push relay');
return toLegacyPushServiceDeliveryWire(next, current.config_version + 1);
});
}
async getDomainMigrationConfig(): Promise<DomainMigrationConfig> {
const raw = await this.getConfig(DOMAIN_MIGRATION_CONFIG_KEY);
return parseStoredDomainMigrationConfig(raw);
}
async setDomainMigrationConfig(config: DomainMigrationConfig): Promise<void> {
await this.updateDomainMigrationConfig(() => config);
}
updateDomainMigrationConfig(
update: (current: DomainMigrationConfig) => DomainMigrationConfig,
): Promise<DomainMigrationConfig> {
return this.updateStoredConfig(DOMAIN_MIGRATION_CONFIG_KEY, (raw) =>
validateStoredConfig(
PushServiceDeliveryConfigSchema,
update(parseStoredPushServiceDeliveryConfig(raw)),
'push service delivery',
DomainMigrationConfigSchema,
update(parseStoredDomainMigrationConfig(raw)),
'domain migration',
),
);
}
async getAltchaCaptchaConfig(): Promise<AltchaCaptchaConfig> {
const raw = await this.getConfig(ALTCHA_CAPTCHA_CONFIG_KEY);
return parseStoredAltchaCaptchaConfig(raw);
}
async setAltchaCaptchaConfig(config: AltchaCaptchaConfig): Promise<void> {
await this.updateAltchaCaptchaConfig(() => config);
}
updateAltchaCaptchaConfig(
update: (current: AltchaCaptchaConfig) => AltchaCaptchaConfig,
): Promise<AltchaCaptchaConfig> {
return this.updateStoredConfig(ALTCHA_CAPTCHA_CONFIG_KEY, (raw) =>
validateStoredConfig(AltchaCaptchaConfigSchema, update(parseStoredAltchaCaptchaConfig(raw)), 'altcha captcha'),
);
}
async getProfileTimezoneConfig(): Promise<ProfileTimezoneConfig> {
const raw = await this.getConfig(PROFILE_TIMEZONE_CONFIG_KEY);
return parseStoredProfileTimezoneConfig(raw);
}
async setProfileTimezoneConfig(config: ProfileTimezoneConfig): Promise<void> {
await this.updateProfileTimezoneConfig(() => config);
}
updateProfileTimezoneConfig(
update: (current: ProfileTimezoneConfig) => ProfileTimezoneConfig,
): Promise<ProfileTimezoneConfig> {
return this.updateStoredConfig(PROFILE_TIMEZONE_CONFIG_KEY, (raw) =>
validateStoredConfig(
ProfileTimezoneConfigSchema,
update(parseStoredProfileTimezoneConfig(raw)),
'profile timezone',
),
);
}
@@ -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,21 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PushServiceDeliveryConfig} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import type {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
const textEncoder = new TextEncoder();
export const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
interface PushServiceDeliveryConfigNatsMessage {
type: 'push_service_delivery_config';
config: PushServiceDeliveryConfig;
config: LegacyPushServiceDeliveryWire;
}
export class PushServiceDeliveryConfigPublisher {
export class PushRelayConfigPublisher {
constructor(private readonly connectionManager: INatsConnectionManager) {}
async publish(config: PushServiceDeliveryConfig): Promise<void> {
async publish(config: LegacyPushServiceDeliveryWire): Promise<void> {
if (this.connectionManager.isClosed()) {
await this.connectionManager.connect();
}
@@ -1,11 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
import type {Context, Next} from 'hono';
const LEGACY_APP_ORIGINS = ['https://web.fluxer.app', 'https://web.canary.fluxer.app'];
export async function BlockAppOriginMiddleware(ctx: Context, next: Next) {
const origin = ctx.req.header('origin');
if (origin === 'https://web.fluxer.app' || origin === 'https://web.canary.fluxer.app') {
if (
origin !== undefined &&
(LEGACY_APP_ORIGINS.includes(origin) || Config.endpoints.webAppOrigins.includes(origin))
) {
throw new InvalidApiOriginError();
}
await next();
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHmac} from 'node:crypto';
import {Config} from '@app/api/Config';
import {ANONYMOUS_EXPERIMENT_TARGETING, resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
import {Logger} from '@app/api/Logger';
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
import type {User} from '@app/api/models/User';
import {accountPolicyContactHasCapability} from '@app/api/risk/AccountPolicyService';
import type {HonoEnv} from '@app/api/types/HonoEnv';
@@ -9,12 +13,44 @@ import {Headers} from '@fluxer/constants/src/Headers';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors';
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {type AltchaCaptchaConfig, altchaCaptchaAppliesTo} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import type {InstanceCaptchaProvider} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory';
import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider';
import {AltchaProvider} from '@pkgs/captcha/src/providers/AltchaProvider';
import type {Context} from 'hono';
import {createMiddleware} from 'hono/factory';
const ALTCHA_SPENT_CHALLENGE_KEY_PREFIX = 'captcha:altcha:spent:';
function deriveAltchaSecret(label: string): string {
return createHmac('sha256', Config.auth.sudoModeSecret).update(label).digest('hex');
}
function createAltchaProvider(config: AltchaCaptchaConfig): AltchaProvider {
return new AltchaProvider({
hmacSignatureSecret: deriveAltchaSecret('fluxer-altcha-challenge-signature-v1'),
hmacKeySignatureSecret: deriveAltchaSecret('fluxer-altcha-key-signature-v1'),
cost: config.cost,
maxCounter: config.max_counter,
claimChallenge: (signature, ttlSeconds) =>
getKVClient().setnx(`${ALTCHA_SPENT_CHALLENGE_KEY_PREFIX}${signature}`, '1', ttlSeconds),
logger: Logger,
});
}
async function altchaChallengeData(altcha: AltchaProvider | null): Promise<Record<string, unknown> | undefined> {
if (!altcha) return undefined;
return {captcha_provider: 'altcha', altcha_challenge: await altcha.createChallenge()};
}
async function resolveAltchaProvider(ctx: Context<HonoEnv>, user: User | undefined): Promise<AltchaProvider | null> {
const config = await ctx.get('instanceConfigRepository').getAltchaCaptchaConfig();
const targeting = user ? await resolveExperimentTargeting(user, [config]) : ANONYMOUS_EXPERIMENT_TARGETING;
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null, targeting)) return null;
return createAltchaProvider(config);
}
function resolveProviderSecret(
config: InstanceCaptchaEffectiveConfig,
provider: InstanceCaptchaProvider,
@@ -58,11 +94,19 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
if (accountPolicyContactHasCapability(user?.email, 'captcha_exempt')) return;
if (userHasCaptchaExemptFlag(user)) return;
if (await requestUserHasCaptchaExemptFlag(ctx)) return;
const altcha = await resolveAltchaProvider(ctx, user);
const token = ctx.req.header(Headers.X_CAPTCHA_TOKEN);
if (!token) {
throw new CaptchaRequiredError();
throw new CaptchaRequiredError(await altchaChallengeData(altcha));
}
const provider = resolveCaptchaProvider(captchaConfig, ctx.req.header(Headers.X_CAPTCHA_TYPE));
const requestedType = ctx.req.header(Headers.X_CAPTCHA_TYPE);
if (requestedType === 'altcha') {
if (!altcha || !(await altcha.verify({token}))) {
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
}
return;
}
const provider = resolveCaptchaProvider(captchaConfig, requestedType);
const isValid = await provider.verify({
token,
remoteIp:
@@ -72,7 +116,7 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
}) ?? undefined,
});
if (!isValid) {
throw new InvalidCaptchaError();
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
}
}
@@ -51,7 +51,7 @@ import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClie
import {VirusScanService} from '@app/api/infrastructure/VirusScanService';
import {GatewayRolloutConfigPublisher} from '@app/api/instance/GatewayRolloutConfigPublisher';
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
import {InviteRepository} from '@app/api/invite/InviteRepository';
import {Logger} from '@app/api/Logger';
import {LimitConfigService} from '@app/api/limits/LimitConfigService';
@@ -157,13 +157,13 @@ export const getGatewayRolloutConfigPublisher = singleton(
),
);
export const getPushServiceDeliveryConfigPublisher = singleton(
export const getPushRelayConfigPublisher = singleton(
() =>
new PushServiceDeliveryConfigPublisher(
new PushRelayConfigPublisher(
new NatsConnectionManager({
url: Config.nats.coreUrl,
token: Config.nats.authToken || undefined,
name: 'fluxer-api-push-service-delivery-config',
name: 'fluxer-api-push-relay-config',
}),
),
);
@@ -8,6 +8,7 @@ import type {
import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
import {DEFAULT_ALTCHA_CAPTCHA_CONFIG} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {Hono} from 'hono';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
@@ -25,6 +26,7 @@ function createHarness(
): (headers: Record<string, string>) => Promise<Response> {
const repository = {
getEffectiveCaptchaConfig: async () => captcha,
getAltchaCaptchaConfig: async () => DEFAULT_ALTCHA_CAPTCHA_CONFIG,
} as unknown as InstanceConfigRepository;
const app = new Hono<HonoEnv>();
app.use(async (ctx, next) => {
@@ -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
+1 -3
View File
@@ -204,9 +204,7 @@ function buildApnsPayload(payload: Record<string, unknown>): Record<string, unkn
if (badge !== undefined) {
aps.badge = badge;
}
if (imageUrl) {
aps['mutable-content'] = 1;
}
aps['mutable-content'] = 1;
return {
...data,
title,
@@ -139,7 +139,7 @@ describe('ApnsPushService', () => {
notification: {title: 'Alice', body: 'Hello', icon: 'https://cdn.example/avatar.png'},
});
expect(payload.image_url).toBeUndefined();
expect(payload.aps).not.toHaveProperty('mutable-content');
expect(payload.aps).toHaveProperty('mutable-content', 1);
expect(payload.author_avatar_url).toBe('https://cdn.example/avatar.png');
});
it('imports the APNs signing key once per PEM and rejects a truncated one every time', async () => {
@@ -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')},
@@ -15,53 +15,6 @@ import {ReadStateService} from '@app/api/read_state/ReadStateService';
import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError';
import {describe, expect, it, vi} from 'vitest';
describe('ReadStateService.bulkIncrementMentionCounts', () => {
it('invalidates badge counts for touched users in a single bulk call', async () => {
const channelId = createChannelID(2n);
const messageId = createMessageID(3n);
const touched: Array<{userId: UserID; channelId: ChannelID}> = [
{userId: createUserID(10n), channelId},
{userId: createUserID(11n), channelId},
{userId: createUserID(10n), channelId: createChannelID(4n)},
];
const repository = {
bulkIncrementMentionCounts: vi.fn().mockResolvedValue(touched),
} as unknown as IReadStateRepository;
const invalidatePushBadgeCounts = vi.fn().mockResolvedValue(undefined);
const invalidatePushBadgeCount = vi.fn().mockResolvedValue(undefined);
const gatewayService = {
invalidatePushBadgeCounts,
invalidatePushBadgeCount,
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
await service.bulkIncrementMentionCounts([
{userId: createUserID(10n), channelId, messageId},
{userId: createUserID(11n), channelId, messageId},
{userId: createUserID(12n), channelId, messageId},
]);
expect(invalidatePushBadgeCount).not.toHaveBeenCalled();
expect(invalidatePushBadgeCounts).toHaveBeenCalledTimes(1);
expect(invalidatePushBadgeCounts).toHaveBeenCalledWith({userIds: [createUserID(10n), createUserID(11n)]});
});
it('skips the bulk call when no read state was touched', async () => {
const repository = {
bulkIncrementMentionCounts: vi.fn().mockResolvedValue([]),
} as unknown as IReadStateRepository;
const invalidatePushBadgeCounts = vi.fn().mockResolvedValue(undefined);
const gatewayService = {invalidatePushBadgeCounts} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
await service.bulkIncrementMentionCounts([
{userId: createUserID(10n), channelId: createChannelID(2n), messageId: createMessageID(3n)},
]);
expect(invalidatePushBadgeCounts).not.toHaveBeenCalled();
});
});
const USER_ID = createUserID(20n);
const CHANNEL_ID = createChannelID(21n);
const MESSAGE_ID = createMessageID(22n);
@@ -78,7 +31,7 @@ function makeReadState(channelId: ChannelID, messageId: MessageID, mentionCount
}
describe('ReadStateService gateway side effects after the write', () => {
it('returns the committed read state when the badge invalidation fails', async () => {
it('returns the committed read state when clearing push notifications fails', async () => {
const stored: Array<{channelId: ChannelID; messageId: MessageID}> = [];
const repository = {
upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => {
@@ -87,8 +40,7 @@ describe('ReadStateService gateway side effects after the write', () => {
}),
} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
clearPushChannelNotifications: vi.fn().mockRejectedValue(new BadGatewayError()),
dispatchPresence: vi.fn().mockResolvedValue(undefined),
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
@@ -113,7 +65,6 @@ describe('ReadStateService gateway side effects after the write', () => {
),
} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockResolvedValue(undefined),
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
dispatchPresence: vi.fn().mockRejectedValue(new BadGatewayError()),
} as unknown as IGatewayService;
@@ -138,7 +89,6 @@ describe('ReadStateService gateway side effects after the write', () => {
}),
} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockResolvedValue(undefined),
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
dispatchPresence: vi.fn().mockRejectedValue(new BadGatewayError()),
} as unknown as IGatewayService;
@@ -156,42 +106,13 @@ describe('ReadStateService gateway side effects after the write', () => {
expect(stored).toEqual(['21', '23']);
});
it('deletes the read state when the badge invalidation fails', async () => {
const deleteReadState = vi.fn().mockResolvedValue(undefined);
const repository = {deleteReadState} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
await expect(service.deleteReadState({userId: USER_ID, channelId: CHANNEL_ID})).resolves.toBeUndefined();
expect(deleteReadState).toHaveBeenCalledWith(USER_ID, CHANNEL_ID);
});
it('increments the mention count when the badge invalidation fails', async () => {
const incrementReadStateMentions = vi.fn().mockResolvedValue(makeReadState(CHANNEL_ID, MESSAGE_ID, 1));
const repository = {incrementReadStateMentions} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
await expect(
service.incrementMentionCount({userId: USER_ID, channelId: CHANNEL_ID, messageId: MESSAGE_ID}),
).resolves.toBeUndefined();
expect(incrementReadStateMentions).toHaveBeenCalledTimes(1);
});
it('returns the bulk acknowledged states when the badge invalidation fails', async () => {
it('returns the bulk acknowledged states when clearing push notifications fails', async () => {
const updated = [makeReadState(CHANNEL_ID, MESSAGE_ID)];
const repository = {
bulkAckMessages: vi.fn().mockResolvedValue(updated),
} as unknown as IReadStateRepository;
const gatewayService = {
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
clearPushChannelNotifications: vi.fn().mockRejectedValue(new BadGatewayError()),
dispatchPresence: vi.fn().mockResolvedValue(undefined),
} as unknown as IGatewayService;
const service = new ReadStateService(repository, gatewayService);
@@ -34,7 +34,6 @@ export class ReadStateService {
undefined,
manual ?? false,
);
await this.invalidatePushBadgeCount(userId);
if (!silent) {
await this.clearPushChannelNotifications({userId, channelId, messageId});
}
@@ -115,7 +114,6 @@ export class ReadStateService {
try {
const updatedReadStates = await this.repository.bulkAckMessages(userId, readStates);
const readStatesByChannel = new Map(updatedReadStates.map((readState) => [readState.channelId, readState]));
await this.invalidatePushBadgeCount(userId);
await Promise.all(
readStates.map(({channelId, messageId}) =>
Promise.all([
@@ -145,7 +143,6 @@ export class ReadStateService {
async deleteReadState({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
await this.repository.deleteReadState(userId, channelId);
await this.invalidatePushBadgeCount(userId);
}
async incrementMentionCount({
@@ -157,11 +154,7 @@ export class ReadStateService {
channelId: ChannelID;
messageId: MessageID;
}): Promise<void> {
const readState = await this.repository.incrementReadStateMentions(userId, channelId, messageId, 1);
if (readState == null) {
return;
}
await this.invalidatePushBadgeCount(userId);
await this.repository.incrementReadStateMentions(userId, channelId, messageId, 1);
}
async bulkIncrementMentionCounts(
@@ -175,15 +168,7 @@ export class ReadStateService {
return;
}
try {
const appliedUpdates = await this.repository.bulkIncrementMentionCounts(updates);
const uniqueUserIds = Array.from(new Set(appliedUpdates.map((update) => update.userId)));
if (uniqueUserIds.length === 0) {
return;
}
await this.gatewayService.invalidatePushBadgeCounts({userIds: uniqueUserIds}).catch((error) => {
Logger.error({userCount: uniqueUserIds.length, error}, 'Failed to invalidate push badge counts');
return null;
});
await this.repository.bulkIncrementMentionCounts(updates);
} catch (error) {
Logger.error({error}, 'Bulk increment mention counts failed');
throw error;
@@ -196,13 +181,6 @@ export class ReadStateService {
await this.dispatchPinsAck({userId, channelId, timestamp});
}
private async invalidatePushBadgeCount(userId: UserID): Promise<void> {
await this.gatewayService.invalidatePushBadgeCount({userId}).catch((error) => {
Logger.error({userId: userId.toString(), error}, 'Failed to invalidate push badge count');
return null;
});
}
private async dispatchMessageAck(params: {
userId: UserID;
channelId: ChannelID;
+49 -14
View File
@@ -1,34 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {parseIpBanEntry, tryParseSingleIp} from '@app/api/utils/IpRangeUtils';
import type {IpAddressFamily} from '@fluxer/ip_utils/src/IpAddress';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
let exemptDecisionKeys: ReadonlySet<string> | null = null;
interface ExemptRange {
family: IpAddressFamily;
start: bigint;
end: bigint;
}
function getExemptDecisionKeys(): ReadonlySet<string> {
if (exemptDecisionKeys) {
return exemptDecisionKeys;
interface IpBanExemptions {
decisionKeys: ReadonlySet<string>;
ranges: ReadonlyArray<ExemptRange>;
}
let exemptions: IpBanExemptions | null = null;
function getExemptions(): IpBanExemptions {
if (exemptions) {
return exemptions;
}
const keys = new Set<string>();
for (const ip of Config.ipBanExemptIps) {
const key = getSameIpDecisionKey(ip);
if (!key) {
throw new Error(`Invalid IP ban exemption in API config: ${ip}`);
const decisionKeys = new Set<string>();
const ranges: Array<ExemptRange> = [];
for (const entry of Config.ipBanExemptIps) {
if (entry.includes('/')) {
const range = parseIpBanEntry(entry);
if (range?.type !== 'range') {
throw new Error(`Invalid IP ban exemption in API config: ${entry}`);
}
ranges.push({family: range.family, start: range.start, end: range.end});
continue;
}
keys.add(key);
const key = getSameIpDecisionKey(entry);
if (!key) {
throw new Error(`Invalid IP ban exemption in API config: ${entry}`);
}
decisionKeys.add(key);
}
exemptDecisionKeys = keys;
return keys;
exemptions = {decisionKeys, ranges};
return exemptions;
}
export function isIpBanExempt(ip: string | null | undefined): boolean {
if (!ip) {
return false;
}
const {decisionKeys, ranges} = getExemptions();
const key = getSameIpDecisionKey(ip);
return key !== null && getExemptDecisionKeys().has(key);
if (key !== null && decisionKeys.has(key)) {
return true;
}
if (ranges.length === 0) {
return false;
}
const parsed = tryParseSingleIp(ip);
if (!parsed) {
return false;
}
return ranges.some(
(range) => range.family === parsed.family && parsed.value >= range.start && parsed.value <= range.end,
);
}
export function resetIpBanExemptionsForTesting(): void {
exemptDecisionKeys = null;
exemptions = null;
}
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getConfig} from '@app/api/Config';
import {isIpBanExempt, resetIpBanExemptionsForTesting} from '@app/api/risk/IpBanExemptions';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
describe('isIpBanExempt', () => {
let originalExemptIps: Array<string>;
beforeEach(() => {
const config = getConfig();
originalExemptIps = config.ipBanExemptIps;
config.ipBanExemptIps = ['198.51.100.7', '2001:db8:6::', '2001:db8:1200:1000::/56', '203.0.113.0/24'];
resetIpBanExemptionsForTesting();
});
afterEach(() => {
getConfig().ipBanExemptIps = originalExemptIps;
resetIpBanExemptionsForTesting();
});
it('matches a bare IPv4 address exactly', () => {
expect(isIpBanExempt('198.51.100.7')).toBe(true);
expect(isIpBanExempt('198.51.100.8')).toBe(false);
});
it('matches a bare IPv6 address on its /64', () => {
expect(isIpBanExempt('2001:db8:6::abcd')).toBe(true);
expect(isIpBanExempt('2001:db8:7::1')).toBe(false);
});
it('matches every address inside a CIDR range', () => {
expect(isIpBanExempt('2001:db8:1200:1000::1')).toBe(true);
expect(isIpBanExempt('2001:db8:1200:10ff:ffff:ffff:ffff:ffff')).toBe(true);
expect(isIpBanExempt('2001:db8:1200:1100::1')).toBe(false);
expect(isIpBanExempt('2001:db8:1200:fff::1')).toBe(false);
expect(isIpBanExempt('203.0.113.200')).toBe(true);
expect(isIpBanExempt('::ffff:203.0.113.200')).toBe(true);
expect(isIpBanExempt('203.0.114.1')).toBe(false);
});
it('does not match empty or unparsable input', () => {
expect(isIpBanExempt(null)).toBe(false);
expect(isIpBanExempt('')).toBe(false);
expect(isIpBanExempt('not-an-ip')).toBe(false);
});
});
+6 -15
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';
@@ -97,7 +99,6 @@ import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {pushServiceDeliveryEnrols} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {VoiceStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
@@ -431,13 +432,6 @@ export class RpcService {
}),
};
case 'send_apns_push': {
const deliveryConfig = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
if (pushServiceDeliveryEnrols(deliveryConfig, request.user_id.toString())) {
Logger.warn(
{userId: request.user_id.toString(), configVersion: deliveryConfig.config_version},
'push service delivery path mismatch',
);
}
const result = await sendApnsPush({
userId: request.user_id.toString(),
subscriptionId: request.subscription_id,
@@ -644,7 +638,7 @@ export class RpcService {
};
}
case 'get_push_service_delivery_config': {
const config = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
const config = await this.instanceConfigRepository.getLegacyPushServiceDeliveryWire();
return {
type: 'get_push_service_delivery_config',
data: {config},
@@ -1199,12 +1193,9 @@ export class RpcService {
longitude: geoipLongitude,
rtc_regions: rtcRegions,
webauthn_credentials: timeRpcStepSync(responseBuildSteps, 'map_webauthn_credentials', () =>
userData.webAuthnCredentials.map((cred) => ({
id: cred.credentialId,
name: cred.name,
created_at: cred.createdAt.toISOString(),
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
})),
visibleWebAuthnCredentials(userData.webAuthnCredentials).map((cred) =>
mapWebAuthnCredentialToResponse(cred, Config.auth.passkeys.rpId),
),
),
version,
};
@@ -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();
});
});
});
@@ -795,12 +795,6 @@ export class NoopGatewayService extends IGatewayService {
async dispatchPresence(_params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void> {}
async invalidatePushBadgeCount(_params: {userId: UserID}): Promise<void> {}
async invalidatePushBadgeCounts(_params: {userIds: Array<UserID>}): Promise<void> {}
async invalidatePushSubscriptions(_params: {userId: UserID}): Promise<void> {}
async clearPushChannelNotifications(_params: {
userId: UserID;
channelId: ChannelID;
+11 -2
View File
@@ -2,7 +2,9 @@
import {Config} from '@app/api/Config';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import {getCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {accountPolicyContactHasCapability} from '@app/api/risk/AccountPolicyService';
import {getCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache';
@@ -13,6 +15,7 @@ import {
SuspiciousActivityFlags,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import type {RequiredAction} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {ms} from 'itty-time';
@@ -275,6 +278,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 {
@@ -285,6 +292,8 @@ export function isBugHunterBotUser(user: Pick<User, 'flags' | 'isBot'>): boolean
return user.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
}
export function canUseProfileTimezone(user: Pick<PremiumCheckable, 'flags'>): boolean {
return (user.flags & UserFlags.STAFF) !== 0n;
export async function canUseProfileTimezone(user: User): Promise<boolean> {
const config = await getInstanceConfigRepository().getProfileTimezoneConfig();
const targeting = await resolveExperimentTargeting(user, [config]);
return resolveProfileTimezoneAssignment(config, user.id.toString(), targeting).enabled;
}
+18 -8
View File
@@ -9,7 +9,8 @@ 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 {canUseProfileTimezone, getRequiredActions} from '@app/api/user/UserHelpers';
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
import {getRequiredActions} from '@app/api/user/UserHelpers';
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
import type {ChannelMessageNotifications} from '@fluxer/constants/src/NotificationConstants';
import {
@@ -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,
@@ -119,7 +121,6 @@ export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
const isStaff = (user.flags & UserFlags.STAFF) !== 0n;
const partialResponse = mapUserToPartialResponse(user);
const isActuallyPremium = user.isPremium();
const includeProfileTimezone = canUseProfileTimezone(user);
const traitSet = new Set<string>();
for (const trait of user.traits ?? []) {
if (trait && trait !== 'premium') {
@@ -145,12 +146,8 @@ export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
bio: user.bio,
pronouns: user.pronouns,
accent_color: user.accentColor,
...(includeProfileTimezone
? {
timezone: user.timezone,
timezone_privacy_flags: user.timezonePrivacyFlags,
}
: {}),
timezone: user.timezone,
timezone_privacy_flags: user.timezonePrivacyFlags,
banner: stripBannerForUser(user),
banner_color: user.bannerColor,
mfa_enabled: authenticatorTypes.length > 0,
@@ -420,3 +417,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({
@@ -127,10 +127,10 @@ export class UserAccountLookupService {
: await this.getProfileFieldPrivacyContext(userId, targetId);
const timezoneVisible =
!restrictProfile &&
canUseProfileTimezone(user) &&
user.timezone != null &&
profileFieldPrivacyContext != null &&
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext);
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext) &&
(await canUseProfileTimezone(user));
const [mutualFriends, mutualGuilds, connections] = await Promise.all([
withMutualFriends && userId !== targetId ? this.getMutualFriends(userId, targetId) : undefined,
withMutualGuilds && userId !== targetId ? this.getMutualGuilds(userId, targetId) : undefined,
@@ -83,7 +83,8 @@ export class UserAccountProfileService {
if (data.accent_color !== undefined) {
await this.processAccentColorUpdate({user, accentColor: data.accent_color, updates});
}
const canUpdateProfileTimezone = canUseProfileTimezone(user);
const canUpdateProfileTimezone =
(data.timezone !== undefined || data.timezone_privacy_flags !== undefined) && (await canUseProfileTimezone(user));
if (canUpdateProfileTimezone && data.timezone !== undefined) {
const nextTimezone = this.processTimezoneUpdate({user, timezone: data.timezone, updates});
if (nextTimezone !== null && user.timezone === null && data.timezone_privacy_flags === undefined) {
@@ -85,11 +85,14 @@ function hasProfileCustomizationUpdate(data: UserUpdatePayload): boolean {
return EMAIL_VERIFICATION_REQUIRED_PROFILE_UPDATE_FIELDS.some((field) => data[field] !== undefined);
}
function stripUnauthorizedProfileTimezoneUpdate(
async function stripUnauthorizedProfileTimezoneUpdate(
user: User,
body: UserUpdateWithVerificationRequest,
): UserUpdateWithVerificationRequest {
if (canUseProfileTimezone(user)) {
): Promise<UserUpdateWithVerificationRequest> {
if (body.timezone === undefined && body.timezone_privacy_flags === undefined) {
return body;
}
if (await canUseProfileTimezone(user)) {
return body;
}
const {timezone: _timezone, timezone_privacy_flags: _timezonePrivacyFlags, ...rest} = body;
@@ -187,7 +190,7 @@ export class UserAccountRequestService {
const {ctx, body, authSession} = params;
let {user} = params;
const oldEmail = user.email;
const sanitizedBody = stripUnauthorizedProfileTimezoneUpdate(user, body);
const sanitizedBody = await stripUnauthorizedProfileTimezoneUpdate(user, body);
const {
mfa_method: _mfaMethod,
mfa_code: _mfaCode,
@@ -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,75 @@ export class UserContentService {
app_id: null,
provider_environment: null,
};
const subscription = await this.userRepository.createPushSubscription(data);
await this.gatewayService.invalidatePushSubscriptions({userId});
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
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);
@@ -387,7 +467,6 @@ export class UserContentService {
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
await this.userRepository.deletePushSubscription(userId, subscriptionId);
await this.gatewayService.invalidatePushSubscriptions({userId});
}
async rotatePushSubscription(params: {
@@ -400,8 +479,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,8 +501,7 @@ export class UserContentService {
app_id: null,
provider_environment: null,
};
const subscription = await this.userRepository.createPushSubscription(data);
await this.gatewayService.invalidatePushSubscriptions({userId});
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
return subscription;
}
@@ -447,7 +527,6 @@ export class UserContentService {
provider_environment: providerEnvironment,
};
const subscription = await this.userRepository.createPushSubscription(data);
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());
});
});
});
@@ -3,16 +3,16 @@
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createFriendship} from '@app/api/channel/tests/ChannelTestUtils';
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {
ProfileFieldPrivacyFlags,
type ProfilePrivacyLevel,
ProfilePrivacyLevels,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
import {DEFAULT_PROFILE_TIMEZONE_CONFIG} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
@@ -31,12 +31,12 @@ async function updateProfileTimezone(
return createBuilder<UserPrivateResponse>(harness, token).patch('/users/@me').body(data).execute();
}
async function setUserFlags(harness: ApiTestHarness, userId: string, flags: bigint): Promise<void> {
await createBuilder(harness, '')
.patch(`/test/users/${userId}/flags`)
.body({flags: flags.toString()})
.expect(HTTP_STATUS.OK)
.execute();
async function setProfileTimezoneUsers(userIds: Array<string>): Promise<void> {
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
included_user_ids: userIds,
});
}
async function updateProfilePrivacy(
@@ -76,7 +76,7 @@ describe('User Profile Timezone Visibility', () => {
it('defaults timezone visibility to everyone when a timezone is set', async () => {
const targetAccount = await createTestAccount(harness);
const viewerAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
expect(updated.timezone).toBe(TEST_TIMEZONE);
expect(updated.timezone_privacy_flags).toBe(ProfileFieldPrivacyFlags.EVERYONE);
@@ -86,7 +86,7 @@ describe('User Profile Timezone Visibility', () => {
});
it('restores default timezone visibility when a timezone is set again without explicit flags', async () => {
const targetAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: 0,
@@ -97,7 +97,7 @@ describe('User Profile Timezone Visibility', () => {
});
it('hides timezone from the public profile when privacy flags are unset', async () => {
const targetAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: 0,
@@ -109,7 +109,7 @@ describe('User Profile Timezone Visibility', () => {
const targetAccount = await createTestAccount(harness);
const friendAccount = await createTestAccount(harness);
const guildMemberAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: ProfileFieldPrivacyFlags.FRIENDS,
@@ -125,7 +125,7 @@ describe('User Profile Timezone Visibility', () => {
const targetAccount = await createTestAccount(harness);
const friendAccount = await createTestAccount(harness);
const guildMemberAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: ProfileFieldPrivacyFlags.MUTUAL_GUILDS,
@@ -140,7 +140,7 @@ describe('User Profile Timezone Visibility', () => {
it('hides timezone when full profile privacy restricts the viewer', async () => {
const targetAccount = await createTestAccount(harness);
const guildMemberAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
await updateProfilePrivacy(harness, targetAccount.token, ProfilePrivacyLevels.FRIENDS_ONLY);
await createSharedGuild(harness, targetAccount.token, guildMemberAccount.token);
@@ -148,23 +148,47 @@ describe('User Profile Timezone Visibility', () => {
expect(profile.profile_limited).toBe(true);
expect(profile.timezone_offset).toBeNull();
});
it('ignores profile timezone updates from non-staff users', async () => {
it('ignores profile timezone updates from users outside the experiment', async () => {
const targetAccount = await createTestAccount(harness, {skipEmailVerification: true});
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
expect(updated).not.toHaveProperty('timezone');
expect(updated).not.toHaveProperty('timezone_privacy_flags');
const updated = await updateProfileTimezone(harness, targetAccount.token, {
timezone: TEST_TIMEZONE,
timezone_privacy_flags: ProfileFieldPrivacyFlags.FRIENDS,
});
expect(updated.timezone).toBeNull();
expect(updated.timezone_privacy_flags).toBe(ProfileFieldPrivacyFlags.EVERYONE);
});
it('hides stored profile timezone after the user no longer has the staff flag', async () => {
it('ignores profile timezone updates from users excluded from a full rollout', async () => {
const targetAccount = await createTestAccount(harness, {skipEmailVerification: true});
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
rollout_basis_points: 10000,
excluded_user_ids: [targetAccount.userId],
});
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
expect(updated.timezone).toBeNull();
});
it('lets members of an included guild set and show a timezone', async () => {
const targetAccount = await createTestAccount(harness);
const viewerAccount = await createTestAccount(harness);
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
const guild = await createGuild(harness, targetAccount.token, 'Timezone Rollout Guild');
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
expect(updated.timezone).toBe(TEST_TIMEZONE);
await createFriendship(harness, targetAccount, viewerAccount);
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
expect(profile.timezone_offset).toBe(TEST_TIMEZONE_OFFSET);
});
it('hides stored profile timezone after the user leaves the experiment', async () => {
const targetAccount = await createTestAccount(harness);
const viewerAccount = await createTestAccount(harness);
await setProfileTimezoneUsers([targetAccount.userId]);
await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
await setUserFlags(harness, targetAccount.userId, 0n);
const currentUser = await createBuilder<UserPrivateResponse>(harness, targetAccount.token)
.get('/users/@me')
.execute();
expect(currentUser).not.toHaveProperty('timezone');
expect(currentUser).not.toHaveProperty('timezone_privacy_flags');
await setProfileTimezoneUsers([]);
await createFriendship(harness, targetAccount, viewerAccount);
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
expect(profile.timezone_offset).toBeNull();
+6 -1
View File
@@ -10,6 +10,11 @@ function getInviteEndpointBase(): string {
return `${url.hostname}${url.pathname.replace(/\/+$/, '')}`;
}
function getWebAppHostsPattern(): string {
const hostnames = new Set(Config.endpoints.webAppOrigins.map((origin) => new URL(origin).hostname));
return [...hostnames].map((hostname) => RegexUtils.escapeRegex(hostname)).join('|');
}
function getInvitePattern(): RegExp {
if (!_invitePattern) {
_invitePattern = new RegExp(
@@ -18,7 +23,7 @@ function getInvitePattern(): RegExp {
'(?:',
`${RegexUtils.escapeRegex(getInviteEndpointBase())}(?:\\/#)?\\/(?!invite\\/)([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
'|',
`${RegexUtils.escapeRegex(new URL(Config.endpoints.webApp).hostname)}(?:\\/#)?\\/invite\\/([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
`(?:${getWebAppHostsPattern()})(?:\\/#)?\\/invite\\/([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
')',
].join(''),
'gi',
+10 -8
View File
@@ -8,7 +8,7 @@ import * as InviteUtils from '@app/api/utils/InviteUtils';
import {URL_REGEX} from '@fluxer/constants/src/Core';
import * as idna from 'idna-uts46-hx';
const CLIENT_ROUTE_PATH_PREFIXES = ['/channels/', '/theme/'];
const CLIENT_ROUTE_PATH_PREFIXES = ['/channels/', '/theme/', '/invite/', '/gift/', '/oauth2/', '/users/'];
interface ExcludedLinkBase {
hostname: string;
@@ -19,12 +19,14 @@ function normalizeHostname(hostname: string | undefined) {
return hostname?.trim().toLowerCase() || '';
}
function getWebAppHostname() {
try {
return new URL(Config.endpoints.webApp).hostname;
} catch {
return '';
}
function getWebAppHostnames(): Array<string> {
return Config.endpoints.webAppOrigins.flatMap((origin) => {
try {
return [new URL(origin).hostname];
} catch {
return [];
}
});
}
function endpointLinkBase(endpoint: string): ExcludedLinkBase | null {
@@ -45,7 +47,7 @@ function getExcludedLinkBases(): Array<ExcludedLinkBase> {
endpointLinkBase(Config.endpoints.invite),
endpointLinkBase(Config.endpoints.gift),
];
for (const hostname of [getWebAppHostname(), Config.hosts.marketing]) {
for (const hostname of [...getWebAppHostnames(), Config.hosts.marketing]) {
for (const pathPrefix of CLIENT_ROUTE_PATH_PREFIXES) {
bases.push({hostname: normalizeHostname(hostname), pathPrefix});
}
@@ -13,7 +13,7 @@ import {
getSubscriptionPremiumPeriodEnd,
getSubscriptionStartDate,
} from '@app/api/stripe/StripeSubscriptionPeriod';
import {createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
@@ -73,6 +73,10 @@ function buildStripePremiumRepairPatch(user: User, subscription: Stripe.Subscrip
if (user.stripeSubscriptionId !== subscription.id) {
patch.stripe_subscription_id = subscription.id;
}
const clearedPremiumFlags = clearPerksSanitizedFlag(user.premiumFlags);
if (user.premiumFlags !== clearedPremiumFlags) {
patch.premium_flags = clearedPremiumFlags;
}
if (subscriptionCustomerId && user.stripeCustomerId !== subscriptionCustomerId) {
patch.stripe_customer_id = subscriptionCustomerId;
}
@@ -10,6 +10,7 @@ import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import processPremiumStateReconciliationQueue from '@app/api/worker/tasks/ProcessPremiumStateReconciliationQueue';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
import {PremiumFlags} from '@fluxer/constants/src/UserConstants';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import type Stripe from 'stripe';
import {afterEach, describe, expect, test} from 'vitest';
@@ -48,6 +49,28 @@ function createCancelledSubscription(endedAtMs: number): Stripe.Subscription {
} as unknown as Stripe.Subscription;
}
function createActiveSubscription(periodEndMs: number): Stripe.Subscription {
return {
id: 'sub_test',
status: 'active',
customer: 'cus_test',
ended_at: null,
canceled_at: null,
cancel_at: null,
cancel_at_period_end: false,
trial_end: null,
start_date: Math.floor((Date.now() - 200 * ONE_DAY_MS) / 1000),
items: {
data: [
{
current_period_end: Math.floor(periodEndMs / 1000),
price: {recurring: {interval: 'month'}},
},
],
},
} as unknown as Stripe.Subscription;
}
function createPaidInvoice(periodEndMs: number): Stripe.Invoice {
return {
id: 'in_test',
@@ -299,4 +322,27 @@ describe('processPremiumStateReconciliationQueue', () => {
expect(patches[0].premium_until).toBeNull();
expect(patches[0].premium_since).toBeNull();
});
test('clears the perks-sanitized latch once the subscription is active again', async () => {
const queueService = createQueueService();
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
const periodEndMs = Math.floor((Date.now() + 20 * ONE_DAY_MS) / 1000) * 1000;
const user = createPremiumUser({
premium_until: new Date(periodEndMs),
premium_flags: PremiumFlags.PERKS_SANITIZED,
});
const {userRepository, patches, extras} = createCapturingDeps(user);
setWorkerDependenciesForTest({
premiumStateReconciliationQueueService: queueService,
stripe: createStripeStub(createActiveSubscription(periodEndMs), []),
userRepository,
...extras,
});
await processPremiumStateReconciliationQueue({}, createHelpers());
expect(patches).toHaveLength(1);
expect(patches[0].premium_flags).toBe(0);
});
});
+1
View File
@@ -201,6 +201,7 @@
"@sapphi-red/web-noise-suppressor": "catalog:",
"@simplewebauthn/browser": "catalog:",
"@tanstack/react-virtual": "^3.14.13",
"altcha-lib": "catalog:",
"animejs": "4.5.0",
"bowser": "catalog:",
"clsx": "catalog:",
+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.

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