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
HampusandGitHub 18c303abf6 feat(push): relay notifications as encrypted web push (#2906) 2026-09-23 14:04:55 +02:00
JiraliteandGitHub 7021a58090 fix: allow copying message snapshots (#2905) 2026-09-23 14:01:10 +02:00
WagnerandGitHub 320725a587 fix(desktop): capture full pipewire quantum on linux (#2481) 2026-09-22 21:37:20 +02:00
fluxer-weblate[bot]andGitHub 8450edc072 chore(i18n): update translations from Weblate (#2895) 2026-09-22 21:28:24 +02:00
omsterandGitHub a1e2bf2c8d feat(dev/linux): select the wayland backend when reachable in the native desktop app (#2899)
Signed-off-by: omstr <[email protected]>
2026-09-22 21:27:59 +02:00
HampusandGitHub 82b2f4ec5e fix(app): put jxl and other image attachments in the mosaic (#2902) 2026-09-22 21:18:39 +02:00
HampusandGitHub c92e5d03a7 fix(api): accept any image or video attachment as embed media (#2901) 2026-09-22 21:18:35 +02:00
662 changed files with 77204 additions and 39524 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/
+3
View File
@@ -28,6 +28,9 @@ f:media_proxy:
f:messages:
- changed-files:
- any-glob-to-any-file: fluxer_messages/**/*
f:push:
- changed-files:
- any-glob-to-any-file: fluxer_push/**/*
f:snowflakes:
- changed-files:
- any-glob-to-any-file: fluxer_snowflakes/**/*
+36
View File
@@ -0,0 +1,36 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: build push
on:
workflow_dispatch:
inputs:
build-version:
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
type: string
required: false
default: ""
permissions:
actions: read
contents: write
packages: write
jobs:
approve:
name: approve build release
permissions: {}
runs-on: ubuntu-24.04
environment: builds
timeout-minutes: 5
steps:
- name: approved
run: echo "Build release approved."
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-push
dockerfile: fluxer_push/Dockerfile
build-version: ${{ inputs['build-version'] }}
+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
+37
View File
@@ -1607,6 +1607,7 @@ dependencies = [
"ff",
"generic-array",
"group",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
@@ -1881,6 +1882,32 @@ dependencies = [
"url",
]
[[package]]
name = "fluxer-push"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"base64 0.23.1",
"clap",
"fluxer-svc",
"futures",
"hmac 0.13.0",
"p256",
"percent-encoding",
"rand 0.10.2",
"reqwest",
"ring",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror",
"tokio",
"tracing",
"tracing-subscriber",
"url",
]
[[package]]
name = "fluxer-snowflakes"
version = "0.1.0"
@@ -2308,6 +2335,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac 0.12.1",
]
[[package]]
name = "hmac"
version = "0.12.1"
@@ -3893,6 +3929,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
+1
View File
@@ -7,6 +7,7 @@ members = [
"fluxer_gifs",
"fluxer_svc",
"fluxer_messages",
"fluxer_push",
"fluxer_snowflakes",
"tools/ci",
"tools/dev",
+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
+7
View File
@@ -147,6 +147,12 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
# Notification jobs the push container holds at once, 1 to 1000000.
#FLUXER_PUSH_SERVICE_QUEUE_CAPACITY=10000
# Provider requests the push container sends at once, 1 to 65536.
#FLUXER_PUSH_SERVICE_SEND_CONCURRENCY=256
# Optional media policies, both off by default. See the operator docs.
#
# CORS limits which web origins may read media. A request with no Origin is
@@ -245,6 +251,7 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
#FLUXER_GATEWAY_MEMORY_RESERVATION=384mb
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
#FLUXER_PUSH_MEMORY_LIMIT=256mb
#FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb
#FLUXER_APP_PROXY_MEMORY_LIMIT=256mb
#FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb
+24
View File
@@ -482,6 +482,30 @@ services:
seaweedfs-init: {condition: service_completed_successfully}
nats: {condition: service_healthy}
push:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-push:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_PUSH_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_PUSH_SERVICE_HOST: 0.0.0.0
FLUXER_PUSH_SERVICE_PORT: "8126"
FLUXER_PUSH_SERVICE_QUEUE_CAPACITY: "${FLUXER_PUSH_SERVICE_QUEUE_CAPACITY:-}"
FLUXER_PUSH_SERVICE_SEND_CONCURRENCY: "${FLUXER_PUSH_SERVICE_SEND_CONCURRENCY:-}"
healthcheck:
test: ["CMD", "/usr/local/bin/fluxer-push", "healthcheck"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
depends_on:
nats: {condition: service_healthy}
api: {condition: service_healthy}
static-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1}
+232 -10
View File
@@ -10524,7 +10524,10 @@
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"screen_share_delivery": {"$ref": "#/components/schemas/ScreenShareDeliveryConfigResponse"},
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
"type": "object",
@@ -10952,7 +10955,10 @@
"sso",
"gateway_rollout",
"voice_noise_suppression",
"screen_share_delivery",
"push_relay",
"domain_migration",
"altcha_captcha",
"profile_timezone",
"experiment_delivery",
"registration",
"self_hosted",
@@ -11087,9 +11093,18 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
},
"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"}]
},
"altcha_captcha": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
},
"profile_timezone": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ProfileTimezoneConfigUpdateRequest"}]
},
"experiment_delivery": {
"nullable": true,
@@ -15184,17 +15199,23 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"ScreenShareDeliveryConfigUpdateRequest": {
"ProfileTimezoneConfigUpdateRequest": {
"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",
@@ -15202,6 +15223,60 @@
}
}
},
"AltchaCaptchaConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_enabled": {"type": "boolean"},
"cost": {"type": "integer", "minimum": 1000, "maximum": 100000},
"max_counter": {"type": "integer", "minimum": 100, "maximum": 1000000}
}
},
"DomainMigrationConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"standalone_forwarding": {"type": "boolean"}
}
},
"PushRelayConfigUpdateRequest": {"type": "object", "properties": {"relay_consent_accepted": {"type": "boolean"}}},
"VoiceNoiseSuppressionConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15220,6 +15295,12 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
@@ -15267,19 +15348,32 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"additionalProperties": false
},
"ScreenShareDeliveryConfigResponse": {
"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": "screen-share-delivery-v1", "type": "string", "minLength": 1, "maxLength": 64},
"rollout_salt": {
"default": "profile-timezone-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,
@@ -15293,10 +15387,128 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids"
],
"additionalProperties": false
},
"AltchaCaptchaConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "altcha-captcha-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_enabled": {"default": false, "type": "boolean"},
"cost": {"default": 5000, "type": "integer", "minimum": 1000, "maximum": 100000},
"max_counter": {"default": 10000, "type": "integer", "minimum": 100, "maximum": 1000000}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"anonymous_enabled",
"cost",
"max_counter"
],
"additionalProperties": false
},
"DomainMigrationConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "domain-migration-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"standalone_forwarding": {"default": false, "type": "boolean"}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"anonymous_rollout_basis_points",
"standalone_forwarding"
],
"additionalProperties": false
},
"PushRelayConfigResponse": {
"type": "object",
"properties": {
"relay_consent_accepted": {"default": false, "type": "boolean"},
"relay_consent_accepted_at": {
"default": null,
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"relay_consent_accepted_by": {"default": null, "nullable": true, "type": "string", "pattern": "^\\d{1,20}$"}
},
"required": ["relay_consent_accepted", "relay_consent_accepted_at", "relay_consent_accepted_by"],
"additionalProperties": false
},
"VoiceNoiseSuppressionConfigResponse": {
"type": "object",
"properties": {
@@ -15321,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,
@@ -15352,6 +15571,8 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"guild_overrides",
"suppression_strength"
@@ -15639,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": {
+225 -41
View File
@@ -23,7 +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 domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub altcha_captcha: AltchaCaptchaConfigResponse,
#[serde(default)]
pub profile_timezone: ProfileTimezoneConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
}
@@ -449,7 +455,11 @@ impl VoiceE2eeScope {
}
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const 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 {
@@ -493,6 +503,8 @@ pub struct VoiceNoiseSuppressionConfigResponse {
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub suppression_strength: u32,
@@ -509,6 +521,8 @@ impl Default for VoiceNoiseSuppressionConfigResponse {
rollout_basis_points: 0,
rollout_salt: "voice-ns-v1".to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
suppression_strength: 80,
@@ -533,6 +547,10 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
@@ -540,32 +558,54 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
pub suppression_strength: Option<u32>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct PushRelayConfigResponse {
pub relay_consent_accepted: bool,
pub relay_consent_accepted_at: Option<String>,
pub relay_consent_accepted_by: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PushRelayConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub relay_consent_accepted: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct 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")]
@@ -575,6 +615,118 @@ 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 AltchaCaptchaConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub anonymous_enabled: bool,
pub cost: u32,
pub max_counter: u32,
}
impl Default for AltchaCaptchaConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_enabled: false,
cost: 5_000,
max_counter: 10_000,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct AltchaCaptchaConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_counter: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ProfileTimezoneConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
}
impl Default for ProfileTimezoneConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ProfileTimezoneConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
@@ -694,7 +846,13 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screen_share_delivery: Option<ScreenShareDeliveryConfigUpdateRequest>,
pub push_relay: Option<PushRelayConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_timezone: Option<ProfileTimezoneConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
}
@@ -1033,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!(
@@ -1053,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)
@@ -1064,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") {
@@ -1076,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 {
@@ -1123,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!({})
);
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ async fn reports_list(
return reports_error_page(
config,
&auth.0,
"That page is out of range. The reports search returns at most the first 10000 reports, so narrow the filters and start again.",
"That page is out of range. The reports search returns at most the first 10000 reports. Narrow the filters and start again.",
);
}
let search_query = query.q.as_deref().and_then(clean_string);
+369 -50
View File
@@ -4,10 +4,12 @@ use crate::{
api::{
client::AdminApiClient,
types::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
AltchaCaptchaConfigUpdateRequest, AppBrandingConfigUpdateRequest,
AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest,
AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest,
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
@@ -18,8 +20,8 @@ use crate::{
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
PremiumMode, RegistrationMode, ScreenShareDeliveryConfigUpdateRequest,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
PremiumMode, ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest,
RegistrationMode, SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
},
},
@@ -207,7 +209,19 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_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_altcha_captcha" => match build_altcha_captcha_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_profile_timezone" => match build_profile_timezone_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
@@ -496,6 +510,21 @@ fn parse_experiment_rollout_salt(
Ok(Some(salt.to_owned()))
}
fn parse_ascii_experiment_rollout_salt(
form: &MultiValueForm,
key: &str,
) -> Result<Option<String>, String> {
let salt = parse_experiment_rollout_salt(form, key)?;
if let Some(value) = salt.as_deref()
&& !value
.bytes()
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
{
return Err("Rollout salt must use printable ASCII".to_owned());
}
Ok(salt)
}
fn is_experiment_snowflake(value: &str) -> bool {
!value.is_empty()
&& value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH
@@ -614,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",
@@ -633,30 +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_altcha_captcha_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
altcha_captcha: Some(AltchaCaptchaConfigUpdateRequest {
enabled: Some(form.bool_value("altcha_captcha_enabled")),
rollout_basis_points: parse_form_number(
form,
"altcha_captcha_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("altcha_captcha_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
anonymous_enabled: Some(form.bool_value("altcha_captcha_anonymous_enabled")),
cost: parse_form_number(
form,
"altcha_captcha_cost",
"Cost",
*ALTCHA_CAPTCHA_COST_RANGE.start(),
*ALTCHA_CAPTCHA_COST_RANGE.end(),
)?,
max_counter: parse_form_number(
form,
"altcha_captcha_max_counter",
"Maximum counter",
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start(),
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end(),
)?,
}),
..Default::default()
})
}
fn build_profile_timezone_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
profile_timezone: Some(ProfileTimezoneConfigUpdateRequest {
enabled: Some(form.bool_value("profile_timezone_enabled")),
rollout_basis_points: parse_form_number(
form,
"profile_timezone_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"profile_timezone_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("profile_timezone_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
@@ -1309,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": [],
}})
@@ -1587,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![
@@ -1615,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,13 +2,15 @@
use crate::{
api::types::{
AppPublicConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, NoiseSuppressionBackend, PendingRegistrationResponse,
RegistrationUrlResponse, SCREEN_SHARE_DELIVERY_DEFAULT_SALT,
ScreenShareDeliveryConfigResponse, SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES,
VoiceNoiseSuppressionConfigResponse,
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_DEFAULT_SALT, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
AltchaCaptchaConfigResponse, AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT,
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
InstanceRegistrationResponse, LimitConfigResponse, NoiseSuppressionBackend,
PROFILE_TIMEZONE_DEFAULT_SALT, PendingRegistrationResponse, ProfileTimezoneConfigResponse,
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -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,7 +158,9 @@ pub fn instance_config_page(
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
(screen_share_delivery_section(base, csrf_token, &instance_config.screen_share_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))
@@ -1112,10 +1123,42 @@ fn voice_noise_suppression_section(
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 \
digits. Invalid entries prevent the save. Blank entries and duplicate \
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",
@@ -1131,7 +1174,7 @@ fn voice_noise_suppression_section(
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage, so this is the per-user kill switch."
percentage. This is the per-user kill switch."
}
}
@@ -1178,72 +1221,163 @@ 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 {
let status = if push_relay.relay_consent_accepted {
("Accepted", BadgeVariant::Success)
} else {
("Not accepted", BadgeVariant::Default)
};
let accepted_at =
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
let accepted_by = push_relay
.relay_consent_accepted_by
.as_deref()
.unwrap_or("Nobody");
section_card_with_description(
"Push Relay",
"Official mobile app notifications travel through Fluxer's relay to Apple and Google. \
The relay delivers them only after an operator accepts its privacy notice.",
html! {
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Relay consent" }
(badge(status.0, status.1))
}
(checkbox(
"push_relay_consent_accepted",
"true",
"Accept the push relay supplemental privacy notice",
push_relay.relay_consent_accepted,
true,
))
p class="text-xs text-neutral-500" {
"Until this is accepted official mobile app notifications are dropped. \
Self-hosted UnifiedPush and ntfy endpoints never reach the relay and are \
unaffected. "
a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer"
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
"Read the notice"
}
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(form_field_group("Accepted at", "push_relay_consent_accepted_at", false, None, None,
html! {
input type="text" id="push_relay_consent_accepted_at"
value=(accepted_at)
disabled class=(FORM_INPUT_CLASS);
},
))
(form_field_group("Accepted by user ID", "push_relay_consent_accepted_by", false, None, None,
html! {
input type="text" id="push_relay_consent_accepted_by"
value=(accepted_by)
disabled class=(FORM_INPUT_CLASS);
},
))
}
(form_actions(html! {
(submit_button("Save Push Relay Settings"))
}))
}
}
},
)
}
fn domain_migration_section(
base: &str,
csrf_token: &str,
domain_migration: &DomainMigrationConfigResponse,
) -> Markup {
let status = if domain_migration.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = screen_share_delivery.included_user_ids.join("\n");
let excluded_user_ids = screen_share_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(
"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.",
"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_screen_share_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 " (screen_share_delivery.config_version)
"Config version " (domain_migration.config_version)
}
}
(checkbox(
"screen_share_delivery_enabled",
"domain_migration_enabled",
"true",
"Serve screen share delivery assignments to clients",
screen_share_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 client is told the \
feature is inert and keeps its current behavior, 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(
"screen_share_delivery_rollout_basis_points",
"domain_migration_rollout_basis_points",
"Rollout (basis points)",
&screen_share_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(
"screen_share_delivery_rollout_salt",
"domain_migration_rollout_salt",
"Rollout Salt",
&screen_share_delivery.rollout_salt,
SCREEN_SHARE_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(
"screen_share_delivery_included_user_ids",
"domain_migration_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
@@ -1251,19 +1385,51 @@ fn screen_share_delivery_section(
false,
))
(entry_count_hint(
screen_share_delivery.included_user_ids.len(),
domain_migration.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save; blank entries and duplicate \
digits. Invalid entries prevent the save. Blank entries and duplicate \
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(
"screen_share_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,
@@ -1271,17 +1437,326 @@ fn screen_share_delivery_section(
false,
))
(entry_count_hint(
screen_share_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, so 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 Screen Share 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"))
}))
}
}
@@ -1933,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)
+107 -14
View File
@@ -403,20 +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
},
"altcha_captcha": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 500,
"rollout_salt": "altcha-captcha-v1",
"included_user_ids": [],
"excluded_user_ids": ["1500000000000000003"],
"anonymous_enabled": true,
"cost": 5000,
"max_counter": 10000,
"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": {
@@ -547,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));
@@ -565,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(
@@ -587,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#"{
@@ -861,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",
+9 -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",
][..],
),
@@ -817,6 +817,9 @@ async fn spawn_mock_api() -> String {
async fn mock_api(method: Method, uri: Uri) -> Response {
let path = uri.path().to_owned();
if method == Method::PATCH && path == "/admin/instance/config" {
return json_response(instance_config());
}
match (method, path.as_str()) {
(Method::GET, "/admin/users/@me") => json_response(json!({ "user": admin_user() })),
(Method::GET, "/admin/api-keys") => json_response(json!([])),
@@ -1197,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,
},
@@ -13,7 +13,11 @@ import {deriveSsoRedirectUri, normalizeAndValidateSsoConfig} from '@app/api/inst
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {getGatewayRolloutConfigPublisher, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {
getGatewayRolloutConfigPublisher,
getInstanceConfigRepository,
getPushRelayConfigPublisher,
} from '@app/api/middleware/ServiceSingletons';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
@@ -30,8 +34,11 @@ import {
PendingRegistrationActionRequest,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {AltchaCaptchaConfigSchema} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {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';
@@ -60,7 +67,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
screenShareDelivery,
pushRelay,
domainMigration,
altchaCaptcha,
profileTimezone,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -69,7 +79,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getScreenShareDeliveryConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -101,7 +114,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
screen_share_delivery: screenShareDelivery,
push_relay: pushRelay,
domain_migration: domainMigration,
altcha_captcha: altchaCaptcha,
profile_timezone: profileTimezone,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -186,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)
@@ -247,43 +277,77 @@ export function InstanceConfigAdminController(app: HonoApp) {
const shouldGrantSetupCompleterAdmin =
appPublicBeforeUpdate !== null && completesInitialSetup(data, appPublicBeforeUpdate.setup.configured);
if (data.gateway_rollout) {
const currentRollout = await instanceConfigRepository.getGatewayRolloutConfig();
const merged = {...currentRollout, ...data.gateway_rollout};
const validated = GatewayRolloutConfigSchema.parse(merged);
await instanceConfigRepository.setGatewayRolloutConfig(validated);
await getGatewayRolloutConfigPublisher().publish(validated);
const patch = data.gateway_rollout;
const landed = await instanceConfigRepository.updateGatewayRolloutConfig((current) =>
GatewayRolloutConfigSchema.parse({...current, ...patch}),
);
await getGatewayRolloutConfigPublisher().publish(landed);
}
if (data.voice_noise_suppression) {
const patch = omitUndefinedFields(data.voice_noise_suppression);
if (Object.keys(patch).length > 0) {
const currentNoiseSuppression = await instanceConfigRepository.getVoiceNoiseSuppressionConfig();
const validated = VoiceNoiseSuppressionConfigSchema.parse({
...currentNoiseSuppression,
...patch,
config_version: currentNoiseSuppression.config_version + 1,
});
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
await instanceConfigRepository.updateVoiceNoiseSuppressionConfig((current) =>
VoiceNoiseSuppressionConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
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) {
const currentScreenShareDelivery = await instanceConfigRepository.getScreenShareDeliveryConfig();
const validated = ScreenShareDeliveryConfigSchema.parse({
...currentScreenShareDelivery,
const adminUserId = ctx.get('adminUserId').toString();
const landed = await instanceConfigRepository.updatePushRelayConfig((current) => ({
...current,
...patch,
config_version: currentScreenShareDelivery.config_version + 1,
});
await instanceConfigRepository.setScreenShareDeliveryConfig(validated);
...relayConsentStamp(current, patch, adminUserId),
}));
await getPushRelayConfigPublisher().publish(landed);
}
}
if (data.domain_migration) {
const patch = omitUndefinedFields(data.domain_migration);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateDomainMigrationConfig((current) =>
DomainMigrationConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.altcha_captcha) {
const patch = omitUndefinedFields(data.altcha_captcha);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateAltchaCaptchaConfig((current) =>
AltchaCaptchaConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.profile_timezone) {
const patch = omitUndefinedFields(data.profile_timezone);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateProfileTimezoneConfig((current) =>
ProfileTimezoneConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.experiment_delivery) {
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
const validated = ExperimentDeliveryConfigSchema.parse({
...currentExperimentDelivery,
...data.experiment_delivery,
});
await instanceConfigRepository.setExperimentDeliveryConfig(validated);
const patch = data.experiment_delivery;
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
ExperimentDeliveryConfigSchema.parse({...current, ...patch}),
);
}
if (data.sso) {
const sso = data.sso;
@@ -308,21 +372,22 @@ export function InstanceConfigAdminController(app: HonoApp) {
const validated = await normalizeAndValidateSsoConfig(next, {
testModeEnabled: Config.dev.testModeEnabled,
});
const supplied = <T>(field: keyof typeof sso, value: T): T | undefined =>
readOptionalField(sso, field) === undefined ? undefined : value;
await instanceConfigRepository.setSsoConfig({
enabled: validated.enabled,
enforced: validated.enforced,
displayName: next.displayName,
issuer: validated.issuer,
authorizationUrl: validated.authorizationUrl,
tokenUrl: validated.tokenUrl,
userInfoUrl: validated.userInfoUrl,
jwksUrl: validated.jwksUrl,
clientId: validated.clientId,
enabled: supplied('enabled', validated.enabled),
enforced: supplied('enforced', validated.enforced),
displayName: supplied('display_name', next.displayName),
issuer: supplied('issuer', validated.issuer),
authorizationUrl: supplied('authorization_url', validated.authorizationUrl),
tokenUrl: supplied('token_url', validated.tokenUrl),
userInfoUrl: supplied('userinfo_url', validated.userInfoUrl),
jwksUrl: supplied('jwks_url', validated.jwksUrl),
clientId: supplied('client_id', validated.clientId),
clientSecret: readOptionalField(sso, 'client_secret'),
scope: next.scope,
allowedEmailDomains: validated.allowedEmailDomains,
autoProvision: next.autoProvision,
redirectUri: null,
scope: supplied('scope', next.scope),
allowedEmailDomains: supplied('allowed_domains', validated.allowedEmailDomains),
autoProvision: supplied('auto_provision', next.autoProvision),
});
}
if (data.registration) {
@@ -625,7 +690,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
async (ctx) => {
const userId = ctx.req.valid('param').user_id.toString();
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
await instanceConfigRepository.getPendingRegistrations();
await updatePendingRegistrationUser(ctx, userId, decision);
await instanceConfigRepository.removePendingRegistration(userId);
return ctx.json(await buildInstanceConfigResponse());
@@ -638,27 +702,47 @@ async function applyInstancePolicyUpdate(
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
): Promise<void> {
const instanceConfigRepository = getInstanceConfigRepository();
const [current, appPublic] = await Promise.all([
instanceConfigRepository.getInstancePolicyConfig(),
instanceConfigRepository.getAppPublicConfig(),
]);
const appPublic = await instanceConfigRepository.getAppPublicConfig();
const adminUser =
policy.single_community_enabled === true
? await ctx.get('userRepository').findUnique(ctx.get('adminUserId'))
: null;
let enablesSingleCommunity = false;
await instanceConfigRepository.updateInstancePolicyConfig((current) => {
const planned = planInstancePolicyPatch(policy, current, {
setupConfigured: appPublic.setup.configured,
adminUserFound: adminUser !== null,
});
enablesSingleCommunity = planned.enablesSingleCommunity;
return planned.patch;
});
if (enablesSingleCommunity && adminUser) {
await ctx.get('singleCommunityService').ensureStockCommunity({
owner: adminUser,
name: policy.single_community_name?.trim() || appPublic.branding.product_name,
});
}
if (policy.premium_mode !== undefined) {
await ctx.get('limitConfigService').updatePolicyConfig({premium_mode: policy.premium_mode});
}
}
function planInstancePolicyPatch(
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
current: InstancePolicyConfig,
context: {setupConfigured: boolean; adminUserFound: boolean},
): {patch: Partial<InstancePolicyConfig>; enablesSingleCommunity: boolean} {
const patch: Partial<InstancePolicyConfig> = {};
let enablesSingleCommunity = false;
if (
policy.single_community_enabled !== undefined &&
policy.single_community_enabled !== current.single_community_enabled
) {
if (policy.single_community_enabled) {
if (appPublic.setup.configured && current.single_community_guild_id == null) {
if ((context.setupConfigured && current.single_community_guild_id == null) || !context.adminUserFound) {
throw new InstancePolicyTransitionNotAllowedError();
}
const adminUser = await ctx.get('userRepository').findUnique(ctx.get('adminUserId'));
if (!adminUser) {
throw new InstancePolicyTransitionNotAllowedError();
}
await ctx.get('singleCommunityService').ensureStockCommunity({
owner: adminUser,
name: policy.single_community_name?.trim() || appPublic.branding.product_name,
});
enablesSingleCommunity = true;
} else {
patch.single_community_enabled = false;
}
@@ -679,9 +763,6 @@ async function applyInstancePolicyUpdate(
patch.direct_messages_locked = true;
}
}
if (policy.premium_mode !== undefined) {
patch.premium_mode = policy.premium_mode;
}
if (policy.services) {
if (policy.services.gif_enabled !== undefined) {
patch.gif_enabled = policy.services.gif_enabled ?? null;
@@ -704,11 +785,7 @@ async function applyInstancePolicyUpdate(
patch.deferred_phone_gate_member_threshold = policy.deferred_phone_gate.member_threshold;
}
}
if (patch.premium_mode !== undefined) {
await ctx.get('limitConfigService').updatePolicyConfig(patch);
} else if (Object.keys(patch).length > 0) {
await instanceConfigRepository.setInstancePolicyConfig(patch);
}
return {patch, enablesSingleCommunity};
}
async function updatePendingRegistrationUser(
@@ -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(
@@ -0,0 +1,116 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
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 {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';
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 {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
type LegacyPushServiceDeliveryWire,
toLegacyPushServiceDeliveryWire,
} 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';
describe('instance config admin PATCH under concurrent writes', () => {
let harness: ApiTestHarness;
let executor: InstanceConfigWriteRaceExecutor;
beforeAll(async () => {
harness = await createApiTestHarness();
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
setCassandraQueryExecutorForTesting(executor);
});
beforeEach(async () => {
await harness.reset();
});
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 spyOnPushRelayPublishes = () =>
vi.spyOn(PushRelayConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
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>> {
const logs = await getAdminRepository().listAllAuditLogsPaginated(100000);
return logs.filter((log) => log.action === 'update_instance_config');
}
it('merges a standalone forwarding patch into the stored domain migration config', async () => {
const admin = await createAdmin();
await patchConfig(admin, {domain_migration: {enabled: true, rollout_basis_points: 250}}).execute();
const updated = await patchConfig(admin, {domain_migration: {standalone_forwarding: true}}).execute();
expect(updated.domain_migration).toMatchObject({
enabled: true,
rollout_basis_points: 250,
standalone_forwarding: true,
config_version: 2,
});
});
it('answers with a conflict and neither writes, publishes nor audits once every attempt has lost the race', async () => {
const publish = spyOnPushRelayPublishes();
const admin = await createAdmin();
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
publish.mockClear();
const auditsBefore = await listConfigUpdateAudits();
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_RELAY_CONFIG_KEY,
JSON.stringify(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites)),
);
});
await patchConfig(admin, {push_relay: {relay_consent_accepted: true}})
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.CONFLICT)
.execute();
expect(executor.events).not.toContain('write');
expect(await readStoredPushRelay()).toEqual(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites));
expect(publish).not.toHaveBeenCalled();
expect(await listConfigUpdateAudits()).toHaveLength(auditsBefore.length);
});
});
@@ -0,0 +1,94 @@
// 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 {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
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 {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
describe('instance config admin PATCH against state another node changed', () => {
let harness: ApiTestHarness;
let executor: InstanceConfigWriteRaceExecutor;
beforeAll(async () => {
harness = await createApiTestHarness();
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
setCassandraQueryExecutorForTesting(executor);
});
beforeEach(async () => {
await harness.reset();
});
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);
it('keeps an SSO field another node changed when a patch changes a different one', async () => {
const admin = await createAdmin();
await patchConfig(admin, {sso: {display_name: 'Before', client_id: 'client-before'}}).execute();
await executor.writeDirectly('sso_display_name', 'Changed on another node');
await patchConfig(admin, {sso: {client_id: 'client-after'}}).execute();
expect(await executor.readDirectly('sso_display_name')).toBe('Changed on another node');
expect(await executor.readDirectly('sso_client_id')).toBe('client-after');
});
it('refuses to disable direct messages when their lock lands between the read and the write', async () => {
const admin = await createAdmin();
await patchConfig(admin, {policy: {services: {gif_enabled: true}}}).execute();
executor.watch(INSTANCE_POLICY_CONFIG_KEY);
let competed = false;
executor.competeBeforeEachWrite(async () => {
if (competed) return;
competed = true;
await executor.writeDirectly(
INSTANCE_POLICY_CONFIG_KEY,
JSON.stringify({direct_messages_disabled: false, direct_messages_locked: true, gif_enabled: true}),
);
});
await patchConfig(admin, {policy: {direct_messages_disabled: true}})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INSTANCE_POLICY_TRANSITION_NOT_ALLOWED)
.execute();
const stored = JSON.parse((await executor.readDirectly(INSTANCE_POLICY_CONFIG_KEY)) ?? 'null');
expect(stored).toMatchObject({direct_messages_disabled: false, direct_messages_locked: true, gif_enabled: true});
});
it('applies the DM rule and a premium mode change from one request', async () => {
const admin = await createAdmin();
await patchConfig(admin, {policy: {direct_messages_disabled: true}}).execute();
const updated = await patchConfig(admin, {
policy: {direct_messages_disabled: false, premium_mode: 'mirror'},
}).execute();
expect(updated.policy).toMatchObject({
direct_messages_disabled: false,
direct_messages_locked: true,
premium_mode: 'mirror',
});
});
});
@@ -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();
+72 -20
View File
@@ -7,12 +7,14 @@ import * as AuthUtility from '@app/api/auth/AuthUtility';
import type {IRegistrationRiskEvaluator} from '@app/api/auth/services/IRegistrationRiskEvaluator';
import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {APIConfig} from '@app/api/config/APIConfig';
import type {UserRow} from '@app/api/database/types/UserTypes';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
import {
type InstanceConfigRepository,
type InstanceRegistrationUrl,
REGISTRATION_PENDING_APPROVAL_TRAIT,
type RegistrationUrlClaim,
} from '@app/api/instance/InstanceConfigRepository';
import type {SingleCommunityService} from '@app/api/instance/SingleCommunityService';
import type {InviteService} from '@app/api/invite/InviteService';
@@ -21,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,
@@ -135,9 +137,6 @@ export async function register(
}
const now = new Date();
const registrationAccess = await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.getPendingRegistrations();
}
const clientIp = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
@@ -228,7 +227,7 @@ export async function register(
const userLocale = parseAcceptLanguage(acceptLanguage);
const passwordHash = data.password ? await AuthPassword.hashPassword(ctx, data.password) : null;
const flags = config.nodeEnv === 'development' ? UserFlags.STAFF : 0n;
let user = await users.create({
const userRow: UserRow = {
user_id: userId,
username,
discriminator,
@@ -287,7 +286,39 @@ export async function register(
mention_flags: null,
last_voice_activity_sharing_change_at: null,
version: 1,
});
};
const registrationUrlUse = await claimRegistrationUrlUse(
instanceConfigRepository,
registrationAccess.registrationUrl,
userId,
);
let user: User;
let createAttempted = false;
try {
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.addPendingRegistration({
user_id: userId.toString(),
username: userRow.username,
discriminator: userRow.discriminator,
global_name: userRow.global_name,
email: rawEmail,
requested_at: now.toISOString(),
registration_url_id: registrationAccess.registrationUrl?.id ?? null,
client_ip: clientIp,
});
}
createAttempted = true;
user = await users.create(userRow);
} catch (error) {
if (!createAttempted) {
await withdrawSignupOfUncreatedAccount(instanceConfigRepository, {
userId,
registrationUrlUse,
pendingApproval: registrationAccess.pendingApproval,
});
}
throw error;
}
await users.upsertSettings(
UserSettings.getDefaultUserSettings({
userId,
@@ -331,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(),
@@ -401,20 +434,7 @@ export async function register(
}
if (rawEmail && emailEnabled) await maybeSendVerificationEmail(ctx, {user, email: rawEmail});
await users.createAuthorizedIp(userId, clientIp);
if (registrationAccess.registrationUrl) {
await instanceConfigRepository.recordRegistrationUrlUse(registrationAccess.registrationUrl.id, user.id.toString());
}
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.addPendingRegistration({
user_id: user.id.toString(),
username: user.username,
discriminator: user.discriminator,
global_name: user.globalName,
email: rawEmail,
requested_at: now.toISOString(),
registration_url_id: registrationAccess.registrationUrl?.id ?? null,
client_ip: clientIp,
});
return {
registration_pending_approval: true,
user_id: user.id.toString(),
@@ -469,6 +489,38 @@ function shouldAttemptBootstrapAdminGrant(
);
}
async function claimRegistrationUrlUse(
instanceConfigRepository: InstanceConfigRepository,
registrationUrl: InstanceRegistrationUrl | null,
userId: UserID,
): Promise<RegistrationUrlClaim | null> {
if (registrationUrl === null) return null;
const use = await instanceConfigRepository.claimRegistrationUrlUse(registrationUrl.id, userId.toString());
if (use === null) {
throw new RegistrationUrlInvalidError();
}
return use;
}
async function withdrawSignupOfUncreatedAccount(
instanceConfigRepository: InstanceConfigRepository,
signup: {userId: UserID; registrationUrlUse: RegistrationUrlClaim | null; pendingApproval: boolean},
): Promise<void> {
try {
if (signup.registrationUrlUse !== null) {
await instanceConfigRepository.releaseRegistrationUrlUse(signup.registrationUrlUse);
}
if (signup.pendingApproval) {
await instanceConfigRepository.removePendingRegistration(signup.userId.toString());
}
} catch (error) {
Logger.warn(
{userId: signup.userId.toString(), registrationUrlId: signup.registrationUrlUse?.registration_url_id, error},
'[AuthRegistration] Failed to withdraw the registration URL use or pending approval of an account that was never created',
);
}
}
async function resolveRegistrationAccess(
instanceConfigRepository: InstanceConfigRepository,
registrationUrlCode: string | null | undefined,
+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);
}
+24 -13
View File
@@ -382,21 +382,8 @@ export class SsoService {
throw new RegistrationClosedError();
}
const pendingApproval = registrationConfig.mode === 'approval';
if (pendingApproval) {
await this.instanceConfigRepository.getPendingRegistrations();
}
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval});
if (pendingApproval) {
await this.instanceConfigRepository.addPendingRegistration({
user_id: user.id.toString(),
username: user.username,
discriminator: user.discriminator,
global_name: user.globalName,
email: user.email,
requested_at: new Date().toISOString(),
registration_url_id: null,
client_ip: null,
});
throw new RegistrationPendingApprovalError();
}
return user;
@@ -537,8 +524,22 @@ export class SsoService {
version: 1,
} as const;
await this.claimSsoIdentity(userId, claims.sub, config);
let createAttempted = false;
let userCreated = false;
try {
if (options?.pendingApproval) {
await this.instanceConfigRepository.addPendingRegistration({
user_id: userId.toString(),
username,
discriminator: discriminatorResult.discriminator,
global_name: globalName,
email: userRow.email,
requested_at: now.toISOString(),
registration_url_id: null,
client_ip: null,
});
}
createAttempted = true;
const user = await users.create(userRow);
userCreated = true;
await users.upsertSettings(
@@ -557,6 +558,16 @@ export class SsoService {
await this.ssoIdentityRepository.releaseIdentity(config.providerId, claims.sub).catch((releaseError) => {
getLogger().error({releaseError}, 'Failed to release SSO identity after user provisioning failed');
});
if (options?.pendingApproval && !createAttempted) {
await this.instanceConfigRepository
.removePendingRegistration(userId.toString())
.catch((removeError: unknown) => {
getLogger().error(
{userId: userId.toString(), removeError},
'Failed to withdraw the pending approval of an SSO user that was never created',
);
});
}
}
throw error;
}
@@ -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,436 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {
createAuthHarness,
createTestAccount,
createUniqueEmail,
createUniqueUsername,
enableSso,
setUserACLs,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {
InstanceConfigRepository,
REGISTRATION_PENDING_APPROVAL_TRAIT,
} from '@app/api/instance/InstanceConfigRepository';
import {getInstanceConfigRepository} 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 {UserRepository} from '@app/api/user/repositories/UserRepository';
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 {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const REGISTRATION_URLS_KEY = 'registration_urls';
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
interface RegistrationResponse {
user_id?: string;
token?: string;
registration_pending_approval?: true;
code?: string;
}
function registrationBody(prefix: string, registrationUrlCode?: string): Record<string, unknown> {
return {
email: createUniqueEmail(prefix),
username: createUniqueUsername(prefix),
global_name: 'Signup Race',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
...(registrationUrlCode === undefined ? {} : {registration_url_code: registrationUrlCode}),
};
}
describe('signups racing on registration URLs and pending approvals', () => {
let harness: ApiTestHarness;
let admin: TestAccount;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
const register = (prefix: string, registrationUrlCode?: string) =>
createBuilderWithoutAuth<RegistrationResponse>(harness)
.post('/auth/register')
.body(registrationBody(prefix, registrationUrlCode))
.executeRaw();
const readAdminConfig = (): Promise<InstanceConfigResponse> =>
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config').execute();
const completeSso = async (prefix: string) => {
const start = await createBuilderWithoutAuth<{state: string}>(harness)
.post('/auth/sso/start')
.body({redirect_to: '/me'})
.execute();
return createBuilderWithoutAuth(harness)
.post('/auth/sso/complete')
.body({code: createUniqueEmail(prefix), state: start.state})
.executeRaw();
};
const failCreateAfterTheUserRowIsWritten = () => {
const create = UserRepository.prototype.create;
vi.spyOn(UserRepository.prototype, 'create').mockImplementationOnce(async function (
this: UserRepository,
row: UserRow,
) {
await create.call(this, row);
throw new Error('the user indexes could not be written after the user row');
});
};
const failAfterThePendingApprovalIsStored = () => {
const addPendingRegistration = InstanceConfigRepository.prototype.addPendingRegistration;
vi.spyOn(InstanceConfigRepository.prototype, 'addPendingRegistration').mockImplementationOnce(async function (
this: InstanceConfigRepository,
entry: Parameters<InstanceConfigRepository['addPendingRegistration']>[0],
) {
await addPendingRegistration.call(this, entry);
throw new Error('the pending approval could not be published');
});
};
const expectOnePendingAccount = async () => {
const pending = (await readAdminConfig()).registration.pending_registrations;
expect(pending).toHaveLength(1);
const account = await new UserRepository().findUnique(createUserID(BigInt(pending[0]!.user_id)));
expect(account?.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)).toBe(true);
};
it('never lets concurrent signups through a capped registration URL exceed max_uses', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Capped',
createdByUserId: '1',
expiresAt: null,
maxUses: 2,
approvalRequired: false,
});
const attempts = await Promise.all(Array.from({length: 6}, (_, index) => register(`capped${index}`, code)));
const admitted = attempts.filter((attempt) => attempt.response.status === HTTP_STATUS.OK);
const refused = attempts.filter((attempt) => attempt.response.status !== HTTP_STATUS.OK);
expect(admitted).toHaveLength(2);
for (const attempt of refused) {
expect(attempt.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(attempt.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
}
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(2);
expect(admitted.map((attempt) => attempt.json.user_id)).toContain(stored?.last_used_by_user_id);
});
it('admits exactly max_uses when 120 signups race through a registration URL capped at 40', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Capped at 40',
createdByUserId: '1',
expiresAt: null,
maxUses: 40,
approvalRequired: false,
});
const registerUntilDecided = async (prefix: string) => {
for (let attempt = 0; attempt < 20; attempt += 1) {
const result = await register(`${prefix}r${attempt}`, code);
if (result.response.status !== HTTP_STATUS.SERVICE_UNAVAILABLE) return result;
}
throw new Error('a signup never reached a decision');
};
const attempts = await Promise.all(Array.from({length: 120}, (_, index) => registerUntilDecided(`surge${index}`)));
const admitted = attempts.filter((attempt) => attempt.response.status === HTTP_STATUS.OK);
expect(admitted).toHaveLength(40);
for (const attempt of attempts.filter((entry) => entry.response.status !== HTTP_STATUS.OK)) {
expect(attempt.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(attempt.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
}
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(40);
});
it('counts every concurrent signup through an uncapped registration URL', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Uncapped',
createdByUserId: '1',
expiresAt: null,
maxUses: null,
approvalRequired: false,
});
const attempts = await Promise.all(Array.from({length: 5}, (_, index) => register(`uncapped${index}`, code)));
expect(attempts.map((attempt) => attempt.response.status)).toEqual(Array(5).fill(HTTP_STATUS.OK));
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(5);
});
it('gives the seat and the pending entry back when the signup failed before the account was created', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Single use',
createdByUserId: '1',
expiresAt: null,
maxUses: 1,
approvalRequired: true,
});
failAfterThePendingApprovalIsStored();
const failed = await register('seatreleased', code);
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
const withdrawn = await readAdminConfig();
expect(withdrawn.registration.pending_registrations).toEqual([]);
expect(withdrawn.registration.urls.find((url) => url.id === registrationUrl.id)?.use_count).toBe(0);
const retried = await register('seatreleasedretry', code);
expect(retried.response.status).toBe(HTTP_STATUS.OK);
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored).toMatchObject({use_count: 1, last_used_by_user_id: retried.json.user_id});
});
it('keeps the seat when the account create itself failed, because the row may still have landed', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Single use',
createdByUserId: '1',
expiresAt: null,
maxUses: 1,
approvalRequired: false,
});
vi.spyOn(UserRepository.prototype, 'create').mockRejectedValueOnce(new Error('the user row write failed'));
const failed = await register('seatkeptoncreate', code);
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
const second = await register('seatkeptcreate2', code);
expect(second.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(second.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(1);
});
it('keeps the seat of an account whose row was written before its creation failed', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Single use',
createdByUserId: '1',
expiresAt: null,
maxUses: 1,
approvalRequired: false,
});
failCreateAfterTheUserRowIsWritten();
const failed = await register('seatkept', code);
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
const second = await register('seatkeptsecond', code);
expect(second.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(second.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(1);
});
it('honours the use count and cap already stored on a registration URL', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const id = 'b3c4f0b2-8a6e-4c41-9f55-3f0c2a7d1e90';
await repository.setConfig(
REGISTRATION_URLS_KEY,
JSON.stringify([
{
id,
label: 'Issued earlier',
code_hash: createHash('sha256').update(id).digest('hex'),
created_by_user_id: '1400000000000000001',
created_at: '2026-09-01T00:00:00.000Z',
expires_at: null,
max_uses: 2,
use_count: 1,
revoked_at: null,
approval_required: false,
last_used_at: '2026-09-02T00:00:00.000Z',
last_used_by_user_id: '1400000000000000002',
},
]),
);
const before = (await readAdminConfig()).registration.urls.find((url) => url.id === id);
expect(before).toMatchObject({use_count: 1, max_uses: 2, last_used_by_user_id: '1400000000000000002'});
const first = await register('storedinvite', id);
expect(first.response.status).toBe(HTTP_STATUS.OK);
const second = await register('storedinviteagain', id);
expect(second.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(second.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
const after = (await readAdminConfig()).registration.urls.find((url) => url.id === id);
expect(after).toMatchObject({use_count: 2, max_uses: 2, last_used_by_user_id: first.json.user_id});
});
it('keeps every pending approval when approval-mode signups race', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const attempts = await Promise.all(Array.from({length: 5}, (_, index) => register(`pending${index}`)));
expect(attempts.map((attempt) => attempt.json.registration_pending_approval)).toEqual(Array(5).fill(true));
const pending = (await readAdminConfig()).registration.pending_registrations.map((entry) => entry.user_id);
expect(pending.toSorted()).toEqual(attempts.map((attempt) => attempt.json.user_id).toSorted());
});
it('lists an approval-mode account whose signup failed after the account was created', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
vi.spyOn(UserRepository.prototype, 'createAuthorizedIp').mockRejectedValueOnce(
new Error('the authorized IP write failed'),
);
const failed = await register('pendingstranded');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
await expectOnePendingAccount();
});
it('lists an approval-mode account whose row was written before its creation failed', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
failCreateAfterTheUserRowIsWritten();
const failed = await register('pendingrowwritten');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
await expectOnePendingAccount();
});
it('keeps the pending approval of an approval-mode signup whose account create failed', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
vi.spyOn(UserRepository.prototype, 'create').mockRejectedValueOnce(new Error('the user row write failed'));
const failed = await register('pendingkept');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toHaveLength(1);
});
it('lists no pending approval for an approval-mode signup that failed before the account was created', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
failAfterThePendingApprovalIsStored();
const failed = await register('pendingnever');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toEqual([]);
});
it('lists an SSO account provisioned in approval mode whose provisioning failed after the account was created', async () => {
await enableSso(harness, admin.token, {enforced: false});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
vi.spyOn(UserRepository.prototype, 'upsertSettings').mockRejectedValueOnce(new Error('the settings write failed'));
const failed = await completeSso('ssopendingstranded');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
await expectOnePendingAccount();
});
it('lists an SSO account provisioned in approval mode whose row was written before its creation failed', async () => {
await enableSso(harness, admin.token, {enforced: false});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
failCreateAfterTheUserRowIsWritten();
const failed = await completeSso('ssopendingrowwritten');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
await expectOnePendingAccount();
});
it('keeps the pending approval of an SSO signup in approval mode whose account create failed', async () => {
await enableSso(harness, admin.token, {enforced: false});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
vi.spyOn(UserRepository.prototype, 'create').mockRejectedValueOnce(new Error('the user row write failed'));
const failed = await completeSso('ssopendingkept');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toHaveLength(1);
});
it('lists no pending approval for an SSO signup in approval mode that failed before the account was created', async () => {
await enableSso(harness, admin.token, {enforced: false});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
failAfterThePendingApprovalIsStored();
const failed = await completeSso('ssopendingnever');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toEqual([]);
});
it('keeps a stored pending approval listed until an admin decides it', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setConfig(
REGISTRATION_PENDING_APPROVALS_KEY,
JSON.stringify([
{
user_id: account.userId,
username: 'stored_pending',
discriminator: 1,
global_name: null,
email: account.email,
requested_at: '2026-09-01T00:00:00.000Z',
registration_url_id: null,
client_ip: '127.0.0.1',
},
]),
);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const fresh = await register('pendingafter');
const listed = (await readAdminConfig()).registration.pending_registrations.map((entry) => entry.user_id);
expect(listed.toSorted()).toEqual([account.userId, fresh.json.user_id].toSorted());
const decided = await createBuilder<InstanceConfigResponse>(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${account.userId}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
expect(decided.registration.pending_registrations.map((entry) => entry.user_id)).toEqual([fresh.json.user_id]);
expect(
JSON.parse(
(await getInstanceConfigRepository().getConfig(REGISTRATION_PENDING_APPROVALS_KEY)) ?? 'null',
) as Array<{user_id: string}>,
).toEqual([expect.objectContaining({user_id: fresh.json.user_id})]);
});
});
@@ -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,
@@ -3,7 +3,7 @@
import type {AttachmentID, ChannelID} from '@app/api/BrandedTypes';
import type {AttachmentRequestData} from '@app/api/channel/AttachmentDTOs';
import type {RichEmbedMediaWithMetadata} from '@app/api/channel/EmbedTypes';
import {makeAttachmentCdnUrl} from '@app/api/channel/services/message/MessageHelpers';
import {getContentType, makeAttachmentCdnUrl} from '@app/api/channel/services/message/MessageHelpers';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {RichEmbedRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
@@ -26,7 +26,10 @@ interface RichEmbedRequestWithMetadata extends Omit<RichEmbedRequest, 'image' |
thumbnail?: RichEmbedMediaWithMetadata | null;
}
const SUPPORTED_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif']);
function isEmbeddableMediaType(contentType: string): boolean {
const normalized = contentType.toLowerCase();
return normalized.startsWith('image/') || normalized.startsWith('video/');
}
export class MessageEmbedAttachmentResolver {
validateAttachmentReferences(params: {
@@ -69,8 +72,7 @@ export class MessageEmbedAttachmentResolver {
{filename},
);
}
const extension = filename.split('.').pop()?.toLowerCase();
if (!extension || !SUPPORTED_IMAGE_EXTENSIONS.has(extension)) {
if (!isEmbeddableMediaType(getContentType(filename))) {
throw InputValidationError.fromCode(
`embeds[${embedIndex}].${field}`,
ValidationErrorCodes.ATTACHMENT_MUST_BE_IMAGE,
@@ -137,8 +139,7 @@ export class MessageEmbedAttachmentResolver {
if (!attachmentData) {
throw InputValidationError.fromCode(field, ValidationErrorCodes.REFERENCED_ATTACHMENT_NOT_FOUND, {filename});
}
const extension = filename.split('.').pop()?.toLowerCase();
if (!extension || !SUPPORTED_IMAGE_EXTENSIONS.has(extension)) {
if (!isEmbeddableMediaType(attachmentData.metadata.content_type)) {
throw InputValidationError.fromCode(field, ValidationErrorCodes.ATTACHMENT_MUST_BE_IMAGE, {filename});
}
return attachmentData;
@@ -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(
@@ -512,6 +512,34 @@ describe('Embed Attachment URL Resolution', () => {
expect(json.embeds).toHaveLength(1);
expect(json.embeds![0].image?.url).not.toContain('attachment://');
});
it('should accept image and video attachments beyond the legacy image extensions', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Media Type Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const payload = {
content: 'Test with jxl and mp4 embed media',
attachments: [
{id: 0, filename: 'photo.jxl'},
{id: 1, filename: 'clip.mp4'},
],
embeds: [
{
title: 'Media Embed',
image: {url: 'attachment://clip.mp4'},
thumbnail: {url: 'attachment://photo.jxl'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'photo.jxl', data: Buffer.from('jxl bytes')},
{index: 1, filename: 'clip.mp4', data: Buffer.from('mp4 bytes')},
]);
expect(response.status).toBe(200);
expect(json.embeds).toHaveLength(1);
expect(json.embeds![0].image?.url).not.toContain('attachment://');
expect(json.embeds![0].thumbnail?.url).not.toContain('attachment://');
});
});
describe('Multiple Embeds and Files', () => {
it('should handle multiple embeds with different URL types', async () => {
@@ -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>;
};
@@ -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']});
});
});
@@ -1,18 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {spawnSync} from 'node:child_process';
import {createHash} from 'node:crypto';
import {createServer} from 'node:net';
import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
import {
INSTANCE_CONFIG_REFRESH_CHANNEL,
INSTANCE_CONFIG_WRITE_ATTEMPTS,
InstanceConfigRepository,
InstanceConfigWriteConflictError,
type InstanceRegistrationConfig,
} from '@app/api/instance/InstanceConfigRepository';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
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,
@@ -21,14 +30,26 @@ import {
DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
type ExperimentDeliveryConfig,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {afterEach, describe, expect, it, vi} from 'vitest';
import {
getDefaultPostgresClient,
type IPostgresClient,
initPostgres,
shutdownPostgres,
} from '@pkgs/postgres/src/Client';
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';
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
const POSTGRES_KV_TABLE = 'kv_instance_config_races';
const POSTGRES_CONTAINER = `fluxer-instance-config-races-${process.pid.toString(36)}-${Date.now().toString(36)}`;
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
class CountingInMemoryCassandraQueryExecutor extends InMemoryCassandraQueryExecutor {
instanceConfigSelects = 0;
@@ -335,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([
@@ -349,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);
@@ -471,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);
@@ -512,3 +535,466 @@ describe('InstanceConfigRepository', () => {
});
});
});
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (typeof address === 'string' || address === null) {
reject(new Error('no port'));
return;
}
server.close(() => resolve(address.port));
});
});
}
function describeConcurrentInstanceConfigWrites(prepareBase: () => Promise<CassandraQueryExecutorForTesting>): void {
const pods: Array<InstanceConfigRepository> = [];
let executor: InstanceConfigWriteRaceExecutor;
beforeEach(async () => {
executor = new InstanceConfigWriteRaceExecutor(await prepareBase());
setCassandraQueryExecutorForTesting(executor);
});
afterEach(async () => {
await Promise.all(pods.map((pod) => pod.shutdown()));
pods.length = 0;
});
function createPod(): InstanceConfigRepository {
const pod = new InstanceConfigRepository(new MockKVProvider());
pods.push(pod);
return pod;
}
async function readStoredRegistrationConfig(): Promise<unknown> {
const raw = await executor.readDirectly(REGISTRATION_CONFIG_KEY);
return raw === null ? null : JSON.parse(raw);
}
it('applies two concurrent patches on top of each other instead of dropping one', async () => {
const first = createPod();
const second = createPod();
await first.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
await second.getRegistrationConfig();
executor.watch(REGISTRATION_CONFIG_KEY);
executor.pauseWritesUntil(2);
await Promise.all([
first.setRegistrationConfig({mode: 'closed'}),
second.setRegistrationConfig({admin_registration_urls_enabled: false}),
]);
expect(executor.events.filter((event) => event === 'write rejected')).toHaveLength(1);
expect(executor.events.filter((event) => event === 'write')).toHaveLength(2);
expect(await readStoredRegistrationConfig()).toEqual({mode: 'closed', admin_registration_urls_enabled: false});
});
it('lets one of two concurrent first writes create the config and applies the other on top', async () => {
const first = createPod();
const second = createPod();
await first.getRegistrationConfig();
await second.getRegistrationConfig();
executor.watch(REGISTRATION_CONFIG_KEY);
executor.pauseWritesUntil(2);
await Promise.all([
first.setRegistrationConfig({mode: 'closed'}),
second.setRegistrationConfig({admin_registration_urls_enabled: false}),
]);
expect(executor.events.filter((event) => event === 'write rejected')).toHaveLength(1);
expect(executor.events.filter((event) => event === 'write')).toHaveLength(2);
expect(await readStoredRegistrationConfig()).toEqual({mode: 'closed', admin_registration_urls_enabled: false});
});
it('re-reads the database, not its stale cache, when a concurrent write lands between its read and its write', async () => {
const stale = createPod();
const other = createPod();
await stale.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
await stale.getRegistrationConfig();
await other.setRegistrationConfig({mode: 'approval'});
expect(await stale.getRegistrationConfig()).toEqual({mode: 'open', admin_registration_urls_enabled: true});
executor.watch(REGISTRATION_CONFIG_KEY);
let competed = false;
executor.competeBeforeEachWrite(async () => {
if (competed) return;
competed = true;
await executor.writeDirectly(
REGISTRATION_CONFIG_KEY,
JSON.stringify({mode: 'approval', admin_registration_urls_enabled: false}),
);
});
await stale.setRegistrationConfig({mode: 'closed'});
expect(executor.events).toEqual(['read', 'write rejected', 'read', 'write']);
expect(await readStoredRegistrationConfig()).toEqual({mode: 'closed', admin_registration_urls_enabled: false});
});
it('fails loudly and writes nothing once every attempt has lost the race', async () => {
const pod = createPod();
await pod.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
executor.watch(REGISTRATION_CONFIG_KEY);
let competingWrites = 0;
executor.competeBeforeEachWrite(async () => {
competingWrites++;
await executor.writeDirectly(
REGISTRATION_CONFIG_KEY,
JSON.stringify({mode: 'approval', admin_registration_urls_enabled: competingWrites % 2 === 0}),
);
});
const write = pod.setRegistrationConfig({mode: 'closed'});
await expect(write).rejects.toBeInstanceOf(InstanceConfigWriteConflictError);
await expect(write).rejects.toMatchObject({
status: 409,
code: 'CONFLICT',
message: expect.stringContaining(REGISTRATION_CONFIG_KEY),
});
expect(executor.events.filter((event) => event === 'write rejected')).toHaveLength(INSTANCE_CONFIG_WRITE_ATTEMPTS);
expect(executor.events).not.toContain('write');
expect(await readStoredRegistrationConfig()).toEqual({
mode: 'approval',
admin_registration_urls_enabled: INSTANCE_CONFIG_WRITE_ATTEMPTS % 2 === 0,
});
});
it('keeps a pending registration another pod added while this pod held a stale list', async () => {
const first = createPod();
const second = createPod();
await first.getPendingRegistrations();
await second.getPendingRegistrations();
await first.addPendingRegistration(pendingRegistration('1400000000000000011'));
await second.addPendingRegistration(pendingRegistration('1400000000000000012'));
const listed = await createPod().getPendingRegistrations();
expect(listed.map((entry) => entry.user_id)).toEqual(['1400000000000000011', '1400000000000000012']);
});
it('keeps a pending registration another pod stored and removes it once decided', async () => {
const pod = createPod();
await executor.writeDirectly(
REGISTRATION_PENDING_APPROVALS_KEY,
JSON.stringify([pendingRegistration('1400000000000000021')]),
);
await pod.addPendingRegistration(pendingRegistration('1400000000000000022'));
expect((await createPod().getPendingRegistrations()).map((entry) => entry.user_id)).toEqual([
'1400000000000000021',
'1400000000000000022',
]);
await pod.removePendingRegistration('1400000000000000021');
await pod.removePendingRegistration('1400000000000000022');
expect(await createPod().getPendingRegistrations()).toEqual([]);
expect(await executor.readDirectly(REGISTRATION_PENDING_APPROVALS_KEY)).toBe('[]');
});
it('keeps a registration URL another pod created while this pod held a stale list', async () => {
const first = createPod();
const second = createPod();
await first.getRegistrationUrls();
await second.getRegistrationUrls();
const created = [
await first.createRegistrationUrl(registrationUrlParams(null)),
await second.createRegistrationUrl(registrationUrlParams(null)),
];
const listed = await createPod().getRegistrationUrlsForAdmin();
expect(listed.map((url) => url.id).toSorted()).toEqual(created.map((entry) => entry.registrationUrl.id).toSorted());
});
it('refuses a registration URL another pod revoked while this pod held a stale list', async () => {
const admin = createPod();
const signup = createPod();
const {code, registrationUrl} = await admin.createRegistrationUrl(registrationUrlParams(null));
expect(await signup.resolveRegistrationUrlCode(code)).not.toBeNull();
await admin.revokeRegistrationUrl(registrationUrl.id);
await expect(signup.resolveRegistrationUrlCode(code)).resolves.toBeNull();
await expect(signup.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000501')).resolves.toBeNull();
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed?.use_count).toBe(0);
});
it('admits a registration URL another pod created while this pod held a stale list', async () => {
const admin = createPod();
const signup = createPod();
await signup.getRegistrationUrls();
const {code, registrationUrl} = await admin.createRegistrationUrl(registrationUrlParams(1));
await expect(signup.resolveRegistrationUrlCode(code)).resolves.toMatchObject({
id: registrationUrl.id,
approval_required: false,
});
await expect(signup.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000601')).resolves.not.toBeNull();
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed?.use_count).toBe(1);
});
it('never seats more signups than max_uses when pods claim the same registration URL at once', async () => {
const pods = [createPod(), createPod(), createPod()];
const {code} = await pods[0]!.createRegistrationUrl(registrationUrlParams(2));
const registrationUrl = await pods[0]!.resolveRegistrationUrlCode(code);
if (registrationUrl === null) throw new Error('registration URL did not resolve');
const claims = await Promise.all(
Array.from({length: 6}, (_, index) =>
pods[index % pods.length]!.claimRegistrationUrlUse(registrationUrl.id, `14000000000000001${index}0`),
),
);
expect(claims.filter((claim) => claim !== null)).toHaveLength(2);
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed?.use_count).toBe(2);
await expect(createPod().resolveRegistrationUrlCode(code)).resolves.toBeNull();
});
it('refuses a claim whose retry finds the registration URL exhausted, rather than reporting the lost attempt', async () => {
const pod = createPod();
const {code, registrationUrl} = await pod.createRegistrationUrl(registrationUrlParams(1));
expect(await pod.resolveRegistrationUrlCode(code)).not.toBeNull();
executor.watch(REGISTRATION_URLS_KEY);
let competed = false;
executor.competeBeforeEachWrite(async () => {
if (competed) return;
competed = true;
const stored = JSON.parse((await executor.readDirectly(REGISTRATION_URLS_KEY)) ?? 'null') as Array<
Record<string, unknown>
>;
await executor.writeDirectly(
REGISTRATION_URLS_KEY,
JSON.stringify(
stored.map((entry) => ({
...entry,
use_count: 1,
last_used_at: '2026-09-20T00:00:00.000Z',
last_used_by_user_id: '1400000000000000901',
})),
),
);
});
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000902')).resolves.toBeNull();
expect(executor.events).toEqual(['read', 'write rejected', 'read']);
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed).toMatchObject({use_count: 1, last_used_by_user_id: '1400000000000000901'});
});
it('counts concurrent uses of an uncapped registration URL without ever refusing one', async () => {
const pods = [createPod(), createPod()];
const {code} = await pods[0]!.createRegistrationUrl(registrationUrlParams(null));
const registrationUrl = await pods[0]!.resolveRegistrationUrlCode(code);
if (registrationUrl === null) throw new Error('registration URL did not resolve');
const claims = await Promise.all(
Array.from({length: 5}, (_, index) =>
pods[index % pods.length]!.claimRegistrationUrlUse(registrationUrl.id, `14000000000000002${index}0`),
),
);
expect(claims.every((claim) => claim !== null)).toBe(true);
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed?.use_count).toBe(5);
});
it('frees a released seat for the next signup', async () => {
const pod = createPod();
const {code} = await pod.createRegistrationUrl(registrationUrlParams(1));
const registrationUrl = await pod.resolveRegistrationUrlCode(code);
if (registrationUrl === null) throw new Error('registration URL did not resolve');
const failedSignup = await pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000301');
if (failedSignup === null) throw new Error('the first claim was refused');
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000302')).resolves.toBeNull();
await pod.releaseRegistrationUrlUse(failedSignup);
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000303')).resolves.not.toBeNull();
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed).toMatchObject({use_count: 1, last_used_by_user_id: '1400000000000000303'});
});
it('enforces max_uses against the use count already stored in the blob', async () => {
const pod = createPod();
const id = 'b3c4f0b2-8a6e-4c41-9f55-3f0c2a7d1e91';
await executor.writeDirectly(
REGISTRATION_URLS_KEY,
JSON.stringify([
{
id,
label: 'Issued earlier',
code_hash: createHash('sha256').update(id).digest('hex'),
created_by_user_id: '1400000000000000001',
created_at: '2026-09-01T00:00:00.000Z',
expires_at: null,
max_uses: 3,
use_count: 2,
revoked_at: null,
approval_required: true,
last_used_at: '2026-09-02T00:00:00.000Z',
last_used_by_user_id: '1400000000000000002',
},
]),
);
expect(await pod.getRegistrationUrlsForAdmin()).toEqual([
expect.objectContaining({
id,
use_count: 2,
max_uses: 3,
approval_required: true,
last_used_at: '2026-09-02T00:00:00.000Z',
last_used_by_user_id: '1400000000000000002',
}),
]);
const registrationUrl = await pod.resolveRegistrationUrlCode(id);
if (registrationUrl === null) throw new Error('stored registration URL did not resolve');
expect(registrationUrl).toMatchObject({id, approval_required: true});
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000401')).resolves.not.toBeNull();
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000402')).resolves.toBeNull();
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed).toMatchObject({use_count: 3, max_uses: 3, last_used_by_user_id: '1400000000000000401'});
await expect(pod.resolveRegistrationUrlCode(id)).resolves.toBeNull();
expect(JSON.parse((await executor.readDirectly(REGISTRATION_URLS_KEY)) ?? 'null')[0]).toMatchObject({
use_count: 3,
max_uses: 3,
});
});
it('keeps an SSO field another pod changed while this pod held a stale snapshot', async () => {
const first = createPod();
const second = createPod();
await first.getSsoConfig();
await second.getSsoConfig();
await first.setSsoConfig({displayName: 'Set by the first pod'});
await second.setSsoConfig({clientId: 'set-by-the-second-pod'});
expect(await createPod().getSsoConfig()).toMatchObject({
displayName: 'Set by the first pod',
clientId: 'set-by-the-second-pod',
});
});
it('leaves an SSO row alone when another pod wrote it between this pod reading and writing it', async () => {
const pod = createPod();
await pod.getSsoConfig();
executor.watch('sso_enforced');
let competed = false;
executor.competeBeforeEachWrite(async () => {
if (competed) return;
competed = true;
await executor.writeDirectly('sso_enforced', 'true');
});
await pod.setSsoConfig({displayName: 'Only the display name'});
expect(await executor.readDirectly('sso_enforced')).toBe('true');
expect(await executor.readDirectly('sso_display_name')).toBe('Only the display name');
});
}
function pendingRegistration(userId: string) {
return {
user_id: userId,
username: `pending_${userId.slice(-3)}`,
discriminator: 1,
global_name: null,
email: `${userId}@example.com`,
requested_at: `2026-09-01T00:00:${userId.slice(-2)}.000Z`,
registration_url_id: null,
client_ip: '127.0.0.1',
};
}
function registrationUrlParams(maxUses: number | null) {
return {
label: maxUses === null ? 'Uncapped' : `Capped at ${maxUses}`,
createdByUserId: '1400000000000000001',
expiresAt: null,
maxUses,
approvalRequired: false,
};
}
describe('InstanceConfigRepository concurrent writes', () => {
describe('in memory', () => {
describeConcurrentInstanceConfigWrites(async () => new InMemoryCassandraQueryExecutor());
});
describe.skipIf(!dockerAvailable)('on postgres', () => {
let client: IPostgresClient;
beforeAll(async () => {
const port = await freePort();
startDockerContainer([
'run',
'-d',
'--name',
POSTGRES_CONTAINER,
'-e',
'POSTGRES_USER=fluxer',
'-e',
'POSTGRES_PASSWORD=fluxer',
'-e',
'POSTGRES_DB=fluxer',
'-p',
`127.0.0.1:${port}:5432`,
'postgres:16-alpine',
'-c',
'fsync=off',
]);
let ready = false;
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
await sleep(500);
const probe = spawnSync('docker', ['exec', POSTGRES_CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
stdio: 'ignore',
});
if (probe.status !== 0) continue;
try {
await initPostgres({
url: `postgres://fluxer:[email protected]:${port}/fluxer`,
maxConnections: 4,
kvTable: POSTGRES_KV_TABLE,
});
await getDefaultPostgresClient().query('SELECT 1');
ready = true;
} catch {
await shutdownPostgres().catch(() => {});
}
}
if (!ready) throw new Error('postgres never came up');
client = getDefaultPostgresClient();
await ensurePostgresKvSchema(client);
}, 900_000);
afterAll(async () => {
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
await shutdownPostgres().catch(() => {});
spawnSync('docker', ['rm', '-f', POSTGRES_CONTAINER], {stdio: 'ignore'});
});
describeConcurrentInstanceConfigWrites(async () => {
await client.query(`DELETE FROM ${POSTGRES_KV_TABLE}`);
return new PostgresKvQueryExecutor(client);
});
});
});
@@ -3,7 +3,8 @@
import crypto from 'node:crypto';
import {Config} from '@app/api/Config';
import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '@app/api/config/APIConfig';
import {fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
import {
getDefaultDateOfBirthCollection,
@@ -17,6 +18,9 @@ import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from
import {InstanceConfiguration} from '@app/api/Tables';
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '@app/api/utils/AttachmentDecay';
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import {
InstanceConfigResponse,
@@ -24,14 +28,28 @@ import {
type PendingRegistrationResponse,
type RegistrationUrlResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
type AltchaCaptchaConfig,
AltchaCaptchaConfigSchema,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {
type DomainMigrationConfig,
DomainMigrationConfigSchema,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
type GatewayRolloutConfig,
GatewayRolloutConfigSchema,
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {
type ScreenShareDeliveryConfig,
ScreenShareDeliveryConfigSchema,
} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
type ProfileTimezoneConfig,
ProfileTimezoneConfigSchema,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {
type LegacyPushServiceDeliveryWire,
type PushRelayConfig,
PushRelayConfigSchema,
toLegacyPushServiceDeliveryWire,
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {
type VoiceNoiseSuppressionConfig,
VoiceNoiseSuppressionConfigSchema,
@@ -58,7 +76,10 @@ import {z} from 'zod';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_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';
@@ -72,6 +93,22 @@ const INSTANCE_MEDIA_CONFIG_KEY = 'instance_media_config';
export const INSTANCE_CONFIG_REFRESH_CHANNEL = 'instance-config-refresh';
export const REGISTRATION_PENDING_APPROVAL_TRAIT = 'registration_pending_approval';
export const REGISTRATION_REJECTED_TRAIT = 'registration_rejected';
export const INSTANCE_CONFIG_WRITE_ATTEMPTS = 5;
export class InstanceConfigWriteConflictError extends ConflictError {
constructor(key: string) {
super({
code: APIErrorCodes.CONFLICT,
message: `Instance config "${key}" changed concurrently on all ${INSTANCE_CONFIG_WRITE_ATTEMPTS} write attempts. Nothing was written. Retry the change.`,
});
this.name = 'InstanceConfigWriteConflictError';
}
}
interface StoredValueUpdate<T> {
value: string | null;
result: T;
}
export type InstanceRegistrationConfig = InstanceRegistration;
@@ -277,6 +314,11 @@ export interface InstanceRegistrationUrl extends RegistrationUrlResponse {
type InstanceRegistrationUrlPublic = RegistrationUrlResponse;
type InstancePendingRegistration = PendingRegistrationResponse;
export interface RegistrationUrlClaim {
registration_url_id: string;
user_id: string;
}
const DEFAULT_REGISTRATION_CONFIG: InstanceRegistrationConfig = {
mode: 'open',
admin_registration_urls_enabled: true,
@@ -344,7 +386,10 @@ type StoredConfigSection =
| 'app public'
| 'gateway rollout'
| 'voice noise suppression'
| 'screen share delivery'
| 'push relay'
| 'domain migration'
| 'altcha captcha'
| 'profile timezone'
| 'experiment delivery'
| 'instance policy'
| 'integrations'
@@ -483,8 +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 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 {
@@ -910,6 +984,75 @@ function parseStoredSsoAllowedEmailDomains(raw: string | undefined, log = false)
return Array.from(domains).slice(0, MAX_SSO_ALLOWED_DOMAINS);
}
function readStoredSsoConfig(
configs: ReadonlyMap<string, string>,
options?: {includeSecret?: boolean},
): InstanceSsoConfig {
const flags = readStoredSsoFlags(configs);
const read = (key: string): string | null => {
const v = configs.get(key);
if (!v) return null;
const trimmed = v.trim();
return trimmed.length === 0 ? null : trimmed;
};
const allowedDomains = parseStoredSsoAllowedEmailDomains(configs.get('sso_allowed_domains'));
const clientSecret = read('sso_client_secret');
return {
...flags,
displayName: read('sso_display_name'),
issuer: read('sso_issuer'),
authorizationUrl: read('sso_authorization_url'),
tokenUrl: read('sso_token_url'),
userInfoUrl: read('sso_userinfo_url'),
jwksUrl: read('sso_jwks_url'),
clientId: read('sso_client_id'),
clientSecret: options?.includeSecret ? clientSecret : undefined,
clientSecretSet: Boolean(clientSecret),
scope: read('sso_scope'),
allowedEmailDomains: allowedDomains,
redirectUri: null,
};
}
interface SsoRowWrite {
key: string;
value: string | undefined;
unset: string;
}
function ssoRow<T>(key: string, value: T | undefined, current: T, format: (value: T) => string): SsoRowWrite {
return {key, value: value === undefined ? undefined : format(value), unset: format(current)};
}
function nextSsoRowValue(row: SsoRowWrite, raw: string | null): string | null {
const value = row.value ?? raw ?? row.unset;
return value === raw ? null : value;
}
function formatSsoBoolean(value: boolean): string {
return value ? 'true' : 'false';
}
function formatSsoString(value: string | null): string {
return value ?? '';
}
function formatSsoDomains(value: Array<string>): string {
return JSON.stringify(value);
}
function normalizeSsoAllowedEmailDomainsForWrite(domains: Array<string>, enabled: boolean): Array<string> {
try {
return normalizeSsoAllowedEmailDomains(domains);
} catch (error) {
if (enabled) {
throw error;
}
Logger.warn({error}, 'Clearing invalid SSO allowed domain config while SSO is disabled');
return [];
}
}
export class InstanceConfigRepository {
private readonly kvClient: IKVProvider | null;
private configCache: InstanceConfigCache;
@@ -993,6 +1136,57 @@ export class InstanceConfigRepository {
);
}
private async updateStoredConfig<T>(key: string, next: (raw: string | null) => T): Promise<T> {
const cache = this.configCache;
const {result} = await this.compareAndSetStoredValue(cache, key, (raw) => {
const config = next(raw);
return {value: JSON.stringify(config), result: config};
});
await this.publishRefresh(cache.sourceId);
return result;
}
private async compareAndSetStoredValue<T>(
cache: InstanceConfigCache,
key: string,
next: (raw: string | null) => StoredValueUpdate<T>,
): Promise<{result: T; written: boolean}> {
await cache.getSnapshot();
for (let attempt = 0; attempt < INSTANCE_CONFIG_WRITE_ATTEMPTS; attempt++) {
cache.assertActive();
const current = await this.fetchConfigForWrite(key);
cache.assertActive();
const {value, result} = next(current);
if (value === null) return {result, written: false};
if (await executeConditional(this.compareAndSetConfig(key, current, value))) {
cache.update(key, value);
return {result, written: true};
}
}
Logger.error(
{key, attempts: INSTANCE_CONFIG_WRITE_ATTEMPTS},
'Instance config write lost to a concurrent write on every attempt',
);
throw new InstanceConfigWriteConflictError(key);
}
private compareAndSetConfig(key: string, current: string | null, value: string): PreparedQuery {
const updatedAt = new Date();
if (current === null) {
return InstanceConfiguration.insertIfNotExists({key, value, updated_at: updatedAt});
}
return InstanceConfiguration.conditionalPatchByPk(
{key},
{value: Db.set(value), updated_at: Db.set(updatedAt)},
{value: current},
);
}
private async fetchConfigForWrite(key: string): Promise<string | null> {
const [row] = await fetchMany<InstanceConfigurationRow>(FETCH_CONFIG_QUERY, {key}, {consistency: 'serial'});
return row?.value ?? null;
}
private async fetchConfigFromDatabase(key: string): Promise<string | null> {
const row = await fetchOne<InstanceConfigurationRow>(FETCH_CONFIG_QUERY, {key});
return row?.value ?? null;
@@ -1014,7 +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);
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', () =>
@@ -1082,8 +1279,12 @@ export class InstanceConfigRepository {
return parseStoredGatewayRolloutConfig(raw);
}
async setGatewayRolloutConfig(config: GatewayRolloutConfig): Promise<void> {
await this.setConfig(GATEWAY_ROLLOUT_CONFIG_KEY, JSON.stringify(decodeGatewayRolloutConfig(config)));
updateGatewayRolloutConfig(
update: (current: GatewayRolloutConfig) => GatewayRolloutConfig,
): Promise<GatewayRolloutConfig> {
return this.updateStoredConfig(GATEWAY_ROLLOUT_CONFIG_KEY, (raw) =>
decodeGatewayRolloutConfig(update(parseStoredGatewayRolloutConfig(raw))),
);
}
async getVoiceNoiseSuppressionConfig(): Promise<VoiceNoiseSuppressionConfig> {
@@ -1092,18 +1293,95 @@ export class InstanceConfigRepository {
}
async setVoiceNoiseSuppressionConfig(config: VoiceNoiseSuppressionConfig): Promise<void> {
const validated = validateStoredConfig(VoiceNoiseSuppressionConfigSchema, config, 'voice noise suppression');
await this.setConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, JSON.stringify(validated));
await this.updateVoiceNoiseSuppressionConfig(() => config);
}
async getScreenShareDeliveryConfig(): Promise<ScreenShareDeliveryConfig> {
const raw = await this.getConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY);
return parseStoredScreenShareDeliveryConfig(raw);
updateVoiceNoiseSuppressionConfig(
update: (current: VoiceNoiseSuppressionConfig) => VoiceNoiseSuppressionConfig,
): Promise<VoiceNoiseSuppressionConfig> {
return this.updateStoredConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, (raw) =>
validateStoredConfig(
VoiceNoiseSuppressionConfigSchema,
update(parseStoredVoiceNoiseSuppressionConfig(raw)),
'voice noise suppression',
),
);
}
async setScreenShareDeliveryConfig(config: ScreenShareDeliveryConfig): Promise<void> {
const validated = validateStoredConfig(ScreenShareDeliveryConfigSchema, config, 'screen share delivery');
await this.setConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, JSON.stringify(validated));
async getLegacyPushServiceDeliveryWire(): Promise<LegacyPushServiceDeliveryWire> {
const raw = await this.getConfig(PUSH_RELAY_CONFIG_KEY);
return parseStoredPushRelayConfig(raw);
}
async getPushRelayConfig(): Promise<PushRelayConfig> {
return toPushRelayConfig(await this.getLegacyPushServiceDeliveryWire());
}
updatePushRelayConfig(update: (current: PushRelayConfig) => PushRelayConfig): Promise<LegacyPushServiceDeliveryWire> {
return this.updateStoredConfig(PUSH_RELAY_CONFIG_KEY, (raw) => {
const current = parseStoredPushRelayConfig(raw);
const next = validateStoredConfig(PushRelayConfigSchema, update(toPushRelayConfig(current)), 'push relay');
return toLegacyPushServiceDeliveryWire(next, current.config_version + 1);
});
}
async getDomainMigrationConfig(): Promise<DomainMigrationConfig> {
const raw = await this.getConfig(DOMAIN_MIGRATION_CONFIG_KEY);
return parseStoredDomainMigrationConfig(raw);
}
async setDomainMigrationConfig(config: DomainMigrationConfig): Promise<void> {
await this.updateDomainMigrationConfig(() => config);
}
updateDomainMigrationConfig(
update: (current: DomainMigrationConfig) => DomainMigrationConfig,
): Promise<DomainMigrationConfig> {
return this.updateStoredConfig(DOMAIN_MIGRATION_CONFIG_KEY, (raw) =>
validateStoredConfig(
DomainMigrationConfigSchema,
update(parseStoredDomainMigrationConfig(raw)),
'domain migration',
),
);
}
async getAltchaCaptchaConfig(): Promise<AltchaCaptchaConfig> {
const raw = await this.getConfig(ALTCHA_CAPTCHA_CONFIG_KEY);
return parseStoredAltchaCaptchaConfig(raw);
}
async setAltchaCaptchaConfig(config: AltchaCaptchaConfig): Promise<void> {
await this.updateAltchaCaptchaConfig(() => config);
}
updateAltchaCaptchaConfig(
update: (current: AltchaCaptchaConfig) => AltchaCaptchaConfig,
): Promise<AltchaCaptchaConfig> {
return this.updateStoredConfig(ALTCHA_CAPTCHA_CONFIG_KEY, (raw) =>
validateStoredConfig(AltchaCaptchaConfigSchema, update(parseStoredAltchaCaptchaConfig(raw)), 'altcha captcha'),
);
}
async getProfileTimezoneConfig(): Promise<ProfileTimezoneConfig> {
const raw = await this.getConfig(PROFILE_TIMEZONE_CONFIG_KEY);
return parseStoredProfileTimezoneConfig(raw);
}
async setProfileTimezoneConfig(config: ProfileTimezoneConfig): Promise<void> {
await this.updateProfileTimezoneConfig(() => config);
}
updateProfileTimezoneConfig(
update: (current: ProfileTimezoneConfig) => ProfileTimezoneConfig,
): Promise<ProfileTimezoneConfig> {
return this.updateStoredConfig(PROFILE_TIMEZONE_CONFIG_KEY, (raw) =>
validateStoredConfig(
ProfileTimezoneConfigSchema,
update(parseStoredProfileTimezoneConfig(raw)),
'profile timezone',
),
);
}
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
@@ -1112,8 +1390,19 @@ export class InstanceConfigRepository {
}
async setExperimentDeliveryConfig(config: ExperimentDeliveryConfig): Promise<void> {
const validated = validateStoredConfig(ExperimentDeliveryConfigSchema, config, 'experiment delivery');
await this.setConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, JSON.stringify(validated));
await this.updateExperimentDeliveryConfig(() => config);
}
updateExperimentDeliveryConfig(
update: (current: ExperimentDeliveryConfig) => ExperimentDeliveryConfig,
): Promise<ExperimentDeliveryConfig> {
return this.updateStoredConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, (raw) =>
validateStoredConfig(
ExperimentDeliveryConfigSchema,
update(parseStoredExperimentDeliveryConfig(raw)),
'experiment delivery',
),
);
}
async readLimitConfigInputs(): Promise<LimitConfigInputs> {
@@ -1149,26 +1438,27 @@ export class InstanceConfigRepository {
legal?: Partial<InstanceAppPublicConfig['legal']>;
registration?: Partial<InstanceAppPublicConfig['registration']>;
}): Promise<InstanceAppPublicConfig> {
const current = await this.getAppPublicConfig();
const next = decodeAppPublicConfig({
branding: {
...current.branding,
...(config.branding ?? {}),
},
setup: {
...current.setup,
...(config.setup ?? {}),
},
legal: {
...current.legal,
...(config.legal ?? {}),
},
registration: {
...current.registration,
...(config.registration ?? {}),
},
const next = await this.updateStoredConfig(APP_PUBLIC_CONFIG_KEY, (raw) => {
const current = parseStoredAppPublicConfig(raw);
return decodeAppPublicConfig({
branding: {
...current.branding,
...(config.branding ?? {}),
},
setup: {
...current.setup,
...(config.setup ?? {}),
},
legal: {
...current.legal,
...(config.legal ?? {}),
},
registration: {
...current.registration,
...(config.registration ?? {}),
},
});
});
await this.setConfig(APP_PUBLIC_CONFIG_KEY, JSON.stringify(next));
setCachedDateOfBirthCollection(next.registration.collect_date_of_birth);
return next;
}
@@ -1188,10 +1478,21 @@ export class InstanceConfigRepository {
return parseStoredInstancePolicyConfig(raw);
}
async setInstancePolicyConfig(config: Partial<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
const current = await this.readStoredInstancePolicyConfig();
const next = decodeInstancePolicyConfig({...current, ...config});
await this.setConfig(INSTANCE_POLICY_CONFIG_KEY, JSON.stringify(next));
setInstancePolicyConfig(config: Partial<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
return this.updateInstancePolicyConfig(() => config);
}
async updateInstancePolicyConfig(
plan: (current: InstancePolicyConfig) => Partial<InstancePolicyConfig>,
): Promise<InstancePolicyConfig> {
const cache = this.configCache;
const {result: next, written} = await this.compareAndSetStoredValue(cache, INSTANCE_POLICY_CONFIG_KEY, (raw) => {
const current = parseStoredInstancePolicyConfig(raw);
const patch = plan(current);
const config = decodeInstancePolicyConfig({...current, ...patch});
return {value: Object.keys(patch).length === 0 ? null : JSON.stringify(config), result: config};
});
if (written) await this.publishRefresh(cache.sourceId);
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(next));
return next;
}
@@ -1201,37 +1502,37 @@ export class InstanceConfigRepository {
return parseStoredInstanceIntegrationsConfig(raw);
}
async setInstanceIntegrationsConfig(config: InstanceIntegrationsConfigPatch): Promise<InstanceIntegrationsConfig> {
const current = await this.getInstanceIntegrationsConfig();
const next = decodeInstanceIntegrationsConfig({
gif: {
...current.gif,
...(config.gif ?? {}),
},
youtube: {
...current.youtube,
...(config.youtube ?? {}),
},
captcha: {
...current.captcha,
...(config.captcha ?? {}),
},
email: {
...current.email,
...(config.email ?? {}),
smtp: {
...current.email.smtp,
...(config.email?.smtp ?? {}),
setInstanceIntegrationsConfig(config: InstanceIntegrationsConfigPatch): Promise<InstanceIntegrationsConfig> {
return this.updateStoredConfig(INSTANCE_INTEGRATIONS_CONFIG_KEY, (raw) => {
const current = parseStoredInstanceIntegrationsConfig(raw);
return decodeInstanceIntegrationsConfig({
gif: {
...current.gif,
...(config.gif ?? {}),
},
},
bluesky: {
...current.bluesky,
...(config.bluesky ?? {}),
keys: config.bluesky?.keys ?? current.bluesky.keys,
},
youtube: {
...current.youtube,
...(config.youtube ?? {}),
},
captcha: {
...current.captcha,
...(config.captcha ?? {}),
},
email: {
...current.email,
...(config.email ?? {}),
smtp: {
...current.email.smtp,
...(config.email?.smtp ?? {}),
},
},
bluesky: {
...current.bluesky,
...(config.bluesky ?? {}),
keys: config.bluesky?.keys ?? current.bluesky.keys,
},
});
});
await this.setConfig(INSTANCE_INTEGRATIONS_CONFIG_KEY, JSON.stringify(next));
return next;
}
async getInstanceMediaConfig(): Promise<InstanceMediaConfig> {
@@ -1239,16 +1540,16 @@ export class InstanceConfigRepository {
return parseStoredInstanceMediaConfig(raw);
}
async setInstanceMediaConfig(config: InstanceMediaConfigPatch): Promise<InstanceMediaConfig> {
const current = await this.getInstanceMediaConfig();
const next = decodeInstanceMediaConfig({
attachment_decay: {
...current.attachment_decay,
...(config.attachment_decay ?? {}),
},
setInstanceMediaConfig(config: InstanceMediaConfigPatch): Promise<InstanceMediaConfig> {
return this.updateStoredConfig(INSTANCE_MEDIA_CONFIG_KEY, (raw) => {
const current = parseStoredInstanceMediaConfig(raw);
return decodeInstanceMediaConfig({
attachment_decay: {
...current.attachment_decay,
...(config.attachment_decay ?? {}),
},
});
});
await this.setConfig(INSTANCE_MEDIA_CONFIG_KEY, JSON.stringify(next));
return next;
}
async getEffectiveAttachmentDecayConfig(): Promise<InstanceAttachmentDecayEffectiveConfig> {
@@ -1485,15 +1786,15 @@ export class InstanceConfigRepository {
return parseStoredRegistrationConfig(raw);
}
async setRegistrationConfig(config: Partial<InstanceRegistrationConfig>): Promise<InstanceRegistrationConfig> {
const current = await this.getRegistrationConfig();
const next = decodeRegistrationConfig({
mode: config.mode ?? current.mode,
admin_registration_urls_enabled:
config.admin_registration_urls_enabled ?? current.admin_registration_urls_enabled,
setRegistrationConfig(config: Partial<InstanceRegistrationConfig>): Promise<InstanceRegistrationConfig> {
return this.updateStoredConfig(REGISTRATION_CONFIG_KEY, (raw) => {
const current = parseStoredRegistrationConfig(raw);
return decodeRegistrationConfig({
mode: config.mode ?? current.mode,
admin_registration_urls_enabled:
config.admin_registration_urls_enabled ?? current.admin_registration_urls_enabled,
});
});
await this.setConfig(REGISTRATION_CONFIG_KEY, JSON.stringify(next));
return next;
}
async getRegistrationPublicConfig(): Promise<InstanceRegistrationConfig> {
@@ -1534,16 +1835,20 @@ export class InstanceConfigRepository {
last_used_at: null,
last_used_by_user_id: null,
};
const registrationUrls = await this.getRegistrationUrls();
await this.setRegistrationUrls([registrationUrl, ...registrationUrls]);
await this.updateStoredConfig(REGISTRATION_URLS_KEY, (raw) =>
validateStoredCollection(
StoredRegistrationUrlSchema,
[registrationUrl, ...parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs')],
'registration URLs',
),
);
return {registrationUrl: redactRegistrationUrl(registrationUrl), code};
}
async revokeRegistrationUrl(id: string): Promise<void> {
const now = new Date().toISOString();
const registrationUrls = await this.getRegistrationUrls();
await this.setRegistrationUrls(
registrationUrls.map((registrationUrl) =>
await this.updateStoredConfig(REGISTRATION_URLS_KEY, (raw) =>
parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs').map((registrationUrl) =>
registrationUrl.id === id && !registrationUrl.revoked_at
? {...registrationUrl, revoked_at: now}
: registrationUrl,
@@ -1556,9 +1861,8 @@ export class InstanceConfigRepository {
if (!normalizedCode) return null;
const hash = this.hashRegistrationUrlCode(normalizedCode);
const now = new Date();
const registrationUrls = await this.getRegistrationUrls();
return (
registrationUrls.find(
(await this.fetchRegistrationUrlDefinitions()).find(
(registrationUrl) =>
(registrationUrl.id === normalizedCode || registrationUrl.code_hash === hash) &&
isRegistrationUrlUsable(registrationUrl, now),
@@ -1566,21 +1870,68 @@ export class InstanceConfigRepository {
);
}
async recordRegistrationUrlUse(id: string, userId: string): Promise<void> {
const now = new Date().toISOString();
const registrationUrls = await this.getRegistrationUrls();
await this.setRegistrationUrls(
registrationUrls.map((registrationUrl) =>
registrationUrl.id === id
? {
...registrationUrl,
use_count: registrationUrl.use_count + 1,
last_used_at: now,
last_used_by_user_id: userId,
}
: registrationUrl,
),
);
async claimRegistrationUrlUse(registrationUrlId: string, userId: string): Promise<RegistrationUrlClaim | null> {
const cache = this.configCache;
let claimed: {result: RegistrationUrlClaim | null; written: boolean};
try {
claimed = await this.compareAndSetStoredValue<RegistrationUrlClaim | null>(
cache,
REGISTRATION_URLS_KEY,
(raw) => {
const registrationUrls = parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs');
const now = new Date();
const claimable = registrationUrls.find(
(registrationUrl) =>
registrationUrl.id === registrationUrlId && isRegistrationUrlUsable(registrationUrl, now),
);
if (!claimable) return {value: null, result: null};
const next = registrationUrls.map((registrationUrl) =>
registrationUrl === claimable
? {
...registrationUrl,
use_count: registrationUrl.use_count + 1,
last_used_at: now.toISOString(),
last_used_by_user_id: userId,
}
: registrationUrl,
);
return {
value: JSON.stringify(validateStoredCollection(StoredRegistrationUrlSchema, next, 'registration URLs')),
result: {registration_url_id: registrationUrlId, user_id: userId},
};
},
);
} catch (error) {
if (error instanceof InstanceConfigWriteConflictError) throw new ServiceUnavailableError();
throw error;
}
if (claimed.written) await this.publishRefresh(cache.sourceId);
return claimed.result;
}
async releaseRegistrationUrlUse(claim: RegistrationUrlClaim): Promise<void> {
const cache = this.configCache;
try {
const {written} = await this.compareAndSetStoredValue<null>(cache, REGISTRATION_URLS_KEY, (raw) => {
const registrationUrls = parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs');
const released = registrationUrls.find(
(registrationUrl) => registrationUrl.id === claim.registration_url_id && registrationUrl.use_count > 0,
);
if (!released) return {value: null, result: null};
const next = registrationUrls.map((registrationUrl) =>
registrationUrl === released
? {...registrationUrl, use_count: registrationUrl.use_count - 1}
: registrationUrl,
);
return {value: JSON.stringify(next), result: null};
});
if (written) await this.publishRefresh(cache.sourceId);
} catch (error) {
Logger.warn(
{registrationUrlId: claim.registration_url_id, userId: claim.user_id, error},
'Releasing a registration URL use failed',
);
}
}
async getPendingRegistrations(): Promise<Array<InstancePendingRegistration>> {
@@ -1591,104 +1942,82 @@ export class InstanceConfigRepository {
}
async addPendingRegistration(pendingRegistration: InstancePendingRegistration): Promise<void> {
const pendingRegistrations = await this.getPendingRegistrations();
const next = [
pendingRegistration,
...pendingRegistrations.filter((entry) => entry.user_id !== pendingRegistration.user_id),
];
await this.setPendingRegistrations(next);
await this.updateStoredConfig(REGISTRATION_PENDING_APPROVALS_KEY, (raw) =>
validateStoredCollection(
StoredPendingRegistrationSchema,
[
pendingRegistration,
...parseStoredCollection(StoredPendingRegistrationSchema, raw, 'pending registrations').filter(
(entry) => entry.user_id !== pendingRegistration.user_id,
),
],
'pending registrations',
),
);
}
async removePendingRegistration(userId: string): Promise<void> {
const pendingRegistrations = await this.getPendingRegistrations();
await this.setPendingRegistrations(pendingRegistrations.filter((entry) => entry.user_id !== userId));
await this.updateStoredConfig(REGISTRATION_PENDING_APPROVALS_KEY, (raw) =>
parseStoredCollection(StoredPendingRegistrationSchema, raw, 'pending registrations').filter(
(entry) => entry.user_id !== userId,
),
);
}
async getSsoConfig(options?: {includeSecret?: boolean}): Promise<InstanceSsoConfig> {
const configs = await this.getAllConfigs();
const flags = readStoredSsoFlags(configs);
const read = (key: string): string | null => {
const v = configs.get(key);
if (!v) return null;
const trimmed = v.trim();
return trimmed.length === 0 ? null : trimmed;
};
const allowedDomains = parseStoredSsoAllowedEmailDomains(configs.get('sso_allowed_domains'));
const clientSecret = read('sso_client_secret');
return {
...flags,
displayName: read('sso_display_name'),
issuer: read('sso_issuer'),
authorizationUrl: read('sso_authorization_url'),
tokenUrl: read('sso_token_url'),
userInfoUrl: read('sso_userinfo_url'),
jwksUrl: read('sso_jwks_url'),
clientId: read('sso_client_id'),
clientSecret: options?.includeSecret ? clientSecret : undefined,
clientSecretSet: Boolean(clientSecret),
scope: read('sso_scope'),
allowedEmailDomains: allowedDomains,
redirectUri: null,
};
return readStoredSsoConfig(await this.getAllConfigs(), options);
}
async setSsoConfig(config: Partial<InstanceSsoConfig>): Promise<InstanceSsoConfig> {
const current = await this.getSsoConfig({includeSecret: true});
const definedConfig = Object.fromEntries(
Object.entries(config).filter(([, value]) => value !== undefined),
) as Partial<InstanceSsoConfig>;
const next: InstanceSsoConfig = {
...current,
...definedConfig,
clientSecret: config.clientSecret !== undefined ? config.clientSecret : current.clientSecret,
};
if (config.enabled === true && config.enforced === undefined && !current.enabled) {
next.enforced = true;
}
let allowedEmailDomains: Array<string>;
try {
allowedEmailDomains = normalizeSsoAllowedEmailDomains(next.allowedEmailDomains);
} catch (error) {
if (next.enabled) {
throw error;
}
Logger.warn({error}, 'Clearing invalid SSO allowed domain config while SSO is disabled');
allowedEmailDomains = [];
}
const entries: Array<[string, string]> = [
['sso_enabled', next.enabled ? 'true' : 'false'],
['sso_enforced', next.enforced ? 'true' : 'false'],
['sso_display_name', next.displayName ?? ''],
['sso_issuer', next.issuer ?? ''],
['sso_authorization_url', next.authorizationUrl ?? ''],
['sso_token_url', next.tokenUrl ?? ''],
['sso_userinfo_url', next.userInfoUrl ?? ''],
['sso_jwks_url', next.jwksUrl ?? ''],
['sso_client_id', next.clientId ?? ''],
['sso_scope', next.scope ?? ''],
['sso_allowed_domains', JSON.stringify(allowedEmailDomains)],
['sso_auto_provision', next.autoProvision ? 'true' : 'false'],
['sso_redirect_uri', ''],
const configs = await this.getAllConfigs();
const current = readStoredSsoConfig(configs, {includeSecret: true});
const enabled = config.enabled ?? current.enabled;
const allowedEmailDomains =
config.allowedEmailDomains === undefined
? undefined
: normalizeSsoAllowedEmailDomainsForWrite(config.allowedEmailDomains, enabled);
const rows: Array<SsoRowWrite> = [
ssoRow('sso_enabled', config.enabled, current.enabled, formatSsoBoolean),
ssoRow('sso_enforced', config.enforced, current.enforced, formatSsoBoolean),
ssoRow('sso_display_name', config.displayName, current.displayName, formatSsoString),
ssoRow('sso_issuer', config.issuer, current.issuer, formatSsoString),
ssoRow('sso_authorization_url', config.authorizationUrl, current.authorizationUrl, formatSsoString),
ssoRow('sso_token_url', config.tokenUrl, current.tokenUrl, formatSsoString),
ssoRow('sso_userinfo_url', config.userInfoUrl, current.userInfoUrl, formatSsoString),
ssoRow('sso_jwks_url', config.jwksUrl, current.jwksUrl, formatSsoString),
ssoRow('sso_client_id', config.clientId, current.clientId, formatSsoString),
ssoRow('sso_scope', config.scope, current.scope, formatSsoString),
ssoRow('sso_allowed_domains', allowedEmailDomains, current.allowedEmailDomains, formatSsoDomains),
ssoRow('sso_auto_provision', config.autoProvision, current.autoProvision, formatSsoBoolean),
ssoRow('sso_redirect_uri', undefined, null, formatSsoString),
];
if (config.clientSecret !== undefined) {
entries.push(['sso_client_secret', config.clientSecret ?? '']);
rows.push(ssoRow('sso_client_secret', config.clientSecret, current.clientSecret ?? null, formatSsoString));
}
await this.setConfigs(entries);
const cache = this.configCache;
const results = await Promise.allSettled(
rows
.filter((row) => row.value !== undefined || !configs.has(row.key))
.map((row) =>
this.compareAndSetStoredValue(cache, row.key, (raw) => ({value: nextSsoRowValue(row, raw), result: null})),
),
);
const errors: Array<unknown> = results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : []));
if (results.some((result) => result.status === 'fulfilled' && result.value.written)) {
try {
await this.publishRefresh(cache.sourceId);
} catch (error) {
errors.push(error);
}
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, 'Failed to write or publish the SSO config');
return this.getSsoConfig({includeSecret: true});
}
private async setRegistrationUrls(registrationUrls: Array<InstanceRegistrationUrl>): Promise<void> {
const validated = validateStoredCollection(StoredRegistrationUrlSchema, registrationUrls, 'registration URLs');
await this.setConfig(REGISTRATION_URLS_KEY, JSON.stringify(validated));
}
private async setPendingRegistrations(pendingRegistrations: Array<InstancePendingRegistration>): Promise<void> {
const validated = validateStoredCollection(
StoredPendingRegistrationSchema,
pendingRegistrations,
'pending registrations',
);
await this.setConfig(REGISTRATION_PENDING_APPROVALS_KEY, JSON.stringify(validated));
private async fetchRegistrationUrlDefinitions(): Promise<Array<InstanceRegistrationUrl>> {
const raw = await this.fetchConfigFromDatabase(REGISTRATION_URLS_KEY);
return parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs');
}
private hashRegistrationUrlCode(code: string): string {
@@ -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());
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
const textEncoder = new TextEncoder();
const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
interface PushServiceDeliveryConfigNatsMessage {
type: 'push_service_delivery_config';
config: LegacyPushServiceDeliveryWire;
}
export class PushRelayConfigPublisher {
constructor(private readonly connectionManager: INatsConnectionManager) {}
async publish(config: LegacyPushServiceDeliveryWire): Promise<void> {
if (this.connectionManager.isClosed()) {
await this.connectionManager.connect();
}
const connection = this.connectionManager.getConnection();
const message: PushServiceDeliveryConfigNatsMessage = {
type: 'push_service_delivery_config',
config,
};
connection.publish(PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT, textEncoder.encode(JSON.stringify(message)));
await connection.flush();
}
}
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getKvMeta} from '@app/api/database/CassandraMetaRegistry';
import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import type {CassandraParams, KvQueryMeta, PreparedQuery} from '@app/api/database/CassandraTypes';
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
import {InstanceConfiguration} from '@app/api/Tables';
type InstanceConfigWriteEvent = 'read' | 'write' | 'write rejected';
interface WriteGate {
size: number;
paused: Array<() => void>;
}
const FETCH_ROW_QUERY = InstanceConfiguration.selectCql({
where: InstanceConfiguration.where.eq('key'),
limit: 1,
});
export class InstanceConfigWriteRaceExecutor implements CassandraQueryExecutorForTesting {
readonly events: Array<InstanceConfigWriteEvent> = [];
private watchedKey: string | null = null;
private gate: WriteGate | null = null;
private beforeEachWrite: (() => Promise<void>) | null = null;
constructor(private readonly base: CassandraQueryExecutorForTesting) {}
watch(key: string): void {
this.watchedKey = key;
this.events.length = 0;
}
pauseWritesUntil(size: number): void {
this.gate = {size, paused: []};
}
competeBeforeEachWrite(write: () => Promise<void>): void {
this.beforeEachWrite = write;
}
async writeDirectly(key: string, value: string): Promise<void> {
await this.base.executeQuery(InstanceConfiguration.upsertAll({key, value, updated_at: new Date()}));
}
async readDirectly(key: string): Promise<string | null> {
const [row] = await this.base.executeQuery<InstanceConfigurationRow>({cql: FETCH_ROW_QUERY, params: {key}});
return row?.value ?? null;
}
async executeQuery<T = Record<string, unknown>, P extends CassandraParams = CassandraParams>(
query: PreparedQuery<P>,
): Promise<Array<T>> {
const meta = query.kvMeta ?? getKvMeta(query.cql);
if (this.watchedKey === null || !this.isWatched(meta, query.params)) {
return this.base.executeQuery<T, P>(query);
}
if (meta?.action === 'select') {
this.events.push('read');
return this.base.executeQuery<T, P>(query);
}
await this.passGate();
await this.beforeEachWrite?.();
const rows = await this.base.executeQuery<T, P>(query);
const applied = (rows[0] as {'[applied]'?: unknown} | undefined)?.['[applied]'];
this.events.push(applied === false ? 'write rejected' : 'write');
return rows;
}
executeBatch(queries: Array<{query: string; params: object; meta?: KvQueryMeta}>, atomic?: boolean): Promise<void> {
return this.base.executeBatch(queries, atomic);
}
reset(): void {
this.base.reset?.();
this.watchedKey = null;
this.gate = null;
this.beforeEachWrite = null;
this.events.length = 0;
}
async shutdown(): Promise<void> {
await this.base.shutdown?.();
}
private isWatched(meta: KvQueryMeta | null | undefined, params: CassandraParams): boolean {
return meta?.table.name === InstanceConfiguration.name && params.key === this.watchedKey;
}
private async passGate(): Promise<void> {
const gate = this.gate;
if (gate === null) return;
await new Promise<void>((release) => {
gate.paused.push(release);
if (gate.paused.length < gate.size) return;
this.gate = null;
for (const release of gate.paused) release();
});
}
}
@@ -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,6 +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 {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';
@@ -155,6 +156,18 @@ export const getGatewayRolloutConfigPublisher = singleton(
}),
),
);
export const getPushRelayConfigPublisher = singleton(
() =>
new PushRelayConfigPublisher(
new NatsConnectionManager({
url: Config.nats.coreUrl,
token: Config.nats.authToken || undefined,
name: 'fluxer-api-push-relay-config',
}),
),
);
export const getVisionarySlotRepository = singleton(() => new VisionarySlotRepository());
export const getCacheService: () => ICacheService = singleton(() => new KVCacheProvider({client: getKVClient()}));
export const getRateLimitService = singleton(() => new RateLimitService(getKVClient()));
@@ -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);
});
});
+12 -6
View File
@@ -3,6 +3,7 @@
import {createHash} from 'node:crypto';
import type {ApiContext} from '@app/api/ApiContext';
import * as AuthSession from '@app/api/auth/AuthSession';
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
import {
createChannelID,
@@ -75,6 +76,7 @@ import {
mapUserGuildSettingsToResponse,
mapUserSettingsToResponse,
mapUserToPrivateResponse,
mapWebAuthnCredentialToResponse,
} from '@app/api/user/UserMappers';
import {isUserAdult} from '@app/api/utils/AgeUtils';
import {deriveDominantAvatarColor} from '@app/api/utils/AvatarColorUtils';
@@ -635,6 +637,13 @@ export class RpcService {
data: {config: rolloutConfig},
};
}
case 'get_push_service_delivery_config': {
const config = await this.instanceConfigRepository.getLegacyPushServiceDeliveryWire();
return {
type: 'get_push_service_delivery_config',
data: {config},
};
}
default: {
const exhaustiveCheck: never = request;
throw new Error(
@@ -1184,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 {

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