mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c488906131 | ||
|
|
39c72f0fb0 | ||
|
|
3736d94d73 | ||
|
|
192cec689a | ||
|
|
e895c41bf0 | ||
|
|
997d98c65c | ||
|
|
c9ae5b6ee8 | ||
|
|
a728be4062 | ||
|
|
fce81367fb | ||
|
|
5a4edc0b59 | ||
|
|
713ae5f7f5 | ||
|
|
eaee820216 | ||
|
|
564c5ae164 | ||
|
|
dd8ed6f205 | ||
|
|
ed8c412415 | ||
|
|
12417a6942 | ||
|
|
d05f6c9aaa | ||
|
|
0ca035c547 | ||
|
|
dfd46ccc2c | ||
|
|
f6df3169ca | ||
|
|
5b280898c5 | ||
|
|
2a9e25c788 | ||
|
|
463c03fb6d | ||
|
|
153dad11e1 | ||
|
|
e2d05a44a8 | ||
|
|
30ba55bd4d | ||
|
|
9def9fbef6 | ||
|
|
fa3fd0027c | ||
|
|
7e1b934637 | ||
|
|
33a118d12a | ||
|
|
01f53a168d | ||
|
|
336b8b7dcd | ||
|
|
48d0034239 | ||
|
|
677ef8491e | ||
|
|
6a6119ed1e | ||
|
|
931327d1dc | ||
|
|
858a2d9e2b | ||
|
|
841fb7af41 | ||
|
|
08e65d41c0 | ||
|
|
f76c4dc041 | ||
|
|
f1f8ba2031 | ||
|
|
5ab8d745c0 | ||
|
|
ff62bc89a4 | ||
|
|
838bbdb5ec | ||
|
|
1c36a59b2c | ||
|
|
6730a242db | ||
|
|
e62ae77643 | ||
|
|
f4f39e6a89 | ||
|
|
00bf74cef5 | ||
|
|
c1c45d835f | ||
|
|
bbfe809bef | ||
|
|
e0843ac4f5 | ||
|
|
43741cdad8 | ||
|
|
b8e3807262 | ||
|
|
3304f01a84 | ||
|
|
2ba463235b | ||
|
|
15136fed59 | ||
|
|
6013581dd9 | ||
|
|
7a91f128e9 | ||
|
|
963ffc5550 | ||
|
|
a90991612c | ||
|
|
50ad23b760 | ||
|
|
425dab983b | ||
|
|
a0825e77c4 | ||
|
|
88038a1d5b | ||
|
|
c2c0fdb445 | ||
|
|
dcd5f09d6a | ||
|
|
590b1f36fd | ||
|
|
168ac727f1 | ||
|
|
deb86dd92e | ||
|
|
7ccec4d3b8 | ||
|
|
2f38bcdf26 | ||
|
|
f2785941aa | ||
|
|
ea9f83a443 | ||
|
|
bd6ca7290e | ||
|
|
b85e975fb5 | ||
|
|
b6e504f68c | ||
|
|
5fde6eb484 | ||
|
|
b16989d567 | ||
|
|
c9754ac11a | ||
|
|
f34e4a5115 | ||
|
|
44b3615298 | ||
|
|
211e98307d | ||
|
|
18c303abf6 | ||
|
|
7021a58090 | ||
|
|
320725a587 | ||
|
|
8450edc072 | ||
|
|
a1e2bf2c8d | ||
|
|
82b2f4ec5e | ||
|
|
c92e5d03a7 | ||
|
|
91340c5c84 | ||
|
|
045dd5d027 | ||
|
|
a21b9c4659 | ||
|
|
4b1b869802 | ||
|
|
1ab7e7dfcc | ||
|
|
31c53d2dff | ||
|
|
412a1ae79d | ||
|
|
0b2306ec3d | ||
|
|
242ed3a934 | ||
|
|
70e1ce682a |
@@ -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/
|
||||
|
||||
@@ -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/**/*
|
||||
|
||||
@@ -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'] }}
|
||||
@@ -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/**') }}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -7,6 +7,7 @@ members = [
|
||||
"fluxer_gifs",
|
||||
"fluxer_svc",
|
||||
"fluxer_messages",
|
||||
"fluxer_push",
|
||||
"fluxer_snowflakes",
|
||||
"tools/ci",
|
||||
"tools/dev",
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
<img src="https://img.shields.io/badge/License-AGPLv3-purple" alt="AGPLv3 License" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://flathub.org/apps/app.fluxer.Fluxer">
|
||||
<img src="https://dl.flathub.org/assets/badges/flathub-badge-en.svg" alt="Get it on Flathub" height="60" /></a>
|
||||
</p>
|
||||
|
||||
# Fluxer
|
||||
|
||||
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
|
||||
@@ -28,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] | [Flatpak][flatpak-ref] | [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] | | |
|
||||
@@ -38,17 +43,23 @@ Fluxer is a free and open source instant messaging and VoIP chat app built for f
|
||||
| | | [tar.gz (x64)][linux-targz-x64] | | |
|
||||
| | | [tar.gz (ARM64)][linux-targz-arm64] | | |
|
||||
|
||||
The macOS disk image is universal and runs on both Apple silicon and Intel. Windows and Linux need the build that matches your processor.
|
||||
The macOS disk image runs on both Apple silicon and Intel. Windows and Linux need the build matching your processor.
|
||||
|
||||
On Linux, prefer a package repository over a file. Fluxer then updates with the rest of your system.
|
||||
On Linux, prefer a repository over a single file so Fluxer updates with the rest of your system.
|
||||
|
||||
## Linux package repositories
|
||||
|
||||
All four repositories serve stable and canary. The package is `fluxer` for stable and `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
|
||||
|
||||
Opening [this reference file][flatpak-ref] hands the install to your desktop software manager. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` pasted into the address bar.
|
||||
Stable is on [Flathub][flathub], the easiest route on most desktops:
|
||||
|
||||
```sh
|
||||
flatpak install flathub app.fluxer.Fluxer
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
@@ -65,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
|
||||
@@ -72,11 +92,18 @@ sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/flux
|
||||
sudo dnf install fluxer
|
||||
```
|
||||
|
||||
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because the base repositories do not ship `libXScrnSaver`. Fedora does not need this.
|
||||
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
|
||||
|
||||
The repository is signed, so pacman needs the key in its own keyring once:
|
||||
The repository is signed, so pacman needs the key once:
|
||||
|
||||
```sh
|
||||
sudo pacman-key --init
|
||||
@@ -85,7 +112,7 @@ sudo pacman-key --add /tmp/fluxer-archive-keyring.asc
|
||||
sudo pacman-key --lsign-key 09D01339EE128925F75E675C855C5BDE34D205D2
|
||||
```
|
||||
|
||||
`--lsign-key` is the step that makes pacman trust the key. Then add the repository:
|
||||
`--lsign-key` is what makes pacman trust it. Then add the repository:
|
||||
|
||||
```sh
|
||||
sudo tee -a /etc/pacman.conf >/dev/null <<'REPO'
|
||||
@@ -97,13 +124,13 @@ REPO
|
||||
sudo pacman -Syu fluxer
|
||||
```
|
||||
|
||||
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, which is why the heredoc above is quoted.
|
||||
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, hence the quoted heredoc.
|
||||
|
||||
Full setup notes, including the canary channel, live in the [Linux repositories documentation][docs-linux].
|
||||
Full setup notes, including canary, are in the [Linux repositories documentation][docs-linux].
|
||||
|
||||
## Other ways to run it
|
||||
|
||||
- [Open Fluxer in a browser](https://web.fluxer.app) with no install at all.
|
||||
- [Open Fluxer in a browser](https://web.fluxer.app), no install needed.
|
||||
- [Host your own instance][docs-selfhost] from this repository.
|
||||
|
||||
## Documentation
|
||||
@@ -117,9 +144,9 @@ Full setup notes, including the canary channel, live in the [Linux repositories
|
||||
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
|
||||
|
||||
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
|
||||
imagery are copyright Fluxer and all rights reserved, as set out in
|
||||
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its
|
||||
own terms, listed in
|
||||
imagery are copyright Fluxer, all rights reserved, as set out in
|
||||
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its own
|
||||
terms, listed in
|
||||
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
|
||||
|
||||
Public availability of this repository does not grant trademark, brand, or
|
||||
@@ -139,6 +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
|
||||
|
||||
@@ -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
|
||||
@@ -281,6 +288,7 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_POSTGRES_WORK_MEM=8MB
|
||||
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
|
||||
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
|
||||
#FLUXER_POSTGRES_SHM_SIZE=1gb
|
||||
|
||||
# The bundled Valkey holds durable state as well as cache, so it runs with an
|
||||
# append-only file and with noeviction, which fails an over-limit write instead
|
||||
|
||||
@@ -188,7 +188,7 @@ services:
|
||||
-c autovacuum_vacuum_cost_limit=2000
|
||||
-c track_io_timing=on
|
||||
-c shared_preload_libraries=pg_stat_statements
|
||||
shm_size: 256mb
|
||||
shm_size: ${FLUXER_POSTGRES_SHM_SIZE:-1gb}
|
||||
environment:
|
||||
POSTGRES_DB: fluxer
|
||||
POSTGRES_USER: fluxer
|
||||
@@ -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}
|
||||
|
||||
+299
-79
@@ -2743,7 +2743,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Create one-use Plutonium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. Not available on self-hosted instances. Requires GIFT_CODES_GENERATE permission.",
|
||||
"description": "Create one-use premium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. On self-hosted instances the premium mode must be mirror. Requires GIFT_CODES_GENERATE permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -10523,8 +10523,10 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"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",
|
||||
@@ -10636,7 +10638,9 @@
|
||||
"favicon_url": {"nullable": true, "type": "string"},
|
||||
"theme_color": {"nullable": true, "type": "string"},
|
||||
"status_page_url": {"nullable": true, "type": "string"},
|
||||
"status_page_incident_history_url": {"nullable": true, "type": "string"}
|
||||
"status_page_incident_history_url": {"nullable": true, "type": "string"},
|
||||
"premium_product_name": {"type": "string"},
|
||||
"premium_info_url": {"nullable": true, "type": "string"}
|
||||
},
|
||||
"required": [
|
||||
"product_name",
|
||||
@@ -10647,7 +10651,9 @@
|
||||
"favicon_url",
|
||||
"theme_color",
|
||||
"status_page_url",
|
||||
"status_page_incident_history_url"
|
||||
"status_page_incident_history_url",
|
||||
"premium_product_name",
|
||||
"premium_info_url"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -10946,20 +10952,24 @@
|
||||
},
|
||||
"required": ["attachment_decay"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"billing": {"$ref": "#/components/schemas/InstanceBillingResponse"}
|
||||
},
|
||||
"required": [
|
||||
"sso",
|
||||
"gateway_rollout",
|
||||
"voice_noise_suppression",
|
||||
"screen_share_delivery",
|
||||
"push_relay",
|
||||
"domain_migration",
|
||||
"altcha_captcha",
|
||||
"profile_timezone",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
"app_public",
|
||||
"policy",
|
||||
"integrations",
|
||||
"media"
|
||||
"media",
|
||||
"billing"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -11083,13 +11093,18 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/GatewayRolloutConfigUpdateRequest"}]
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"push_relay": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PushRelayConfigUpdateRequest"}]},
|
||||
"domain_migration": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
|
||||
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
|
||||
},
|
||||
"screen_share_delivery": {
|
||||
"altcha_captcha": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ScreenShareDeliveryConfigUpdateRequest"}]
|
||||
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
|
||||
},
|
||||
"profile_timezone": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ProfileTimezoneConfigUpdateRequest"}]
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
@@ -11138,7 +11153,9 @@
|
||||
"favicon_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"theme_color": {"nullable": true, "type": "string", "maxLength": 64},
|
||||
"status_page_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048}
|
||||
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"premium_product_name": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 40},
|
||||
"premium_info_url": {"nullable": true, "type": "string", "maxLength": 2048}
|
||||
}
|
||||
},
|
||||
"setup": {"nullable": true, "type": "object", "properties": {"configured": {"type": "boolean"}}},
|
||||
@@ -11306,7 +11323,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"billing": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/InstanceBillingUpdateRequest"}]}
|
||||
}
|
||||
},
|
||||
"ListGuildStickersResponse": {
|
||||
@@ -15165,6 +15183,28 @@
|
||||
{"name": "BANNER_UNSET", "value": "2", "description": "Guild member banner is unset"}
|
||||
]
|
||||
},
|
||||
"InstanceBillingUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"nullable": true, "type": "boolean"},
|
||||
"stripe_secret_key": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4096},
|
||||
"stripe_webhook_secret": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4096},
|
||||
"automatic_tax": {"nullable": true, "type": "boolean"},
|
||||
"tax_id_collection": {"nullable": true, "type": "boolean"},
|
||||
"terms_consent_required": {"nullable": true, "type": "boolean"},
|
||||
"default_currency": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PremiumCurrency"}]},
|
||||
"prices": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"additionalProperties": {"$ref": "#/components/schemas/BillingPriceSetUpdateRequest"}
|
||||
},
|
||||
"country_currencies": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/BillingCountryCurrenciesSchema"}]
|
||||
},
|
||||
"legacy_prices": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/BillingLegacyPricesSchema"}]}
|
||||
}
|
||||
},
|
||||
"InstanceCaptchaProviderSchema": {"type": "string", "enum": ["hcaptcha", "turnstile", "none"]},
|
||||
"InstanceRegistrationModeSchema": {
|
||||
"description": "Registration mode",
|
||||
@@ -15184,17 +15224,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,44 +15248,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigUpdateRequest": {
|
||||
"AltchaCaptchaConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"default_backend": {"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}]},
|
||||
"enabled_backends": {
|
||||
"maxItems": 7,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
|
||||
},
|
||||
"allow_user_override": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"guild_overrides": {
|
||||
"maxItems": 200,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guild_id": {"type": "string", "pattern": "^\\d{1,20}$"},
|
||||
"backend": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
|
||||
},
|
||||
"required": ["guild_id", "backend"]
|
||||
}
|
||||
},
|
||||
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
|
||||
"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"}}},
|
||||
"GatewayRolloutConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15254,9 +15316,84 @@
|
||||
"voice_e2ee_scope": {"type": "string", "enum": ["guild_feature_only", "platform_wide"]}
|
||||
}
|
||||
},
|
||||
"VoiceNoiseSuppressionBackendSchema": {
|
||||
"type": "string",
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
|
||||
"BillingLegacyPricesSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"maxItems": 32,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/StripePriceIdSchema"}
|
||||
}
|
||||
},
|
||||
"BillingCountryCurrenciesSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"$ref": "#/components/schemas/PremiumCurrency"}
|
||||
},
|
||||
"BillingPriceSetUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"monthly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
|
||||
"yearly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
|
||||
"gift_1_month": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
|
||||
"gift_1_year": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]}
|
||||
}
|
||||
},
|
||||
"PremiumCurrency": {"type": "string", "pattern": "^[A-Z]{3}$"},
|
||||
"StripePriceIdSchema": {"type": "string", "maxLength": 255, "pattern": "^price_[A-Za-z0-9]+$"},
|
||||
"InstanceBillingResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"nullable": true, "type": "boolean"},
|
||||
"effective_enabled": {"type": "boolean"},
|
||||
"stripe_secret_key_set": {"type": "boolean"},
|
||||
"stripe_webhook_secret_set": {"type": "boolean"},
|
||||
"stripe_secret_key_stored": {"type": "boolean"},
|
||||
"stripe_webhook_secret_stored": {"type": "boolean"},
|
||||
"automatic_tax": {"nullable": true, "type": "boolean"},
|
||||
"tax_id_collection": {"nullable": true, "type": "boolean"},
|
||||
"terms_consent_required": {"nullable": true, "type": "boolean"},
|
||||
"effective_automatic_tax": {"type": "boolean"},
|
||||
"effective_tax_id_collection": {"type": "boolean"},
|
||||
"effective_terms_consent_required": {"type": "boolean"},
|
||||
"default_currency": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PremiumCurrency"}]},
|
||||
"prices": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"additionalProperties": {"$ref": "#/components/schemas/BillingPriceSetResponse"}
|
||||
},
|
||||
"country_currencies": {"nullable": true, "type": "object", "additionalProperties": {"type": "string"}},
|
||||
"legacy_prices": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "array", "items": {"type": "string"}}
|
||||
},
|
||||
"billing_active": {"type": "boolean"},
|
||||
"stripe_serviceable": {"type": "boolean"},
|
||||
"catalog_mode": {"$ref": "#/components/schemas/BillingCatalogModeSchema"},
|
||||
"webhook_url": {"type": "string"}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"effective_enabled",
|
||||
"stripe_secret_key_set",
|
||||
"stripe_webhook_secret_set",
|
||||
"stripe_secret_key_stored",
|
||||
"stripe_webhook_secret_stored",
|
||||
"automatic_tax",
|
||||
"tax_id_collection",
|
||||
"terms_consent_required",
|
||||
"effective_automatic_tax",
|
||||
"effective_tax_id_collection",
|
||||
"effective_terms_consent_required",
|
||||
"default_currency",
|
||||
"prices",
|
||||
"country_currencies",
|
||||
"legacy_prices",
|
||||
"billing_active",
|
||||
"stripe_serviceable",
|
||||
"catalog_mode",
|
||||
"webhook_url"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ExperimentDeliveryConfigResponse": {
|
||||
"type": "object",
|
||||
@@ -15267,19 +15404,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,71 +15443,128 @@
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigResponse": {
|
||||
"AltchaCaptchaConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"default_backend": {
|
||||
"default": "standard",
|
||||
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}]
|
||||
},
|
||||
"enabled_backends": {
|
||||
"default": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
|
||||
"maxItems": 7,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
|
||||
},
|
||||
"allow_user_override": {"default": true, "type": "boolean"},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"default": "voice-ns-v1", "type": "string", "minLength": 1, "maxLength": 64},
|
||||
"rollout_salt": {
|
||||
"default": "altcha-captcha-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"default": false, "type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"guild_overrides": {
|
||||
"default": [],
|
||||
"maxItems": 200,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guild_id": {"type": "string", "pattern": "^\\d{1,20}$"},
|
||||
"backend": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
|
||||
},
|
||||
"required": ["guild_id", "backend"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"suppression_strength": {"default": 80, "type": "integer", "minimum": 0, "maximum": 100}
|
||||
"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",
|
||||
"default_backend",
|
||||
"enabled_backends",
|
||||
"allow_user_override",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids",
|
||||
"guild_overrides",
|
||||
"suppression_strength"
|
||||
"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
|
||||
},
|
||||
"GatewayRolloutConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15388,6 +15595,18 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BillingCatalogModeSchema": {"type": "string", "enum": ["env", "operator"]},
|
||||
"BillingPriceSetResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"monthly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
|
||||
"yearly": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
|
||||
"gift_1_month": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]},
|
||||
"gift_1_year": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/StripePriceIdSchema"}]}
|
||||
},
|
||||
"required": ["monthly", "yearly", "gift_1_month", "gift_1_year"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"JobLedgerEntrySchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15639,9 +15858,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": {
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{
|
||||
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
|
||||
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
|
||||
InstancePremiumDiscovery,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -11,6 +12,10 @@ impl AdminApiClient {
|
||||
self.get("/admin/instance/config", None).await
|
||||
}
|
||||
|
||||
pub async fn get_instance_premium_discovery(&self) -> ApiResult<InstancePremiumDiscovery> {
|
||||
self.get("/.well-known/fluxer", None).await
|
||||
}
|
||||
|
||||
pub async fn update_instance_config(
|
||||
&self,
|
||||
update: &InstanceConfigUpdateRequest,
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::{InstanceConfigResponse, PremiumMode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const BILLING_MAX_CURRENCIES: usize = 64;
|
||||
pub const BILLING_MAX_COUNTRY_CURRENCIES: usize = 300;
|
||||
pub const BILLING_MAX_LEGACY_SLOTS: usize = 256;
|
||||
pub const BILLING_MAX_LEGACY_PRICES_PER_SLOT: usize = 32;
|
||||
pub const BILLING_PRICE_SLOTS: [&str; 4] = ["monthly", "yearly", "gift_1_month", "gift_1_year"];
|
||||
pub const PREMIUM_PRODUCT_NAME_MAX_CHARS: usize = 40;
|
||||
pub const TRI_STATE_DEFAULT: &str = "default";
|
||||
pub const TRI_STATE_ON: &str = "on";
|
||||
pub const TRI_STATE_OFF: &str = "off";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BillingCatalogMode {
|
||||
#[default]
|
||||
Env,
|
||||
Operator,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct BillingPriceSet {
|
||||
pub monthly: Option<String>,
|
||||
pub yearly: Option<String>,
|
||||
pub gift_1_month: Option<String>,
|
||||
pub gift_1_year: Option<String>,
|
||||
}
|
||||
|
||||
impl BillingPriceSet {
|
||||
pub fn has_recurring_pair(&self) -> bool {
|
||||
self.monthly.is_some() && self.yearly.is_some()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.monthly.is_none()
|
||||
&& self.yearly.is_none()
|
||||
&& self.gift_1_month.is_none()
|
||||
&& self.gift_1_year.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct InstanceBillingResponse {
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub effective_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub stripe_secret_key_set: bool,
|
||||
#[serde(default)]
|
||||
pub stripe_webhook_secret_set: bool,
|
||||
#[serde(default)]
|
||||
pub stripe_secret_key_stored: bool,
|
||||
#[serde(default)]
|
||||
pub stripe_webhook_secret_stored: bool,
|
||||
pub default_currency: Option<String>,
|
||||
pub prices: Option<BTreeMap<String, BillingPriceSet>>,
|
||||
pub country_currencies: Option<BTreeMap<String, String>>,
|
||||
pub legacy_prices: Option<BTreeMap<String, Vec<String>>>,
|
||||
#[serde(default)]
|
||||
pub billing_active: bool,
|
||||
#[serde(default)]
|
||||
pub stripe_serviceable: bool,
|
||||
#[serde(default)]
|
||||
pub catalog_mode: BillingCatalogMode,
|
||||
#[serde(default)]
|
||||
pub webhook_url: String,
|
||||
pub automatic_tax: Option<bool>,
|
||||
pub tax_id_collection: Option<bool>,
|
||||
pub terms_consent_required: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub effective_automatic_tax: bool,
|
||||
#[serde(default)]
|
||||
pub effective_tax_id_collection: bool,
|
||||
#[serde(default)]
|
||||
pub effective_terms_consent_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct InstanceBillingUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<Option<bool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stripe_secret_key: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stripe_webhook_secret: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_currency: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prices: Option<Option<BTreeMap<String, BillingPriceSet>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub country_currencies: Option<Option<BTreeMap<String, String>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub legacy_prices: Option<Option<BTreeMap<String, Vec<String>>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub automatic_tax: Option<Option<bool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tax_id_collection: Option<Option<bool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub terms_consent_required: Option<Option<bool>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct InstancePremiumDiscovery {
|
||||
#[serde(default)]
|
||||
pub app_public: InstancePremiumDiscoveryAppPublic,
|
||||
#[serde(default)]
|
||||
pub features: InstancePremiumDiscoveryFeatures,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct InstancePremiumDiscoveryAppPublic {
|
||||
#[serde(default)]
|
||||
pub branding: InstancePremiumDiscoveryBranding,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct InstancePremiumDiscoveryBranding {
|
||||
pub premium_product_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct InstancePremiumDiscoveryFeatures {
|
||||
#[serde(default)]
|
||||
pub premium_enabled: bool,
|
||||
}
|
||||
|
||||
impl InstancePremiumDiscovery {
|
||||
pub fn premium_product_name(&self) -> Option<&str> {
|
||||
self.app_public
|
||||
.branding
|
||||
.premium_product_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PremiumBranding {
|
||||
pub name: Option<String>,
|
||||
pub premium_enabled: bool,
|
||||
}
|
||||
|
||||
impl PremiumBranding {
|
||||
pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self {
|
||||
Self {
|
||||
name: discovery.premium_product_name().map(str::to_owned),
|
||||
premium_enabled: discovery.features.premium_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_instance_config(config: &InstanceConfigResponse) -> Self {
|
||||
Self::from_config_parts(
|
||||
config.self_hosted,
|
||||
&config.app_public.branding.premium_product_name,
|
||||
config.policy.premium_mode,
|
||||
)
|
||||
}
|
||||
|
||||
fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self {
|
||||
let name = name.trim();
|
||||
Self {
|
||||
name: (!name.is_empty()).then(|| name.to_owned()),
|
||||
premium_enabled: !self_hosted || matches!(premium_mode, PremiumMode::Mirror),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::generated::types as generated_types;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn billing_response_round_trips_through_the_generated_contract() {
|
||||
let value = json!({
|
||||
"enabled": true,
|
||||
"effective_enabled": true,
|
||||
"stripe_secret_key_set": true,
|
||||
"stripe_webhook_secret_set": false,
|
||||
"stripe_secret_key_stored": true,
|
||||
"stripe_webhook_secret_stored": false,
|
||||
"default_currency": "GBP",
|
||||
"prices": {
|
||||
"GBP": {
|
||||
"monthly": "price_1Monthly",
|
||||
"yearly": "price_1Yearly",
|
||||
"gift_1_month": null,
|
||||
"gift_1_year": null
|
||||
}
|
||||
},
|
||||
"country_currencies": {"GB": "GBP"},
|
||||
"legacy_prices": {"monthly_GBP": ["price_1Old"]},
|
||||
"billing_active": false,
|
||||
"stripe_serviceable": false,
|
||||
"catalog_mode": "operator",
|
||||
"webhook_url": "https://api.example.com/stripe/webhook",
|
||||
"automatic_tax": null,
|
||||
"tax_id_collection": false,
|
||||
"terms_consent_required": true,
|
||||
"effective_automatic_tax": false,
|
||||
"effective_tax_id_collection": false,
|
||||
"effective_terms_consent_required": true
|
||||
});
|
||||
let generated: generated_types::InstanceBillingResponse =
|
||||
serde_json::from_value(value.clone()).expect("generated billing response");
|
||||
let ours: InstanceBillingResponse =
|
||||
serde_json::from_value(value.clone()).expect("hand-written billing response");
|
||||
assert_eq!(ours.catalog_mode, BillingCatalogMode::Operator);
|
||||
assert!(ours.stripe_secret_key_stored);
|
||||
assert_eq!(ours.automatic_tax, None);
|
||||
assert_eq!(ours.tax_id_collection, Some(false));
|
||||
assert!(ours.effective_terms_consent_required);
|
||||
assert!(ours.prices.as_ref().expect("prices")["GBP"].has_recurring_pair());
|
||||
assert_eq!(serde_json::to_value(&ours).expect("serializable"), value);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated).expect("serializable generated"),
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_billing_response_matches_the_generated_contract() {
|
||||
let value = serde_json::to_value(InstanceBillingResponse::default()).expect("serializable");
|
||||
serde_json::from_value::<generated_types::InstanceBillingResponse>(value.clone())
|
||||
.expect("generated billing response");
|
||||
assert_eq!(value["catalog_mode"], json!("env"));
|
||||
assert_eq!(value["prices"], json!(null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_update_preserves_explicit_nulls_and_omits_untouched_fields() {
|
||||
let mut prices = BTreeMap::new();
|
||||
prices.insert(
|
||||
"SEK".to_owned(),
|
||||
BillingPriceSet {
|
||||
monthly: Some("price_1Monthly".to_owned()),
|
||||
yearly: Some("price_1Yearly".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let update = InstanceBillingUpdateRequest {
|
||||
enabled: Some(None),
|
||||
stripe_secret_key: Some(None),
|
||||
default_currency: Some(None),
|
||||
prices: Some(Some(prices)),
|
||||
country_currencies: Some(None),
|
||||
legacy_prices: Some(Some(BTreeMap::new())),
|
||||
automatic_tax: Some(None),
|
||||
tax_id_collection: Some(Some(true)),
|
||||
terms_consent_required: Some(Some(false)),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::InstanceBillingUpdateRequest>(value.clone())
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"enabled": null,
|
||||
"stripe_secret_key": null,
|
||||
"default_currency": null,
|
||||
"prices": {
|
||||
"SEK": {
|
||||
"monthly": "price_1Monthly",
|
||||
"yearly": "price_1Yearly",
|
||||
"gift_1_month": null,
|
||||
"gift_1_year": null
|
||||
}
|
||||
},
|
||||
"country_currencies": null,
|
||||
"legacy_prices": {},
|
||||
"automatic_tax": null,
|
||||
"tax_id_collection": true,
|
||||
"terms_consent_required": false
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(InstanceBillingUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_discovery_reads_the_name_and_feature_flag() {
|
||||
let discovery: InstancePremiumDiscovery = serde_json::from_value(json!({
|
||||
"app_public": {"branding": {"product_name": "Example", "premium_product_name": " Gold "}},
|
||||
"features": {"premium_enabled": true, "stripe_enabled": false}
|
||||
}))
|
||||
.expect("discovery");
|
||||
assert_eq!(discovery.premium_product_name(), Some("Gold"));
|
||||
assert!(discovery.features.premium_enabled);
|
||||
let empty: InstancePremiumDiscovery =
|
||||
serde_json::from_value(json!({})).expect("empty discovery");
|
||||
assert_eq!(empty.premium_product_name(), None);
|
||||
assert!(!empty.features.premium_enabled);
|
||||
assert_eq!(
|
||||
PremiumBranding::from_discovery(&discovery),
|
||||
PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_branding_from_instance_config_matches_discovery_rules() {
|
||||
assert_eq!(
|
||||
PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone),
|
||||
PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: false
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
PremiumBranding::from_config_parts(true, "Gold", PremiumMode::Mirror).premium_enabled
|
||||
);
|
||||
assert_eq!(
|
||||
PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone),
|
||||
PremiumBranding {
|
||||
name: None,
|
||||
premium_enabled: true
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
|
||||
use super::{InstanceBillingResponse, InstanceBillingUpdateRequest};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InstanceConfigResponse {
|
||||
@@ -21,11 +21,17 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub media: InstanceMediaResponse,
|
||||
#[serde(default)]
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
pub push_relay: PushRelayConfigResponse,
|
||||
#[serde(default)]
|
||||
pub screen_share_delivery: ScreenShareDeliveryConfigResponse,
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub altcha_captcha: AltchaCaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub profile_timezone: ProfileTimezoneConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub billing: InstanceBillingResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -330,6 +336,9 @@ pub struct AppBrandingConfigResponse {
|
||||
pub theme_color: Option<String>,
|
||||
pub status_page_url: Option<String>,
|
||||
pub status_page_incident_history_url: Option<String>,
|
||||
#[serde(default = "default_premium_product_name")]
|
||||
pub premium_product_name: String,
|
||||
pub premium_info_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppBrandingConfigResponse {
|
||||
@@ -344,6 +353,8 @@ impl Default for AppBrandingConfigResponse {
|
||||
theme_color: None,
|
||||
status_page_url: None,
|
||||
status_page_incident_history_url: None,
|
||||
premium_product_name: default_premium_product_name(),
|
||||
premium_info_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,6 +363,10 @@ fn default_product_name() -> String {
|
||||
"Fluxer".to_owned()
|
||||
}
|
||||
|
||||
fn default_premium_product_name() -> String {
|
||||
"Premium".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct AppSetupConfigResponse {
|
||||
#[serde(default)]
|
||||
@@ -449,123 +464,116 @@ 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 VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
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";
|
||||
|
||||
impl NoiseSuppressionBackend {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::None,
|
||||
Self::Standard,
|
||||
Self::Gate,
|
||||
Self::Speex,
|
||||
Self::Rnnoise,
|
||||
Self::Gtcrn,
|
||||
Self::DeepFilter,
|
||||
];
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None (pass-through)",
|
||||
Self::Standard => "Standard (WebRTC)",
|
||||
Self::Gate => "Noise gate",
|
||||
Self::Speex => "Speex",
|
||||
Self::Rnnoise => "RNNoise",
|
||||
Self::Gtcrn => "GTCRN",
|
||||
Self::DeepFilter => "DeepFilterNet",
|
||||
}
|
||||
}
|
||||
#[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, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionGuildOverride {
|
||||
pub guild_id: String,
|
||||
pub backend: NoiseSuppressionBackend,
|
||||
#[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 VoiceNoiseSuppressionConfigResponse {
|
||||
pub struct DomainMigrationConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub default_backend: NoiseSuppressionBackend,
|
||||
pub enabled_backends: Vec<NoiseSuppressionBackend>,
|
||||
pub allow_user_override: bool,
|
||||
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,
|
||||
pub anonymous_rollout_basis_points: u32,
|
||||
pub standalone_forwarding: bool,
|
||||
}
|
||||
|
||||
impl Default for VoiceNoiseSuppressionConfigResponse {
|
||||
impl Default for DomainMigrationConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
default_backend: NoiseSuppressionBackend::Standard,
|
||||
enabled_backends: NoiseSuppressionBackend::ALL.to_vec(),
|
||||
allow_user_override: true,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "voice-ns-v1".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(),
|
||||
guild_overrides: Vec::new(),
|
||||
suppression_strength: 80,
|
||||
anonymous_rollout_basis_points: 0,
|
||||
standalone_forwarding: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
pub struct DomainMigrationConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_backend: Option<NoiseSuppressionBackend>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_backends: Option<Vec<NoiseSuppressionBackend>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_user_override: 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 guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
|
||||
pub anonymous_rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppression_strength: Option<u32>,
|
||||
pub standalone_forwarding: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ScreenShareDeliveryConfigResponse {
|
||||
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 ScreenShareDeliveryConfigResponse {
|
||||
impl Default for AltchaCaptchaConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: SCREEN_SHARE_DELIVERY_DEFAULT_SALT.to_owned(),
|
||||
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
anonymous_enabled: false,
|
||||
cost: 5_000,
|
||||
max_counter: 10_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ScreenShareDeliveryConfigUpdateRequest {
|
||||
pub struct AltchaCaptchaConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -575,6 +583,62 @@ 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_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>>,
|
||||
}
|
||||
|
||||
@@ -692,11 +756,17 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media: Option<InstanceMediaUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
|
||||
pub push_relay: Option<PushRelayConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub screen_share_delivery: Option<ScreenShareDeliveryConfigUpdateRequest>,
|
||||
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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub billing: Option<InstanceBillingUpdateRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -905,6 +975,10 @@ pub struct AppBrandingConfigUpdateRequest {
|
||||
pub status_page_url: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status_page_incident_history_url: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub premium_product_name: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub premium_info_url: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -1009,53 +1083,51 @@ mod tests {
|
||||
use crate::api::generated::types as generated_types;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_backend_choices_use_the_generated_wire_contract() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(NoiseSuppressionBackend::ALL).expect("serializable backends"),
|
||||
json!([
|
||||
"none",
|
||||
"standard",
|
||||
"gate",
|
||||
"speex",
|
||||
"rnnoise",
|
||||
"gtcrn",
|
||||
"deep_filter"
|
||||
])
|
||||
);
|
||||
assert!(serde_json::from_value::<NoiseSuppressionBackend>(json!("deepfilter")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_instance_experiment_config_matches_the_published_contract() {
|
||||
let schema: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../openapi-admin.json"))
|
||||
.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!(
|
||||
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
|
||||
noise
|
||||
serde_json::to_value(generated_domain_migration)
|
||||
.expect("serializable generated domain migration config"),
|
||||
domain_migration
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_screen_share)
|
||||
.expect("serializable generated screen share config"),
|
||||
screen_share
|
||||
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)
|
||||
@@ -1063,8 +1135,9 @@ mod tests {
|
||||
delivery
|
||||
);
|
||||
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") {
|
||||
@@ -1077,14 +1150,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_share_delivery_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = ScreenShareDeliveryConfigUpdateRequest {
|
||||
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::ScreenShareDeliveryConfigUpdateRequest>(
|
||||
serde_json::from_value::<generated_types::DomainMigrationConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
@@ -1093,32 +1166,7 @@ mod tests {
|
||||
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 {
|
||||
enabled_backends: Some(Vec::new()),
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
guild_overrides: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::VoiceNoiseSuppressionConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
|
||||
serde_json::to_value(DomainMigrationConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ mod codes;
|
||||
mod common;
|
||||
mod discovery;
|
||||
mod guild_assets;
|
||||
mod instance_billing;
|
||||
mod instance_config;
|
||||
mod jobs;
|
||||
mod limit_config;
|
||||
@@ -28,6 +29,7 @@ pub use codes::*;
|
||||
pub use common::*;
|
||||
pub use discovery::*;
|
||||
pub use guild_assets::*;
|
||||
pub use instance_billing::*;
|
||||
pub use instance_config::*;
|
||||
pub use jobs::*;
|
||||
pub use limit_config::*;
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
client::ApiError,
|
||||
types::{
|
||||
AppBrandingConfigUpdateRequest, AppPublicConfigUpdateRequest,
|
||||
BILLING_MAX_COUNTRY_CURRENCIES, BILLING_MAX_CURRENCIES,
|
||||
BILLING_MAX_LEGACY_PRICES_PER_SLOT, BILLING_MAX_LEGACY_SLOTS, BILLING_PRICE_SLOTS,
|
||||
BillingPriceSet, InstanceBillingUpdateRequest, InstanceConfigUpdateRequest,
|
||||
PREMIUM_PRODUCT_NAME_MAX_CHARS, TRI_STATE_DEFAULT, TRI_STATE_OFF, TRI_STATE_ON,
|
||||
},
|
||||
},
|
||||
middleware::flash::FlashData,
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const PRICE_ID_MAX_CHARS: usize = 255;
|
||||
const INFO_URL_MAX_CHARS: usize = 2048;
|
||||
|
||||
pub(super) fn build_billing_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
let premium_product_name = if form.contains_key("billing_premium_product_name") {
|
||||
Some(parse_premium_product_name(
|
||||
form.clean("billing_premium_product_name"),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let premium_info_url = if form.contains_key("billing_premium_info_url") {
|
||||
Some(parse_info_url(form.clean("billing_premium_info_url"))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let branding = (premium_product_name.is_some() || premium_info_url.is_some()).then(|| {
|
||||
AppBrandingConfigUpdateRequest {
|
||||
premium_product_name,
|
||||
premium_info_url,
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
let prices = if form.contains_key("billing_price_currency") {
|
||||
Some(parse_price_rows(form)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let default_currency = if form.contains_key("billing_default_currency") {
|
||||
Some(
|
||||
form.clean("billing_default_currency")
|
||||
.map(|value| parse_currency(&value))
|
||||
.transpose()?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let country_currencies = if form.contains_key("billing_country_currencies") {
|
||||
Some(parse_country_currencies(
|
||||
form.first("billing_country_currencies").unwrap_or(""),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let legacy_prices = if form.contains_key("billing_legacy_prices") {
|
||||
Some(parse_legacy_prices(
|
||||
form.first("billing_legacy_prices").unwrap_or(""),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(Some(prices)) = &prices {
|
||||
if let Some(Some(currency)) = &default_currency
|
||||
&& !prices.contains_key(currency)
|
||||
{
|
||||
return Err(format!(
|
||||
"Default currency {currency} has no row in the price table"
|
||||
));
|
||||
}
|
||||
if let Some(Some(countries)) = &country_currencies
|
||||
&& let Some((country, currency)) = countries
|
||||
.iter()
|
||||
.find(|(_, currency)| !prices.contains_key(*currency))
|
||||
{
|
||||
return Err(format!(
|
||||
"{country} maps to {currency}, which has no row in the price table"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
app_public: branding.map(|branding| AppPublicConfigUpdateRequest {
|
||||
branding: Some(branding),
|
||||
..Default::default()
|
||||
}),
|
||||
billing: Some(InstanceBillingUpdateRequest {
|
||||
enabled: parse_tri_state(form, "billing_enabled")?,
|
||||
stripe_secret_key: secret_update(
|
||||
form,
|
||||
"billing_stripe_secret_key",
|
||||
"billing_clear_stripe_secret_key",
|
||||
),
|
||||
stripe_webhook_secret: secret_update(
|
||||
form,
|
||||
"billing_stripe_webhook_secret",
|
||||
"billing_clear_stripe_webhook_secret",
|
||||
),
|
||||
default_currency,
|
||||
prices,
|
||||
country_currencies,
|
||||
legacy_prices,
|
||||
automatic_tax: parse_tri_state(form, "billing_automatic_tax")?,
|
||||
tax_id_collection: parse_tri_state(form, "billing_tax_id_collection")?,
|
||||
terms_consent_required: parse_tri_state(form, "billing_terms_consent_required")?,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_tri_state(form: &MultiValueForm, key: &str) -> Result<Option<Option<bool>>, String> {
|
||||
if !form.contains_key(key) {
|
||||
return Ok(None);
|
||||
}
|
||||
match form.first(key).map(str::trim).unwrap_or("") {
|
||||
TRI_STATE_DEFAULT => Ok(Some(None)),
|
||||
TRI_STATE_ON => Ok(Some(Some(true))),
|
||||
TRI_STATE_OFF => Ok(Some(Some(false))),
|
||||
other => Err(format!("Invalid choice \"{other}\" for {key}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn billing_result<T>(result: Result<T, ApiError>) -> FlashData {
|
||||
match result {
|
||||
Ok(_) => FlashData::success("Premium and billing settings updated"),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: update billing config");
|
||||
match validation_message(&error) {
|
||||
Some(message) => FlashData::error(format!(
|
||||
"Failed to update premium and billing settings: {message}"
|
||||
)),
|
||||
None => FlashData::error("Failed to update premium and billing settings"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validation_message(error: &ApiError) -> Option<String> {
|
||||
let ApiError::Http {
|
||||
status: 400,
|
||||
message,
|
||||
} = error
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let body: serde_json::Value = serde_json::from_str(message).ok()?;
|
||||
let first = body["errors"].as_array().and_then(|errors| errors.first());
|
||||
let detail = first.and_then(|error| {
|
||||
let message = error["message"].as_str()?;
|
||||
Some(
|
||||
match error["path"].as_str().filter(|path| !path.is_empty()) {
|
||||
Some(path) => format!("{path}: {message}"),
|
||||
None => message.to_owned(),
|
||||
},
|
||||
)
|
||||
});
|
||||
detail.or_else(|| body["message"].as_str().map(str::to_owned))
|
||||
}
|
||||
|
||||
fn secret_update(form: &MultiValueForm, key: &str, clear_key: &str) -> Option<Option<String>> {
|
||||
match form.clean(key) {
|
||||
Some(secret) => Some(Some(secret)),
|
||||
None if form.bool_value(clear_key) => Some(None),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_premium_product_name(value: Option<String>) -> Result<Option<String>, String> {
|
||||
match value {
|
||||
Some(name) if name.encode_utf16().count() > PREMIUM_PRODUCT_NAME_MAX_CHARS => Err(format!(
|
||||
"Premium name must be at most {PREMIUM_PRODUCT_NAME_MAX_CHARS} characters"
|
||||
)),
|
||||
other => Ok(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_info_url(value: Option<String>) -> Result<Option<String>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let valid = value.chars().count() <= INFO_URL_MAX_CHARS
|
||||
&& url::Url::parse(&value).is_ok_and(|url| {
|
||||
matches!(url.scheme(), "http" | "https")
|
||||
&& url.host_str().is_some_and(|h| !h.is_empty())
|
||||
});
|
||||
if valid {
|
||||
Ok(Some(value))
|
||||
} else {
|
||||
Err("Premium info URL must be an absolute http or https URL".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_currency(value: &str) -> Result<String, String> {
|
||||
let currency = value.trim().to_ascii_uppercase();
|
||||
if currency.len() == 3 && currency.bytes().all(|byte| byte.is_ascii_uppercase()) {
|
||||
Ok(currency)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Invalid currency \"{}\": use a 3-letter ISO 4217 code such as GBP",
|
||||
value.trim()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_country(value: &str) -> Result<String, String> {
|
||||
let country = value.trim().to_ascii_uppercase();
|
||||
if country.len() == 2 && country.bytes().all(|byte| byte.is_ascii_uppercase()) {
|
||||
Ok(country)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Invalid country \"{}\": use a 2-letter ISO 3166 code such as SE",
|
||||
value.trim()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_price_id(value: &str) -> Result<String, String> {
|
||||
let id = value.trim();
|
||||
let valid = id.len() <= PRICE_ID_MAX_CHARS
|
||||
&& id.strip_prefix("price_").is_some_and(|rest| {
|
||||
!rest.is_empty() && rest.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
});
|
||||
if valid {
|
||||
Ok(id.to_owned())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Invalid Stripe price ID \"{id}\": it must look like price_1AbC"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_price_id(value: Option<&String>) -> Result<Option<String>, String> {
|
||||
match value
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
Some(id) => parse_price_id(id).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_price_rows(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<Option<BTreeMap<String, BillingPriceSet>>, String> {
|
||||
let currencies = form.values("billing_price_currency");
|
||||
let column = |key: &str, index: usize| form.values(key).get(index);
|
||||
let mut prices = BTreeMap::new();
|
||||
for (index, currency) in currencies.iter().enumerate() {
|
||||
if currency.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let currency = parse_currency(currency)?;
|
||||
let set = BillingPriceSet {
|
||||
monthly: parse_optional_price_id(column("billing_price_monthly", index))?,
|
||||
yearly: parse_optional_price_id(column("billing_price_yearly", index))?,
|
||||
gift_1_month: parse_optional_price_id(column("billing_price_gift_1_month", index))?,
|
||||
gift_1_year: parse_optional_price_id(column("billing_price_gift_1_year", index))?,
|
||||
};
|
||||
if set.is_empty() {
|
||||
return Err(format!("{currency} needs at least one price ID"));
|
||||
}
|
||||
if prices.insert(currency.clone(), set).is_some() {
|
||||
return Err(format!(
|
||||
"{currency} appears more than once in the price table"
|
||||
));
|
||||
}
|
||||
}
|
||||
if prices.len() > BILLING_MAX_CURRENCIES {
|
||||
return Err(format!(
|
||||
"The price table holds at most {BILLING_MAX_CURRENCIES} currencies"
|
||||
));
|
||||
}
|
||||
Ok((!prices.is_empty()).then_some(prices))
|
||||
}
|
||||
|
||||
fn key_value_lines(value: &str) -> impl Iterator<Item = Result<(&str, &str), String>> {
|
||||
value
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| {
|
||||
line.split_once('=')
|
||||
.map(|(key, value)| (key.trim(), value.trim()))
|
||||
.ok_or_else(|| format!("Line \"{line}\" must use the form KEY=VALUE"))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_country_currencies(value: &str) -> Result<Option<BTreeMap<String, String>>, String> {
|
||||
let mut countries = BTreeMap::new();
|
||||
for line in key_value_lines(value) {
|
||||
let (country, currency) = line?;
|
||||
let country = parse_country(country)?;
|
||||
let currency = parse_currency(currency)?;
|
||||
if countries.insert(country.clone(), currency).is_some() {
|
||||
return Err(format!("{country} is mapped more than once"));
|
||||
}
|
||||
}
|
||||
if countries.len() > BILLING_MAX_COUNTRY_CURRENCIES {
|
||||
return Err(format!(
|
||||
"At most {BILLING_MAX_COUNTRY_CURRENCIES} country mappings are allowed"
|
||||
));
|
||||
}
|
||||
Ok((!countries.is_empty()).then_some(countries))
|
||||
}
|
||||
|
||||
fn parse_legacy_slot(value: &str) -> Result<String, String> {
|
||||
let invalid = || {
|
||||
format!(
|
||||
"Invalid legacy price slot \"{value}\": use monthly, yearly, gift_1_month or gift_1_year followed by _ and a currency, such as monthly_GBP"
|
||||
)
|
||||
};
|
||||
let (slot, currency) = value.rsplit_once('_').ok_or_else(invalid)?;
|
||||
let slot = slot.to_ascii_lowercase();
|
||||
if !BILLING_PRICE_SLOTS.contains(&slot.as_str()) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let currency = parse_currency(currency).map_err(|_| invalid())?;
|
||||
Ok(format!("{slot}_{currency}"))
|
||||
}
|
||||
|
||||
fn parse_legacy_prices(value: &str) -> Result<Option<BTreeMap<String, Vec<String>>>, String> {
|
||||
let mut legacy: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for line in key_value_lines(value) {
|
||||
let (slot, ids) = line?;
|
||||
let slot = parse_legacy_slot(slot)?;
|
||||
let entry = legacy.entry(slot.clone()).or_default();
|
||||
for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) {
|
||||
let id = parse_price_id(id)?;
|
||||
if !entry.contains(&id) {
|
||||
entry.push(id);
|
||||
}
|
||||
}
|
||||
if entry.is_empty() {
|
||||
return Err(format!("{slot} needs at least one price ID"));
|
||||
}
|
||||
if entry.len() > BILLING_MAX_LEGACY_PRICES_PER_SLOT {
|
||||
return Err(format!(
|
||||
"{slot} holds at most {BILLING_MAX_LEGACY_PRICES_PER_SLOT} legacy price IDs"
|
||||
));
|
||||
}
|
||||
}
|
||||
if legacy.len() > BILLING_MAX_LEGACY_SLOTS {
|
||||
return Err(format!(
|
||||
"At most {BILLING_MAX_LEGACY_SLOTS} legacy price slots are allowed"
|
||||
));
|
||||
}
|
||||
Ok((!legacy.is_empty()).then_some(legacy))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::generated::types as generated_types;
|
||||
use serde_json::json;
|
||||
|
||||
fn full_form(extra: &str) -> MultiValueForm {
|
||||
let base = "billing_premium_product_name=%20Gold%20\
|
||||
&billing_premium_info_url=https%3A%2F%2Fexample.com%2Fgold\
|
||||
&billing_enabled=on\
|
||||
&billing_automatic_tax=default&billing_tax_id_collection=on&billing_terms_consent_required=off\
|
||||
&billing_stripe_secret_key=\
|
||||
&billing_stripe_webhook_secret=whsec_new\
|
||||
&billing_default_currency=gbp\
|
||||
&billing_price_currency=gbp&billing_price_monthly=price_1GbpM&billing_price_yearly=price_1GbpY\
|
||||
&billing_price_gift_1_month=&billing_price_gift_1_year=price_1GbpG\
|
||||
&billing_price_currency=SEK&billing_price_monthly=price_1SekM&billing_price_yearly=price_1SekY\
|
||||
&billing_price_gift_1_month=&billing_price_gift_1_year=\
|
||||
&billing_price_currency=&billing_price_monthly=&billing_price_yearly=\
|
||||
&billing_price_gift_1_month=&billing_price_gift_1_year=\
|
||||
&billing_country_currencies=se%3Dsek%0D%0AGB%20%3D%20GBP%0D%0A\
|
||||
&billing_legacy_prices=monthly_GBP%3Dprice_1OldA%0Amonthly_gbp%3Dprice_1OldB%2Cprice_1OldA%0Ayearly_SEK%3Dprice_1OldC";
|
||||
MultiValueForm::parse(format!("{base}{extra}").as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_billing_form_builds_the_expected_patch() {
|
||||
let update = build_billing_update(&full_form("")).expect("valid form");
|
||||
let value = serde_json::to_value(&update).expect("serializable");
|
||||
serde_json::from_value::<generated_types::InstanceConfigUpdateRequest>(value.clone())
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"app_public": {
|
||||
"branding": {
|
||||
"premium_product_name": "Gold",
|
||||
"premium_info_url": "https://example.com/gold"
|
||||
}
|
||||
},
|
||||
"billing": {
|
||||
"enabled": true,
|
||||
"stripe_webhook_secret": "whsec_new",
|
||||
"default_currency": "GBP",
|
||||
"prices": {
|
||||
"GBP": {
|
||||
"monthly": "price_1GbpM",
|
||||
"yearly": "price_1GbpY",
|
||||
"gift_1_month": null,
|
||||
"gift_1_year": "price_1GbpG"
|
||||
},
|
||||
"SEK": {
|
||||
"monthly": "price_1SekM",
|
||||
"yearly": "price_1SekY",
|
||||
"gift_1_month": null,
|
||||
"gift_1_year": null
|
||||
}
|
||||
},
|
||||
"country_currencies": {"GB": "GBP", "SE": "SEK"},
|
||||
"legacy_prices": {
|
||||
"monthly_GBP": ["price_1OldA", "price_1OldB"],
|
||||
"yearly_SEK": ["price_1OldC"]
|
||||
},
|
||||
"automatic_tax": null,
|
||||
"tax_id_collection": true,
|
||||
"terms_consent_required": false
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_fields_clear_and_the_default_choice_sends_null() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"billing_premium_product_name=&billing_premium_info_url=&billing_enabled=default\
|
||||
&billing_stripe_secret_key=&billing_clear_stripe_secret_key=true\
|
||||
&billing_stripe_webhook_secret=\
|
||||
&billing_default_currency=\
|
||||
&billing_price_currency=&billing_price_monthly=price_1Ignored\
|
||||
&billing_country_currencies=&billing_legacy_prices=",
|
||||
);
|
||||
let value = serde_json::to_value(build_billing_update(&form).expect("valid form")).unwrap();
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"app_public": {
|
||||
"branding": {"premium_product_name": null, "premium_info_url": null}
|
||||
},
|
||||
"billing": {
|
||||
"enabled": null,
|
||||
"stripe_secret_key": null,
|
||||
"default_currency": null,
|
||||
"prices": null,
|
||||
"country_currencies": null,
|
||||
"legacy_prices": null
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_secret_wins_over_the_clear_checkbox() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"billing_stripe_secret_key=%20sk_live_x%20&billing_clear_stripe_secret_key=true",
|
||||
);
|
||||
let billing = build_billing_update(&form)
|
||||
.expect("valid form")
|
||||
.billing
|
||||
.expect("billing");
|
||||
assert_eq!(
|
||||
billing.stripe_secret_key,
|
||||
Some(Some("sk_live_x".to_owned()))
|
||||
);
|
||||
assert_eq!(billing.stripe_webhook_secret, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_form_keys_leave_their_fields_untouched() {
|
||||
let update = build_billing_update(&MultiValueForm::parse(b"billing_enabled=off"))
|
||||
.expect("valid form");
|
||||
assert!(update.app_public.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_value(update.billing).unwrap(),
|
||||
json!({"enabled": false})
|
||||
);
|
||||
let untouched = build_billing_update(&MultiValueForm::parse(b"")).expect("valid form");
|
||||
assert_eq!(serde_json::to_value(untouched.billing).unwrap(), json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_input_is_rejected_with_a_message() {
|
||||
let cases: &[(&str, &str)] = &[
|
||||
(
|
||||
"billing_premium_info_url=ftp%3A%2F%2Fexample.com",
|
||||
"http or https",
|
||||
),
|
||||
("billing_premium_info_url=example.com", "http or https"),
|
||||
("billing_default_currency=GB", "Invalid currency"),
|
||||
("billing_enabled=true", "Invalid choice"),
|
||||
("billing_automatic_tax=maybe", "Invalid choice"),
|
||||
(
|
||||
"billing_price_currency=GBPX&billing_price_monthly=price_1A",
|
||||
"Invalid currency",
|
||||
),
|
||||
(
|
||||
"billing_price_currency=GBP&billing_price_monthly=prod_1A",
|
||||
"Invalid Stripe price ID",
|
||||
),
|
||||
(
|
||||
"billing_price_currency=GBP&billing_price_monthly=price_1-A",
|
||||
"Invalid Stripe price ID",
|
||||
),
|
||||
("billing_price_currency=GBP", "needs at least one price ID"),
|
||||
(
|
||||
"billing_price_currency=GBP&billing_price_monthly=price_1A&billing_price_currency=gbp&billing_price_monthly=price_1B",
|
||||
"more than once",
|
||||
),
|
||||
("billing_country_currencies=SWE%3DSEK", "Invalid country"),
|
||||
("billing_country_currencies=SE", "KEY=VALUE"),
|
||||
(
|
||||
"billing_country_currencies=SE%3DSEK%0ASE%3DEUR",
|
||||
"mapped more than once",
|
||||
),
|
||||
(
|
||||
"billing_legacy_prices=weekly_GBP%3Dprice_1A",
|
||||
"Invalid legacy price slot",
|
||||
),
|
||||
(
|
||||
"billing_legacy_prices=monthly_GBP%3D",
|
||||
"needs at least one price ID",
|
||||
),
|
||||
(
|
||||
"billing_default_currency=EUR&billing_price_currency=GBP&billing_price_monthly=price_1A",
|
||||
"Default currency EUR has no row",
|
||||
),
|
||||
(
|
||||
"billing_country_currencies=SE%3DSEK&billing_price_currency=GBP&billing_price_monthly=price_1A",
|
||||
"SE maps to SEK",
|
||||
),
|
||||
];
|
||||
let long_name = format!("billing_premium_product_name={}", "A".repeat(41));
|
||||
let emoji_name = format!(
|
||||
"billing_premium_product_name=Gold{}",
|
||||
"%F0%9F%92%8E".repeat(20)
|
||||
);
|
||||
let long_case = [
|
||||
(long_name.as_str(), "at most 40"),
|
||||
(emoji_name.as_str(), "at most 40"),
|
||||
];
|
||||
for (body, expected) in long_case.iter().chain(cases.iter()) {
|
||||
let error =
|
||||
build_billing_update(&MultiValueForm::parse(body.as_bytes())).expect_err(body);
|
||||
assert!(error.contains(expected), "{body}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_name_limit_counts_utf16_units() {
|
||||
let name = format!("{}{}", "A".repeat(39), "\u{1F48E}");
|
||||
assert_eq!(name.chars().count(), 40);
|
||||
assert!(parse_premium_product_name(Some(name)).is_err());
|
||||
let fits = format!("{}{}", "A".repeat(38), "\u{E9}\u{E9}");
|
||||
assert_eq!(
|
||||
parse_premium_product_name(Some(fits.clone())),
|
||||
Ok(Some(fits))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn country_currencies_are_not_cross_checked_without_a_price_table() {
|
||||
let form = MultiValueForm::parse(b"billing_country_currencies=SE%3DSEK");
|
||||
let billing = build_billing_update(&form).unwrap().billing.unwrap();
|
||||
assert_eq!(
|
||||
billing.country_currencies,
|
||||
Some(Some(BTreeMap::from([("SE".to_owned(), "SEK".to_owned())])))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_errors_surface_the_first_api_message() {
|
||||
let error = ApiError::Http {
|
||||
status: 400,
|
||||
message: json!({
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Validation failed",
|
||||
"errors": [{"path": "billing.enabled", "code": "X", "message": "Switch the premium model to mirror first"}]
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
validation_message(&error).as_deref(),
|
||||
Some("billing.enabled: Switch the premium model to mirror first")
|
||||
);
|
||||
let server_error = ApiError::Http {
|
||||
status: 500,
|
||||
message: "{}".to_owned(),
|
||||
};
|
||||
assert_eq!(validation_message(&server_error), None);
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,15 @@ use crate::{
|
||||
flash::{self, FlashData},
|
||||
},
|
||||
state::AppState,
|
||||
templates::{self, pages::gift_codes::MAX_GIFT_CODES},
|
||||
templates::{
|
||||
self,
|
||||
pages::gift_codes::{GiftCodesPremium, MAX_GIFT_CODES},
|
||||
},
|
||||
};
|
||||
use axum::{
|
||||
Form, Router,
|
||||
extract::{FromRequest, Query, Request, State},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -46,10 +49,11 @@ async fn gift_codes_page(
|
||||
Query(query): Query<GiftCodesQuery>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
|
||||
if config.self_hosted {
|
||||
return Redirect::to(&format!("{}/dashboard", config.base_path)).into_response();
|
||||
}
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let premium = GiftCodesPremium::from_branding(
|
||||
config.self_hosted,
|
||||
state.premium_branding(&client).await.as_ref(),
|
||||
);
|
||||
|
||||
let generated_codes: Option<Vec<String>> = query
|
||||
.codes
|
||||
@@ -60,6 +64,7 @@ async fn gift_codes_page(
|
||||
config,
|
||||
&auth.0,
|
||||
&csrf.0.0,
|
||||
&premium,
|
||||
generated_codes.as_deref(),
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
@@ -72,9 +77,6 @@ async fn gift_codes_post(
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let base = &config.base_path;
|
||||
if config.self_hosted {
|
||||
return Redirect::to(&format!("{base}/dashboard")).into_response();
|
||||
}
|
||||
let form: GiftCodesForm = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(error) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod applications;
|
||||
pub mod auth;
|
||||
pub mod bans;
|
||||
mod bans_actions;
|
||||
mod billing_actions;
|
||||
pub mod codes;
|
||||
pub mod discovery;
|
||||
mod guild_tabs;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -221,6 +221,11 @@ async fn instance_config_page(
|
||||
.get_instance_config()
|
||||
.await
|
||||
.log_error("load instance config");
|
||||
if let Some(instance_config) = &instance_config {
|
||||
state.remember_premium_branding(crate::api::types::PremiumBranding::from_instance_config(
|
||||
instance_config,
|
||||
));
|
||||
}
|
||||
let limit_config = client
|
||||
.get_limit_config()
|
||||
.await
|
||||
|
||||
@@ -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,
|
||||
@@ -17,10 +19,9 @@ use crate::{
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
|
||||
PremiumMode, RegistrationMode, ScreenShareDeliveryConfigUpdateRequest,
|
||||
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
|
||||
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -193,7 +194,9 @@ pub async fn instance_config_post(
|
||||
}
|
||||
"update_policy" => {
|
||||
let update = build_policy_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
let result = client.update_instance_config(&update).await;
|
||||
remember_premium_branding(&state, &result);
|
||||
instance_config_result(result)
|
||||
}
|
||||
"update_integrations" => {
|
||||
let update = build_integrations_update(&form);
|
||||
@@ -203,11 +206,27 @@ pub async fn instance_config_post(
|
||||
let update = build_media_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"update_voice_noise_suppression" => match build_voice_noise_suppression_update(&form) {
|
||||
"update_billing" => match super::billing_actions::build_billing_update(&form) {
|
||||
Ok(update) => {
|
||||
let result = client.update_instance_config(&update).await;
|
||||
remember_premium_branding(&state, &result);
|
||||
super::billing_actions::billing_result(result)
|
||||
}
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"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_screen_share_delivery" => match build_screen_share_delivery_update(&form) {
|
||||
"update_altcha_captcha" => match build_altcha_captcha_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_profile_timezone" => match build_profile_timezone_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
@@ -338,6 +357,17 @@ pub async fn instance_config_post(
|
||||
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
fn remember_premium_branding(
|
||||
state: &AppState,
|
||||
result: &Result<crate::api::types::InstanceConfigResponse, crate::api::client::ApiError>,
|
||||
) {
|
||||
if let Ok(instance_config) = result {
|
||||
state.remember_premium_branding(crate::api::types::PremiumBranding::from_instance_config(
|
||||
instance_config,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_registration_url_list_response(
|
||||
config: &AdminConfig,
|
||||
csrf_token: &str,
|
||||
@@ -452,7 +482,6 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
|
||||
}
|
||||
|
||||
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
|
||||
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
|
||||
@@ -493,6 +522,12 @@ fn parse_experiment_rollout_salt(
|
||||
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
));
|
||||
}
|
||||
if !salt
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
|
||||
{
|
||||
return Err("Rollout salt must use printable ASCII".to_owned());
|
||||
}
|
||||
Ok(Some(salt.to_owned()))
|
||||
}
|
||||
|
||||
@@ -528,135 +563,135 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_guild_overrides(
|
||||
value: &str,
|
||||
) -> Result<Vec<VoiceNoiseSuppressionGuildOverride>, String> {
|
||||
let mut overrides: Vec<VoiceNoiseSuppressionGuildOverride> = Vec::new();
|
||||
for (index, line) in value.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let line_number = index + 1;
|
||||
let (guild_id, backend) = line.split_once('=').ok_or_else(|| {
|
||||
format!("Guild overrides line {line_number} must use guild_id=backend")
|
||||
})?;
|
||||
let guild_id = guild_id.trim();
|
||||
if !is_experiment_snowflake(guild_id) {
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
|
||||
));
|
||||
}
|
||||
let backend = backend.trim().parse().map_err(|_| {
|
||||
format!("Guild overrides line {line_number} must name a supported backend")
|
||||
})?;
|
||||
if let Some(existing) = overrides
|
||||
.iter()
|
||||
.find(|existing| existing.guild_id == guild_id)
|
||||
{
|
||||
if existing.backend != backend {
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} conflicts with an earlier rule for guild {guild_id}"
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if overrides.len() == VOICE_NS_MAX_GUILD_OVERRIDES {
|
||||
return Err(format!(
|
||||
"Guild overrides must contain at most {VOICE_NS_MAX_GUILD_OVERRIDES} unique guilds"
|
||||
));
|
||||
}
|
||||
overrides.push(VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: guild_id.to_owned(),
|
||||
backend,
|
||||
});
|
||||
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()
|
||||
}
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
fn build_voice_noise_suppression_update(
|
||||
fn build_domain_migration_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
let selected: Vec<NoiseSuppressionBackend> = form
|
||||
.list_values_any(&["voice_ns_enabled_backends[]", "voice_ns_enabled_backends"])
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
value.parse().map_err(|_| {
|
||||
"Enabled backends must name supported noise suppression backends".to_owned()
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
let enabled_backends = NoiseSuppressionBackend::ALL
|
||||
.into_iter()
|
||||
.filter(|backend| selected.contains(backend))
|
||||
.collect();
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
voice_noise_suppression: Some(VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("voice_ns_enabled")),
|
||||
default_backend: form
|
||||
.first("voice_ns_default_backend")
|
||||
.map(|value| {
|
||||
value.parse().map_err(|_| {
|
||||
"Default backend must name a supported noise suppression backend".to_owned()
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
enabled_backends: Some(enabled_backends),
|
||||
allow_user_override: Some(form.bool_value("voice_ns_allow_user_override")),
|
||||
domain_migration: Some(DomainMigrationConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("domain_migration_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"voice_ns_rollout_basis_points",
|
||||
"domain_migration_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "domain_migration_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_included_user_ids").unwrap_or_default(),
|
||||
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("voice_ns_excluded_user_ids").unwrap_or_default(),
|
||||
form.first("domain_migration_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
|
||||
form.first("voice_ns_guild_overrides").unwrap_or_default(),
|
||||
)?),
|
||||
suppression_strength: parse_form_number(
|
||||
anonymous_rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression strength",
|
||||
"domain_migration_anonymous_rollout_basis_points",
|
||||
"Anonymous rollout basis points",
|
||||
0,
|
||||
VOICE_NS_SUPPRESSION_STRENGTH_MAX,
|
||||
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_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_screen_share_delivery_update(
|
||||
fn build_profile_timezone_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
screen_share_delivery: Some(ScreenShareDeliveryConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("screen_share_delivery_enabled")),
|
||||
profile_timezone: Some(ProfileTimezoneConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("profile_timezone_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"screen_share_delivery_rollout_basis_points",
|
||||
"profile_timezone_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(
|
||||
form,
|
||||
"screen_share_delivery_rollout_salt",
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "profile_timezone_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("screen_share_delivery_included_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("screen_share_delivery_excluded_user_ids")
|
||||
form.first("profile_timezone_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
@@ -721,6 +756,7 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
|
||||
theme_color: optional("app_theme_color"),
|
||||
status_page_url: optional("app_status_page_url"),
|
||||
status_page_incident_history_url: optional("app_status_page_incident_history_url"),
|
||||
..Default::default()
|
||||
}),
|
||||
setup: Some(AppSetupConfigUpdateRequest {
|
||||
configured: Some(form.bool_value("app_setup_configured")),
|
||||
@@ -1271,75 +1307,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_collects_backends_and_validates_numbers() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_ns_enabled=true&voice_ns_allow_user_override=on&voice_ns_default_backend=rnnoise&voice_ns_enabled_backends%5B%5D=deep_filter&voice_ns_enabled_backends%5B%5D=none&voice_ns_enabled_backends%5B%5D=none&voice_ns_rollout_basis_points=10000&voice_ns_suppression_strength=100&voice_ns_rollout_salt=%20voice-ns-v2%20",
|
||||
);
|
||||
let request = build_voice_noise_suppression_update(&form).expect("valid form");
|
||||
let update = request
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.allow_user_override, Some(true));
|
||||
assert_eq!(
|
||||
update.default_backend,
|
||||
Some(NoiseSuppressionBackend::Rnnoise)
|
||||
);
|
||||
assert_eq!(
|
||||
update.enabled_backends,
|
||||
Some(vec![
|
||||
NoiseSuppressionBackend::None,
|
||||
NoiseSuppressionBackend::DeepFilter
|
||||
])
|
||||
);
|
||||
assert_eq!(update.rollout_basis_points, Some(10_000));
|
||||
assert_eq!(update.suppression_strength, Some(100));
|
||||
assert_eq!(update.rollout_salt, Some("voice-ns-v2".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_voice_noise_suppression_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"voice_noise_suppression": {
|
||||
"enabled": false,
|
||||
"allow_user_override": false,
|
||||
"enabled_backends": [],
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_reads_user_id_textareas() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_ns_included_user_ids=1500000000000000001%0A1500000000000000002&voice_ns_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
|
||||
);
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000001".to_owned(),
|
||||
"1500000000000000002".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000003".to_owned(),
|
||||
"1500000000000000004".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
||||
assert_eq!(
|
||||
@@ -1400,207 +1367,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_rejects_malformed_lines() {
|
||||
for (line, message) in [
|
||||
("456", "Guild overrides line 3 must use guild_id=backend"),
|
||||
(
|
||||
"=gate",
|
||||
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"not-a-guild=gate",
|
||||
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"999999999999999999999=gate",
|
||||
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"456=unknown_backend",
|
||||
"Guild overrides line 3 must name a supported backend",
|
||||
),
|
||||
(
|
||||
"456=",
|
||||
"Guild overrides line 3 must name a supported backend",
|
||||
),
|
||||
(
|
||||
"123=gate",
|
||||
"Guild overrides line 3 conflicts with an earlier rule for guild 123",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_guild_overrides(&format!("\n123=rnnoise\n{line}"))
|
||||
.expect_err("invalid guild rule"),
|
||||
message,
|
||||
"{line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_rejects_invalid_numbers() {
|
||||
for (key, message, above_max) in [
|
||||
(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
"10001",
|
||||
),
|
||||
(
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression strength must be a whole number between 0 and 100",
|
||||
"101",
|
||||
),
|
||||
] {
|
||||
for value in [
|
||||
"",
|
||||
"%20%20",
|
||||
"abc",
|
||||
"-1",
|
||||
"1.5",
|
||||
"9999999999999999999999999",
|
||||
above_max,
|
||||
] {
|
||||
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_noise_suppression_update(&form).expect_err("invalid number"),
|
||||
message,
|
||||
"{key}={value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_accepts_padded_numbers() {
|
||||
let form = MultiValueForm::parse(b"voice_ns_rollout_basis_points=%20250%20");
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_basis_points, Some(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_rejects_invalid_rollout_salts() {
|
||||
for salt in [
|
||||
String::new(),
|
||||
" ".to_owned(),
|
||||
"é".repeat(65),
|
||||
"🎲".repeat(33),
|
||||
] {
|
||||
let form = MultiValueForm::parse(format!("voice_ns_rollout_salt={salt}").as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_noise_suppression_update(&form).expect_err("invalid salt"),
|
||||
"Rollout salt must be between 1 and 64 characters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_preserves_valid_rollout_salts() {
|
||||
for salt in ["x".to_owned(), "é".repeat(64), "🎲".repeat(32)] {
|
||||
let form =
|
||||
MultiValueForm::parse(format!("voice_ns_rollout_salt=%20{salt}%20").as_bytes());
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.expect("valid form")
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_salt, Some(salt));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_normalizes_identical_rules() {
|
||||
let overrides = parse_voice_noise_suppression_guild_overrides(
|
||||
" 1600000000000000001 = rnnoise \n\n1600000000000000001=rnnoise\n1600000000000000002=speex\n",
|
||||
).expect("valid guild rules");
|
||||
assert_eq!(
|
||||
overrides,
|
||||
vec![
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000001".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Rnnoise,
|
||||
},
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000002".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Speex,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_rejects_exceeding_the_cap() {
|
||||
let value = (0..VOICE_NS_MAX_GUILD_OVERRIDES)
|
||||
.map(|index| format!("{index}=gate"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let overrides =
|
||||
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n199=gate"))
|
||||
.expect("valid guild rules at cap");
|
||||
assert_eq!(overrides.len(), VOICE_NS_MAX_GUILD_OVERRIDES);
|
||||
assert_eq!(
|
||||
overrides.last().map(|entry| entry.guild_id.as_str()),
|
||||
Some("199")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n200=gate"))
|
||||
.expect_err("too many guild rules"),
|
||||
"Guild overrides must contain at most 200 unique guilds"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_reports_invalid_targeting_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"voice_ns_default_backend=unknown",
|
||||
"Default backend must name a supported noise suppression backend",
|
||||
),
|
||||
(
|
||||
"voice_ns_default_backend=",
|
||||
"Default backend must name a supported noise suppression backend",
|
||||
),
|
||||
(
|
||||
"voice_ns_enabled_backends%5B%5D=rnnoise&voice_ns_enabled_backends%5B%5D=unknown",
|
||||
"Enabled backends must name supported noise suppression backends",
|
||||
),
|
||||
(
|
||||
"voice_ns_included_user_ids=123%2Cinvalid",
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"voice_ns_excluded_user_ids=123%2Cinvalid",
|
||||
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"voice_ns_guild_overrides=123%3Dgate%0A123%3Drnnoise",
|
||||
"Guild overrides line 2 conflicts with an earlier rule for guild 123",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_voice_noise_suppression_update(&form).expect_err("invalid targeting"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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 +1392,231 @@ 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",
|
||||
),
|
||||
(
|
||||
format!("domain_migration_rollout_salt={}", "x".repeat(65)).as_str(),
|
||||
"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",
|
||||
"domain_migration",
|
||||
build_domain_migration_update
|
||||
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
|
||||
),
|
||||
("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");
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
acl,
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResult, ApiResultExt},
|
||||
types::AdminUser,
|
||||
types::{AdminUser, PremiumBranding},
|
||||
},
|
||||
middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx},
|
||||
routes::user_tabs,
|
||||
@@ -22,6 +22,7 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
|
||||
const USER_ID_LOOKUP_BATCH: usize = 100;
|
||||
const DEFAULT_PREMIUM_NAME: &str = "Premium";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UserListQuery {
|
||||
@@ -87,32 +88,47 @@ async fn users_list(
|
||||
.unwrap_or(&[]);
|
||||
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let results = if params.has_id_lookup() {
|
||||
lookup_users_in_batches(&client, ¶ms.requested_ids)
|
||||
.await
|
||||
.log_error("lookup users by ids")
|
||||
.map(|users| (users, false))
|
||||
} else if params.has_search() {
|
||||
let offset = u64::from(params.page) * u64::from(params.limit);
|
||||
client
|
||||
.search_users(
|
||||
params.search_query(),
|
||||
params.email_query(),
|
||||
params.ip_query(),
|
||||
params.limit,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
.log_error("search users")
|
||||
.map(|r| {
|
||||
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
|
||||
(r.users, has_more)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
let searching = params.has_id_lookup() || params.has_search();
|
||||
let results = async {
|
||||
if params.has_id_lookup() {
|
||||
lookup_users_in_batches(&client, ¶ms.requested_ids)
|
||||
.await
|
||||
.log_error("lookup users by ids")
|
||||
.map(|users| (users, false))
|
||||
} else if params.has_search() {
|
||||
let offset = u64::from(params.page) * u64::from(params.limit);
|
||||
client
|
||||
.search_users(
|
||||
params.search_query(),
|
||||
params.email_query(),
|
||||
params.ip_query(),
|
||||
params.limit,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
.log_error("search users")
|
||||
.map(|r| {
|
||||
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
|
||||
(r.users, has_more)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let badge = async {
|
||||
if searching {
|
||||
self_hosted_premium_badge_name(&state, &client).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let (results, badge_name) = tokio::join!(results, badge);
|
||||
let result_users = results.as_ref().map(|r| r.0.as_slice());
|
||||
let has_more = results.as_ref().is_some_and(|r| r.1);
|
||||
let premium_badge_name = match result_users {
|
||||
Some(users) if !users.is_empty() => badge_name,
|
||||
_ => None,
|
||||
};
|
||||
let markup = templates::pages::users_list::users_list_page(
|
||||
config,
|
||||
&auth.0,
|
||||
@@ -120,11 +136,34 @@ async fn users_list(
|
||||
result_users,
|
||||
has_more,
|
||||
can_view_email,
|
||||
premium_badge_name.as_deref(),
|
||||
is_results_fragment,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn self_hosted_premium_badge_name(
|
||||
state: &AppState,
|
||||
client: &AdminApiClient,
|
||||
) -> Option<String> {
|
||||
if !state.config().self_hosted {
|
||||
return None;
|
||||
}
|
||||
premium_badge_name(state.premium_branding(client).await.as_ref())
|
||||
}
|
||||
|
||||
fn premium_badge_name(branding: Option<&PremiumBranding>) -> Option<String> {
|
||||
match branding {
|
||||
Some(branding) => branding.premium_enabled.then(|| {
|
||||
branding
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_PREMIUM_NAME.to_owned())
|
||||
}),
|
||||
None => Some(DEFAULT_PREMIUM_NAME.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn lookup_users_in_batches(
|
||||
client: &AdminApiClient,
|
||||
user_ids: &[String],
|
||||
@@ -148,10 +187,15 @@ async fn user_detail(
|
||||
let is_detail_fragment = htmx::targets(&headers, "main-content");
|
||||
let active_tab = query.tab.as_deref().unwrap_or("overview");
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let user = client
|
||||
.get_user_by_id(&user_id)
|
||||
.await
|
||||
.log_error("load user detail");
|
||||
let (user, badge_name) = tokio::join!(
|
||||
async {
|
||||
client
|
||||
.get_user_by_id(&user_id)
|
||||
.await
|
||||
.log_error("load user detail")
|
||||
},
|
||||
self_hosted_premium_badge_name(&state, &client)
|
||||
);
|
||||
let tq = to_tab_query(&query);
|
||||
let admin_acls = auth
|
||||
.0
|
||||
@@ -167,6 +211,7 @@ async fn user_detail(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let premium_badge_name = user.as_ref().and(badge_name);
|
||||
let markup = templates::pages::user_detail::user_detail_with_tab(
|
||||
config,
|
||||
&auth.0,
|
||||
@@ -174,6 +219,7 @@ async fn user_detail(
|
||||
&user_id,
|
||||
active_tab,
|
||||
tab_body,
|
||||
premium_badge_name.as_deref(),
|
||||
is_detail_fragment,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
@@ -275,18 +321,29 @@ async fn user_peek(
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let user = client
|
||||
.get_user_by_id(&user_id)
|
||||
.await
|
||||
.log_error("load user peek");
|
||||
let (user, badge_name) = tokio::join!(
|
||||
async {
|
||||
client
|
||||
.get_user_by_id(&user_id)
|
||||
.await
|
||||
.log_error("load user peek")
|
||||
},
|
||||
self_hosted_premium_badge_name(&state, &client)
|
||||
);
|
||||
let admin_acls = auth
|
||||
.0
|
||||
.admin_user
|
||||
.as_ref()
|
||||
.map(|user| user.acls.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let premium_badge_name = user.as_ref().and(badge_name);
|
||||
let markup = match user {
|
||||
Some(ref u) => templates::pages::user_peek::user_peek_fragment(config, u, admin_acls),
|
||||
Some(ref u) => templates::pages::user_peek::user_peek_fragment(
|
||||
config,
|
||||
u,
|
||||
admin_acls,
|
||||
premium_badge_name.as_deref(),
|
||||
),
|
||||
None => maud::html! {
|
||||
div class="p-4 text-red-600 text-sm" { "User not found." }
|
||||
},
|
||||
@@ -319,3 +376,31 @@ fn append_query_params(url: &mut String, params: &[(String, String)]) {
|
||||
url.push_str(&urlencoding::encode(value));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn badge_name_follows_the_cached_branding_and_falls_back_to_the_default() {
|
||||
let gold = PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: true,
|
||||
};
|
||||
assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold"));
|
||||
let unnamed = PremiumBranding {
|
||||
name: None,
|
||||
premium_enabled: true,
|
||||
};
|
||||
assert_eq!(
|
||||
premium_badge_name(Some(&unnamed)).as_deref(),
|
||||
Some("Premium")
|
||||
);
|
||||
let everyone = PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: false,
|
||||
};
|
||||
assert_eq!(premium_badge_name(Some(&everyone)), None);
|
||||
assert_eq!(premium_badge_name(None).as_deref(), Some("Premium"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::config::AdminConfig;
|
||||
use std::sync::Arc;
|
||||
use crate::{
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
types::PremiumBranding,
|
||||
},
|
||||
config::AdminConfig,
|
||||
};
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const PREMIUM_BRANDING_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
@@ -11,6 +22,7 @@ pub struct AppState {
|
||||
struct AppStateInner {
|
||||
pub config: AdminConfig,
|
||||
pub http_client: reqwest::Client,
|
||||
premium_branding: Mutex<Option<(Instant, PremiumBranding)>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -23,6 +35,7 @@ impl AppState {
|
||||
inner: Arc::new(AppStateInner {
|
||||
config,
|
||||
http_client,
|
||||
premium_branding: Mutex::new(None),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -34,6 +47,40 @@ impl AppState {
|
||||
pub fn http_client(&self) -> &reqwest::Client {
|
||||
&self.inner.http_client
|
||||
}
|
||||
|
||||
pub fn cached_premium_branding(&self) -> Option<PremiumBranding> {
|
||||
let cache = self
|
||||
.inner
|
||||
.premium_branding
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
cache
|
||||
.as_ref()
|
||||
.filter(|(fetched_at, _)| fetched_at.elapsed() < PREMIUM_BRANDING_TTL)
|
||||
.map(|(_, branding)| branding.clone())
|
||||
}
|
||||
|
||||
pub fn remember_premium_branding(&self, branding: PremiumBranding) {
|
||||
*self
|
||||
.inner
|
||||
.premium_branding
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((Instant::now(), branding));
|
||||
}
|
||||
|
||||
pub async fn premium_branding(&self, client: &AdminApiClient) -> Option<PremiumBranding> {
|
||||
if let Some(branding) = self.cached_premium_branding() {
|
||||
return Some(branding);
|
||||
}
|
||||
let branding = PremiumBranding::from_discovery(
|
||||
&client
|
||||
.get_instance_premium_discovery()
|
||||
.await
|
||||
.log_error("load premium branding")?,
|
||||
);
|
||||
self.remember_premium_branding(branding.clone());
|
||||
Some(branding)
|
||||
}
|
||||
}
|
||||
|
||||
impl axum::extract::FromRef<AppState> for AdminConfig {
|
||||
|
||||
@@ -13,12 +13,39 @@ struct BadgeDef {
|
||||
tooltip: String,
|
||||
}
|
||||
|
||||
fn premium_tooltip(
|
||||
premium_type: i32,
|
||||
premium_since: Option<&str>,
|
||||
is_self_hosted: bool,
|
||||
self_hosted_premium_name: Option<&str>,
|
||||
) -> Option<String> {
|
||||
if is_self_hosted {
|
||||
let name = self_hosted_premium_name?;
|
||||
return Some(match premium_since {
|
||||
Some(since) => format!("{name} subscriber since {since}"),
|
||||
None => name.to_owned(),
|
||||
});
|
||||
}
|
||||
Some(if premium_type == premium_types::LIFETIME {
|
||||
match premium_since {
|
||||
Some(since) => format!("Fluxer Visionary since {since}"),
|
||||
None => "Fluxer Visionary".into(),
|
||||
}
|
||||
} else {
|
||||
match premium_since {
|
||||
Some(since) => format!("Fluxer Plutonium subscriber since {since}"),
|
||||
None => "Fluxer Plutonium".into(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn user_profile_badges(
|
||||
static_cdn_endpoint: &str,
|
||||
flags: u64,
|
||||
premium_type: Option<i32>,
|
||||
premium_since: Option<&str>,
|
||||
is_self_hosted: bool,
|
||||
self_hosted_premium_name: Option<&str>,
|
||||
size_sm: bool,
|
||||
) -> Markup {
|
||||
let cdn = static_cdn_endpoint.trim_end_matches('/');
|
||||
@@ -42,23 +69,11 @@ pub fn user_profile_badges(
|
||||
tooltip: "Fluxer Bug Hunter".into(),
|
||||
});
|
||||
}
|
||||
if !is_self_hosted
|
||||
&& let Some(pt) = premium_type
|
||||
if let Some(pt) = premium_type
|
||||
&& pt != premium_types::NONE
|
||||
&& let Some(tooltip) =
|
||||
premium_tooltip(pt, premium_since, is_self_hosted, self_hosted_premium_name)
|
||||
{
|
||||
let tooltip = if pt == premium_types::LIFETIME {
|
||||
match premium_since {
|
||||
Some(since) => format!("Fluxer Visionary since {since}"),
|
||||
None => "Fluxer Visionary".into(),
|
||||
}
|
||||
} else {
|
||||
match premium_since {
|
||||
Some(since) => {
|
||||
format!("Fluxer Plutonium subscriber since {since}")
|
||||
}
|
||||
None => "Fluxer Plutonium".into(),
|
||||
}
|
||||
};
|
||||
badges.push(BadgeDef {
|
||||
icon_url: format!("{cdn}/badges/plutonium.svg"),
|
||||
tooltip,
|
||||
@@ -84,3 +99,38 @@ pub fn user_profile_badges(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn render(self_hosted: bool, name: Option<&str>, premium_type: i32) -> String {
|
||||
user_profile_badges(
|
||||
"https://static.example.com",
|
||||
0,
|
||||
Some(premium_type),
|
||||
Some("2026-01-01"),
|
||||
self_hosted,
|
||||
name,
|
||||
false,
|
||||
)
|
||||
.into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_premium_badges_keep_their_fluxer_labels() {
|
||||
assert!(
|
||||
render(false, Some("Gold"), 1).contains("Fluxer Plutonium subscriber since 2026-01-01")
|
||||
);
|
||||
assert!(render(false, None, 2).contains("Fluxer Visionary since 2026-01-01"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_hosted_premium_badges_use_the_configured_name() {
|
||||
let markup = render(true, Some("Gold"), 1);
|
||||
assert!(markup.contains("Gold subscriber since 2026-01-01"));
|
||||
assert!(!markup.contains("Plutonium"));
|
||||
assert!(render(true, Some("Gold"), 2).contains("Gold subscriber since"));
|
||||
assert!(!render(true, None, 1).contains("img"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,13 +255,12 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
],
|
||||
},
|
||||
NavSection {
|
||||
title: "Hosted Features",
|
||||
title: "Premium",
|
||||
items: &[item!(
|
||||
"Gift Codes",
|
||||
"/gift-codes",
|
||||
"gift-codes",
|
||||
[acl::GIFT_CODES_GENERATE],
|
||||
hosted
|
||||
[acl::GIFT_CODES_GENERATE]
|
||||
)],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::PremiumBranding,
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
@@ -19,21 +20,52 @@ use maud::{Markup, html};
|
||||
pub const MAX_GIFT_CODES: u32 = 100;
|
||||
const DEFAULT_GIFT_COUNT: u32 = 10;
|
||||
|
||||
pub struct GiftCodesPremium {
|
||||
pub name: String,
|
||||
pub needs_mirror_mode: bool,
|
||||
}
|
||||
|
||||
impl GiftCodesPremium {
|
||||
pub fn from_branding(self_hosted: bool, branding: Option<&PremiumBranding>) -> Self {
|
||||
let default_name = if self_hosted { "Premium" } else { "Plutonium" };
|
||||
Self {
|
||||
name: branding
|
||||
.and_then(|branding| branding.name.as_deref())
|
||||
.unwrap_or(default_name)
|
||||
.to_owned(),
|
||||
needs_mirror_mode: self_hosted
|
||||
&& branding.is_some_and(|branding| !branding.premium_enabled),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gift_codes_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
csrf_token: &str,
|
||||
premium: &GiftCodesPremium,
|
||||
generated_codes: Option<&[String]>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let codes_value = generated_codes.map(|c| c.join("\n")).unwrap_or_default();
|
||||
let description = format!(
|
||||
"Create one-use {} gift URLs with a fixed positive duration. \
|
||||
Lifetime gifts cannot be generated here.",
|
||||
premium.name
|
||||
);
|
||||
|
||||
let content = html! {
|
||||
(page_header(
|
||||
"Gift Codes",
|
||||
Some("Create one-use Plutonium gift URLs with a fixed positive \
|
||||
duration. Lifetime gifts cannot be generated here."),
|
||||
))
|
||||
(page_header("Gift Codes", Some(&description)))
|
||||
|
||||
@if premium.needs_mirror_mode {
|
||||
(card(html! {
|
||||
p class="text-sm text-amber-700" {
|
||||
"The premium model is Everyone, so every member already has " (premium.name)
|
||||
" and gift codes cannot be generated or redeemed. Switch the premium model to \
|
||||
Mirror in Instance Config to use gift codes."
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
(card(html! {
|
||||
div class="flex flex-col gap-4" {
|
||||
@@ -107,3 +139,39 @@ pub fn gift_codes_page(
|
||||
};
|
||||
admin_layout(config, auth, "Gift Codes", "gift-codes", None, content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn branding(name: &str, premium_enabled: bool) -> PremiumBranding {
|
||||
PremiumBranding {
|
||||
name: Some(name.to_owned()),
|
||||
premium_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_name_comes_from_branding_with_per_deployment_fallbacks() {
|
||||
let hosted = GiftCodesPremium::from_branding(false, None);
|
||||
assert_eq!(hosted.name, "Plutonium");
|
||||
assert!(!hosted.needs_mirror_mode);
|
||||
let self_hosted = GiftCodesPremium::from_branding(true, None);
|
||||
assert_eq!(self_hosted.name, "Premium");
|
||||
assert!(!self_hosted.needs_mirror_mode);
|
||||
let gold = GiftCodesPremium::from_branding(true, Some(&branding("Gold", true)));
|
||||
assert_eq!(gold.name, "Gold");
|
||||
assert!(!gold.needs_mirror_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everyone_mode_is_only_flagged_on_self_hosted_instances() {
|
||||
assert!(
|
||||
GiftCodesPremium::from_branding(true, Some(&branding("Gold", false))).needs_mirror_mode
|
||||
);
|
||||
assert!(
|
||||
!GiftCodesPremium::from_branding(false, Some(&branding("Plutonium", false)))
|
||||
.needs_mirror_mode
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppBrandingConfigResponse, BillingCatalogMode, BillingPriceSet, InstanceBillingResponse,
|
||||
PremiumMode, TRI_STATE_DEFAULT, TRI_STATE_OFF, TRI_STATE_ON,
|
||||
},
|
||||
templates::components::{
|
||||
badge::{BadgeVariant, badge},
|
||||
form::{
|
||||
FORM_INPUT_CLASS, FORM_SELECT_CLASS, checkbox, csrf_input, form_actions,
|
||||
form_field_group, select_chevron, submit_button, text_input, textarea_input,
|
||||
},
|
||||
section_card::section_card_with_description,
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const PRICE_COLUMNS: [(&str, &str); 4] = [
|
||||
("billing_price_monthly", "Monthly"),
|
||||
("billing_price_yearly", "Yearly"),
|
||||
("billing_price_gift_1_month", "Gift 1 month"),
|
||||
("billing_price_gift_1_year", "Gift 1 year"),
|
||||
];
|
||||
|
||||
pub fn billing_blockers(
|
||||
billing: &InstanceBillingResponse,
|
||||
premium_mode: PremiumMode,
|
||||
) -> Vec<&'static str> {
|
||||
if billing.billing_active {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut blockers = Vec::new();
|
||||
if matches!(premium_mode, PremiumMode::Everyone) {
|
||||
blockers.push("the premium model is Everyone, so there is no paid tier to sell");
|
||||
}
|
||||
if !billing.effective_enabled {
|
||||
blockers.push("billing is not enabled");
|
||||
}
|
||||
if !billing.stripe_secret_key_set {
|
||||
blockers.push("no Stripe secret key is set");
|
||||
}
|
||||
let has_pair = billing
|
||||
.prices
|
||||
.as_ref()
|
||||
.is_some_and(|prices| prices.values().any(BillingPriceSet::has_recurring_pair));
|
||||
if billing.catalog_mode == BillingCatalogMode::Operator && !has_pair {
|
||||
blockers.push("no currency has both a monthly and a yearly price ID");
|
||||
}
|
||||
if blockers.is_empty() {
|
||||
blockers.push(match billing.catalog_mode {
|
||||
BillingCatalogMode::Env => {
|
||||
"the environment price catalog has no currency with both a monthly and a yearly price ID"
|
||||
}
|
||||
BillingCatalogMode::Operator => "the API reports billing as inactive",
|
||||
});
|
||||
}
|
||||
blockers
|
||||
}
|
||||
|
||||
fn billing_status(billing: &InstanceBillingResponse, premium_mode: PremiumMode) -> Markup {
|
||||
let blockers = billing_blockers(billing, premium_mode);
|
||||
html! {
|
||||
div class="space-y-2" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
@if billing.billing_active {
|
||||
(badge("Billing active", BadgeVariant::Success))
|
||||
} @else {
|
||||
(badge("Billing inactive", BadgeVariant::Default))
|
||||
}
|
||||
@match billing.catalog_mode {
|
||||
BillingCatalogMode::Operator => {
|
||||
(badge("Catalog: price table", BadgeVariant::Default))
|
||||
}
|
||||
BillingCatalogMode::Env => {
|
||||
(badge("Catalog: environment", BadgeVariant::Default))
|
||||
}
|
||||
}
|
||||
(secret_badge(
|
||||
"Stripe secret key",
|
||||
billing.stripe_secret_key_set,
|
||||
billing.stripe_secret_key_stored,
|
||||
BadgeVariant::Default,
|
||||
))
|
||||
(secret_badge(
|
||||
"Webhook secret",
|
||||
billing.stripe_webhook_secret_set,
|
||||
billing.stripe_webhook_secret_stored,
|
||||
BadgeVariant::Warning,
|
||||
))
|
||||
}
|
||||
@if !blockers.is_empty() {
|
||||
p class="text-sm text-neutral-600" {
|
||||
"Purchases are unavailable because " (blockers.join("; ")) "."
|
||||
}
|
||||
}
|
||||
@if billing.billing_active && !billing.stripe_webhook_secret_set {
|
||||
p class="text-sm text-amber-700" {
|
||||
"Without a webhook secret, Stripe events are rejected, so subscriptions never reach accounts."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn secret_badge(label: &str, is_set: bool, is_stored: bool, missing: BadgeVariant) -> Markup {
|
||||
match (is_set, is_stored) {
|
||||
(_, true) => badge(&format!("{label} set"), BadgeVariant::Success),
|
||||
(true, false) => badge(&format!("{label} from environment"), BadgeVariant::Success),
|
||||
(false, false) => badge(&format!("{label} missing"), missing),
|
||||
}
|
||||
}
|
||||
|
||||
fn secret_field(
|
||||
name: &str,
|
||||
clear_name: &str,
|
||||
label: &str,
|
||||
is_set: bool,
|
||||
is_stored: bool,
|
||||
) -> Markup {
|
||||
let helper = if !is_stored && is_set {
|
||||
"Set from the environment. Enter a value to override it, or leave blank to keep using it."
|
||||
} else {
|
||||
"Leave blank to keep the current value."
|
||||
};
|
||||
html! {
|
||||
div class="flex flex-col gap-2" {
|
||||
(form_field_group(
|
||||
label,
|
||||
name,
|
||||
false,
|
||||
None,
|
||||
Some(helper),
|
||||
html! {
|
||||
input type="password" id=(name) name=(name) value="" class=(FORM_INPUT_CLASS)
|
||||
autocomplete="new-password";
|
||||
},
|
||||
))
|
||||
@if is_stored {
|
||||
(checkbox(clear_name, "true", "Clear the stored value", false, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tri_state_value(value: Option<bool>) -> &'static str {
|
||||
match value {
|
||||
None => TRI_STATE_DEFAULT,
|
||||
Some(true) => TRI_STATE_ON,
|
||||
Some(false) => TRI_STATE_OFF,
|
||||
}
|
||||
}
|
||||
|
||||
fn tri_state_select(
|
||||
name: &str,
|
||||
label: &str,
|
||||
default_label: &str,
|
||||
stored: Option<bool>,
|
||||
helper: &str,
|
||||
) -> Markup {
|
||||
let selected = tri_state_value(stored);
|
||||
let options = [
|
||||
(TRI_STATE_DEFAULT, default_label),
|
||||
(TRI_STATE_ON, "On"),
|
||||
(TRI_STATE_OFF, "Off"),
|
||||
];
|
||||
form_field_group(
|
||||
label,
|
||||
name,
|
||||
false,
|
||||
None,
|
||||
Some(helper),
|
||||
html! {
|
||||
div class="relative" {
|
||||
select id=(name) name=(name) class=(FORM_SELECT_CLASS) {
|
||||
@for (value, display) in options {
|
||||
option value=(value) selected[value == selected] { (display) }
|
||||
}
|
||||
}
|
||||
(select_chevron())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn on_off(value: bool) -> &'static str {
|
||||
if value { "on" } else { "off" }
|
||||
}
|
||||
|
||||
fn checkout_options(billing: &InstanceBillingResponse) -> Markup {
|
||||
let automatic_tax = format!(
|
||||
"Calculates tax at checkout. Needs Stripe Tax activated and a head office address in the Stripe dashboard. Currently {}.",
|
||||
on_off(billing.effective_automatic_tax)
|
||||
);
|
||||
let tax_id = format!(
|
||||
"Lets buyers add a VAT or other tax ID at checkout. Pair it with automatic tax. Currently {}.",
|
||||
on_off(billing.effective_tax_id_collection)
|
||||
);
|
||||
let terms = format!(
|
||||
"Buyers must accept your terms of service at checkout. Needs a terms of service URL in the Stripe dashboard public details. Currently {}.",
|
||||
on_off(billing.effective_terms_consent_required)
|
||||
);
|
||||
html! {
|
||||
div class="space-y-4" {
|
||||
h4 class="text-sm font-medium text-neutral-900" { "Checkout options" }
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-3" {
|
||||
(tri_state_select(
|
||||
"billing_automatic_tax",
|
||||
"Automatic tax",
|
||||
"Use default",
|
||||
billing.automatic_tax,
|
||||
&automatic_tax,
|
||||
))
|
||||
(tri_state_select(
|
||||
"billing_tax_id_collection",
|
||||
"Tax ID collection",
|
||||
"Use default",
|
||||
billing.tax_id_collection,
|
||||
&tax_id,
|
||||
))
|
||||
(tri_state_select(
|
||||
"billing_terms_consent_required",
|
||||
"Terms consent",
|
||||
"Use default",
|
||||
billing.terms_consent_required,
|
||||
&terms,
|
||||
))
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Members manage and cancel subscriptions in the Stripe customer portal. It only opens after you save its \
|
||||
settings once in the Stripe dashboard under Settings, Billing, Customer portal."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn price_cell(name: &str, label: &str, value: Option<&str>) -> Markup {
|
||||
html! {
|
||||
td class="px-2 py-2" {
|
||||
input type="text" name=(name) value=(value.unwrap_or(""))
|
||||
placeholder="price_..." aria-label=(label)
|
||||
autocomplete="off" spellcheck="false"
|
||||
class=(FORM_INPUT_CLASS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn price_row(currency: &str, set: &BillingPriceSet) -> Markup {
|
||||
let values = [
|
||||
set.monthly.as_deref(),
|
||||
set.yearly.as_deref(),
|
||||
set.gift_1_month.as_deref(),
|
||||
set.gift_1_year.as_deref(),
|
||||
];
|
||||
html! {
|
||||
tr {
|
||||
td class="px-2 py-2" {
|
||||
input type="text" name="billing_price_currency" value=(currency)
|
||||
placeholder="GBP" maxlength="3" aria-label="Currency"
|
||||
autocomplete="off" spellcheck="false"
|
||||
class={(FORM_INPUT_CLASS) " w-24 uppercase"};
|
||||
}
|
||||
@for ((name, label), value) in PRICE_COLUMNS.iter().zip(values) {
|
||||
(price_cell(name, label, value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn price_table(billing: &InstanceBillingResponse) -> Markup {
|
||||
let empty = BillingPriceSet::default();
|
||||
html! {
|
||||
div class="space-y-2" {
|
||||
h4 class="text-sm font-medium text-neutral-900" { "Prices" }
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One row per currency, using Stripe price IDs from your own account. Monthly and yearly are the \
|
||||
subscription prices; the gift prices are one-time prices for buying gifts. Clear a currency to \
|
||||
remove its row. Leave the table empty to use the prices from environment variables."
|
||||
}
|
||||
div class="overflow-x-auto" {
|
||||
table class="min-w-full text-sm" {
|
||||
thead {
|
||||
tr class="text-left text-xs text-neutral-500" {
|
||||
th class="px-2 py-1 font-medium" { "Currency" }
|
||||
@for (_, label) in PRICE_COLUMNS {
|
||||
th class="px-2 py-1 font-medium" { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
@if let Some(prices) = &billing.prices {
|
||||
@for (currency, set) in prices {
|
||||
(price_row(currency, set))
|
||||
}
|
||||
}
|
||||
(price_row("", &empty))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn country_currencies_text(billing: &InstanceBillingResponse) -> String {
|
||||
billing
|
||||
.country_currencies
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|(country, currency)| format!("{country}={currency}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn legacy_prices_text(billing: &InstanceBillingResponse) -> String {
|
||||
billing
|
||||
.legacy_prices
|
||||
.iter()
|
||||
.flatten()
|
||||
.flat_map(|(slot, ids)| ids.iter().map(move |id| format!("{slot}={id}")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn premium_billing_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
branding: &AppBrandingConfigResponse,
|
||||
billing: &InstanceBillingResponse,
|
||||
premium_mode: PremiumMode,
|
||||
) -> Markup {
|
||||
let enabled_helper = format!(
|
||||
"Use environment setting follows FLUXER_STRIPE_ENABLED or the config file. Billing is currently {}.",
|
||||
on_off(billing.effective_enabled)
|
||||
);
|
||||
section_card_with_description(
|
||||
"Premium & Billing",
|
||||
"Name the premium tier and sell it through your own Stripe account. Subscriptions and gift purchases need \
|
||||
the Mirror premium model, a Stripe secret key and at least one currency with monthly and yearly prices.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_billing"}
|
||||
data-admin-result-form="true" {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-8" {
|
||||
div class="space-y-4" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Premium tier" }
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(text_input(
|
||||
"billing_premium_product_name",
|
||||
"Premium name",
|
||||
&branding.premium_product_name,
|
||||
"Premium",
|
||||
))
|
||||
(text_input(
|
||||
"billing_premium_info_url",
|
||||
"Premium info URL",
|
||||
branding.premium_info_url.as_deref().unwrap_or(""),
|
||||
"https://example.com/premium",
|
||||
))
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Clients show this name wherever the premium tier is mentioned. Clear it to use the default. \
|
||||
The info URL is an optional page that describes the tier."
|
||||
}
|
||||
@if matches!(premium_mode, PremiumMode::Everyone) {
|
||||
p class="text-sm text-amber-700" {
|
||||
"The premium model is Everyone, so every member already has premium limits and clients hide \
|
||||
premium. Switch the premium model to Mirror to sell subscriptions or redeem gift codes."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Stripe" }
|
||||
(billing_status(billing, premium_mode))
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(tri_state_select(
|
||||
"billing_enabled",
|
||||
"Billing",
|
||||
"Use environment setting",
|
||||
billing.enabled,
|
||||
&enabled_helper,
|
||||
))
|
||||
}
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(secret_field(
|
||||
"billing_stripe_secret_key",
|
||||
"billing_clear_stripe_secret_key",
|
||||
"Stripe secret key",
|
||||
billing.stripe_secret_key_set,
|
||||
billing.stripe_secret_key_stored,
|
||||
))
|
||||
(secret_field(
|
||||
"billing_stripe_webhook_secret",
|
||||
"billing_clear_stripe_webhook_secret",
|
||||
"Stripe webhook signing secret",
|
||||
billing.stripe_webhook_secret_set,
|
||||
billing.stripe_webhook_secret_stored,
|
||||
))
|
||||
}
|
||||
(form_field_group(
|
||||
"Webhook URL",
|
||||
"billing_webhook_url",
|
||||
false,
|
||||
None,
|
||||
Some("Add this endpoint in the Stripe dashboard, then paste its signing secret above."),
|
||||
html! {
|
||||
input type="text" id="billing_webhook_url" value=(billing.webhook_url)
|
||||
readonly class=(FORM_INPUT_CLASS);
|
||||
},
|
||||
))
|
||||
(checkout_options(billing))
|
||||
}
|
||||
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Catalog" }
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(text_input(
|
||||
"billing_default_currency",
|
||||
"Default currency",
|
||||
billing.default_currency.as_deref().unwrap_or(""),
|
||||
"GBP",
|
||||
))
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Used when a buyer's country has no mapping below. Leave blank to use the first currency in the table."
|
||||
}
|
||||
(price_table(billing))
|
||||
div class="grid grid-cols-1 gap-4 lg:grid-cols-2" {
|
||||
div class="space-y-2" {
|
||||
(textarea_input(
|
||||
"billing_country_currencies",
|
||||
"Country currencies",
|
||||
"SE=SEK\nGB=GBP",
|
||||
&country_currencies_text(billing),
|
||||
6,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One COUNTRY=CURRENCY per line, using 2-letter country codes. Each currency needs a row in the table."
|
||||
}
|
||||
}
|
||||
div class="space-y-2" {
|
||||
(textarea_input(
|
||||
"billing_legacy_prices",
|
||||
"Legacy prices",
|
||||
"monthly_GBP=price_...",
|
||||
&legacy_prices_text(billing),
|
||||
6,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Older price IDs that existing subscribers may still be on, one SLOT_CURRENCY=price ID per line. \
|
||||
Repeat a slot for several IDs. Slots are monthly, yearly, gift_1_month and gift_1_year."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save premium & billing"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn operator_billing() -> InstanceBillingResponse {
|
||||
InstanceBillingResponse {
|
||||
enabled: Some(true),
|
||||
effective_enabled: true,
|
||||
stripe_secret_key_set: true,
|
||||
stripe_webhook_secret_set: true,
|
||||
stripe_secret_key_stored: true,
|
||||
stripe_webhook_secret_stored: true,
|
||||
default_currency: Some("GBP".to_owned()),
|
||||
prices: Some(BTreeMap::from([(
|
||||
"GBP".to_owned(),
|
||||
BillingPriceSet {
|
||||
monthly: Some("price_1GbpM".to_owned()),
|
||||
yearly: Some("price_1GbpY".to_owned()),
|
||||
gift_1_month: None,
|
||||
gift_1_year: Some("price_1GbpG".to_owned()),
|
||||
},
|
||||
)])),
|
||||
country_currencies: Some(BTreeMap::from([
|
||||
("GB".to_owned(), "GBP".to_owned()),
|
||||
("IE".to_owned(), "GBP".to_owned()),
|
||||
])),
|
||||
legacy_prices: Some(BTreeMap::from([(
|
||||
"monthly_GBP".to_owned(),
|
||||
vec!["price_1OldA".to_owned(), "price_1OldB".to_owned()],
|
||||
)])),
|
||||
billing_active: true,
|
||||
stripe_serviceable: true,
|
||||
catalog_mode: BillingCatalogMode::Operator,
|
||||
webhook_url: "https://api.example.com/stripe/webhook".to_owned(),
|
||||
automatic_tax: None,
|
||||
tax_id_collection: Some(true),
|
||||
terms_consent_required: Some(false),
|
||||
effective_automatic_tax: false,
|
||||
effective_tax_id_collection: true,
|
||||
effective_terms_consent_required: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn branding(name: &str) -> AppBrandingConfigResponse {
|
||||
AppBrandingConfigResponse {
|
||||
premium_product_name: name.to_owned(),
|
||||
premium_info_url: Some("https://example.com/gold".to_owned()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_renders_every_field_and_one_empty_price_row() {
|
||||
let markup = premium_billing_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&branding("Gold"),
|
||||
&operator_billing(),
|
||||
PremiumMode::Mirror,
|
||||
)
|
||||
.into_string();
|
||||
assert!(markup.contains("action=\"/admin/instance-config?action=update_billing\""));
|
||||
assert!(markup.contains("data-admin-result-form=\"true\""));
|
||||
assert!(markup.contains("<option value=\"on\" selected>On</option>"));
|
||||
assert!(markup.contains("name=\"billing_automatic_tax\""));
|
||||
assert!(markup.contains("name=\"billing_tax_id_collection\""));
|
||||
assert!(markup.contains("name=\"billing_terms_consent_required\""));
|
||||
assert!(markup.contains("Customer portal"));
|
||||
assert!(markup.contains("name=\"billing_premium_product_name\""));
|
||||
assert!(markup.contains("value=\"Gold\""));
|
||||
assert!(markup.contains("value=\"https://example.com/gold\""));
|
||||
assert!(markup.contains("name=\"billing_enabled\""));
|
||||
assert!(markup.contains("type=\"password\" id=\"billing_stripe_secret_key\""));
|
||||
assert!(markup.contains("name=\"billing_clear_stripe_secret_key\""));
|
||||
assert!(markup.contains("name=\"billing_clear_stripe_webhook_secret\""));
|
||||
assert!(markup.contains("value=\"https://api.example.com/stripe/webhook\""));
|
||||
assert!(markup.contains("Billing active"));
|
||||
assert!(!markup.contains("Purchases are unavailable"));
|
||||
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 2);
|
||||
assert_eq!(
|
||||
markup.matches("name=\"billing_price_gift_1_year\"").count(),
|
||||
2
|
||||
);
|
||||
assert!(markup.contains("value=\"price_1GbpG\""));
|
||||
assert!(markup.contains("GB=GBP\nIE=GBP"));
|
||||
assert!(markup.contains("monthly_GBP=price_1OldA\nmonthly_GBP=price_1OldB"));
|
||||
assert!(!markup.contains("Plutonium"));
|
||||
assert!(!markup.contains("sk_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_secrets_have_no_clear_checkbox() {
|
||||
let billing = InstanceBillingResponse::default();
|
||||
let markup = premium_billing_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&branding("Premium"),
|
||||
&billing,
|
||||
PremiumMode::Everyone,
|
||||
)
|
||||
.into_string();
|
||||
assert!(!markup.contains("billing_clear_stripe_secret_key"));
|
||||
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
|
||||
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 1);
|
||||
assert!(markup.contains("Switch the premium model to Mirror"));
|
||||
assert!(markup.contains("Catalog: environment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_secrets_are_labelled_and_cannot_be_cleared() {
|
||||
let billing = InstanceBillingResponse {
|
||||
stripe_secret_key_set: true,
|
||||
stripe_webhook_secret_set: true,
|
||||
..Default::default()
|
||||
};
|
||||
let markup = premium_billing_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&branding("Premium"),
|
||||
&billing,
|
||||
PremiumMode::Mirror,
|
||||
)
|
||||
.into_string();
|
||||
assert!(markup.contains("Stripe secret key from environment"));
|
||||
assert!(markup.contains("Webhook secret from environment"));
|
||||
assert!(markup.contains("Set from the environment"));
|
||||
assert!(!markup.contains("billing_clear_stripe_secret_key"));
|
||||
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tri_state_selects_reflect_the_stored_value() {
|
||||
let render = |stored| {
|
||||
tri_state_select(
|
||||
"billing_enabled",
|
||||
"Billing",
|
||||
"Use environment setting",
|
||||
stored,
|
||||
"",
|
||||
)
|
||||
.into_string()
|
||||
};
|
||||
assert!(
|
||||
render(None)
|
||||
.contains("<option value=\"default\" selected>Use environment setting</option>")
|
||||
);
|
||||
assert!(render(Some(true)).contains("<option value=\"on\" selected>On</option>"));
|
||||
assert!(render(Some(false)).contains("<option value=\"off\" selected>Off</option>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockers_explain_why_billing_is_inactive() {
|
||||
let mut billing = InstanceBillingResponse::default();
|
||||
assert_eq!(
|
||||
billing_blockers(&billing, PremiumMode::Everyone),
|
||||
vec![
|
||||
"the premium model is Everyone, so there is no paid tier to sell",
|
||||
"billing is not enabled",
|
||||
"no Stripe secret key is set",
|
||||
]
|
||||
);
|
||||
billing.effective_enabled = true;
|
||||
billing.stripe_secret_key_set = true;
|
||||
assert_eq!(
|
||||
billing_blockers(&billing, PremiumMode::Mirror),
|
||||
vec![
|
||||
"the environment price catalog has no currency with both a monthly and a yearly price ID"
|
||||
]
|
||||
);
|
||||
billing.catalog_mode = BillingCatalogMode::Operator;
|
||||
billing.prices = Some(BTreeMap::from([(
|
||||
"GBP".to_owned(),
|
||||
BillingPriceSet {
|
||||
monthly: Some("price_1A".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
)]));
|
||||
assert_eq!(
|
||||
billing_blockers(&billing, PremiumMode::Mirror),
|
||||
vec!["no currency has both a monthly and a yearly price ID"]
|
||||
);
|
||||
assert!(billing_blockers(&operator_billing(), PremiumMode::Mirror).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
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, PROFILE_TIMEZONE_DEFAULT_SALT,
|
||||
PendingRegistrationResponse, ProfileTimezoneConfigResponse, PushRelayConfigResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -24,6 +25,7 @@ use crate::{
|
||||
section_card::{section_card_simple, section_card_with_description},
|
||||
},
|
||||
layout::admin_layout,
|
||||
pages::instance_billing::premium_billing_section,
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
@@ -125,7 +127,25 @@ pub fn instance_config_page(
|
||||
"Community & policy",
|
||||
"Community shape, direct messaging, the premium model, and optional embed services.",
|
||||
html! {
|
||||
(policy_config_section(base, csrf_token, &instance_config.policy))
|
||||
(policy_config_section(
|
||||
base,
|
||||
csrf_token,
|
||||
&instance_config.policy,
|
||||
&instance_config.app_public.branding.premium_product_name,
|
||||
))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
"Premium & billing",
|
||||
"The premium tier's name, Stripe credentials and the prices members pay.",
|
||||
html! {
|
||||
(premium_billing_section(
|
||||
base,
|
||||
csrf_token,
|
||||
&instance_config.app_public.branding,
|
||||
&instance_config.billing,
|
||||
instance_config.policy.premium_mode,
|
||||
))
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -136,6 +156,13 @@ pub fn instance_config_page(
|
||||
(integrations_config_section(base, csrf_token, &instance_config.integrations))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
"Push notifications",
|
||||
"Consent for the relay that delivers official mobile app notifications.",
|
||||
html! {
|
||||
(push_relay_section(base, csrf_token, &instance_config.push_relay))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
"Media & retention",
|
||||
"Attachment expiry rules that can be changed without editing environment variables.",
|
||||
@@ -148,8 +175,9 @@ pub fn instance_config_page(
|
||||
"Gateway rollout behavior and the limit rules applied to users and guilds.",
|
||||
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))
|
||||
@@ -198,7 +226,12 @@ fn config_group(title: &str, description: &str, content: Markup) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn policy_config_section(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
|
||||
fn policy_config_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
premium_name: &str,
|
||||
) -> Markup {
|
||||
section_card_with_description(
|
||||
"Community & Policy",
|
||||
"Control whether this instance runs as a single community, whether direct messages and \
|
||||
@@ -208,7 +241,7 @@ fn policy_config_section(base: &str, csrf_token: &str, policy: &InstancePolicyRe
|
||||
div class="space-y-8" {
|
||||
(single_community_form(base, csrf_token, policy))
|
||||
(direct_messages_form(base, csrf_token, policy))
|
||||
(premium_mode_form(base, csrf_token, policy))
|
||||
(premium_mode_form(base, csrf_token, policy, premium_name))
|
||||
(services_form(base, csrf_token, policy))
|
||||
}
|
||||
},
|
||||
@@ -352,7 +385,14 @@ fn deferred_phone_gate_form(
|
||||
}
|
||||
}
|
||||
|
||||
fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
|
||||
fn premium_mode_form(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
premium_name: &str,
|
||||
) -> Markup {
|
||||
let mirror_label = format!("Mirror (Free and {premium_name} tiers)");
|
||||
let everyone_label = format!("Everyone (every member gets {premium_name} limits)");
|
||||
html! {
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Premium model" }
|
||||
@@ -360,8 +400,8 @@ fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyRespon
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(select_input("policy_premium_mode", "Premium model", &[
|
||||
("mirror", "Mirror (Free and Premium tiers)"),
|
||||
("everyone", "Everyone (every member gets Plutonium limits)"),
|
||||
("mirror", mirror_label.as_str()),
|
||||
("everyone", everyone_label.as_str()),
|
||||
], policy.premium_mode.as_str()))
|
||||
(form_actions(html! {
|
||||
(submit_button("Save premium model"))
|
||||
@@ -982,195 +1022,69 @@ fn gateway_rollout_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn voice_noise_suppression_section(
|
||||
fn push_relay_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||
push_relay: &PushRelayConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if voice_noise_suppression.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
let status = if push_relay.relay_consent_accepted {
|
||||
("Accepted", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
("Not accepted", BadgeVariant::Default)
|
||||
};
|
||||
let backend_labels =
|
||||
NoiseSuppressionBackend::ALL.map(|backend| (backend.to_string(), backend.label()));
|
||||
let backend_options = backend_labels
|
||||
.iter()
|
||||
.map(|(value, label)| (value.as_str(), *label))
|
||||
.collect::<Vec<_>>();
|
||||
let included_user_ids = voice_noise_suppression.included_user_ids.join("\n");
|
||||
let excluded_user_ids = voice_noise_suppression.excluded_user_ids.join("\n");
|
||||
let guild_overrides = voice_noise_suppression
|
||||
.guild_overrides
|
||||
.iter()
|
||||
.map(|entry| format!("{}={}", entry.guild_id, entry.backend))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let accepted_at =
|
||||
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
|
||||
let accepted_by = push_relay
|
||||
.relay_consent_accepted_by
|
||||
.as_deref()
|
||||
.unwrap_or("Nobody");
|
||||
section_card_with_description(
|
||||
"Voice Noise Suppression",
|
||||
"Pick which noise suppression backend targeted clients load in voice calls, and how many \
|
||||
of them are targeted. While the master switch below is off nothing on this form reaches \
|
||||
any client: every user keeps the audio pipeline they have today, whatever the rest of \
|
||||
these fields say.",
|
||||
"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_voice_noise_suppression"} {
|
||||
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Relay consent" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (voice_noise_suppression.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"voice_ns_enabled",
|
||||
"push_relay_consent_accepted",
|
||||
"true",
|
||||
"Serve noise suppression assignments to clients",
|
||||
voice_noise_suppression.enabled,
|
||||
"Accept the push relay supplemental privacy notice",
|
||||
push_relay.relay_consent_accepted,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
feature is inert and keeps its current behavior, so the rollout, targeting \
|
||||
and override fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Backends" }
|
||||
(select_input(
|
||||
"voice_ns_default_backend",
|
||||
"Default Backend",
|
||||
&backend_options,
|
||||
&voice_noise_suppression.default_backend.to_string(),
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"The backend assigned by always-on user rules and the canary. A default \
|
||||
that is not ticked below is unavailable, but per-guild overrides can \
|
||||
still target users."
|
||||
}
|
||||
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
|
||||
@for backend in NoiseSuppressionBackend::ALL {
|
||||
(checkbox(
|
||||
"voice_ns_enabled_backends[]",
|
||||
&backend.to_string(),
|
||||
backend.label(),
|
||||
voice_noise_suppression.enabled_backends.contains(&backend),
|
||||
true,
|
||||
))
|
||||
"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"
|
||||
}
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Backends clients are allowed to load. Unticking one withdraws it from \
|
||||
every user, including anyone who picked it themselves."
|
||||
}
|
||||
(checkbox(
|
||||
"voice_ns_allow_user_override",
|
||||
"true",
|
||||
"Let users pick their own backend from the ticked list",
|
||||
voice_noise_suppression.allow_user_override,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Applies only to users who are already targeted. It never pulls anyone \
|
||||
into the rollout."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&voice_noise_suppression.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"voice_ns_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&voice_noise_suppression.rollout_salt,
|
||||
"voice-ns-v1",
|
||||
))
|
||||
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."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save; blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.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."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Per-guild overrides" }
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_guild_overrides",
|
||||
"Guild Overrides",
|
||||
"1600000000000000001=rnnoise\n1600000000000000002=deep_filter",
|
||||
&guild_overrides,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.guild_overrides.len(),
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One per line as guild_id=backend. A guild \
|
||||
rule targets callers even outside the canary. Always-on user rules \
|
||||
take precedence, and excluded users stay off. Invalid lines and \
|
||||
conflicting rules for the same guild prevent the save. \
|
||||
Unticked backends stay stored but are inactive."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(number_field(
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression Strength",
|
||||
&voice_noise_suppression.suppression_strength.to_string(),
|
||||
Some(0), Some(100), "1",
|
||||
Some("How aggressively the backend removes noise, 0 to 100. Higher values cut more background but chew more of the voice."),
|
||||
(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 Voice Noise Suppression Configuration"))
|
||||
(submit_button("Save Push Relay Settings"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1178,72 +1092,93 @@ fn voice_noise_suppression_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn screen_share_delivery_section(
|
||||
fn domain_migration_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
screen_share_delivery: &ScreenShareDeliveryConfigResponse,
|
||||
domain_migration: &DomainMigrationConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if screen_share_delivery.enabled {
|
||||
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 +1186,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 +1238,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"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1881,7 +2157,7 @@ fn sso_config_section(base: &str, csrf_token: &str, sso: &SsoConfigResponse) ->
|
||||
|
||||
fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Markup {
|
||||
let description = if limit_config.self_hosted.unwrap_or(false) {
|
||||
"Self-hosted instance with all premium features enabled. Configure user and guild limits."
|
||||
"Self-hosted instance with all premium features enabled by default. Configure user and guild limits."
|
||||
} else {
|
||||
"Configure limit rules that control user and guild restrictions based on traits and features."
|
||||
};
|
||||
@@ -1903,63 +2179,73 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::types::VoiceNoiseSuppressionGuildOverride;
|
||||
|
||||
fn rendered_voice_noise_suppression_section(
|
||||
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||
) -> String {
|
||||
voice_noise_suppression_section("/admin", "csrf", voice_noise_suppression).into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_shows_list_counts_and_caps() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
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(),
|
||||
],
|
||||
guild_overrides: vec![VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000001".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Rnnoise,
|
||||
}],
|
||||
..VoiceNoiseSuppressionConfigResponse::default()
|
||||
..DomainMigrationConfigResponse::default()
|
||||
};
|
||||
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
|
||||
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("1 of 200 stored"));
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_share_delivery_section_shows_list_counts_and_the_master_switch() {
|
||||
let screen_share_delivery = ScreenShareDeliveryConfigResponse {
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
..ScreenShareDeliveryConfigResponse::default()
|
||||
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 premium_mode_options_use_the_configured_premium_name() {
|
||||
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"));
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(!markup.contains("at the cap"));
|
||||
premium_mode_form("/admin", "csrf", &InstancePolicyResponse::default(), "Gold")
|
||||
.into_string();
|
||||
assert!(markup.contains("Mirror (Free and Gold tiers)"));
|
||||
assert!(markup.contains("Everyone (every member gets Gold limits)"));
|
||||
assert!(!markup.contains("Plutonium"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
fn domain_migration_section_flags_a_list_at_its_cap() {
|
||||
let domain_migration = DomainMigrationConfigResponse {
|
||||
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect(),
|
||||
..VoiceNoiseSuppressionConfigResponse::default()
|
||||
..DomainMigrationConfigResponse::default()
|
||||
};
|
||||
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
|
||||
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
|
||||
assert!(markup.contains("1000 of 1000 stored"));
|
||||
assert!(markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod gift_codes;
|
||||
pub mod guild_detail;
|
||||
pub mod guild_detail_tabs;
|
||||
pub mod guilds_list;
|
||||
pub mod instance_billing;
|
||||
pub mod instance_config;
|
||||
pub mod job_detail;
|
||||
pub mod jobs_list;
|
||||
|
||||
@@ -38,11 +38,22 @@ pub fn user_detail_page(
|
||||
auth: &AuthContext,
|
||||
user: Option<&AdminUser>,
|
||||
user_id: &str,
|
||||
premium_badge_name: Option<&str>,
|
||||
is_htmx: bool,
|
||||
) -> Markup {
|
||||
user_detail_with_tab(config, auth, user, user_id, "overview", None, is_htmx)
|
||||
user_detail_with_tab(
|
||||
config,
|
||||
auth,
|
||||
user,
|
||||
user_id,
|
||||
"overview",
|
||||
None,
|
||||
premium_badge_name,
|
||||
is_htmx,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn user_detail_with_tab(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
@@ -50,10 +61,13 @@ pub fn user_detail_with_tab(
|
||||
user_id: &str,
|
||||
active_tab: &str,
|
||||
tab_body: Option<Markup>,
|
||||
premium_badge_name: Option<&str>,
|
||||
is_htmx: bool,
|
||||
) -> Markup {
|
||||
let content = match user {
|
||||
Some(user) => render_user_detail(config, auth, user, active_tab, tab_body),
|
||||
Some(user) => {
|
||||
render_user_detail(config, auth, user, active_tab, tab_body, premium_badge_name)
|
||||
}
|
||||
None => not_found_state("User", user_id, None, None),
|
||||
};
|
||||
let title = user
|
||||
@@ -79,6 +93,7 @@ fn render_user_detail(
|
||||
user: &AdminUser,
|
||||
active_tab: &str,
|
||||
tab_body: Option<Markup>,
|
||||
premium_badge_name: Option<&str>,
|
||||
) -> Markup {
|
||||
let display_name = user
|
||||
.global_name
|
||||
@@ -143,6 +158,7 @@ fn render_user_detail(
|
||||
user.premium_type,
|
||||
user.premium_since.as_deref(),
|
||||
config.self_hosted,
|
||||
premium_badge_name,
|
||||
false,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -33,7 +33,12 @@ fn status_badge(user: &AdminUser) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_peek_fragment(config: &AdminConfig, user: &AdminUser, admin_acls: &[String]) -> Markup {
|
||||
pub fn user_peek_fragment(
|
||||
config: &AdminConfig,
|
||||
user: &AdminUser,
|
||||
admin_acls: &[String],
|
||||
premium_badge_name: Option<&str>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
|
||||
let display = user
|
||||
@@ -62,6 +67,7 @@ pub fn user_peek_fragment(config: &AdminConfig, user: &AdminUser, admin_acls: &[
|
||||
user.premium_type,
|
||||
user.premium_since.as_deref(),
|
||||
config.self_hosted,
|
||||
premium_badge_name,
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ impl UserListParams {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn users_list_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
@@ -102,10 +103,18 @@ pub fn users_list_page(
|
||||
results: Option<&[AdminUser]>,
|
||||
has_more: bool,
|
||||
can_view_email: bool,
|
||||
premium_badge_name: Option<&str>,
|
||||
is_htmx: bool,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let results_markup = render_results(config, params, results, has_more, can_view_email);
|
||||
let results_markup = render_results(
|
||||
config,
|
||||
params,
|
||||
results,
|
||||
has_more,
|
||||
can_view_email,
|
||||
premium_badge_name,
|
||||
);
|
||||
|
||||
if is_htmx {
|
||||
return results_markup;
|
||||
@@ -114,7 +123,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)
|
||||
@@ -212,6 +224,7 @@ fn render_results(
|
||||
results: Option<&[AdminUser]>,
|
||||
page_has_more: bool,
|
||||
can_view_email: bool,
|
||||
premium_badge_name: Option<&str>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
@@ -233,7 +246,7 @@ fn render_results(
|
||||
"Copy IDs"
|
||||
}
|
||||
}
|
||||
(render_users_table(config, users, can_view_email))
|
||||
(render_users_table(config, users, can_view_email, premium_badge_name))
|
||||
script { (maud::PreEscaped(copy_ids_script())) }
|
||||
@if !params.has_id_lookup() && (params.page > 0 || page_has_more) {
|
||||
(pagination_controls(base, params, page_has_more))
|
||||
@@ -298,7 +311,12 @@ fn user_status_badge(user: &AdminUser) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email: bool) -> Markup {
|
||||
fn render_users_table(
|
||||
config: &AdminConfig,
|
||||
users: &[AdminUser],
|
||||
can_view_email: bool,
|
||||
premium_badge_name: Option<&str>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
table_container(html! {
|
||||
table class="min-w-full divide-y divide-neutral-200" {
|
||||
@@ -340,6 +358,7 @@ fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email:
|
||||
user.premium_type,
|
||||
user.premium_since.as_deref(),
|
||||
config.self_hosted,
|
||||
premium_badge_name,
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ impl MultiValueForm {
|
||||
self.fields.contains_key(key)
|
||||
}
|
||||
|
||||
pub fn values(&self, key: &str) -> &[String] {
|
||||
self.fields.get(key).map(Vec::as_slice).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn first(&self, key: &str) -> Option<&str> {
|
||||
self.fields
|
||||
.get(key)
|
||||
|
||||
@@ -392,31 +392,52 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"voice_e2ee_scope": "guild_feature_only",
|
||||
"future_rollout_knob": 3
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"enabled": true,
|
||||
"config_version": 4,
|
||||
"default_backend": "rnnoise",
|
||||
"enabled_backends": ["none", "standard", "rnnoise"],
|
||||
"allow_user_override": true,
|
||||
"rollout_basis_points": 10000,
|
||||
"rollout_salt": "voice-ns-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"suppression_strength": 80,
|
||||
"future_presentation_knob": "verbose",
|
||||
"future_knob": 7,
|
||||
"future_object_knob": {"nested": true},
|
||||
"future_list_knob": ["a", "b"]
|
||||
"push_relay": {
|
||||
"relay_consent_accepted": true,
|
||||
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
|
||||
"relay_consent_accepted_by": "1130650140672000000"
|
||||
},
|
||||
"screen_share_delivery": {
|
||||
"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,
|
||||
"future_presentation_knob": "verbose",
|
||||
"future_knob": 7,
|
||||
"future_object_knob": {"nested": true},
|
||||
"future_list_knob": ["a", "b"],
|
||||
"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": {
|
||||
@@ -435,7 +456,9 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"wordmark_url": "https://cdn.example.com/wordmark.svg",
|
||||
"favicon_url": "https://cdn.example.com/favicon.ico",
|
||||
"theme_color": "#5865f2",
|
||||
"future_asset_url": "https://cdn.example.com/future.png"
|
||||
"future_asset_url": "https://cdn.example.com/future.png",
|
||||
"premium_product_name": "Gold",
|
||||
"premium_info_url": "https://example.com/gold"
|
||||
},
|
||||
"setup": {"configured": true},
|
||||
"legal": {
|
||||
@@ -533,6 +556,36 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
}
|
||||
}
|
||||
},
|
||||
"billing": {
|
||||
"enabled": true,
|
||||
"effective_enabled": true,
|
||||
"stripe_secret_key_set": true,
|
||||
"stripe_webhook_secret_set": false,
|
||||
"stripe_secret_key_stored": true,
|
||||
"stripe_webhook_secret_stored": false,
|
||||
"automatic_tax": null,
|
||||
"tax_id_collection": true,
|
||||
"terms_consent_required": false,
|
||||
"effective_automatic_tax": false,
|
||||
"effective_tax_id_collection": true,
|
||||
"effective_terms_consent_required": false,
|
||||
"default_currency": "GBP",
|
||||
"prices": {
|
||||
"GBP": {
|
||||
"monthly": "price_1GbpM",
|
||||
"yearly": "price_1GbpY",
|
||||
"gift_1_month": null,
|
||||
"gift_1_year": "price_1GbpG"
|
||||
}
|
||||
},
|
||||
"country_currencies": {"GB": "GBP"},
|
||||
"legacy_prices": {"monthly_GBP": ["price_1OldA"]},
|
||||
"billing_active": true,
|
||||
"stripe_serviceable": true,
|
||||
"catalog_mode": "operator",
|
||||
"webhook_url": "https://api.example.com/stripe/webhook",
|
||||
"future_billing_knob": 1
|
||||
},
|
||||
"future_section": {"enabled": true, "rollout_basis_points": 10000},
|
||||
"future_flag": 3
|
||||
}"##;
|
||||
@@ -542,30 +595,63 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
);
|
||||
|
||||
assert!(!resp.self_hosted);
|
||||
assert!(resp.voice_noise_suppression.enabled);
|
||||
assert_eq!(resp.voice_noise_suppression.config_version, 4);
|
||||
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_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));
|
||||
assert_eq!(resp.app_public.branding.product_name, "Fluxer");
|
||||
assert_eq!(resp.app_public.branding.premium_product_name, "Gold");
|
||||
assert!(resp.billing.billing_active);
|
||||
assert!(resp.media.attachment_decay.effective.enabled);
|
||||
|
||||
let ours: types::InstanceConfigResponse =
|
||||
serde_json::from_str(json).expect("hand-written instance config");
|
||||
assert_eq!(ours.app_public.branding.premium_product_name, "Gold");
|
||||
assert!(ours.billing.stripe_secret_key_stored);
|
||||
assert_eq!(ours.billing.tax_id_collection, Some(true));
|
||||
assert!(ours.billing.effective_tax_id_collection);
|
||||
assert_eq!(
|
||||
ours.app_public.branding.premium_info_url.as_deref(),
|
||||
Some("https://example.com/gold")
|
||||
);
|
||||
assert!(ours.billing.billing_active);
|
||||
assert!(ours.billing.stripe_serviceable);
|
||||
assert!(!ours.billing.stripe_webhook_secret_set);
|
||||
assert_eq!(
|
||||
ours.billing.catalog_mode,
|
||||
types::BillingCatalogMode::Operator
|
||||
);
|
||||
assert_eq!(ours.billing.default_currency.as_deref(), Some("GBP"));
|
||||
let gbp = &ours.billing.prices.as_ref().expect("prices")["GBP"];
|
||||
assert_eq!(gbp.gift_1_year.as_deref(), Some("price_1GbpG"));
|
||||
assert_eq!(gbp.gift_1_month, None);
|
||||
assert_eq!(
|
||||
ours.billing.legacy_prices.as_ref().expect("legacy")["monthly_GBP"],
|
||||
vec!["price_1OldA".to_owned()]
|
||||
);
|
||||
|
||||
let without_unknown_keys = json
|
||||
.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(
|
||||
@@ -574,6 +660,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
)
|
||||
.replace("\"future_service_enabled\": true,", "")
|
||||
.replace("\"future_curve\": 1.5,", "")
|
||||
.replace(",\n \"future_billing_knob\": 1", "")
|
||||
.replace(
|
||||
"\"future_section\": {\"enabled\": true, \"rollout_basis_points\": 10000},",
|
||||
"",
|
||||
@@ -587,6 +674,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 +993,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",
|
||||
|
||||
@@ -464,8 +464,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 +816,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!([])),
|
||||
@@ -1176,34 +1178,15 @@ fn instance_config() -> Value {
|
||||
"max_concurrent_guild_starts": 16,
|
||||
"voice_e2ee_scope": "guild_feature_only"
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"domain_migration": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"default_backend": "standard",
|
||||
"enabled_backends": [
|
||||
"none",
|
||||
"standard",
|
||||
"gate",
|
||||
"speex",
|
||||
"rnnoise",
|
||||
"gtcrn",
|
||||
"deep_filter"
|
||||
],
|
||||
"allow_user_override": true,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "voice-ns-v1",
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"suppression_strength": 80
|
||||
},
|
||||
"screen_share_delivery": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "screen-share-delivery-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
"anonymous_rollout_basis_points": 0,
|
||||
"standalone_forwarding": false
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"poll_interval_seconds": 300,
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@simplewebauthn/server": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@vvo/tzdb": "catalog:",
|
||||
"altcha-lib": "catalog:",
|
||||
"archiver": "catalog:",
|
||||
"argon2": "catalog:",
|
||||
"bowser": "catalog:",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ interface PostgresIpInfoOptions {
|
||||
}
|
||||
|
||||
const VALUE_SEPARATOR = '\u001f';
|
||||
export const IPINFO_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
|
||||
export const IPINFO_REQUEST_AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
|
||||
function getClient(options: PostgresIpInfoOptions): IPostgresClient | null {
|
||||
return options.client ?? options.getClient?.() ?? null;
|
||||
@@ -34,12 +36,9 @@ async function upsertKvRow(
|
||||
partitionKey: string,
|
||||
key: string,
|
||||
row: Record<string, unknown>,
|
||||
ttlSeconds?: number,
|
||||
ttlSeconds: number,
|
||||
): Promise<void> {
|
||||
const expiresAt =
|
||||
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0
|
||||
? new Date(Date.now() + ttlSeconds * 1000)
|
||||
: null;
|
||||
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
|
||||
await client.query(
|
||||
`INSERT INTO ${table(client)} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
@@ -77,7 +76,14 @@ export function createPostgresIpInfoCache(options: PostgresIpInfoOptions): IpInf
|
||||
try {
|
||||
const client = getClient(options);
|
||||
if (!client) return;
|
||||
await upsertKvRow(client, 'ipinfo_cache', rowKey([key]), rowKey([key]), {cache_key: key, payload}, ttlSeconds);
|
||||
await upsertKvRow(
|
||||
client,
|
||||
'ipinfo_cache',
|
||||
rowKey([key]),
|
||||
rowKey([key]),
|
||||
{cache_key: key, payload},
|
||||
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : IPINFO_CACHE_TTL_SECONDS,
|
||||
);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_cache_set');
|
||||
}
|
||||
@@ -124,6 +130,7 @@ export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOp
|
||||
is_residential_proxy: event.isResidentialProxy,
|
||||
metadata_json: serializeMetadata(event.metadata),
|
||||
},
|
||||
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
|
||||
);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_request_audit_record');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {Pool, PoolClient, QueryResult, QueryResultRow} from 'pg';
|
||||
import type {Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow} from 'pg';
|
||||
import pg from 'pg';
|
||||
|
||||
const MAX_DIAGNOSTIC_FIELD_LENGTH = 128;
|
||||
@@ -131,7 +131,7 @@ class PostgresClient implements IPostgresClient {
|
||||
}
|
||||
|
||||
private async openPool(): Promise<void> {
|
||||
const pool = new pg.Pool({
|
||||
const poolConfig: PoolConfig & {scramMaxIterations: number} = {
|
||||
connectionString: this.config.url || undefined,
|
||||
host: this.config.url ? undefined : (this.config.host ?? '127.0.0.1'),
|
||||
port: this.config.url ? undefined : (this.config.port ?? 5432),
|
||||
@@ -140,7 +140,9 @@ class PostgresClient implements IPostgresClient {
|
||||
password: this.config.url ? undefined : (this.config.password ?? 'fluxer'),
|
||||
ssl: this.config.ssl ? {rejectUnauthorized: true, ca: normalizePem(this.config.sslCa)} : undefined,
|
||||
max: this.config.maxConnections ?? 20,
|
||||
});
|
||||
scramMaxIterations: 0,
|
||||
};
|
||||
const pool = new pg.Pool(poolConfig);
|
||||
this.observePoolConnections(pool);
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
|
||||
configureMiddleware(routes, {
|
||||
logger,
|
||||
nodeEnv: config.nodeEnv,
|
||||
corsOrigins: [config.endpoints.webApp, config.endpoints.marketing],
|
||||
corsOrigins: [...config.endpoints.webAppOrigins, config.endpoints.marketing],
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
maxInflightRequests: config.maxInflightRequests,
|
||||
|
||||
@@ -1,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,
|
||||
@@ -272,8 +282,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
donationProxyKey,
|
||||
},
|
||||
hosts: {
|
||||
invite: extractHostname(master.endpoints.invite),
|
||||
gift: extractHostname(master.endpoints.gift),
|
||||
marketing: extractHostname(master.endpoints.marketing),
|
||||
unfurlIgnored: master.services.api.unfurl_ignored_hosts,
|
||||
},
|
||||
@@ -478,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,
|
||||
},
|
||||
|
||||
@@ -341,6 +341,7 @@ import {
|
||||
type UsersPendingDeletionRow,
|
||||
} from '@app/api/database/types/UserTypes';
|
||||
import {ATTACHMENT_DECAY_COLUMNS, type AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
export const Users = defineTable<UserRow, 'user_id'>({
|
||||
name: 'users',
|
||||
@@ -499,16 +500,19 @@ export const GuildAuditLogs = defineTable<GuildAuditLogRow, 'guild_id' | 'log_id
|
||||
name: 'guild_audit_logs_v2',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'log_id'],
|
||||
defaultTtlSeconds: seconds('45 days'),
|
||||
});
|
||||
export const GuildAuditLogsByUser = defineTable<GuildAuditLogRow, 'guild_id' | 'user_id' | 'log_id'>({
|
||||
name: 'guild_audit_logs_v2_by_user',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'user_id', 'log_id'],
|
||||
defaultTtlSeconds: seconds('45 days'),
|
||||
});
|
||||
export const GuildAuditLogsByAction = defineTable<GuildAuditLogRow, 'guild_id' | 'action_type' | 'log_id'>({
|
||||
name: 'guild_audit_logs_v2_by_action',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'action_type', 'log_id'],
|
||||
defaultTtlSeconds: seconds('45 days'),
|
||||
});
|
||||
export const GuildAuditLogsByUserAction = defineTable<
|
||||
GuildAuditLogRow,
|
||||
@@ -517,6 +521,7 @@ export const GuildAuditLogsByUserAction = defineTable<
|
||||
name: 'guild_audit_logs_v2_by_user_action',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'user_id', 'action_type', 'log_id'],
|
||||
defaultTtlSeconds: seconds('45 days'),
|
||||
});
|
||||
export const GuildMembershipMetadata = defineTable<GuildMembershipMetadataRow, 'guild_id' | 'user_id'>({
|
||||
name: 'guild_membership_metadata',
|
||||
@@ -655,6 +660,7 @@ export const RecentMentions = defineTable<RecentMentionRow, 'user_id' | 'message
|
||||
name: 'recent_mentions',
|
||||
columns: RECENT_MENTION_COLUMNS,
|
||||
primaryKey: ['user_id', 'message_id'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
|
||||
interface RecentMentionsByGuildRow {
|
||||
@@ -678,6 +684,7 @@ export const RecentMentionsByGuild = defineTable<RecentMentionsByGuildRow, 'user
|
||||
name: 'recent_mentions_by_guild',
|
||||
columns: RECENT_MENTIONS_BY_GUILD_COLUMNS,
|
||||
primaryKey: ['user_id', 'guild_id', 'message_id'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
export const SavedMessages = defineTable<SavedMessageRow, 'user_id' | 'message_id'>({
|
||||
name: 'saved_messages',
|
||||
@@ -688,6 +695,7 @@ export const PushSubscriptions = defineTable<PushSubscriptionRow, 'user_id' | 's
|
||||
name: 'push_subscriptions',
|
||||
columns: PUSH_SUBSCRIPTION_COLUMNS,
|
||||
primaryKey: ['user_id', 'subscription_id'],
|
||||
defaultTtlSeconds: seconds('90 days'),
|
||||
});
|
||||
export const Payments = defineTable<PaymentRow, 'checkout_session_id'>({
|
||||
name: 'payments',
|
||||
@@ -854,11 +862,13 @@ export const EmailVerificationTokens = defineTable<EmailVerificationTokenRow, 't
|
||||
name: 'email_verification_tokens',
|
||||
columns: EMAIL_VERIFICATION_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
defaultTtlSeconds: seconds('24 hours'),
|
||||
});
|
||||
export const PasswordResetTokens = defineTable<PasswordResetTokenRow, 'token_' | 'user_id'>({
|
||||
name: 'password_reset_tokens',
|
||||
columns: PASSWORD_RESET_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
defaultTtlSeconds: seconds('24 hours'),
|
||||
});
|
||||
export const PasswordResetTokensByUserId = defineTable<
|
||||
{
|
||||
@@ -870,16 +880,19 @@ export const PasswordResetTokensByUserId = defineTable<
|
||||
name: 'password_reset_tokens_by_user_id',
|
||||
columns: ['user_id', 'token_'],
|
||||
primaryKey: ['user_id', 'token_'],
|
||||
defaultTtlSeconds: seconds('24 hours'),
|
||||
});
|
||||
export const EmailRevertTokens = defineTable<EmailRevertTokenRow, 'token_' | 'user_id'>({
|
||||
name: 'email_revert_tokens',
|
||||
columns: EMAIL_REVERT_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
defaultTtlSeconds: seconds('48 hours'),
|
||||
});
|
||||
export const PhoneTokens = defineTable<PhoneTokenRow, 'token_'>({
|
||||
name: 'phone_tokens',
|
||||
columns: PHONE_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const AuthSessions = defineTable<AuthSessionRow, 'session_id_hash'>({
|
||||
name: 'auth_sessions',
|
||||
@@ -901,11 +914,13 @@ export const AuthSessionTombstones = defineTable<AuthSessionTombstoneRow, 'user_
|
||||
name: 'auth_session_tombstones',
|
||||
columns: AUTH_SESSION_TOMBSTONE_COLUMNS,
|
||||
primaryKey: ['user_id', 'session_id_hash'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const UserCountryHistory = defineTable<UserCountryHistoryRow, 'user_id' | 'country'>({
|
||||
name: 'user_country_history',
|
||||
columns: USER_COUNTRY_HISTORY_COLUMNS,
|
||||
primaryKey: ['user_id', 'country'],
|
||||
defaultTtlSeconds: seconds('365 days'),
|
||||
});
|
||||
export const MfaBackupCodes = defineTable<MfaBackupCodeRow, 'user_id' | 'code'>({
|
||||
name: 'mfa_backup_codes',
|
||||
@@ -932,6 +947,7 @@ export const IpAuthorizationTokens = defineTable<IpAuthorizationTokenRow, 'token
|
||||
name: 'ip_authorization_tokens',
|
||||
columns: IP_AUTHORIZATION_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
defaultTtlSeconds: seconds('30 minutes'),
|
||||
});
|
||||
export const AuthorizedIps = defineTable<AuthorizedIpRow, 'user_id' | 'ip'>({
|
||||
name: 'authorized_ips_v2',
|
||||
@@ -1057,26 +1073,31 @@ export const OAuth2AuthorizationCodes = defineTable<OAuth2AuthorizationCodeRow,
|
||||
name: 'oauth2_authorization_codes',
|
||||
columns: OAUTH2_AUTHORIZATION_CODE_COLUMNS,
|
||||
primaryKey: ['code'],
|
||||
defaultTtlSeconds: seconds('10 minutes'),
|
||||
});
|
||||
export const OAuth2AccessTokens = defineTable<OAuth2AccessTokenRow, 'token_'>({
|
||||
name: 'oauth2_access_tokens',
|
||||
columns: OAUTH2_ACCESS_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
export const OAuth2AccessTokensByUser = defineTable<OAuth2AccessTokenByUserRow, 'user_id' | 'token_'>({
|
||||
name: 'oauth2_access_tokens_by_user',
|
||||
columns: OAUTH2_ACCESS_TOKENS_BY_USER_COLUMNS,
|
||||
primaryKey: ['user_id', 'token_'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
export const OAuth2RefreshTokens = defineTable<OAuth2RefreshTokenRow, 'token_'>({
|
||||
name: 'oauth2_refresh_tokens',
|
||||
columns: OAUTH2_REFRESH_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const OAuth2RefreshTokensByUser = defineTable<OAuth2RefreshTokenByUserRow, 'user_id' | 'token_'>({
|
||||
name: 'oauth2_refresh_tokens_by_user',
|
||||
columns: OAUTH2_REFRESH_TOKENS_BY_USER_COLUMNS,
|
||||
primaryKey: ['user_id', 'token_'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
|
||||
interface WebhooksByChannelRow {
|
||||
@@ -1117,12 +1138,14 @@ export const JobsById = defineTable<JobByIdRow, 'job_id'>({
|
||||
name: 'jobs_by_id',
|
||||
columns: JOB_BY_ID_COLUMNS,
|
||||
primaryKey: ['job_id'],
|
||||
defaultTtlSeconds: seconds('90 days'),
|
||||
});
|
||||
export const JobsByDayBucket = defineTable<JobByDayBucketRow, 'bucket_day' | 'created_at' | 'job_id'>({
|
||||
name: 'jobs_by_day_bucket',
|
||||
columns: JOB_BY_DAY_BUCKET_COLUMNS,
|
||||
primaryKey: ['bucket_day', 'created_at', 'job_id'],
|
||||
partitionKey: ['bucket_day'],
|
||||
defaultTtlSeconds: seconds('90 days'),
|
||||
});
|
||||
export const JobsActive = defineTable<JobActiveRow, 'job_id'>({
|
||||
name: 'jobs_active',
|
||||
@@ -1133,11 +1156,13 @@ export const AttachmentUploadTracesByKey = defineTable<AttachmentUploadTraceByKe
|
||||
name: 'attachment_upload_traces_by_key',
|
||||
columns: ATTACHMENT_UPLOAD_TRACE_BY_KEY_COLUMNS,
|
||||
primaryKey: ['upload_key'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const AttachmentUploadTracesByAttachment = defineTable<AttachmentUploadTraceByAttachmentRow, 'attachment_id'>({
|
||||
name: 'attachment_upload_traces_by_attachment',
|
||||
columns: ATTACHMENT_UPLOAD_TRACE_BY_ATTACHMENT_COLUMNS,
|
||||
primaryKey: ['attachment_id'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const NcmecAttachmentSubmissions = defineTable<NcmecAttachmentSubmissionRow, 'attachment_id'>({
|
||||
name: 'ncmec_attachment_submissions',
|
||||
@@ -1154,6 +1179,7 @@ export const RegistrationEventsByIp = defineTable<RegistrationEventByIpRow, 'ip'
|
||||
columns: REGISTRATION_EVENT_BY_IP_COLUMNS,
|
||||
primaryKey: ['ip', 'created_at', 'user_id'],
|
||||
partitionKey: ['ip'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const RegistrationEventsBySubnet = defineTable<
|
||||
RegistrationEventBySubnetRow,
|
||||
@@ -1164,6 +1190,7 @@ export const RegistrationEventsBySubnet = defineTable<
|
||||
columns: REGISTRATION_EVENT_BY_SUBNET_COLUMNS,
|
||||
primaryKey: ['subnet', 'created_at', 'user_id'],
|
||||
partitionKey: ['subnet'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const RegistrationEventsByEmailDomain = defineTable<
|
||||
RegistrationEventByEmailDomainRow,
|
||||
@@ -1174,6 +1201,7 @@ export const RegistrationEventsByEmailDomain = defineTable<
|
||||
columns: REGISTRATION_EVENT_BY_EMAIL_DOMAIN_COLUMNS,
|
||||
primaryKey: ['email_domain', 'created_at', 'user_id'],
|
||||
partitionKey: ['email_domain'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const RegistrationEventsByPlusAddressBase = defineTable<
|
||||
RegistrationEventByPlusAddressBaseRow,
|
||||
@@ -1184,6 +1212,7 @@ export const RegistrationEventsByPlusAddressBase = defineTable<
|
||||
columns: REGISTRATION_EVENT_BY_PLUS_ADDRESS_BASE_COLUMNS,
|
||||
primaryKey: ['plus_address_base', 'created_at', 'user_id'],
|
||||
partitionKey: ['plus_address_base'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const LatestRiskContextByUser = defineTable<LatestRiskContextByUserRow, 'user_id'>({
|
||||
name: 'latest_risk_context_by_user',
|
||||
@@ -1194,6 +1223,7 @@ export const SuspiciousIps = defineTable<SuspiciousIpRow, 'ip'>({
|
||||
name: 'suspicious_ips',
|
||||
columns: SUSPICIOUS_IP_COLUMNS,
|
||||
primaryKey: ['ip'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
export const RiskOutcomesByIp = defineTable<RiskOutcomeByIpRow, 'ip' | 'created_at' | 'user_id' | 'outcome_code', 'ip'>(
|
||||
{
|
||||
@@ -1201,6 +1231,7 @@ export const RiskOutcomesByIp = defineTable<RiskOutcomeByIpRow, 'ip' | 'created_
|
||||
columns: RISK_OUTCOME_BY_IP_COLUMNS,
|
||||
primaryKey: ['ip', 'created_at', 'user_id', 'outcome_code'],
|
||||
partitionKey: ['ip'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
},
|
||||
);
|
||||
export const RiskOutcomesBySubnet = defineTable<
|
||||
@@ -1212,6 +1243,7 @@ export const RiskOutcomesBySubnet = defineTable<
|
||||
columns: RISK_OUTCOME_BY_SUBNET_COLUMNS,
|
||||
primaryKey: ['subnet', 'created_at', 'user_id', 'outcome_code'],
|
||||
partitionKey: ['subnet'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
export const RiskOutcomesByEmailDomain = defineTable<
|
||||
RiskOutcomeByEmailDomainRow,
|
||||
@@ -1222,6 +1254,7 @@ export const RiskOutcomesByEmailDomain = defineTable<
|
||||
columns: RISK_OUTCOME_BY_EMAIL_DOMAIN_COLUMNS,
|
||||
primaryKey: ['email_domain', 'created_at', 'user_id', 'outcome_code'],
|
||||
partitionKey: ['email_domain'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
export const RiskOutcomesByAsn = defineTable<
|
||||
RiskOutcomeByAsnRow,
|
||||
@@ -1232,6 +1265,7 @@ export const RiskOutcomesByAsn = defineTable<
|
||||
columns: RISK_OUTCOME_BY_ASN_COLUMNS,
|
||||
primaryKey: ['asn', 'created_at', 'user_id', 'outcome_code'],
|
||||
partitionKey: ['asn'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
export const RiskAssessments = defineTable<RiskAssessmentRow, 'assessment_id'>({
|
||||
name: 'risk_assessments',
|
||||
@@ -1248,6 +1282,7 @@ export const InboundSmsChallenges = defineTable<InboundSmsChallengeRow, 'challen
|
||||
name: 'inbound_sms_challenges',
|
||||
columns: INBOUND_SMS_CHALLENGE_COLUMNS,
|
||||
primaryKey: ['challenge_code'],
|
||||
defaultTtlSeconds: seconds('15 minutes'),
|
||||
});
|
||||
export const InboundSmsChallengesByUser = defineTable<
|
||||
InboundSmsChallengeByUserRow,
|
||||
@@ -1258,16 +1293,19 @@ export const InboundSmsChallengesByUser = defineTable<
|
||||
columns: INBOUND_SMS_CHALLENGE_BY_USER_COLUMNS,
|
||||
primaryKey: ['user_id', 'created_at'],
|
||||
partitionKey: ['user_id'],
|
||||
defaultTtlSeconds: seconds('15 minutes'),
|
||||
});
|
||||
export const PhoneLookupCache = defineTable<PhoneLookupCacheRow, 'phone'>({
|
||||
name: 'phone_lookup_cache',
|
||||
columns: PHONE_LOOKUP_CACHE_COLUMNS,
|
||||
primaryKey: ['phone'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
export const PhoneVerificationAttempts = defineTable<PhoneVerificationAttemptRow, 'attempt_id'>({
|
||||
name: 'phone_verification_attempts',
|
||||
columns: PHONE_VERIFICATION_ATTEMPT_COLUMNS,
|
||||
primaryKey: ['attempt_id'],
|
||||
defaultTtlSeconds: seconds('90 days'),
|
||||
});
|
||||
export const BillingCustomers = defineTable<BillingCustomerRow, 'provider_id'>({
|
||||
name: 'billing_customers',
|
||||
|
||||
@@ -12,7 +12,16 @@ import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationEr
|
||||
import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata';
|
||||
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
|
||||
import {getCookie} from 'hono/cookie';
|
||||
import {type core, type input, type output, ZodObject, ZodOptional, type ZodSafeParseResult, type ZodType} from 'zod';
|
||||
import {
|
||||
type core,
|
||||
type input,
|
||||
type output,
|
||||
ZodNullable,
|
||||
ZodObject,
|
||||
ZodOptional,
|
||||
type ZodSafeParseResult,
|
||||
type ZodType,
|
||||
} from 'zod';
|
||||
|
||||
initializeFluxerErrorMap();
|
||||
|
||||
@@ -46,8 +55,9 @@ function extractVariablesFromIssue(issue: core.$ZodIssue): Record<string, unknow
|
||||
}
|
||||
|
||||
function convertEmptyValuesToNull(obj: unknown, schema?: core.$ZodType, isRoot = true): unknown {
|
||||
while (schema instanceof ZodOptional) schema = schema.unwrap();
|
||||
if (schema && schemaMetadata.get(schema)?.preserveEmptyValues) return obj;
|
||||
while (schema instanceof ZodOptional || schema instanceof ZodNullable) schema = schema.unwrap();
|
||||
const metadata = schema ? schemaMetadata.get(schema) : undefined;
|
||||
if (metadata?.preserveEmptyValues) return obj;
|
||||
if (typeof obj === 'string' && obj === '') return null;
|
||||
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, undefined, false));
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
@@ -59,7 +69,9 @@ function convertEmptyValuesToNull(obj: unknown, schema?: core.$ZodType, isRoot =
|
||||
convertEmptyValuesToNull(value, shape && Object.hasOwn(shape, key) ? shape[key] : undefined, false),
|
||||
]),
|
||||
);
|
||||
if (!isRoot && Object.values(processed).every((value) => value === null)) return null;
|
||||
if (!isRoot && !metadata?.preserveNullFields && Object.values(processed).every((value) => value === null)) {
|
||||
return null;
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
return obj;
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from '@app/api/admin/IAdminRepository';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ContentBlocklistCategory} from '@app/api/constants/ContentModeration';
|
||||
import {
|
||||
deleteOneOrMany,
|
||||
executeConditional,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {
|
||||
AdminAuditLogRow,
|
||||
BannedAvatarHashRow,
|
||||
@@ -282,6 +290,7 @@ export class AdminRepository implements IAdminRepository {
|
||||
}
|
||||
|
||||
async isEmailDomainDisposable(domain: string): Promise<boolean> {
|
||||
if (!Config.blocklistFeeds.enabled) return false;
|
||||
const domainLower = domain.toLowerCase();
|
||||
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
|
||||
const result = await fetchOne<{
|
||||
@@ -395,6 +404,15 @@ export class AdminRepository implements IAdminRepository {
|
||||
await deleteOneOrMany(BannedFileShas.deleteByPk({sha256_hex: sha256Hex.toLowerCase()}));
|
||||
}
|
||||
|
||||
async unbanFeedFileSha(sha256Hex: string): Promise<boolean> {
|
||||
return executeConditional(
|
||||
BannedFileShas.conditionalDeleteByPk(
|
||||
{sha256_hex: sha256Hex.toLowerCase()},
|
||||
{added_by: null, category: ContentBlocklistCategory.MALWARE_BAZAAR},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>> {
|
||||
return fetchMany<BannedFileShaRow>(LOAD_ALL_BANNED_FILE_SHAS_QUERY.bind({}));
|
||||
}
|
||||
|
||||
@@ -109,6 +109,8 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract unbanFileSha(sha256Hex: string): Promise<void>;
|
||||
|
||||
abstract unbanFeedFileSha(sha256Hex: string): Promise<boolean>;
|
||||
|
||||
abstract loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>>;
|
||||
|
||||
abstract isAvatarHashBanned(hashShort: string): Promise<boolean>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import {isPremiumTieringActive} from '@app/api/stripe/BillingConfigCache';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
@@ -25,14 +26,14 @@ export function CodesAdminController(app: HonoApp) {
|
||||
operationId: 'create_admin_gift_codes',
|
||||
summary: 'Issue gift codes',
|
||||
description:
|
||||
'Create one-use Plutonium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. Not available on self-hosted instances. Requires GIFT_CODES_GENERATE permission.',
|
||||
'Create one-use premium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. On self-hosted instances the premium mode must be mirror. Requires GIFT_CODES_GENERATE permission.',
|
||||
responseSchema: CodesResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (Config.instance.selfHosted) {
|
||||
if (!isPremiumTieringActive()) {
|
||||
throw new FeatureNotAvailableSelfHostedError();
|
||||
}
|
||||
const adminService = ctx.get('adminService');
|
||||
|
||||
@@ -13,11 +13,16 @@ 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';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
|
||||
import {
|
||||
BrandingAssetUploadRequest,
|
||||
@@ -30,9 +35,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 {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {ProfileTimezoneConfigSchema} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
@@ -59,8 +66,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
const [
|
||||
ssoConfig,
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
screenShareDelivery,
|
||||
pushRelay,
|
||||
domainMigration,
|
||||
altchaCaptcha,
|
||||
profileTimezone,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -68,19 +77,22 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
] = await Promise.all([
|
||||
instanceConfigRepository.getSsoConfig(),
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getScreenShareDeliveryConfig(),
|
||||
instanceConfigRepository.getPushRelayConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getProfileTimezoneConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
instanceConfigRepository.getPendingRegistrations(),
|
||||
]);
|
||||
const [appPublic, policy, resolvedServices, integrations, media] = await Promise.all([
|
||||
const [appPublic, policy, resolvedServices, integrations, media, billing] = await Promise.all([
|
||||
instanceConfigRepository.getAppPublicConfig(),
|
||||
instanceConfigRepository.getInstancePolicyConfig(),
|
||||
instanceConfigRepository.getResolvedServicesConfig(),
|
||||
instanceConfigRepository.getInstanceIntegrationsAdminConfig(),
|
||||
instanceConfigRepository.getInstanceMediaAdminConfig(),
|
||||
instanceConfigRepository.getInstanceBillingAdminConfig(),
|
||||
]);
|
||||
return {
|
||||
sso: {
|
||||
@@ -100,8 +112,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
redirect_uri: deriveSsoRedirectUri(Config.endpoints.webApp),
|
||||
},
|
||||
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,
|
||||
@@ -135,6 +149,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
},
|
||||
integrations,
|
||||
media,
|
||||
billing,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,6 +201,109 @@ 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 assertSelfHostedBillingSections(data: InstanceConfigUpdateRequest): void {
|
||||
if (Config.instance.selfHosted) {
|
||||
return;
|
||||
}
|
||||
if (data.billing) {
|
||||
throw InputValidationError.create('billing', 'Billing is configured through the environment on this instance');
|
||||
}
|
||||
const branding = data.app_public?.branding;
|
||||
if (!branding) {
|
||||
return;
|
||||
}
|
||||
for (const field of ['premium_product_name', 'premium_info_url'] as const) {
|
||||
if (readOptionalField(branding, field) !== undefined) {
|
||||
throw InputValidationError.create(
|
||||
`app_public.branding.${field}`,
|
||||
'This setting is only available on self-hosted instances',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBillingCompatibleWithStoredPremiumMode(
|
||||
billing: NonNullable<InstanceConfigUpdateRequest['billing']>,
|
||||
): Promise<void> {
|
||||
const requestedEnabled = readOptionalField(billing, 'enabled');
|
||||
if (requestedEnabled !== true) {
|
||||
return;
|
||||
}
|
||||
const policy = await getInstanceConfigRepository().readStoredInstancePolicyConfig();
|
||||
if (policy.premium_mode === 'everyone') {
|
||||
throw InputValidationError.create('billing.enabled', 'Billing can only be enabled when the premium mode is mirror');
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPremiumModeCompatibleWithStoredBilling(
|
||||
requestedBillingEnabled: boolean | null | undefined,
|
||||
): Promise<void> {
|
||||
if (!Config.instance.selfHosted) {
|
||||
return;
|
||||
}
|
||||
const repository = getInstanceConfigRepository();
|
||||
const [policy, billing] = await Promise.all([
|
||||
repository.readStoredInstancePolicyConfig(),
|
||||
repository.readStoredInstanceBillingConfig(),
|
||||
]);
|
||||
if (policy.premium_mode === 'everyone') {
|
||||
return;
|
||||
}
|
||||
const nextEnabled = requestedBillingEnabled === undefined ? billing.enabled : requestedBillingEnabled;
|
||||
if (nextEnabled === true) {
|
||||
throw InputValidationError.create(
|
||||
'policy.premium_mode',
|
||||
'Disable billing before switching the premium mode to everyone',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBillingCompatibleWithPremiumMode(data: InstanceConfigUpdateRequest): Promise<void> {
|
||||
if (!Config.instance.selfHosted) {
|
||||
return;
|
||||
}
|
||||
const requestedEnabled = data.billing ? readOptionalField(data.billing, 'enabled') : undefined;
|
||||
const requestedPremiumMode = data.policy ? readOptionalField(data.policy, 'premium_mode') : undefined;
|
||||
if (requestedEnabled === undefined && requestedPremiumMode === undefined) {
|
||||
return;
|
||||
}
|
||||
const currentPremiumMode = (await getInstanceConfigRepository().getInstancePolicyConfig()).premium_mode;
|
||||
const nextPremiumMode = requestedPremiumMode ?? currentPremiumMode;
|
||||
if (nextPremiumMode !== 'everyone') {
|
||||
return;
|
||||
}
|
||||
const nextEnabled =
|
||||
requestedEnabled === undefined
|
||||
? (await getInstanceConfigRepository().readStoredInstanceBillingConfig()).enabled
|
||||
: requestedEnabled;
|
||||
if (nextEnabled !== true) {
|
||||
return;
|
||||
}
|
||||
if (requestedEnabled !== undefined) {
|
||||
throw InputValidationError.create('billing.enabled', 'Billing can only be enabled when the premium mode is mirror');
|
||||
}
|
||||
if (currentPremiumMode !== 'everyone') {
|
||||
throw InputValidationError.create(
|
||||
'policy.premium_mode',
|
||||
'Disable billing before switching the premium mode to everyone',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined {
|
||||
const sections = Object.entries(data)
|
||||
.filter(([, value]) => value != null)
|
||||
@@ -241,49 +359,73 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
assertSelfHostedBillingSections(data);
|
||||
await assertBillingCompatibleWithPremiumMode(data);
|
||||
const appPublicBeforeUpdate = completesInitialSetup(data, false)
|
||||
? await instanceConfigRepository.getAppPublicConfig()
|
||||
: null;
|
||||
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 (data.push_relay) {
|
||||
const patch = omitUndefinedFields(data.push_relay);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentNoiseSuppression = await instanceConfigRepository.getVoiceNoiseSuppressionConfig();
|
||||
const validated = VoiceNoiseSuppressionConfigSchema.parse({
|
||||
...currentNoiseSuppression,
|
||||
const adminUserId = ctx.get('adminUserId').toString();
|
||||
const landed = await instanceConfigRepository.updatePushRelayConfig((current) => ({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: currentNoiseSuppression.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
|
||||
...relayConsentStamp(current, patch, adminUserId),
|
||||
}));
|
||||
await getPushRelayConfigPublisher().publish(landed);
|
||||
}
|
||||
}
|
||||
if (data.screen_share_delivery) {
|
||||
const patch = omitUndefinedFields(data.screen_share_delivery);
|
||||
if (data.domain_migration) {
|
||||
const patch = omitUndefinedFields(data.domain_migration);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentScreenShareDelivery = await instanceConfigRepository.getScreenShareDeliveryConfig();
|
||||
const validated = ScreenShareDeliveryConfigSchema.parse({
|
||||
...currentScreenShareDelivery,
|
||||
...patch,
|
||||
config_version: currentScreenShareDelivery.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setScreenShareDeliveryConfig(validated);
|
||||
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 +450,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) {
|
||||
@@ -347,6 +490,8 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
data.app_public.branding,
|
||||
'status_page_incident_history_url',
|
||||
),
|
||||
premium_product_name: readOptionalField(data.app_public.branding, 'premium_product_name'),
|
||||
premium_info_url: readOptionalField(data.app_public.branding, 'premium_info_url'),
|
||||
})
|
||||
: undefined,
|
||||
legal: data.app_public.legal
|
||||
@@ -439,7 +584,28 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
});
|
||||
}
|
||||
if (data.policy) {
|
||||
await applyInstancePolicyUpdate(ctx, data.policy);
|
||||
await applyInstancePolicyUpdate(
|
||||
ctx,
|
||||
data.policy,
|
||||
data.billing ? readOptionalField(data.billing, 'enabled') : undefined,
|
||||
);
|
||||
}
|
||||
if (data.billing) {
|
||||
await assertBillingCompatibleWithStoredPremiumMode(data.billing);
|
||||
await instanceConfigRepository.setInstanceBillingConfig(
|
||||
omitUndefinedFields({
|
||||
enabled: readOptionalField(data.billing, 'enabled'),
|
||||
stripe_secret_key: readOptionalField(data.billing, 'stripe_secret_key'),
|
||||
stripe_webhook_secret: readOptionalField(data.billing, 'stripe_webhook_secret'),
|
||||
default_currency: readOptionalField(data.billing, 'default_currency'),
|
||||
prices: readOptionalField(data.billing, 'prices'),
|
||||
country_currencies: readOptionalField(data.billing, 'country_currencies'),
|
||||
legacy_prices: readOptionalField(data.billing, 'legacy_prices'),
|
||||
automatic_tax: readOptionalField(data.billing, 'automatic_tax'),
|
||||
tax_id_collection: readOptionalField(data.billing, 'tax_id_collection'),
|
||||
terms_consent_required: readOptionalField(data.billing, 'terms_consent_required'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (data.app_public?.setup) {
|
||||
await instanceConfigRepository.setAppPublicConfig({
|
||||
@@ -625,7 +791,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());
|
||||
@@ -636,29 +801,53 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
async function applyInstancePolicyUpdate(
|
||||
ctx: Context<HonoEnv>,
|
||||
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
|
||||
requestedBillingEnabled: boolean | null | undefined,
|
||||
): 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;
|
||||
if (policy.premium_mode === 'everyone') {
|
||||
await assertPremiumModeCompatibleWithStoredBilling(requestedBillingEnabled);
|
||||
}
|
||||
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 +868,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 +890,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,365 @@
|
||||
// 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 {getConfig} from '@app/api/Config';
|
||||
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||
import {getAdminRepository, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {getStoredBillingConfig, setStoredBillingConfig} from '@app/api/stripe/BillingConfigCache';
|
||||
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 {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, vi} from 'vitest';
|
||||
|
||||
const OPERATOR_SECRET_KEY = 'sk_test_operator_secret_value';
|
||||
const OPERATOR_WEBHOOK_SECRET = 'whsec_operator_secret_value';
|
||||
|
||||
interface GlobalState {
|
||||
selfHosted: boolean;
|
||||
premiumMode: ReturnType<typeof getCachedInstancePremiumMode>;
|
||||
storedBilling: ReturnType<typeof getStoredBillingConfig>;
|
||||
}
|
||||
|
||||
function captureGlobalState(): GlobalState {
|
||||
return {
|
||||
selfHosted: getConfig().instance.selfHosted,
|
||||
premiumMode: getCachedInstancePremiumMode(),
|
||||
storedBilling: getStoredBillingConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
function restoreGlobalState(state: GlobalState): void {
|
||||
getConfig().instance.selfHosted = state.selfHosted;
|
||||
setCachedInstancePremiumMode(state.premiumMode);
|
||||
setStoredBillingConfig(state.storedBilling);
|
||||
}
|
||||
|
||||
function useInstanceHarness(selfHosted: boolean) {
|
||||
const context: {harness: ApiTestHarness} = {harness: undefined as unknown as ApiTestHarness};
|
||||
let original: GlobalState;
|
||||
beforeAll(async () => {
|
||||
original = captureGlobalState();
|
||||
getConfig().instance.selfHosted = selfHosted;
|
||||
context.harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await context.harness.reset();
|
||||
setStoredBillingConfig(null);
|
||||
setCachedInstancePremiumMode('everyone');
|
||||
});
|
||||
afterAll(async () => {
|
||||
await context.harness.shutdown();
|
||||
restoreGlobalState(original);
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
async function createAdmin(harness: ApiTestHarness): Promise<TestAccount> {
|
||||
return await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
}
|
||||
|
||||
function patchConfig(harness: ApiTestHarness, admin: TestAccount, body: Record<string, unknown>) {
|
||||
return createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
|
||||
}
|
||||
|
||||
const OPERATOR_BILLING = {
|
||||
enabled: true,
|
||||
stripe_secret_key: OPERATOR_SECRET_KEY,
|
||||
stripe_webhook_secret: OPERATOR_WEBHOOK_SECRET,
|
||||
default_currency: 'GBP',
|
||||
prices: {
|
||||
GBP: {
|
||||
monthly: 'price_monthlygbp',
|
||||
yearly: 'price_yearlygbp',
|
||||
gift_1_month: 'price_gift1monthgbp',
|
||||
gift_1_year: 'price_gift1yeargbp',
|
||||
},
|
||||
},
|
||||
country_currencies: {GB: 'GBP'},
|
||||
legacy_prices: {monthly_GBP: ['price_oldmonthlygbp']},
|
||||
};
|
||||
|
||||
describe('instance config billing on a self-hosted instance', () => {
|
||||
const context = useInstanceHarness(true);
|
||||
|
||||
it('rejects enabling billing while the premium mode is everyone', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: {enabled: true}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
await patchConfig(context.harness, admin, {billing: {enabled: true}, policy: {premium_mode: 'everyone'}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.billing.enabled).toBeNull();
|
||||
expect(current.policy.premium_mode).toBe('everyone');
|
||||
});
|
||||
|
||||
it('saves the environment fallback in everyone mode even when the environment enables billing', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const originalEnabled = getConfig().stripe.enabled;
|
||||
getConfig().stripe.enabled = true;
|
||||
try {
|
||||
const updated = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: 'Gold'}},
|
||||
billing: {enabled: null},
|
||||
}).execute();
|
||||
expect(updated.billing.enabled).toBeNull();
|
||||
expect(updated.billing.billing_active).toBe(false);
|
||||
expect(updated.app_public.branding.premium_product_name).toBe('Gold');
|
||||
await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}}).execute();
|
||||
} finally {
|
||||
getConfig().stripe.enabled = originalEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
it('enables billing and mirror mode from one request and redacts the secrets', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const updated = await patchConfig(context.harness, admin, {
|
||||
billing: OPERATOR_BILLING,
|
||||
policy: {premium_mode: 'mirror'},
|
||||
}).execute();
|
||||
expect(updated.policy.premium_mode).toBe('mirror');
|
||||
expect(updated.billing).toMatchObject({
|
||||
enabled: true,
|
||||
effective_enabled: true,
|
||||
stripe_secret_key_set: true,
|
||||
stripe_webhook_secret_set: true,
|
||||
default_currency: 'GBP',
|
||||
prices: OPERATOR_BILLING.prices,
|
||||
country_currencies: {GB: 'GBP'},
|
||||
legacy_prices: {monthly_GBP: ['price_oldmonthlygbp']},
|
||||
billing_active: true,
|
||||
catalog_mode: 'operator',
|
||||
});
|
||||
expect(updated.billing.webhook_url).toMatch(/\/stripe\/webhook$/);
|
||||
const {text} = await createBuilder(context.harness, admin.token).get('/admin/instance/config').executeRaw();
|
||||
expect(text).not.toContain(OPERATOR_SECRET_KEY);
|
||||
expect(text).not.toContain(OPERATOR_WEBHOOK_SECRET);
|
||||
expect(JSON.parse(text).billing).toMatchObject({stripe_secret_key_set: true, billing_active: true});
|
||||
});
|
||||
|
||||
it('rejects switching the premium mode to everyone while billing is enabled', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
const unchanged = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(unchanged.policy.premium_mode).toBe('mirror');
|
||||
const switched = await patchConfig(context.harness, admin, {
|
||||
billing: {enabled: false},
|
||||
policy: {premium_mode: 'everyone'},
|
||||
}).execute();
|
||||
expect(switched.policy.premium_mode).toBe('everyone');
|
||||
expect(switched.billing).toMatchObject({enabled: false, effective_enabled: false, billing_active: false});
|
||||
});
|
||||
|
||||
it('clears secrets and the operator catalog with null', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
const kept = await patchConfig(context.harness, admin, {billing: {default_currency: 'GBP'}}).execute();
|
||||
expect(kept.billing.prices).toEqual(OPERATOR_BILLING.prices);
|
||||
const cleared = await patchConfig(context.harness, admin, {
|
||||
billing: {enabled: true, stripe_webhook_secret: null, prices: null, legacy_prices: null},
|
||||
}).execute();
|
||||
expect(cleared.billing).toMatchObject({
|
||||
enabled: true,
|
||||
prices: null,
|
||||
legacy_prices: null,
|
||||
catalog_mode: 'env',
|
||||
stripe_secret_key_set: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed billing input', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
for (const billing of [
|
||||
{prices: {gbp: {monthly: 'price_x'}}},
|
||||
{prices: {GBP: {monthly: 'not_a_price'}}},
|
||||
{country_currencies: {GBR: 'GBP'}},
|
||||
{legacy_prices: {weekly_GBP: ['price_x']}},
|
||||
{default_currency: 'pounds'},
|
||||
]) {
|
||||
await patchConfig(context.harness, admin, {billing})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the premium name and info URL when both are sent as null', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'}},
|
||||
}).execute();
|
||||
const reset = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: null, premium_info_url: null}},
|
||||
}).execute();
|
||||
expect(reset.app_public.branding).toMatchObject({premium_product_name: 'Premium', premium_info_url: null});
|
||||
await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'}},
|
||||
}).execute();
|
||||
const resetWithBilling = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: null, premium_info_url: null}},
|
||||
billing: {enabled: false},
|
||||
}).execute();
|
||||
expect(resetWithBilling.app_public.branding).toMatchObject({
|
||||
premium_product_name: 'Premium',
|
||||
premium_info_url: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies billing sections that only hold nulls', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
const envCatalog = await patchConfig(context.harness, admin, {billing: {prices: null}}).execute();
|
||||
expect(envCatalog.billing).toMatchObject({prices: null, catalog_mode: 'env', enabled: true});
|
||||
const followsEnv = await patchConfig(context.harness, admin, {billing: {enabled: null}}).execute();
|
||||
expect(followsEnv.billing).toMatchObject({enabled: null, effective_enabled: getConfig().stripe.enabled});
|
||||
});
|
||||
|
||||
it('applies the policy before billing so a failed policy change stores no billing', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {app_public: {setup: {configured: true}}}).execute();
|
||||
await patchConfig(context.harness, admin, {
|
||||
billing: {enabled: true},
|
||||
policy: {premium_mode: 'mirror', single_community_enabled: true},
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.policy.premium_mode).toBe('everyone');
|
||||
expect(current.billing.enabled).toBeNull();
|
||||
});
|
||||
|
||||
it('re-checks the stored premium mode right before the billing write', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const repository = getInstanceConfigRepository();
|
||||
const stored = await repository.getInstancePolicyConfig();
|
||||
const spy = vi.spyOn(repository, 'getInstancePolicyConfig').mockResolvedValue({...stored, premium_mode: 'mirror'});
|
||||
try {
|
||||
await patchConfig(context.harness, admin, {billing: {enabled: true}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.billing.enabled).toBeNull();
|
||||
});
|
||||
|
||||
it('re-checks the stored billing right before the premium mode write', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
const cached = getStoredBillingConfig();
|
||||
expect(cached?.enabled).toBe(true);
|
||||
setStoredBillingConfig(cached === null ? null : {...cached, enabled: false});
|
||||
await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.policy.premium_mode).toBe('mirror');
|
||||
expect(current.billing.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('records the billing section in the audit log without secrets', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
const logs = await getAdminRepository().listAllAuditLogsPaginated(100000);
|
||||
const update = logs.find((log) => log.action === 'update_instance_config');
|
||||
expect(update?.metadata.get('sections')).toBe('billing,policy');
|
||||
const serialized = JSON.stringify(logs.map((log) => [...log.metadata.entries()]));
|
||||
expect(serialized).not.toContain(OPERATOR_SECRET_KEY);
|
||||
expect(serialized).not.toContain(OPERATOR_WEBHOOK_SECRET);
|
||||
});
|
||||
|
||||
it('stores the premium name and info URL and resets the name to the self-hosted default', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const initial = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(initial.app_public.branding).toMatchObject({premium_product_name: 'Premium', premium_info_url: null});
|
||||
const named = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'}},
|
||||
}).execute();
|
||||
expect(named.app_public.branding).toMatchObject({
|
||||
premium_product_name: 'Gold',
|
||||
premium_info_url: 'https://example.com/gold',
|
||||
});
|
||||
const renamedProduct = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {product_name: 'Example Chat'}},
|
||||
}).execute();
|
||||
expect(renamedProduct.app_public.branding.premium_product_name).toBe('Gold');
|
||||
const reset = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {product_name: 'Example Chat', premium_product_name: null, premium_info_url: null}},
|
||||
}).execute();
|
||||
expect(reset.app_public.branding).toMatchObject({premium_product_name: 'Premium', premium_info_url: null});
|
||||
await patchConfig(context.harness, admin, {app_public: {branding: {premium_info_url: 'javascript:alert(1)'}}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('instance config billing on a hosted instance', () => {
|
||||
const context = useInstanceHarness(false);
|
||||
|
||||
it('keeps the Plutonium default and env billing', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.app_public.branding.premium_product_name).toBe('Plutonium');
|
||||
expect(current.billing).toMatchObject({enabled: null, catalog_mode: 'env', stripe_secret_key_set: true});
|
||||
const updated = await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}}).execute();
|
||||
expect(updated.policy.premium_mode).toBe('everyone');
|
||||
});
|
||||
|
||||
it('rejects the billing section and the premium branding fields', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
for (const body of [
|
||||
{billing: {enabled: true}},
|
||||
{billing: {enabled: null}},
|
||||
{billing: {prices: null}},
|
||||
{billing: OPERATOR_BILLING},
|
||||
{app_public: {branding: {premium_product_name: 'Gold'}}},
|
||||
{app_public: {branding: {premium_info_url: 'https://example.com/gold'}}},
|
||||
{app_public: {branding: {premium_product_name: null, premium_info_url: null}}},
|
||||
{app_public: {branding: {product_name: 'Fluxer', premium_product_name: 'Gold'}}},
|
||||
]) {
|
||||
await patchConfig(context.harness, admin, body)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
}
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.billing).toMatchObject({enabled: null, catalog_mode: 'env', prices: null});
|
||||
expect(current.app_public.branding).toMatchObject({premium_product_name: 'Plutonium', premium_info_url: null});
|
||||
expect(getStoredBillingConfig()?.prices ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('still accepts other branding fields', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const updated = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {theme_color: '#123456'}},
|
||||
}).execute();
|
||||
expect(updated.app_public.branding.theme_color).toBe('#123456');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -77,8 +81,8 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
PremiumController(routes);
|
||||
if (!config.instance.selfHosted) {
|
||||
DonationController(routes);
|
||||
StripeController(routes);
|
||||
}
|
||||
StripeController(routes);
|
||||
}
|
||||
|
||||
function registerInboundSmsWebhook(routes: HonoApp): void {
|
||||
|
||||
@@ -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'});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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})]);
|
||||
});
|
||||
});
|
||||
@@ -333,13 +333,10 @@ export function MessageController(app: HonoApp) {
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Clears all read state and acknowledgement records for a channel, marking all messages as unread. Returns 204 No Content on success.',
|
||||
deprecated: true,
|
||||
description: 'Deprecated. Has no effect on the read state. Returns 204 No Content.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
await ctx.get('readStateService').deleteReadState({userId, channelId});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -62,6 +62,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
|
||||
const target = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, sender.token);
|
||||
await ensureSessionStarted(harness, target.token);
|
||||
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
|
||||
await updateUserSettings(harness, target.token, {default_guilds_restricted: false});
|
||||
const guild = await createGuild(harness, sender.token, 'Mutual Community');
|
||||
const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, sender.token, systemChannel.id);
|
||||
@@ -80,6 +82,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
|
||||
await ensureSessionStarted(harness, sender.token);
|
||||
await ensureSessionStarted(harness, target.token);
|
||||
await createFriendship(harness, sender, target);
|
||||
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
|
||||
await updateUserSettings(harness, target.token, {default_guilds_restricted: false});
|
||||
const guild = await createGuild(harness, sender.token, 'Verified Community');
|
||||
await createBuilder(harness, '')
|
||||
.post(`/test/guilds/${guild.id}/features`)
|
||||
@@ -97,6 +101,25 @@ describe('DM Privacy Bidirectional Enforcement', () => {
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
it('blocks message from a non-friend guild member to a new account by default', async () => {
|
||||
const sender = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, sender.token);
|
||||
await ensureSessionStarted(harness, target.token);
|
||||
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
|
||||
await createFriendship(harness, sender, target);
|
||||
const guild = await createGuild(harness, sender.token, 'Default Community');
|
||||
const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, sender.token, systemChannel.id);
|
||||
await acceptInvite(harness, target.token, invite.code);
|
||||
const channel = await createDmChannel(harness, sender.token, target.userId);
|
||||
await removeRelationship(harness, sender.token, target.userId);
|
||||
await createBuilder(harness, sender.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: 'default restricted target'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
|
||||
.execute();
|
||||
});
|
||||
it('blocks message when sender restricts the only mutual guild', async () => {
|
||||
const sender = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
@@ -175,6 +198,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
|
||||
const user2 = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await updateUserSettings(harness, user1.token, {default_guilds_restricted: false});
|
||||
await updateUserSettings(harness, user2.token, {default_guilds_restricted: false});
|
||||
const guild = await createGuild(harness, user1.token, 'Shared Community');
|
||||
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,7 @@ export interface APIConfig {
|
||||
apiPublic: string;
|
||||
apiClient: string;
|
||||
webApp: string;
|
||||
webAppOrigins: Array<string>;
|
||||
gateway: string;
|
||||
media: string;
|
||||
staticCdn: string;
|
||||
@@ -143,8 +144,6 @@ export interface APIConfig {
|
||||
donationProxyKey: string;
|
||||
};
|
||||
hosts: {
|
||||
invite: string;
|
||||
gift: string;
|
||||
marketing: string;
|
||||
unfurlIgnored: Array<string>;
|
||||
};
|
||||
@@ -338,6 +337,10 @@ export interface APIConfig {
|
||||
};
|
||||
abusePolicy: {
|
||||
inboundPhoneCountryCodes: Array<string>;
|
||||
phoneFlagging: {
|
||||
enabled: boolean;
|
||||
exemptCountryCodes: Array<string>;
|
||||
};
|
||||
phoneVerification: {
|
||||
inboundRequiredPrefixes: Array<string>;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {BannedFileShaRow} from '@app/api/database/types/AdminArchiveTypes';
|
||||
|
||||
export const BANNED_URLS_REFRESH_CHANNEL = 'banned_urls_refresh';
|
||||
export const BANNED_URL_DOMAINS_REFRESH_CHANNEL = 'banned_url_domains_refresh';
|
||||
export const BANNED_FILE_SHAS_REFRESH_CHANNEL = 'banned_file_shas_refresh';
|
||||
@@ -23,3 +25,7 @@ export const ContentBlocklistCategory = {
|
||||
GIFCT: 'gifct',
|
||||
STOP_NCII: 'stop_ncii',
|
||||
} as const;
|
||||
|
||||
export function isBlocklistFeedFileSha(row: Pick<BannedFileShaRow, 'category' | 'added_by'>): boolean {
|
||||
return row.added_by == null && row.category === ContentBlocklistCategory.MALWARE_BAZAAR;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {DEFAULT_TTL_TABLES} from '@app/api/database/PostgresKvDefaultTtlExpiry';
|
||||
import * as DonationTables from '@app/api/donation/DonationTables';
|
||||
import * as Tables from '@app/api/Tables';
|
||||
import {IPINFO_CACHE_TTL_SECONDS, IPINFO_REQUEST_AUDIT_TTL_SECONDS} from '@pkgs/geoip/src/PostgresIpInfoKv';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(THIS_DIR, '../../../..');
|
||||
|
||||
interface SchemaTable {
|
||||
name: string;
|
||||
options: string;
|
||||
}
|
||||
|
||||
const SCHEMA = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'tools/dev/cassandra_target_schema.json'), 'utf8')) as {
|
||||
tables: Array<SchemaTable>;
|
||||
};
|
||||
|
||||
const SCHEMA_DEFAULTS = new Map<string, number>(
|
||||
SCHEMA.tables.flatMap((table): Array<[string, number]> => {
|
||||
const match = /default_time_to_live = (\d+)/.exec(table.options);
|
||||
return match ? [[table.name, Number(match[1])]] : [];
|
||||
}),
|
||||
);
|
||||
|
||||
const DSL_TABLES = [...Object.values(Tables), ...Object.values(DonationTables)];
|
||||
const DSL_NAMES = new Set<string>(DSL_TABLES.map((table) => table.name));
|
||||
|
||||
const NON_DSL_DEFAULTS: Record<string, number | null> = {
|
||||
ipinfo_cache: IPINFO_CACHE_TTL_SECONDS,
|
||||
ipinfo_requests_by_hour: IPINFO_REQUEST_AUDIT_TTL_SECONDS,
|
||||
billing_webhook_events: null,
|
||||
forensic_identifier_by_key_day: null,
|
||||
forensic_identifier_by_request: null,
|
||||
forensic_request_meta_by_actor_day: null,
|
||||
forensic_request_meta_by_id: null,
|
||||
forensic_request_meta_by_route_day_shard: null,
|
||||
forensic_resource_exposure_by_request: null,
|
||||
forensic_resource_exposure_by_route_day_shard: null,
|
||||
forensic_resource_exposure_by_subject_day: null,
|
||||
};
|
||||
|
||||
const OWN_EXPIRY_PASS = new Set(['jobs_by_id', 'jobs_by_day_bucket']);
|
||||
|
||||
function schemaDefault(name: string): number {
|
||||
return SCHEMA_DEFAULTS.get(name) ?? 0;
|
||||
}
|
||||
|
||||
function byName(left: {name: string}, right: {name: string}): number {
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
describe('Cassandra default TTL parity', () => {
|
||||
it('declares every Cassandra default TTL on the matching table', () => {
|
||||
const mismatches = DSL_TABLES.flatMap((table) => {
|
||||
const declared = table.defaultTtlSeconds ?? 0;
|
||||
return declared === schemaDefault(table.name)
|
||||
? []
|
||||
: [{table: table.name, declared, schema: schemaDefault(table.name)}];
|
||||
});
|
||||
expect(mismatches).toEqual([]);
|
||||
});
|
||||
|
||||
it('declares a writer or no writer for every other table with a default', () => {
|
||||
const undeclared = [...SCHEMA_DEFAULTS]
|
||||
.filter(([name, ttl]) => ttl > 0 && !DSL_NAMES.has(name) && !Object.hasOwn(NON_DSL_DEFAULTS, name))
|
||||
.map(([name]) => name);
|
||||
expect(undeclared).toEqual([]);
|
||||
const stale = Object.keys(NON_DSL_DEFAULTS).filter((name) => schemaDefault(name) === 0 || DSL_NAMES.has(name));
|
||||
expect(stale).toEqual([]);
|
||||
const mismatched = Object.entries(NON_DSL_DEFAULTS)
|
||||
.filter(([name, ttl]) => ttl !== null && ttl !== schemaDefault(name))
|
||||
.map(([name]) => name);
|
||||
expect(mismatched).toEqual([]);
|
||||
});
|
||||
|
||||
it('the Postgres expiry pass covers every table with a default except the job ledger', () => {
|
||||
const expected = [...SCHEMA_DEFAULTS]
|
||||
.filter(([name, ttl]) => ttl > 0 && NON_DSL_DEFAULTS[name] !== null && !OWN_EXPIRY_PASS.has(name))
|
||||
.map(([name, ttl]) => ({name, defaultTtlSeconds: ttl}))
|
||||
.sort(byName);
|
||||
expect([...DEFAULT_TTL_TABLES].sort(byName)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ interface TableMetadata {
|
||||
columns: ReadonlyArray<string>;
|
||||
primaryKey: ReadonlyArray<string>;
|
||||
partitionKey: ReadonlyArray<string>;
|
||||
defaultTtlSeconds?: number;
|
||||
}
|
||||
|
||||
const kvMetaRegistry = new Map<string, KvQueryMeta<Record<string, unknown>>>();
|
||||
@@ -24,6 +25,7 @@ export function registerTableSpec<Row extends object>(tableSpec: KvTableSpec<Row
|
||||
columns: tableSpec.columns as ReadonlyArray<string>,
|
||||
primaryKey: tableSpec.primaryKey as ReadonlyArray<string>,
|
||||
partitionKey: tableSpec.partitionKey as ReadonlyArray<string>,
|
||||
defaultTtlSeconds: tableSpec.defaultTtlSeconds,
|
||||
};
|
||||
tableRegistry.set(tableSpec.name, metadata);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getTableMetadata} from '@app/api/database/CassandraMetaRegistry';
|
||||
import {defineTable} from '@app/api/database/CassandraTableDsl';
|
||||
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
@@ -76,3 +77,41 @@ describe('CassandraTableDsl select templates', () => {
|
||||
expect(longQuery.cql).not.toContain('LIMIT 20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CassandraTableDsl default TTL', () => {
|
||||
it('keeps the CQL of a table with a default TTL free of USING TTL', () => {
|
||||
const DefaultTtlRows = defineTable<TtlHelperTestRow, 'id'>({
|
||||
name: 'default_ttl_dsl_rows',
|
||||
columns: ['id', 'value'],
|
||||
primaryKey: ['id'],
|
||||
defaultTtlSeconds: 600,
|
||||
});
|
||||
expect(DefaultTtlRows.defaultTtlSeconds).toBe(600);
|
||||
const queries = [
|
||||
DefaultTtlRows.insert({id: 'insert', value: 'a'}),
|
||||
DefaultTtlRows.upsertAll({id: 'upsert', value: 'b'}),
|
||||
DefaultTtlRows.patchByPk({id: 'patch'}, {value: Db.set('c')}),
|
||||
];
|
||||
for (const query of queries) {
|
||||
expect(query.cql).not.toContain('USING TTL');
|
||||
expect(query.kvMeta?.table.defaultTtlSeconds).toBe(600);
|
||||
}
|
||||
expect(getTableMetadata('default_ttl_dsl_rows')?.defaultTtlSeconds).toBe(600);
|
||||
expect(TtlHelperTestRows.defaultTtlSeconds).toBeUndefined();
|
||||
expect(getTableMetadata('ttl_helper_test_rows')?.defaultTtlSeconds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a default TTL of zero, a fraction or past the maximum', () => {
|
||||
for (const defaultTtlSeconds of [0, 1.5, 630_720_001]) {
|
||||
expect(() =>
|
||||
defineTable<TtlHelperTestRow, 'id'>({
|
||||
name: 'default_ttl_dsl_rejected_rows',
|
||||
columns: ['id', 'value'],
|
||||
primaryKey: ['id'],
|
||||
defaultTtlSeconds,
|
||||
}),
|
||||
).toThrow();
|
||||
}
|
||||
expect(getTableMetadata('default_ttl_dsl_rejected_rows')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,6 +83,7 @@ export function defineTable<Row extends object, PK extends ColumnName<Row>, Part
|
||||
columns: ReadonlyArray<ColumnName<Row>>;
|
||||
primaryKey: ReadonlyArray<PK>;
|
||||
partitionKey?: ReadonlyArray<PartKey>;
|
||||
defaultTtlSeconds?: number;
|
||||
}): Table<Row, PK, PartKey> {
|
||||
const columns = [...def.columns];
|
||||
const pk = [...def.primaryKey];
|
||||
@@ -91,11 +92,15 @@ export function defineTable<Row extends object, PK extends ColumnName<Row>, Part
|
||||
for (const c of columns) assertCqlIdentifier(c as string);
|
||||
for (const k of pk) assertCqlIdentifier(k as string);
|
||||
for (const k of partitionKey) assertCqlIdentifier(k as string);
|
||||
if (def.defaultTtlSeconds !== undefined && validateTtlSeconds(def.defaultTtlSeconds) === 0) {
|
||||
throw new Error(`Table "${def.name}" needs a positive default TTL`);
|
||||
}
|
||||
const tableSpec: KvTableSpec<Row> = {
|
||||
name: def.name,
|
||||
columns,
|
||||
primaryKey: pk as ReadonlyArray<ColumnName<Row>>,
|
||||
partitionKey: partitionKey as ReadonlyArray<ColumnName<Row>>,
|
||||
defaultTtlSeconds: def.defaultTtlSeconds,
|
||||
};
|
||||
registerTableSpec(tableSpec);
|
||||
const nonPkColumns = columns.filter((c) => !pk.includes(c as PK)) as Array<Exclude<ColumnName<Row>, PK>>;
|
||||
@@ -685,6 +690,7 @@ WHERE ${pk.map((k) => `${k} = :${k}`).join(' AND ')};
|
||||
columns: def.columns,
|
||||
primaryKey: def.primaryKey,
|
||||
partitionKey: partitionKey,
|
||||
defaultTtlSeconds: def.defaultTtlSeconds,
|
||||
selectCql,
|
||||
select,
|
||||
updateAllCql() {
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface KvTableSpec<Row extends object = Record<string, unknown>> {
|
||||
columns: ReadonlyArray<ColumnName<Row>>;
|
||||
primaryKey: ReadonlyArray<ColumnName<Row>>;
|
||||
partitionKey: ReadonlyArray<ColumnName<Row>>;
|
||||
defaultTtlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface KvColumnParam<Row extends object = Record<string, unknown>> {
|
||||
@@ -190,6 +191,7 @@ export interface Table<Row extends object, PK extends ColumnName<Row>, PartKey e
|
||||
columns: ReadonlyArray<ColumnName<Row>>;
|
||||
primaryKey: ReadonlyArray<PK>;
|
||||
partitionKey: ReadonlyArray<PartKey>;
|
||||
defaultTtlSeconds: number | undefined;
|
||||
selectCql(opts?: {
|
||||
columns?: ReadonlyArray<ColumnName<Row>>;
|
||||
where?: WhereExpr<Row> | ReadonlyArray<WhereExpr<Row>>;
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {createServer} from 'node:net';
|
||||
import {defineTable} from '@app/api/database/CassandraTableDsl';
|
||||
import {Db} from '@app/api/database/CassandraTypes';
|
||||
import {
|
||||
DEFAULT_TTL_EXPIRY_RESUME,
|
||||
DEFAULT_TTL_TABLES,
|
||||
expireLegacyDefaultTtlRows,
|
||||
} from '@app/api/database/PostgresKvDefaultTtlExpiry';
|
||||
import {
|
||||
ensurePostgresKvSchema,
|
||||
PostgresKvQueryExecutor,
|
||||
pruneExpiredPostgresKvRows,
|
||||
} from '@app/api/database/PostgresKvQueryExecutor';
|
||||
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
|
||||
import {
|
||||
getDefaultPostgresClient,
|
||||
type IPostgresClient,
|
||||
initPostgres,
|
||||
shutdownPostgres,
|
||||
} from '@pkgs/postgres/src/Client';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const KV_TABLE = 'kv_default_ttl';
|
||||
const CONTAINER = `fluxer-kvttl-${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
||||
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
|
||||
const DEFAULT_TTL_SECONDS = 600;
|
||||
|
||||
interface ProbeRow {
|
||||
id: string;
|
||||
value: string | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
interface OwnedProbeRow {
|
||||
owner: string;
|
||||
id: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const DefaultTtlProbe = defineTable<ProbeRow, 'id'>({
|
||||
name: 'default_ttl_probe',
|
||||
columns: ['id', 'value', 'note'],
|
||||
primaryKey: ['id'],
|
||||
defaultTtlSeconds: DEFAULT_TTL_SECONDS,
|
||||
});
|
||||
|
||||
const DefaultTtlProbeRows = defineTable<OwnedProbeRow, 'owner' | 'id', 'owner'>({
|
||||
name: 'default_ttl_probe_rows',
|
||||
columns: ['owner', 'id', 'value'],
|
||||
primaryKey: ['owner', 'id'],
|
||||
partitionKey: ['owner'],
|
||||
defaultTtlSeconds: DEFAULT_TTL_SECONDS,
|
||||
});
|
||||
|
||||
const NoTtlProbe = defineTable<ProbeRow, 'id'>({
|
||||
name: 'no_ttl_probe',
|
||||
columns: ['id', 'value', 'note'],
|
||||
primaryKey: ['id'],
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function expectExpiresIn(value: Date | number | null, ttlSeconds: number): void {
|
||||
expect(value).toBeInstanceOf(Date);
|
||||
const remainingSeconds = ((value as Date).getTime() - Date.now()) / 1000;
|
||||
expect(remainingSeconds).toBeGreaterThan(ttlSeconds - 60);
|
||||
expect(remainingSeconds).toBeLessThanOrEqual(ttlSeconds);
|
||||
}
|
||||
|
||||
describe.skipIf(!dockerAvailable)('Postgres KV default TTL', () => {
|
||||
let raw: IPostgresClient;
|
||||
let executor: PostgresKvQueryExecutor;
|
||||
|
||||
async function stored(table: string, id: string): Promise<{expires_at: Date | number | null; row_data: object}> {
|
||||
const result = await raw.query<{expires_at: Date | number | null; row_data: object}>(
|
||||
`SELECT expires_at, row_data FROM ${KV_TABLE} WHERE table_name = $1 AND row_data ->> 'id' = $2`,
|
||||
[table, id],
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
return result.rows[0]!;
|
||||
}
|
||||
|
||||
async function expiresAt(table: string, id: string): Promise<Date | number | null> {
|
||||
return (await stored(table, id)).expires_at;
|
||||
}
|
||||
|
||||
async function neverExpires(table: string, id: string): Promise<boolean> {
|
||||
const result = await raw.query<{forever: boolean}>(
|
||||
`SELECT expires_at = 'infinity'::timestamptz AS forever FROM ${KV_TABLE} WHERE table_name = $1 AND row_data ->> 'id' = $2`,
|
||||
[table, id],
|
||||
);
|
||||
return result.rows[0]?.forever === true;
|
||||
}
|
||||
|
||||
async function setExpiry(table: string, id: string, expression: string): Promise<void> {
|
||||
await raw.query(
|
||||
`UPDATE ${KV_TABLE} SET expires_at = ${expression} WHERE table_name = $1 AND row_data ->> 'id' = $2`,
|
||||
[table, id],
|
||||
);
|
||||
}
|
||||
|
||||
async function seed(table: string, key: string, age: string, expires: Date | string | null = null): Promise<string> {
|
||||
const result = await raw.query<{updated_at: string}>(
|
||||
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $2, '{}'::jsonb, $3::timestamptz, now() - $4::interval)
|
||||
RETURNING updated_at::text`,
|
||||
[table, key, expires, age],
|
||||
);
|
||||
return result.rows[0]!.updated_at;
|
||||
}
|
||||
|
||||
async function remaining(): Promise<Array<{table_name: string; row_key: string}>> {
|
||||
const result = await raw.query<{table_name: string; row_key: string}>(
|
||||
`SELECT table_name, row_key FROM ${KV_TABLE} WHERE table_name <> '__fluxer_schema_migrations' ORDER BY table_name, row_key`,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function ageMarker(): Promise<void> {
|
||||
await raw.query(
|
||||
`UPDATE ${KV_TABLE} SET row_data = jsonb_build_object('applied_at', now() - interval '2 days') WHERE table_name = '__fluxer_schema_migrations' AND row_key = 'default_ttl_expiry_v1'`,
|
||||
);
|
||||
}
|
||||
|
||||
async function resumePoint(): Promise<object | null> {
|
||||
const result = await raw.query<{row_data: object}>(
|
||||
`SELECT row_data FROM ${KV_TABLE} WHERE table_name = '__fluxer_schema_migrations' AND row_key = $1`,
|
||||
[DEFAULT_TTL_EXPIRY_RESUME],
|
||||
);
|
||||
return result.rows[0]?.row_data ?? null;
|
||||
}
|
||||
|
||||
async function markerCount(): Promise<number> {
|
||||
const result = await raw.query<{n: number}>(
|
||||
`SELECT count(*)::int AS n FROM ${KV_TABLE} WHERE table_name = '__fluxer_schema_migrations' AND row_key = 'default_ttl_expiry_v1'`,
|
||||
);
|
||||
return result.rows[0]!.n;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const port = await freePort();
|
||||
startDockerContainer([
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
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', 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: KV_TABLE,
|
||||
});
|
||||
await getDefaultPostgresClient().query('SELECT 1');
|
||||
ready = true;
|
||||
} catch {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (!ready) throw new Error('postgres never came up');
|
||||
raw = getDefaultPostgresClient();
|
||||
await ensurePostgresKvSchema(raw);
|
||||
executor = new PostgresKvQueryExecutor(raw);
|
||||
}, 900_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await raw.query(`DELETE FROM ${KV_TABLE}`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
|
||||
});
|
||||
|
||||
it('gives every full-row write without a TTL the table default', async () => {
|
||||
await executor.executeQuery(DefaultTtlProbe.insert({id: 'insert', value: 'a', note: null}));
|
||||
await executor.executeQuery(DefaultTtlProbe.upsertAll({id: 'upsert', value: 'b', note: 'n'}));
|
||||
expect(
|
||||
await executor.executeQuery(DefaultTtlProbe.insertIfNotExists({id: 'claimed', value: 'c', note: null})),
|
||||
).toEqual([{'[applied]': true}]);
|
||||
expect(
|
||||
await executor.executeQuery(
|
||||
DefaultTtlProbeRows.conditionalBatch([{action: 'insert', row: {owner: 'o', id: 'batched', value: 'd'}}]),
|
||||
),
|
||||
).toEqual([{'[applied]': true}]);
|
||||
|
||||
for (const id of ['insert', 'upsert', 'claimed']) {
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', id), DEFAULT_TTL_SECONDS);
|
||||
}
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe_rows', 'batched'), DEFAULT_TTL_SECONDS);
|
||||
});
|
||||
|
||||
it('keeps an explicit TTL ahead of the default', async () => {
|
||||
await executor.executeQuery(DefaultTtlProbe.insertWithTtl({id: 'short', value: 'a', note: null}, 60));
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'short'), 60);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.insert({id: 'patched', value: 'a', note: null}));
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPkWithTtl({id: 'patched'}, {value: Db.set('b')}, 60));
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'patched'), 60);
|
||||
});
|
||||
|
||||
it('keeps an explicit TTL of zero as no expiry', async () => {
|
||||
await executor.executeQuery(DefaultTtlProbe.insertWithTtl({id: 'forever', value: 'a', note: null}, 0));
|
||||
expect(await neverExpires('default_ttl_probe', 'forever')).toBe(true);
|
||||
expect(
|
||||
await executor.executeQuery(
|
||||
DefaultTtlProbe.select({where: DefaultTtlProbe.where.eq('id')}).bind({id: 'forever'}),
|
||||
),
|
||||
).toEqual([{id: 'forever', value: 'a', note: null}]);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'forever'}, {note: Db.set('patched')}));
|
||||
expect(await neverExpires('default_ttl_probe', 'forever')).toBe(true);
|
||||
|
||||
await pruneExpiredPostgresKvRows(raw);
|
||||
expect(await neverExpires('default_ttl_probe', 'forever')).toBe(true);
|
||||
});
|
||||
|
||||
it('raises a patched row to the default but never lowers it', async () => {
|
||||
await executor.executeQuery(DefaultTtlProbe.insertWithTtl({id: 'longer', value: 'a', note: null}, 3600));
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'longer'}, {note: Db.set('patched')}));
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'longer'), 3600);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.insert({id: 'soon', value: 'a', note: null}));
|
||||
await setExpiry('default_ttl_probe', 'soon', "now() + interval '5 seconds'");
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'soon'}, {note: Db.set('patched')}));
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'soon'), DEFAULT_TTL_SECONDS);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'missing'}, {note: Db.set('created')}));
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'missing'), DEFAULT_TTL_SECONDS);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.insert({id: 'unset', value: 'a', note: null}));
|
||||
await setExpiry('default_ttl_probe', 'unset', 'NULL');
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'unset'}, {note: Db.set('patched')}));
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'unset'), DEFAULT_TTL_SECONDS);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.insert({id: 'expired', value: 'a', note: null}));
|
||||
await setExpiry('default_ttl_probe', 'expired', "now() - interval '1 second'");
|
||||
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'expired'}, {note: Db.set('patched')}));
|
||||
const revived = await stored('default_ttl_probe', 'expired');
|
||||
expect(revived.row_data).toEqual({id: 'expired', note: 'patched'});
|
||||
expectExpiresIn(revived.expires_at, DEFAULT_TTL_SECONDS);
|
||||
});
|
||||
|
||||
it('raises conditional patches the same way', async () => {
|
||||
await executor.executeQuery(DefaultTtlProbe.insert({id: 'soon', value: 'a', note: null}));
|
||||
await setExpiry('default_ttl_probe', 'soon', "now() + interval '5 seconds'");
|
||||
expect(
|
||||
await executor.executeQuery(
|
||||
DefaultTtlProbe.conditionalPatchByPk({id: 'soon'}, {note: Db.set('patched')}, {value: 'a'}),
|
||||
),
|
||||
).toEqual([{'[applied]': true}]);
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'soon'), DEFAULT_TTL_SECONDS);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbe.insertWithTtl({id: 'longer', value: 'a', note: null}, 3600));
|
||||
expect(
|
||||
await executor.executeQuery(
|
||||
DefaultTtlProbe.conditionalPatchByPk({id: 'longer'}, {note: Db.set('patched')}, {value: 'a'}),
|
||||
),
|
||||
).toEqual([{'[applied]': true}]);
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe', 'longer'), 3600);
|
||||
|
||||
await executor.executeQuery(DefaultTtlProbeRows.insert({owner: 'o', id: 'existing', value: 'old'}));
|
||||
await setExpiry('default_ttl_probe_rows', 'existing', 'NULL');
|
||||
expect(
|
||||
await executor.executeQuery(
|
||||
DefaultTtlProbeRows.conditionalBatch([
|
||||
{action: 'insert', row: {owner: 'o', id: 'added', value: 'new'}},
|
||||
{
|
||||
action: 'patch',
|
||||
pk: {owner: 'o', id: 'existing'},
|
||||
patch: {value: Db.set('updated')},
|
||||
expected: {value: 'old'},
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([{'[applied]': true}]);
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe_rows', 'added'), DEFAULT_TTL_SECONDS);
|
||||
expectExpiresIn(await expiresAt('default_ttl_probe_rows', 'existing'), DEFAULT_TTL_SECONDS);
|
||||
});
|
||||
|
||||
it('leaves tables without a default untouched', async () => {
|
||||
await executor.executeQuery(NoTtlProbe.insert({id: 'plain', value: 'a', note: null}));
|
||||
expect(await expiresAt('no_ttl_probe', 'plain')).toBeNull();
|
||||
await executor.executeQuery(NoTtlProbe.patchByPk({id: 'plain'}, {note: Db.set('patched')}));
|
||||
expect(await expiresAt('no_ttl_probe', 'plain')).toBeNull();
|
||||
await executor.executeQuery(NoTtlProbe.insertWithTtl({id: 'zero', value: 'a', note: null}, 0));
|
||||
expect(await expiresAt('no_ttl_probe', 'zero')).toBeNull();
|
||||
});
|
||||
|
||||
it('gives rows an older image wrote the expiry of their last write and deletes the ones past it', async () => {
|
||||
const mentionWrittenAt = await seed('recent_mentions', 'rm-day', '1 day');
|
||||
await seed('recent_mentions', 'rm-week', '8 days');
|
||||
await seed('attachment_upload_traces_by_key', 'at-31', '31 days');
|
||||
await seed('attachment_upload_traces_by_key', 'at-29', '29 days');
|
||||
await seed('phone_lookup_cache', 'pl-8', '8 days');
|
||||
await seed('donor_magic_link_tokens', 'dm-hour', '1 hour');
|
||||
await seed('ipinfo_requests_by_hour', 'ip-day', '1 day');
|
||||
await seed('jobs_by_id', 'job', '100 days');
|
||||
await seed('users', 'user', '100 days');
|
||||
await seed('recent_mentions', 'rm-forever', '1 day', 'infinity');
|
||||
await seed('recent_mentions', 'rm-hour', '30 days', new Date(Date.now() + 3_600_000));
|
||||
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 4,
|
||||
expiring: 3,
|
||||
complete: true,
|
||||
});
|
||||
expect(await remaining()).toEqual([
|
||||
{table_name: 'attachment_upload_traces_by_key', row_key: 'at-29'},
|
||||
{table_name: 'ipinfo_requests_by_hour', row_key: 'ip-day'},
|
||||
{table_name: 'jobs_by_id', row_key: 'job'},
|
||||
{table_name: 'recent_mentions', row_key: 'rm-day'},
|
||||
{table_name: 'recent_mentions', row_key: 'rm-forever'},
|
||||
{table_name: 'recent_mentions', row_key: 'rm-hour'},
|
||||
{table_name: 'users', row_key: 'user'},
|
||||
]);
|
||||
|
||||
const exact = await raw.query<{row_key: string; exact: boolean; unchanged: boolean | null}>(
|
||||
`SELECT row_key,
|
||||
expires_at = updated_at + CASE table_name WHEN 'recent_mentions' THEN interval '7 days' WHEN 'attachment_upload_traces_by_key' THEN interval '30 days' ELSE interval '90 days' END AS exact,
|
||||
CASE WHEN row_key = 'rm-day' THEN updated_at = $1::timestamptz END AS unchanged
|
||||
FROM ${KV_TABLE}
|
||||
WHERE row_key IN ('rm-day', 'at-29', 'ip-day')
|
||||
ORDER BY row_key`,
|
||||
[mentionWrittenAt],
|
||||
);
|
||||
expect(exact.rows).toEqual([
|
||||
{row_key: 'at-29', exact: true, unchanged: null},
|
||||
{row_key: 'ip-day', exact: true, unchanged: null},
|
||||
{row_key: 'rm-day', exact: true, unchanged: true},
|
||||
]);
|
||||
const untouched = await raw.query<{row_key: string; state: string}>(
|
||||
`SELECT row_key, CASE WHEN expires_at IS NULL THEN 'unset' WHEN expires_at = 'infinity' THEN 'forever' ELSE 'set' END AS state
|
||||
FROM ${KV_TABLE}
|
||||
WHERE row_key IN ('job', 'user', 'rm-forever', 'rm-hour')
|
||||
ORDER BY row_key`,
|
||||
);
|
||||
expect(untouched.rows).toEqual([
|
||||
{row_key: 'job', state: 'unset'},
|
||||
{row_key: 'rm-forever', state: 'forever'},
|
||||
{row_key: 'rm-hour', state: 'set'},
|
||||
{row_key: 'user', state: 'unset'},
|
||||
]);
|
||||
|
||||
expect(await markerCount()).toBe(0);
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
expect(await markerCount()).toBe(1);
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
it('checks again a day after a clean pass', async () => {
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
|
||||
|
||||
await seed('recent_mentions', 'rm-rolled-back', '1 day');
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
|
||||
const before = await raw.query(`SELECT expires_at FROM ${KV_TABLE} WHERE row_key = 'rm-rolled-back'`);
|
||||
expect(before.rows).toEqual([{expires_at: null}]);
|
||||
|
||||
await ageMarker();
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 1,
|
||||
complete: true,
|
||||
});
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
it('pages through more rows than one page holds and stops at its deadline', async () => {
|
||||
await raw.query(
|
||||
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data, updated_at)
|
||||
SELECT 'recent_mentions', 'rm-' || lpad(g::text, 5, '0'), 'rm-' || lpad(g::text, 5, '0'), '{}'::jsonb, now() - interval '1 day'
|
||||
FROM generate_series(1, 2300) g`,
|
||||
);
|
||||
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() - 1)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: false,
|
||||
});
|
||||
expect(await markerCount()).toBe(0);
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 2300,
|
||||
complete: true,
|
||||
});
|
||||
const unset = await raw.query<{n: number}>(
|
||||
`SELECT count(*)::int AS n FROM ${KV_TABLE} WHERE table_name = 'recent_mentions' AND expires_at IS NULL`,
|
||||
);
|
||||
expect(unset.rows[0]).toEqual({n: 0});
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
it('saves where a run stopped and starts the next run there', async () => {
|
||||
const first = DEFAULT_TTL_TABLES[0]!.name;
|
||||
const last = DEFAULT_TTL_TABLES.at(-1)!.name;
|
||||
await seed(first, 'a', '1 hour');
|
||||
await seed(first, 'z', '1 hour');
|
||||
await seed(last, 'k', '1 hour');
|
||||
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() - 1)).toEqual({deleted: 0, expiring: 0, complete: false});
|
||||
expect(await resumePoint()).toEqual({table: first, row_key: '', unset: 0});
|
||||
|
||||
await raw.query(
|
||||
`UPDATE ${KV_TABLE} SET row_data = jsonb_build_object('table', $1::text, 'row_key', 'm', 'unset', 0) WHERE table_name = '__fluxer_schema_migrations' AND row_key = $2`,
|
||||
[first, DEFAULT_TTL_EXPIRY_RESUME],
|
||||
);
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 2,
|
||||
complete: true,
|
||||
});
|
||||
const untouched = await raw.query<{expires_at: Date | null}>(
|
||||
`SELECT expires_at FROM ${KV_TABLE} WHERE table_name = $1 AND row_key = 'a'`,
|
||||
[first],
|
||||
);
|
||||
expect(untouched.rows).toEqual([{expires_at: null}]);
|
||||
expect(await resumePoint()).toBeNull();
|
||||
expect(await markerCount()).toBe(0);
|
||||
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 1,
|
||||
complete: true,
|
||||
});
|
||||
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
|
||||
deleted: 0,
|
||||
expiring: 0,
|
||||
complete: true,
|
||||
});
|
||||
expect(await markerCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
POSTGRES_KV_MIGRATION_TABLE,
|
||||
postgresKvPassIsFresh,
|
||||
recordPostgresKvCleanPass,
|
||||
} from '@app/api/database/PostgresKvQueryExecutor';
|
||||
import * as DonationTables from '@app/api/donation/DonationTables';
|
||||
import * as Tables from '@app/api/Tables';
|
||||
import {IPINFO_CACHE_TTL_SECONDS, IPINFO_REQUEST_AUDIT_TTL_SECONDS} from '@pkgs/geoip/src/PostgresIpInfoKv';
|
||||
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const DEFAULT_TTL_EXPIRY_MARKER = 'default_ttl_expiry_v1';
|
||||
export const DEFAULT_TTL_EXPIRY_RESUME = 'default_ttl_expiry_v1_resume';
|
||||
const PAGE_SIZE = 2000;
|
||||
const CLEAN_PASS_INTERVAL_MS = ms('1 day');
|
||||
const OWN_EXPIRY_PASS = new Set<string>([Tables.JobsById.name, Tables.JobsByDayBucket.name]);
|
||||
|
||||
export const DEFAULT_TTL_TABLES: ReadonlyArray<{name: string; defaultTtlSeconds: number}> = [
|
||||
...[...Object.values(Tables), ...Object.values(DonationTables)].flatMap((table) =>
|
||||
table.defaultTtlSeconds === undefined || OWN_EXPIRY_PASS.has(table.name)
|
||||
? []
|
||||
: [{name: table.name, defaultTtlSeconds: table.defaultTtlSeconds}],
|
||||
),
|
||||
{name: 'ipinfo_cache', defaultTtlSeconds: IPINFO_CACHE_TTL_SECONDS},
|
||||
{name: 'ipinfo_requests_by_hour', defaultTtlSeconds: IPINFO_REQUEST_AUDIT_TTL_SECONDS},
|
||||
];
|
||||
|
||||
export interface LegacyDefaultTtlExpiryResult {
|
||||
deleted: number;
|
||||
expiring: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
interface ResumePoint {
|
||||
table: string;
|
||||
rowKey: string;
|
||||
unset: number;
|
||||
}
|
||||
|
||||
async function readResumePoint(client: IPostgresClient, kvTable: string): Promise<ResumePoint | null> {
|
||||
const result = await client.query<{row_data: Record<string, unknown>}>(
|
||||
`SELECT row_data FROM ${kvTable} WHERE table_name = $1 AND row_key = $2`,
|
||||
[POSTGRES_KV_MIGRATION_TABLE, DEFAULT_TTL_EXPIRY_RESUME],
|
||||
);
|
||||
const data = result.rows[0]?.row_data;
|
||||
if (typeof data?.table !== 'string' || typeof data.row_key !== 'string' || typeof data.unset !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return {table: data.table, rowKey: data.row_key, unset: data.unset};
|
||||
}
|
||||
|
||||
async function writeResumePoint(client: IPostgresClient, kvTable: string, point: ResumePoint | null): Promise<void> {
|
||||
if (point === null) {
|
||||
await client.query(`DELETE FROM ${kvTable} WHERE table_name = $1 AND row_key = $2`, [
|
||||
POSTGRES_KV_MIGRATION_TABLE,
|
||||
DEFAULT_TTL_EXPIRY_RESUME,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO ${kvTable} (table_name, partition_key, row_key, row_data)
|
||||
VALUES ($1, $2, $2, jsonb_build_object('table', $3::text, 'row_key', $4::text, 'unset', $5::bigint))
|
||||
ON CONFLICT (table_name, row_key) DO UPDATE SET row_data = EXCLUDED.row_data, updated_at = now()`,
|
||||
[POSTGRES_KV_MIGRATION_TABLE, DEFAULT_TTL_EXPIRY_RESUME, point.table, point.rowKey, point.unset],
|
||||
);
|
||||
}
|
||||
|
||||
function pageSql(table: string): string {
|
||||
return `
|
||||
WITH page AS (
|
||||
SELECT kv.row_key, kv.expires_at IS NULL AS unset
|
||||
FROM ${table} kv
|
||||
WHERE kv.table_name = $1 AND kv.row_key > $2
|
||||
ORDER BY kv.row_key
|
||||
LIMIT $3
|
||||
), removed AS (
|
||||
DELETE FROM ${table} kv
|
||||
USING page
|
||||
WHERE kv.table_name = $1 AND kv.row_key = page.row_key AND kv.expires_at IS NULL
|
||||
AND kv.updated_at + make_interval(secs => $4::double precision) <= now()
|
||||
RETURNING 1
|
||||
), expiring AS (
|
||||
UPDATE ${table} kv
|
||||
SET expires_at = kv.updated_at + make_interval(secs => $4::double precision)
|
||||
FROM page
|
||||
WHERE kv.table_name = $1 AND kv.row_key = page.row_key AND kv.expires_at IS NULL
|
||||
AND kv.updated_at + make_interval(secs => $4::double precision) > now()
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT
|
||||
(SELECT max(row_key) FROM page) AS last_row_key,
|
||||
(SELECT count(*) FROM page WHERE unset) AS unset,
|
||||
(SELECT count(*) FROM removed) AS deleted,
|
||||
(SELECT count(*) FROM expiring) AS expiring`;
|
||||
}
|
||||
|
||||
export async function expireLegacyDefaultTtlRows(
|
||||
client: IPostgresClient,
|
||||
deadlineMs: number,
|
||||
): Promise<LegacyDefaultTtlExpiryResult | null> {
|
||||
if (await postgresKvPassIsFresh(client, DEFAULT_TTL_EXPIRY_MARKER, CLEAN_PASS_INTERVAL_MS)) {
|
||||
return null;
|
||||
}
|
||||
const kvTable = quoteIdentifier(client.kvTable());
|
||||
const sql = pageSql(kvTable);
|
||||
const resume = await readResumePoint(client, kvTable);
|
||||
const resumeIndex = resume === null ? -1 : DEFAULT_TTL_TABLES.findIndex((target) => target.name === resume.table);
|
||||
let unset = resumeIndex < 0 ? 0 : resume!.unset;
|
||||
let deleted = 0;
|
||||
let expiring = 0;
|
||||
for (let index = Math.max(resumeIndex, 0); index < DEFAULT_TTL_TABLES.length; index += 1) {
|
||||
const target = DEFAULT_TTL_TABLES[index]!;
|
||||
let cursor = index === resumeIndex ? resume!.rowKey : '';
|
||||
for (;;) {
|
||||
if (Date.now() >= deadlineMs) {
|
||||
await writeResumePoint(client, kvTable, {table: target.name, rowKey: cursor, unset});
|
||||
return {deleted, expiring, complete: false};
|
||||
}
|
||||
const result = await client.query<{
|
||||
last_row_key: string | null;
|
||||
unset: string;
|
||||
deleted: string;
|
||||
expiring: string;
|
||||
}>(sql, [target.name, cursor, PAGE_SIZE, target.defaultTtlSeconds]);
|
||||
const page = result.rows[0];
|
||||
if (!page || page.last_row_key === null) {
|
||||
break;
|
||||
}
|
||||
unset += Number(page.unset);
|
||||
deleted += Number(page.deleted);
|
||||
expiring += Number(page.expiring);
|
||||
cursor = page.last_row_key;
|
||||
}
|
||||
}
|
||||
await writeResumePoint(client, kvTable, null);
|
||||
if (unset === 0) {
|
||||
await recordPostgresKvCleanPass(client, DEFAULT_TTL_EXPIRY_MARKER);
|
||||
}
|
||||
return {deleted, expiring, complete: true};
|
||||
}
|
||||
@@ -89,6 +89,28 @@ const NUMERIC_ROW_KEY_NUMBER_PATTERN = '^(-?[0-9]+(?:\\.[0-9]+)?(?:[eE][-+]?[0-9
|
||||
const EXPIRED_STORED_ROW = 'kv.expires_at IS NOT NULL AND kv.expires_at <= now()';
|
||||
const MERGED_ROW_DATA = `CASE WHEN ${EXPIRED_STORED_ROW} THEN EXCLUDED.row_data ELSE kv.row_data || EXCLUDED.row_data END`;
|
||||
const KEPT_EXPIRES_AT = `CASE WHEN ${EXPIRED_STORED_ROW} THEN NULL ELSE kv.expires_at END`;
|
||||
const NO_EXPIRY = 'infinity';
|
||||
|
||||
export async function postgresKvPassIsFresh(
|
||||
client: IPostgresClient,
|
||||
marker: string,
|
||||
maxAgeMs: number,
|
||||
): Promise<boolean> {
|
||||
const result = await client.query(
|
||||
`SELECT 1 FROM ${quoteIdentifier(client.kvTable())} WHERE table_name = $1 AND row_key = $2 AND (row_data ->> 'applied_at')::timestamptz > now() - make_interval(secs => $3::double precision)`,
|
||||
[POSTGRES_KV_MIGRATION_TABLE, marker, maxAgeMs / 1000],
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
export async function recordPostgresKvCleanPass(client: IPostgresClient, marker: string): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO ${quoteIdentifier(client.kvTable())} (table_name, partition_key, row_key, row_data)
|
||||
VALUES ($1, $2, $2, jsonb_build_object('applied_at', now()))
|
||||
ON CONFLICT (table_name, row_key) DO UPDATE SET row_data = EXCLUDED.row_data, updated_at = now()`,
|
||||
[POSTGRES_KV_MIGRATION_TABLE, marker],
|
||||
);
|
||||
}
|
||||
|
||||
function numericRowKeyExpr(column: string): string {
|
||||
return `(COALESCE(substring(${column} from '${NUMERIC_ROW_KEY_BIGINT_PATTERN}'), substring(${column} from '${NUMERIC_ROW_KEY_NUMBER_PATTERN}'))::numeric)`;
|
||||
@@ -333,20 +355,21 @@ function projectRow(row: Row, columns: ReadonlyArray<string> | undefined): Row {
|
||||
return projected;
|
||||
}
|
||||
|
||||
function rowComparator(meta: KvQueryMeta): (left: Row, right: Row) => number {
|
||||
if (meta.orderBy) {
|
||||
const column = meta.orderBy.col as string;
|
||||
const direction = meta.orderBy.direction === 'DESC' ? -1 : 1;
|
||||
return (left, right) => compareValues(left[column], right[column]) * direction;
|
||||
function compareColumns(columns: ReadonlyArray<string>, left: Row, right: Row): number {
|
||||
for (const column of columns) {
|
||||
const cmp = compareValues(left[column], right[column]);
|
||||
if (cmp !== 0) return cmp;
|
||||
}
|
||||
const columns = meta.table.primaryKey as ReadonlyArray<string>;
|
||||
return (left, right) => {
|
||||
for (const column of columns) {
|
||||
const cmp = compareValues(left[column], right[column]);
|
||||
if (cmp !== 0) return cmp;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
return 0;
|
||||
}
|
||||
|
||||
function rowComparator(meta: KvQueryMeta): (left: Row, right: Row) => number {
|
||||
const primaryKey = meta.table.primaryKey as ReadonlyArray<string>;
|
||||
if (!meta.orderBy) return (left, right) => compareColumns(primaryKey, left, right);
|
||||
const column = meta.orderBy.col as string;
|
||||
const columns = [column, ...primaryKey.slice(primaryKey.indexOf(column) + 1)];
|
||||
const direction = meta.orderBy.direction === 'DESC' ? -1 : 1;
|
||||
return (left, right) => compareColumns(columns, left, right) * direction;
|
||||
}
|
||||
|
||||
function sortRows(meta: KvQueryMeta, rows: Array<Row>): Array<Row> {
|
||||
@@ -679,7 +702,7 @@ function logFullScan(meta: KvQueryMeta): void {
|
||||
logWarn({table: meta.table.name, action: meta.action, where: shape.summary || 'none'}, 'Postgres KV full table scan');
|
||||
}
|
||||
|
||||
function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | null | undefined {
|
||||
function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | typeof NO_EXPIRY | null | undefined {
|
||||
const ttlParam = meta.ttlParamName;
|
||||
if (!ttlParam) return undefined;
|
||||
const ttlRaw = params[ttlParam];
|
||||
@@ -687,7 +710,13 @@ function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | null |
|
||||
throw new Error(`TTL parameter ${ttlParam} must be a number`);
|
||||
}
|
||||
const ttlSeconds = validateTtlSeconds(ttlRaw);
|
||||
return ttlSeconds === 0 ? null : new Date(Date.now() + ttlSeconds * 1000);
|
||||
if (ttlSeconds === 0) return meta.table.defaultTtlSeconds === undefined ? null : NO_EXPIRY;
|
||||
return new Date(Date.now() + ttlSeconds * 1000);
|
||||
}
|
||||
|
||||
function defaultExpiresAt(meta: KvQueryMeta): Date | undefined {
|
||||
const ttlSeconds = meta.table.defaultTtlSeconds;
|
||||
return ttlSeconds === undefined ? undefined : new Date(Date.now() + ttlSeconds * 1000);
|
||||
}
|
||||
|
||||
function encodePageState(pageState: PageState): string {
|
||||
@@ -1191,7 +1220,8 @@ export class PostgresKvQueryExecutor {
|
||||
'kv_del_expired',
|
||||
);
|
||||
}
|
||||
const expiresAt = ttlExpiresAt(meta, params) ?? null;
|
||||
const explicit = ttlExpiresAt(meta, params);
|
||||
const expiresAt = explicit === undefined ? (defaultExpiresAt(meta) ?? null) : explicit;
|
||||
const result = await db.query(
|
||||
`INSERT INTO ${this.table} AS kv (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
@@ -1244,10 +1274,14 @@ WHERE NOT $6`,
|
||||
}
|
||||
bindings.push(JSON.stringify(encodeRow(paramsRow(params, meta.patchKeys))));
|
||||
const assignments = [`row_data = kv.row_data || $${bindings.length}::jsonb`, 'updated_at = now()'];
|
||||
const expiresAt = ttlExpiresAt(meta, params);
|
||||
if (expiresAt !== undefined) {
|
||||
bindings.push(expiresAt);
|
||||
const explicit = ttlExpiresAt(meta, params);
|
||||
const fallback = explicit === undefined ? defaultExpiresAt(meta) : undefined;
|
||||
if (explicit !== undefined) {
|
||||
bindings.push(explicit);
|
||||
assignments.push(`expires_at = $${bindings.length}`);
|
||||
} else if (fallback !== undefined) {
|
||||
bindings.push(fallback);
|
||||
assignments.push(`expires_at = GREATEST(kv.expires_at, $${bindings.length}::timestamptz)`);
|
||||
}
|
||||
sql = `UPDATE ${this.table} kv SET ${assignments.join(', ')} WHERE ${where}`;
|
||||
}
|
||||
@@ -1346,15 +1380,27 @@ WHERE NOT $6`,
|
||||
for (const column of meta.patchKeys ?? []) {
|
||||
incoming[column] = column in params ? params[column] : null;
|
||||
}
|
||||
const ttl = ttlExpiresAt(meta, params);
|
||||
const expiresAtExpr = ttl === undefined ? KEPT_EXPIRES_AT : 'EXCLUDED.expires_at';
|
||||
const explicit = ttlExpiresAt(meta, params);
|
||||
const fallback = explicit === undefined ? defaultExpiresAt(meta) : undefined;
|
||||
const [expiresAtExpr, statementName] =
|
||||
explicit !== undefined
|
||||
? ['EXCLUDED.expires_at', 'kv_patch_set_ttl']
|
||||
: fallback !== undefined
|
||||
? ['GREATEST(kv.expires_at, EXCLUDED.expires_at)', 'kv_patch_default_ttl']
|
||||
: [KEPT_EXPIRES_AT, 'kv_patch_keep_ttl'];
|
||||
await db.query(
|
||||
`INSERT INTO ${this.table} AS kv (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
ON CONFLICT (table_name, row_key)
|
||||
DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = ${MERGED_ROW_DATA}, expires_at = ${expiresAtExpr}, updated_at = now()`,
|
||||
[meta.table.name, partitionKey(meta, incoming), key, JSON.stringify(encodeRow(incoming)), ttl ?? null],
|
||||
ttl === undefined ? 'kv_patch_keep_ttl' : 'kv_patch_set_ttl',
|
||||
[
|
||||
meta.table.name,
|
||||
partitionKey(meta, incoming),
|
||||
key,
|
||||
JSON.stringify(encodeRow(incoming)),
|
||||
explicit ?? fallback ?? null,
|
||||
],
|
||||
statementName,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ const Composite: KvTableSpec<Row> = {
|
||||
partitionKey: ['owner_id'],
|
||||
};
|
||||
|
||||
const Expiring: KvTableSpec<Row> = {...Composite, name: 'stmt_expiring', defaultTtlSeconds: 600};
|
||||
|
||||
const Bucketed: KvTableSpec<Row> = {
|
||||
name: 'stmt_bucketed',
|
||||
columns: ['bucket', 'item_id', 'payload'],
|
||||
@@ -118,6 +120,7 @@ async function runShapes(): Promise<Array<Statement>> {
|
||||
meta(Composite, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload'], ttlParamName: 'ttl_'}),
|
||||
{...OWNER_ITEM, ttl_: 600} as CassandraParams,
|
||||
],
|
||||
[meta(Expiring, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload']}), OWNER_ITEM],
|
||||
];
|
||||
for (const [kvMeta, params] of cases) {
|
||||
await executor.executeQuery({cql: `__stmt_${kvMeta.action}`, params, kvMeta: kvMeta as KvQueryMeta});
|
||||
@@ -142,6 +145,7 @@ describe('PostgresKvQueryExecutor statement names', () => {
|
||||
'kv_del_keys',
|
||||
'kv_del_rowkeys',
|
||||
'kv_get_row',
|
||||
'kv_patch_default_ttl',
|
||||
'kv_patch_keep_ttl',
|
||||
'kv_patch_set_ttl',
|
||||
'kv_sel_range',
|
||||
@@ -231,6 +235,17 @@ async function exerciseKvShapes(executor: PostgresKvQueryExecutor): Promise<void
|
||||
kvMeta: meta(Composite, 'select', [eq('owner_id'), eq('item_id')]) as KvQueryMeta,
|
||||
});
|
||||
expect(patched.map((row) => row.payload)).toEqual(['patched']);
|
||||
await executor.executeQuery({
|
||||
cql: '__stmt_patch_default_ttl',
|
||||
params: {owner_id: 'o5', item_id: 'i5', payload: 'defaulted'} as CassandraParams,
|
||||
kvMeta: meta(Expiring, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload']}) as KvQueryMeta,
|
||||
});
|
||||
const defaulted = await executor.executeQuery<Row>({
|
||||
cql: '__stmt_point',
|
||||
params: {owner_id: 'o5', item_id: 'i5'} as CassandraParams,
|
||||
kvMeta: meta(Expiring, 'select', [eq('owner_id'), eq('item_id')]) as KvQueryMeta,
|
||||
});
|
||||
expect(defaulted.map((row) => row.payload)).toEqual(['defaulted']);
|
||||
await executor.executeQuery({
|
||||
cql: '__stmt_delete',
|
||||
params: {owner_id: 'o0', item_id: 'i0'} as CassandraParams,
|
||||
@@ -323,6 +338,7 @@ describe.skipIf(!dockerAvailable)('PostgresKvQueryExecutor statement names again
|
||||
'kv_del_expired',
|
||||
'kv_del_rowkeys',
|
||||
'kv_get_row',
|
||||
'kv_patch_default_ttl',
|
||||
'kv_patch_keep_ttl',
|
||||
'kv_patch_set_ttl',
|
||||
'kv_sel_range',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {createServer} from 'node:net';
|
||||
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
|
||||
import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/postgres/src/Client';
|
||||
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||
|
||||
const CONTAINER = `fluxer-kvscram-${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
||||
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
|
||||
const SCRAM_ITERATIONS = 200_000;
|
||||
|
||||
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;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!dockerAvailable)('postgres client against a server with raised SCRAM iterations', () => {
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
port = await freePort();
|
||||
startDockerContainer([
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
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',
|
||||
'-c',
|
||||
`scram_iterations=${SCRAM_ITERATIONS}`,
|
||||
]);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
|
||||
await sleep(500);
|
||||
const probe = spawnSync(
|
||||
'docker',
|
||||
['exec', CONTAINER, 'psql', '-h', '127.0.0.1', '-U', 'fluxer', '-d', 'fluxer', '-Atc', 'SELECT 1'],
|
||||
{stdio: 'ignore'},
|
||||
);
|
||||
ready = probe.status === 0;
|
||||
}
|
||||
if (!ready) throw new Error('postgres never came up');
|
||||
const rehash = spawnSync(
|
||||
'docker',
|
||||
['exec', CONTAINER, 'psql', '-U', 'fluxer', '-d', 'fluxer', '-Atc', "ALTER ROLE fluxer PASSWORD 'fluxer'"],
|
||||
{
|
||||
stdio: 'ignore',
|
||||
},
|
||||
);
|
||||
if (rehash.status !== 0) throw new Error('could not re-hash the role password');
|
||||
}, 900_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
|
||||
});
|
||||
|
||||
it('connects when the role verifier uses more iterations than the driver default allows', async () => {
|
||||
await initPostgres({url: `postgres://fluxer:[email protected]:${port}/fluxer`, maxConnections: 1});
|
||||
const verifier = await getDefaultPostgresClient().query<{rolpassword: string}>(
|
||||
"SELECT rolpassword FROM pg_authid WHERE rolname = 'fluxer'",
|
||||
);
|
||||
expect(verifier.rows[0]?.rolpassword.startsWith(`SCRAM-SHA-256$${SCRAM_ITERATIONS}:`)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user