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
HampusandGitHub a0825e77c4 feat(voice): ship the screen share delivery rework to everyone (#2931) 2026-09-24 14:57:40 +02:00
HampusandGitHub 88038a1d5b fix(voice): stop direct input capturing microphones in stereo (#2929) 2026-09-24 14:51:05 +02:00
HampusandGitHub c2c0fdb445 fix(app): make corner volume control the focused stream (#2928) 2026-09-24 14:04:05 +02:00
HampusandGitHub dcd5f09d6a feat(api): add env toggles for automatic phone flagging (#2927) 2026-09-24 03:36:35 +02:00
HampusandGitHub 590b1f36fd docs(downloads): document the canary apt and dnf repositories (#2926) 2026-09-24 03:29:52 +02:00
HampusandGitHub 168ac727f1 fix(desktop): set the deb package synopsis (#2925) 2026-09-24 03:29:33 +02:00
HampusandGitHub deb86dd92e fix(admin): format users list search hint (#2924) 2026-09-24 02:12:37 +02:00
omsterandGitHub 7ccec4d3b8 feat(admin): hint text for * search in user page (#2922) 2026-09-24 01:56:17 +02:00
omsterandGitHub 2f38bcdf26 fix(admin): ordering fixes for admin user search and meilisearch (#2920) 2026-09-24 01:45:56 +02:00
HampusandGitHub f2785941aa fix(app): point self-hosted users at their instance admins (#2921) 2026-09-24 01:42:33 +02:00
HampusandGitHub ea9f83a443 fix(push): keep read-state clears alive as long as the alert (#2919) 2026-09-24 01:26:18 +02:00
HampusandGitHub bd6ca7290e fix(api): allow deleting messages without send permission (#2918) 2026-09-24 01:14:01 +02:00
HampusandGitHub b85e975fb5 feat(push): deliver our own relay endpoints in process (#2917) 2026-09-24 01:07:09 +02:00
HampusandGitHub b6e504f68c fix(push): keep device tokens out of logs (#2916) 2026-09-24 00:33:50 +02:00
HampusandGitHub 5fde6eb484 feat(push): ring Android calls and harden the relay (#2915) 2026-09-24 00:07:15 +02:00
HampusandGitHub b16989d567 feat(push): ring incoming calls on Apple PushKit devices (#2911) 2026-09-23 20:21:08 +02:00
HampusandGitHub c9754ac11a fix(api): exempt internal rpc from the client ip check (#2910) 2026-09-23 18:03:36 +02:00
fluxer-weblate[bot]andGitHub f34e4a5115 chore(i18n): update translations from Weblate (#2903) 2026-09-23 17:28:25 +02:00
fluxer-weblate[bot]andGitHub 44b3615298 chore(i18n): update translations from Weblate (#2904) 2026-09-23 17:27:59 +02:00
HampusandGitHub 211e98307d perf(push): cache endpoint guard dns verdicts (#2907) 2026-09-23 17:27:19 +02:00
627 changed files with 67767 additions and 41409 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/
Generated
+1
View File
@@ -1894,6 +1894,7 @@ dependencies = [
"futures",
"hmac 0.13.0",
"p256",
"percent-encoding",
"rand 0.10.2",
"reqwest",
"ring",
+23 -5
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] | | |
@@ -49,7 +49,7 @@ On Linux, prefer a repository over a single file so Fluxer updates with the rest
## Linux package repositories
Every repository serves both channels. The package is `fluxer` for stable, `fluxer-canary` for canary.
The package is `fluxer` for stable and `fluxer-canary` for canary. apt and dnf subscribe to one channel per entry file. pacman and Flatpak serve both from one repository.
### Flatpak
@@ -59,7 +59,7 @@ Stable is on [Flathub][flathub], the easiest route on most desktops:
flatpak install flathub app.fluxer.Fluxer
```
Flathub has stable only. For canary, or to use Fluxer's own repository, open [this reference file][flatpak-ref] and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar.
Flathub has stable only. To use Fluxer's own repository, open [the stable][flatpak-ref] or [the canary][flatpak-canary-ref] reference file and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar.
From a terminal:
@@ -76,6 +76,15 @@ sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources https://pkgs.fluxer.co
sudo apt update && sudo apt install fluxer
```
For canary, use the canary entry file and package.
```sh
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer-canary.sources https://pkgs.fluxer.com/deb/fluxer-canary.sources
sudo apt update && sudo apt install fluxer-canary
```
A `.deb` installed from a download only updates once its channel's entry is added.
### Fedora and RHEL
```sh
@@ -83,6 +92,13 @@ sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/flux
sudo dnf install fluxer
```
For canary, use the canary entry file and package.
```sh
sudo curl -fsSL -o /etc/yum.repos.d/fluxer-canary.repo https://pkgs.fluxer.com/rpm/fluxer-canary.repo
sudo dnf install fluxer-canary
```
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because their base repositories lack `libXScrnSaver`. Fedora does not.
### Arch Linux
@@ -150,7 +166,9 @@ endorsement rights.
[linux-targz-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/tar_gz
[linux-targz-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/tar_gz
[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
+182 -20
View File
@@ -10524,8 +10524,10 @@
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"screen_share_delivery": {"$ref": "#/components/schemas/ScreenShareDeliveryConfigResponse"},
"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",
@@ -10953,8 +10955,10 @@
"sso",
"gateway_rollout",
"voice_noise_suppression",
"screen_share_delivery",
"push_service_delivery",
"push_relay",
"domain_migration",
"altcha_captcha",
"profile_timezone",
"experiment_delivery",
"registration",
"self_hosted",
@@ -11089,13 +11093,18 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
},
"screen_share_delivery": {
"push_relay": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PushRelayConfigUpdateRequest"}]},
"domain_migration": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ScreenShareDeliveryConfigUpdateRequest"}]
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
},
"push_service_delivery": {
"altcha_captcha": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
},
"profile_timezone": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ProfileTimezoneConfigUpdateRequest"}]
},
"experiment_delivery": {
"nullable": true,
@@ -15190,7 +15199,7 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"PushServiceDeliveryConfigUpdateRequest": {
"ProfileTimezoneConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
@@ -15201,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",
@@ -15208,24 +15223,60 @@
}
}
},
"ScreenShareDeliveryConfigUpdateRequest": {
"AltchaCaptchaConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
},
"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": {
@@ -15244,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",
@@ -15291,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,
@@ -15310,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,
@@ -15323,29 +15387,47 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids"
],
"additionalProperties": false
},
"ScreenShareDeliveryConfigResponse": {
"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": "screen-share-delivery-v1", "type": "string", "minLength": 1, "maxLength": 64},
"rollout_salt": {
"default": "altcha-captcha-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
},
"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",
@@ -15353,10 +15435,80 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"excluded_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": {
@@ -15381,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,
@@ -15412,6 +15571,8 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"guild_overrides",
"suppression_strength"
@@ -15699,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": {
+189 -48
View File
@@ -23,9 +23,13 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
#[serde(default)]
pub screen_share_delivery: ScreenShareDeliveryConfigResponse,
pub push_relay: PushRelayConfigResponse,
#[serde(default)]
pub push_service_delivery: PushServiceDeliveryConfigResponse,
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub altcha_captcha: AltchaCaptchaConfigResponse,
#[serde(default)]
pub profile_timezone: ProfileTimezoneConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
}
@@ -451,8 +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 SCREEN_SHARE_DELIVERY_DEFAULT_SALT: &str = "screen-share-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 {
@@ -496,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,
@@ -512,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,
@@ -536,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>>,
@@ -543,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 ScreenShareDeliveryConfigResponse {
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 ScreenShareDeliveryConfigResponse {
impl Default for DomainMigrationConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: SCREEN_SHARE_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 ScreenShareDeliveryConfigUpdateRequest {
pub struct DomainMigrationConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -578,35 +615,53 @@ pub struct ScreenShareDeliveryConfigUpdateRequest {
#[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 PushServiceDeliveryConfigResponse {
pub struct AltchaCaptchaConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub anonymous_enabled: bool,
pub cost: u32,
pub max_counter: u32,
}
impl Default for PushServiceDeliveryConfigResponse {
impl Default for AltchaCaptchaConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_enabled: false,
cost: 5_000,
max_counter: 10_000,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PushServiceDeliveryConfigUpdateRequest {
pub struct AltchaCaptchaConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -616,6 +671,62 @@ pub struct PushServiceDeliveryConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_counter: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ProfileTimezoneConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
}
impl Default for ProfileTimezoneConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ProfileTimezoneConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
@@ -735,9 +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 screen_share_delivery: Option<ScreenShareDeliveryConfigUpdateRequest>,
pub push_relay: Option<PushRelayConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_timezone: Option<ProfileTimezoneConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
}
@@ -1076,19 +1191,33 @@ mod tests {
.expect("admin schema");
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
.expect("default noise config");
let screen_share = serde_json::from_value::<ScreenShareDeliveryConfigResponse>(json!({}))
.expect("default screen share 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 screen_share =
serde_json::to_value(screen_share).expect("serializable screen share 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_screen_share: generated_types::ScreenShareDeliveryConfigResponse =
serde_json::from_value(screen_share.clone())
.expect("generated screen share 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!(
@@ -1096,9 +1225,19 @@ mod tests {
noise
);
assert_eq!(
serde_json::to_value(generated_screen_share)
.expect("serializable generated screen share config"),
screen_share
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)
@@ -1107,7 +1246,9 @@ mod tests {
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("ScreenShareDeliveryConfigResponse", screen_share),
("DomainMigrationConfigResponse", domain_migration),
("AltchaCaptchaConfigResponse", altcha_captcha),
("ProfileTimezoneConfigResponse", profile_timezone),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
@@ -1119,29 +1260,6 @@ mod tests {
}
}
#[test]
fn screen_share_delivery_update_preserves_empty_lists_and_omitted_fields() {
let update = ScreenShareDeliveryConfigUpdateRequest {
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::ScreenShareDeliveryConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(ScreenShareDeliveryConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
let update = VoiceNoiseSuppressionConfigUpdateRequest {
@@ -1166,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!({})
);
}
}
+331 -64
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,10 +20,9 @@ use crate::{
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
PremiumMode, PushServiceDeliveryConfigUpdateRequest, RegistrationMode,
ScreenShareDeliveryConfigUpdateRequest, SsoConfigUpdateRequest,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
PremiumMode, ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest,
RegistrationMode, SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
},
},
config::AdminConfig,
@@ -208,11 +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_screen_share_delivery" => match build_screen_share_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_push_service_delivery" => match build_push_service_delivery_update(&form) {
"update_altcha_captcha" => match build_altcha_captcha_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_profile_timezone" => match build_profile_timezone_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
@@ -501,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> {
@@ -634,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",
@@ -653,62 +668,141 @@ fn build_voice_noise_suppression_update(
})
}
fn build_screen_share_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 {
screen_share_delivery: Some(ScreenShareDeliveryConfigUpdateRequest {
enabled: Some(form.bool_value("screen_share_delivery_enabled")),
domain_migration: Some(DomainMigrationConfigUpdateRequest {
enabled: Some(form.bool_value("domain_migration_enabled")),
rollout_basis_points: parse_form_number(
form,
"screen_share_delivery_rollout_basis_points",
"domain_migration_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"screen_share_delivery_rollout_salt",
"domain_migration_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("screen_share_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("screen_share_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_push_service_delivery_update(
fn build_altcha_captcha_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
push_service_delivery: Some(PushServiceDeliveryConfigUpdateRequest {
enabled: Some(form.bool_value("push_service_delivery_enabled")),
altcha_captcha: Some(AltchaCaptchaConfigUpdateRequest {
enabled: Some(form.bool_value("altcha_captcha_enabled")),
rollout_basis_points: parse_form_number(
form,
"push_service_delivery_rollout_basis_points",
"altcha_captcha_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_push_service_delivery_rollout_salt(
form,
"push_service_delivery_rollout_salt",
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_included_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("push_service_delivery_excluded_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",
)?),
@@ -1361,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": [],
}})
@@ -1639,20 +1735,17 @@ mod tests {
}
#[test]
fn build_screen_share_delivery_update_reads_the_rollout_fields() {
fn build_domain_migration_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"screen_share_delivery_enabled=true&screen_share_delivery_rollout_basis_points=%20250%20&screen_share_delivery_rollout_salt=%20screen-share-delivery-v2%20&screen_share_delivery_included_user_ids=1500000000000000001%0A1500000000000000002&screen_share_delivery_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
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_screen_share_delivery_update(&form)
let update = build_domain_migration_update(&form)
.expect("valid form")
.screen_share_delivery
.expect("screen share delivery update");
.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("screen-share-delivery-v2".to_owned())
);
assert_eq!(update.rollout_salt, Some("domain-migration-v2".to_owned()));
assert_eq!(
update.included_user_ids,
Some(vec![
@@ -1667,54 +1760,228 @@ mod tests {
"1500000000000000004".to_owned()
])
);
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
assert_eq!(update.standalone_forwarding, Some(true));
}
#[test]
fn build_screen_share_delivery_update_leaves_the_feature_inert_when_nothing_is_submitted() {
fn build_domain_migration_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_screen_share_delivery_update(&form).expect("valid form");
let request = build_domain_migration_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"screen_share_delivery": {
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 build_screen_share_delivery_update_rejects_invalid_rollout_fields() {
for (form, message) in [
fn every_experiment_update_rejects_an_invalid_included_guild_id() {
for (prefix, build) in [
(
"screen_share_delivery_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"screen_share_delivery_rollout_basis_points=abc",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"screen_share_delivery_rollout_salt=%20%20",
"Rollout salt must be between 1 and 64 characters",
),
(
"screen_share_delivery_included_user_ids=123%2Cinvalid",
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"screen_share_delivery_excluded_user_ids=123%2Cinvalid",
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
"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(form.as_bytes());
let form = MultiValueForm::parse(
format!("{prefix}_included_guild_ids=1500000000000000005%0Anot-a-guild").as_bytes(),
);
assert_eq!(
build_screen_share_delivery_update(&form).expect_err("invalid rollout field"),
message
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,14 @@
use crate::{
api::types::{
AppPublicConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
PendingRegistrationResponse, PushServiceDeliveryConfigResponse, RegistrationUrlResponse,
SCREEN_SHARE_DELIVERY_DEFAULT_SALT, ScreenShareDeliveryConfigResponse, SsoConfigResponse,
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,
@@ -136,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.",
@@ -149,8 +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))
(screen_share_delivery_section(base, csrf_token, &instance_config.screen_share_delivery))
(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))
@@ -1117,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",
@@ -1179,110 +1221,69 @@ fn voice_noise_suppression_section(
)
}
fn screen_share_delivery_section(
fn push_relay_section(
base: &str,
csrf_token: &str,
screen_share_delivery: &ScreenShareDeliveryConfigResponse,
push_relay: &PushRelayConfigResponse,
) -> Markup {
let status = if screen_share_delivery.enabled {
("Live", BadgeVariant::Success)
let status = if push_relay.relay_consent_accepted {
("Accepted", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
("Not accepted", BadgeVariant::Default)
};
let included_user_ids = screen_share_delivery.included_user_ids.join("\n");
let excluded_user_ids = screen_share_delivery.excluded_user_ids.join("\n");
let accepted_at =
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
let accepted_by = push_relay
.relay_consent_accepted_by
.as_deref()
.unwrap_or("Nobody");
section_card_with_description(
"Screen Share Delivery",
"Pick how many clients publish screen shares through the reworked delivery path. While \
the master switch below is off nothing on this form reaches any client: every user \
keeps the screen share pipeline they have today, whatever the rest of these fields say. \
A client that is already sharing keeps the path it started on until the share ends.",
"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_screen_share_delivery"} {
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
h3 class="text-sm font-semibold text-neutral-900" { "Relay consent" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (screen_share_delivery.config_version)
}
}
(checkbox(
"screen_share_delivery_enabled",
"push_relay_consent_accepted",
"true",
"Serve screen share delivery assignments to clients",
screen_share_delivery.enabled,
"Accept the push relay supplemental privacy notice",
push_relay.relay_consent_accepted,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
feature is inert and keeps its current behavior, so the rollout and \
targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"screen_share_delivery_rollout_basis_points",
"Rollout (basis points)",
&screen_share_delivery.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"screen_share_delivery_rollout_salt",
"Rollout Salt",
&screen_share_delivery.rollout_salt,
SCREEN_SHARE_DELIVERY_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."
"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="flex flex-col gap-2" {
(textarea_input(
"screen_share_delivery_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
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);
},
))
(entry_count_hint(
screen_share_delivery.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
(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);
},
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"screen_share_delivery_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
screen_share_delivery.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."
}
}
(form_actions(html! {
(submit_button("Save Screen Share Delivery Configuration"))
(submit_button("Save Push Relay Settings"))
}))
}
}
@@ -1290,70 +1291,93 @@ fn screen_share_delivery_section(
)
}
fn push_service_delivery_section(
fn domain_migration_section(
base: &str,
csrf_token: &str,
push_service_delivery: &PushServiceDeliveryConfigResponse,
domain_migration: &DomainMigrationConfigResponse,
) -> Markup {
let status = if push_service_delivery.enabled {
let status = if domain_migration.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = push_service_delivery.included_user_ids.join("\n");
let excluded_user_ids = push_service_delivery.excluded_user_ids.join("\n");
let included_user_ids = domain_migration.included_user_ids.join("\n");
let excluded_user_ids = domain_migration.excluded_user_ids.join("\n");
section_card_with_description(
"Push Service Delivery",
"Routes push notification delivery for the selected accounts through the push service. \
Accounts the rollout does not select keep the current path.",
"Domain Migration",
"Moves web clients of the official instance from the legacy web app origin to the new \
one. Selected accounts copy their local data across and continue on the new origin. \
Clients of other instances read this configuration and ignore it.",
html! {
form method="post" action={(base) "/instance-config?action=update_push_service_delivery"} {
form method="post" action={(base) "/instance-config?action=update_domain_migration"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (push_service_delivery.config_version)
"Config version " (domain_migration.config_version)
}
}
(checkbox(
"push_service_delivery_enabled",
"domain_migration_enabled",
"true",
"Hand push notifications to the push service",
push_service_delivery.enabled,
"Move selected web clients to the new origin",
domain_migration.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every notification keeps the \
current delivery path, so the rollout and targeting fields below have no \
effect at all."
"Off is the safe state and the kill switch. With this unchecked no client \
starts a migration and clients that already migrated stop forwarding the \
legacy origin, so the rollout and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Installed apps" }
(checkbox(
"domain_migration_standalone_forwarding",
"true",
"Forward installed desktop web apps to the new origin",
domain_migration.standalone_forwarding,
true,
))
p class="text-xs text-neutral-500" {
"Leave this off until the manifest scope extension and the association file \
are live and verified. While it is off, installed Chromium desktop apps copy \
their data across but stay on the legacy origin and offer to install the new \
app. Installed mobile and Safari apps never forward either way."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"push_service_delivery_rollout_basis_points",
"domain_migration_rollout_basis_points",
"Rollout (basis points)",
&push_service_delivery.rollout_basis_points.to_string(),
&domain_migration.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
Some("Share of logged-in users bucketed into the migration, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
(number_field(
"domain_migration_anonymous_rollout_basis_points",
"Anonymous rollout (basis points)",
&domain_migration.anonymous_rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
))
div class="flex flex-col gap-2" {
(text_input(
"push_service_delivery_rollout_salt",
"domain_migration_rollout_salt",
"Rollout Salt",
&push_service_delivery.rollout_salt,
PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
&domain_migration.rollout_salt,
DOMAIN_MIGRATION_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
"Seeds the bucketing hash for users and devices. Changing it reshuffles \
which users and devices fall inside the percentages above. Leave it \
alone to keep the current cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"push_service_delivery_included_user_ids",
"domain_migration_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
@@ -1361,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" {
@@ -1371,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,
@@ -1381,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"))
}))
}
}
@@ -2043,24 +2408,52 @@ mod tests {
}
#[test]
fn screen_share_delivery_section_shows_list_counts_and_the_master_switch() {
let screen_share_delivery = ScreenShareDeliveryConfigResponse {
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(),
],
..ScreenShareDeliveryConfigResponse::default()
..DomainMigrationConfigResponse::default()
};
let markup =
screen_share_delivery_section("/admin", "csrf", &screen_share_delivery).into_string();
assert!(markup.contains("action=update_screen_share_delivery"));
assert!(markup.contains("screen_share_delivery_enabled"));
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 {
@@ -114,7 +114,10 @@ pub fn users_list_page(
let content = html! {
div class="space-y-6" {
(page_header("Users", None))
div class="rounded-lg bg-white transition-all border border-neutral-200 p-4" {
div class="rounded-lg bg-white transition-all border border-neutral-200 p-3" {
p class="mb-1 text-xs text-neutral-500" {
"For example, type " span class="font-mono" { "*" } " in to search for all users."
}
(search_form(base, params))
}
(results_markup)
+104 -19
View File
@@ -403,28 +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"]
},
"screen_share_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": "screen-share-delivery-v1",
"rollout_salt": "domain-migration-v1",
"included_user_ids": ["1500000000000000001"],
"future_delivery_knob": 9,
"excluded_user_ids": []
"excluded_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"future_migration_knob": 9,
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
},
"push_service_delivery": {
"altcha_captcha": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1",
"included_user_ids": ["1500000000000000002"],
"excluded_user_ids": []
"rollout_basis_points": 500,
"rollout_salt": "altcha-captcha-v1",
"included_user_ids": [],
"excluded_user_ids": ["1500000000000000003"],
"anonymous_enabled": true,
"cost": 5000,
"max_counter": 10000,
"included_guild_ids": [],
"include_premium_users": false,
"future_altcha_knob": "argon2id"
},
"profile_timezone": {
"enabled": true,
"config_version": 2,
"rollout_basis_points": 0,
"rollout_salt": "profile-timezone-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"future_profile_timezone_knob": true
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
@@ -555,14 +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.screen_share_delivery.enabled);
assert_eq!(resp.screen_share_delivery.config_version, 2);
assert_eq!(resp.screen_share_delivery.rollout_basis_points, 2500);
assert_eq!(
*resp.screen_share_delivery.rollout_salt,
"screen-share-delivery-v1"
);
assert_eq!(resp.screen_share_delivery.included_user_ids.len(), 1);
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));
@@ -573,7 +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_delivery_knob\": 9,", "")
.replace("\"future_migration_knob\": 9,", "")
.replace("\"future_object_knob\": {\"nested\": true},", "")
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
.replace(
@@ -595,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#"{
@@ -869,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",
+6 -4
View File
@@ -465,7 +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_screen_share_delivery",
"/instance-config?action=update_domain_migration",
"/instance-config?action=update_experiment_delivery",
][..],
),
@@ -1200,13 +1200,15 @@ fn instance_config() -> Value {
"guild_overrides": [],
"suppression_strength": 80
},
"screen_share_delivery": {
"domain_migration": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "screen-share-delivery-v1",
"rollout_salt": "domain-migration-v1",
"included_user_ids": [],
"excluded_user_ids": []
"excluded_user_ids": [],
"anonymous_rollout_basis_points": 0,
"standalone_forwarding": false
},
"experiment_delivery": {
"poll_interval_seconds": 300,
+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,
+14
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,
@@ -476,6 +486,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
abusePolicy: {
inboundPhoneCountryCodes: master.instance.abuse_policy.inbound_phone_country_codes,
phoneFlagging: {
enabled: master.instance.abuse_policy.phone_flagging.enabled,
exemptCountryCodes: master.instance.abuse_policy.phone_flagging.exempt_country_codes,
},
phoneVerification: {
inboundRequiredPrefixes: master.instance.abuse_policy.phone_verification.inbound_required_prefixes,
},
@@ -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,9 +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 {ScreenShareDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
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';
@@ -65,8 +67,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
screenShareDelivery,
pushServiceDelivery,
pushRelay,
domainMigration,
altchaCaptcha,
profileTimezone,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -75,8 +79,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getScreenShareDeliveryConfig(),
instanceConfigRepository.getPushServiceDeliveryConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -108,8 +114,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
screen_share_delivery: screenShareDelivery,
push_service_delivery: pushServiceDelivery,
push_relay: pushRelay,
domain_migration: domainMigration,
altcha_captcha: altchaCaptcha,
profile_timezone: profileTimezone,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -194,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)
@@ -273,11 +295,23 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.screen_share_delivery) {
const patch = omitUndefinedFields(data.screen_share_delivery);
if (data.push_relay) {
const patch = omitUndefinedFields(data.push_relay);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateScreenShareDeliveryConfig((current) =>
ScreenShareDeliveryConfigSchema.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,
@@ -285,17 +319,28 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.push_service_delivery) {
const patch = omitUndefinedFields(data.push_service_delivery);
if (data.altcha_captcha) {
const patch = omitUndefinedFields(data.altcha_captcha);
if (Object.keys(patch).length > 0) {
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
PushServiceDeliveryConfigSchema.parse({
await instanceConfigRepository.updateAltchaCaptchaConfig((current) =>
AltchaCaptchaConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.profile_timezone) {
const patch = omitUndefinedFields(data.profile_timezone);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateProfileTimezoneConfig((current) =>
ProfileTimezoneConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
await getPushServiceDeliveryConfigPublisher().publish(landed);
}
}
if (data.experiment_delivery) {
@@ -11,6 +11,7 @@ import {Logger} from '@app/api/Logger';
import {getGuildSearchService, getUserSearchService} from '@app/api/SearchFactory';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {UserSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
interface RefreshSearchIndexJobPayload extends WorkerJobPayload {
@@ -130,16 +131,28 @@ export class AdminSearchService {
throw new FeatureTemporarilyDisabledError();
}
const query = data.query?.trim() || '';
const isBrowseAll = query === '' || query === '*';
const searchFilters: UserSearchFilters = isBrowseAll
? {sortBy: 'createdAt', sortOrder: 'asc'}
: {sortBy: 'relevance'};
const directUserId = /^\d+$/.test(query) ? createUserID(BigInt(query)) : null;
const canResolveDirectUser = directUserId !== null && !isSyntheticUserId(directUserId) && data.offset === 0;
const [searchResult, directUser] = await Promise.all([
userSearchService.search(query, {}, {limit: data.limit, offset: data.offset}),
userSearchService.search(query, searchFilters, {limit: data.limit, offset: data.offset}),
canResolveDirectUser ? userRepository.findUnique(directUserId).catch(() => null) : Promise.resolve(null),
]);
const {hits, total} = searchResult;
const userIds = hits.map((hit) => createUserID(BigInt(hit.id)));
const users = await userRepository.listUsers(userIds);
const response = await Promise.all(users.map((user) => mapUserToAdminResponse(user, cacheService, acls)));
const usersById = new Map(users.map((user) => [user.id.toString(), user]));
const orderedUsers = [];
for (const userId of userIds) {
const user = usersById.get(userId.toString());
if (user) {
orderedUsers.push(user);
}
}
const response = await Promise.all(orderedUsers.map((user) => mapUserToAdminResponse(user, cacheService, acls)));
if (directUser && data.offset === 0) {
const directId = directUser.id.toString();
if (!response.some((u) => u.id === directId)) {
@@ -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);
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
import {Config} from '@app/api/Config';
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {AppErrorHandler, AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
import {Hono} from 'hono';
import {afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const CLIENT_IP_HEADER_NAME = 'x-real-ip';
function createProductionApp(): Hono<HonoEnv> {
const routes = new Hono<HonoEnv>({strict: true});
configureMiddleware(routes, {
logger: new NoopLogger(),
nodeEnv: 'production',
corsOrigins: ['https://web.fluxer.app'],
trustClientIpHeader: true,
clientIpHeaderName: CLIENT_IP_HEADER_NAME,
maxInflightRequests: 100,
torExitBlockingEnabled: false,
});
routes.onError(AppErrorHandler);
routes.notFound(AppNotFoundHandler);
routes.post('/internal/rpc', (ctx) => ctx.json({ok: true}));
routes.get('/connections/bluesky/jwks.json', (ctx) => ctx.json({keys: []}));
routes.get('/users/@me', (ctx) => ctx.json({ok: true}));
const app = new Hono<HonoEnv>({strict: true});
app.route('/v1', routes);
app.route('/', routes);
app.onError(AppErrorHandler);
app.notFound(AppNotFoundHandler);
return app;
}
describe('client ip requirements across the production middleware pipeline', () => {
let previousTestModeEnabled: boolean;
let previousTrustClientIpHeader: boolean;
let previousClientIpHeader: string;
beforeAll(() => {
setInjectedWorkerService(new NoopWorkerService());
});
beforeEach(() => {
previousTestModeEnabled = Config.dev.testModeEnabled;
previousTrustClientIpHeader = Config.proxy.trust_client_ip_header;
previousClientIpHeader = Config.proxy.client_ip_header;
Config.dev.testModeEnabled = false;
Config.proxy.trust_client_ip_header = true;
Config.proxy.client_ip_header = CLIENT_IP_HEADER_NAME;
});
afterEach(() => {
Config.dev.testModeEnabled = previousTestModeEnabled;
Config.proxy.trust_client_ip_header = previousTrustClientIpHeader;
Config.proxy.client_ip_header = previousClientIpHeader;
});
it('serves the internal rpc route without a client ip header', async () => {
const app = createProductionApp();
const response = await app.request('http://api:8080/internal/rpc', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: '{}',
});
expect(response.status).toBe(200);
});
it('serves the internal rpc route with a client ip header', async () => {
const app = createProductionApp();
const response = await app.request('http://api:8080/internal/rpc', {
method: 'POST',
headers: {'content-type': 'application/json', [CLIENT_IP_HEADER_NAME]: '203.0.113.10'},
body: '{}',
});
expect(response.status).toBe(200);
});
it('serves an exempt public route without a client ip header', async () => {
const app = createProductionApp();
const response = await app.request('http://api:8080/connections/bluesky/jwks.json');
expect(response.status).toBe(200);
});
it('still rejects a non exempt route without a client ip header', async () => {
const app = createProductionApp();
const response = await app.request('http://api:8080/users/@me');
expect(response.status).toBe(403);
expect(await response.json()).toMatchObject({code: 'FORBIDDEN'});
});
});
+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();
+4 -2
View File
@@ -23,7 +23,7 @@ import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstri
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {UserSettings} from '@app/api/models/UserSettings';
import {countryRequiresInboundPhoneVerification} from '@app/api/risk/AbusePolicy';
import {countryRequiresInboundPhoneVerification, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
import {
type IAccountPolicyEvaluator,
isAssessmentThresholdAuditEvent,
@@ -362,7 +362,9 @@ export async function register(
action: riskResult.recommendedAction,
},
});
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(policyDecision.flagBits);
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(
await stripDisallowedPhoneFlags(policyDecision.flagBits, async () => countryCode),
);
const createdAt = new Date();
const riskContext = deriveLatestRiskContext({
userId: userId.toString(),
+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);
});
});
@@ -9,6 +9,7 @@ import {
loginAccount,
registerUser,
} from '@app/api/auth/tests/AuthTestUtils';
import {Config} from '@app/api/Config';
import {setInjectedRegistrationRiskEvaluator} from '@app/api/middleware/ServiceMiddleware';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {
@@ -33,7 +34,7 @@ import {
SuspiciousActivityFlags,
} from '@fluxer/constants/src/UserConstants';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
function phoneRiskEvaluator(level: RiskLevelType, riskScore: number): IRegistrationRiskEvaluator {
return {
@@ -241,6 +242,59 @@ describe('Deferred phone verification gate', () => {
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
});
describe('with phone flagging disabled', () => {
const originalPhoneFlagging = {...Config.abusePolicy.phoneFlagging};
afterEach(() => {
Config.abusePolicy.phoneFlagging = originalPhoneFlagging;
});
it('sets no phone requirement and no deferral at registration', async () => {
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
const registration = await registerUser(harness, {
email: createUniqueEmail('flagging-off'),
username: createUniqueUsername('flagging_off'),
global_name: 'Flagging Off',
password: 'StrongPassword!123',
date_of_birth: '2000-01-01',
consent: true,
});
const flags = await readFlags(registration.user_id);
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).toBe(0);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
});
it('keeps an existing deferral dormant on a qualifying join', async () => {
await getInstanceConfigRepository().setInstancePolicyConfig({
deferred_phone_gate_enabled: true,
deferred_phone_gate_member_threshold: 1,
deferred_phone_gate_window_hours: 24,
});
const {inviteCode} = await createGuildWithInvite(harness);
const filler = await createTestAccount(harness);
await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute();
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
const registration = await registerUser(harness, {
email: createUniqueEmail('flagging-off-join'),
username: createUniqueUsername('flagging_off_join'),
global_name: 'Flagging Off Join',
password: 'StrongPassword!123',
date_of_birth: '2000-01-01',
consent: true,
});
setInjectedRegistrationRiskEvaluator(undefined);
expect((await readFlags(registration.user_id)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(200).execute();
const flags = await readFlags(registration.user_id);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
});
});
describe('phone gate escape', () => {
async function configurePhoneGate(
overrides: {
@@ -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();
}
@@ -0,0 +1,210 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createChannelID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import {CallService} from '@app/api/channel/services/CallService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {CallCaller, CallData, IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Channel} from '@app/api/models/Channel';
import type {User} from '@app/api/models/User';
import type {ReadStateService} from '@app/api/read_state/ReadStateService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {beforeEach, describe, expect, it} from 'vitest';
const CALLER_ID = createUserID(1n);
const RECIPIENT_ID = createUserID(2n);
const CHANNEL_ID = createChannelID(12n);
interface CallerOverrides {
username?: string;
globalName?: string | null;
avatar?: string | null;
nickname?: string;
userRowMissing?: boolean;
}
interface Harness {
service: CallService;
created: Array<CallCaller | undefined>;
rung: Array<CallCaller | undefined>;
}
const EXISTING_CALL: CallData = {
channel_id: CHANNEL_ID.toString(),
message_id: '99',
region: 'automatic',
ringing: [],
recipients: [CALLER_ID.toString(), RECIPIENT_ID.toString()],
voice_states: [],
};
function harness(overrides: CallerOverrides, existingCall: CallData | null): Harness {
const username = overrides.username ?? 'elias';
const globalName = overrides.globalName === undefined ? 'Elias' : overrides.globalName;
const avatar = overrides.avatar === undefined ? 'a1b2c3d4' : overrides.avatar;
const nicknames = new Map<string, string>();
if (overrides.nickname !== undefined) {
nicknames.set(CALLER_ID.toString(), overrides.nickname);
}
const channel = {
id: CHANNEL_ID,
type: ChannelTypes.GROUP_DM,
recipientIds: new Set<UserID>([CALLER_ID, RECIPIENT_ID]),
nicknames,
} as unknown as Channel;
const created: Array<CallCaller | undefined> = [];
const rung: Array<CallCaller | undefined> = [];
const channelRepository = {
findUnique: async () => channel,
upsertMessage: async () => {},
getMessage: async () => null,
} as unknown as IChannelRepository;
const userRepository = {
findUnique: async () => (overrides.userRowMissing ? null : ({...callerUser(username, globalName, avatar)} as User)),
listUsers: async () => [],
findSettings: async () => null,
isDmChannelOpen: async () => true,
} as unknown as IUserRepository;
const gatewayService = {
getCall: async () => existingCall,
createCall: async (
_channelId: unknown,
_messageId: string,
_region: string,
_ringing: Array<string>,
_recipients: Array<string>,
caller?: CallCaller,
) => {
created.push(caller);
return EXISTING_CALL;
},
ringCallRecipients: async (_channelId: unknown, _recipients: Array<string>, caller?: CallCaller) => {
rung.push(caller);
return true;
},
} as unknown as IGatewayService;
const userCacheService = {
getUserPartialResponse: async (): Promise<UserPartialResponse> =>
({
id: CALLER_ID.toString(),
username,
discriminator: '0001',
global_name: globalName,
avatar,
avatar_color: null,
flags: 0,
}) as unknown as UserPartialResponse,
} as unknown as UserCacheService;
const snowflakeService = {
generateForChannel: async () => 7777n,
} as unknown as ISnowflakeService;
const readStateService = {
ackMessage: async () => {},
bulkIncrementMentionCounts: async () => {},
} as unknown as ReadStateService;
const service = new CallService(
channelRepository,
userRepository,
{} as unknown as IGuildRepositoryAggregate,
gatewayService,
userCacheService,
snowflakeService,
readStateService,
null,
{} as unknown as IVoiceRoomStore,
);
return {service, created, rung};
}
function callerUser(username: string, globalName: string | null, avatar: string | null): Partial<User> {
return {
id: CALLER_ID,
username,
globalName,
avatarHash: avatar,
isBot: false,
};
}
const requestCache = {
userPartials: new Map(),
} as unknown as RequestCache;
describe('CallService caller identity', () => {
let harnessState: Harness;
const createCall = (overrides: CallerOverrides = {}) => {
harnessState = harness(overrides, null);
return harnessState.service.createOrGetCall({
userId: CALLER_ID,
channelId: CHANNEL_ID,
ringing: [RECIPIENT_ID],
requestCache,
});
};
const ringExistingCall = (overrides: CallerOverrides = {}) => {
harnessState = harness(overrides, EXISTING_CALL);
return harnessState.service.ringCallRecipients({
userId: CALLER_ID,
channelId: CHANNEL_ID,
requestCache,
});
};
beforeEach(() => {
requestCache.userPartials.clear();
});
it('sends the caller id, display name and avatar hash to createCall', async () => {
await createCall();
expect(harnessState.created).toEqual([{id: '1', name: 'Elias', avatar: 'a1b2c3d4'}]);
});
it('prefers the group dm nickname over the global name on createCall', async () => {
await createCall({nickname: 'Eli'});
expect(harnessState.created[0]?.name).toBe('Eli');
});
it('falls back to the username when the caller has no nickname and no global name', async () => {
await createCall({globalName: null});
expect(harnessState.created[0]?.name).toBe('elias');
});
it('sends a null avatar when the caller has no custom avatar', async () => {
await createCall({avatar: null});
expect(harnessState.created[0]).toEqual({id: '1', name: 'Elias', avatar: null});
});
it('sends no caller at all when the caller user row is gone', async () => {
await createCall({userRowMissing: true});
expect(harnessState.created).toEqual([undefined]);
});
it('sends the caller id, display name and avatar hash to ringCallRecipients', async () => {
await ringExistingCall();
expect(harnessState.rung).toEqual([{id: '1', name: 'Elias', avatar: 'a1b2c3d4'}]);
});
it('prefers the group dm nickname over the global name on ringCallRecipients', async () => {
await ringExistingCall({nickname: 'Eli'});
expect(harnessState.rung[0]?.name).toBe('Eli');
});
it('falls back to the username on ringCallRecipients', async () => {
await ringExistingCall({globalName: null});
expect(harnessState.rung[0]?.name).toBe('elias');
});
it('resolves the caller on the ring branch and not on the create branch', async () => {
await ringExistingCall();
expect(harnessState.created).toEqual([]);
expect(harnessState.rung).toHaveLength(1);
});
});
@@ -12,6 +12,7 @@ import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Channel} from '@app/api/models/Channel';
import type {ReadStateService} from '@app/api/read_state/ReadStateService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {VoiceAccessContext, VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
@@ -208,14 +209,26 @@ export class CallService {
has_reaction: false,
version: 1,
});
const author = await this.userRepository.findUnique(userId);
const call = await this.gatewayService.createCall(
channelId,
messageId.toString(),
selectedRegion,
ringing.map((id) => id.toString()),
allRecipients.map((id) => id.toString()),
author
? {
id: userId.toString(),
name: this.resolveCallerName({
channel,
userId,
globalName: author.globalName,
username: author.username,
}),
avatar: author.avatarHash,
}
: undefined,
);
const author = await this.userRepository.findUnique(userId);
await incrementDmMentionCounts({
readStateService: this.readStateService,
userRepository: this.userRepository,
@@ -390,13 +403,45 @@ export class CallService {
longitude,
});
} else {
const caller = await this.userCacheService.getUserPartialResponse(userId, requestCache);
await this.gatewayService.ringCallRecipients(
channelId,
recipientsToRing.map((id) => id.toString()),
{
id: userId.toString(),
name: this.resolveCallerName({
channel,
userId,
globalName: caller.global_name,
username: caller.username,
}),
avatar: caller.avatar,
},
);
}
}
private resolveCallerName({
channel,
userId,
globalName,
username,
}: {
channel: Channel;
userId: UserID;
globalName: string | null;
username: string;
}): string {
const nickname = channel.nicknames.get(userId.toString());
if (nickname) {
return nickname;
}
if (globalName) {
return globalName;
}
return username;
}
async stopRingingCallRecipients({
userId,
channelId,
@@ -252,9 +252,7 @@ export class MessageValidationService {
const isAuthor = message.authorId === userId;
if (!guild) return isAuthor;
if (isAuthor) return true;
const canManageMessages =
(await hasPermission(Permissions.SEND_MESSAGES)) && (await hasPermission(Permissions.MANAGE_MESSAGES));
return canManageMessages;
return hasPermission(Permissions.MANAGE_MESSAGES);
}
private validateVoiceMessageConstraints(
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
createPermissionOverwrite,
sendChannelMessage,
setupTestGuildWithMembers,
} from '@app/api/channel/tests/ChannelTestUtils';
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 {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
describe('Message delete permissions', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('lets a member with MANAGE_MESSAGES but without SEND_MESSAGES delete another member message', async () => {
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
const [author, moderator] = members as [TestAccount, TestAccount];
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'delete me');
await createPermissionOverwrite(harness, owner.token, systemChannel.id, moderator.userId, {
type: 1,
allow: Permissions.MANAGE_MESSAGES.toString(),
deny: Permissions.SEND_MESSAGES.toString(),
});
await createBuilder(harness, moderator.token)
.delete(`/channels/${systemChannel.id}/messages/${message.id}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, author.token)
.get(`/channels/${systemChannel.id}/messages/${message.id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('refuses a member without MANAGE_MESSAGES deleting another member message', async () => {
const {members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
const [author, other] = members as [TestAccount, TestAccount];
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'keep me');
await createBuilder(harness, other.token)
.delete(`/channels/${systemChannel.id}/messages/${message.id}`)
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
.execute();
});
});
+5
View File
@@ -129,6 +129,7 @@ export interface APIConfig {
apiPublic: string;
apiClient: string;
webApp: string;
webAppOrigins: Array<string>;
gateway: string;
media: string;
staticCdn: string;
@@ -336,6 +337,10 @@ export interface APIConfig {
};
abusePolicy: {
inboundPhoneCountryCodes: Array<string>;
phoneFlagging: {
enabled: boolean;
exemptCountryCodes: Array<string>;
};
phoneVerification: {
inboundRequiredPrefixes: Array<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 {
@@ -15,7 +15,12 @@ import type {GuildFolderIcon, MentionReplyPreference} from '@fluxer/constants/sr
import type {types} from 'cassandra-driver';
type Nullish<T> = T | null;
export type PushSubscriptionPlatform = 'web_push' | 'android_fcm' | 'ios_apns' | 'android_unified_push';
export type PushSubscriptionPlatform =
| 'web_push'
| 'android_fcm'
| 'ios_apns'
| 'ios_apns_voip'
| 'android_unified_push';
export interface UserRow {
user_id: UserID;
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
@@ -8,7 +9,9 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {entityTagMatches} from '@app/api/utils/EntityTag';
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
import {resolveScreenShareDeliveryAssignment} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
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';
@@ -29,18 +32,30 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, voiceConfig, screenShareConfig] = await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getScreenShareDeliveryConfig(),
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),
screen_share_delivery: resolveScreenShareDeliveryAssignment(screenShareConfig, 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,15 +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_SCREEN_SHARE_DELIVERY_CONFIG,
INERT_SCREEN_SHARE_DELIVERY_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
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,
@@ -19,7 +30,7 @@ import {
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
type ExperimentAssignmentsResponse,
type ExperimentDeliveryConfigResponse,
readScreenShareDeliveryAssignment,
readDomainMigrationAssignment,
readVoiceNoiseSuppressionAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
@@ -56,7 +67,9 @@ describe('GET /experiments', () => {
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
assignments: {
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
screen_share_delivery: INERT_SCREEN_SHARE_DELIVERY_ASSIGNMENT,
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
profile_timezone: INERT_PROFILE_TIMEZONE_ASSIGNMENT,
},
});
});
@@ -88,20 +101,20 @@ describe('GET /experiments', () => {
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
});
it('populates the screen share assignment key even when the rollout is disabled', async () => {
it('populates the domain migration assignment key even when the rollout is disabled', async () => {
const account = await createTestAccount(harness);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(Object.hasOwn(body.assignments, 'screen_share_delivery')).toBe(true);
expect(readScreenShareDeliveryAssignment(body).enabled).toBe(false);
expect(Object.hasOwn(body.assignments, 'domain_migration')).toBe(true);
expect(readDomainMigrationAssignment(body).enabled).toBe(false);
});
it('resolves the screen share caller through the allowlist', async () => {
it('resolves the domain migration caller through the allowlist', async () => {
const targeted = await createTestAccount(harness);
const untargeted = await createTestAccount(harness);
await getInstanceConfigRepository().setScreenShareDeliveryConfig({
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
config_version: 4,
rollout_basis_points: 0,
@@ -111,18 +124,18 @@ describe('GET /experiments', () => {
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.screen_share_delivery).toEqual({enabled: true});
expect(targetedBody.assignments.domain_migration).toEqual({enabled: true});
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
.get(ENDPOINT)
.execute();
expect(untargetedBody.assignments.screen_share_delivery).toEqual({enabled: false});
expect(untargetedBody.assignments.domain_migration).toEqual({enabled: false});
});
it('keeps the screen share exclusion ahead of a full rollout', async () => {
it('keeps the domain migration exclusion ahead of a full rollout', async () => {
const excluded = await createTestAccount(harness);
await getInstanceConfigRepository().setScreenShareDeliveryConfig({
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
rollout_basis_points: 10000,
included_user_ids: [excluded.userId],
@@ -131,7 +144,216 @@ describe('GET /experiments', () => {
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token).get(ENDPOINT).execute();
expect(body.assignments.screen_share_delivery).toEqual({enabled: false});
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 () => {
@@ -248,7 +470,7 @@ describe('GET /experiments', () => {
});
});
it('serves a fresh body once the screen share config changes', async () => {
it('serves a fresh body once the domain migration config changes', async () => {
const account = await createTestAccount(harness);
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
@@ -256,8 +478,8 @@ describe('GET /experiments', () => {
.executeWithResponse();
const staleEtag = first.response.headers.get('etag') as string;
await getInstanceConfigRepository().setScreenShareDeliveryConfig({
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
config_version: 1,
rollout_basis_points: 10000,
@@ -269,7 +491,7 @@ describe('GET /experiments', () => {
.executeWithResponse();
expect(refreshed.response.status).toBe(HTTP_STATUS.OK);
expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag);
expect(refreshed.json?.assignments.screen_share_delivery).toEqual({enabled: true});
expect(refreshed.json?.assignments.domain_migration).toEqual({enabled: true});
});
it('serves a fresh body once the delivery config changes', async () => {
@@ -328,42 +550,45 @@ describe('GET /experiments', () => {
});
});
it('bumps the screen share config version on every admin update without the client sending one', async () => {
it('bumps the domain migration config version on every admin update without the client sending one', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const afterFirst = await createBuilder<{screen_share_delivery: {config_version: number; enabled: boolean}}>(
const afterFirst = await createBuilder<{domain_migration: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({screen_share_delivery: {enabled: true, rollout_basis_points: 10000}})
.body({domain_migration: {enabled: true, rollout_basis_points: 10000}})
.execute();
expect(afterFirst.screen_share_delivery).toMatchObject({config_version: 1, enabled: true});
expect(afterFirst.domain_migration).toMatchObject({config_version: 1, enabled: true});
const afterSecond = await createBuilder<{screen_share_delivery: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
const afterSecond = await createBuilder<{
domain_migration: {config_version: number; enabled: boolean; anonymous_rollout_basis_points: number};
}>(harness, admin.token)
.patch('/admin/instance/config')
.body({screen_share_delivery: {rollout_salt: 'screen-share-delivery-v2'}})
.body({domain_migration: {anonymous_rollout_basis_points: 2500}})
.execute();
expect(afterSecond.screen_share_delivery).toMatchObject({config_version: 2, enabled: true});
expect(afterSecond.domain_migration).toMatchObject({
config_version: 2,
enabled: true,
anonymous_rollout_basis_points: 2500,
});
const afterEmpty = await createBuilder<{screen_share_delivery: {config_version: number; enabled: boolean}}>(
const afterEmpty = await createBuilder<{domain_migration: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({screen_share_delivery: {}})
.body({domain_migration: {}})
.execute();
expect(afterEmpty.screen_share_delivery).toMatchObject({config_version: 2, enabled: true});
expect(afterEmpty.domain_migration).toMatchObject({config_version: 2, enabled: true});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(body.assignments.screen_share_delivery).toEqual({enabled: true});
expect(body.assignments.domain_migration).toEqual({enabled: true});
});
it('leaves the config version alone for an admin update that sets no field', async () => {
@@ -3,6 +3,7 @@
import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
import type {GuildID, InviteCode, RoleID, UserID} from '@app/api/BrandedTypes';
import {createChannelID, createRoleID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import {assertMutableUserId} from '@app/api/constants/Core';
import type {GuildMemberRow} from '@app/api/database/types/GuildTypes';
@@ -421,6 +422,13 @@ export class GuildMemberOperationsService {
memberCount: guild.memberCount,
accountAgeMs: Date.now() - snowflakeToDate(BigInt(user.id)).getTime(),
};
if (
!Config.abusePolicy.phoneFlagging.enabled &&
(getEffectiveSuspiciousFlags(user) & PHONE_REQUIREMENT_FLAGS) === 0
) {
Logger.info(logContext, 'deferred_phone_gate.skipped_phone_flagging_disabled');
return;
}
if (status !== 'ok') {
const undeferredFlags = getEffectiveSuspiciousFlags({
...user,
@@ -3,7 +3,7 @@
import {Config} from '@app/api/Config';
import {GatewayRpcMethodError, GatewayRpcMethodErrorCodes} from '@app/api/infrastructure/GatewayRpcError';
import type {IGatewayRpcTransport} from '@app/api/infrastructure/IGatewayRpcTransport';
import type {CallData} from '@app/api/infrastructure/IGatewayService';
import {type CallCaller, type CallData, callCallerRpcParams} from '@app/api/infrastructure/IGatewayService';
import {NatsGatewayRpcTransport} from '@app/api/infrastructure/NatsGatewayRpcTransport';
import {Logger} from '@app/api/Logger';
import {NatsConnectionManager} from '@pkgs/nats/src/NatsConnectionManager';
@@ -128,6 +128,7 @@ export class GatewayRpcClient {
region: string,
ringing: Array<string>,
recipients: Array<string>,
caller?: CallCaller,
): Promise<CallData> {
return this.call<CallData>('call.create', {
channel_id: channelId,
@@ -135,6 +136,7 @@ export class GatewayRpcClient {
region,
ringing,
recipients,
...callCallerRpcParams(caller),
});
}
@@ -142,8 +144,8 @@ export class GatewayRpcClient {
return this.call('call.update_region', {channel_id: channelId, region});
}
async ringCallRecipients(channelId: string, recipients: Array<string>): Promise<boolean> {
return this.call('call.ring', {channel_id: channelId, recipients});
async ringCallRecipients(channelId: string, recipients: Array<string>, caller?: CallCaller): Promise<boolean> {
return this.call('call.ring', {channel_id: channelId, recipients, ...callCallerRpcParams(caller)});
}
async stopRingingCallRecipients(channelId: string, recipients: Array<string>): Promise<boolean> {
@@ -6,16 +6,18 @@ import {SYSTEM_USER_ID} from '@app/api/constants/Core';
import type {GatewayDispatchEvent} from '@app/api/constants/Gateway';
import {GatewayRpcClient} from '@app/api/infrastructure/GatewayRpcClient';
import {GatewayRpcMethodError, GatewayRpcMethodErrorCodes} from '@app/api/infrastructure/GatewayRpcError';
import type {
CallData,
GatewayChannelMention,
GatewayGuildMemoryStats,
GatewayMentionSources,
GatewayMentionSourcesPage,
GatewayNodeStats,
GatewayVoiceStateCounts,
GatewayVoiceStateEntry,
GuildChannelAuthContext,
import {
type CallCaller,
type CallData,
callCallerRpcParams,
type GatewayChannelMention,
type GatewayGuildMemoryStats,
type GatewayMentionSources,
type GatewayMentionSourcesPage,
type GatewayNodeStats,
type GatewayVoiceStateCounts,
type GatewayVoiceStateEntry,
type GuildChannelAuthContext,
} from '@app/api/infrastructure/IGatewayService';
import {Logger} from '@app/api/Logger';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
@@ -35,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> = {
@@ -65,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;
@@ -285,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();
@@ -702,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,
@@ -1695,6 +1640,7 @@ export class GatewayService {
region: string,
ringing: Array<string>,
recipients: Array<string>,
caller?: CallCaller,
): Promise<CallData> {
return this.call<CallData>('call.create', {
channel_id: channelId.toString(),
@@ -1702,6 +1648,7 @@ export class GatewayService {
region,
ringing,
recipients,
...callCallerRpcParams(caller),
});
}
@@ -1709,8 +1656,12 @@ export class GatewayService {
return this.call<boolean>('call.update_region', {channel_id: channelId.toString(), region});
}
async ringCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean> {
return this.call<boolean>('call.ring', {channel_id: channelId.toString(), recipients});
async ringCallRecipients(channelId: ChannelID, recipients: Array<string>, caller?: CallCaller): Promise<boolean> {
return this.call<boolean>('call.ring', {
channel_id: channelId.toString(),
recipients,
...callCallerRpcParams(caller),
});
}
async stopRingingCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean> {
@@ -24,6 +24,19 @@ export interface CallData {
voice_states: Array<VoiceState>;
}
export interface CallCaller {
id: string;
name: string;
avatar: string | null;
}
export function callCallerRpcParams(caller: CallCaller | undefined): Record<string, unknown> {
if (!caller) {
return {};
}
return {caller_id: caller.id, caller_name: caller.name, caller_avatar: caller.avatar};
}
export interface GatewayGuildMemoryStatsEntry {
node_id: string;
guild_id: string | null;
@@ -279,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;
@@ -381,11 +388,12 @@ export abstract class IGatewayService {
region: string,
ringing: Array<string>,
recipients: Array<string>,
caller?: CallCaller,
): Promise<CallData>;
abstract updateCallRegion(channelId: ChannelID, region: string | null): Promise<boolean>;
abstract ringCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean>;
abstract ringCallRecipients(channelId: ChannelID, recipients: Array<string>, caller?: CallCaller): Promise<boolean>;
abstract stopRingingCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean>;
@@ -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';
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createChannelID} from '@app/api/BrandedTypes';
import {GatewayRpcClient} from '@app/api/infrastructure/GatewayRpcClient';
import {GatewayService} from '@app/api/infrastructure/GatewayService';
import type {IGatewayRpcTransport} from '@app/api/infrastructure/IGatewayRpcTransport';
import {afterEach, describe, expect, it} from 'vitest';
const CHANNEL_ID = createChannelID(12n);
interface RecordedCall {
method: string;
params: Record<string, unknown>;
}
function recordingService(recorded: Array<RecordedCall>): GatewayService {
const transport: IGatewayRpcTransport = {
async call(method: string, params: Record<string, unknown>): Promise<unknown> {
recorded.push({method, params});
return null;
},
async destroy(): Promise<void> {},
};
GatewayRpcClient.createForTests(transport);
return new GatewayService();
}
describe('call rpc caller params', () => {
afterEach(async () => {
await GatewayRpcClient.resetForTests();
});
it('sends the caller to call.create as caller_id, caller_name and caller_avatar', async () => {
const recorded: Array<RecordedCall> = [];
const service = recordingService(recorded);
await service.createCall(CHANNEL_ID, '99', 'automatic', ['2'], ['1', '2'], {
id: '1',
name: 'Elias',
avatar: 'a1b2c3d4',
});
expect(recorded).toHaveLength(1);
expect(recorded[0].method).toBe('call.create');
expect(recorded[0].params.caller_id).toBe('1');
expect(recorded[0].params.caller_name).toBe('Elias');
expect(recorded[0].params.caller_avatar).toBe('a1b2c3d4');
});
it('sends the caller to call.ring as caller_id, caller_name and caller_avatar', async () => {
const recorded: Array<RecordedCall> = [];
const service = recordingService(recorded);
await service.ringCallRecipients(CHANNEL_ID, ['2'], {id: '1', name: 'Elias', avatar: 'a1b2c3d4'});
expect(recorded).toHaveLength(1);
expect(recorded[0].method).toBe('call.ring');
expect(recorded[0].params.caller_id).toBe('1');
expect(recorded[0].params.caller_name).toBe('Elias');
expect(recorded[0].params.caller_avatar).toBe('a1b2c3d4');
});
it('sends caller_avatar as null when the caller has no avatar', async () => {
const recorded: Array<RecordedCall> = [];
const service = recordingService(recorded);
await service.ringCallRecipients(CHANNEL_ID, ['2'], {id: '1', name: 'Elias', avatar: null});
expect(recorded[0].params.caller_avatar).toBeNull();
expect(Object.hasOwn(recorded[0].params, 'caller_avatar')).toBe(true);
});
it('omits every caller key from call.create when no caller was resolved', async () => {
const recorded: Array<RecordedCall> = [];
const service = recordingService(recorded);
await service.createCall(CHANNEL_ID, '99', 'automatic', ['2'], ['1', '2']);
expect(recorded[0].params).toEqual({
channel_id: '12',
message_id: '99',
region: 'automatic',
ringing: ['2'],
recipients: ['1', '2'],
});
});
it('omits every caller key from call.ring when no caller was resolved', async () => {
const recorded: Array<RecordedCall> = [];
const service = recordingService(recorded);
await service.ringCallRecipients(CHANNEL_ID, ['2']);
expect(recorded[0].params).toEqual({channel_id: '12', recipients: ['2']});
});
});
@@ -19,9 +19,9 @@ import {startDockerContainer} from '@app/api/test/DockerTestContainer';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {
DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
type ScreenShareDeliveryConfig,
} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
DEFAULT_DOMAIN_MIGRATION_CONFIG,
type DomainMigrationConfig,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
type VoiceNoiseSuppressionConfig,
@@ -39,7 +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 SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_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';
@@ -356,13 +356,13 @@ describe('InstanceConfigRepository', () => {
});
});
it('returns the default screen share delivery config when the key is absent', async () => {
it('returns the default domain migration config when the key is absent', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual(DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG);
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
});
it.each([
@@ -370,56 +370,78 @@ describe('InstanceConfigRepository', () => {
{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 screen share delivery config for $name', async ({stored}) => {
])('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(SCREEN_SHARE_DELIVERY_CONFIG_KEY, stored);
await repository.setConfig(DOMAIN_MIGRATION_CONFIG_KEY, stored);
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual(DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG);
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
});
it('round-trips a stored screen share delivery config', async () => {
it('round-trips a stored domain migration config', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
const config: ScreenShareDeliveryConfig = {
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
const config: DomainMigrationConfig = {
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
config_version: 5,
rollout_basis_points: 2500,
rollout_salt: 'screen-share-delivery-v2',
rollout_salt: 'domain-migration-v2',
included_user_ids: ['1400000000000000001'],
excluded_user_ids: ['1400000000000000002'],
anonymous_rollout_basis_points: 300,
standalone_forwarding: true,
};
await repository.setScreenShareDeliveryConfig(config);
await repository.setDomainMigrationConfig(config);
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual(config);
await expect(repository.getDomainMigrationConfig()).resolves.toEqual(config);
});
it('fills newly added screen share delivery fields from the schema defaults', async () => {
it('fills newly added domain migration fields from the schema defaults', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
await repository.setConfig(
SCREEN_SHARE_DELIVERY_CONFIG_KEY,
DOMAIN_MIGRATION_CONFIG_KEY,
JSON.stringify({enabled: true, config_version: 2, rollout_basis_points: 1000}),
);
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual({
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
await expect(repository.getDomainMigrationConfig()).resolves.toEqual({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
config_version: 2,
rollout_basis_points: 1000,
});
});
it('publishes a refresh so another repository observes the domain migration config', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const reader = createRepository(kvProvider);
const writer = createRepository(kvProvider);
await expect(reader.getDomainMigrationConfig()).resolves.toEqual(DEFAULT_DOMAIN_MIGRATION_CONFIG);
await writer.setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
config_version: 1,
});
await vi.waitFor(async () => {
expect(await reader.getDomainMigrationConfig()).toMatchObject({enabled: true, config_version: 1});
});
});
it('returns the default experiment delivery config when the key is absent', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
@@ -492,26 +514,6 @@ describe('InstanceConfigRepository', () => {
});
});
it('publishes a refresh so another repository observes the screen share delivery config', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const reader = createRepository(kvProvider);
const writer = createRepository(kvProvider);
await expect(reader.getScreenShareDeliveryConfig()).resolves.toEqual(DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG);
await writer.setScreenShareDeliveryConfig({
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
enabled: true,
config_version: 1,
});
await vi.waitFor(async () => {
expect(await reader.getScreenShareDeliveryConfig()).toMatchObject({enabled: true, config_version: 1});
});
});
it('uses the registration URL id as the admin-visible registration code', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
@@ -28,18 +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 ScreenShareDeliveryConfig,
ScreenShareDeliveryConfigSchema,
} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
type LegacyPushServiceDeliveryWire,
type PushRelayConfig,
PushRelayConfigSchema,
toLegacyPushServiceDeliveryWire,
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {
type VoiceNoiseSuppressionConfig,
VoiceNoiseSuppressionConfigSchema,
@@ -66,8 +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 SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_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';
@@ -374,8 +386,10 @@ type StoredConfigSection =
| 'app public'
| 'gateway rollout'
| 'voice noise suppression'
| 'screen share delivery'
| 'push service delivery'
| 'push relay'
| 'domain migration'
| 'altcha captcha'
| 'profile timezone'
| 'experiment delivery'
| 'instance policy'
| 'integrations'
@@ -514,12 +528,37 @@ function parseStoredVoiceNoiseSuppressionConfig(raw: string | null): VoiceNoiseS
return parseStoredConfigOrDefault(VoiceNoiseSuppressionConfigSchema, raw, 'voice noise suppression');
}
function parseStoredScreenShareDeliveryConfig(raw: string | null): ScreenShareDeliveryConfig {
return parseStoredConfigOrDefault(ScreenShareDeliveryConfigSchema, raw, 'screen share 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 parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDeliveryConfig {
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
function toPushRelayConfig(wire: LegacyPushServiceDeliveryWire): PushRelayConfig {
return {
relay_consent_accepted: wire.relay_consent_accepted,
relay_consent_accepted_at: wire.relay_consent_accepted_at,
relay_consent_accepted_by: wire.relay_consent_accepted_by,
};
}
function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationConfig {
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
}
function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig {
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
}
function parseStoredProfileTimezoneConfig(raw: string | null): ProfileTimezoneConfig {
return parseStoredConfigOrDefault(ProfileTimezoneConfigSchema, raw, 'profile timezone');
}
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
@@ -1169,8 +1208,10 @@ export class InstanceConfigRepository {
parseStoredGatewayRolloutConfig(snapshot.get(GATEWAY_ROLLOUT_CONFIG_KEY) ?? null),
);
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
parseStoredScreenShareDeliveryConfig(snapshot.get(SCREEN_SHARE_DELIVERY_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', () =>
@@ -1267,40 +1308,78 @@ export class InstanceConfigRepository {
);
}
async getScreenShareDeliveryConfig(): Promise<ScreenShareDeliveryConfig> {
const raw = await this.getConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY);
return parseStoredScreenShareDeliveryConfig(raw);
async getLegacyPushServiceDeliveryWire(): Promise<LegacyPushServiceDeliveryWire> {
const raw = await this.getConfig(PUSH_RELAY_CONFIG_KEY);
return parseStoredPushRelayConfig(raw);
}
async setScreenShareDeliveryConfig(config: ScreenShareDeliveryConfig): Promise<void> {
await this.updateScreenShareDeliveryConfig(() => config);
async getPushRelayConfig(): Promise<PushRelayConfig> {
return toPushRelayConfig(await this.getLegacyPushServiceDeliveryWire());
}
updateScreenShareDeliveryConfig(
update: (current: ScreenShareDeliveryConfig) => ScreenShareDeliveryConfig,
): Promise<ScreenShareDeliveryConfig> {
return this.updateStoredConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, (raw) =>
updatePushRelayConfig(update: (current: PushRelayConfig) => PushRelayConfig): Promise<LegacyPushServiceDeliveryWire> {
return this.updateStoredConfig(PUSH_RELAY_CONFIG_KEY, (raw) => {
const current = parseStoredPushRelayConfig(raw);
const next = validateStoredConfig(PushRelayConfigSchema, update(toPushRelayConfig(current)), 'push relay');
return toLegacyPushServiceDeliveryWire(next, current.config_version + 1);
});
}
async getDomainMigrationConfig(): Promise<DomainMigrationConfig> {
const raw = await this.getConfig(DOMAIN_MIGRATION_CONFIG_KEY);
return parseStoredDomainMigrationConfig(raw);
}
async setDomainMigrationConfig(config: DomainMigrationConfig): Promise<void> {
await this.updateDomainMigrationConfig(() => config);
}
updateDomainMigrationConfig(
update: (current: DomainMigrationConfig) => DomainMigrationConfig,
): Promise<DomainMigrationConfig> {
return this.updateStoredConfig(DOMAIN_MIGRATION_CONFIG_KEY, (raw) =>
validateStoredConfig(
ScreenShareDeliveryConfigSchema,
update(parseStoredScreenShareDeliveryConfig(raw)),
'screen share delivery',
DomainMigrationConfigSchema,
update(parseStoredDomainMigrationConfig(raw)),
'domain migration',
),
);
}
async getPushServiceDeliveryConfig(): Promise<PushServiceDeliveryConfig> {
const raw = await this.getConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
return parseStoredPushServiceDeliveryConfig(raw);
async getAltchaCaptchaConfig(): Promise<AltchaCaptchaConfig> {
const raw = await this.getConfig(ALTCHA_CAPTCHA_CONFIG_KEY);
return parseStoredAltchaCaptchaConfig(raw);
}
updatePushServiceDeliveryConfig(
update: (current: PushServiceDeliveryConfig) => PushServiceDeliveryConfig,
): Promise<PushServiceDeliveryConfig> {
return this.updateStoredConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY, (raw) =>
async setAltchaCaptchaConfig(config: AltchaCaptchaConfig): Promise<void> {
await this.updateAltchaCaptchaConfig(() => config);
}
updateAltchaCaptchaConfig(
update: (current: AltchaCaptchaConfig) => AltchaCaptchaConfig,
): Promise<AltchaCaptchaConfig> {
return this.updateStoredConfig(ALTCHA_CAPTCHA_CONFIG_KEY, (raw) =>
validateStoredConfig(AltchaCaptchaConfigSchema, update(parseStoredAltchaCaptchaConfig(raw)), 'altcha captcha'),
);
}
async getProfileTimezoneConfig(): Promise<ProfileTimezoneConfig> {
const raw = await this.getConfig(PROFILE_TIMEZONE_CONFIG_KEY);
return parseStoredProfileTimezoneConfig(raw);
}
async setProfileTimezoneConfig(config: ProfileTimezoneConfig): Promise<void> {
await this.updateProfileTimezoneConfig(() => config);
}
updateProfileTimezoneConfig(
update: (current: ProfileTimezoneConfig) => ProfileTimezoneConfig,
): Promise<ProfileTimezoneConfig> {
return this.updateStoredConfig(PROFILE_TIMEZONE_CONFIG_KEY, (raw) =>
validateStoredConfig(
PushServiceDeliveryConfigSchema,
update(parseStoredPushServiceDeliveryConfig(raw)),
'push service delivery',
ProfileTimezoneConfigSchema,
update(parseStoredProfileTimezoneConfig(raw)),
'profile timezone',
),
);
}
@@ -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));
}
}
@@ -15,6 +15,7 @@ interface RequireClientIpOptions {
const defaultExemptPaths: Array<string> = [
'/_health',
'/internal',
'/webhooks/livekit',
'/test',
'/connections/bluesky/client-metadata.json',
@@ -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',
}),
),
);
@@ -5,7 +5,7 @@ import {Logger} from '@app/api/Logger';
import {hashAuthToken, recordAbuseSignal} from '@app/api/middleware/AbusiveIpAutoBanner';
import type {User} from '@app/api/models/User';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {requireRequestClientIp} from '@app/api/utils/RequestClientIp';
import {getRequestClientIp} from '@app/api/utils/RequestClientIp';
import {stripApiPrefix} from '@app/api/utils/RequestPathUtils';
import type {Context} from 'hono';
import {createMiddleware} from 'hono/factory';
@@ -60,7 +60,7 @@ function setUserInContext(ctx: Context<HonoEnv>, user: User, trackActivity: bool
ctx.set('user', user);
if (trackActivity) {
const now = new Date();
const ip = requireRequestClientIp(ctx);
const ip = getRequestClientIp(ctx);
const kvActivityTracker = ctx.get('kvActivityTracker');
const userActivityBuffer = ctx.get('userActivityBuffer');
userActivityBuffer.recordActivity(user.id, now, ip);
@@ -77,7 +77,7 @@ export const UserMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
}
const rawAuthHeader = ctx.req.header('Authorization');
const parsed = parseAuthHeader(rawAuthHeader);
const resolvedClientIp = requireRequestClientIp(ctx);
const resolvedClientIp = getRequestClientIp(ctx);
ctx.set('oauthBearerToken', undefined);
ctx.set('oauthBearerApplicationId', undefined);
ctx.set('oauthBearerAllowed', false);
@@ -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) => {
@@ -22,6 +22,7 @@ function createHarness(path = 'http://localhost/v1/messages'): Harness {
return ctx.text('ok');
});
app.get('/_health', (ctx) => ctx.text('OK'));
app.get('/internal/rpc', (ctx) => ctx.text('OK'));
app.onError(AppErrorHandler);
return {
request: async (headers) => app.request(path, {headers}),
@@ -80,6 +81,12 @@ describe('RequireClientIpMiddleware', () => {
expect(response.status).toBe(200);
});
it('leaves internal service to service calls alone', async () => {
const harness = createHarness('http://localhost/internal/rpc');
const response = await harness.request({});
expect(response.status).toBe(200);
});
it('passes every request through in test mode', async () => {
Config.dev.testModeEnabled = true;
const harness = createHarness();
@@ -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;
+42
View File
@@ -1,6 +1,29 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {PHONE_REQUIREMENT_FLAGS, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
const EMAIL_ONLY_EQUIVALENTS: ReadonlyArray<readonly [number, number]> = [
[SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL],
[SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL],
[
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE,
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL,
],
[
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE,
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL,
],
];
const PHONE_OFFERING_FLAGS = EMAIL_ONLY_EQUIVALENTS.reduce((mask, [either]) => mask | either, PHONE_REQUIREMENT_FLAGS);
function withoutPhoneOfferingFlags(flagBits: number): number {
return EMAIL_ONLY_EQUIVALENTS.reduce(
(next, [either, emailOnly]) => ((flagBits & either) !== 0 ? next | emailOnly : next),
flagBits & ~PHONE_OFFERING_FLAGS,
);
}
function normalizeCountryCode(countryCode: string | null | undefined): string | null {
const trimmed = countryCode?.trim();
@@ -17,6 +40,25 @@ export function countryRequiresInboundPhoneVerification(countryCode: string | nu
return configuredCountrySet(Config.abusePolicy.inboundPhoneCountryCodes).has(normalized);
}
export function phoneFlaggingAllowedForCountry(countryCode: string | null | undefined): boolean {
const {enabled, exemptCountryCodes} = Config.abusePolicy.phoneFlagging;
if (!enabled) return false;
const normalized = normalizeCountryCode(countryCode);
if (!normalized) return true;
return !configuredCountrySet(exemptCountryCodes).has(normalized);
}
export async function stripDisallowedPhoneFlags(
flagBits: number,
resolveCountryCode: () => Promise<string | null>,
): Promise<number> {
if ((flagBits & PHONE_OFFERING_FLAGS) === 0) return flagBits;
const {enabled, exemptCountryCodes} = Config.abusePolicy.phoneFlagging;
if (enabled && exemptCountryCodes.length === 0) return flagBits;
if (enabled && phoneFlaggingAllowedForCountry(await resolveCountryCode())) return flagBits;
return withoutPhoneOfferingFlags(flagBits);
}
export function phoneRequiresInboundVerification(
phone: string,
prefixes: ReadonlyArray<string> = Config.abusePolicy.phoneVerification.inboundRequiredPrefixes,
+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,86 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {phoneFlaggingAllowedForCountry, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const PHONE_AND_EMAIL =
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION;
describe('phone flagging policy', () => {
const original = {...Config.abusePolicy.phoneFlagging};
beforeEach(() => {
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: []};
});
afterEach(() => {
Config.abusePolicy.phoneFlagging = original;
});
it('keeps phone flags by default without resolving the country', async () => {
const resolveCountryCode = vi.fn(async () => 'NG');
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, resolveCountryCode)).toBe(PHONE_AND_EMAIL);
expect(resolveCountryCode).not.toHaveBeenCalled();
expect(phoneFlaggingAllowedForCountry('NG')).toBe(true);
});
it('strips only phone flags when disabled', async () => {
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
const resolveCountryCode = vi.fn(async () => 'NG');
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, resolveCountryCode)).toBe(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
);
expect(resolveCountryCode).not.toHaveBeenCalled();
expect(phoneFlaggingAllowedForCountry('NG')).toBe(false);
expect(phoneFlaggingAllowedForCountry(null)).toBe(false);
});
it('strips phone flags for exempt countries only', async () => {
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: [' br', 'PT']};
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => 'BR')).toBe(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
);
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => 'ng')).toBe(PHONE_AND_EMAIL);
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => null)).toBe(PHONE_AND_EMAIL);
expect(phoneFlaggingAllowedForCountry('pt')).toBe(false);
expect(phoneFlaggingAllowedForCountry('NG')).toBe(true);
});
it('replaces email or phone flags with their email only equivalent', async () => {
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
expect(
await stripDisallowedPhoneFlags(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE,
async () => null,
),
).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
expect(
await stripDisallowedPhoneFlags(
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION,
async () => null,
),
).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL);
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: ['BR']};
expect(
await stripDisallowedPhoneFlags(
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE,
async () => 'BR',
),
).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL);
});
it('skips the country lookup when no phone flags are present', async () => {
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: ['BR']};
const resolveCountryCode = vi.fn(async () => 'BR');
expect(await stripDisallowedPhoneFlags(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL, resolveCountryCode)).toBe(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
);
expect(resolveCountryCode).not.toHaveBeenCalled();
});
});
@@ -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,
};
@@ -10,7 +10,7 @@ import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {Logger} from '@app/api/Logger';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {countryRequiresInboundPhoneVerification} from '@app/api/risk/AbusePolicy';
import {countryRequiresInboundPhoneVerification, phoneFlaggingAllowedForCountry} from '@app/api/risk/AbusePolicy';
import {
createRpcTimingNode,
RpcTimingRecorder,
@@ -311,6 +311,17 @@ export class RpcSessionStartService {
) {
return null;
}
if (
!timeRpcStepSync(timingSteps, 'check_phone_flagging_allowed', () =>
phoneFlaggingAllowedForCountry(geoipCountryIso),
)
) {
Logger.info(
{userId: user.id.toString(), countryIso: geoipCountryIso},
'Skipping configured-country inbound phone requirement: phone flagging disabled for this country',
);
return null;
}
if (
timeRpcStepSync(timingSteps, 'check_not_suspicious_flag', () => (user.flags & UserFlags.NOT_SUSPICIOUS) !== 0n)
) {
@@ -52,7 +52,8 @@ function snowflakeSeconds(snowflake: string): number {
function buildSort(sortBy: string, sortOrder: 'asc' | 'desc' | undefined): Array<string> | undefined {
if (sortBy === 'relevance') return undefined;
return [`${sortBy}:${sortOrder ?? 'desc'}`, 'id:desc'];
const direction = sortOrder ?? 'desc';
return [`${sortBy}:${direction}`, `id:${direction}`];
}
function buildTimestampSort(filters: MessageSearchFilters | AuditLogSearchFilters): Array<string> | undefined {
@@ -117,7 +117,7 @@ describe('MeilisearchMessageAdapter', () => {
'(guildId = "guild-1") AND ((channelId = "channel-\\"quoted\\"" OR channelId = "channel-2")) AND (mentionedUserIds = "user-1")',
limit: 10,
offset: 20,
sort: ['createdAt:asc', 'id:desc'],
sort: ['createdAt:asc', 'id:asc'],
attributesToSearchOn: ['content', 'embedContent'],
showRankingScore: false,
},
@@ -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();
});
});
});
@@ -19,6 +19,7 @@ import {GuildMemberRepository} from '@app/api/guild/repositories/GuildMemberRepo
import {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {GuildRoleRepository} from '@app/api/guild/repositories/GuildRoleRepository';
import {
type CallCaller,
type CallData,
type GatewayChannelMention,
type GatewayGuildMemoryStats,
@@ -794,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;
@@ -925,6 +920,7 @@ export class NoopGatewayService extends IGatewayService {
_region: string,
_ringing: Array<string>,
_recipients: Array<string>,
_caller?: CallCaller,
): Promise<CallData> {
return {
channel_id: _channelId.toString(),
@@ -940,7 +936,7 @@ export class NoopGatewayService extends IGatewayService {
return true;
}
async ringCallRecipients(_channelId: ChannelID, _recipients: Array<string>): Promise<boolean> {
async ringCallRecipients(_channelId: ChannelID, _recipients: Array<string>, _caller?: CallCaller): Promise<boolean> {
return true;
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import type {User} from '@app/api/models/User';
import {setInjectedAccountPolicyEvaluator} from '@app/api/risk/AccountPolicyService';
import {setCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache';
@@ -52,6 +53,20 @@ describe('deferred phone gate marker', () => {
});
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']);
});
it('keeps a deferral suppressed when the gate reads off but phone flagging is disabled', () => {
setCachedDeferredPhoneGateEnabled(false);
const original = {...Config.abusePolicy.phoneFlagging};
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
try {
const user = createUser({
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
});
expect(getRequiredActions(user)).toEqual([]);
expect(getEffectiveSuspiciousFlags(user)).toBe(0);
} finally {
Config.abusePolicy.phoneFlagging = original;
}
});
it('suppresses a deferred phone requirement so the account is not locked out', () => {
const user = createUser({
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
+12 -3
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';
@@ -134,7 +137,7 @@ function suppressDeferredPhoneFlags(rawFlags: number): number {
if ((rawFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0) {
return rawFlags;
}
if (getCachedDeferredPhoneGateEnabled() === false) {
if (getCachedDeferredPhoneGateEnabled() === false && Config.abusePolicy.phoneFlagging.enabled) {
return rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN;
}
return rawFlags & ~DEFERRABLE_PHONE_FLAGS;
@@ -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});
},

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