Compare commits

...
Author SHA1 Message Date
HampusandGitHub 6730a242db feat(web): prepare the fluxer.com domain migration (#2952) 2026-09-25 13:43:34 +02:00
HampusandGitHub e62ae77643 refactor(config): trim the default passkey origin list (#2951) 2026-09-25 13:42:02 +02:00
HampusandGitHub f4f39e6a89 feat(app): show where forward destinations come from (#2950) 2026-09-25 13:12:17 +02:00
HampusandGitHub 00bf74cef5 fix(app): handle swapped overwrites when comparing channels (#2949) 2026-09-24 23:38:04 +02:00
HampusandGitHub c1c45d835f fix(app): only parse markdown in rich embeds (#2948) 2026-09-24 22:50:34 +02:00
HampusandGitHub bbfe809bef fix(app): crop animated images on web with libwebp (#2947) 2026-09-24 22:45:53 +02:00
HampusandGitHub e0843ac4f5 fix(app): keep guild folder expansion state local (#2944) 2026-09-24 17:52:33 +02:00
HampusandGitHub 43741cdad8 fix(gateway): always trim the connect snapshot for guild connects (#2943) 2026-09-24 17:09:48 +02:00
HampusandGitHub b8e3807262 Revert "fix(push): deliver direct messages without holding them" (#2942) 2026-09-24 17:09:44 +02:00
HampusandGitHub 3304f01a84 chore(i18n): recompile uk error catalog (#2941) 2026-09-24 17:09:36 +02:00
fluxer-weblate[bot]andGitHub 2ba463235b chore(i18n): update translations from Weblate (#2909) 2026-09-24 16:25:26 +02:00
fluxer-weblate[bot]andGitHub 15136fed59 chore(i18n): update translations from Weblate (#2923) 2026-09-24 16:25:05 +02:00
HampusandGitHub 6013581dd9 fix(push): deliver direct messages without holding them (#2938) 2026-09-24 16:21:42 +02:00
HampusandGitHub 7a91f128e9 fix(app-proxy): drop link preview metadata on self-hosted (#2936) 2026-09-24 16:07:00 +02:00
HampusandGitHub 963ffc5550 feat(push): scope read clears to the enrolled cohort (#2935) 2026-09-24 15:15:45 +02:00
HampusandGitHub a90991612c fix(gateway): truncate reads on an expired outbox entry (#2934) 2026-09-24 15:04:24 +02:00
HampusandGitHub 50ad23b760 fix(api): run the notification extension on every iOS alert (#2933) 2026-09-24 15:04:01 +02:00
HampusandGitHub 425dab983b fix(push): restore iOS avatars and stop misrouting relay endpoints (#2932) 2026-09-24 15:03:32 +02:00
HampusandGitHub a0825e77c4 feat(voice): ship the screen share delivery rework to everyone (#2931) 2026-09-24 14:57:40 +02:00
HampusandGitHub 88038a1d5b fix(voice): stop direct input capturing microphones in stereo (#2929) 2026-09-24 14:51:05 +02:00
HampusandGitHub c2c0fdb445 fix(app): make corner volume control the focused stream (#2928) 2026-09-24 14:04:05 +02:00
HampusandGitHub dcd5f09d6a feat(api): add env toggles for automatic phone flagging (#2927) 2026-09-24 03:36:35 +02:00
HampusandGitHub 590b1f36fd docs(downloads): document the canary apt and dnf repositories (#2926) 2026-09-24 03:29:52 +02:00
HampusandGitHub 168ac727f1 fix(desktop): set the deb package synopsis (#2925) 2026-09-24 03:29:33 +02:00
HampusandGitHub deb86dd92e fix(admin): format users list search hint (#2924) 2026-09-24 02:12:37 +02:00
omsterandGitHub 7ccec4d3b8 feat(admin): hint text for * search in user page (#2922) 2026-09-24 01:56:17 +02:00
omsterandGitHub 2f38bcdf26 fix(admin): ordering fixes for admin user search and meilisearch (#2920) 2026-09-24 01:45:56 +02:00
HampusandGitHub f2785941aa fix(app): point self-hosted users at their instance admins (#2921) 2026-09-24 01:42:33 +02:00
HampusandGitHub ea9f83a443 fix(push): keep read-state clears alive as long as the alert (#2919) 2026-09-24 01:26:18 +02:00
HampusandGitHub bd6ca7290e fix(api): allow deleting messages without send permission (#2918) 2026-09-24 01:14:01 +02:00
HampusandGitHub b85e975fb5 feat(push): deliver our own relay endpoints in process (#2917) 2026-09-24 01:07:09 +02:00
HampusandGitHub b6e504f68c fix(push): keep device tokens out of logs (#2916) 2026-09-24 00:33:50 +02:00
HampusandGitHub 5fde6eb484 feat(push): ring Android calls and harden the relay (#2915) 2026-09-24 00:07:15 +02:00
HampusandGitHub b16989d567 feat(push): ring incoming calls on Apple PushKit devices (#2911) 2026-09-23 20:21:08 +02:00
HampusandGitHub c9754ac11a fix(api): exempt internal rpc from the client ip check (#2910) 2026-09-23 18:03:36 +02:00
fluxer-weblate[bot]andGitHub f34e4a5115 chore(i18n): update translations from Weblate (#2903) 2026-09-23 17:28:25 +02:00
fluxer-weblate[bot]andGitHub 44b3615298 chore(i18n): update translations from Weblate (#2904) 2026-09-23 17:27:59 +02:00
HampusandGitHub 211e98307d perf(push): cache endpoint guard dns verdicts (#2907) 2026-09-23 17:27:19 +02:00
HampusandGitHub 18c303abf6 feat(push): relay notifications as encrypted web push (#2906) 2026-09-23 14:04:55 +02:00
JiraliteandGitHub 7021a58090 fix: allow copying message snapshots (#2905) 2026-09-23 14:01:10 +02:00
WagnerandGitHub 320725a587 fix(desktop): capture full pipewire quantum on linux (#2481) 2026-09-22 21:37:20 +02:00
fluxer-weblate[bot]andGitHub 8450edc072 chore(i18n): update translations from Weblate (#2895) 2026-09-22 21:28:24 +02:00
omsterandGitHub a1e2bf2c8d feat(dev/linux): select the wayland backend when reachable in the native desktop app (#2899)
Signed-off-by: omstr <[email protected]>
2026-09-22 21:27:59 +02:00
HampusandGitHub 82b2f4ec5e fix(app): put jxl and other image attachments in the mosaic (#2902) 2026-09-22 21:18:39 +02:00
HampusandGitHub c92e5d03a7 fix(api): accept any image or video attachment as embed media (#2901) 2026-09-22 21:18:35 +02:00
HampusandGitHub 91340c5c84 fix(markdown): compile the parser wasm asynchronously (#2900) 2026-09-22 19:58:38 +02:00
HampusandGitHub 045dd5d027 test(api): make the harvest token tamper test deterministic (#2894) 2026-09-22 02:20:18 +02:00
HampusandGitHub a21b9c4659 docs(readme): clean up the download prose (#2893) 2026-09-22 02:08:42 +02:00
HampusandGitHub 4b1b869802 docs(readme): point Linux installs at Flathub (#2892) 2026-09-22 02:04:06 +02:00
HampusandGitHub 1ab7e7dfcc fix(api): unfurl links to a self-hosted instance's own domain (#2891) 2026-09-22 02:00:51 +02:00
HampusandGitHub 31c53d2dff fix(app): stop pending stickers from reloading the channel (#2890) 2026-09-22 02:00:26 +02:00
HampusandGitHub 412a1ae79d perf(api): stop ledgering session payment reconciliation (#2889) 2026-09-22 01:37:21 +02:00
HampusandGitHub 0b2306ec3d fix(api): honour default TTLs and expire stale job ledger rows (#2887) 2026-09-21 23:16:39 +02:00
HampusandGitHub 242ed3a934 fix(desktop): drop orphaned Squirrel uninstall entry (#2885) 2026-09-21 20:03:33 +02:00
HampusandGitHub 70e1ce682a feat(emoji): add Unicode 17 emoji and fix mixed skin tones (#2883) 2026-09-21 16:26:06 +02:00
HampusandGitHub 7601bf98ee fix(channel): sync a cleared group DM name without a reload (#2882) 2026-09-21 15:34:18 +02:00
c7ec2a0f58 chore(tooling): Ignore .vscode/ in .gitignore (#2868)
Co-authored-by: Hampus <[email protected]>
2026-09-21 13:29:33 +02:00
XeonandGitHub 6a5e0056a8 fix(flatpak): Add a release tag and make small corrections (#2872) 2026-09-21 13:28:36 +02:00
HampusandGitHub 78d105b46e fix(desktop): stop looping on an update that never installs (#2879) 2026-09-21 03:36:11 +02:00
HampusandGitHub c68d62b8a0 fix(voice): darken screen share source titles in light theme (#2878) 2026-09-21 01:20:54 +02:00
HampusandGitHub df58020f4c fix(api): keep premium paid for after a subscription cancels (#2875) 2026-09-20 23:50:49 +02:00
HampusandGitHub f052ce05aa fix(workspace): point the Erlang extension at the repo root (#2871) 2026-09-20 19:49:06 +02:00
HampusandGitHub eedfd9275f fix(api): only require permissions a channel overwrite grants (#2867) 2026-09-20 17:56:22 +02:00
HampusandGitHub 416af4bec4 fix(docs): correct the flatpak and dnf signing instructions (#2865) 2026-09-20 16:42:33 +02:00
HampusandGitHub 108d282ddd chore(deps): pin pnpm 11 so the lockfile parses for packagers (#2864) 2026-09-20 15:30:50 +02:00
HampusandGitHub a6103244b0 docs(readme): fix the license wording and shrink the preview (#2862) 2026-09-20 15:11:06 +02:00
HampusandGitHub 38935c83c5 docs(readme): document every download and install method (#2861) 2026-09-20 15:05:54 +02:00
HampusandGitHub c157ab5752 feat(voice): rework screen share delivery behind an experiment (#2859) 2026-09-20 06:10:20 +02:00
HampusandGitHub 574a93257c docs(downloads): the pacman repository is signed (#2858) 2026-09-20 05:54:28 +02:00
HampusandGitHub ba7d8781cf feat(auth): make passkey two-factor authentication opt-in (#2857) 2026-09-20 05:06:50 +02:00
HampusandGitHub 3256af8d92 refactor(app-proxy): remove the stable time freeze (#2856) 2026-09-20 04:02:47 +02:00
HampusandGitHub 86043212f2 docs(downloads): one pacman repository holds both channels (#2855) 2026-09-20 02:50:08 +02:00
HampusandGitHub 5d85e88532 fix(search): suggest yourself in DM from: and mentions: filters (#2854) 2026-09-20 01:24:43 +02:00
HampusandGitHub e2abfd476a feat(api): redirect desktop downloads to pkgs (#2853) 2026-09-20 01:20:30 +02:00
HampusandGitHub 487febac8e fix(voice): make stereo microphones work in studio and custom (#2852) 2026-09-20 00:19:53 +02:00
HampusandGitHub a3454e8245 fix(installer): name the services that are not ready (#2851) 2026-09-19 23:34:08 +02:00
HampusandGitHub bf7567b768 fix(user): push guild member updates on profile field changes (#2850) 2026-09-19 23:30:25 +02:00
HampusandGitHub ac3450ab32 feat(ci): publish appimage zsync control files (#2849) 2026-09-19 22:22:54 +02:00
HampusandGitHub 5d034becb8 fix(installer): stop waiting for an absent bucket initialiser (#2848) 2026-09-19 22:14:13 +02:00
HampusandGitHub f9397d0db9 feat(ci): publish linux repositories from the desktop release (#2847) 2026-09-19 22:01:06 +02:00
HampusandGitHub 9005139dc8 fix(voice): stop stereo microphones publishing as mono (#2846) 2026-09-19 21:54:38 +02:00
HampusandGitHub d93604afa2 fix(voice): let screen shares use the hardware H.264 encoder (#2845) 2026-09-19 21:54:30 +02:00
HampusandGitHub c4f0b2ece0 feat(desktop): self-update appimages in place (#2843) 2026-09-19 19:06:53 +02:00
HampusandGitHub 98a42f612b fix(desktop): supersede the legacy linux packages on upgrade (#2842) 2026-09-19 18:50:37 +02:00
HampusandGitHub cc75e1318d fix(ci): raise the macos minimum to 13.0 (#2841) 2026-09-19 18:35:05 +02:00
HampusandGitHub 9027cbdf3e fix(voice): send screen shares at the quality the user picked (#2840) 2026-09-19 16:46:22 +02:00
HampusandGitHub 2119e10ed5 chore(static): update marketing screenshots and readme cover (#2839) 2026-09-19 16:44:32 +02:00
HampusandGitHub 87f3eb3c81 feat(desktop): add flatpak and arch packaging inputs (#2838) 2026-09-19 15:31:41 +02:00
HampusandGitHub f9bb8bd585 test(voice): remove the slow screen share delivery proof (#2836) 2026-09-19 02:33:32 +02:00
798 changed files with 79487 additions and 56366 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ ARG USER_UID=1000
ARG USER_GID=1000
ARG NODE_MAJOR=26
ARG ELP_VERSION=2026-08-10
ARG PNPM_VERSION=12.4.2
ARG PNPM_VERSION=11.27.0
ARG WASM_BINDGEN_VERSION=0.2.128
ENV DEBIAN_FRONTEND=noninteractive
+5 -1
View File
@@ -38,7 +38,11 @@
"customizations": {
"vscode": {
"settings": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "biomejs.biome",
"erlang.includePaths": ["."],
"search.exclude": {
"**/_build/default/lib/fluxer_gateway": true
}
},
"extensions": [
"biomejs.biome",
+1
View File
@@ -32,6 +32,7 @@
/fluxer_docs/.astro/
/fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/
+3
View File
@@ -28,6 +28,9 @@ f:media_proxy:
f:messages:
- changed-files:
- any-glob-to-any-file: fluxer_messages/**/*
f:push:
- changed-files:
- any-glob-to-any-file: fluxer_push/**/*
f:snowflakes:
- changed-files:
- any-glob-to-any-file: fluxer_snowflakes/**/*
@@ -71,7 +71,6 @@ jobs:
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL: ""
BUNDLE_LOCAL_ASSETS: "true"
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED: "false"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
@@ -155,7 +154,6 @@ jobs:
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
APP_ASSETS_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
+40 -81
View File
@@ -11,11 +11,6 @@ on:
- stable
- canary
default: stable
test_build:
description: Stash artifacts under desktop-test/ instead of desktop/ (API will not pick these up as a release).
required: false
default: false
type: boolean
build_version:
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
required: false
@@ -32,13 +27,12 @@ permissions:
actions: read
concurrency:
group: desktop-${{ inputs.channel }}-${{ inputs.test_build && 'test' || 'release' }}
group: desktop-${{ inputs.channel }}
cancel-in-progress: true
env:
CHANNEL: ${{ inputs.channel }}
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
TEST_BUILD: ${{ inputs.test_build && 'true' || 'false' }}
jobs:
meta:
@@ -53,8 +47,6 @@ jobs:
pub_date: ${{ steps.meta.outputs.pub_date }}
channel: ${{ steps.meta.outputs.channel }}
build_channel: ${{ steps.meta.outputs.build_channel }}
test_build: ${{ steps.meta.outputs.test_build }}
s3_prefix: ${{ steps.meta.outputs.s3_prefix }}
source_sha: ${{ steps.meta.outputs.source_sha }}
steps:
- name: Checkout source
@@ -85,7 +77,6 @@ jobs:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step set_metadata
--channel "${{ inputs.channel }}"
--test-build "${{ inputs.test_build }}"
matrix:
name: Resolve build matrix
@@ -113,7 +104,7 @@ jobs:
--skip-targets "${{ inputs.skip_targets }}"
build:
name: Build ${{ matrix.platform }} (${{ matrix.arch }}, ${{ matrix.desktop_variant }})
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
needs:
- meta
- matrix
@@ -137,15 +128,8 @@ jobs:
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
DESKTOP_PLATFORM: ${{ matrix.platform }}
DESKTOP_ARCH: ${{ matrix.arch }}
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
PLATFORM: ${{ matrix.platform }}
ARCH: ${{ matrix.arch }}
ELECTRON_ARCH: ${{ matrix.electron_arch }}
@@ -477,6 +461,12 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step prepare_artifacts_unix
- name: Build AppImage update feed (Linux)
if: matrix.platform == 'linux'
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_appimage_update_feed
- name: Normalize updater YAML (macOS)
if: matrix.platform == 'macos'
run: >-
@@ -495,13 +485,23 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step generate_checksums_windows
- name: Upload artifacts to S3 handoff
- name: Stage build artifacts
id: handoff
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step upload_handoff
--step stage_handoff
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ${{ steps.handoff.outputs.artifact_name }}
path: upload_staging
if-no-files-found: error
retention-days: 1
compression-level: 0
upload:
name: Upload to S3
name: Assemble desktop release assets
if: ${{ !cancelled() && needs.build.result == 'success' }}
needs:
- meta
@@ -520,17 +520,8 @@ jobs:
BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
TEST_BUILD: ${{ needs.meta.outputs.test_build }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
@@ -542,12 +533,13 @@ jobs:
with:
toolchain: "1.98.1"
- name: Download S3 handoff artifacts
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step download_handoff
- name: Download build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
path: artifacts
pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-*
- name: Build S3 payload layout (+ manifest.json)
- name: Build payload layout (+ manifest.json)
env:
VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
@@ -556,42 +548,27 @@ jobs:
--step build_payload
- name: Prepare GitHub release assets
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step prepare_release_assets
- name: Publish GitHub release descriptor
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_descriptor
- name: Upload payload to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_payload
- name: Upload GitHub release asset handoff
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_release_assets
- name: Upload GitHub release assets
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: fluxer-desktop-release-assets
path: release_assets
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Build summary
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_summary
- name: Cleanup S3 handoff
if: ${{ success() }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step cleanup_handoff
publish_release:
name: Publish GitHub desktop release
if: ${{ !cancelled() && needs.upload.result == 'success' && needs.meta.outputs.test_build != 'true' }}
if: ${{ !cancelled() && needs.upload.result == 'success' }}
needs:
- meta
- upload
@@ -603,13 +580,6 @@ jobs:
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
@@ -622,9 +592,10 @@ jobs:
toolchain: "1.98.1"
- name: Download GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step download_release_assets
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: fluxer-desktop-release-assets
path: release_assets
- name: Create token
id: create-token
@@ -656,15 +627,3 @@ jobs:
release_args+=(--prerelease)
fi
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
- name: Publish GitHub release readiness marker
env:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_marker
- name: Publish payload metadata to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_payload_metadata
+36
View File
@@ -0,0 +1,36 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: build push
on:
workflow_dispatch:
inputs:
build-version:
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
type: string
required: false
default: ""
permissions:
actions: read
contents: write
packages: write
jobs:
approve:
name: approve build release
permissions: {}
runs-on: ubuntu-24.04
environment: builds
timeout-minutes: 5
steps:
- name: approved
run: echo "Build release approved."
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-push
dockerfile: fluxer_push/Dockerfile
build-version: ${{ inputs['build-version'] }}
+22
View File
@@ -123,12 +123,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -142,12 +146,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -190,6 +198,9 @@ jobs:
- name: Check Rust dependencies
run: cargo deny --locked check -D warnings
- name: Check libfluxwebp dependencies
run: cargo deny --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml --config deny.toml --locked check licenses bans sources
- name: Check desktop native dependencies
run: tools/ci/check-desktop-native-workspaces.sh dependencies
@@ -242,6 +253,9 @@ jobs:
- name: Check formatting
run: cargo fmt --all -- --check
- name: Check formatting (libfluxwebp)
run: cargo fmt --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml -- --check
- name: Check formatting (desktop native workspaces)
run: tools/ci/check-desktop-native-workspaces.sh fmt
@@ -398,12 +412,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -417,12 +435,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
+2
View File
@@ -10,6 +10,7 @@
/.direnv/
/.fluxer/
/.pnpm-store/
/.vscode/
**/*.css.d.ts
**/*.tsbuildinfo
@@ -25,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 -10
View File
@@ -1607,6 +1607,7 @@ dependencies = [
"ff",
"generic-array",
"group",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
@@ -1755,16 +1756,6 @@ dependencies = [
"zip",
]
[[package]]
name = "fluxer-content-update-frozen-snapshot"
version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.23.1",
"sha2 0.11.0",
"tempfile",
]
[[package]]
name = "fluxer-dev"
version = "0.1.0"
@@ -1891,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"
@@ -2318,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"
@@ -3903,6 +3929,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
+1 -1
View File
@@ -7,9 +7,9 @@ members = [
"fluxer_gifs",
"fluxer_svc",
"fluxer_messages",
"fluxer_push",
"fluxer_snowflakes",
"tools/ci",
"tools/content/update-frozen-snapshot",
"tools/dev",
"tools/i18n_auto",
"fluxer_users",
+157 -3
View File
@@ -6,18 +6,172 @@
</p>
<p align="center">
<a href="https://fluxer.app/donate">
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
<a href="https://fluxer.app/download">
<img src="https://img.shields.io/badge/Download-fluxer.app-4641D9" alt="Download" /></a>
<a href="https://docs.fluxer.app">
<img src="https://img.shields.io/badge/Docs-docs.fluxer.app-blue" alt="Documentation" /></a>
<a href="https://fluxer.app/donate">
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
<a href="./LICENSE">
<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.
<p align="center">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
</p>
## Download
| Windows | macOS | Linux | Android | iOS |
| --- | --- | --- | --- | --- |
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [APK][android-apk] | [TestFlight][ios-testflight] |
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [Obtainium][obtainium] | |
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | | |
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
| | | [AppImage (x64)][linux-appimage-x64] | | |
| | | [AppImage (ARM64)][linux-appimage-arm64] | | |
| | | [tar.gz (x64)][linux-targz-x64] | | |
| | | [tar.gz (ARM64)][linux-targz-arm64] | | |
The macOS disk image runs on both Apple silicon and Intel. Windows and Linux need the build matching your processor.
On Linux, prefer a repository over a single file so Fluxer updates with the rest of your system.
## Linux package repositories
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
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:
```sh
flatpak install https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
```
### Debian and Ubuntu
```sh
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL -o /etc/apt/keyrings/fluxer-archive-keyring.gpg https://pkgs.fluxer.com/keys/fluxer-archive-keyring.gpg
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources https://pkgs.fluxer.com/deb/fluxer.sources
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
sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/fluxer.repo
sudo dnf install fluxer
```
For canary, use the canary entry file and package.
```sh
sudo curl -fsSL -o /etc/yum.repos.d/fluxer-canary.repo https://pkgs.fluxer.com/rpm/fluxer-canary.repo
sudo dnf install fluxer-canary
```
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because their base repositories lack `libXScrnSaver`. Fedora does not.
### Arch Linux
The repository is signed, so pacman needs the key once:
```sh
sudo pacman-key --init
curl -fsSL -o /tmp/fluxer-archive-keyring.asc https://pkgs.fluxer.com/keys/fluxer-archive-keyring.asc
sudo pacman-key --add /tmp/fluxer-archive-keyring.asc
sudo pacman-key --lsign-key 09D01339EE128925F75E675C855C5BDE34D205D2
```
`--lsign-key` is what makes pacman trust it. Then add the repository:
```sh
sudo tee -a /etc/pacman.conf >/dev/null <<'REPO'
[fluxer]
SigLevel = Required TrustedOnly
Server = https://pkgs.fluxer.com/arch/$repo/os/$arch
REPO
sudo pacman -Syu fluxer
```
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, hence the quoted heredoc.
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), no install needed.
- [Host your own instance][docs-selfhost] from this repository.
## Documentation
- [Documentation home][docs]
- [Downloads][docs-downloads]
- [Self-hosting][docs-selfhost]
## License
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, 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
endorsement rights.
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
[win-portable-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/portable
[win-portable-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/portable
[mac-dmg]: https://pkgs.fluxer.com/desktop/stable/darwin/arm64/latest/dmg
[linux-deb-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/deb
[linux-deb-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/deb
[linux-rpm-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/rpm
[linux-rpm-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/rpm
[linux-appimage-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/appimage
[linux-appimage-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/appimage
[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-apk]: https://github.com/fluxerapp/flutter_client/releases
[obtainium]: https://obtainium.imranr.dev/
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
[docs]: https://docs.fluxer.app
[docs-downloads]: https://docs.fluxer.app/downloads/overview/
[docs-linux]: https://docs.fluxer.app/downloads/linux-repositories/
[docs-selfhost]: https://docs.fluxer.app/operator/get-started/
-1
View File
@@ -52,7 +52,6 @@ FLUXER_S3_SECRET_ACCESS_KEY=fluxer-secret
FLUXER_S3_FORCE_PATH_STYLE=true
FLUXER_S3_BUCKET_CDN=fluxer
FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
FLUXER_S3_BUCKET_REPORTS=fluxer-reports
FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
FLUXER_S3_BUCKET_STATIC=fluxer-static
+8 -1
View File
@@ -82,7 +82,6 @@ MEILI_MASTER_KEY=CHANGE_ME
# exist already.
#FLUXER_S3_BUCKET_CDN=fluxer
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
#FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
@@ -148,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
@@ -246,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
@@ -282,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
+26 -4
View File
@@ -46,7 +46,6 @@ x-fluxer-env: &fluxer-env
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
@@ -189,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
@@ -295,14 +294,13 @@ services:
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
entrypoint:
- /bin/sh
- -c
- >
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
missing="$$buckets";
for attempt in $$(seq 1 60); do
if ! nc -z seaweedfs 9333 2>/dev/null; then
@@ -484,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}
+1 -1
View File
@@ -9,7 +9,7 @@ WORKDIR /usr/src/app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates nodejs npm pkg-config \
&& npm install -g pnpm@12.4.2 \
&& npm install -g pnpm@11.27.0 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install --no-audit --no-fund @tailwindcss/[email protected] [email protected]
+80 -11
View File
@@ -10524,7 +10524,8 @@
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"screen_share_delivery": {"$ref": "#/components/schemas/ScreenShareDeliveryConfigResponse"},
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
"type": "object",
@@ -10952,7 +10953,8 @@
"sso",
"gateway_rollout",
"voice_noise_suppression",
"screen_share_delivery",
"push_service_delivery",
"domain_migration",
"experiment_delivery",
"registration",
"self_hosted",
@@ -11087,9 +11089,13 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
},
"screen_share_delivery": {
"push_service_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ScreenShareDeliveryConfigUpdateRequest"}]
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
},
"domain_migration": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
},
"experiment_delivery": {
"nullable": true,
@@ -15184,12 +15190,32 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"ScreenShareDeliveryConfigUpdateRequest": {
"DomainMigrationConfigUpdateRequest": {
"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}$"}
},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"standalone_forwarding": {"type": "boolean"}
}
},
"PushServiceDeliveryConfigUpdateRequest": {
"type": "object",
"properties": {
"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",
@@ -15237,7 +15263,6 @@
"required": ["guild_id", "backend"]
}
},
"stereo_enabled": {"type": "boolean"},
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
}
},
@@ -15268,13 +15293,59 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"additionalProperties": false
},
"ScreenShareDeliveryConfigResponse": {
"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": "screen-share-delivery-v1", "type": "string", "minLength": 1, "maxLength": 64},
"rollout_salt": {
"default": "domain-migration-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"standalone_forwarding": {"default": false, "type": "boolean"}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"excluded_user_ids",
"anonymous_rollout_basis_points",
"standalone_forwarding"
],
"additionalProperties": false
},
"PushServiceDeliveryConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "push-service-delivery-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
@@ -15342,7 +15413,6 @@
"additionalProperties": false
}
},
"stereo_enabled": {"default": false, "type": "boolean"},
"suppression_strength": {"default": 80, "type": "integer", "minimum": 0, "maximum": 100}
},
"required": [
@@ -15356,7 +15426,6 @@
"included_user_ids",
"excluded_user_ids",
"guild_overrides",
"stereo_enabled",
"suppression_strength"
],
"additionalProperties": false
+129 -45
View File
@@ -23,7 +23,9 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
#[serde(default)]
pub screen_share_delivery: ScreenShareDeliveryConfigResponse,
pub push_service_delivery: PushServiceDeliveryConfigResponse,
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
}
@@ -448,7 +450,9 @@ impl VoiceE2eeScope {
}
}
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1";
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
@@ -494,7 +498,6 @@ pub struct VoiceNoiseSuppressionConfigResponse {
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub stereo_enabled: bool,
pub suppression_strength: u32,
}
@@ -511,50 +514,11 @@ impl Default for VoiceNoiseSuppressionConfigResponse {
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
stereo_enabled: false,
suppression_strength: 80,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ScreenShareDeliveryConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
}
impl Default for ScreenShareDeliveryConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: "screen-share-delivery-v1".to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ScreenShareDeliveryConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -576,11 +540,93 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stereo_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suppression_strength: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct PushServiceDeliveryConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
}
impl Default for PushServiceDeliveryConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PushServiceDeliveryConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct DomainMigrationConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
pub anonymous_rollout_basis_points: u32,
pub standalone_forwarding: bool,
}
impl Default for DomainMigrationConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
anonymous_rollout_basis_points: 0,
standalone_forwarding: false,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct DomainMigrationConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ExperimentDeliveryConfigResponse {
@@ -697,7 +743,9 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screen_share_delivery: Option<ScreenShareDeliveryConfigUpdateRequest>,
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
}
@@ -1036,18 +1084,30 @@ mod tests {
.expect("admin schema");
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
.expect("default noise config");
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
.expect("default domain migration config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let noise = serde_json::to_value(noise).expect("serializable noise config");
let domain_migration =
serde_json::to_value(domain_migration).expect("serializable domain migration config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
serde_json::from_value(noise.clone()).expect("generated noise config contract");
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
serde_json::from_value(domain_migration.clone())
.expect("generated domain migration config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
assert_eq!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
);
assert_eq!(
serde_json::to_value(generated_domain_migration)
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_delivery)
.expect("serializable generated delivery config"),
@@ -1055,6 +1115,7 @@ mod tests {
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("DomainMigrationConfigResponse", domain_migration),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
@@ -1090,4 +1151,27 @@ mod tests {
json!({})
);
}
#[test]
fn domain_migration_update_preserves_empty_lists_and_omitted_fields() {
let update = DomainMigrationConfigUpdateRequest {
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::DomainMigrationConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(DomainMigrationConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ async fn reports_list(
return reports_error_page(
config,
&auth.0,
"That page is out of range. The reports search returns at most the first 10000 reports, so narrow the filters and start again.",
"That page is out of range. The reports search returns at most the first 10000 reports. Narrow the filters and start again.",
);
}
let search_query = query.q.as_deref().and_then(clean_string);
+187 -45
View File
@@ -7,7 +7,8 @@ use crate::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
DeferredPhoneGateUpdateRequest, DomainMigrationConfigUpdateRequest,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
@@ -17,10 +18,10 @@ use crate::{
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
ScreenShareDeliveryConfigUpdateRequest, SsoConfigUpdateRequest,
VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode,
PushServiceDeliveryConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
},
},
config::AdminConfig,
@@ -207,7 +208,11 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_screen_share_delivery" => match build_screen_share_delivery_update(&form) {
"update_push_service_delivery" => match build_push_service_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_domain_migration" => match build_domain_migration_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
@@ -496,6 +501,21 @@ fn parse_experiment_rollout_salt(
Ok(Some(salt.to_owned()))
}
fn parse_ascii_experiment_rollout_salt(
form: &MultiValueForm,
key: &str,
) -> Result<Option<String>, String> {
let salt = parse_experiment_rollout_salt(form, key)?;
if let Some(value) = salt.as_deref()
&& !value
.bytes()
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
{
return Err("Rollout salt must use printable ASCII".to_owned());
}
Ok(salt)
}
fn is_experiment_snowflake(value: &str) -> bool {
!value.is_empty()
&& value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH
@@ -518,9 +538,9 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
if ids.iter().any(|existing| existing == candidate) {
continue;
}
if ids.len() == VOICE_NS_MAX_TARGETED_USERS {
if ids.len() == EXPERIMENT_MAX_TARGETED_USERS {
return Err(format!(
"{label} must contain at most {VOICE_NS_MAX_TARGETED_USERS} unique IDs"
"{label} must contain at most {EXPERIMENT_MAX_TARGETED_USERS} unique IDs"
));
}
ids.push(candidate.to_owned());
@@ -573,38 +593,6 @@ fn parse_voice_noise_suppression_guild_overrides(
Ok(overrides)
}
fn build_screen_share_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
screen_share_delivery: Some(ScreenShareDeliveryConfigUpdateRequest {
enabled: Some(form.bool_value("screen_share_delivery_enabled")),
rollout_basis_points: parse_form_number(
form,
"screen_share_delivery_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(
form,
"screen_share_delivery_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("screen_share_delivery_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("screen_share_delivery_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_voice_noise_suppression_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -653,7 +641,6 @@ fn build_voice_noise_suppression_update(
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
form.first("voice_ns_guild_overrides").unwrap_or_default(),
)?),
stereo_enabled: Some(form.bool_value("voice_ns_stereo_enabled")),
suppression_strength: parse_form_number(
form,
"voice_ns_suppression_strength",
@@ -666,6 +653,78 @@ fn build_voice_noise_suppression_update(
})
}
fn build_push_service_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
push_service_delivery: Some(PushServiceDeliveryConfigUpdateRequest {
enabled: Some(form.bool_value("push_service_delivery_enabled")),
rollout_basis_points: parse_form_number(
form,
"push_service_delivery_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"push_service_delivery_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_domain_migration_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
domain_migration: Some(DomainMigrationConfigUpdateRequest {
enabled: Some(form.bool_value("domain_migration_enabled")),
rollout_basis_points: parse_form_number(
form,
"domain_migration_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"domain_migration_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
anonymous_rollout_basis_points: parse_form_number(
form,
"domain_migration_anonymous_rollout_basis_points",
"Anonymous rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
standalone_forwarding: Some(form.bool_value("domain_migration_standalone_forwarding")),
}),
..Default::default()
})
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -1283,7 +1342,6 @@ mod tests {
.expect("voice noise suppression update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.allow_user_override, Some(true));
assert_eq!(update.stereo_enabled, Some(false));
assert_eq!(
update.default_backend,
Some(NoiseSuppressionBackend::Rnnoise)
@@ -1309,7 +1367,6 @@ mod tests {
serde_json::json!({"voice_noise_suppression": {
"enabled": false,
"allow_user_override": false,
"stereo_enabled": false,
"enabled_backends": [],
"included_user_ids": [],
"excluded_user_ids": [],
@@ -1387,13 +1444,13 @@ mod tests {
#[test]
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
let value = (0..EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join("\n");
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
.expect("valid IDs at cap");
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
assert_eq!(ids.len(), EXPERIMENT_MAX_TARGETED_USERS);
assert_eq!(ids.last(), Some(&"999".to_owned()));
assert_eq!(
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
@@ -1589,6 +1646,91 @@ mod tests {
}
}
#[test]
fn build_domain_migration_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true",
);
let update = build_domain_migration_update(&form)
.expect("valid form")
.domain_migration
.expect("domain migration update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(250));
assert_eq!(update.rollout_salt, Some("domain-migration-v2".to_owned()));
assert_eq!(
update.included_user_ids,
Some(vec![
"1500000000000000001".to_owned(),
"1500000000000000002".to_owned()
])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000003".to_owned(),
"1500000000000000004".to_owned()
])
);
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
assert_eq!(update.standalone_forwarding, Some(true));
}
#[test]
fn build_domain_migration_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_domain_migration_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"domain_migration": {
"enabled": false,
"included_user_ids": [],
"excluded_user_ids": [],
"standalone_forwarding": false,
}})
);
}
#[test]
fn build_domain_migration_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"domain_migration_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"domain_migration_anonymous_rollout_basis_points=10001",
"Anonymous rollout basis points must be a whole number between 0 and 10000",
),
(
"domain_migration_anonymous_rollout_basis_points=abc",
"Anonymous rollout basis points must be a whole number between 0 and 10000",
),
(
"domain_migration_rollout_salt=%20%20",
"Rollout salt must be between 1 and 64 characters",
),
(
"domain_migration_rollout_salt=caf%C3%A9",
"Rollout salt must use printable ASCII",
),
(
"domain_migration_included_user_ids=123%2Cinvalid",
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"domain_migration_excluded_user_ids=123%2Cinvalid",
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_domain_migration_update(&form).expect_err("invalid rollout field"),
message
);
}
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
+5 -9
View File
@@ -73,15 +73,11 @@ pub async fn render(
.map(|r| r.sessions)
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"))
.unwrap_or_default();
let webauthn_credentials = if u.authenticator_types.contains(&2) {
client
.list_webauthn_credentials(user_id)
.await
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
.unwrap_or_default()
} else {
Vec::new()
};
let webauthn_credentials = client
.list_webauthn_credentials(user_id)
.await
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
.unwrap_or_default();
Some(tabs::account::account_tab(
config,
&u,
@@ -2,12 +2,13 @@
use crate::{
api::types::{
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
ScreenShareDeliveryConfigResponse, SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES,
VOICE_NS_MAX_TARGETED_USERS, VoiceNoiseSuppressionConfigResponse,
AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
PendingRegistrationResponse, PushServiceDeliveryConfigResponse, RegistrationUrlResponse,
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -148,7 +149,8 @@ pub fn instance_config_page(
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
(screen_share_delivery_section(base, csrf_token, &instance_config.screen_share_delivery))
(push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -1106,12 +1108,12 @@ fn voice_noise_suppression_section(
))
(entry_count_hint(
voice_noise_suppression.included_user_ids.len(),
VOICE_NS_MAX_TARGETED_USERS,
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."
}
}
@@ -1126,11 +1128,11 @@ fn voice_noise_suppression_section(
))
(entry_count_hint(
voice_noise_suppression.excluded_user_ids.len(),
VOICE_NS_MAX_TARGETED_USERS,
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. This is the per-user kill switch."
}
}
@@ -1158,17 +1160,6 @@ fn voice_noise_suppression_section(
}
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
(checkbox(
"voice_ns_stereo_enabled",
"true",
"Process stereo input instead of downmixing to mono",
voice_noise_suppression.stereo_enabled,
true,
))
p class="text-xs text-neutral-500" {
"Costs more CPU on the client. Leave off unless you are testing stereo \
capture."
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(number_field(
"voice_ns_suppression_strength",
@@ -1188,65 +1179,60 @@ fn voice_noise_suppression_section(
)
}
fn screen_share_delivery_section(
fn push_service_delivery_section(
base: &str,
csrf_token: &str,
screen_share_delivery: &ScreenShareDeliveryConfigResponse,
push_service_delivery: &PushServiceDeliveryConfigResponse,
) -> Markup {
let status = if screen_share_delivery.enabled {
let status = if push_service_delivery.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 = push_service_delivery.included_user_ids.join("\n");
let excluded_user_ids = push_service_delivery.excluded_user_ids.join("\n");
section_card_with_description(
"Screen Share Delivery",
"Picks whether a targeted client adapts its own screen share after it starts. A targeted \
client measures what the encoder sends, steps the share down a resolution and frame rate \
ladder while it cannot keep up, probes back up when it can, remembers the settled rung \
per codec, and tells the sharer what is going out. A client that is not targeted \
publishes the same target and never changes it. Both arms still get the publish-time \
fixes and both arms still recover a stalled encoder. While the master switch below is \
off every client publishes and holds, whatever the rest of these fields say.",
"Push Service Delivery",
"Routes push notification delivery for the selected accounts through the push service. \
Accounts the rollout does not select keep the current path.",
html! {
form method="post" action={(base) "/instance-config?action=update_screen_share_delivery"} {
form method="post" action={(base) "/instance-config?action=update_push_service_delivery"} {
(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 " (push_service_delivery.config_version)
}
}
(checkbox(
"screen_share_delivery_enabled",
"push_service_delivery_enabled",
"true",
"Serve screen share delivery assignments to clients",
screen_share_delivery.enabled,
"Hand push notifications to the push service",
push_service_delivery.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
rollout is inert and holds its published target for the life of the \
share, so the rollout and targeting fields below have no effect at all."
"Off is the safe state. With this unchecked every notification keeps the \
current delivery path, so the rollout and targeting fields below have no \
effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"screen_share_delivery_rollout_basis_points",
"push_service_delivery_rollout_basis_points",
"Rollout (basis points)",
&screen_share_delivery.rollout_basis_points.to_string(),
&push_service_delivery.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"screen_share_delivery_rollout_salt",
"push_service_delivery_rollout_salt",
"Rollout Salt",
&screen_share_delivery.rollout_salt,
"screen-share-delivery-v1",
&push_service_delivery.rollout_salt,
PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
@@ -1256,37 +1242,178 @@ fn screen_share_delivery_section(
}
div class="flex flex-col gap-2" {
(textarea_input(
"screen_share_delivery_included_user_ids",
"push_service_delivery_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
push_service_delivery.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" {
(textarea_input(
"screen_share_delivery_excluded_user_ids",
"push_service_delivery_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
push_service_delivery.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage, so this is the per-user kill switch."
percentage. This is the per-user kill switch."
}
}
(form_actions(html! {
(submit_button("Save Screen Share Delivery Configuration"))
(submit_button("Save Push Service Delivery Configuration"))
}))
}
}
},
)
}
fn domain_migration_section(
base: &str,
csrf_token: &str,
domain_migration: &DomainMigrationConfigResponse,
) -> Markup {
let status = if domain_migration.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = domain_migration.included_user_ids.join("\n");
let excluded_user_ids = domain_migration.excluded_user_ids.join("\n");
section_card_with_description(
"Domain Migration",
"Moves web clients of the official instance from the legacy web app origin to the new \
one. Selected accounts copy their local data across and continue on the new origin. \
Clients of other instances read this configuration and ignore it.",
html! {
form method="post" action={(base) "/instance-config?action=update_domain_migration"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (domain_migration.config_version)
}
}
(checkbox(
"domain_migration_enabled",
"true",
"Move selected web clients to the new origin",
domain_migration.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked no client \
starts a migration and clients that already migrated stop forwarding the \
legacy origin, so the rollout and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Installed apps" }
(checkbox(
"domain_migration_standalone_forwarding",
"true",
"Forward installed desktop web apps to the new origin",
domain_migration.standalone_forwarding,
true,
))
p class="text-xs text-neutral-500" {
"Leave this off until the manifest scope extension and the association file \
are live and verified. While it is off, installed Chromium desktop apps copy \
their data across but stay on the legacy origin and offer to install the new \
app. Installed mobile and Safari apps never forward either way."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"domain_migration_rollout_basis_points",
"Rollout (basis points)",
&domain_migration.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-in users bucketed into the migration, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
(number_field(
"domain_migration_anonymous_rollout_basis_points",
"Anonymous rollout (basis points)",
&domain_migration.anonymous_rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
))
div class="flex flex-col gap-2" {
(text_input(
"domain_migration_rollout_salt",
"Rollout Salt",
&domain_migration.rollout_salt,
DOMAIN_MIGRATION_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash for users and devices. Changing it reshuffles \
which users and devices fall inside the percentages above. Leave it \
alone to keep the current cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"domain_migration_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
domain_migration.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"domain_migration_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
domain_migration.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage. It stops new migrations only. A user who already moved \
stays on the new origin."
}
}
(form_actions(html! {
(submit_button("Save Domain Migration Configuration"))
}))
}
}
@@ -1302,7 +1429,7 @@ fn experiment_delivery_section(
section_card_with_description(
"Experiment Delivery",
"How often every client revalidates its experiment assignments. This is instance-wide \
and covers every experiment, not just the one above. Raising the interval sheds \
and covers every experiment, not just the ones above. Raising the interval sheds \
request volume and makes a change take longer to reach a client. Raising the jitter \
spreads a fleet that has synchronised on one tick back out across the interval.",
html! {
@@ -1937,10 +2064,32 @@ mod tests {
assert!(!markup.contains("at the cap"));
}
#[test]
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
let domain_migration = DomainMigrationConfigResponse {
anonymous_rollout_basis_points: 250,
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..DomainMigrationConfigResponse::default()
};
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
assert!(markup.contains("action=update_domain_migration"));
assert!(markup.contains("domain_migration_enabled"));
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
assert!(markup.contains("value=\"250\""));
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(!markup.contains("at the cap"));
}
#[test]
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
included_user_ids: (0..VOICE_NS_MAX_TARGETED_USERS)
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect(),
..VoiceNoiseSuppressionConfigResponse::default()
@@ -114,7 +114,10 @@ pub fn users_list_page(
let content = html! {
div class="space-y-6" {
(page_header("Users", None))
div class="rounded-lg bg-white transition-all border border-neutral-200 p-4" {
div class="rounded-lg bg-white transition-all border border-neutral-200 p-3" {
p class="mb-1 text-xs text-neutral-500" {
"For example, type " span class="font-mono" { "*" } " in to search for all users."
}
(search_form(base, params))
}
(results_markup)
+25 -7
View File
@@ -403,21 +403,31 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"stereo_enabled": false,
"suppression_strength": 80,
"future_presentation_knob": "verbose",
"future_knob": 7,
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"]
},
"screen_share_delivery": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "screen-share-delivery-v1",
"included_user_ids": [],
"push_service_delivery": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1",
"included_user_ids": ["1500000000000000002"],
"excluded_user_ids": []
},
"domain_migration": {
"enabled": true,
"config_version": 2,
"rollout_basis_points": 2500,
"rollout_salt": "domain-migration-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"future_migration_knob": 9,
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
"mode": "open",
@@ -547,6 +557,13 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert_eq!(resp.voice_noise_suppression.rollout_basis_points, 10000);
assert_eq!(*resp.voice_noise_suppression.rollout_salt, "voice-ns-v1");
assert_eq!(resp.voice_noise_suppression.enabled_backends.len(), 3);
assert!(resp.domain_migration.enabled);
assert_eq!(resp.domain_migration.config_version, 2);
assert_eq!(resp.domain_migration.rollout_basis_points, 2500);
assert_eq!(*resp.domain_migration.rollout_salt, "domain-migration-v1");
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
assert!(resp.domain_migration.standalone_forwarding);
assert_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));
@@ -557,6 +574,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
.replace("\"future_rollout_knob\": 3,", "")
.replace("\"future_presentation_knob\": \"verbose\",", "")
.replace("\"future_knob\": 7,", "")
.replace("\"future_migration_knob\": 9,", "")
.replace("\"future_object_knob\": {\"nested\": true},", "")
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
.replace(
+14 -2
View File
@@ -465,7 +465,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
"/instance-config?action=update_gateway_rollout",
"/instance-config?action=update_sso",
"/instance-config?action=update_voice_noise_suppression",
"/instance-config?action=update_screen_share_delivery",
"/instance-config?action=update_domain_migration",
"/instance-config?action=update_experiment_delivery",
][..],
),
@@ -817,6 +817,9 @@ async fn spawn_mock_api() -> String {
async fn mock_api(method: Method, uri: Uri) -> Response {
let path = uri.path().to_owned();
if method == Method::PATCH && path == "/admin/instance/config" {
return json_response(instance_config());
}
match (method, path.as_str()) {
(Method::GET, "/admin/users/@me") => json_response(json!({ "user": admin_user() })),
(Method::GET, "/admin/api-keys") => json_response(json!([])),
@@ -1195,9 +1198,18 @@ fn instance_config() -> Value {
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"stereo_enabled": false,
"suppression_strength": 80
},
"domain_migration": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "domain-migration-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"anonymous_rollout_basis_points": 0,
"standalone_forwarding": false
},
"experiment_delivery": {
"poll_interval_seconds": 300,
"poll_jitter_percent": 15
@@ -95,39 +95,6 @@ async fn clearing_the_region_restriction_fields_reaches_the_api_as_empty_lists()
assert_eq!(body["allowed_guild_ids"], json!([]));
}
#[tokio::test]
async fn the_screen_share_delivery_form_keeps_the_included_and_excluded_lists_apart() {
let app = setup().await;
let csrf_token = csrf_token(&app).await;
let status = post_form(
&app,
"/instance-config?action=update_screen_share_delivery",
&format!(
"_csrf={csrf_token}&screen_share_delivery_enabled=true\
&screen_share_delivery_rollout_basis_points=2500\
&screen_share_delivery_rollout_salt=screen-share-delivery-v1\
&screen_share_delivery_included_user_ids=1500000000000000001\
&screen_share_delivery_excluded_user_ids=1500000000000000002"
),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
let body = captured_body(&app, "PATCH /admin/instance/config");
let delivery = &body["screen_share_delivery"];
assert_eq!(delivery["enabled"], json!(true));
assert_eq!(delivery["rollout_basis_points"], json!(2500));
assert_eq!(delivery["rollout_salt"], json!("screen-share-delivery-v1"));
assert_eq!(
delivery["included_user_ids"],
json!(["1500000000000000001"])
);
assert_eq!(
delivery["excluded_user_ids"],
json!(["1500000000000000002"])
);
}
struct TestApp {
router: Router,
session_cookie: String,
+2 -2
View File
@@ -5,7 +5,7 @@ FROM node:26-trixie-slim AS base
WORKDIR /usr/src/app
RUN npm install -g pnpm@12.4.2
RUN npm install -g pnpm@11.27.0
FROM base AS deploy
@@ -57,7 +57,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libvips42t64 && \
rm -rf /var/lib/apt/lists/*
RUN npm install -g pnpm@12.4.2
RUN npm install -g pnpm@11.27.0
COPY --from=deploy /out ./
COPY --from=deploy /usr/src/app/fluxer_api/dist ./dist
+1 -1
View File
@@ -94,5 +94,5 @@
"typescript": "catalog:ts7",
"vitest": "catalog:"
},
"packageManager": "pnpm@12.4.2"
"packageManager": "pnpm@11.27.0"
}
+13 -6
View File
@@ -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');
+5 -3
View File
@@ -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();
+1 -1
View File
@@ -45,7 +45,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
configureMiddleware(routes, {
logger,
nodeEnv: config.nodeEnv,
corsOrigins: [config.endpoints.webApp, config.endpoints.marketing],
corsOrigins: [...config.endpoints.webAppOrigins, config.endpoints.marketing],
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
maxInflightRequests: config.maxInflightRequests,
+5 -22
View File
@@ -3,7 +3,6 @@
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
@@ -92,18 +91,6 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
return Array.from(normalized);
}
function normalizeCountryCodes(values: Array<string>, configName: string): ReadonlySet<string> {
const normalized = new Set<string>();
for (const value of values) {
const countryCode = value.trim().toUpperCase();
if (!/^[A-Z]{2}$/u.test(countryCode)) {
throw new Error(`${configName} contains an invalid ISO 3166-1 alpha-2 country code: ${value}`);
}
normalized.add(countryCode);
}
return normalized;
}
function mapPushProviderApps(
apps:
| Array<{
@@ -157,7 +144,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
const s3Buckets = s3Config.buckets ?? {
cdn: '',
uploads: '',
downloads: '',
reports: '',
harvests: '',
};
@@ -174,10 +160,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
requestTimeoutMs: master.services.api.request_timeout_ms,
maxInflightRequests: master.services.api.max_inflight_requests,
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
desktopGitHubRedirectCountries: normalizeCountryCodes(
master.services.api.desktop_github_redirect_countries,
'FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES',
),
cassandra: {
hosts: cassandraSource?.hosts.join(',') ?? '',
port: cassandraSource?.port ?? 9042,
@@ -276,6 +258,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
apiPublic: master.endpoints.api,
apiClient: master.endpoints.api_client,
webApp: master.endpoints.app,
webAppOrigins: [...new Set([new URL(master.endpoints.app).origin, ...master.services.api.app_origin_aliases])],
gateway: master.endpoints.gateway,
media: master.endpoints.media,
marketing: master.endpoints.marketing,
@@ -290,8 +273,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,
},
@@ -304,7 +285,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
},
s3Downloads: resolveDownloadsProvider(master),
s3: {
endpoint: s3Config.endpoint,
presignedUrlBase: s3Config.presigned_url_base,
@@ -497,6 +477,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,
},
@@ -523,7 +507,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
validateResponses: resolveValidateResponses(master),
},
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
presignedHarvestDownloadsEnabled: master.services.api.presigned_harvest_downloads_enabled ?? true,
attachmentDecayEnabled: master.attachment_decay_enabled,
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
+38
View File
@@ -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',
+19 -1
View File
@@ -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>;
@@ -13,7 +13,11 @@ import {deriveSsoRedirectUri, normalizeAndValidateSsoConfig} from '@app/api/inst
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {getGatewayRolloutConfigPublisher, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {
getGatewayRolloutConfigPublisher,
getInstanceConfigRepository,
getPushServiceDeliveryConfigPublisher,
} from '@app/api/middleware/ServiceSingletons';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
@@ -30,11 +34,12 @@ import {
PendingRegistrationActionRequest,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {PushServiceDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {ScreenShareDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ScreenShareDeliverySchemas';
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import type {Context} from 'hono';
@@ -60,7 +65,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
screenShareDelivery,
pushServiceDelivery,
domainMigration,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -69,7 +75,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getScreenShareDeliveryConfig(),
instanceConfigRepository.getPushServiceDeliveryConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -101,7 +108,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
screen_share_delivery: screenShareDelivery,
push_service_delivery: pushServiceDelivery,
domain_migration: domainMigration,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -247,43 +255,54 @@ export function InstanceConfigAdminController(app: HonoApp) {
const shouldGrantSetupCompleterAdmin =
appPublicBeforeUpdate !== null && completesInitialSetup(data, appPublicBeforeUpdate.setup.configured);
if (data.gateway_rollout) {
const currentRollout = await instanceConfigRepository.getGatewayRolloutConfig();
const merged = {...currentRollout, ...data.gateway_rollout};
const validated = GatewayRolloutConfigSchema.parse(merged);
await instanceConfigRepository.setGatewayRolloutConfig(validated);
await getGatewayRolloutConfigPublisher().publish(validated);
const patch = data.gateway_rollout;
const landed = await instanceConfigRepository.updateGatewayRolloutConfig((current) =>
GatewayRolloutConfigSchema.parse({...current, ...patch}),
);
await getGatewayRolloutConfigPublisher().publish(landed);
}
if (data.voice_noise_suppression) {
const patch = omitUndefinedFields(data.voice_noise_suppression);
if (Object.keys(patch).length > 0) {
const currentNoiseSuppression = await instanceConfigRepository.getVoiceNoiseSuppressionConfig();
const validated = VoiceNoiseSuppressionConfigSchema.parse({
...currentNoiseSuppression,
...patch,
config_version: currentNoiseSuppression.config_version + 1,
});
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
await instanceConfigRepository.updateVoiceNoiseSuppressionConfig((current) =>
VoiceNoiseSuppressionConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.screen_share_delivery) {
const patch = omitUndefinedFields(data.screen_share_delivery);
if (data.push_service_delivery) {
const patch = omitUndefinedFields(data.push_service_delivery);
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);
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
PushServiceDeliveryConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
await getPushServiceDeliveryConfigPublisher().publish(landed);
}
}
if (data.domain_migration) {
const patch = omitUndefinedFields(data.domain_migration);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateDomainMigrationConfig((current) =>
DomainMigrationConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
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 +327,22 @@ export function InstanceConfigAdminController(app: HonoApp) {
const validated = await normalizeAndValidateSsoConfig(next, {
testModeEnabled: Config.dev.testModeEnabled,
});
const supplied = <T>(field: keyof typeof sso, value: T): T | undefined =>
readOptionalField(sso, field) === undefined ? undefined : value;
await instanceConfigRepository.setSsoConfig({
enabled: validated.enabled,
enforced: validated.enforced,
displayName: next.displayName,
issuer: validated.issuer,
authorizationUrl: validated.authorizationUrl,
tokenUrl: validated.tokenUrl,
userInfoUrl: validated.userInfoUrl,
jwksUrl: validated.jwksUrl,
clientId: validated.clientId,
enabled: supplied('enabled', validated.enabled),
enforced: supplied('enforced', validated.enforced),
displayName: supplied('display_name', next.displayName),
issuer: supplied('issuer', validated.issuer),
authorizationUrl: supplied('authorization_url', validated.authorizationUrl),
tokenUrl: supplied('token_url', validated.tokenUrl),
userInfoUrl: supplied('userinfo_url', validated.userInfoUrl),
jwksUrl: supplied('jwks_url', validated.jwksUrl),
clientId: supplied('client_id', validated.clientId),
clientSecret: readOptionalField(sso, 'client_secret'),
scope: next.scope,
allowedEmailDomains: validated.allowedEmailDomains,
autoProvision: next.autoProvision,
redirectUri: null,
scope: supplied('scope', next.scope),
allowedEmailDomains: supplied('allowed_domains', validated.allowedEmailDomains),
autoProvision: supplied('auto_provision', next.autoProvision),
});
}
if (data.registration) {
@@ -625,7 +645,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
async (ctx) => {
const userId = ctx.req.valid('param').user_id.toString();
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
await instanceConfigRepository.getPendingRegistrations();
await updatePendingRegistrationUser(ctx, userId, decision);
await instanceConfigRepository.removePendingRegistration(userId);
return ctx.json(await buildInstanceConfigResponse());
@@ -638,27 +657,47 @@ async function applyInstancePolicyUpdate(
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
): Promise<void> {
const instanceConfigRepository = getInstanceConfigRepository();
const [current, appPublic] = await Promise.all([
instanceConfigRepository.getInstancePolicyConfig(),
instanceConfigRepository.getAppPublicConfig(),
]);
const appPublic = await instanceConfigRepository.getAppPublicConfig();
const adminUser =
policy.single_community_enabled === true
? await ctx.get('userRepository').findUnique(ctx.get('adminUserId'))
: null;
let enablesSingleCommunity = false;
await instanceConfigRepository.updateInstancePolicyConfig((current) => {
const planned = planInstancePolicyPatch(policy, current, {
setupConfigured: appPublic.setup.configured,
adminUserFound: adminUser !== null,
});
enablesSingleCommunity = planned.enablesSingleCommunity;
return planned.patch;
});
if (enablesSingleCommunity && adminUser) {
await ctx.get('singleCommunityService').ensureStockCommunity({
owner: adminUser,
name: policy.single_community_name?.trim() || appPublic.branding.product_name,
});
}
if (policy.premium_mode !== undefined) {
await ctx.get('limitConfigService').updatePolicyConfig({premium_mode: policy.premium_mode});
}
}
function planInstancePolicyPatch(
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
current: InstancePolicyConfig,
context: {setupConfigured: boolean; adminUserFound: boolean},
): {patch: Partial<InstancePolicyConfig>; enablesSingleCommunity: boolean} {
const patch: Partial<InstancePolicyConfig> = {};
let enablesSingleCommunity = false;
if (
policy.single_community_enabled !== undefined &&
policy.single_community_enabled !== current.single_community_enabled
) {
if (policy.single_community_enabled) {
if (appPublic.setup.configured && current.single_community_guild_id == null) {
if ((context.setupConfigured && current.single_community_guild_id == null) || !context.adminUserFound) {
throw new InstancePolicyTransitionNotAllowedError();
}
const adminUser = await ctx.get('userRepository').findUnique(ctx.get('adminUserId'));
if (!adminUser) {
throw new InstancePolicyTransitionNotAllowedError();
}
await ctx.get('singleCommunityService').ensureStockCommunity({
owner: adminUser,
name: policy.single_community_name?.trim() || appPublic.branding.product_name,
});
enablesSingleCommunity = true;
} else {
patch.single_community_enabled = false;
}
@@ -679,9 +718,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 +740,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)) {
@@ -1,14 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
import {propagatePartialUserChange} from '@app/api/user/services/PartialUserChangePropagation';
import {hasPartialUserFieldsChanged} from '@app/api/user/UserMappers';
interface AdminUserUpdatePropagatorDeps {
@@ -26,41 +25,11 @@ export class AdminUserUpdatePropagator extends BaseUserUpdatePropagator {
});
}
async propagateUserUpdate({
userId,
oldUser,
updatedUser,
}: {
userId: UserID;
oldUser: User;
updatedUser: User;
}): Promise<void> {
async propagateUserUpdate(params: {userId: UserID; oldUser: User; updatedUser: User}): Promise<void> {
const {oldUser, updatedUser} = params;
await this.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(oldUser, updatedUser)) {
await this.updateUserCache(updatedUser);
await this.propagateToGuilds(userId);
await propagatePartialUserChange(this.deps, updatedUser);
}
}
private async propagateToGuilds(userId: UserID): Promise<void> {
const {userRepository, guildRepository, gatewayService, userCacheService} = this.deps;
const guildIds = await userRepository.getUserGuildIds(userId);
if (guildIds.length === 0) {
return;
}
const requestCache = createRequestCache();
for (const guildId of guildIds) {
const member = await guildRepository.getMember(guildId, userId);
if (!member) {
continue;
}
const memberResponse = await mapGuildMemberToResponse(member, userCacheService, requestCache);
await gatewayService.dispatchGuild({
guildId,
event: 'GUILD_MEMBER_UPDATE',
data: memberResponse,
});
}
requestCache.clear();
}
}
@@ -2,10 +2,10 @@
import {createTestAccount, createTotpSecret, generateTotpCode, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {
createRegistrationResponse,
createWebAuthnDevice,
registerWebAuthnCredential,
setWebAuthnTwoFactor,
type WebAuthnCredentialMetadata,
type WebAuthnRegistrationOptions,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
@@ -31,13 +31,15 @@ describe('Admin WebAuthn credential delete', () => {
afterAll(async () => {
await harness?.shutdown();
});
test('removes the WebAuthn authenticator type when admin deletes the last credential', async () => {
let admin = await createTestAccount(harness);
admin = await setUserACLs(harness, admin, [
async function createAdmin() {
const admin = await createTestAccount(harness);
return await setUserACLs(harness, admin, [
AdminACLs.AUTHENTICATE,
AdminACLs.USER_LOOKUP,
AdminACLs.USER_UPDATE_MFA,
]);
}
async function createPasskeyTarget(twoFactorEnabled: boolean) {
const target = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
@@ -45,28 +47,19 @@ describe('Admin WebAuthn credential delete', () => {
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: target.password})
.execute();
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, target.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (registrationOptions.rp.id) {
device.rpId = registrationOptions.rp.id;
}
await createBuilder(harness, target.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: createRegistrationResponse(device, registrationOptions, 'Admin Delete Test Passkey'),
challenge: registrationOptions.challenge,
name: 'Admin Delete Test Passkey',
await registerWebAuthnCredential(
harness,
target.token,
device,
() => ({mfa_method: 'totp', mfa_code: generateTotpCode(secret)}),
'Admin Delete Test Passkey',
);
if (twoFactorEnabled) {
await setWebAuthnTwoFactor(harness, target.token, true, {
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const credentialsBeforeDelete = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, target.token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
expect(credentialsBeforeDelete).toHaveLength(1);
});
}
await createBuilder(harness, target.token)
.post('/users/@me/mfa/totp/disable')
.body({
@@ -76,6 +69,15 @@ describe('Admin WebAuthn credential delete', () => {
})
.expect(204)
.execute();
return target;
}
test('removes the WebAuthn authenticator type when admin deletes the last credential of a two-factor user', async () => {
const admin = await createAdmin();
const target = await createPasskeyTarget(true);
const credentialsBeforeDelete = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, target.token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
expect(credentialsBeforeDelete).toHaveLength(1);
const userBeforeDelete = await createBuilder<AdminLookupResponse>(harness, `${admin.token}`)
.get(`/admin/users/${target.userId}`)
.execute();
@@ -93,4 +95,28 @@ describe('Admin WebAuthn credential delete', () => {
.execute();
expect(userAfterDelete.users[0]?.authenticator_types).toEqual([]);
});
test('leaves the authenticator types empty throughout for a user who never turned passkey two-factor on', async () => {
const admin = await createAdmin();
const target = await createPasskeyTarget(false);
const credentialsBeforeDelete = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, target.token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
expect(credentialsBeforeDelete).toHaveLength(1);
const userBeforeDelete = await createBuilder<AdminLookupResponse>(harness, `${admin.token}`)
.get(`/admin/users/${target.userId}`)
.execute();
expect(userBeforeDelete.users[0]?.authenticator_types).toEqual([]);
await createBuilder(harness, `${admin.token}`)
.delete(`/admin/users/${target.userId}/webauthn-credentials/${credentialsBeforeDelete[0]!.id}`)
.expect(204)
.execute();
const credentialsAfterDelete = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, target.token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
expect(credentialsAfterDelete).toHaveLength(0);
const userAfterDelete = await createBuilder<AdminLookupResponse>(harness, `${admin.token}`)
.get(`/admin/users/${target.userId}`)
.execute();
expect(userAfterDelete.users[0]?.authenticator_types).toEqual([]);
});
});
@@ -0,0 +1,121 @@
// 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 {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
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 {
DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
type PushServiceDeliveryConfig,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const PUSH_SERVICE_DELIVERY_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 spyOnPushDeliveryPublishes = () =>
vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
async function readStoredPushServiceDelivery(): Promise<PushServiceDeliveryConfig> {
const raw = await executor.readDirectly(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
if (raw === null) throw new Error('push service delivery config was never stored');
return JSON.parse(raw) as PushServiceDeliveryConfig;
}
async function 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 = spyOnPushDeliveryPublishes();
const admin = await createAdmin();
await patchConfig(admin, {push_service_delivery: {enabled: true, rollout_basis_points: 1000}}).execute();
publish.mockClear();
const auditsBefore = await listConfigUpdateAudits();
executor.watch(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
let competingWrites = 0;
executor.competeBeforeEachWrite(async () => {
competingWrites++;
await executor.writeDirectly(
PUSH_SERVICE_DELIVERY_CONFIG_KEY,
JSON.stringify({
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
enabled: false,
rollout_basis_points: 1000,
config_version: 100 + competingWrites,
}),
);
});
await patchConfig(admin, {push_service_delivery: {rollout_basis_points: 5000}})
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.CONFLICT)
.execute();
expect(executor.events).not.toContain('write');
expect(await readStoredPushServiceDelivery()).toEqual({
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
enabled: false,
rollout_basis_points: 1000,
config_version: 100 + competingWrites,
});
expect(publish).not.toHaveBeenCalled();
expect(await listConfigUpdateAudits()).toHaveLength(auditsBefore.length);
});
});
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
describe('instance config admin PATCH against state another node changed', () => {
let harness: ApiTestHarness;
let executor: InstanceConfigWriteRaceExecutor;
beforeAll(async () => {
harness = await createApiTestHarness();
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
setCassandraQueryExecutorForTesting(executor);
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness.shutdown();
});
const createAdmin = async (): Promise<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
it('keeps an SSO field another node changed when a patch changes a different one', async () => {
const admin = await createAdmin();
await patchConfig(admin, {sso: {display_name: 'Before', client_id: 'client-before'}}).execute();
await executor.writeDirectly('sso_display_name', 'Changed on another node');
await patchConfig(admin, {sso: {client_id: 'client-after'}}).execute();
expect(await executor.readDirectly('sso_display_name')).toBe('Changed on another node');
expect(await executor.readDirectly('sso_client_id')).toBe('client-after');
});
it('refuses to disable direct messages when their lock lands between the read and the write', async () => {
const admin = await createAdmin();
await patchConfig(admin, {policy: {services: {gif_enabled: true}}}).execute();
executor.watch(INSTANCE_POLICY_CONFIG_KEY);
let competed = false;
executor.competeBeforeEachWrite(async () => {
if (competed) return;
competed = true;
await executor.writeDirectly(
INSTANCE_POLICY_CONFIG_KEY,
JSON.stringify({direct_messages_disabled: false, direct_messages_locked: true, gif_enabled: true}),
);
});
await patchConfig(admin, {policy: {direct_messages_disabled: true}})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INSTANCE_POLICY_TRANSITION_NOT_ALLOWED)
.execute();
const stored = JSON.parse((await executor.readDirectly(INSTANCE_POLICY_CONFIG_KEY)) ?? 'null');
expect(stored).toMatchObject({direct_messages_disabled: false, direct_messages_locked: true, gif_enabled: true});
});
it('applies the DM rule and a premium mode change from one request', async () => {
const admin = await createAdmin();
await patchConfig(admin, {policy: {direct_messages_disabled: true}}).execute();
const updated = await patchConfig(admin, {
policy: {direct_messages_disabled: false, premium_mode: 'mirror'},
}).execute();
expect(updated.policy).toMatchObject({
direct_messages_disabled: false,
direct_messages_locked: true,
premium_mode: 'mirror',
});
});
});
@@ -3,6 +3,7 @@
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 {BlueskyOAuthController} from '@app/api/bluesky/BlueskyOAuthController';
import {Config} from '@app/api/Config';
import {ChannelController} from '@app/api/channel/ChannelController';
@@ -46,6 +47,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
GeolocationController(routes);
registerAdminControllers(routes);
AuthController(routes);
OriginHandoffController(routes);
AttachmentController(routes);
ChannelController(routes);
ConnectionController(routes);
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
import {Config} from '@app/api/Config';
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {AppErrorHandler, AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
import {Hono} from 'hono';
import {afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const CLIENT_IP_HEADER_NAME = 'x-real-ip';
function createProductionApp(): Hono<HonoEnv> {
const routes = new Hono<HonoEnv>({strict: true});
configureMiddleware(routes, {
logger: new NoopLogger(),
nodeEnv: 'production',
corsOrigins: ['https://web.fluxer.app'],
trustClientIpHeader: true,
clientIpHeaderName: CLIENT_IP_HEADER_NAME,
maxInflightRequests: 100,
torExitBlockingEnabled: false,
});
routes.onError(AppErrorHandler);
routes.notFound(AppNotFoundHandler);
routes.post('/internal/rpc', (ctx) => ctx.json({ok: true}));
routes.get('/connections/bluesky/jwks.json', (ctx) => ctx.json({keys: []}));
routes.get('/users/@me', (ctx) => ctx.json({ok: true}));
const app = new Hono<HonoEnv>({strict: true});
app.route('/v1', routes);
app.route('/', routes);
app.onError(AppErrorHandler);
app.notFound(AppNotFoundHandler);
return app;
}
describe('client ip requirements across the production middleware pipeline', () => {
let previousTestModeEnabled: boolean;
let previousTrustClientIpHeader: boolean;
let previousClientIpHeader: string;
beforeAll(() => {
setInjectedWorkerService(new NoopWorkerService());
});
beforeEach(() => {
previousTestModeEnabled = Config.dev.testModeEnabled;
previousTrustClientIpHeader = Config.proxy.trust_client_ip_header;
previousClientIpHeader = Config.proxy.client_ip_header;
Config.dev.testModeEnabled = false;
Config.proxy.trust_client_ip_header = true;
Config.proxy.client_ip_header = CLIENT_IP_HEADER_NAME;
});
afterEach(() => {
Config.dev.testModeEnabled = previousTestModeEnabled;
Config.proxy.trust_client_ip_header = previousTrustClientIpHeader;
Config.proxy.client_ip_header = previousClientIpHeader;
});
it('serves the internal rpc route without a client ip header', async () => {
const app = createProductionApp();
const response = await app.request('http://api:8080/internal/rpc', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: '{}',
});
expect(response.status).toBe(200);
});
it('serves the internal rpc route with a client ip header', async () => {
const app = createProductionApp();
const response = await app.request('http://api:8080/internal/rpc', {
method: 'POST',
headers: {'content-type': 'application/json', [CLIENT_IP_HEADER_NAME]: '203.0.113.10'},
body: '{}',
});
expect(response.status).toBe(200);
});
it('serves an exempt public route without a client ip header', async () => {
const app = createProductionApp();
const response = await app.request('http://api:8080/connections/bluesky/jwks.json');
expect(response.status).toBe(200);
});
it('still rejects a non exempt route without a client ip header', async () => {
const app = createProductionApp();
const response = await app.request('http://api:8080/users/@me');
expect(response.status).toBe(403);
expect(await response.json()).toMatchObject({code: 'FORBIDDEN'});
});
});
@@ -602,6 +602,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 -10
View File
@@ -5,6 +5,7 @@ import * as AuthMfa from '@app/api/auth/AuthMfa';
import * as AuthPassword from '@app/api/auth/AuthPassword';
import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {resolveWebAuthnSecondFactor} from '@app/api/auth/services/WebAuthnSecondFactor';
import {
createInviteCode,
createIpAuthorizationTicket,
@@ -30,6 +31,7 @@ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {IpAuthorizationRequiredError} from '@fluxer/errors/src/domains/auth/IpAuthorizationRequiredError';
import {IpAuthorizationResendCooldownError} from '@fluxer/errors/src/domains/auth/IpAuthorizationResendCooldownError';
import {IpAuthorizationResendLimitExceededError} from '@fluxer/errors/src/domains/auth/IpAuthorizationResendLimitExceededError';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import {RegistrationPendingApprovalError} from '@fluxer/errors/src/domains/auth/RegistrationPendingApprovalError';
import {RegistrationRejectedError} from '@fluxer/errors/src/domains/auth/RegistrationRejectedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
@@ -72,12 +74,13 @@ interface LoginTokenResult {
token: string;
}
interface LoginMfaResult {
export interface LoginMfaResult {
mfa: true;
ticket: string;
allowed_methods: Array<string>;
totp: boolean;
webauthn: boolean;
backup_codes: boolean;
}
type LoginResult = LoginTokenResult | LoginMfaResult;
@@ -323,7 +326,8 @@ export async function login(
}
}
if (hasMfa) {
return await createMfaTicketResponse(ctx, currentUser);
const webauthnIsSecondFactor = await resolveWebAuthnSecondFactor(ctx, currentUser);
return await createMfaTicketResponse(ctx, currentUser, webauthnIsSecondFactor);
}
if (data.invite_code && inviteService) {
try {
@@ -387,13 +391,14 @@ export async function loginMfaTotp(
throw new UnknownUserError();
}
AuthUtility.assertNonBotUser(ctx, user);
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
const hasTotp = Boolean(user.totpSecret) && user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
if (!hasTotp && !(await AuthMfa.hasUnconsumedBackupCodes(ctx, user.id))) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
}
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'code'});
const isValid = await AuthMfa.verifyMfaCode(ctx, {
userId: user.id,
mfaSecret: user.totpSecret,
mfaSecret: hasTotp ? user.totpSecret : null,
code,
allowBackup: true,
});
@@ -424,6 +429,9 @@ export async function loginMfaWebAuthn(
throw new UnknownUserError();
}
AuthUtility.assertNonBotUser(ctx, user);
if (!(await resolveWebAuthnSecondFactor(ctx, user))) {
throw new MfaNotEnabledError();
}
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}`);
@@ -436,21 +444,26 @@ export async function loginMfaWebAuthn(
return {user_id: user.id.toString(), token};
}
async function createMfaTicketResponse(ctx: ApiContext, user: User): Promise<LoginMfaResult> {
const {users, cache} = ctx.services;
export async function createMfaTicketResponse(
ctx: ApiContext,
user: User,
webauthnIsSecondFactor: boolean,
): Promise<LoginMfaResult> {
const {cache} = ctx.services;
const ticket = createMfaTicket(await AuthUtility.generateSecureToken(ctx));
await cache.set(`mfa-ticket:${ticket}`, user.id.toString(), seconds('5 minutes'));
const credentials = await users.listWebAuthnCredentials(user.id);
const hasWebauthn = credentials.length > 0;
const hasTotp = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
const hasBackupCodes = await AuthMfa.hasUnconsumedBackupCodes(ctx, user.id);
const allowedMethods: Array<string> = [];
if (hasTotp) allowedMethods.push('totp');
if (hasWebauthn) allowedMethods.push('webauthn');
if (webauthnIsSecondFactor) allowedMethods.push('webauthn');
if (hasBackupCodes) allowedMethods.push('backup_codes');
return {
mfa: true,
ticket,
allowed_methods: allowedMethods,
totp: hasTotp,
webauthn: hasWebauthn,
webauthn: webauthnIsSecondFactor,
backup_codes: hasBackupCodes,
};
}
+91 -39
View File
@@ -2,9 +2,11 @@
import {timingSafeEqual} from 'node:crypto';
import type {ApiContext} from '@app/api/ApiContext';
import {deriveSudoMethods, userHasMfa} from '@app/api/auth/services/SudoMethods';
import * as AuthUtility from '@app/api/auth/AuthUtility';
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';
@@ -48,7 +50,7 @@ interface SudoMfaVerificationResult {
interface VerifyMfaCodeParams {
userId: UserID;
mfaSecret: string;
mfaSecret: string | null;
code: string;
allowBackup?: boolean;
}
@@ -56,9 +58,15 @@ interface VerifyMfaCodeParams {
interface AvailableMfaMethods {
totp: boolean;
webauthn: boolean;
backup_codes: boolean;
has_mfa: boolean;
}
interface SetWebAuthnTwoFactorResult {
user: User;
backupCodes: Array<MfaBackupCode> | null;
}
function constantTimeEquals(a: string, b: string): boolean {
const bufferA = Buffer.from(a);
const bufferB = Buffer.from(b);
@@ -72,24 +80,31 @@ function normalizeBackupCode(code: string): string {
return code.toLowerCase().replace(/[^a-z0-9]/g, '');
}
export async function hasUnconsumedBackupCodes(ctx: ApiContext, userId: UserID): Promise<boolean> {
const backupCodes = await ctx.services.users.listMfaBackupCodes(userId);
return backupCodes.some((backupCode) => !backupCode.consumed);
}
export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams): Promise<boolean> {
const {userId, mfaSecret, code, allowBackup = false} = params;
const {users, cache, config} = ctx.services;
try {
const totp = new TotpGenerator(mfaSecret);
const isValidTotp = await totp.validateTotp(code);
if (isValidTotp) {
if (config.dev.testModeEnabled) {
return true;
}
const reuseKey = `mfa-totp:${userId}:${code}`;
const lockToken = await cache.acquireLock(reuseKey, seconds('90 seconds'));
if (lockToken) {
return true;
if (mfaSecret !== null) {
try {
const totp = new TotpGenerator(mfaSecret);
const isValidTotp = await totp.validateTotp(code);
if (isValidTotp) {
if (config.dev.testModeEnabled) {
return true;
}
const reuseKey = `mfa-totp:${userId}:${code}`;
const lockToken = await cache.acquireLock(reuseKey, seconds('90 seconds'));
if (lockToken) {
return true;
}
}
} catch (error) {
Logger.error({userId, code: `${code.slice(0, 3)}***`, error}, 'Failed to validate TOTP code');
}
} catch (error) {
Logger.error({userId, code: `${code.slice(0, 3)}***`, error}, 'Failed to validate TOTP code');
}
if (allowBackup) {
const normalizedCode = normalizeBackupCode(code);
@@ -145,8 +160,7 @@ export async function verifyWebAuthnRegistration(
expectedChallenge: string,
name: string,
): Promise<void> {
const {users, gateway, botMfaMirror, config} = ctx.services;
const user = await users.findUniqueAssert(userId);
const {users, config} = ctx.services;
const existingCredentials = await users.listWebAuthnCredentials(userId);
await consumeWebAuthnChallenge(ctx, expectedChallenge, 'registration', {userId});
if (existingCredentials.length >= 10) {
@@ -214,13 +228,6 @@ export async function verifyWebAuthnRegistration(
name,
);
}
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
if (!authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
}
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
}
@@ -234,15 +241,55 @@ export async function deleteWebAuthnCredential(ctx: ApiContext, userId: UserID,
const remainingCredentials = await users.listWebAuthnCredentials(userId);
if (remainingCredentials.length === 0) {
const user = await users.findUniqueAssert(userId);
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
if (user.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
const authenticatorTypes = new Set<number>(user.authenticatorTypes ?? []);
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
if (!userHasMfa(updatedUser)) {
await users.clearMfaBackupCodes(userId);
}
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
}
}
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
}
export async function setWebAuthnTwoFactor(
ctx: ApiContext,
userId: UserID,
enabled: boolean,
): Promise<SetWebAuthnTwoFactorResult> {
const {users, gateway, botMfaMirror} = ctx.services;
const user = await users.findUniqueAssert(userId);
const credentials = await users.listWebAuthnCredentials(userId);
if (enabled && credentials.length === 0) {
throw new NoPasskeysRegisteredError();
}
const authenticatorTypes = new Set<number>(user.authenticatorTypes ?? []);
if (authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN) === enabled) {
return {user, backupCodes: null};
}
if (enabled) {
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
} else {
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
}
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
let backupCodes: Array<MfaBackupCode> | null = null;
if (enabled) {
const existingBackupCodes = await users.listMfaBackupCodes(userId);
if (existingBackupCodes.every((backupCode) => backupCode.consumed)) {
backupCodes = await users.createMfaBackupCodes(userId, AuthUtility.generateBackupCodes(ctx));
}
} else if (!userHasMfa(updatedUser)) {
await users.clearMfaBackupCodes(userId);
}
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
return {user: updatedUser, backupCodes};
}
export async function renameWebAuthnCredential(
ctx: ApiContext,
userId: UserID,
@@ -430,16 +477,17 @@ export async function verifySudoMfa(
const {users} = ctx.services;
const {userId, method, code, webauthnResponse, webauthnChallenge} = params;
const user = await users.findUnique(userId);
const hasMfa =
(user?.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false) ||
(user?.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false);
if (!user || !hasMfa) {
if (!user) {
return {success: false, error: 'MFA not enabled'};
}
const credentials = await users.listWebAuthnCredentials(userId);
const hasPasskeyCredentials = credentials.length > 0;
if (!userHasSudoCapability(user, hasPasskeyCredentials)) {
return {success: false, error: 'MFA not enabled'};
}
switch (method) {
case 'totp': {
if (!code) return {success: false, error: 'TOTP code is required'};
if (!user.totpSecret) return {success: false, error: 'TOTP is not enabled'};
await consumeSudoMfaAttempt(ctx, userId);
const isValid = await verifyMfaCode(ctx, {userId, mfaSecret: user.totpSecret, code, allowBackup: true});
if (isValid) {
@@ -451,7 +499,7 @@ export async function verifySudoMfa(
if (!webauthnResponse || !webauthnChallenge) {
return {success: false, error: 'WebAuthn response and challenge are required'};
}
if (!user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN)) {
if (!hasPasskeyCredentials) {
return {success: false, error: 'WebAuthn is not enabled'};
}
try {
@@ -467,15 +515,19 @@ export async function verifySudoMfa(
}
export async function getAvailableMfaMethods(ctx: ApiContext, userId: UserID): Promise<AvailableMfaMethods> {
const user = await ctx.services.users.findUnique(userId);
const {users} = ctx.services;
const user = await users.findUnique(userId);
if (!user) {
return {totp: false, webauthn: false, has_mfa: false};
return {totp: false, webauthn: false, backup_codes: false, has_mfa: false};
}
const methods = deriveSudoMethods(user);
const credentials = await users.listWebAuthnCredentials(userId);
const hasPasskeyCredentials = credentials.length > 0;
const methods = deriveSudoMethods(user, hasPasskeyCredentials, await hasUnconsumedBackupCodes(ctx, userId));
return {
totp: methods.totp,
webauthn: methods.webauthn,
has_mfa: userHasMfa(user),
backup_codes: methods.backup_codes,
has_mfa: userHasSudoCapability(user, hasPasskeyCredentials),
};
}
+22 -50
View File
@@ -2,12 +2,14 @@
import crypto from 'node:crypto';
import type {ApiContext} from '@app/api/ApiContext';
import {createMfaTicketResponse, type LoginMfaResult} from '@app/api/auth/AuthLogin';
import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {createMfaTicket, createPasswordResetToken} from '@app/api/BrandedTypes';
import {resolveWebAuthnSecondFactor} from '@app/api/auth/services/WebAuthnSecondFactor';
import {createPasswordResetToken} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {Logger} from '@app/api/Logger';
import type {User} from '@app/api/models/User';
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
import * as FetchUtils from '@app/api/utils/FetchUtils';
import {hashPassword as hashPasswordUtil, verifyPassword as verifyPasswordUtil} from '@app/api/utils/PasswordUtils';
@@ -19,7 +21,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {ForgotPasswordRequest, ResetPasswordRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {ms, seconds} from 'itty-time';
import {ms} from 'itty-time';
const PWNED_PASSWORDS_TIMEOUT_MS = ms('5 seconds');
const PWNED_PASSWORD_CACHE_MAX_PREFIXES = 128;
@@ -91,13 +93,7 @@ type ResetPasswordResult =
user_id: string;
token: string;
}
| {
mfa: true;
ticket: string;
allowed_methods: Array<string>;
totp: boolean;
webauthn: boolean;
};
| LoginMfaResult;
const pwnedPasswordCache = new PwnedPasswordCache(PWNED_PASSWORD_CACHE_MAX_PREFIXES, ms('1 hour'));
@@ -267,22 +263,26 @@ export async function resetPassword(
if (await isPasswordPwned(ctx, data.password)) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
}
const webauthnIsSecondFactor = await resolveWebAuthnSecondFactor(ctx, user);
const hasMfa = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) || webauthnIsSecondFactor;
const newPasswordHash = await hashPassword(ctx, data.password);
const updatedUser = await users.patchUpsert(
user.id,
{
password_hash: newPasswordHash,
password_last_changed_at: new Date(),
},
user.toRow(),
);
const updates: Partial<UserRow> = {
password_hash: newPasswordHash,
password_last_changed_at: new Date(),
};
if (webauthnIsSecondFactor && !user.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
const authenticatorTypes = new Set<number>(user.authenticatorTypes);
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
updates.authenticator_types = authenticatorTypes;
}
const updatedUser = await users.patchUpsert(user.id, updates, user.toRow());
if (updates.authenticator_types) {
await ctx.services.botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
}
await AuthSession.terminateAllUserSessions(ctx, user.id);
await users.deletePasswordResetToken(data.token);
const hasMfa =
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN);
if (hasMfa) {
return await createMfaTicketResponse(ctx, updatedUser);
return await createMfaTicketResponse(ctx, updatedUser, webauthnIsSecondFactor);
}
const [token] = await AuthSession.createAuthSession(ctx, {
user: updatedUser,
@@ -290,31 +290,3 @@ export async function resetPassword(
});
return {user_id: updatedUser.id.toString(), token};
}
async function createMfaTicketResponse(
ctx: ApiContext,
user: User,
): Promise<{
mfa: true;
ticket: string;
allowed_methods: Array<string>;
totp: boolean;
webauthn: boolean;
}> {
const {users, cache} = ctx.services;
const ticket = createMfaTicket(await AuthUtility.generateSecureToken(ctx));
await cache.set(`mfa-ticket:${ticket}`, user.id.toString(), seconds('5 minutes'));
const credentials = await users.listWebAuthnCredentials(user.id);
const hasWebauthn = credentials.length > 0;
const hasTotp = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
const allowedMethods: Array<string> = [];
if (hasTotp) allowedMethods.push('totp');
if (hasWebauthn) allowedMethods.push('webauthn');
return {
mfa: true,
ticket: ticket,
allowed_methods: allowedMethods,
totp: hasTotp,
webauthn: hasWebauthn,
};
}
+72 -20
View File
@@ -7,12 +7,14 @@ import * as AuthUtility from '@app/api/auth/AuthUtility';
import type {IRegistrationRiskEvaluator} from '@app/api/auth/services/IRegistrationRiskEvaluator';
import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {APIConfig} from '@app/api/config/APIConfig';
import type {UserRow} from '@app/api/database/types/UserTypes';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
import {
type InstanceConfigRepository,
type InstanceRegistrationUrl,
REGISTRATION_PENDING_APPROVAL_TRAIT,
type RegistrationUrlClaim,
} from '@app/api/instance/InstanceConfigRepository';
import type {SingleCommunityService} from '@app/api/instance/SingleCommunityService';
import type {InviteService} from '@app/api/invite/InviteService';
@@ -21,7 +23,7 @@ import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstri
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {UserSettings} from '@app/api/models/UserSettings';
import {countryRequiresInboundPhoneVerification} from '@app/api/risk/AbusePolicy';
import {countryRequiresInboundPhoneVerification, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
import {
type IAccountPolicyEvaluator,
isAssessmentThresholdAuditEvent,
@@ -135,9 +137,6 @@ export async function register(
}
const now = new Date();
const registrationAccess = await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.getPendingRegistrations();
}
const clientIp = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
@@ -228,7 +227,7 @@ export async function register(
const userLocale = parseAcceptLanguage(acceptLanguage);
const passwordHash = data.password ? await AuthPassword.hashPassword(ctx, data.password) : null;
const flags = config.nodeEnv === 'development' ? UserFlags.STAFF : 0n;
let user = await users.create({
const userRow: UserRow = {
user_id: userId,
username,
discriminator,
@@ -287,7 +286,39 @@ export async function register(
mention_flags: null,
last_voice_activity_sharing_change_at: null,
version: 1,
});
};
const registrationUrlUse = await claimRegistrationUrlUse(
instanceConfigRepository,
registrationAccess.registrationUrl,
userId,
);
let user: User;
let createAttempted = false;
try {
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.addPendingRegistration({
user_id: userId.toString(),
username: userRow.username,
discriminator: userRow.discriminator,
global_name: userRow.global_name,
email: rawEmail,
requested_at: now.toISOString(),
registration_url_id: registrationAccess.registrationUrl?.id ?? null,
client_ip: clientIp,
});
}
createAttempted = true;
user = await users.create(userRow);
} catch (error) {
if (!createAttempted) {
await withdrawSignupOfUncreatedAccount(instanceConfigRepository, {
userId,
registrationUrlUse,
pendingApproval: registrationAccess.pendingApproval,
});
}
throw error;
}
await users.upsertSettings(
UserSettings.getDefaultUserSettings({
userId,
@@ -331,7 +362,9 @@ export async function register(
action: riskResult.recommendedAction,
},
});
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(policyDecision.flagBits);
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(
await stripDisallowedPhoneFlags(policyDecision.flagBits, async () => countryCode),
);
const createdAt = new Date();
const riskContext = deriveLatestRiskContext({
userId: userId.toString(),
@@ -401,20 +434,7 @@ export async function register(
}
if (rawEmail && emailEnabled) await maybeSendVerificationEmail(ctx, {user, email: rawEmail});
await users.createAuthorizedIp(userId, clientIp);
if (registrationAccess.registrationUrl) {
await instanceConfigRepository.recordRegistrationUrlUse(registrationAccess.registrationUrl.id, user.id.toString());
}
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.addPendingRegistration({
user_id: user.id.toString(),
username: user.username,
discriminator: user.discriminator,
global_name: user.globalName,
email: rawEmail,
requested_at: now.toISOString(),
registration_url_id: registrationAccess.registrationUrl?.id ?? null,
client_ip: clientIp,
});
return {
registration_pending_approval: true,
user_id: user.id.toString(),
@@ -469,6 +489,38 @@ function shouldAttemptBootstrapAdminGrant(
);
}
async function claimRegistrationUrlUse(
instanceConfigRepository: InstanceConfigRepository,
registrationUrl: InstanceRegistrationUrl | null,
userId: UserID,
): Promise<RegistrationUrlClaim | null> {
if (registrationUrl === null) return null;
const use = await instanceConfigRepository.claimRegistrationUrlUse(registrationUrl.id, userId.toString());
if (use === null) {
throw new RegistrationUrlInvalidError();
}
return use;
}
async function withdrawSignupOfUncreatedAccount(
instanceConfigRepository: InstanceConfigRepository,
signup: {userId: UserID; registrationUrlUse: RegistrationUrlClaim | null; pendingApproval: boolean},
): Promise<void> {
try {
if (signup.registrationUrlUse !== null) {
await instanceConfigRepository.releaseRegistrationUrlUse(signup.registrationUrlUse);
}
if (signup.pendingApproval) {
await instanceConfigRepository.removePendingRegistration(signup.userId.toString());
}
} catch (error) {
Logger.warn(
{userId: signup.userId.toString(), registrationUrlId: signup.registrationUrlUse?.registration_url_id, error},
'[AuthRegistration] Failed to withdraw the registration URL use or pending approval of an account that was never created',
);
}
}
async function resolveRegistrationAccess(
instanceConfigRepository: InstanceConfigRepository,
registrationUrlCode: string | null | undefined,
+50 -6
View File
@@ -8,12 +8,19 @@ import * as AuthMfa from '@app/api/auth/AuthMfa';
import * as AuthPassword from '@app/api/auth/AuthPassword';
import * as AuthRegistration from '@app/api/auth/AuthRegistration';
import * as AuthSession from '@app/api/auth/AuthSession';
import {getTokenIdHash} from '@app/api/auth/AuthUtility';
import type {DesktopHandoffService} from '@app/api/auth/services/DesktopHandoffService';
import type {SsoService} from '@app/api/auth/services/SsoService';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {Logger} from '@app/api/Logger';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {
classifyWebPushOrigin,
encodePushSessionIdHash,
recordPushSessionPredecessor,
} from '@app/api/user/services/WebPushOriginReplacement';
import {mapUserToPartialResponse} from '@app/api/user/UserMappers';
import {lookupGeoip} from '@app/api/utils/IpUtils';
import {parseJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
@@ -91,6 +98,7 @@ interface AuthHandoffCompleteRequest {
data: HandoffCompleteRequest;
clientIp: string;
authToken?: string;
approverOrigin?: string | null;
}
interface AuthAuthorizeIpRequest {
@@ -305,7 +313,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 +351,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> {
@@ -417,6 +460,7 @@ export class AuthRequestService {
...result,
totp: allowedMethods.has('totp'),
webauthn: allowedMethods.has('webauthn'),
backup_codes: allowedMethods.has('backup_codes'),
};
}
}
@@ -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);
},
);
}
@@ -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;
}
+24 -13
View File
@@ -382,21 +382,8 @@ export class SsoService {
throw new RegistrationClosedError();
}
const pendingApproval = registrationConfig.mode === 'approval';
if (pendingApproval) {
await this.instanceConfigRepository.getPendingRegistrations();
}
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval});
if (pendingApproval) {
await this.instanceConfigRepository.addPendingRegistration({
user_id: user.id.toString(),
username: user.username,
discriminator: user.discriminator,
global_name: user.globalName,
email: user.email,
requested_at: new Date().toISOString(),
registration_url_id: null,
client_ip: null,
});
throw new RegistrationPendingApprovalError();
}
return user;
@@ -537,8 +524,22 @@ export class SsoService {
version: 1,
} as const;
await this.claimSsoIdentity(userId, claims.sub, config);
let createAttempted = false;
let userCreated = false;
try {
if (options?.pendingApproval) {
await this.instanceConfigRepository.addPendingRegistration({
user_id: userId.toString(),
username,
discriminator: discriminatorResult.discriminator,
global_name: globalName,
email: userRow.email,
requested_at: now.toISOString(),
registration_url_id: null,
client_ip: null,
});
}
createAttempted = true;
const user = await users.create(userRow);
userCreated = true;
await users.upsertSettings(
@@ -557,6 +558,16 @@ export class SsoService {
await this.ssoIdentityRepository.releaseIdentity(config.providerId, claims.sub).catch((releaseError) => {
getLogger().error({releaseError}, 'Failed to release SSO identity after user provisioning failed');
});
if (options?.pendingApproval && !createAttempted) {
await this.instanceConfigRepository
.removePendingRegistration(userId.toString())
.catch((removeError: unknown) => {
getLogger().error(
{userId: userId.toString(), removeError},
'Failed to withdraw the pending approval of an SSO user that was never created',
);
});
}
}
throw error;
}
@@ -3,6 +3,15 @@
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import type {SudoModeMethods} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';
interface SudoMethodsUser {
totpSecret?: string | null;
authenticatorTypes?: Set<number> | null;
}
function hasTotpEnrolled(user: SudoMethodsUser): boolean {
return (user.totpSecret ?? null) !== null && (user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false);
}
export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boolean {
return (
(user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false) ||
@@ -10,13 +19,18 @@ export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boo
);
}
export function deriveSudoMethods(user: {
totpSecret?: string | null;
authenticatorTypes?: Set<number> | null;
}): SudoModeMethods {
const authenticatorTypes = user.authenticatorTypes ?? null;
export function userHasSudoCapability(user: SudoMethodsUser, hasPasskeyCredentials: boolean): boolean {
return hasTotpEnrolled(user) || hasPasskeyCredentials;
}
export function deriveSudoMethods(
user: SudoMethodsUser,
hasPasskeyCredentials: boolean,
hasBackupCodes: boolean,
): SudoModeMethods {
return {
totp: (user.totpSecret ?? null) !== null && (authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false),
webauthn: authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false,
totp: hasTotpEnrolled(user),
webauthn: hasPasskeyCredentials,
backup_codes: hasBackupCodes,
};
}
@@ -2,7 +2,7 @@
import * as AuthMfa from '@app/api/auth/AuthMfa';
import * as AuthPassword from '@app/api/auth/AuthPassword';
import {deriveSudoMethods, userHasMfa} from '@app/api/auth/services/SudoMethods';
import {deriveSudoMethods, userHasMfa, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
import {getSudoModeService} from '@app/api/auth/services/SudoModeService';
import {SUDO_MODE_HEADER} from '@app/api/middleware/SudoModeMiddleware';
import type {User} from '@app/api/models/User';
@@ -26,8 +26,9 @@ type SudoVerificationMethod = 'password' | 'mfa' | 'sudo_token';
export function hasNoVerifiableCredential(
user: {passwordHash: string | null; isBot: boolean},
hasMfa: boolean,
hasPasskeyCredentials: boolean,
): boolean {
if (user.isBot || hasMfa) {
if (user.isBot || hasMfa || hasPasskeyCredentials) {
return false;
}
return user.passwordHash === null;
@@ -52,18 +53,22 @@ async function verifySudoMode(
if (user.isBot) {
return {verified: true, method: 'sudo_token'};
}
const apiContext = ctx.get('apiContext');
const credentials = await apiContext.services.users.listWebAuthnCredentials(user.id);
const hasPasskeyCredentials = credentials.length > 0;
const hasMfa = userHasMfa(user);
const issueSudoToken = options.issueSudoToken ?? hasMfa;
if (hasMfa && ctx.get('sudoModeValid')) {
const hasSudoCapability = userHasSudoCapability(user, hasPasskeyCredentials);
const issueSudoToken = options.issueSudoToken ?? hasSudoCapability;
if (hasSudoCapability && ctx.get('sudoModeValid')) {
const sudoToken = ctx.get('sudoModeToken') ?? ctx.req.header(SUDO_MODE_HEADER) ?? undefined;
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? sudoToken : undefined};
}
const incomingToken = ctx.req.header(SUDO_MODE_HEADER);
if (!hasMfa && incomingToken && ctx.get('sudoModeValid')) {
if (!hasSudoCapability && incomingToken && ctx.get('sudoModeValid')) {
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? incomingToken : undefined};
}
if (hasMfa && body.mfa_method) {
const result = await AuthMfa.verifySudoMfa(ctx.get('apiContext'), {
if (hasSudoCapability && body.mfa_method) {
const result = await AuthMfa.verifySudoMfa(apiContext, {
userId: user.id,
method: body.mfa_method,
code: body.mfa_code,
@@ -77,14 +82,14 @@ async function verifySudoMode(
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
return {verified: true, sudoToken, method: 'mfa'};
}
if (hasNoVerifiableCredential(user, hasMfa)) {
if (hasNoVerifiableCredential(user, hasMfa, hasPasskeyCredentials)) {
return {verified: true, method: 'password'};
}
if (body.password && !hasMfa) {
if (!user.passwordHash) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
}
const passwordValid = await AuthPassword.verifyPassword(ctx.get('apiContext'), {
const passwordValid = await AuthPassword.verifyPassword(apiContext, {
password: body.password,
passwordHash: user.passwordHash,
});
@@ -93,7 +98,8 @@ async function verifySudoMode(
}
return {verified: true, method: 'password'};
}
throw new SudoModeRequiredError(hasMfa, deriveSudoMethods(user));
const hasBackupCodes = await AuthMfa.hasUnconsumedBackupCodes(apiContext, user.id);
throw new SudoModeRequiredError(hasSudoCapability, deriveSudoMethods(user, hasPasskeyCredentials, hasBackupCodes));
}
function setSudoTokenHeader(
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import type {User} from '@app/api/models/User';
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
interface WebAuthnSecondFactorUser {
passwordHash: string | null;
authenticatorTypes?: Set<number> | null;
}
export function webAuthnIsSecondFactor(user: WebAuthnSecondFactorUser, hasPasskeyCredentials: boolean): boolean {
return (
(user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false) ||
(user.passwordHash === null && hasPasskeyCredentials)
);
}
export async function resolveWebAuthnSecondFactor(ctx: ApiContext, user: User): Promise<boolean> {
const credentials = await ctx.services.users.listWebAuthnCredentials(user.id);
return webAuthnIsSecondFactor(user, credentials.length > 0);
}
+2 -12
View File
@@ -22,20 +22,10 @@ export interface LoginMfaResponse {
allowed_methods: Array<string>;
totp: boolean;
webauthn: boolean;
backup_codes: boolean;
}
type LoginResponse =
| {
user_id: string;
token: string;
}
| {
mfa: true;
ticket: string;
allowed_methods: Array<string>;
totp: boolean;
webauthn: boolean;
};
type LoginResponse = LoginSuccessResponse | LoginMfaResponse;
export interface UserMeResponse {
id: string;
@@ -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: {
@@ -627,6 +627,7 @@ describe('Email change flow', () => {
methods?: {
totp?: boolean;
webauthn?: boolean;
backup_codes?: boolean;
};
}>(harness, mfaAccount.token)
.patch('/users/@me')
@@ -634,13 +635,14 @@ describe('Email change flow', () => {
.expect(403, 'SUDO_MODE_REQUIRED')
.execute();
expect(noSudoResp.has_mfa).toBe(true);
expect(noSudoResp.methods).toEqual({totp: true, webauthn: false});
expect(noSudoResp.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
const passwordOnlyResp = await createBuilder<{
code: string;
has_mfa?: boolean;
methods?: {
totp?: boolean;
webauthn?: boolean;
backup_codes?: boolean;
};
}>(harness, mfaAccount.token)
.patch('/users/@me')
@@ -648,7 +650,7 @@ describe('Email change flow', () => {
.expect(403, 'SUDO_MODE_REQUIRED')
.execute();
expect(passwordOnlyResp.has_mfa).toBe(true);
expect(passwordOnlyResp.methods).toEqual({totp: true, webauthn: false});
expect(passwordOnlyResp.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
const updated = await createBuilder<UserPrivateResponse>(harness, mfaAccount.token)
.patch('/users/@me')
.body({
@@ -712,6 +714,7 @@ describe('Email change flow', () => {
methods?: {
totp?: boolean;
webauthn?: boolean;
backup_codes?: boolean;
};
}>(harness, mfaAccount.token)
.post('/users/@me/email-change/apply')
@@ -719,13 +722,14 @@ describe('Email change flow', () => {
.expect(403, 'SUDO_MODE_REQUIRED')
.execute();
expect(noSudoResp.has_mfa).toBe(true);
expect(noSudoResp.methods).toEqual({totp: true, webauthn: false});
expect(noSudoResp.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
const passwordOnlyResp = await createBuilder<{
code: string;
has_mfa?: boolean;
methods?: {
totp?: boolean;
webauthn?: boolean;
backup_codes?: boolean;
};
}>(harness, mfaAccount.token)
.post('/users/@me/email-change/apply')
@@ -733,7 +737,7 @@ describe('Email change flow', () => {
.expect(403, 'SUDO_MODE_REQUIRED')
.execute();
expect(passwordOnlyResp.has_mfa).toBe(true);
expect(passwordOnlyResp.methods).toEqual({totp: true, webauthn: false});
expect(passwordOnlyResp.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
const updated = await createBuilder<UserPrivateResponse>(harness, mfaAccount.token)
.post('/users/@me/email-change/apply')
.body({
@@ -776,6 +780,7 @@ describe('Email change flow', () => {
methods?: {
totp?: boolean;
webauthn?: boolean;
backup_codes?: boolean;
};
}>(harness, account.token)
.post('/users/@me/email-change/apply')
@@ -783,7 +788,7 @@ describe('Email change flow', () => {
.expect(403, 'SUDO_MODE_REQUIRED')
.execute();
expect(noSudoResp.has_mfa).toBe(false);
expect(noSudoResp.methods).toEqual({totp: false, webauthn: false});
expect(noSudoResp.methods).toEqual({totp: false, webauthn: false, backup_codes: false});
const updated = await createBuilder<UserPrivateResponse>(harness, account.token)
.post('/users/@me/email-change/apply')
.body({email_token: emailToken, password: account.password})
@@ -887,6 +892,7 @@ describe('Email change flow', () => {
methods?: {
totp?: boolean;
webauthn?: boolean;
backup_codes?: boolean;
};
}>(harness, mfaAccount.token)
.post('/users/@me/email-change/apply')
@@ -894,7 +900,7 @@ describe('Email change flow', () => {
.expect(403, 'SUDO_MODE_REQUIRED')
.execute();
expect(discovery.has_mfa).toBe(true);
expect(discovery.methods).toEqual({totp: true, webauthn: false});
expect(discovery.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
const applied = await createBuilder<UserPrivateResponse>(harness, mfaAccount.token)
.post('/users/@me/email-change/apply')
.body({
@@ -10,6 +10,7 @@ import {
createAuthenticationResponse,
createRegistrationResponse,
createWebAuthnDevice,
setWebAuthnTwoFactor,
type WebAuthnAuthenticationOptions,
type WebAuthnDevice,
type WebAuthnRegistrationOptions,
@@ -37,6 +38,7 @@ interface LoginMfaResponse {
interface SudoMfaMethodsResponse {
totp: boolean;
webauthn: boolean;
backup_codes: boolean;
has_mfa: boolean;
}
@@ -46,6 +48,7 @@ interface SudoModeRequiredResponse {
methods?: {
totp?: boolean;
webauthn?: boolean;
backup_codes?: boolean;
};
}
@@ -73,6 +76,7 @@ async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secr
async function setupWebAuthnOnlyUser(
harness: ApiTestHarness,
account: TestAccount,
twoFactorEnabled: boolean,
): Promise<{
account: TestAccount;
device: WebAuthnDevice;
@@ -124,6 +128,12 @@ async function setupWebAuthnOnlyUser(
})
.expect(204)
.execute();
if (twoFactorEnabled) {
await setWebAuthnTwoFactor(harness, updatedAccount.token, true, {
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[5]!.code,
});
}
await createBuilder(harness, updatedAccount.token)
.post('/users/@me/mfa/totp/disable')
.body({
@@ -162,9 +172,9 @@ describe('MFA Consistency Tests', () => {
await harness?.shutdown();
});
describe('WebAuthn sudo verification flow', () => {
test('WebAuthn user can complete sudo verification with passkey', async () => {
test('WebAuthn user with two-factor on can complete sudo verification with passkey', async () => {
const account = await createTestAccount(harness);
const {account: webauthnAccount, device} = await setupWebAuthnOnlyUser(harness, account);
const {account: webauthnAccount, device} = await setupWebAuthnOnlyUser(harness, account, true);
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, webauthnAccount.token)
.post('/users/@me/sudo/webauthn/authentication-options')
.body(null)
@@ -180,9 +190,27 @@ describe('MFA Consistency Tests', () => {
.expect(204)
.execute();
});
test('WebAuthn-only user cannot use password for sudo verification', async () => {
test('WebAuthn user with two-factor off can complete sudo verification with passkey', async () => {
const account = await createTestAccount(harness);
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account);
const {account: webauthnAccount, device} = await setupWebAuthnOnlyUser(harness, account, false);
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, webauthnAccount.token)
.post('/users/@me/sudo/webauthn/authentication-options')
.body(null)
.execute();
const sudoAssertion = createAuthenticationResponse(device, sudoOptions);
await createBuilder(harness, webauthnAccount.token)
.post('/users/@me/disable')
.body({
mfa_method: 'webauthn',
webauthn_response: sudoAssertion,
webauthn_challenge: sudoOptions.challenge,
})
.expect(204)
.execute();
});
test('WebAuthn-only user with two-factor on cannot use password for sudo verification', async () => {
const account = await createTestAccount(harness);
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account, true);
const errorResp = await createBuilder<{
code: string;
}>(harness, webauthnAccount.token)
@@ -194,6 +222,17 @@ describe('MFA Consistency Tests', () => {
.execute();
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
});
test('WebAuthn-only user with two-factor off can use password for sudo verification', async () => {
const account = await createTestAccount(harness);
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account, false);
await createBuilder(harness, webauthnAccount.token)
.post('/users/@me/disable')
.body({
password: account.password,
})
.expect(204)
.execute();
});
});
describe('Password-only sudo flow for non-MFA users', () => {
test('Non-MFA user can use password for sudo verification', async () => {
@@ -323,14 +362,37 @@ describe('MFA Consistency Tests', () => {
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, account.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(methods).toEqual({totp: false, webauthn: false, has_mfa: false});
expect(methods).toEqual({totp: false, webauthn: false, backup_codes: false, has_mfa: false});
const errorResp = await createBuilder<SudoModeRequiredResponse>(harness, account.token)
.post('/users/@me/disable')
.body({})
.expect(403, 'SUDO_MODE_REQUIRED')
.execute();
expect(errorResp.has_mfa).toBe(methods.has_mfa);
expect(errorResp.methods).toEqual({totp: methods.totp, webauthn: methods.webauthn});
expect(errorResp.methods).toEqual({
totp: methods.totp,
webauthn: methods.webauthn,
backup_codes: methods.backup_codes,
});
});
test('mfa-methods reports webauthn for a passkey user with two-factor off', async () => {
const account = await createTestAccount(harness);
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account, false);
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, webauthnAccount.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(methods).toEqual({totp: false, webauthn: true, backup_codes: false, has_mfa: true});
const errorResp = await createBuilder<SudoModeRequiredResponse>(harness, webauthnAccount.token)
.post('/users/@me/disable')
.body({})
.expect(403, 'SUDO_MODE_REQUIRED')
.execute();
expect(errorResp.has_mfa).toBe(methods.has_mfa);
expect(errorResp.methods).toEqual({
totp: methods.totp,
webauthn: methods.webauthn,
backup_codes: methods.backup_codes,
});
});
test('mfa-methods agrees with the SUDO_MODE_REQUIRED body for an enrolled TOTP user', async () => {
const account = await createTestAccount(harness);
@@ -347,14 +409,18 @@ describe('MFA Consistency Tests', () => {
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, loggedIn.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(methods).toEqual({totp: true, webauthn: false, has_mfa: true});
expect(methods).toEqual({totp: true, webauthn: false, backup_codes: true, has_mfa: true});
const errorResp = await createBuilder<SudoModeRequiredResponse>(harness, loggedIn.token)
.post('/users/@me/disable')
.body({})
.expect(403, 'SUDO_MODE_REQUIRED')
.execute();
expect(errorResp.has_mfa).toBe(methods.has_mfa);
expect(errorResp.methods).toEqual({totp: methods.totp, webauthn: methods.webauthn});
expect(errorResp.methods).toEqual({
totp: methods.totp,
webauthn: methods.webauthn,
backup_codes: methods.backup_codes,
});
});
});
describe('MFA requirement propagates to sensitive operations', () => {
@@ -8,9 +8,9 @@ import {
seedMfaTicket,
} from '@app/api/auth/tests/AuthTestUtils';
import {
createRegistrationResponse,
createWebAuthnDevice,
type WebAuthnRegistrationOptions,
registerWebAuthnCredential,
setWebAuthnTwoFactor,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
@@ -41,7 +41,7 @@ describe('Auth MFA TOTP without secret', () => {
.execute();
expect(login.code).toBe('INVALID_FORM_BODY');
});
it('rejects TOTP login when only WebAuthn is enabled', async () => {
it('rejects TOTP login when passkey two-factor is on and no TOTP secret remains', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
@@ -53,25 +53,14 @@ describe('Auth MFA TOTP without secret', () => {
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (regOptions.rp.id) {
device.rpId = regOptions.rp.id;
}
const registrationResponse = createRegistrationResponse(device, regOptions, 'Test Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: regOptions.challenge,
name: 'Test Passkey',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
await registerWebAuthnCredential(harness, account.token, device, () => ({
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
}));
await setWebAuthnTwoFactor(harness, account.token, true, {
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
});
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
@@ -105,4 +94,39 @@ describe('Auth MFA TOTP without secret', () => {
.execute();
expect(bypassAttempt.code).toBe('INVALID_FORM_BODY');
});
it('issues a session token when passkey two-factor is off and no TOTP secret remains', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
const totpData = await createBuilder<{
backup_codes: Array<{
code: string;
}>;
}>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
await registerWebAuthnCredential(harness, account.token, device, () => ({
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
}));
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: totpData.backup_codes[0]!.code,
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const login = await createBuilderWithoutAuth<{
mfa?: true;
token: string;
}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect(login.mfa).toBeUndefined();
expect(login.token).toBeTruthy();
});
});
@@ -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,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})]);
});
});
@@ -4,13 +4,25 @@ import {
clearTestEmails,
createAuthHarness,
createTestAccount,
createUniqueEmail,
findLastTestEmail,
type LoginSuccessResponse,
listTestEmails,
type TestAccount,
type TestEmailRecord,
totpCodeNow,
unclaimAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {
createAuthenticationResponse,
createWebAuthnDevice,
registerWebAuthnCredential,
setWebAuthnTwoFactor,
type WebAuthnAuthenticationOptions,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface MfaRequiredResponse {
@@ -19,6 +31,7 @@ interface MfaRequiredResponse {
allowed_methods: Array<string>;
totp: boolean;
webauthn: boolean;
backup_codes: boolean;
}
async function waitForEmail(harness: ApiTestHarness, type: string, recipient: string): Promise<TestEmailRecord> {
@@ -34,6 +47,34 @@ async function waitForEmail(harness: ApiTestHarness, type: string, recipient: st
throw new Error(`Email not found: type=${type}, recipient=${recipient}`);
}
async function claimEmailWithoutPassword(harness: ApiTestHarness, account: TestAccount): Promise<TestAccount> {
await unclaimAccount(harness, account.userId);
const start = await createBuilder<{ticket: string; original_proof?: string}>(harness, account.token)
.post('/users/@me/email-change/start')
.body({})
.execute();
const email = createUniqueEmail('passwordless-reset');
await createBuilder(harness, account.token)
.post('/users/@me/email-change/request-new')
.body({ticket: start.ticket, new_email: email, original_proof: start.original_proof})
.execute();
const newEmail = await waitForEmail(harness, 'email_change_new', email);
const verify = await createBuilder<{email_token: string}>(harness, account.token)
.post('/users/@me/email-change/verify-new')
.body({ticket: start.ticket, code: newEmail.metadata['code'], original_proof: start.original_proof})
.execute();
await createBuilder(harness, account.token).patch('/users/@me').body({email_token: verify.email_token}).execute();
return {...account, email};
}
async function requestPasswordReset(harness: ApiTestHarness, email: string): Promise<string> {
await clearTestEmails(harness);
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email}).expect(204).execute();
const mail = await waitForEmail(harness, 'password_reset', email);
const token = mail.metadata['token'];
expect(token).toBeDefined();
return token!;
}
describe('Auth reset password requires MFA', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
@@ -66,7 +107,8 @@ describe('Auth reset password requires MFA', () => {
expect(resetResp.ticket).toBeDefined();
expect(resetResp.totp).toBe(true);
expect(resetResp.webauthn).toBe(false);
expect(resetResp.allowed_methods).toEqual(['totp']);
expect(resetResp.backup_codes).toBe(true);
expect(resetResp.allowed_methods).toEqual(['totp', 'backup_codes']);
const mfaResp = await createBuilderWithoutAuth<{
token: string;
}>(harness)
@@ -86,4 +128,112 @@ describe('Auth reset password requires MFA', () => {
expect(login.totp).toBe(true);
expect(login.webauthn).toBe(false);
});
it('returns a session after password reset for a passkey user who left two-factor off', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
await clearTestEmails(harness);
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email: account.email}).expect(204).execute();
const email = await waitForEmail(harness, 'password_reset', account.email);
const token = email.metadata['token'];
expect(token).toBeDefined();
const resetResp = await createBuilderWithoutAuth<LoginSuccessResponse | MfaRequiredResponse>(harness)
.post('/auth/reset')
.body({token, password: 'new-strong-password-123'})
.execute();
expect('mfa' in resetResp).toBe(false);
expect((resetResp as LoginSuccessResponse).token).toBeTruthy();
});
it('returns an MFA ticket after password reset for a passkey user who turned two-factor on', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
await clearTestEmails(harness);
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email: account.email}).expect(204).execute();
const email = await waitForEmail(harness, 'password_reset', account.email);
const token = email.metadata['token'];
expect(token).toBeDefined();
const resetResp = await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
.post('/auth/reset')
.body({token, password: 'new-strong-password-123'})
.execute();
expect(resetResp.mfa).toBe(true);
expect(resetResp.totp).toBe(false);
expect(resetResp.webauthn).toBe(true);
expect(resetResp.allowed_methods).toContain('webauthn');
const mfaOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/login/mfa/webauthn/authentication-options')
.body({ticket: resetResp.ticket})
.execute();
if (mfaOptions.rpId) {
device.rpId = mfaOptions.rpId;
}
const mfaResp = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/login/mfa/webauthn')
.body({
response: createAuthenticationResponse(device, mfaOptions),
challenge: mfaOptions.challenge,
ticket: resetResp.ticket,
})
.execute();
expect(mfaResp.token).toBeDefined();
});
it('returns an MFA ticket after password reset for an account with no password that holds a passkey', async () => {
const base = await createTestAccount(harness);
const account = await claimEmailWithoutPassword(harness, base);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({}));
const token = await requestPasswordReset(harness, account.email);
const resetResp = await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
.post('/auth/reset')
.body({token, password: 'new-strong-password-123'})
.execute();
expect(resetResp.mfa).toBe(true);
expect(resetResp.totp).toBe(false);
expect(resetResp.webauthn).toBe(true);
expect(resetResp.allowed_methods).toContain('webauthn');
const mfaOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/login/mfa/webauthn/authentication-options')
.body({ticket: resetResp.ticket})
.execute();
if (mfaOptions.rpId) {
device.rpId = mfaOptions.rpId;
}
const mfaResp = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
.post('/auth/login/mfa/webauthn')
.body({
response: createAuthenticationResponse(device, mfaOptions),
challenge: mfaOptions.challenge,
ticket: resetResp.ticket,
})
.execute();
expect(mfaResp.token).toBeTruthy();
const me = await createBuilder<{id: string; authenticator_types: Array<number>}>(harness, mfaResp.token)
.get('/users/@me')
.execute();
expect(me.id).toBe(account.userId);
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
});
it('keeps demanding the passkey on a second password reset for an account that started with no password', async () => {
const base = await createTestAccount(harness);
const account = await claimEmailWithoutPassword(harness, base);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({}));
const firstToken = await requestPasswordReset(harness, account.email);
await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
.post('/auth/reset')
.body({token: firstToken, password: 'new-strong-password-123'})
.execute();
const secondToken = await requestPasswordReset(harness, account.email);
const secondReset = await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
.post('/auth/reset')
.body({token: secondToken, password: 'another-strong-password-456'})
.execute();
expect(secondReset.mfa).toBe(true);
expect(secondReset.webauthn).toBe(true);
expect(secondReset.allowed_methods).toContain('webauthn');
});
});
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAuthHarness, createTestAccount, unclaimAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
createSudoWebAuthnBody,
createWebAuthnDevice,
registerWebAuthnCredential,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface SudoMfaMethodsResponse {
totp: boolean;
webauthn: boolean;
backup_codes: boolean;
has_mfa: boolean;
}
describe('Sudo mode for passwordless accounts holding a passkey', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('still waves through a passwordless account that holds no credential at all', async () => {
const account = await createTestAccount(harness);
await unclaimAccount(harness, account.userId);
await createBuilder(harness, account.token)
.post('/users/@me/disable')
.body({})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
it('challenges a passwordless account that holds a passkey it never made a second factor', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
await unclaimAccount(harness, account.userId);
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, account.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(methods).toEqual({totp: false, webauthn: true, backup_codes: false, has_mfa: true});
const errorResp = await createBuilder<{
code: string;
}>(harness, account.token)
.post('/users/@me/disable')
.body({})
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
.execute();
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
});
it('lets the passwordless passkey holder clear the challenge with an assertion', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
await unclaimAccount(harness, account.userId);
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
await createBuilder(harness, account.token)
.post('/users/@me/disable')
.body(sudoBody)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
});
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {userHasMfa} from '@app/api/auth/services/SudoMethods';
import {userHasMfa, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
import {hasNoVerifiableCredential} from '@app/api/auth/services/SudoVerificationService';
import {createUserID} from '@app/api/BrandedTypes';
import {EMPTY_USER_ROW, type UserRow} from '@app/api/database/types/UserTypes';
@@ -25,17 +25,17 @@ describe('sudo verification credential capability', () => {
it('lets an SSO provisioned account without a password satisfy sudo mode', () => {
const user = createUser({password_hash: null, traits: new Set<string>(['sso'])});
expect(user.isUnclaimedAccount()).toBe(false);
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(true);
});
it('still lets an unclaimed account satisfy sudo mode', () => {
const user = createUser({password_hash: null});
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(true);
});
it('still requires a password from accounts that have one', () => {
const user = createUser({password_hash: 'hash', traits: new Set<string>(['sso'])});
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(false);
});
it('still requires MFA from an SSO account that enrolled a second factor', () => {
@@ -45,11 +45,36 @@ describe('sudo verification credential capability', () => {
authenticator_types: new Set<number>([UserAuthenticatorTypes.TOTP]),
});
expect(userHasMfa(user)).toBe(true);
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(false);
});
it('never applies to bots', () => {
const user = createUser({password_hash: null, bot: true});
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(false);
});
it('still requires MFA from a passwordless account holding a passkey it never made a second factor', () => {
const user = createUser({password_hash: null, traits: new Set<string>(['sso'])});
expect(userHasMfa(user)).toBe(false);
expect(userHasSudoCapability(user, true)).toBe(true);
expect(hasNoVerifiableCredential(user, userHasMfa(user), true)).toBe(false);
});
it('still requires MFA from an unclaimed account holding a passkey', () => {
const user = createUser({password_hash: null});
expect(hasNoVerifiableCredential(user, userHasMfa(user), true)).toBe(false);
});
it('reports no sudo capability for an account with a TOTP secret that was never enrolled', () => {
const user = createUser({totp_secret: 'JBSWY3DPEHPK3PXP'});
expect(userHasSudoCapability(user, false)).toBe(false);
});
it('reports sudo capability from an enrolled TOTP secret without any passkey', () => {
const user = createUser({
totp_secret: 'JBSWY3DPEHPK3PXP',
authenticator_types: new Set<number>([UserAuthenticatorTypes.TOTP]),
});
expect(userHasSudoCapability(user, false)).toBe(true);
});
});
@@ -6,11 +6,13 @@ import {
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
registerWebAuthnCredential,
type WebAuthnCredentialMetadata,
type WebAuthnRegistrationOptions,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('WebAuthn credential registration', () => {
@@ -64,4 +66,23 @@ describe('WebAuthn credential registration', () => {
expect(credentials[0].name).toBe('Test Passkey');
expect(credentials[0].id).toBe(device.credentialId.toString('base64url'));
});
it('does not turn passkeys into a second factor when a credential is registered', 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 registerWebAuthnCredential(harness, account.token, device, () => ({
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
}));
const me = await createBuilder<{
authenticator_types: Array<number>;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.TOTP]);
});
});
@@ -1,54 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, createTotpSecret, generateTotpCode} from '@app/api/auth/tests/AuthTestUtils';
import {
createAuthenticationResponse,
createRegistrationResponse,
createTestAccount,
createTotpSecret,
generateTotpCode,
type LoginMfaResponse,
type LoginSuccessResponse,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {
createSudoWebAuthnBody,
createWebAuthnDevice,
loginWithDiscoverablePasskey,
registerWebAuthnCredential,
setWebAuthnTwoFactor,
type WebAuthnAuthenticationOptions,
type WebAuthnDevice,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {beforeEach, describe, expect, test} from 'vitest';
interface BackupCodesResponse {
backup_codes: Array<{
code: string;
}>;
}
interface LoginMfaResponse {
mfa: true;
ticket: string;
totp: boolean;
webauthn: boolean;
}
interface WebAuthnRegistrationOptions {
challenge: string;
rp: {
id: string;
name: string;
};
user: {
id: string;
name: string;
displayName: string;
};
authenticatorSelection?: {
residentKey?: string;
requireResidentKey?: boolean;
userVerification?: string;
};
}
interface WebAuthnAuthenticationOptions {
challenge: string;
rpId: string;
allowCredentials?: Array<{
id: string;
type: string;
}>;
userVerification: string;
async function setupPasskeyOnlyAccount(
harness: ApiTestHarness,
twoFactorEnabled: boolean,
): Promise<{
account: TestAccount;
device: WebAuthnDevice;
}> {
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 registerWebAuthnCredential(harness, account.token, device, () => ({
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
}));
if (twoFactorEnabled) {
await setWebAuthnTwoFactor(harness, account.token, true, {
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
});
}
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: generateTotpCode(secret),
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const token = await loginWithDiscoverablePasskey(harness, device);
return {account: {...account, token}, device};
}
describe('WebAuthn MFA Consistency Tests', () => {
@@ -56,84 +67,8 @@ describe('WebAuthn MFA Consistency Tests', () => {
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('WebAuthn-only user cannot use password for sudo - password rejected with 403', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
expect(login.mfa).toBe(true);
const mfaLogin = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/login/mfa/totp')
.body({
code: backupCodes.backup_codes[0]!.code,
ticket: login.ticket,
})
.execute();
account.token = mfaLogin.token;
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[1]!.code,
})
.execute();
expect(registrationOptions.authenticatorSelection).toMatchObject({
residentKey: 'preferred',
requireResidentKey: false,
userVerification: 'preferred',
});
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: registrationOptions.challenge,
name: 'Test Passkey',
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[2]!.code,
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCodes.backup_codes[3]!.code,
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[4]!.code,
})
.expect(204)
.execute();
const discoverableOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null)
.execute();
const discoverableAssertion = createAuthenticationResponse(device, discoverableOptions);
const passkeyLogin = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/webauthn/authenticate')
.body({
response: discoverableAssertion,
challenge: discoverableOptions.challenge,
})
.execute();
account.token = passkeyLogin.token;
test('passkey user with two-factor on cannot use password for sudo - password rejected with 403', async () => {
const {account} = await setupPasskeyOnlyAccount(harness, true);
const {json: errorResp} = await createBuilder<{
code: string;
}>(harness, account.token)
@@ -145,18 +80,34 @@ describe('WebAuthn MFA Consistency Tests', () => {
.executeWithResponse();
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
});
test('WebAuthn-only user can use WebAuthn for sudo verification', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
test('passkey user with two-factor off can still use password for sudo', async () => {
const {account} = await setupPasskeyOnlyAccount(harness, false);
await createBuilder(harness, account.token)
.post('/users/@me/disable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.expect(204)
.execute();
});
test('passkey user with two-factor on can use WebAuthn for sudo verification', async () => {
const {account, device} = await setupPasskeyOnlyAccount(harness, true);
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
const {response: disableResp} = await createBuilder(harness, account.token)
.post('/users/@me/disable')
.body(sudoBody)
.expect(204)
.executeWithResponse();
const sudoToken = disableResp.headers.get('x-sudo-mode-token');
expect(sudoToken).toBeNull();
});
test('passkey user with two-factor off can use WebAuthn for sudo verification', async () => {
const {account, device} = await setupPasskeyOnlyAccount(harness, false);
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
await createBuilder(harness, account.token).post('/users/@me/disable').body(sudoBody).expect(204).execute();
});
test('passkey user with two-factor on requires MFA when logging in with password', async () => {
const {account} = await setupPasskeyOnlyAccount(harness, true);
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
.body({
@@ -165,144 +116,35 @@ describe('WebAuthn MFA Consistency Tests', () => {
})
.execute();
expect(login.mfa).toBe(true);
const mfaLogin = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/login/mfa/totp')
expect(login.ticket).toBeTruthy();
expect(login.webauthn).toBe(true);
});
test('passkey user with two-factor off logs in with password and receives a session token', async () => {
const {account} = await setupPasskeyOnlyAccount(harness, false);
const login = await createBuilderWithoutAuth<LoginSuccessResponse | LoginMfaResponse>(harness)
.post('/auth/login')
.body({
code: backupCodes.backup_codes[0]!.code,
ticket: login.ticket,
email: account.email,
password: account.password,
})
.execute();
account.token = mfaLogin.token;
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[1]!.code,
})
expect('mfa' in login).toBe(false);
expect((login as LoginSuccessResponse).token).toBeTruthy();
const userInfo = await createBuilder<{
id: string;
}>(harness, (login as LoginSuccessResponse).token)
.get('/users/@me')
.execute();
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: registrationOptions.challenge,
name: 'Test Passkey',
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[2]!.code,
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCodes.backup_codes[3]!.code,
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[4]!.code,
})
.expect(204)
.execute();
const discoverableOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null)
.execute();
const discoverableAssertion = createAuthenticationResponse(device, discoverableOptions);
const passkeyLogin = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/webauthn/authenticate')
.body({
response: discoverableAssertion,
challenge: discoverableOptions.challenge,
})
.execute();
account.token = passkeyLogin.token;
expect(userInfo.id).toBe(account.userId);
});
test('sudo WebAuthn options stay available to a passkey user with two-factor off', async () => {
const {account, device} = await setupPasskeyOnlyAccount(harness, false);
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, account.token)
.post('/users/@me/sudo/webauthn/authentication-options')
.body(null)
.execute();
expect(sudoOptions.userVerification).toBe('discouraged');
const sudoAssertion = createAuthenticationResponse(device, sudoOptions);
const {response: disableResp2} = await createBuilder(harness, account.token)
.post('/users/@me/disable')
.body({
mfa_method: 'webauthn',
webauthn_response: sudoAssertion,
webauthn_challenge: sudoOptions.challenge,
})
.expect(204)
.executeWithResponse();
const sudoToken = disableResp2.headers.get('x-sudo-mode-token');
expect(sudoToken).toBeNull();
});
test('WebAuthn-only user requires MFA when logging in with password', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
expect(login.mfa).toBe(true);
const mfaLogin = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/login/mfa/totp')
.body({
code: backupCodes.backup_codes[0]!.code,
ticket: login.ticket,
})
.execute();
account.token = mfaLogin.token;
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[1]!.code,
})
.execute();
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: registrationOptions.challenge,
name: 'Test Passkey',
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[2]!.code,
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCodes.backup_codes[3]!.code,
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[4]!.code,
})
.expect(204)
.execute();
const login2 = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
expect(login2.mfa).toBe(true);
expect(login2.ticket).toBeTruthy();
expect(login2.webauthn).toBe(true);
expect(sudoOptions.allowCredentials?.length).toBeGreaterThan(0);
expect(device.credentialId.length).toBeGreaterThan(0);
});
});
@@ -4,16 +4,17 @@ import {
createAuthHarness,
createTestAccount,
type LoginMfaResponse,
type LoginSuccessResponse,
loginUser,
} from '@app/api/auth/tests/AuthTestUtils';
import {
createAuthenticationResponse,
createRegistrationResponse,
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
registerWebAuthnCredential,
setWebAuthnTwoFactor,
type WebAuthnAuthenticationOptions,
type WebAuthnRegistrationOptions,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
@@ -30,7 +31,7 @@ describe('WebAuthn MFA login', () => {
afterAll(async () => {
await harness?.shutdown();
});
it('validates the WebAuthn MFA login flow', async () => {
it('validates the WebAuthn MFA login flow when passkey two-factor is turned on', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
@@ -38,25 +39,17 @@ describe('WebAuthn MFA login', () => {
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (regOptions.rp.id) {
device.rpId = regOptions.rp.id;
}
const registrationResponse = createRegistrationResponse(device, regOptions, 'MFA Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: regOptions.challenge,
name: 'MFA Passkey',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
await registerWebAuthnCredential(
harness,
account.token,
device,
() => ({mfa_method: 'totp', mfa_code: generateTotpCode(secret)}),
'MFA Passkey',
);
await setWebAuthnTwoFactor(harness, account.token, true, {
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
});
const loginResp = await loginUser(harness, {email: account.email, password: account.password});
expect('mfa' in loginResp && loginResp.mfa).toBe(true);
const loginMfaResp = loginResp as LoginMfaResponse;
@@ -83,7 +76,7 @@ describe('WebAuthn MFA login', () => {
.body({
response: mfaAssertion,
challenge: mfaOptions.challenge,
ticket: (loginResp as LoginMfaResponse).ticket,
ticket: loginMfaResp.ticket,
})
.execute();
expect(webauthnMfaLogin.token).toBeTruthy();
@@ -94,4 +87,39 @@ describe('WebAuthn MFA login', () => {
.execute();
expect(userInfo.id).toBe(account.userId);
});
it('issues a session token instead of an MFA ticket when passkey two-factor is left off', 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 registerWebAuthnCredential(
harness,
account.token,
device,
() => ({mfa_method: 'totp', mfa_code: generateTotpCode(secret)}),
'MFA Passkey',
);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: generateTotpCode(secret),
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const loginResp = await loginUser(harness, {email: account.email, password: account.password});
expect('mfa' in loginResp).toBe(false);
const loginSuccessResp = loginResp as LoginSuccessResponse;
expect(loginSuccessResp.token).toBeTruthy();
const userInfo = await createBuilder<{
id: string;
}>(harness, loginSuccessResp.token)
.get('/users/@me')
.execute();
expect(userInfo.id).toBe(account.userId);
});
});
@@ -0,0 +1,124 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
createAuthHarness,
createTestAccount,
createTotpSecret,
generateTotpCode,
type LoginMfaResponse,
type LoginSuccessResponse,
loginUser,
} from '@app/api/auth/tests/AuthTestUtils';
import {
createAuthenticationResponse,
createWebAuthnDevice,
loginWithDiscoverablePasskey,
registerWebAuthnCredential,
type WebAuthnAuthenticationOptions,
} from '@app/api/auth/tests/WebAuthnTestUtils';
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 {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('WebAuthn opt-in login', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('does not offer webauthn at login to a TOTP user whose passkey is opted out', 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 registerWebAuthnCredential(harness, account.token, device, () => ({
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
}));
const login = (await loginUser(harness, {
email: account.email,
password: account.password,
})) as LoginMfaResponse;
expect(login.mfa).toBe(true);
expect(login.totp).toBe(true);
expect(login.webauthn).toBe(false);
expect(login.allowed_methods).not.toContain('webauthn');
expect(login.allowed_methods).toContain('totp');
});
it('rejects the WebAuthn MFA login route for a user who never turned passkey two-factor on', 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 registerWebAuthnCredential(harness, account.token, device, () => ({
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
}));
const login = (await loginUser(harness, {
email: account.email,
password: account.password,
})) as LoginMfaResponse;
const mfaOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/login/mfa/webauthn/authentication-options')
.body({ticket: login.ticket})
.execute();
if (mfaOptions.rpId) {
device.rpId = mfaOptions.rpId;
}
await createBuilderWithoutAuth(harness)
.post('/auth/login/mfa/webauthn')
.body({
response: createAuthenticationResponse(device, mfaOptions),
challenge: mfaOptions.challenge,
ticket: login.ticket,
})
.expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED')
.execute();
const totpLogin = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/login/mfa/totp')
.body({ticket: login.ticket, code: generateTotpCode(secret)})
.execute();
expect(totpLogin.token).toBeTruthy();
});
it('completes the passwordless journey for an account that never enrolled TOTP or the toggle', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
const me = await createBuilder<{
authenticator_types: Array<number>;
mfa_enabled: boolean;
}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.authenticator_types).toEqual([]);
expect(me.mfa_enabled).toBe(false);
const passwordLogin = await loginUser(harness, {email: account.email, password: account.password});
expect('mfa' in passwordLogin).toBe(false);
expect((passwordLogin as LoginSuccessResponse).token).toBeTruthy();
const passkeyToken = await loginWithDiscoverablePasskey(harness, device);
expect(passkeyToken).toBeTruthy();
const passkeyMe = await createBuilder<{
id: string;
authenticator_types: Array<number>;
}>(harness, passkeyToken)
.get('/users/@me')
.execute();
expect(passkeyMe.id).toBe(account.userId);
expect(passkeyMe.authenticator_types).not.toContain(UserAuthenticatorTypes.WEBAUTHN);
});
});
@@ -9,6 +9,8 @@ import {
generateKeyPairSync,
randomBytes,
} from 'node:crypto';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {decode as base32Decode, encode as base32Encode} from 'hi-base32';
export interface WebAuthnDevice {
@@ -54,6 +56,22 @@ export interface WebAuthnCredentialMetadata {
name: string;
}
export type SudoVerificationBody = Record<string, unknown>;
export type SudoVerificationBodyFactory = () => SudoVerificationBody;
export interface WebAuthnTwoFactorResult {
user: {
id: string;
mfa_enabled: boolean;
authenticator_types: Array<number>;
};
backup_codes: Array<{
code: string;
consumed: boolean;
}> | null;
}
interface AuthenticatorAttestationResponse {
clientDataJSON: string;
attestationObject: string;
@@ -415,3 +433,80 @@ export function createAuthenticationResponseWithoutUV(
},
};
}
export async function registerWebAuthnCredential(
harness: ApiTestHarness,
token: string,
device: WebAuthnDevice,
createSudoBody: SudoVerificationBodyFactory,
name = 'Test Passkey',
): Promise<void> {
const options = await createBuilder<WebAuthnRegistrationOptions>(harness, token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body(createSudoBody())
.execute();
if (options.rp.id) {
device.rpId = options.rp.id;
}
await createBuilder(harness, token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: createRegistrationResponse(device, options, name),
challenge: options.challenge,
name,
...createSudoBody(),
})
.expect(204)
.execute();
}
export async function createSudoWebAuthnBody(
harness: ApiTestHarness,
token: string,
device: WebAuthnDevice,
): Promise<SudoVerificationBody> {
const options = await createBuilder<WebAuthnAuthenticationOptions>(harness, token)
.post('/users/@me/sudo/webauthn/authentication-options')
.body(null)
.execute();
if (options.rpId) {
device.rpId = options.rpId;
}
return {
mfa_method: 'webauthn',
webauthn_response: createAuthenticationResponse(device, options),
webauthn_challenge: options.challenge,
};
}
export async function setWebAuthnTwoFactor(
harness: ApiTestHarness,
token: string,
enabled: boolean,
sudo: SudoVerificationBody,
): Promise<WebAuthnTwoFactorResult> {
return createBuilder<WebAuthnTwoFactorResult>(harness, token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled, ...sudo})
.execute();
}
export async function loginWithDiscoverablePasskey(harness: ApiTestHarness, device: WebAuthnDevice): Promise<string> {
const options = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null)
.execute();
if (options.rpId) {
device.rpId = options.rpId;
}
const login = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/webauthn/authenticate')
.body({
response: createAuthenticationResponse(device, options),
challenge: options.challenge,
})
.execute();
return login.token;
}
@@ -0,0 +1,217 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
createAuthHarness,
createTestAccount,
createTotpSecret,
generateTotpCode,
type LoginMfaResponse,
loginUser,
} from '@app/api/auth/tests/AuthTestUtils';
import {
createSudoWebAuthnBody,
createWebAuthnDevice,
registerWebAuthnCredential,
type SudoVerificationBody,
setWebAuthnTwoFactor,
type WebAuthnCredentialMetadata,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface BackupCode {
code: string;
consumed: boolean;
}
async function readBackupCodes(
harness: ApiTestHarness,
token: string,
sudo: SudoVerificationBody,
): Promise<Array<BackupCode>> {
const response = await createBuilder<{
backup_codes: Array<BackupCode>;
}>(harness, token)
.post('/users/@me/mfa/backup-codes')
.body({regenerate: false, ...sudo})
.execute();
return response.backup_codes;
}
describe('WebAuthn two-factor backup codes', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('mints backup codes when passkey two-factor is turned on for an account with no TOTP', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
expect(enabled.backup_codes).not.toBeNull();
expect(enabled.backup_codes!.length).toBeGreaterThan(0);
expect(enabled.backup_codes!.every((backupCode) => !backupCode.consumed)).toBe(true);
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
const stored = await readBackupCodes(harness, account.token, sudoBody);
expect(stored.map((backupCode) => backupCode.code).sort()).toEqual(
enabled.backup_codes!.map((backupCode) => backupCode.code).sort(),
);
});
it('mints nothing when the account already holds backup codes from TOTP', 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 registerWebAuthnCredential(harness, account.token, device, () => ({
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
}));
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
});
expect(enabled.backup_codes).toBeNull();
});
it('accepts a minted backup code at login when the passkey is unavailable', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
const login = (await loginUser(harness, {
email: account.email,
password: account.password,
})) as LoginMfaResponse;
expect(login.mfa).toBe(true);
expect(login.totp).toBe(false);
expect(login.webauthn).toBe(true);
expect(login.backup_codes).toBe(true);
expect(login.allowed_methods).toContain('backup_codes');
const backupLogin = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/login/mfa/totp')
.body({ticket: login.ticket, code: enabled.backup_codes![0]!.code})
.execute();
expect(backupLogin.token).toBeTruthy();
const me = await createBuilder<{
id: string;
}>(harness, backupLogin.token)
.get('/users/@me')
.execute();
expect(me.id).toBe(account.userId);
});
it('keeps backup codes when TOTP is disabled while passkey two-factor stays on', 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 registerWebAuthnCredential(harness, account.token, device, () => ({
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
}));
await setWebAuthnTwoFactor(harness, account.token, true, {
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
});
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: generateTotpCode(secret),
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
const stored = await readBackupCodes(harness, account.token, sudoBody);
expect(stored.length).toBeGreaterThan(0);
});
it('keeps backup codes when passkey two-factor is turned off while TOTP stays on', 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 registerWebAuthnCredential(harness, account.token, device, () => ({
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
}));
await setWebAuthnTwoFactor(harness, account.token, true, {
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
});
await setWebAuthnTwoFactor(harness, account.token, false, {
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
});
const stored = await readBackupCodes(harness, account.token, {
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
});
expect(stored.length).toBeGreaterThan(0);
});
it('clears backup codes only once no second factor remains', 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 registerWebAuthnCredential(harness, account.token, device, () => ({
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
}));
await setWebAuthnTwoFactor(harness, account.token, true, {
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
});
await setWebAuthnTwoFactor(harness, account.token, false, {
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
});
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: generateTotpCode(secret),
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const stored = await readBackupCodes(harness, account.token, {password: account.password});
expect(stored).toHaveLength(0);
});
it('clears backup codes when the last passkey is deleted and nothing else remains', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
const credentials = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
await createBuilder(harness, account.token)
.delete(`/users/@me/mfa/webauthn/credentials/${credentials[0]!.id}`)
.body(sudoBody)
.expect(204)
.execute();
const stored = await readBackupCodes(harness, account.token, {password: account.password});
expect(stored).toHaveLength(0);
});
});
@@ -0,0 +1,181 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAuthHarness, createTestAccount, loginUser, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
createWebAuthnDevice,
registerWebAuthnCredential,
setWebAuthnTwoFactor,
type WebAuthnDevice,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {Config} from '@app/api/Config';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface PrivateUserResponse {
id: string;
mfa_enabled: boolean;
authenticator_types: Array<number>;
}
interface SudoMfaMethodsResponse {
totp: boolean;
webauthn: boolean;
backup_codes: boolean;
has_mfa: boolean;
}
interface SudoModeRequiredResponse {
code: string;
has_mfa?: boolean;
methods?: {
totp?: boolean;
webauthn?: boolean;
backup_codes?: boolean;
};
}
interface ValidationErrorBody {
code: string;
errors: Array<{path: string; code: string}>;
}
interface BackupCode {
code: string;
consumed: boolean;
}
async function withTotpReplayProtection<T>(run: () => Promise<T>): Promise<T> {
const previous = Config.dev.testModeEnabled;
Config.dev.testModeEnabled = false;
try {
return await run();
} finally {
Config.dev.testModeEnabled = previous;
}
}
async function createPasskeyOnlyTwoFactorAccount(
harness: ApiTestHarness,
): Promise<{account: TestAccount; device: WebAuthnDevice; backupCodes: Array<string>}> {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
expect(enabled.user.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
expect(enabled.backup_codes).not.toBeNull();
return {account, device, backupCodes: enabled.backup_codes!.map((backupCode) => backupCode.code)};
}
async function fetchMe(harness: ApiTestHarness, token: string): Promise<PrivateUserResponse> {
return createBuilder<PrivateUserResponse>(harness, token).get('/users/@me').execute();
}
describe('Sudo mode recovery for passkey two-factor accounts without TOTP', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('turns passkey two-factor off with a backup code when the passkey cannot be used', async () => {
const {account, backupCodes} = await createPasskeyOnlyTwoFactorAccount(harness);
await createBuilder(harness, account.token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled: false, password: account.password})
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
.execute();
await withTotpReplayProtection(async () => {
const disabled = await createBuilder<{user: PrivateUserResponse}>(harness, account.token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled: false, mfa_method: 'totp', mfa_code: backupCodes[0]!})
.expect(HTTP_STATUS.OK)
.execute();
expect(disabled.user.authenticator_types).toEqual([]);
expect(disabled.user.mfa_enabled).toBe(false);
});
const me = await fetchMe(harness, account.token);
expect(me.authenticator_types).toEqual([]);
expect(me.mfa_enabled).toBe(false);
const login = await loginUser(harness, {email: account.email, password: account.password});
expect('mfa' in login).toBe(false);
});
it('advertises the backup code option in the sudo methods endpoint and the sudo mode challenge alike', async () => {
const {account} = await createPasskeyOnlyTwoFactorAccount(harness);
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, account.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(methods).toEqual({totp: false, webauthn: true, backup_codes: true, has_mfa: true});
const challenge = await createBuilder<SudoModeRequiredResponse>(harness, account.token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled: false})
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
.execute();
expect(challenge.has_mfa).toBe(methods.has_mfa);
expect(challenge.methods).toEqual({
totp: methods.totp,
webauthn: methods.webauthn,
backup_codes: methods.backup_codes,
});
});
it('spends a backup code accepted as a sudo proof and refuses the same code afterwards', async () => {
const {account, backupCodes} = await createPasskeyOnlyTwoFactorAccount(harness);
const spent = backupCodes[0]!;
await withTotpReplayProtection(async () => {
const stored = await createBuilder<{backup_codes: Array<BackupCode>}>(harness, account.token)
.post('/users/@me/mfa/backup-codes')
.body({regenerate: false, mfa_method: 'totp', mfa_code: spent})
.expect(HTTP_STATUS.OK)
.execute();
expect(stored.backup_codes.find((backupCode) => backupCode.code === spent)?.consumed).toBe(true);
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled: false, mfa_method: 'totp', mfa_code: spent})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
expect(error.errors[0]?.path).toBe('mfa_code');
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_MFA_CODE);
});
const me = await fetchMe(harness, account.token);
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
});
it('rejects a backup code that was never minted and leaves passkey two-factor on', async () => {
const {account} = await createPasskeyOnlyTwoFactorAccount(harness);
await withTotpReplayProtection(async () => {
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled: false, mfa_method: 'totp', mfa_code: 'aaaa-bbbb'})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
expect(error.errors[0]?.path).toBe('mfa_code');
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_MFA_CODE);
});
const me = await fetchMe(harness, account.token);
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
});
it('rejects a code-entry sudo proof for a passkey account that holds neither TOTP nor backup codes', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, account.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(methods).toEqual({totp: false, webauthn: true, backup_codes: false, has_mfa: true});
await withTotpReplayProtection(async () => {
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
.post('/users/@me/disable')
.body({mfa_method: 'totp', mfa_code: 'aaaa-bbbb'})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
expect(error.errors[0]?.path).toBe('mfa_code');
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_MFA_CODE);
});
});
});
@@ -0,0 +1,126 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
createSudoWebAuthnBody,
createWebAuthnDevice,
registerWebAuthnCredential,
setWebAuthnTwoFactor,
type WebAuthnCredentialMetadata,
type WebAuthnTwoFactorResult,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface PrivateUserResponse {
id: string;
mfa_enabled: boolean;
authenticator_types: Array<number>;
}
describe('WebAuthn two-factor toggle', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('reports an empty authenticator types array for an account with no second factor', async () => {
const account = await createTestAccount(harness);
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
expect(me.authenticator_types).toEqual([]);
expect(me.mfa_enabled).toBe(false);
});
it('rejects enabling passkey two-factor when the account has no registered credential', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled: true, password: account.password})
.expect(HTTP_STATUS.BAD_REQUEST, 'NO_PASSKEYS_REGISTERED')
.execute();
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
expect(me.authenticator_types).toEqual([]);
});
it('round trips enabling and disabling passkey two-factor', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
expect(enabled.user.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
expect(enabled.user.mfa_enabled).toBe(true);
const afterEnable = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
expect(afterEnable.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
const disabled = await setWebAuthnTwoFactor(harness, account.token, false, sudoBody);
expect(disabled.user.authenticator_types).toEqual([]);
expect(disabled.user.mfa_enabled).toBe(false);
const afterDisable = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
expect(afterDisable.authenticator_types).toEqual([]);
});
it('refuses to disable passkey two-factor without a sudo proof', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
await createBuilder(harness, account.token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled: false})
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
.execute();
await createBuilder(harness, account.token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled: false, password: account.password})
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
.execute();
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
});
it('accepts a passkey assertion as the sudo proof for disabling passkey two-factor', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
const disabled = await createBuilder<WebAuthnTwoFactorResult>(harness, account.token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled: false, ...sudoBody})
.execute();
expect(disabled.user.authenticator_types).toEqual([]);
});
it('leaves the account untouched when the requested state already matches', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
const firstEnable = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
expect(firstEnable.backup_codes).not.toBeNull();
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
const secondEnable = await setWebAuthnTwoFactor(harness, account.token, true, sudoBody);
expect(secondEnable.backup_codes).toBeNull();
expect(secondEnable.user.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
});
it('drops the passkey second factor when the last credential is deleted', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
const credentials = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
await createBuilder(harness, account.token)
.delete(`/users/@me/mfa/webauthn/credentials/${credentials[0]!.id}`)
.body(sudoBody)
.expect(204)
.execute();
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
expect(me.authenticator_types).toEqual([]);
expect(me.mfa_enabled).toBe(false);
});
});
+1 -1
View File
@@ -150,7 +150,7 @@ function serializeGroupDMChannel(channel: Channel): ChannelResponse {
return {
...serializeBaseChannelFields(channel),
...serializeMessageableFields(channel),
name: channel.name ?? undefined,
name: channel.name ?? null,
icon: channel.iconHash ?? null,
owner_id: channel.ownerId ? channel.ownerId.toString() : null,
nicks: nicknameMap.size > 0 ? nicks : undefined,
@@ -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,
@@ -24,6 +24,7 @@ import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearch
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {serializeChannelForAudit} from '@app/api/utils/AuditSerializationUtils';
import {applyProtectedOverwriteBits} from '@app/api/utils/featureUtils';
import {overwriteGrantedBits} from '@app/api/utils/PermissionUtils';
import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
import type {VoiceRegionAvailability} from '@app/api/voice/VoiceModel';
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
@@ -208,25 +209,6 @@ export class ChannelOperationsService {
userId,
channelId: channel.id,
});
if (!isOwner) {
for (const overwrite of data.permission_overwrites ?? []) {
const allowPerms = (overwrite.allow ? BigInt(overwrite.allow) : 0n) & ALL_PERMISSIONS;
if ((allowPerms & ~channelPermissions) !== 0n) {
throw new MissingPermissionsError();
}
}
const nextDeny = new Map<RoleID | UserID, bigint>();
for (const overwrite of data.permission_overwrites ?? []) {
const targetKey = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
nextDeny.set(targetKey, (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS);
}
for (const [targetId, existing] of previousPermissionOverwrites ?? []) {
const removedDeny = existing.deny & ~(nextDeny.get(targetId) ?? 0n);
if ((removedDeny & ~channelPermissions) !== 0n) {
throw new MissingPermissionsError();
}
}
}
permissionOverwrites = new Map();
for (const overwrite of data.permission_overwrites ?? []) {
const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
@@ -251,6 +233,18 @@ export class ChannelOperationsService {
}),
);
}
if (!isOwner) {
const targetIds = new Set([...(previousPermissionOverwrites?.keys() ?? []), ...permissionOverwrites.keys()]);
for (const targetId of targetIds) {
const grantedBits = overwriteGrantedBits(
previousPermissionOverwrites?.get(targetId),
permissionOverwrites.get(targetId),
);
if ((grantedBits & ~channelPermissions) !== 0n) {
throw new MissingPermissionsError();
}
}
}
}
const requestedParentId =
data.parent_id !== undefined ? (data.parent_id ? createChannelID(data.parent_id) : null) : channel.parentId;
@@ -646,9 +640,8 @@ export class ChannelOperationsService {
const sanitizedAllow = protectedBits.allow;
const sanitizedDeny = protectedBits.deny;
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
if (!hasAdministrator && (sanitizedAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const removedDeny = (existing?.deny ?? 0n) & ~sanitizedDeny;
if (!hasAdministrator && (removedDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const grantedBits = overwriteGrantedBits(existing, {allow: sanitizedAllow, deny: sanitizedDeny});
if (!hasAdministrator && (grantedBits & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const previousPermissionOverwrites = channel.permissionOverwrites;
const nextOverwrite = new ChannelPermissionOverwrite({
type: params.overwrite.type,
@@ -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(
@@ -294,6 +294,48 @@ describe('Channel Permission Overwrites', () => {
expect(overwrite?.allow).toBe(Permissions.VIEW_CHANNEL.toString());
expect(overwrite?.deny).toBe(Permissions.MANAGE_MESSAGES.toString());
});
test('should let an editor change an overwrite that already allows a permission they lack', async () => {
const {owner, members, guild, systemChannel} = await setupTestGuildWithMembers(harness, 1);
const manager = members[0];
const managerRole = await createRole(harness, owner.token, guild.id, {
name: 'Queue Manager',
permissions: Permissions.MANAGE_ROLES.toString(),
});
const botRole = await createRole(harness, owner.token, guild.id, {name: 'Bot'});
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
await createPermissionOverwrite(harness, owner.token, systemChannel.id, botRole.id, {
type: 0,
allow: Permissions.PIN_MESSAGES.toString(),
deny: '0',
});
await createBuilder(harness, manager.token)
.put(`/channels/${systemChannel.id}/permissions/${botRole.id}`)
.body({
type: 0,
allow: (Permissions.PIN_MESSAGES | Permissions.SEND_MESSAGES).toString(),
deny: '0',
})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const updated = await getChannel(harness, owner.token, systemChannel.id);
const botOverwrite = updated.permission_overwrites?.find((o) => o.id === botRole.id);
expect(botOverwrite?.allow).toBe((Permissions.PIN_MESSAGES | Permissions.SEND_MESSAGES).toString());
});
test('should reject an editor granting a permission they lack', async () => {
const {owner, members, guild, systemChannel} = await setupTestGuildWithMembers(harness, 1);
const manager = members[0];
const managerRole = await createRole(harness, owner.token, guild.id, {
name: 'Queue Manager',
permissions: Permissions.MANAGE_ROLES.toString(),
});
const botRole = await createRole(harness, owner.token, guild.id, {name: 'Bot'});
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
await createBuilder(harness, manager.token)
.put(`/channels/${systemChannel.id}/permissions/${botRole.id}`)
.body({type: 0, allow: Permissions.PIN_MESSAGES.toString(), deny: '0'})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should propagate category permission patches only to children that were synced when the category changed', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const targetRole = await createRole(harness, owner.token, guild.id, {name: 'Readers'});
@@ -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,62 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createFriendship, createGroupDmChannel, getChannel} from '@app/api/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
describe('Group DM name clear', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it.each([
['an empty string', ''],
['null', null],
])('sends a null name to every recipient when cleared with %s', async (_label, clearedName) => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await ensureSessionStarted(harness, user3.token);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
await createBuilder<ChannelResponse>(harness, user1.token)
.patch(`/channels/${groupDm.id}`)
.body({name: 'Weekend plans'})
.expect(HTTP_STATUS.OK)
.execute();
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence');
try {
const cleared = await createBuilder<ChannelResponse>(harness, user1.token)
.patch(`/channels/${groupDm.id}`)
.body({name: clearedName})
.expect(HTTP_STATUS.OK)
.execute();
expect(cleared).toHaveProperty('name', null);
const channelUpdates = dispatchSpy.mock.calls.filter(([params]) => params.event === 'CHANNEL_UPDATE');
expect(channelUpdates.map(([params]) => params.userId.toString()).sort()).toEqual(
[user1.userId, user2.userId, user3.userId].sort(),
);
for (const [params] of channelUpdates) {
expect(params.data).toHaveProperty('name', null);
}
} finally {
dispatchSpy.mockRestore();
}
expect(await getChannel(harness, user2.token, groupDm.id)).toHaveProperty('name', null);
});
});
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
createPermissionOverwrite,
sendChannelMessage,
setupTestGuildWithMembers,
} from '@app/api/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
describe('Message delete permissions', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('lets a member with MANAGE_MESSAGES but without SEND_MESSAGES delete another member message', async () => {
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
const [author, moderator] = members as [TestAccount, TestAccount];
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'delete me');
await createPermissionOverwrite(harness, owner.token, systemChannel.id, moderator.userId, {
type: 1,
allow: Permissions.MANAGE_MESSAGES.toString(),
deny: Permissions.SEND_MESSAGES.toString(),
});
await createBuilder(harness, moderator.token)
.delete(`/channels/${systemChannel.id}/messages/${message.id}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, author.token)
.get(`/channels/${systemChannel.id}/messages/${message.id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('refuses a member without MANAGE_MESSAGES deleting another member message', async () => {
const {members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
const [author, other] = members as [TestAccount, TestAccount];
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'keep me');
await createBuilder(harness, other.token)
.delete(`/channels/${systemChannel.id}/messages/${message.id}`)
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
.execute();
});
});
+5 -7
View File
@@ -2,7 +2,6 @@
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {CachePurgeAdapterName} from '@fluxer/config/src/MasterConfig';
import type {ResolvedDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
export type APIWorkerLaneName = 'realtime' | 'unfurl' | 'lifecycle' | 'batch';
@@ -48,7 +47,6 @@ export interface APIConfig {
requestTimeoutMs: number;
maxInflightRequests: number;
ipBanExemptIps: Array<string>;
desktopGitHubRedirectCountries: ReadonlySet<string>;
cassandra: {
hosts: string;
port: number;
@@ -131,6 +129,7 @@ export interface APIConfig {
apiPublic: string;
apiClient: string;
webApp: string;
webAppOrigins: Array<string>;
gateway: string;
media: string;
staticCdn: string;
@@ -145,8 +144,6 @@ export interface APIConfig {
donationProxyKey: string;
};
hosts: {
invite: string;
gift: string;
marketing: string;
unfurlIgnored: Array<string>;
};
@@ -171,10 +168,8 @@ export interface APIConfig {
uploads: string;
reports: string;
harvests: string;
downloads: string;
};
};
s3Downloads: ResolvedDownloadsProvider;
email: {
enabled: boolean;
provider: 'smtp' | 'none';
@@ -342,6 +337,10 @@ export interface APIConfig {
};
abusePolicy: {
inboundPhoneCountryCodes: Array<string>;
phoneFlagging: {
enabled: boolean;
exemptCountryCodes: Array<string>;
};
phoneVerification: {
inboundRequiredPrefixes: Array<string>;
};
@@ -368,7 +367,6 @@ export interface APIConfig {
validateResponses: boolean;
};
presignedAttachmentUploadsEnabled: boolean;
presignedDownloadsEnabled: boolean;
presignedHarvestDownloadsEnabled: boolean;
attachmentDecayEnabled: boolean;
deletionGracePeriodHours: number;
@@ -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);
});
});
@@ -15,7 +15,12 @@ import type {GuildFolderIcon, MentionReplyPreference} from '@fluxer/constants/sr
import type {types} from 'cassandra-driver';
type Nullish<T> = T | null;
export type PushSubscriptionPlatform = 'web_push' | 'android_fcm' | 'ios_apns' | 'android_unified_push';
export type PushSubscriptionPlatform =
| 'web_push'
| 'android_fcm'
| 'ios_apns'
| 'ios_apns_voip'
| 'android_unified_push';
export interface UserRow {
user_id: UserID;
@@ -13,6 +13,7 @@ import {
type DonorMagicLinkTokenRow,
type DonorRow,
} from '@app/api/database/types/DonationTypes';
import {seconds} from 'itty-time';
export const Donors = defineTable<DonorRow, 'email'>({
name: 'donors',
@@ -43,9 +44,11 @@ export const DonorMagicLinkTokens = defineTable<DonorMagicLinkTokenRow, 'token_'
name: 'donor_magic_link_tokens',
columns: DONOR_MAGIC_LINK_TOKEN_COLUMNS,
primaryKey: ['token_'],
defaultTtlSeconds: seconds('15 minutes'),
});
export const DonorMagicLinkTokensByEmail = defineTable<DonorMagicLinkTokenByEmailRow, 'donor_email' | 'token_'>({
name: 'donor_magic_link_tokens_by_email',
columns: DONOR_MAGIC_LINK_TOKEN_BY_EMAIL_COLUMNS,
primaryKey: ['donor_email', 'token_'],
defaultTtlSeconds: seconds('15 minutes'),
});
@@ -1,201 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
import type {DesktopArch, DesktopChannel, DesktopPlatform} from '@fluxer/schema/src/domains/download/DownloadSchemas';
const DESKTOP_BUCKET_PREFIX = 'desktop';
const MIN_RELEASE_ROUTE_COUNT = 28;
const MAX_RELEASE_ROUTE_COUNT = 128;
const MIN_RELEASE_ASSET_COUNT = 24;
interface DesktopReleaseAsset {
storage_key: string;
release_asset: string;
sha256: string;
size: number;
}
interface DesktopReleaseDescriptor {
schema_version: 1;
channel: DesktopChannel;
version: string;
release_tag: string;
source_sha: string;
assets: Array<DesktopReleaseAsset>;
}
interface DesktopReleaseReadiness {
schema_version: 1;
channel: DesktopChannel;
version: string;
release_tag: string;
source_sha: string;
descriptor_sha256: string;
}
interface DesktopArtifactScope {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
}
export function parseDesktopArtifactScope(key: string): DesktopArtifactScope | null {
const segments = key.split('/');
if (segments.length !== 5 || segments[0] !== DESKTOP_BUCKET_PREFIX || segments[4].length === 0) {
return null;
}
const [, channel, plat, arch] = segments;
if (
(channel !== 'stable' && channel !== 'canary') ||
(plat !== 'win32' && plat !== 'darwin' && plat !== 'linux') ||
(arch !== 'x64' && arch !== 'arm64')
) {
return null;
}
return {channel, plat, arch};
}
function parseDesktopReleaseAsset(value: unknown): DesktopReleaseAsset | null {
if (
!isJsonRecord(value) ||
typeof value.storage_key !== 'string' ||
typeof value.release_asset !== 'string' ||
typeof value.sha256 !== 'string' ||
typeof value.size !== 'number'
) {
return null;
}
if (
!/^desktop\/(?:stable|canary)\/(?:win32|darwin|linux)\/(?:x64|arm64)\/[A-Za-z0-9._-]+$/u.test(value.storage_key) ||
!/^[A-Za-z0-9._-]+$/u.test(value.release_asset) ||
!/^[a-f0-9]{64}$/u.test(value.sha256) ||
!Number.isSafeInteger(value.size) ||
value.size <= 0
) {
return null;
}
return {
storage_key: value.storage_key,
release_asset: value.release_asset,
sha256: value.sha256,
size: value.size,
};
}
export function parseDesktopReleaseDescriptor(value: unknown): DesktopReleaseDescriptor | null {
if (
!isJsonRecord(value) ||
value.schema_version !== 1 ||
(value.channel !== 'stable' && value.channel !== 'canary') ||
typeof value.version !== 'string' ||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
typeof value.release_tag !== 'string' ||
typeof value.source_sha !== 'string' ||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
!Array.isArray(value.assets) ||
value.assets.length < MIN_RELEASE_ROUTE_COUNT ||
value.assets.length > MAX_RELEASE_ROUTE_COUNT
) {
return null;
}
const expectedTag = `fluxer-desktop-${value.channel}@${value.version}`;
const expectedStoragePrefix = `desktop/${value.channel}/`;
const expectedReleasePrefix = `${value.channel === 'canary' ? 'Fluxer-Canary' : 'Fluxer'}-${value.version}-`;
const descriptorName = `${expectedReleasePrefix}release-manifest.json`;
if (value.release_tag !== expectedTag) {
return null;
}
const storageKeys = new Set<string>();
const routeCounts = new Map<string, number>();
const releaseAssets = new Map<string, {sha256: string; size: number}>();
const releaseAssetNames = new Map<string, string>([[descriptorName.toLowerCase(), descriptorName]]);
const assets: Array<DesktopReleaseAsset> = [];
for (const rawAsset of value.assets) {
const asset = parseDesktopReleaseAsset(rawAsset);
if (
!asset?.storage_key.startsWith(expectedStoragePrefix) ||
!asset.release_asset.startsWith(expectedReleasePrefix) ||
storageKeys.has(asset.storage_key)
) {
return null;
}
storageKeys.add(asset.storage_key);
const [, , platform, arch, filename] = asset.storage_key.split('/');
const platformToken = platform === 'win32' ? 'win' : platform === 'darwin' ? 'mac' : 'linux';
const releaseFilename =
platform === 'darwin' && filename.toLowerCase() === 'releases.json' ? 'releases.json' : filename;
const expectedReleaseAsset = filename.startsWith(expectedReleasePrefix)
? filename
: `${expectedReleasePrefix}${platformToken}-${arch}-${releaseFilename}`;
if (
asset.release_asset !== expectedReleaseAsset ||
asset.release_asset.toLowerCase() === descriptorName.toLowerCase()
) {
return null;
}
const caseFoldedReleaseAsset = asset.release_asset.toLowerCase();
const existingReleaseAssetName = releaseAssetNames.get(caseFoldedReleaseAsset);
if (existingReleaseAssetName && existingReleaseAssetName !== asset.release_asset) {
return null;
}
releaseAssetNames.set(caseFoldedReleaseAsset, asset.release_asset);
const scope = `${platform}/${arch}`;
routeCounts.set(scope, (routeCounts.get(scope) ?? 0) + 1);
const existing = releaseAssets.get(asset.release_asset);
if (existing && (existing.sha256 !== asset.sha256 || existing.size !== asset.size)) {
return null;
}
releaseAssets.set(asset.release_asset, {sha256: asset.sha256, size: asset.size});
assets.push(asset);
}
if (releaseAssets.size < MIN_RELEASE_ASSET_COUNT || releaseAssets.size > MAX_RELEASE_ROUTE_COUNT) {
return null;
}
const expectedRouteCounts = new Map([
['darwin/arm64', 4],
['darwin/x64', 4],
['linux/arm64', 4],
['linux/x64', 4],
['win32/arm64', 6],
['win32/x64', 6],
]);
if (
routeCounts.size !== expectedRouteCounts.size ||
Array.from(expectedRouteCounts).some(([scope, count]) => (routeCounts.get(scope) ?? 0) < count)
) {
return null;
}
return {
schema_version: 1,
channel: value.channel,
version: value.version,
release_tag: value.release_tag,
source_sha: value.source_sha,
assets,
};
}
export function parseDesktopReleaseReadiness(value: unknown): DesktopReleaseReadiness | null {
if (
!isJsonRecord(value) ||
value.schema_version !== 1 ||
(value.channel !== 'stable' && value.channel !== 'canary') ||
typeof value.version !== 'string' ||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
typeof value.release_tag !== 'string' ||
typeof value.source_sha !== 'string' ||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
typeof value.descriptor_sha256 !== 'string' ||
!/^[a-f0-9]{64}$/u.test(value.descriptor_sha256)
) {
return null;
}
return {
schema_version: 1,
channel: value.channel,
version: value.version,
release_tag: value.release_tag,
source_sha: value.source_sha,
descriptor_sha256: value.descriptor_sha256,
};
}
+27 -322
View File
@@ -1,370 +1,75 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {Config} from '@app/api/Config';
import {resolveArtifactRoute} from '@app/api/download/DownloadRouting';
import type {DesktopChecksumFile, DownloadService, DownloadStreamResult} from '@app/api/download/DownloadService';
import {
DESKTOP_REDIRECT_PREFIX,
DOWNLOAD_PREFIX,
downloadCacheControlForKey,
UnsatisfiableRangeError,
} from '@app/api/download/DownloadService';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {DESKTOP_REDIRECT_PREFIX, DOWNLOAD_PREFIX, resolveDownloadRedirect} from '@app/api/download/DownloadRedirects';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {
DesktopChecksumRedirectParam,
DesktopRedirectParam,
DesktopTestBuildQuery,
DesktopVersionedChecksumRedirectParam,
DesktopVersionedRedirectParam,
DesktopVersionedZsyncRedirectParam,
DesktopVersionsParam,
DesktopVersionsQuery,
DesktopVersionsResponse,
DownloadChecksumResponse,
DownloadFileResponse,
VersionInfoResponse,
DesktopZsyncRedirectParam,
} from '@fluxer/schema/src/domains/download/DownloadSchemas';
import type {Context, Hono} from 'hono';
function artifactFilename(key: string, filenameOverride?: string): string {
return filenameOverride ?? key.split('/').pop() ?? 'download';
}
function artifactRedirectResponse(location: string, cacheControl = 'no-store'): Response {
function redirectToPackageOrigin(ctx: Context<HonoEnv>): Response {
const redirect = resolveDownloadRedirect(ctx.req.path);
if (!redirect) {
return ctx.text('Not Found', 404);
}
return new Response(null, {
status: 302,
headers: new Headers({
Location: location,
'Cache-Control': cacheControl,
Location: redirect.location,
'Cache-Control': redirect.cacheControl,
'Accept-Ranges': 'bytes',
}),
});
}
function setCommonArtifactHeaders(
headers: Headers,
key: string,
cacheControl: string,
filenameOverride: string | undefined,
contentType: string | null | undefined,
contentDisposition: string | null | undefined,
etag: string | null | undefined,
lastModified: Date | null | undefined,
): void {
const filename = artifactFilename(key, filenameOverride);
headers.set('Content-Type', contentType ?? 'application/octet-stream');
headers.set('Content-Disposition', contentDisposition ?? `attachment; filename="${encodeURIComponent(filename)}"`);
headers.set('Accept-Ranges', 'bytes');
headers.set('Cache-Control', cacheControl);
if (etag) {
headers.set('ETag', etag);
}
if (lastModified) {
headers.set('Last-Modified', lastModified.toUTCString());
}
}
async function headArtifactResponse(
ctx: Context<HonoEnv>,
downloadService: DownloadService,
key: string,
cacheControl: string,
filenameOverride?: string,
): Promise<Response> {
const metadata = await downloadService.getDownloadMetadata({key});
if (!metadata) {
return ctx.text('Not Found', 404);
}
const headers = new Headers();
setCommonArtifactHeaders(
headers,
key,
cacheControl,
filenameOverride,
metadata.contentType,
undefined,
metadata.etag,
metadata.lastModified,
);
headers.set('Content-Length', String(metadata.contentLength));
return new Response(null, {status: 200, headers});
}
const PRESIGNED_DOWNLOAD_TTL_SECONDS = 900;
async function streamArtifactResponse(
ctx: Context<HonoEnv>,
downloadService: DownloadService,
key: string,
cacheControl: string,
filenameOverride?: string,
): Promise<Response> {
const route = await resolveArtifactRoute({request: ctx.req.raw, downloadService, key, cacheControl});
if (route.kind === 'redirect') {
return artifactRedirectResponse(route.location, route.cacheControl);
}
if (ctx.req.method === 'HEAD') {
return headArtifactResponse(ctx, downloadService, key, route.cacheControl, filenameOverride);
}
if (downloadService.isPresignedDownloadEnabled()) {
const location = await downloadService.getPresignedDownloadRedirect({
key,
filename: artifactFilename(key, filenameOverride),
expiresIn: PRESIGNED_DOWNLOAD_TTL_SECONDS,
});
if (!location) {
return ctx.text('Not Found', 404);
}
return artifactRedirectResponse(location);
}
const range = ctx.req.header('range') ?? undefined;
let result: DownloadStreamResult | null;
try {
result = await downloadService.streamDownload({key, range});
} catch (error) {
if (error instanceof UnsatisfiableRangeError) {
const headers = new Headers();
headers.set('Accept-Ranges', 'bytes');
headers.set('Content-Range', `bytes */${error.totalSize}`);
headers.set('Cache-Control', route.cacheControl);
return new Response(null, {status: 416, headers});
}
throw error;
}
if (!result) {
return ctx.text('Not Found', 404);
}
const headers = new Headers();
setCommonArtifactHeaders(
headers,
key,
route.cacheControl,
filenameOverride,
result.contentType,
result.contentDisposition,
result.etag,
result.lastModified,
);
headers.set('Content-Length', String(result.contentLength));
if (result.contentRange) {
headers.set('Content-Range', result.contentRange);
}
const body = Readable.toWeb(result.body) as ReadableStream;
return new Response(body, {status: result.contentRange ? 206 : 200, headers});
}
function checksumFileResponse(ctx: Context<HonoEnv>, checksum: DesktopChecksumFile, cacheControl: string): Response {
const headers = new Headers();
const body = ctx.req.method === 'HEAD' ? null : checksum.body;
headers.set('Content-Type', 'text/plain; charset=utf-8');
headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(`${checksum.filename}.sha256`)}"`);
headers.set('Cache-Control', cacheControl);
headers.set('Content-Length', String(new TextEncoder().encode(checksum.body).byteLength));
return new Response(body, {status: 200, headers});
}
export function DownloadController(routes: Hono<HonoEnv>): void {
routes.get(
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest`,
Validator('param', DesktopVersionsParam),
Validator('query', DesktopTestBuildQuery),
OpenAPI({
operationId: 'get_latest_desktop_version',
summary: 'Get latest desktop version',
responseSchema: VersionInfoResponse,
statusCode: 200,
security: [],
tags: ['Downloads'],
description:
'Returns metadata for the latest desktop version including download URLs and SHA-256 checksums for all available formats. Pass ?test=1 to resolve against unreleased test builds.',
}),
async (ctx) => {
const {channel, plat, arch} = ctx.req.valid('param');
const {test} = ctx.req.valid('query');
const result = await ctx.get('downloadService').getLatestDesktopVersion({
channel,
plat,
arch,
baseUrl: Config.endpoints.apiClient,
test,
});
if (!result) {
return ctx.text('Not Found', 404);
}
return ctx.json(result, 200, {
'Cache-Control': 'public, max-age=300',
});
},
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.on(
['GET', 'HEAD'],
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest/:format{[a-z_]+\\.sha256}`,
Validator('param', DesktopChecksumRedirectParam),
Validator('query', DesktopTestBuildQuery),
OpenAPI({
operationId: 'download_latest_desktop_version_checksum',
summary: 'Download latest desktop version checksum',
responseSchema: DownloadChecksumResponse,
responseContentType: 'text/plain',
statusCode: 200,
security: [],
tags: ['Downloads'],
description:
'Returns a plain text SHA-256 checksum file for the latest available desktop application version. The format path segment must end in .sha256, for example appimage.sha256.',
}),
async (ctx) => {
const {channel, plat, arch, format} = ctx.req.valid('param');
const {test} = ctx.req.valid('query');
const checksum = await ctx
.get('downloadService')
.resolveLatestDesktopChecksumFile({channel, plat, arch, format, test});
if (!checksum) {
return ctx.text('Not Found', 404);
}
return checksumFileResponse(ctx, checksum, 'no-store');
},
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.on(
['GET', 'HEAD'],
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest/:format{[a-z_]+\\.zsync}`,
Validator('param', DesktopZsyncRedirectParam),
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.on(
['GET', 'HEAD'],
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest/:format`,
Validator('param', DesktopRedirectParam),
Validator('query', DesktopTestBuildQuery),
OpenAPI({
operationId: 'download_latest_desktop_version',
summary: 'Download latest desktop version',
responseSchema: DownloadFileResponse,
responseContentType: '*/*',
statusCode: [200, 206, 302],
bodylessStatusCodes: [302],
security: [],
tags: ['Downloads'],
description:
'Streams the latest available desktop application version for the specified platform and architecture. Pass ?test=1 to download an unreleased test build.',
}),
async (ctx) => {
const {channel, plat, arch, format} = ctx.req.valid('param');
const {test} = ctx.req.valid('query');
const downloadService = ctx.get('downloadService');
const key = await downloadService.resolveLatestDesktopKey({channel, plat, arch, format, test});
if (!key) {
return ctx.text('Not Found', 404);
}
return streamArtifactResponse(ctx, downloadService, key, 'no-store');
},
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.get(
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/versions`,
Validator('param', DesktopVersionsParam),
Validator('query', DesktopVersionsQuery),
OpenAPI({
operationId: 'list_desktop_versions',
summary: 'List desktop versions',
responseSchema: DesktopVersionsResponse,
statusCode: 200,
security: [],
tags: ['Downloads'],
description: 'Lists available desktop versions with pagination for the specified platform and architecture.',
}),
async (ctx) => {
const {channel, plat, arch} = ctx.req.valid('param');
const {limit, before, after, test} = ctx.req.valid('query');
const {versions, hasMore} = await ctx.get('downloadService').listDesktopVersions({
channel,
plat,
arch,
limit,
before,
after,
baseUrl: Config.endpoints.apiClient,
test,
});
return ctx.json({versions, has_more: hasMore}, 200, {
'Cache-Control': 'public, max-age=300',
});
},
routes.on(
['GET', 'HEAD'],
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:version/:format{[a-z_]+\\.zsync}`,
Validator('param', DesktopVersionedZsyncRedirectParam),
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.on(
['GET', 'HEAD'],
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:version/:format{[a-z_]+\\.sha256}`,
Validator('param', DesktopVersionedChecksumRedirectParam),
Validator('query', DesktopTestBuildQuery),
OpenAPI({
operationId: 'download_desktop_version_checksum',
summary: 'Download desktop version checksum',
responseSchema: DownloadChecksumResponse,
responseContentType: 'text/plain',
statusCode: 200,
security: [],
tags: ['Downloads'],
description:
'Returns a plain text SHA-256 checksum file for a specific desktop application version. The format path segment must end in .sha256, for example appimage.sha256.',
}),
async (ctx) => {
const {channel, plat, arch, version, format} = ctx.req.valid('param');
const {test} = ctx.req.valid('query');
const checksum = await ctx
.get('downloadService')
.resolveVersionedDesktopChecksumFile({channel, plat, arch, version, format, test});
if (!checksum) {
return ctx.text('Not Found', 404);
}
return checksumFileResponse(ctx, checksum, downloadCacheControlForKey(checksum.key));
},
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.on(
['GET', 'HEAD'],
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:version/:format`,
Validator('param', DesktopVersionedRedirectParam),
Validator('query', DesktopTestBuildQuery),
OpenAPI({
operationId: 'download_desktop_version',
summary: 'Download desktop version',
responseSchema: DownloadFileResponse,
responseContentType: '*/*',
statusCode: [200, 206, 302],
bodylessStatusCodes: [302],
security: [],
tags: ['Downloads'],
description:
'Streams a specific desktop application version for the given platform and architecture. Pass ?test=1 to download an unreleased test build.',
}),
async (ctx) => {
const {channel, plat, arch, version, format} = ctx.req.valid('param');
const {test} = ctx.req.valid('query');
const downloadService = ctx.get('downloadService');
const key = await downloadService.resolveVersionedDesktopKey({channel, plat, arch, version, format, test});
if (!key) {
return ctx.text('Not Found', 404);
}
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
},
);
routes.on(
['GET', 'HEAD'],
`${DOWNLOAD_PREFIX}/*`,
Validator('query', DesktopTestBuildQuery),
OpenAPI({
operationId: 'download_file',
summary: 'Download file',
responseSchema: DownloadFileResponse,
responseContentType: '*/*',
statusCode: [200, 206, 302],
bodylessStatusCodes: [302],
security: [],
tags: ['Downloads'],
description:
'Streams the requested file from storage. Pass ?test=1 on a desktop/ path to resolve against the desktop-test/ bucket prefix instead.',
}),
async (ctx) => {
const {test} = ctx.req.valid('query');
const downloadService = ctx.get('downloadService');
const key = await downloadService.resolveDownloadKey({path: ctx.req.path, test});
if (!key) {
return ctx.text('Not Found', 404);
}
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
},
async (ctx) => redirectToPackageOrigin(ctx),
);
routes.on(['GET', 'HEAD'], `${DOWNLOAD_PREFIX}/*`, async (ctx) => redirectToPackageOrigin(ctx));
}

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