Compare commits

...
Author SHA1 Message Date
Hampus dcc3273889 feat(app): ask everyone to review who can message them (#3323) 2026-10-08 20:47:44 +02:00
Hampus e9a4f33a7e feat(api): track privacy setup and open community DMs by default (#3322) 2026-10-08 20:47:22 +02:00
Hampus ae820ba4ea chore(i18n): refresh catalog references (#3320) 2026-10-08 20:34:03 +02:00
Hampus 94f1239050 fix(premium): reflect limit config flags in plan comparisons (#3319) 2026-10-08 20:33:20 +02:00
Hampus acc1392a53 fix(ui): reopen a popout clicked while it is closing (#3318) 2026-10-08 20:33:04 +02:00
Hampus a6a9789091 fix(ui): stop clicks passing through the user area footer (#3317) 2026-10-08 20:32:49 +02:00
Hampus fdb7410976 fix(threads): light up joined threads in muted communities (#3316) 2026-10-08 20:32:33 +02:00
Hampus b146b8ee33 fix(desktop): trust locally added CAs in Chromium on Linux (#3315) 2026-10-08 20:21:04 +02:00
Hampus 4b1afc953f fix(app): redraw the favicon badge when branding changes (#3314) 2026-10-08 20:20:49 +02:00
Hampus 4ecbe9603c fix(api): store branding images as media references (#3313) 2026-10-08 20:20:34 +02:00
Hampus 847d449882 chore(i18n): refresh catalog references (#3312) 2026-10-08 20:06:41 +02:00
Hampus bef20cba85 fix(links): warn on unhandled middle clicks and desktop links (#3311) 2026-10-08 20:05:54 +02:00
Jiralite ebf91ccc76 fix(links): warn on embed link middle clicks (#3305) 2026-10-08 20:05:27 +02:00
Hampus abfaff16c2 fix(guild): fit text icon initials to the icon by measured width (#3310) 2026-10-08 20:05:09 +02:00
Hampus 89fa895a0a fix(forum): keep closed posts visible without a reload (#3309) 2026-10-08 20:04:53 +02:00
Hampus 334fef52e1 fix(desktop): keep slow connects racing instead of aborting them (#3308) 2026-10-08 20:04:39 +02:00
Hampus aa42bf13ce fix(threads): announce threads above the latest five messages (#3307) 2026-10-08 20:04:23 +02:00
Hampus c486c2d3d0 revert(admin): restore user type toggles (#3306) 2026-10-08 20:04:01 +02:00
Hampus 0ec1cee99e test(gateway): keep the recheck timer out of the eunit process (#3304) 2026-10-08 18:14:47 +02:00
Hampus ccbe4857a1 docs(auth): document the desktop handoff deep link and deny (#3303) 2026-10-08 18:00:17 +02:00
Hampus 7f8b0afedb chore(i18n): refresh catalog references (#3302) 2026-10-08 17:58:50 +02:00
Jiralite e94b7d7ad8 fix(threads): add a debug thread button (#3276) 2026-10-08 17:57:47 +02:00
Hampus 27baa38fd1 fix(threads): open the thread menu on browser right-click (#3301) 2026-10-08 17:45:33 +02:00
Hampus 4af10dd3e3 fix(threads): keep header actions clear of the channel name (#3300) 2026-10-08 17:44:48 +02:00
Hampus 269d33cab2 fix(threads): confirm before leaving a private thread (#3299) 2026-10-08 17:44:00 +02:00
Hampus 170e12595b fix(threads): let the thread members popout scroll (#3298) 2026-10-08 17:42:56 +02:00
Hampus b82bbaa2dd fix(ui): drop the switch label tab stop with no focus ring (#3297) 2026-10-08 17:42:11 +02:00
Hampus b8f3dbddbf fix(threads): keep Tab and focus inside the thread create pane (#3296) 2026-10-08 17:41:14 +02:00
Hampus 663572fd10 fix(forum): keep attachments on forum and media posts (#3295) 2026-10-08 17:40:29 +02:00
Hampus 5033cf7203 fix(app): follow theme library changes made in other windows (#3294) 2026-10-08 17:39:34 +02:00
Hampus 0fcab2f8f0 fix(app): hold the switched view until its route commits (#3293) 2026-10-08 17:38:36 +02:00
Hampus c3d790f664 fix(app): refocus the editor before text context menu actions (#3292) 2026-10-08 17:37:40 +02:00
Hampus 0906a85d6f fix(app): keep mature content consent across account switches (#3291) 2026-10-08 17:36:55 +02:00
Hampus 4da8dec4ac fix(app): ignore leftover mentions in unseen channels (#3290) 2026-10-08 17:35:59 +02:00
Hampus d6e3254a16 feat(app): show instance branding in switchers and pickers (#3289) 2026-10-08 17:34:56 +02:00
Hampus 6dc698ff5e fix(app): follow instance branding in tab title and favicon (#3288) 2026-10-08 17:33:28 +02:00
Hampus 39e48458da fix(app): keep a global define from breaking bundled modules (#3287) 2026-10-08 17:32:40 +02:00
Hampus c5453f83e8 feat(desktop): sign in with the browser without typing a code (#3286) 2026-10-08 17:31:45 +02:00
Hampus 510cc1b916 fix(desktop): show the text context menu on password fields (#3285) 2026-10-08 17:30:11 +02:00
Hampus e7859fefb1 fix(desktop): leave full screen before hiding on macOS close (#3284) 2026-10-08 17:28:58 +02:00
Hampus 46a356cc40 fix(media): keep self-host media loading after restarts (#3283) 2026-10-08 17:28:18 +02:00
Hampus bd56174870 fix(desktop): frame streamed request bodies for every method (#3282) 2026-10-08 17:26:41 +02:00
Hampus 965bb5f634 fix(desktop): trust the system CA store in main requests (#3281) 2026-10-08 17:26:12 +02:00
Hampus 7937d5f802 fix(desktop): update from the splash only after the user clicks (#3280) 2026-10-08 17:25:34 +02:00
Hampus f1373a63c3 feat(ci): publish renderer modules without a shell build (#3279) 2026-10-08 17:23:59 +02:00
Hampus e0d7625c39 feat(api): hand desktop sign-in back with a deep link grant (#3278) 2026-10-08 17:23:28 +02:00
Hampus f8505d19f7 feat(threads): ship threads and the Plutonium page to everyone (#3277) 2026-10-08 16:30:39 +02:00
Hampus 1515a8487f fix(desktop): fall back across resolved addresses on connect (#3275) 2026-10-08 05:08:00 +02:00
Hampus 7a4aa42d4b feat(premium): native Nordic prices and subscribing during grace (#3274) 2026-10-08 04:21:54 +02:00
Hampus 0ef1cd14c3 feat(desktop): show download progress and diagnostics on splash (#3273) 2026-10-08 03:06:06 +02:00
Hampus 795e190bda fix(desktop): let the Theme Studio popout resolve its runtime (#3272) 2026-10-08 03:05:37 +02:00
Hampus d00389ab30 fix(desktop): reach split-horizon instances on their LAN IP (#3271) 2026-10-08 03:05:15 +02:00
Hampus 3071bc7525 fix(desktop): honour the system proxy in main-process requests (#3270) 2026-10-08 03:04:55 +02:00
Hampus 6de6be2358 fix(threads): open the thread menu on thread chip right-click (#3269) 2026-10-08 03:04:35 +02:00
Hampus c51c222c47 fix(voice): stop stale mute echoes from toggling mute (#3268) 2026-10-08 03:04:10 +02:00
Hampus e218ba21de fix(app): load saved account avatars from their own instance (#3267) 2026-10-08 03:03:47 +02:00
Hampus de358c0def fix(app): let action toolbar overrides win over its defaults (#3266) 2026-10-08 03:03:26 +02:00
Hampus 4b88d64b2d fix(app): show the official badge and a visible account menu (#3265) 2026-10-08 03:03:05 +02:00
Hampus ee1c861eb2 perf(desktop): cache display media and retain shown images (#3264) 2026-10-08 03:02:41 +02:00
Hampus 6a24ac3f4e fix(desktop): fetch media bytes via the local resource proxy (#3263) 2026-10-08 03:02:15 +02:00
Hampus 8b312c610e fix(app): tag only desktop proxy uploads with a local upload id (#3262) 2026-10-08 00:40:51 +02:00
Hampus 2e3e3a1536 fix(desktop): fix manual update wording and retry Velopack (#3261) 2026-10-08 00:04:58 +02:00
Hampus 5c63d81ffd fix(app): show the setup wizard on new instances, not sign-in (#3260) 2026-10-07 23:28:04 +02:00
Hampus c402c52a8a fix(app): read instance config safely in startup skeletons (#3259) 2026-10-07 23:27:59 +02:00
Hampus 317fedeef9 fix(ci): resolve a bare drive WORKDIR to the drive root (#3258) 2026-10-07 21:44:18 +02:00
Hampus 002502a7fa feat(admin): one threads toggle, hide hosted-only rollouts (#3257) 2026-10-07 21:32:45 +02:00
Hampus 1997850d55 fix(threads): let administrators grant thread permissions (#3256) 2026-10-07 21:32:39 +02:00
Hampus d346eb0e88 fix(desktop): generate build channel in tests, bump yoke-derive (#3255) 2026-10-07 19:57:20 +02:00
Hampus 7c5fa2180a feat(desktop): bundled renderer, modules and instance accounts (#3254) 2026-10-07 16:48:07 +02:00
1614 changed files with 245096 additions and 100719 deletions

No files matched your search

+2
View File
@@ -50,6 +50,8 @@
/app-dist-output/
/artifacts/
/desktop-shared-assets/
/desktop-modules/
/s3_payload/
/upload_staging/
@@ -154,6 +154,7 @@ jobs:
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
FLUXER_SELF_HOSTED=true
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 }}
+1
View File
@@ -200,6 +200,7 @@ jobs:
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
@@ -0,0 +1,350 @@
name: build desktop modules
on:
workflow_dispatch:
inputs:
channel:
description: Release channel to ship renderer modules on. The live shell on that channel stays as it is.
required: true
type: choice
options:
- canary
- stable
default: canary
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
default: ""
type: string
allow_shell_drift:
description: Publish even though shell sources changed since the live shell was built. Only when the renderer does not depend on those changes.
required: false
default: false
type: boolean
permissions:
contents: write
id-token: write
actions: read
concurrency:
group: desktop-modules-${{ inputs.channel }}
cancel-in-progress: false
env:
CHANNEL: ${{ inputs.channel }}
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
jobs:
meta:
name: Resolve build metadata
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 25
permissions:
contents: read
outputs:
version: ${{ steps.meta.outputs.version }}
pub_date: ${{ steps.meta.outputs.pub_date }}
channel: ${{ steps.meta.outputs.channel }}
build_channel: ${{ steps.meta.outputs.build_channel }}
source_sha: ${{ steps.meta.outputs.source_sha }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: main
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Set metadata
id: meta
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
FLUXER_BUILD_VERSION: ${{ inputs.build_version }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step set_metadata
--channel "${{ inputs.channel }}"
shared_assets:
name: Build shared renderer assets
needs:
- meta
runs-on: ubuntu-24.04
environment: desktop-releases
timeout-minutes: 60
permissions:
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.channel }}
BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }}
RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
PUBLIC_RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
steps:
- name: Checkout CI helpers
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: _ci
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: source
- name: Set up Rust toolchain (renderer wasm)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Set workdir (Unix)
env:
SUBST_TARGET: ${{ github.workspace }}/source
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_workdir_unix
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 26
- name: Set up pnpm
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step setup_pnpm
- name: Resolve pnpm store path (Unix)
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step resolve_pnpm_store_unix
- name: Cache pnpm store
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-shared-renderer-pnpm-store-${{ hashFiles('source/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-shared-renderer-pnpm-store-
- name: Cache cargo registry
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-shared-renderer-cargo-registry-${{ hashFiles('source/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-shared-renderer-cargo-registry-
- name: Install dependencies
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step install_dependencies
- name: Update version
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step update_version
- name: Set build channel
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_build_channel
- name: Build shared renderer assets
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_shared_assets
- name: Prepare shared renderer artifact
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step prepare_shared_assets
- name: Upload shared renderer artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: source/desktop-shared-assets
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Split renderer into desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step split_modules
- name: Pack desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step pack_modules
- name: Upload desktop module packages
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: |
source/desktop-modules/classification.json
source/desktop-modules/*/module.json
source/desktop-modules/*/package.br
source/desktop-modules/*/package.br.sha256
if-no-files-found: error
retention-days: 1
compression-level: 0
assemble:
name: Assemble the modules-only release
if: ${{ !cancelled() && needs.shared_assets.result == 'success' }}
needs:
- meta
- shared_assets
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 60
permissions:
actions: read
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
fetch-depth: 0
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: Download desktop module packages
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: desktop-modules
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Check the live shell runs these modules
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
ALLOW_SHELL_DRIFT: ${{ inputs.allow_shell_drift }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step check_shell_drift
- name: Build the modules-only manifests
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_modules_only_manifest
- name: Prepare GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step prepare_modules_release_assets
- name: Upload GitHub release assets
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: fluxer-desktop-modules-release-assets
path: release_assets
if-no-files-found: error
retention-days: 1
compression-level: 0
publish_release:
name: Publish GitHub modules-only release
if: ${{ !cancelled() && needs.assemble.result == 'success' }}
needs:
- meta
- assemble
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 60
permissions:
contents: write
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: Download GitHub release assets
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: fluxer-desktop-modules-release-assets
path: release_assets
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub modules-only release
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
run: |
set -euo pipefail
release_args=(
release publish
--component "fluxer-desktop-${CHANNEL}"
--build-version "${VERSION}"
--source-sha "${SOURCE_SHA}"
--previous-sha "${RELEASE_BASELINE_SHA}"
--asset-dir release_assets
)
if [[ "${CHANNEL}" == "canary" ]]; then
release_args+=(--prerelease)
fi
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
+164
View File
@@ -103,11 +103,150 @@ jobs:
--step set_matrix
--skip-targets "${{ inputs.skip_targets }}"
shared_assets:
name: Build shared renderer assets
needs:
- meta
runs-on: ubuntu-24.04
environment: desktop-releases
timeout-minutes: 60
permissions:
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.channel }}
BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }}
RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
PUBLIC_RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
steps:
- name: Checkout CI helpers
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: _ci
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: source
- name: Set up Rust toolchain (renderer wasm)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Set workdir (Unix)
env:
SUBST_TARGET: ${{ github.workspace }}/source
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_workdir_unix
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 26
- name: Set up pnpm
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step setup_pnpm
- name: Resolve pnpm store path (Unix)
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step resolve_pnpm_store_unix
- name: Cache pnpm store
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-shared-renderer-pnpm-store-${{ hashFiles('source/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-shared-renderer-pnpm-store-
- name: Cache cargo registry
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-shared-renderer-cargo-registry-${{ hashFiles('source/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-shared-renderer-cargo-registry-
- name: Install dependencies
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step install_dependencies
- name: Update version
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step update_version
- name: Set build channel
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_build_channel
- name: Build shared renderer assets
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_shared_assets
- name: Prepare shared renderer artifact
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step prepare_shared_assets
- name: Upload shared renderer artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: source/desktop-shared-assets
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Split renderer into desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step split_modules
- name: Pack desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step pack_modules
- name: Upload desktop module packages
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: |
source/desktop-modules/classification.json
source/desktop-modules/*/module.json
source/desktop-modules/*/package.br
source/desktop-modules/*/package.br.sha256
if-no-files-found: error
retention-days: 1
compression-level: 0
build:
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
needs:
- meta
- matrix
- shared_assets
runs-on: ${{ matrix.os }}
environment: desktop-releases
timeout-minutes: 180
@@ -130,6 +269,7 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
DESKTOP_PLATFORM: ${{ matrix.platform }}
DESKTOP_ARCH: ${{ matrix.arch }}
FLUXER_MODULES: "1"
PLATFORM: ${{ matrix.platform }}
ARCH: ${{ matrix.arch }}
ELECTRON_ARCH: ${{ matrix.electron_arch }}
@@ -276,6 +416,17 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_build_channel
- name: Download shared renderer artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: source/desktop-shared-assets
- name: Restore shared renderer assets
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step restore_shared_assets
- name: Build Electron main process
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
env:
@@ -505,11 +656,13 @@ jobs:
if: ${{ !cancelled() && needs.build.result == 'success' }}
needs:
- meta
- shared_assets
- build
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 180
permissions:
actions: read
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
@@ -547,6 +700,17 @@ jobs:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_payload
- name: Download desktop module packages
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: desktop-modules
- name: Build desktop module manifest
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_module_manifest
- name: Prepare GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
+2
View File
@@ -44,6 +44,8 @@
/app-dist-output/
/artifacts/
/desktop-shared-assets/
/desktop-modules/
/s3_payload/
/upload_staging/
Generated
+33 -1
View File
@@ -1710,6 +1710,16 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
@@ -1741,6 +1751,7 @@ dependencies = [
"aws-config",
"aws-sdk-s3",
"base64 0.23.1",
"brotli",
"bytes",
"chrono",
"clap",
@@ -1750,6 +1761,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
"tar",
"tempfile",
"tokio",
"walkdir",
@@ -2042,7 +2054,6 @@ dependencies = [
"hex",
"rand 0.10.2",
"reqwest",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
@@ -4790,6 +4801,17 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -5818,6 +5840,16 @@ dependencies = [
"tls_codec",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xmlparser"
version = "0.13.6"
+1
View File
@@ -671,6 +671,7 @@ services:
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
+153 -181
View File
@@ -5491,7 +5491,7 @@
"Guild member search index",
"Favourite meme search index",
"Discovery guild search index",
"Thread search index (channel_threads experiment)"
"Thread search index"
],
"enum": [
"guilds",
@@ -6266,6 +6266,72 @@
}
}
},
"/admin/users/{user_id}/bot-status": {
"put": {
"operationId": "set_admin_user_bot_status",
"summary": "Set user bot status",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"description": "The ID of the user"
}
],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserBotStatusRequest"}}}
}
}
},
"/admin/users/{user_id}/change-log": {
"get": {
"operationId": "list_admin_user_change_log",
@@ -7967,6 +8033,72 @@
]
}
},
"/admin/users/{user_id}/system-status": {
"put": {
"operationId": "set_admin_user_system_status",
"summary": "Set user system status",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"description": "The ID of the user"
}
],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserSystemStatusRequest"}}}
}
}
},
"/admin/users/{user_id}/traits": {
"put": {
"operationId": "set_admin_user_traits",
@@ -9413,6 +9545,13 @@
},
"required": ["traits"]
},
"AdminUserSystemStatusRequest": {
"type": "object",
"properties": {
"system": {"type": "boolean", "description": "Whether the user should be marked as a system user"}
},
"required": ["system"]
},
"AdminStorePurchaseListResponse": {
"type": "object",
"properties": {
@@ -9877,6 +10016,11 @@
"required": ["entries", "next_page_token"],
"additionalProperties": false
},
"AdminUserBotStatusRequest": {
"type": "object",
"properties": {"bot": {"type": "boolean", "description": "Whether the user should be marked as a bot"}},
"required": ["bot"]
},
"AdminUserBanNoteRequest": {
"type": "object",
"properties": {
@@ -9991,7 +10135,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 105,
"maxItems": 106,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions to assign"
@@ -10701,9 +10845,7 @@
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
"plutonium_page": {"$ref": "#/components/schemas/PlutoniumPageConfigResponse"},
"captcha": {"$ref": "#/components/schemas/CaptchaConfigResponse"},
"channel_threads": {"$ref": "#/components/schemas/ChannelThreadsConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
"type": "object",
@@ -11116,9 +11258,7 @@
"gateway_rollout",
"push_relay",
"domain_migration",
"plutonium_page",
"captcha",
"channel_threads",
"experiment_delivery",
"registration",
"self_hosted",
@@ -11256,15 +11396,7 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
},
"plutonium_page": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/PlutoniumPageConfigUpdateRequest"}]
},
"captcha": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/CaptchaConfigUpdateRequest"}]},
"channel_threads": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ChannelThreadsConfigUpdateRequest"}]
},
"experiment_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
@@ -12904,7 +13036,7 @@
},
"acls": {
"description": "Replacement list of access control permissions for the key",
"maxItems": 105,
"maxItems": 106,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"}
}
@@ -12922,7 +13054,7 @@
"type": "string"
},
"acls": {
"maxItems": 105,
"maxItems": 106,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12952,7 +13084,7 @@
"maximum": 365
},
"acls": {
"maxItems": 105,
"maxItems": 106,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions for the key"
@@ -12973,7 +13105,7 @@
"type": "string"
},
"acls": {
"maxItems": 105,
"maxItems": 106,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12986,7 +13118,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 105,
"maxItems": 106,
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 64},
"description": "Every admin access control permission the admin API recognises"
@@ -13088,6 +13220,7 @@
"user:view:email",
"user:view:ip",
"user:temp_ban",
"user:update:bot_status",
"user:update:dob",
"user:update:email",
"user:update:flags",
@@ -15752,36 +15885,6 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"ChannelThreadsConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"guild_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"guild_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"enabled_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"disabled_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"user_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"user_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}$"}
}
}
},
"CaptchaConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15790,30 +15893,6 @@
"max_counter": {"type": "integer", "minimum": 100, "maximum": 20000}
}
},
"PlutoniumPageConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
}
},
"DomainMigrationConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15960,68 +16039,6 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"additionalProperties": false
},
"ChannelThreadsConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"ever_enabled": {"default": false, "type": "boolean"},
"guild_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"guild_salt": {
"default": "channel-threads-guild-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"enabled_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"disabled_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"user_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"user_salt": {
"default": "channel-threads-user-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}$"}
}
},
"required": [
"enabled",
"config_version",
"ever_enabled",
"guild_basis_points",
"guild_salt",
"enabled_guild_ids",
"disabled_guild_ids",
"user_basis_points",
"user_salt",
"included_user_ids",
"excluded_user_ids"
],
"additionalProperties": false
},
"CaptchaConfigResponse": {
"type": "object",
"properties": {
@@ -16032,51 +16049,6 @@
"required": ["enabled", "cost", "max_counter"],
"additionalProperties": false
},
"PlutoniumPageConfigResponse": {
"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": "plutonium-page-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids"
],
"additionalProperties": false
},
"DomainMigrationConfigResponse": {
"type": "object",
"properties": {
@@ -16296,7 +16268,7 @@
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
"type": "string"
},
"acls": {"maxItems": 105, "type": "array", "items": {"type": "string"}},
"acls": {"maxItems": 106, "type": "array", "items": {"type": "string"}},
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
"has_totp": {"type": "boolean"},
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
+2
View File
@@ -90,6 +90,7 @@ pub const USER_VIEW_DOB: &str = "user:view:dob";
pub const USER_VIEW_EMAIL: &str = "user:view:email";
pub const USER_VIEW_IP: &str = "user:view:ip";
pub const USER_TEMP_BAN: &str = "user:temp_ban";
pub const USER_UPDATE_BOT_STATUS: &str = "user:update:bot_status";
pub const USER_UPDATE_DOB: &str = "user:update:dob";
pub const USER_UPDATE_EMAIL: &str = "user:update:email";
pub const USER_UPDATE_FLAGS: &str = "user:update:flags";
@@ -196,6 +197,7 @@ pub const ALL_ACLS: &[&str] = &[
USER_VIEW_EMAIL,
USER_VIEW_IP,
USER_TEMP_BAN,
USER_UPDATE_BOT_STATUS,
USER_UPDATE_DOB,
USER_UPDATE_EMAIL,
USER_UPDATE_FLAGS,
@@ -27,12 +27,8 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub plutonium_page: PlutoniumPageConfigResponse,
#[serde(default)]
pub captcha: CaptchaConfigResponse,
#[serde(default)]
pub channel_threads: ChannelThreadsConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
#[serde(default)]
pub billing: InstanceBillingResponse,
@@ -509,11 +505,8 @@ impl VoiceE2eeScope {
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const PLUTONIUM_PAGE_DEFAULT_SALT: &str = "plutonium-page-v1";
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
pub const CHANNEL_THREADS_DEFAULT_GUILD_SALT: &str = "channel-threads-guild-v1";
pub const CHANNEL_THREADS_DEFAULT_USER_SALT: &str = "channel-threads-user-v1";
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
@@ -583,52 +576,6 @@ pub struct DomainMigrationConfigUpdateRequest {
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct PlutoniumPageConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
}
impl Default for PlutoniumPageConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PLUTONIUM_PAGE_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PlutoniumPageConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct CaptchaConfigResponse {
@@ -657,62 +604,6 @@ pub struct CaptchaConfigUpdateRequest {
pub max_counter: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ChannelThreadsConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub ever_enabled: bool,
pub guild_basis_points: u32,
pub guild_salt: String,
pub enabled_guild_ids: Vec<String>,
pub disabled_guild_ids: Vec<String>,
pub user_basis_points: u32,
pub user_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
}
impl Default for ChannelThreadsConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
ever_enabled: false,
guild_basis_points: 0,
guild_salt: CHANNEL_THREADS_DEFAULT_GUILD_SALT.to_owned(),
enabled_guild_ids: Vec::new(),
disabled_guild_ids: Vec::new(),
user_basis_points: 0,
user_salt: CHANNEL_THREADS_DEFAULT_USER_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ChannelThreadsConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_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 ExperimentDeliveryConfigResponse {
@@ -831,12 +722,8 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plutonium_page: Option<PlutoniumPageConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captcha: Option<CaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_threads: Option<ChannelThreadsConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub billing: Option<InstanceBillingUpdateRequest>,
@@ -1137,24 +1024,17 @@ mod tests {
.expect("admin schema");
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
.expect("default domain migration config");
let plutonium_page = serde_json::from_value::<PlutoniumPageConfigResponse>(json!({}))
.expect("default plutonium page config");
let captcha = serde_json::from_value::<CaptchaConfigResponse>(json!({}))
.expect("default captcha config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let domain_migration =
serde_json::to_value(domain_migration).expect("serializable domain migration config");
let plutonium_page =
serde_json::to_value(plutonium_page).expect("serializable plutonium page config");
let captcha = serde_json::to_value(captcha).expect("serializable captcha config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
serde_json::from_value(domain_migration.clone())
.expect("generated domain migration config contract");
let generated_plutonium_page: generated_types::PlutoniumPageConfigResponse =
serde_json::from_value(plutonium_page.clone())
.expect("generated plutonium page config contract");
let generated_captcha: generated_types::CaptchaConfigResponse =
serde_json::from_value(captcha.clone()).expect("generated captcha config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
@@ -1164,11 +1044,6 @@ mod tests {
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_plutonium_page)
.expect("serializable generated plutonium page config"),
plutonium_page
);
assert_eq!(
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
captcha
@@ -1180,7 +1055,6 @@ mod tests {
);
for (name, value) in [
("DomainMigrationConfigResponse", domain_migration),
("PlutoniumPageConfigResponse", plutonium_page),
("CaptchaConfigResponse", captcha),
("ExperimentDeliveryConfigResponse", delivery),
] {
@@ -1215,25 +1089,4 @@ mod tests {
json!({})
);
}
#[test]
fn plutonium_page_update_preserves_empty_lists_and_omitted_fields() {
let update = PlutoniumPageConfigUpdateRequest {
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::PlutoniumPageConfigUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(PlutoniumPageConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
}
+22
View File
@@ -306,6 +306,28 @@ impl AdminApiClient {
Ok(resp.user)
}
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
let response = self
.generated()
.set_admin_user_bot_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
let response = self
.generated()
.set_admin_user_system_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn change_username(
&self,
user_id: &str,
-9
View File
@@ -106,20 +106,11 @@ pub async fn render(
.map(|response| response.threads)
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild threads"))
.unwrap_or_default();
let threads_enabled = client
.get_instance_config()
.await
.map(|instance| instance.channel_threads.enabled)
.map_err(
|error| tracing::warn!(%error, "admin API request failed: get instance config"),
)
.unwrap_or(false);
Some(tabs::threads::threads_tab(
config,
&guild,
&threads,
acl::has_permission(admin_acls, acl::MESSAGE_DELETE_ALL),
threads_enabled,
csrf_token,
))
}
+13 -275
View File
@@ -7,20 +7,19 @@ use crate::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
CaptchaConfigUpdateRequest, ChannelThreadsConfigUpdateRequest,
CreateRegistrationUrlRequest, DomainMigrationConfigUpdateRequest,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters,
PlutoniumPageConfigUpdateRequest, PremiumMode, PushRelayConfigUpdateRequest,
RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, RegistrationMode,
SsoConfigUpdateRequest, VoiceE2eeScope,
},
},
config::AdminConfig,
@@ -221,18 +220,10 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_plutonium_page" => match build_plutonium_page_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_captcha" => match build_captcha_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_channel_threads" => match build_channel_threads_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
@@ -618,41 +609,6 @@ fn build_domain_migration_update(
})
}
fn build_plutonium_page_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
plutonium_page: Some(PlutoniumPageConfigUpdateRequest {
enabled: Some(form.bool_value("plutonium_page_enabled")),
rollout_basis_points: parse_form_number(
form,
"plutonium_page_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "plutonium_page_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("plutonium_page_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
captcha: Some(CaptchaConfigUpdateRequest {
@@ -676,53 +632,6 @@ fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateReq
})
}
fn build_channel_threads_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
channel_threads: Some(ChannelThreadsConfigUpdateRequest {
enabled: Some(form.bool_value("channel_threads_enabled")),
guild_basis_points: parse_form_number(
form,
"channel_threads_guild_basis_points",
"Guild rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
guild_salt: parse_experiment_rollout_salt(form, "channel_threads_guild_salt")?,
enabled_guild_ids: Some(parse_experiment_user_ids(
form.first("channel_threads_enabled_guild_ids")
.unwrap_or_default(),
"Enabled guild IDs",
)?),
disabled_guild_ids: Some(parse_experiment_user_ids(
form.first("channel_threads_disabled_guild_ids")
.unwrap_or_default(),
"Disabled guild IDs",
)?),
user_basis_points: parse_form_number(
form,
"channel_threads_user_basis_points",
"User rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
user_salt: parse_experiment_rollout_salt(form, "channel_threads_user_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("channel_threads_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("channel_threads_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -1488,111 +1397,6 @@ mod tests {
}
}
#[test]
fn build_channel_threads_update_reads_both_rollout_dimensions() {
let form = MultiValueForm::parse(
b"channel_threads_enabled=true&channel_threads_guild_basis_points=%2010%20&channel_threads_guild_salt=%20channel-threads-guild-v2%20&channel_threads_enabled_guild_ids=1600000000000000001%0A1600000000000000002%0A1600000000000000001&channel_threads_disabled_guild_ids=1600000000000000003&channel_threads_user_basis_points=10000&channel_threads_user_salt=channel-threads-user-v2&channel_threads_included_user_ids=1500000000000000001&channel_threads_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
);
let update = build_channel_threads_update(&form)
.expect("valid form")
.channel_threads
.expect("channel threads update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.guild_basis_points, Some(10));
assert_eq!(
update.guild_salt,
Some("channel-threads-guild-v2".to_owned())
);
assert_eq!(
update.enabled_guild_ids,
Some(vec![
"1600000000000000001".to_owned(),
"1600000000000000002".to_owned()
])
);
assert_eq!(
update.disabled_guild_ids,
Some(vec!["1600000000000000003".to_owned()])
);
assert_eq!(update.user_basis_points, Some(10000));
assert_eq!(update.user_salt, Some("channel-threads-user-v2".to_owned()));
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000003".to_owned(),
"1500000000000000004".to_owned()
])
);
}
#[test]
fn build_channel_threads_update_leaves_the_experiment_off_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_channel_threads_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"channel_threads": {
"enabled": false,
"enabled_guild_ids": [],
"disabled_guild_ids": [],
"included_user_ids": [],
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_channel_threads_update_rejects_invalid_targeting() {
let too_many_guilds = (0..=EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join("%2C");
for (form, message) in [
(
"channel_threads_guild_basis_points=10001".to_owned(),
"Guild rollout basis points must be a whole number between 0 and 10000",
),
(
"channel_threads_user_basis_points=-1".to_owned(),
"User rollout basis points must be a whole number between 0 and 10000",
),
(
"channel_threads_guild_salt=%20%20".to_owned(),
"Rollout salt must be between 1 and 64 characters",
),
(
"channel_threads_user_salt=caf%C3%A9".to_owned(),
"Rollout salt must use printable ASCII",
),
(
"channel_threads_enabled_guild_ids=123%2Cinvalid".to_owned(),
"Enabled guild IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"channel_threads_disabled_guild_ids=123456789012345678901".to_owned(),
"Disabled guild IDs entry 1 must contain 1 to 20 decimal digits",
),
(
"channel_threads_excluded_user_ids=abc".to_owned(),
"Excluded user IDs entry 1 must contain 1 to 20 decimal digits",
),
(
format!("channel_threads_enabled_guild_ids={too_many_guilds}"),
"Enabled guild IDs must contain at most 1000 unique IDs",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_channel_threads_update(&form).expect_err("invalid targeting"),
message
);
}
}
#[test]
fn build_push_relay_update_reads_the_consent_checkbox() {
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
@@ -1673,72 +1477,6 @@ mod tests {
);
}
#[test]
fn build_plutonium_page_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"plutonium_page_enabled=true&plutonium_page_rollout_basis_points=%20500%20&plutonium_page_rollout_salt=%20plutonium-page-v2%20&plutonium_page_included_user_ids=1500000000000000001&plutonium_page_excluded_user_ids=1500000000000000002&plutonium_page_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&plutonium_page_include_premium_users=true",
);
let update = build_plutonium_page_update(&form)
.expect("valid form")
.plutonium_page
.expect("plutonium page update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("plutonium-page-v2".to_owned()));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(
update.included_guild_ids,
Some(vec![
"1500000000000000005".to_owned(),
"1500000000000000006".to_owned()
])
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
}
#[test]
fn build_plutonium_page_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_plutonium_page_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"plutonium_page": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_plutonium_page_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"plutonium_page_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"plutonium_page_included_guild_ids=1500000000000000005%0Anot-a-guild",
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_plutonium_page_update(&form).expect_err("invalid field"),
message
);
}
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
+16
View File
@@ -178,6 +178,22 @@ pub async fn dispatch(
"Failed to clear user fields",
)
}
"set_bot_status" => {
let val = form.bool_value("bot");
DispatchOutcome::from_result(
client.set_bot_status(user_id, val).await,
"Bot status updated successfully",
"Failed to update bot status",
)
}
"set_system_status" => {
let val = form.bool_value("system");
DispatchOutcome::from_result(
client.set_system_status(user_id, val).await,
"System status updated successfully",
"Failed to update system status",
)
}
"change_username" => {
let Some(username) = get("username") else {
return DispatchOutcome::error("Username is required");
@@ -17,24 +17,21 @@ pub fn threads_tab(
guild: &GuildInfo,
threads: &[GuildThreadItem],
can_delete: bool,
can_reindex: bool,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
html! {
@if can_reindex {
(card_with_header("Thread search index", html! {
form method="post"
action={(base) "/guilds/" (guild.id) "?tab=threads&action=refresh_search_index"}
class="w-full" {
(csrf_input(csrf_token))
input type="hidden" name="index_type" value="threads";
(form_actions(html! {
(submit_button("Refresh threads"))
}))
}
}))
}
(card_with_header("Thread search index", html! {
form method="post"
action={(base) "/guilds/" (guild.id) "?tab=threads&action=refresh_search_index"}
class="w-full" {
(csrf_input(csrf_token))
input type="hidden" name="index_type" value="threads";
(form_actions(html! {
(submit_button("Refresh threads"))
}))
}
}))
(card_with_header(
&format!("Threads ({})", threads.len()),
html! {
@@ -3,21 +3,18 @@
use crate::{
api::types::{
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CHANNEL_THREADS_DEFAULT_GUILD_SALT,
CHANNEL_THREADS_DEFAULT_USER_SALT, CaptchaConfigResponse, ChannelThreadsConfigResponse,
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, PLUTONIUM_PAGE_DEFAULT_SALT, PendingRegistrationResponse,
PlutoniumPageConfigResponse, PushRelayConfigResponse, RegistrationUrlResponse,
SsoConfigResponse, TagStyle,
LimitConfigResponse, PendingRegistrationResponse, PushRelayConfigResponse,
RegistrationUrlResponse, SsoConfigResponse, TagStyle,
},
config::AdminConfig,
middleware::auth::AuthContext,
templates::{
components::{
alert::alert_warning,
badge::{BadgeVariant, badge},
form::{
FORM_INPUT_CLASS, checkbox, csrf_input, danger_button, form_actions,
@@ -192,9 +189,9 @@ pub fn instance_config_page(
"Gateway rollout behavior and the limit rules applied to users and guilds.",
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
(channel_threads_section(base, csrf_token, &instance_config.channel_threads))
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page))
@if !instance_config.self_hosted {
(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))
@@ -1279,147 +1276,6 @@ fn domain_migration_section(
)
}
fn plutonium_page_section(
base: &str,
csrf_token: &str,
plutonium_page: &PlutoniumPageConfigResponse,
) -> Markup {
let status = if plutonium_page.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = plutonium_page.included_user_ids.join("\n");
let excluded_user_ids = plutonium_page.excluded_user_ids.join("\n");
section_card_with_description(
"Plutonium page",
"Replaces the Plutonium settings tab with a full Plutonium page, makes app pages linkable \
in chat, and uses a minimal gift purchase modal.",
html! {
form method="post" action={(base) "/instance-config?action=update_plutonium_page"} {
(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 " (plutonium_page.config_version)
}
}
(checkbox(
"plutonium_page_enabled",
"true",
"Serve the Plutonium page to the selected users",
plutonium_page.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked every \
client keeps the Plutonium settings tab, so the rollout and targeting \
fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"plutonium_page_rollout_basis_points",
"Rollout (basis points)",
&plutonium_page.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the Plutonium page, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"plutonium_page_rollout_salt",
"Rollout Salt",
&plutonium_page.rollout_salt,
PLUTONIUM_PAGE_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"plutonium_page_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
plutonium_page.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" {
(checkbox(
"plutonium_page_include_premium_users",
"true",
"Include premium users",
plutonium_page.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"plutonium_page_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&plutonium_page.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
plutonium_page.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"plutonium_page_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
plutonium_page.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage."
}
}
(form_actions(html! {
(submit_button("Save Plutonium Page Configuration"))
}))
}
}
},
)
}
fn estimate_low_end_solve_seconds(cost: u32, max_counter: u32) -> f64 {
0.75 * f64::from(cost) * f64::from(max_counter) / 1_050_000.0
}
@@ -1487,189 +1343,6 @@ fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse
)
}
fn channel_threads_section(
base: &str,
csrf_token: &str,
channel_threads: &ChannelThreadsConfigResponse,
) -> Markup {
let status = if channel_threads.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let enabled_guild_ids = channel_threads.enabled_guild_ids.join("\n");
let disabled_guild_ids = channel_threads.disabled_guild_ids.join("\n");
let included_user_ids = channel_threads.included_user_ids.join("\n");
let excluded_user_ids = channel_threads.excluded_user_ids.join("\n");
section_card_with_description(
"Channel threads",
"Threads, forum channels and media channels. A guild gets the feature only when the guild \
is selected, and a member sees it only when they are also selected and use a client \
that supports threads. Bots follow the guild selection.",
html! {
form method="post" action={(base) "/instance-config?action=update_channel_threads"} {
(csrf_input(csrf_token))
div class="space-y-6" {
(alert_warning("Before enabling", html! {
p class="text-sm" {
"Enable only after every gateway role, the api, the workers and the \
messages service run the gate build and the bit 34-38 overwrite audit \
is clean."
}
}))
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))
@if channel_threads.ever_enabled {
(badge("Ever enabled", BadgeVariant::Warning))
}
span class="text-xs text-neutral-500" {
"Config version " (channel_threads.config_version)
}
}
(checkbox(
"channel_threads_enabled",
"true",
"Turn on threads for the selected guilds and users",
channel_threads.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked no guild \
has threads, and existing threads and forums stay stored but hidden \
until it is turned back on."
}
h3 class="text-sm font-semibold text-neutral-900" { "Guilds" }
(number_field(
"channel_threads_guild_basis_points",
"Guild rollout (basis points)",
&channel_threads.guild_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of guilds bucketed into the experiment, in basis points: 0 is nobody, 100 is 1%, 10000 is every guild."),
))
div class="flex flex-col gap-2" {
(text_input(
"channel_threads_guild_salt",
"Guild rollout salt",
&channel_threads.guild_salt,
CHANNEL_THREADS_DEFAULT_GUILD_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the guild bucketing hash. Printable ASCII only. Changing it \
reshuffles which guilds fall inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"channel_threads_enabled_guild_ids",
"Always-on Guild IDs",
"1600000000000000001\n1600000000000000002",
&enabled_guild_ids,
4,
false,
))
(entry_count_hint(
channel_threads.enabled_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These guilds 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(
"channel_threads_disabled_guild_ids",
"Never-on Guild IDs",
"1600000000000000003\n1600000000000000004",
&disabled_guild_ids,
4,
false,
))
(entry_count_hint(
channel_threads.disabled_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage. This is the per-guild kill switch."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Users" }
(number_field(
"channel_threads_user_basis_points",
"User rollout (basis points)",
&channel_threads.user_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the experiment, in basis points. Set 10000 before enrolling any guild outside staff, so every member of that guild, moderators included, sees its threads."),
))
div class="flex flex-col gap-2" {
(text_input(
"channel_threads_user_salt",
"User rollout salt",
&channel_threads.user_salt,
CHANNEL_THREADS_DEFAULT_USER_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the user bucketing hash. Printable ASCII only. Changing it \
reshuffles which users fall inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"channel_threads_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
channel_threads.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format as the guild lists. These users are targeted regardless \
of the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"channel_threads_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
channel_threads.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, and it also applies to bots."
}
}
(alert_warning("Excluded bots", html! {
p class="text-sm" {
"Excluded bots are blind to threads, including moderation bots."
}
}))
(form_actions(html! {
(submit_button("Save channel threads configuration"))
}))
}
}
},
)
}
fn experiment_delivery_section(
base: &str,
csrf_token: &str,
@@ -2413,80 +2086,6 @@ mod tests {
assert!(!markup.contains("at the cap"));
}
#[test]
fn channel_threads_section_shows_both_dimensions_and_the_warnings() {
let channel_threads = ChannelThreadsConfigResponse {
enabled: true,
config_version: 7,
ever_enabled: true,
guild_basis_points: 25,
enabled_guild_ids: vec!["1600000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..ChannelThreadsConfigResponse::default()
};
let markup = channel_threads_section("/admin", "csrf", &channel_threads).into_string();
assert!(markup.contains("action=update_channel_threads"));
for name in [
"channel_threads_enabled",
"channel_threads_guild_basis_points",
"channel_threads_guild_salt",
"channel_threads_enabled_guild_ids",
"channel_threads_disabled_guild_ids",
"channel_threads_user_basis_points",
"channel_threads_user_salt",
"channel_threads_included_user_ids",
"channel_threads_excluded_user_ids",
] {
assert!(markup.contains(&format!("name=\"{name}\"")), "{name}");
}
assert!(markup.contains("value=\"25\""));
assert!(markup.contains("Config version 7"));
assert!(markup.contains("Ever enabled"));
assert!(markup.contains("bit 34-38 overwrite audit is clean"));
assert!(markup.contains("Excluded bots are blind to threads, including moderation bots."));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
let off =
channel_threads_section("/admin", "csrf", &ChannelThreadsConfigResponse::default())
.into_string();
assert!(off.contains("Inert"));
assert!(!off.contains("Ever enabled"));
assert!(off.contains(CHANNEL_THREADS_DEFAULT_GUILD_SALT));
assert!(off.contains(CHANNEL_THREADS_DEFAULT_USER_SALT));
}
#[test]
fn plutonium_page_section_shows_the_rollout_and_list_counts() {
let plutonium_page = PlutoniumPageConfigResponse {
enabled: true,
config_version: 3,
rollout_basis_points: 250,
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..PlutoniumPageConfigResponse::default()
};
let markup = plutonium_page_section("/admin", "csrf", &plutonium_page).into_string();
assert!(markup.contains("Plutonium page"));
assert!(markup.contains("action=update_plutonium_page"));
assert!(markup.contains("name=\"plutonium_page_enabled\""));
assert!(markup.contains("name=\"plutonium_page_rollout_basis_points\""));
assert!(markup.contains("value=\"250\""));
assert!(markup.contains("name=\"plutonium_page_include_premium_users\""));
assert!(markup.contains("name=\"plutonium_page_included_guild_ids\""));
assert!(markup.contains("Config version 3"));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(!markup.contains("anonymous_rollout_basis_points"));
assert!(!markup.contains("standalone_forwarding"));
}
#[test]
fn push_relay_section_shows_the_consent_toggle() {
let accepted = PushRelayConfigResponse {
@@ -52,6 +52,7 @@ pub fn account_tab(
(sessions_card(config, sessions))
(quick_actions_card(base, user, csrf_token, username_sign_in, can_revoke_recovery_kit))
(clear_fields_card(base, user, csrf_token))
(user_status_card(base, user, csrf_token))
(security_actions_card(base, user, csrf_token))
(webauthn_credentials_card(base, user, webauthn_credentials, csrf_token))
}
@@ -310,6 +311,19 @@ fn clear_fields_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
}
}
fn user_status_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
let is_bot = user.bot;
let is_sys = user.system;
html! {
(card_with_header("User Status", html! {
div class="grid grid-cols-1 gap-4 md:grid-cols-2" {
(status_toggle(base, &user.id, "set_bot_status", is_bot, "bot", csrf_token))
(status_toggle(base, &user.id, "set_system_status", is_sys, "system", csrf_token))
}
}))
}
}
fn security_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
html! {
(card_with_header("Security Actions", html! {
@@ -450,3 +464,40 @@ fn action_form(
}
}
}
fn status_toggle(
base: &str,
uid: &str,
action: &str,
active: bool,
kind: &str,
csrf: &str,
) -> Markup {
let status_val = if active { "false" } else { "true" };
let label = format!(
"{} {} Status",
if active { "Remove" } else { "Set" },
capitalize(kind)
);
let action_url = format!("{base}/users/{uid}?action={action}&status={status_val}&tab=account");
html! {
form method="post"
action=(&action_url)
hx-post=(&action_url)
hx-target="#flash-container"
hx-swap="none"
hx-push-url="false" {
(csrf_input(csrf))
input type="hidden" name=(kind) value=(status_val);
button type="submit" class=(BTN_CLS) { (label) }
}
}
}
fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().chain(c).collect(),
}
}
@@ -437,6 +437,11 @@ fn acls_card(
(flag_checkbox("acls[]", item.to_string(), item, checked, true))
}
}
@for item in &user.acls {
@if !acl::ALL_ACLS.iter().any(|known| known == &item.as_str()) {
input type="hidden" name="acls[]" value=(item);
}
}
(form_actions(html! {
(submit_button("Save ACLs"))
}))
-92
View File
@@ -416,36 +416,12 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
},
"plutonium_page": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 500,
"rollout_salt": "plutonium-page-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": ["1500000000000000002"],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"future_plutonium_page_knob": true
},
"captcha": {
"enabled": true,
"cost": 5000,
"max_counter": 1000,
"future_captcha_knob": 1
},
"channel_threads": {
"enabled": true,
"config_version": 3,
"ever_enabled": true,
"guild_basis_points": 0,
"guild_salt": "channel-threads-guild-v1",
"enabled_guild_ids": ["1600000000000000001"],
"disabled_guild_ids": [],
"user_basis_points": 10000,
"user_salt": "channel-threads-user-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
"mode": "open",
@@ -597,20 +573,9 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
assert!(resp.domain_migration.standalone_forwarding);
assert!(resp.plutonium_page.enabled);
assert_eq!(resp.plutonium_page.config_version, 3);
assert_eq!(resp.plutonium_page.rollout_basis_points, 500);
assert_eq!(*resp.plutonium_page.rollout_salt, "plutonium-page-v1");
assert_eq!(resp.plutonium_page.included_user_ids.len(), 1);
assert_eq!(resp.plutonium_page.excluded_user_ids.len(), 1);
assert_eq!(resp.plutonium_page.included_guild_ids.len(), 1);
assert!(resp.plutonium_page.include_premium_users);
assert!(resp.push_relay.relay_consent_accepted);
assert!(resp.captcha.enabled);
assert_eq!(resp.captcha.max_counter, 1000);
assert!(resp.channel_threads.enabled);
assert_eq!(resp.channel_threads.config_version, 3);
assert_eq!(resp.channel_threads.enabled_guild_ids.len(), 1);
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));
@@ -678,63 +643,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
);
}
#[test]
fn deserialize_channel_threads_config() {
let config: types::ChannelThreadsConfigResponse = serde_json::from_str(
r#"{
"enabled": true,
"config_version": 12,
"ever_enabled": true,
"guild_basis_points": 50,
"guild_salt": "channel-threads-guild-v2",
"enabled_guild_ids": ["1600000000000000001"],
"disabled_guild_ids": ["1600000000000000002", "1600000000000000003"],
"user_basis_points": 10000,
"user_salt": "channel-threads-user-v1",
"included_user_ids": [],
"excluded_user_ids": ["1500000000000000001"],
"future_threads_knob": 1
}"#,
)
.expect("a channel threads config must deserialize");
assert!(config.enabled);
assert!(config.ever_enabled);
assert_eq!(config.config_version, 12);
assert_eq!(config.guild_basis_points, 50);
assert_eq!(config.guild_salt, "channel-threads-guild-v2");
assert_eq!(config.enabled_guild_ids, vec!["1600000000000000001"]);
assert_eq!(config.disabled_guild_ids.len(), 2);
assert_eq!(config.user_basis_points, 10000);
assert_eq!(config.excluded_user_ids, vec!["1500000000000000001"]);
let absent: types::ChannelThreadsConfigResponse =
serde_json::from_str("{}").expect("an api without the experiment still deserializes");
assert!(!absent.enabled);
assert!(!absent.ever_enabled);
assert_eq!(absent.guild_salt, types::CHANNEL_THREADS_DEFAULT_GUILD_SALT);
assert_eq!(absent.user_salt, types::CHANNEL_THREADS_DEFAULT_USER_SALT);
}
#[test]
fn serialize_channel_threads_update_never_sends_server_owned_fields() {
let update = types::InstanceConfigUpdateRequest {
channel_threads: Some(types::ChannelThreadsConfigUpdateRequest {
enabled: Some(true),
enabled_guild_ids: Some(vec!["1600000000000000001".to_owned()]),
..Default::default()
}),
..Default::default()
};
assert_eq!(
serde_json::to_value(&update).unwrap(),
serde_json::json!({"channel_threads": {
"enabled": true,
"enabled_guild_ids": ["1600000000000000001"],
}})
);
}
#[test]
fn deserialize_push_relay_config() {
let accepted: types::PushRelayConfigResponse = serde_json::from_str(
+5 -69
View File
@@ -469,8 +469,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
"/instance-config?action=update_gateway_rollout",
"/instance-config?action=update_sso",
"/instance-config?action=update_domain_migration",
"/instance-config?action=update_channel_threads",
"/instance-config?action=update_plutonium_page",
"/instance-config?action=update_experiment_delivery",
][..],
),
@@ -487,54 +485,13 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
}
#[tokio::test]
async fn channel_threads_section_renders_and_saves_through_htmx_toasts() {
async fn instance_config_has_no_threads_or_plutonium_page_rollout_sections() {
let app = setup().await;
let (headers, body) = get_with_headers(&app, "/instance-config", &[]).await;
let body = get(&app, "/instance-config", &[]).await;
assert_full_layout(&body);
assert!(body.contains("Channel threads"), "{body}");
assert!(body.contains("Config version 3"), "{body}");
assert!(body.contains("Ever enabled"), "{body}");
assert!(body.contains("1600000000000000001"), "{body}");
assert!(body.contains("1500000000000000009"), "{body}");
assert!(
body.contains("Excluded bots are blind to threads, including moderation bots."),
"{body}"
);
let csrf_token = csrf_cookie(&headers)
.unwrap_or_else(|| panic!("instance config page did not set csrf_token cookie\n{body}"));
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
let htmx_headers = [
("HX-Request", "true"),
("HX-Target", "flash-container"),
("Cookie", cookie.as_str()),
];
for (form, expected) in [
(
format!(
"_csrf={csrf_token}&channel_threads_enabled=true&channel_threads_guild_basis_points=0&channel_threads_enabled_guild_ids=1600000000000000001&channel_threads_user_basis_points=10000"
),
"Instance config updated",
),
(
format!("_csrf={csrf_token}&channel_threads_enabled_guild_ids=not-a-guild"),
"Enabled guild IDs entry 1 must contain 1 to 20 decimal digits",
),
] {
let (status, response_headers, response_body) = post_form_with_headers(
&app,
"/instance-config?action=update_channel_threads",
&htmx_headers,
&form,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "{response_body}");
let toast = response_headers
.get("X-Fluxer-Admin-Toast")
.and_then(|value| value.to_str().ok())
.unwrap_or_else(|| panic!("missing toast header\n{response_body}"));
assert!(toast.contains(expected), "{toast}");
}
assert!(!body.contains("Threads and forums"), "{body}");
assert!(!body.contains("update_channel_threads"), "{body}");
assert!(!body.contains("update_plutonium_page"), "{body}");
}
#[tokio::test]
@@ -1245,27 +1202,6 @@ fn instance_config() -> Value {
"anonymous_rollout_basis_points": 0,
"standalone_forwarding": false
},
"channel_threads": {
"enabled": false,
"config_version": 3,
"ever_enabled": true,
"guild_basis_points": 0,
"guild_salt": "channel-threads-guild-v1",
"enabled_guild_ids": ["1600000000000000001"],
"disabled_guild_ids": [],
"user_basis_points": 10000,
"user_salt": "channel-threads-user-v1",
"included_user_ids": [],
"excluded_user_ids": ["1500000000000000009"]
},
"plutonium_page": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "plutonium-page-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"experiment_delivery": {
"poll_interval_seconds": 300,
"poll_jitter_percent": 15
+4
View File
@@ -330,6 +330,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
monthlyBrl: master.integrations.stripe.prices.monthly_brl,
monthlyDkk: master.integrations.stripe.prices.monthly_dkk,
monthlyInr: master.integrations.stripe.prices.monthly_inr,
monthlyIsk: master.integrations.stripe.prices.monthly_isk,
monthlyNok: master.integrations.stripe.prices.monthly_nok,
monthlyPln: master.integrations.stripe.prices.monthly_pln,
monthlySek: master.integrations.stripe.prices.monthly_sek,
@@ -339,6 +340,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
yearlyBrl: master.integrations.stripe.prices.yearly_brl,
yearlyDkk: master.integrations.stripe.prices.yearly_dkk,
yearlyInr: master.integrations.stripe.prices.yearly_inr,
yearlyIsk: master.integrations.stripe.prices.yearly_isk,
yearlyNok: master.integrations.stripe.prices.yearly_nok,
yearlyPln: master.integrations.stripe.prices.yearly_pln,
yearlySek: master.integrations.stripe.prices.yearly_sek,
@@ -351,6 +353,8 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
gift1YearDkk: master.integrations.stripe.prices.gift_1_year_dkk,
gift1MonthNok: master.integrations.stripe.prices.gift_1_month_nok,
gift1YearNok: master.integrations.stripe.prices.gift_1_year_nok,
gift1MonthIsk: master.integrations.stripe.prices.gift_1_month_isk,
gift1YearIsk: master.integrations.stripe.prices.gift_1_year_isk,
gift1MonthBrl: master.integrations.stripe.prices.gift_1_month_brl,
gift1MonthInr: master.integrations.stripe.prices.gift_1_month_inr,
gift1MonthPln: master.integrations.stripe.prices.gift_1_month_pln,
@@ -4,7 +4,6 @@ import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {enqueueRebuildThreadAutoArchiveQueue, enqueueThreadSearchBackfill} from '@app/api/channel/threads/ThreadJobs';
import {
type InstancePolicyConfig,
REGISTRATION_PENDING_APPROVAL_TRAIT,
@@ -15,7 +14,6 @@ import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {
getChannelThreadsConfigPublisher,
getGatewayRolloutConfigPublisher,
getInstanceConfigRepository,
getPushRelayConfigPublisher,
@@ -23,11 +21,6 @@ import {
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {
enabledThreadGuildIds,
enqueueThreadPermissionSeeds,
newlyEnabledThreadGuildIds,
} from '@app/api/worker/tasks/SeedThreadPermissions';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
@@ -42,13 +35,8 @@ import {
PendingRegistrationActionRequest,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
applyChannelThreadsConfigUpdate,
type ChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {PlutoniumPageConfigSchema} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -78,9 +66,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gatewayRollout,
pushRelay,
domainMigration,
plutoniumPage,
captcha,
channelThreads,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -90,9 +76,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getPlutoniumPageConfig(),
instanceConfigRepository.getCaptchaConfig(),
instanceConfigRepository.getChannelThreadsConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -129,9 +113,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gateway_rollout: gatewayRollout,
push_relay: pushRelay,
domain_migration: domainMigration,
plutonium_page: plutoniumPage,
captcha,
channel_threads: channelThreads,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -414,43 +396,12 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.plutonium_page) {
const patch = omitUndefinedFields(data.plutonium_page);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updatePlutoniumPageConfig((current) =>
PlutoniumPageConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.captcha) {
const patch = omitUndefinedFields(data.captcha);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateCaptchaConfig(patch);
}
}
let channelThreadsConfigVersion: number | undefined;
if (data.channel_threads) {
const patch = omitUndefinedFields(data.channel_threads);
if (Object.keys(patch).length > 0) {
let previous: ChannelThreadsConfig | undefined;
const landed = await instanceConfigRepository.updateChannelThreadsConfig((current) => {
previous = current;
return applyChannelThreadsConfigUpdate(current, patch);
});
channelThreadsConfigVersion = landed.config_version;
await enqueueThreadPermissionSeeds(ctx.get('channelRepository').threads, landed);
const newlyEnabled = previous ? newlyEnabledThreadGuildIds(previous, landed) : enabledThreadGuildIds(landed);
for (const guildId of newlyEnabled) {
await enqueueRebuildThreadAutoArchiveQueue(guildId);
await enqueueThreadSearchBackfill(guildId);
}
await getChannelThreadsConfigPublisher().publish(landed);
}
}
if (data.experiment_delivery) {
const patch = data.experiment_delivery;
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
@@ -646,7 +597,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
action: 'update_instance_config',
metadata: {
sections: listSuppliedSections(data),
channel_threads_config_version: channelThreadsConfigVersion?.toString(),
granted_acls: grantedSetupCompleterAdmin ? AdminACLs.WILDCARD : undefined,
},
});
@@ -24,6 +24,7 @@ import {
AdminUserAclsRequest,
AdminUserBanNoteRequest,
AdminUserBanRequest,
AdminUserBotStatusRequest,
AdminUserChangeLogQuery,
AdminUserClearFieldsRequest,
AdminUserDeletionCancelRequest,
@@ -38,6 +39,7 @@ import {
AdminUserPremiumFlagsUpdateRequest,
AdminUserRelationshipCategoryQuery,
AdminUserRelationshipParam,
AdminUserSystemStatusRequest,
AdminUsersMeResponse,
AdminUserTraitsRequest,
AdminUserUnbanRequest,
@@ -601,6 +603,70 @@ export function UserAdminController(app: HonoApp) {
);
},
);
app.put(
'/admin/users/:user_id/bot-status',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
Validator('param', UserIdParam),
Validator('json', AdminUserBotStatusRequest),
OpenAPI({
operationId: 'set_admin_user_bot_status',
summary: 'Set user bot status',
responseSchema: UserMutationResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const adminUserAcls = ctx.get('adminUserAcls');
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.profileService.setUserBotStatus(
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
),
);
},
);
app.put(
'/admin/users/:user_id/system-status',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
Validator('param', UserIdParam),
Validator('json', AdminUserSystemStatusRequest),
OpenAPI({
operationId: 'set_admin_user_system_status',
summary: 'Set user system status',
responseSchema: UserMutationResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const adminUserAcls = ctx.get('adminUserAcls');
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.profileService.setUserSystemStatus(
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
),
);
},
);
app.patch(
'/admin/users/:user_id/username',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
@@ -15,6 +15,9 @@ import type {User} from '@app/api/models/User';
import {enqueueStripeCustomerEmailSync} from '@app/api/stripe/StripeCustomer';
import {assertNoDiscriminatorChange, reserveUsername, type UsernameReservation} from '@app/api/user/UniqueUsernames';
import {USERNAME_MODE_DISCRIMINATOR} from '@app/api/user/UserTag';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {TagAlreadyTakenError} from '@fluxer/errors/src/domains/user/TagAlreadyTakenError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {
@@ -22,6 +25,8 @@ import type {
ChangeEmailRequest,
ChangeUsernameRequest,
ClearUserFieldsRequest,
SetUserBotStatusRequest,
SetUserSystemStatusRequest,
VerifyUserEmailRequest,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {types} from 'cassandra-driver';
@@ -104,6 +109,75 @@ export class AdminUserProfileService {
};
}
async setUserBotStatus(
data: SetUserBotStatusRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
) {
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
const {auditService, updatePropagator} = this.deps;
const userId = createUserID(data.user_id);
const user = await userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
if (data.bot && user.acls.size > 0) {
throw new AccessDeniedError();
}
const updates: Record<string, boolean> = {bot: data.bot};
if (!data.bot) {
updates['system'] = false;
}
const updatedUser = await userRepository.patchUpsert(userId, updates, user.toRow());
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'set_bot_status',
auditLogReason,
metadata: new Map([['bot', data.bot.toString()]]),
});
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
}
async setUserSystemStatus(
data: SetUserSystemStatusRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
) {
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
const {auditService, updatePropagator} = this.deps;
const userId = createUserID(data.user_id);
const user = await userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
if (data.system && !user.isBot) {
throw InputValidationError.fromCode(
'system',
ValidationErrorCodes.USER_MUST_BE_A_BOT_TO_BE_MARKED_AS_A_SYSTEM_USER,
);
}
const updatedUser = await userRepository.patchUpsert(userId, {system: data.system}, user.toRow());
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'set_system_status',
auditLogReason,
metadata: new Map([['system', data.system.toString()]]),
});
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
}
async verifyUserEmail(
data: VerifyUserEmailRequest,
adminUserId: UserID,
@@ -78,6 +78,7 @@ const adminEndpoints: Array<AdminEndpointCase> = [
{method: 'GET', path: '/admin/users/1/webauthn-credentials', requiredACL: 'user:update:mfa'},
{method: 'DELETE', path: '/admin/users/1/webauthn-credentials/credential', requiredACL: 'user:update:mfa'},
{method: 'DELETE', path: '/admin/users/1/profile-fields', requiredACL: 'user:update:profile'},
{method: 'PUT', path: '/admin/users/1/bot-status', requiredACL: 'user:update:bot_status'},
{method: 'PUT', path: '/admin/users/1/acls', requiredACL: 'acl:set:user'},
{method: 'PUT', path: '/admin/users/1/deletion', requiredACL: 'user:delete'},
{method: 'POST', path: '/admin/users/1/avatar-block', requiredACL: 'ban:avatar_hash:add'},
@@ -20,6 +20,8 @@ const MUTATIONS: Array<{verb: 'put' | 'patch' | 'delete'; path: string; acl: str
{verb: 'put', path: 'ban', acl: AdminACLs.USER_TEMP_BAN, body: {duration_hours: 1, reason: 'test'}},
{verb: 'put', path: 'deletion', acl: AdminACLs.USER_DELETE, body: {delay_days: 1}},
{verb: 'delete', path: 'profile-fields', acl: AdminACLs.USER_UPDATE_PROFILE, body: {fields: ['bio']}},
{verb: 'put', path: 'bot-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {bot: true}},
{verb: 'put', path: 'system-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {system: true}},
];
const CASES = SYNTHETIC_USER_IDS.flatMap((userId) =>
@@ -8,13 +8,11 @@ import {
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS, TEST_IDS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import type {CreateRegistrationUrlResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {vi} from 'vitest';
async function createRegistrationUrl(harness: ApiTestHarness, admin: TestAccount): Promise<string> {
const created = await createBuilder<CreateRegistrationUrlResponse>(harness, admin.token)
@@ -82,26 +80,6 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
};
},
},
{
method: 'PATCH',
route: '/admin/instance/config',
name: 'channel threads',
async prepare() {
vi.spyOn(ChannelThreadsConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
return {
request: {
path: '/admin/instance/config',
body: {channel_threads: {enabled: true, enabled_guild_ids: ['1']}},
},
expected: {
action: 'update_instance_config',
targetType: 'instance_config',
targetId: '0',
metadata: {sections: 'channel_threads', channel_threads_config_version: '1'},
},
};
},
},
{
method: 'POST',
route: '/admin/instance/config/branding-assets',
@@ -191,6 +191,39 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/bot-status',
async prepare({harness}) {
const target = await createTestAccount(harness);
return {
request: {path: `/admin/users/${target.userId}/bot-status`, body: {bot: true}},
expected: {
action: 'set_bot_status',
targetType: 'user',
targetId: target.userId,
metadata: {bot: 'true'},
},
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/system-status',
async prepare(context) {
const target = await createTestAccount(context.harness);
await adminBuilder(context).put(`/admin/users/${target.userId}/bot-status`).body({bot: true}).execute();
return {
request: {path: `/admin/users/${target.userId}/system-status`, body: {system: true}},
expected: {
action: 'set_system_status',
targetType: 'user',
targetId: target.userId,
metadata: {system: 'true'},
},
};
},
},
{
method: 'PATCH',
route: '/admin/users/:user_id/username',
+9 -2
View File
@@ -58,10 +58,17 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
allowedHeaders: [
HttpHeaders.CONTENT_TYPE,
HttpHeaders.AUTHORIZATION,
'X-Requested-With',
'Accept-Language',
HttpHeaders.X_REQUESTED_WITH,
HttpHeaders.ACCEPT_LANGUAGE,
HttpHeaders.X_REQUEST_ID,
HttpHeaders.IF_NONE_MATCH,
HttpHeaders.X_AUDIT_LOG_REASON,
HttpHeaders.X_CAPTCHA_ID,
HttpHeaders.X_CAPTCHA_TOKEN,
HttpHeaders.X_FLUXER_CLIENT_INSTALLATION_ID,
HttpHeaders.X_FLUXER_FEATURES,
HttpHeaders.X_FLUXER_PLATFORM,
HttpHeaders.X_FLUXER_SUDO_MODE_JWT,
],
exposedHeaders: [HttpHeaders.X_FLUXER_VERSION, HttpHeaders.ETAG],
},
+39 -6
View File
@@ -29,7 +29,9 @@ import {
HandoffCancelRequest,
HandoffCodeParam,
HandoffCompleteRequest,
HandoffCompleteResponse,
HandoffInfoResponse,
HandoffInitiateRequest,
HandoffInitiateResponse,
HandoffStatusRequest,
HandoffStatusResponse,
@@ -609,6 +611,7 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/handoff/initiate',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_INITIATE),
Validator('json', HandoffInitiateRequest),
OpenAPI({
operationId: 'initiate_handoff',
summary: 'Initiate handoff',
@@ -617,10 +620,15 @@ export function AuthController(app: HonoApp) {
security: [],
tags: ['Auth'],
description:
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking. A desktop app that registers a return deep link can be signed in without the user typing the code.',
}),
async (ctx) => {
return ctx.json(await ctx.get('authRequestService').initiateHandoff({request: ctx.req.raw}));
return ctx.json(
await ctx.get('authRequestService').initiateHandoff({
request: ctx.req.raw,
returnUri: ctx.req.valid('json')?.return_uri ?? null,
}),
);
},
);
app.get(
@@ -656,23 +664,47 @@ export function AuthController(app: HonoApp) {
OpenAPI({
operationId: 'complete_handoff',
summary: 'Complete handoff',
responseSchema: null,
statusCode: 204,
responseSchema: HandoffCompleteResponse,
statusCode: [200, 204],
security: [],
tags: ['Auth'],
description: 'Complete the handoff process and authenticate on the target device using the handoff code.',
description:
'Complete the handoff process and authenticate on the target device using the handoff code. With the deep_link return method, responds with the deep link that hands the one-time grant back to the initiating app. Otherwise responds with no content.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
await ctx.get('authRequestService').completeHandoff({
const response = await ctx.get('authRequestService').completeHandoff({
data: ctx.req.valid('json'),
clientIp,
authToken: ctx.get('authToken') ?? undefined,
approverOrigin: ctx.req.header('origin'),
});
return response ? ctx.json(response) : ctx.body(null, 204);
},
);
app.post(
'/auth/handoff/:code/deny',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_DENY),
Validator('param', HandoffCodeParam),
OpenAPI({
operationId: 'deny_handoff',
summary: 'Deny handoff',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description:
'Decline a handoff request after looking it up. The initiating device sees the denied status and the code can no longer be approved.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
await ctx.get('authRequestService').denyHandoff({code: ctx.req.valid('param').code, clientIp});
return ctx.body(null, 204);
},
);
@@ -726,6 +758,7 @@ export function AuthController(app: HonoApp) {
code: ctx.req.valid('param').code,
clientIp,
pollSecret: ctx.req.valid('json').poll_secret,
grant: ctx.req.valid('json').grant,
});
return ctx.json(response);
},
+27 -5
View File
@@ -41,6 +41,7 @@ import type {
EmailRevertRequest,
ForgotPasswordRequest,
HandoffCompleteRequest,
HandoffCompleteResponse,
HandoffInfoResponse,
HandoffInitiateResponse,
HandoffStatusResponse,
@@ -141,6 +142,7 @@ interface AuthLogoutAuthSessionsRequest {
interface AuthHandoffInitiateRequest {
request: Request;
returnUri?: string | null;
}
interface AuthHandoffInfoRequest {
@@ -152,6 +154,7 @@ interface AuthHandoffStatusRequest {
code: string;
clientIp: string;
pollSecret?: string;
grant?: string;
}
interface AuthHandoffCancelRequest {
@@ -339,16 +342,18 @@ export class AuthRequestService {
return {available: !(await isUsernameTaken(this.apiContext.services.users, username))};
}
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
async initiateHandoff({request, returnUri}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
const result = await this.desktopHandoffService.initiateHandoff({
origin,
initiatorOrigin: request.headers.get('origin'),
returnUri,
});
return {
code: result.code,
expires_at: result.expiresAt.toISOString(),
poll_secret: result.pollSecret,
return_method: result.returnMethod,
};
}
@@ -376,16 +381,26 @@ export class AuthRequestService {
country: geo.countryName,
},
},
return_method: info.returnMethod,
};
}
async completeHandoff({data, clientIp, authToken, approverOrigin}: AuthHandoffCompleteRequest): Promise<void> {
async denyHandoff({code, clientIp}: AuthHandoffInfoRequest): Promise<void> {
await this.desktopHandoffService.denyHandoff(code, clientIp);
}
async completeHandoff({
data,
clientIp,
authToken,
approverOrigin,
}: AuthHandoffCompleteRequest): Promise<HandoffCompleteResponse | null> {
const sessionToken = data.token ?? authToken;
if (!sessionToken) {
throw new UnauthorizedError();
}
let createdToken: string | null = null;
const {initiatorOrigin} = await this.desktopHandoffService.completeHandoff(
const {initiatorOrigin, returnUrl} = await this.desktopHandoffService.completeHandoff(
data.code,
async (origin) => {
const created = await AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
@@ -397,10 +412,12 @@ export class AuthRequestService {
return created;
},
clientIp,
data.return_method,
);
if (createdToken !== null) {
await this.recordPushSessionPredecessor(createdToken, sessionToken, initiatorOrigin, approverOrigin);
}
return returnUrl ? {return_url: returnUrl} : null;
}
private async recordPushSessionPredecessor(
@@ -428,8 +445,13 @@ export class AuthRequestService {
}
}
async getHandoffStatus({code, clientIp, pollSecret}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret);
async getHandoffStatus({
code,
clientIp,
pollSecret,
grant,
}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret, grant);
return {
status: result.status,
token: result.token,
@@ -21,6 +21,7 @@ const MAX_FAILED_ATTEMPTS = 5;
const ATTEMPT_TTL_SECONDS = 900;
const MAX_INFO_LOOKUPS = 3;
const POLL_SECRET_BYTES = 32;
const GRANT_BYTES = 32;
interface HandoffData {
createdAt: number;
@@ -28,14 +29,19 @@ interface HandoffData {
initiatorOrigin?: string | null;
infoLookupCount: number;
pollSecretHash: string;
returnUri?: string | null;
denied?: boolean;
}
interface HandoffTokenData {
token: string;
userId: string;
pollSecretHash: string;
grantHash?: string;
}
export type DesktopHandoffReturnMethod = 'deep_link' | 'code';
interface HandoffApproverData {
approvedAt: number;
}
@@ -62,15 +68,19 @@ function requireNormalizedHandoffCode(code: string): string {
return normalized;
}
function generatePollSecret(): string {
return randomBytes(POLL_SECRET_BYTES).toString('base64url');
function generateSecret(byteLength: number): string {
return randomBytes(byteLength).toString('base64url');
}
function hashPollSecret(secret: string): string {
return createHash('sha256').update(secret).digest('hex');
}
function pollSecretMatches(presented: string | undefined, storedHash: string | undefined): boolean {
function resolveReturnMethod(handoffData: HandoffData): DesktopHandoffReturnMethod {
return handoffData.returnUri ? 'deep_link' : 'code';
}
function secretMatches(presented: string | undefined, storedHash: string | undefined): boolean {
if (!presented || !storedHash) {
return false;
}
@@ -85,32 +95,44 @@ function pollSecretMatches(presented: string | undefined, storedHash: string | u
export class DesktopHandoffService {
constructor(private readonly apiContext: ApiContext) {}
async initiateHandoff(args: {origin: SessionOrigin; initiatorOrigin?: string | null}): Promise<{
async initiateHandoff(args: {
origin: SessionOrigin;
initiatorOrigin?: string | null;
returnUri?: string | null;
}): Promise<{
code: string;
expiresAt: Date;
pollSecret: string;
returnMethod: DesktopHandoffReturnMethod;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = generateNormalizedHandoffCode();
const pollSecret = generatePollSecret();
const pollSecret = generateSecret(POLL_SECRET_BYTES);
const handoffData: HandoffData = {
createdAt: Date.now(),
origin: args.origin,
initiatorOrigin: args.initiatorOrigin ?? null,
infoLookupCount: 0,
pollSecretHash: hashPollSecret(pollSecret),
returnUri: args.returnUri ?? null,
};
const expirySeconds = seconds('5 minutes');
await cache.set(`${HANDOFF_CODE_PREFIX}${normalizedCode}`, handoffData, expirySeconds);
const expiresAt = new Date(Date.now() + ms('5 minutes'));
return {code: formatDesktopHandoffCode(normalizedCode), expiresAt, pollSecret};
return {
code: formatDesktopHandoffCode(normalizedCode),
expiresAt,
pollSecret,
returnMethod: resolveReturnMethod(handoffData),
};
}
async completeHandoff(
code: string,
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
approverIp: string,
): Promise<{initiatorOrigin: string | null}> {
returnMethod: DesktopHandoffReturnMethod = 'code',
): Promise<{initiatorOrigin: string | null; returnUrl: string | null}> {
const {cache} = this.apiContext.services;
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
@@ -120,10 +142,13 @@ export class DesktopHandoffService {
throw new InvalidHandoffCodeError();
}
const handoffData = await cache.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
if (!handoffData) {
if (!handoffData || handoffData.denied) {
await this.recordFailedAttempt(approverIp);
throw new InvalidHandoffCodeError();
}
if (returnMethod === 'deep_link' && !handoffData.returnUri) {
throw new InvalidHandoffCodeError();
}
const remainingSeconds = Math.max(
0,
seconds('5 minutes') - Math.floor((Date.now() - handoffData.createdAt) / 1000),
@@ -132,15 +157,22 @@ export class DesktopHandoffService {
throw new HandoffCodeExpiredError();
}
const {token, userId} = await createTokenData(handoffData.origin);
const grant = returnMethod === 'deep_link' ? generateSecret(GRANT_BYTES) : null;
const tokenData: HandoffTokenData = {
token,
userId,
pollSecretHash: handoffData.pollSecretHash,
...(grant ? {grantHash: hashPollSecret(grant)} : {}),
};
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};
let returnUrl: string | null = null;
if (grant && handoffData.returnUri) {
const params = new URLSearchParams({code: formatDesktopHandoffCode(normalizedCode), grant});
returnUrl = `${handoffData.returnUri}?${params.toString()}`;
}
return {initiatorOrigin: handoffData.initiatorOrigin ?? null, returnUrl};
}
async getHandoffInfo(
@@ -149,13 +181,14 @@ export class DesktopHandoffService {
): Promise<{
status: 'pending' | 'expired';
origin?: SessionOrigin;
returnMethod?: DesktopHandoffReturnMethod;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
const handoffData = await cache.get<HandoffData>(codeKey);
if (!handoffData) {
if (!handoffData || handoffData.denied) {
await this.recordFailedAttempt(approverIp);
return {status: 'expired'};
}
@@ -172,15 +205,35 @@ export class DesktopHandoffService {
{approvedAt: Date.now()},
remainingTtl > 0 ? remainingTtl : seconds('5 minutes'),
);
return {status: 'pending', origin: handoffData.origin};
return {status: 'pending', origin: handoffData.origin, returnMethod: resolveReturnMethod(handoffData)};
}
async denyHandoff(code: string, approverIp: string): Promise<void> {
const {cache} = this.apiContext.services;
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
const approverKey = `${HANDOFF_APPROVER_PREFIX}${normalizedCode}`;
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
const storedApprover = await cache.get<HandoffApproverData>(approverKey);
const handoffData = await cache.get<HandoffData>(codeKey);
if (!storedApprover || !handoffData) {
await this.recordFailedAttempt(approverIp);
throw new InvalidHandoffCodeError();
}
const remainingTtl = await cache.ttl(codeKey);
if (remainingTtl > 0) {
await cache.set(codeKey, {...handoffData, denied: true}, remainingTtl);
}
await cache.delete(approverKey);
}
async getHandoffStatus(
code: string,
pollerIp: string,
pollSecret: string | undefined,
grant?: string,
): Promise<{
status: 'pending' | 'completed' | 'expired';
status: 'pending' | 'completed' | 'denied' | 'expired';
token?: string;
userId?: string;
}> {
@@ -190,10 +243,19 @@ export class DesktopHandoffService {
const tokenKey = `${HANDOFF_TOKEN_PREFIX}${normalizedCode}`;
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
if (tokenData) {
if (!pollSecretMatches(pollSecret, tokenData.pollSecretHash)) {
if (!secretMatches(pollSecret, tokenData.pollSecretHash)) {
await this.recordFailedAttempt(pollerIp);
return {status: 'pending'};
}
if (tokenData.grantHash) {
if (!grant) {
return {status: 'pending'};
}
if (!secretMatches(grant, tokenData.grantHash)) {
await this.recordFailedAttempt(pollerIp);
return {status: 'pending'};
}
}
await cache.delete(tokenKey);
return {
status: 'completed',
@@ -203,7 +265,7 @@ export class DesktopHandoffService {
}
const handoffData = await cache.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
if (handoffData) {
return {status: 'pending'};
return {status: handoffData.denied ? 'denied' : 'pending'};
}
return {status: 'expired'};
}
@@ -216,7 +278,7 @@ export class DesktopHandoffService {
const handoffData = await cache.get<HandoffData>(codeKey);
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
const storedHash = handoffData?.pollSecretHash ?? tokenData?.pollSecretHash;
if (!pollSecretMatches(pollSecret, storedHash)) {
if (!secretMatches(pollSecret, storedHash)) {
throw new InvalidHandoffCodeError();
}
await cache.delete(codeKey);
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAuthHarness, createTestAccount, loginAccount} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface HandoffInitiateResponse {
code: string;
poll_secret: string;
return_method: 'deep_link' | 'code';
}
interface HandoffInfoResponse {
status: 'pending' | 'expired';
return_method?: 'deep_link' | 'code';
}
interface HandoffCompleteResponse {
return_url: string;
}
interface HandoffStatusResponse {
status: 'pending' | 'completed' | 'denied' | 'expired';
token?: string | null;
user_id?: string | null;
}
const RETURN_URI = 'fluxer-canary://handoff';
describe('Auth desktop handoff deep link return', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function initiate(body: Record<string, unknown> | null): Promise<HandoffInitiateResponse> {
return createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
.post('/auth/handoff/initiate')
.body(body)
.execute();
}
async function readStatus(code: string, body: Record<string, unknown>): Promise<HandoffStatusResponse> {
return createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.post(`/auth/handoff/${code}/status`)
.body(body)
.execute();
}
it('releases the token only to the app that presents both the poll secret and the grant', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
expect(initResp.return_method).toBe('deep_link');
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.execute();
expect(info.return_method).toBe('deep_link');
const completed = await createBuilderWithoutAuth<HandoffCompleteResponse>(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
.expect(200)
.execute();
const returnUrl = new URL(completed.return_url);
expect(`${returnUrl.protocol}//${returnUrl.host}`).toBe(RETURN_URI);
expect(returnUrl.searchParams.get('code')).toBe(initResp.code);
const grant = returnUrl.searchParams.get('grant');
expect(grant).toBeTruthy();
const withoutGrant = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(withoutGrant.status).toBe('pending');
expect(withoutGrant.token ?? null).toBeNull();
const wrongGrant = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant: 'not-the-grant'});
expect(wrongGrant.status).toBe('pending');
const grantWithoutSecret = await readStatus(initResp.code, {poll_secret: 'not-the-secret', grant: grant!});
expect(grantWithoutSecret.status).toBe('pending');
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant: grant!});
expect(released.status).toBe('completed');
expect(released.token).toBeTruthy();
expect(released.user_id).toBe(login.userId);
});
it('keeps polling without a grant from counting as a failed attempt', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
const completed = await createBuilderWithoutAuth<HandoffCompleteResponse>(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
.execute();
for (let i = 0; i < 8; i++) {
const pending = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(pending.status).toBe('pending');
}
const grant = new URL(completed.return_url).searchParams.get('grant')!;
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant});
expect(released.status).toBe('completed');
});
it('still completes through the typed code when the app registered a deep link', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'code'})
.expect(204)
.execute();
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(released.status).toBe('completed');
});
it('reports the code return method when the app did not register a deep link', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate(null);
expect(initResp.return_method).toBe('code');
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.execute();
expect(info.return_method).toBe('code');
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
});
it('rejects a return deep link outside the desktop app schemes', async () => {
for (const returnUri of ['https://evil.example/handoff', 'fluxer://handoff/extra', 'javascript://handoff']) {
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/initiate')
.body({return_uri: returnUri})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
}
});
it('tells the app when the browser declines the request and blocks later approval', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
await createBuilderWithoutAuth(harness)
.post(`/auth/handoff/${initResp.code}/deny`)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
await createBuilderWithoutAuth(harness).post(`/auth/handoff/${initResp.code}/deny`).expect(204).execute();
const denied = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(denied.status).toBe('denied');
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.execute();
expect(info.status).toBe('expired');
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
});
});
@@ -15,7 +15,7 @@ import type {IUserRepository} from '@app/api/user/IUserRepository';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {afterEach, describe, expect, it} from 'vitest';
import {beforeEach, describe, expect, it} from 'vitest';
const GUILD_ID = createGuildID(100n);
const CHANNEL_ID = createChannelID(300n);
@@ -54,7 +54,7 @@ function enroll(): void {
const capableViewer: ThreadViewer = {kind: 'user', userId: USER_ID, bot: false, capable: true};
describe('getChannelAuthenticated thread gate', () => {
afterEach(() => {
beforeEach(() => {
syncChannelThreadsConfig(null, () => ChannelThreadsConfigSchema.parse({}));
});
@@ -76,10 +76,9 @@ import type {
} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas';
import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
const THREAD_CREATED_MESSAGE_MIN_SOURCE_AGE_MS = 5 * 60 * 1000;
const THREAD_CREATED_MESSAGE_RECENT_WINDOW = 5;
function slowmodeThreadKey(parentId: ChannelID, userId: UserID): string {
return `slowmode-thread:${parentId}:${userId}`;
@@ -159,13 +158,22 @@ export class ThreadCreationService {
}
const starter = await this.buildStarterEvent(params.viewer, params.user.id, view, params.requestCache);
if (starter) events.push(starter);
if (Date.now() - snowflakeToDate(source.id).getTime() >= THREAD_CREATED_MESSAGE_MIN_SOURCE_AGE_MS) {
if (!(await this.isAmongRecentMessages(parentAuth.channel.id, source.id))) {
events.push(await this.createThreadCreatedMessage(parentAuth.channel, view, params.user.id));
}
await dispatchThreadEvents(this.ctx.gatewayService, view.state.guildId, events);
return mapThreadToResponse(view);
}
private async isAmongRecentMessages(channelId: ChannelID, messageId: MessageID): Promise<boolean> {
const recent = await this.ctx.channelRepository.messages.listMessages(
channelId,
undefined,
THREAD_CREATED_MESSAGE_RECENT_WINDOW,
);
return recent.some((message) => message.id === messageId);
}
async authenticateParent(viewer: ThreadViewer, userId: UserID, channelId: ChannelID): Promise<AuthenticatedChannel> {
return this.ctx.channelAuth.getChannelAuthenticated({userId, channelId, viewer});
}
@@ -101,12 +101,11 @@ describe('DM Privacy Bidirectional Enforcement', () => {
.expect(HTTP_STATUS.OK)
.execute();
});
it('blocks message from a non-friend guild member to a new account by default', async () => {
it('allows message from a non-friend guild member to a new account by default', async () => {
const sender = await createTestAccount(harness);
const target = await createTestAccount(harness);
await ensureSessionStarted(harness, sender.token);
await ensureSessionStarted(harness, target.token);
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
await createFriendship(harness, sender, target);
const guild = await createGuild(harness, sender.token, 'Default Community');
const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!);
@@ -116,8 +115,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
await removeRelationship(harness, sender.token, target.userId);
await createBuilder(harness, sender.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'default restricted target'})
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
.body({content: 'default open target'})
.expect(HTTP_STATUS.OK)
.execute();
});
it('blocks message when sender restricts the only mutual guild', async () => {
@@ -268,25 +268,28 @@ describe('thread routes', () => {
expect(member?.flags).toBe(ThreadMemberFlags.HAS_INTERACTED);
});
it('announces threads started from older messages in the parent without moving its last message', async () => {
it('announces threads started from messages above the latest five without moving its last message', async () => {
const s = await setup();
const message = await sendMessage(harness, s.owner.token, s.channelId, 'old news');
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now + 6 * 60 * 1000);
const pushedUp = await sendMessage(harness, s.owner.token, s.channelId, 'old news');
const nearBottom = await sendMessage(harness, s.owner.token, s.channelId, 'recent news');
let latest = nearBottom;
for (let i = 0; i < 4; i++) latest = await sendMessage(harness, s.owner.token, s.channelId, `filler ${i}`);
const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
await threadsRequest(harness, s.owner.token)
.post(`/channels/${s.channelId}/messages/${message.id}/threads`)
.body({name: 'Later'})
.expect(201)
.execute();
for (const message of [nearBottom, pushedUp]) {
await threadsRequest(harness, s.owner.token)
.post(`/channels/${s.channelId}/messages/${message.id}/threads`)
.body({name: message.content})
.expect(201)
.execute();
}
const announced = dispatch.mock.calls
.map(([params]) => params.data as MessageResponse)
.filter((data) => data.type === MessageTypes.THREAD_CREATED);
expect(announced).toHaveLength(1);
expect(announced[0]).toMatchObject({channel_id: s.channelId, content: 'Later'});
expect(announced[0]!.message_reference?.channel_id).toBe(message.id);
expect(announced[0]).toMatchObject({channel_id: s.channelId, content: 'old news'});
expect(announced[0]!.message_reference?.channel_id).toBe(pushedUp.id);
const parent = await repository.findUnique(createChannelID(BigInt(s.channelId)));
expect(parent?.lastMessageId?.toString()).toBe(message.id);
expect(parent?.lastMessageId?.toString()).toBe(latest.id);
});
it('refuses a second thread on the same message', async () => {
@@ -2,14 +2,15 @@
import {
clearChannelThreadsTaintCacheForTesting,
syncChannelThreadsConfig,
getCompiledChannelThreadsConfig,
pinChannelThreadsConfigForTesting,
} from '@app/api/experiment/ChannelThreadsGate';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
import {
type ChannelThreadsConfig,
ChannelThreadsConfigSchema,
DEFAULT_CHANNEL_THREADS_CONFIG,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
export const THREADS_FEATURE_HEADER = 'X-Fluxer-Features';
@@ -22,7 +23,8 @@ export const ALL_THREADS_ACTIVE: Partial<ChannelThreadsConfig> = {
};
export async function setChannelThreadsConfig(patch: Partial<ChannelThreadsConfig>): Promise<void> {
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
const current = getCompiledChannelThreadsConfig().config;
pinChannelThreadsConfigForTesting(
ChannelThreadsConfigSchema.parse({
...patch,
ever_enabled: current.ever_enabled || patch.enabled === true,
@@ -33,7 +35,7 @@ export async function setChannelThreadsConfig(patch: Partial<ChannelThreadsConfi
}
export function resetChannelThreadsConfig(): void {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
pinChannelThreadsConfigForTesting(DEFAULT_CHANNEL_THREADS_CONFIG);
clearChannelThreadsTaintCacheForTesting();
}
@@ -28,14 +28,6 @@ export async function enqueueRemoveThreadMemberships(guildId: GuildID, userId: U
);
}
export async function enqueueRebuildThreadAutoArchiveQueue(guildId: string): Promise<void> {
await (await workerService()).addJob(
'rebuildThreadAutoArchiveQueue',
{guildId, configVersion: getCompiledChannelThreadsConfig().config.config_version},
{jobKey: `rebuild-thread-archive-queue-${guildId}`},
);
}
export function enqueueRepairThreadIndexes(threadIds: Array<ChannelID>): void {
if (threadIds.length === 0) return;
const window = Math.floor(Date.now() / REPAIR_DEBOUNCE_MS);
@@ -2,21 +2,14 @@
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createDmChannel, createFriendship, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import type {CassandraParams, KvQueryMeta, PreparedQuery} from '@app/api/database/CassandraTypes';
import {
clearChannelThreadsTaintCacheForTesting,
syncChannelThreadsConfig,
} from '@app/api/experiment/ChannelThreadsGate';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {getCompiledChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate';
import {type ApiTestHarness, 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 {
applyChannelThreadsConfigUpdate,
ChannelThreadsConfigSchema,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
class RecordingExecutor extends InMemoryCassandraQueryExecutor {
@@ -38,11 +31,6 @@ class RecordingExecutor extends InMemoryCassandraQueryExecutor {
}
}
function resetConfig(): void {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
clearChannelThreadsTaintCacheForTesting();
}
describe('channel threads control identity', () => {
let harness: ApiTestHarness;
let executor: RecordingExecutor;
@@ -53,13 +41,13 @@ describe('channel threads control identity', () => {
beforeEach(async () => {
await harness.reset();
resetConfig();
resetChannelThreadsConfig();
executor = new RecordingExecutor();
setCassandraQueryExecutorForTesting(executor);
});
afterEach(() => {
resetConfig();
resetChannelThreadsConfig();
});
afterAll(async () => {
@@ -93,10 +81,8 @@ describe('channel threads control identity', () => {
const baselineDm = await recorded(() => createDmChannel(harness, baseline.owner.token, baseline.friend.userId));
const baselineChannels = await loadChannels(baselineGuild.result.id);
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled: true, enabled_guild_ids: ['1']}),
);
expect((await getInstanceConfigRepository().refreshChannelThreadsConfig()).config.ever_enabled).toBe(true);
await setChannelThreadsConfig({enabled: true, enabled_guild_ids: ['1']});
expect(getCompiledChannelThreadsConfig().config.ever_enabled).toBe(true);
const enrolledOff = await createPair();
const guild = await recorded(() => createGuild(harness, enrolledOff.owner.token, 'control'));
@@ -3,22 +3,20 @@
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
import {ChannelThreadsRouteGuard} from '@app/api/channel/threads/ChannelThreadsRouteGuard';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import type {CassandraParams, PreparedQuery} from '@app/api/database/CassandraTypes';
import {getCompiledChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate';
import {BotOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {
applyChannelThreadsConfigUpdate,
type ChannelThreadsConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {ChannelIdParam, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {z} from 'zod';
@@ -96,6 +94,7 @@ describe('ChannelThreadsRouteGuard', () => {
beforeEach(async () => {
await harness.reset();
resetChannelThreadsConfig();
executor = new RecordingExecutor();
setCassandraQueryExecutorForTesting(executor);
owner = await createTestAccount(harness);
@@ -109,10 +108,8 @@ describe('ChannelThreadsRouteGuard', () => {
await harness.shutdown();
});
async function setConfig(update: ChannelThreadsConfigUpdateRequest): Promise<void> {
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, update),
);
async function setConfig(update: Partial<ChannelThreadsConfig>): Promise<void> {
await setChannelThreadsConfig({...getCompiledChannelThreadsConfig().config, ...update});
}
async function snapshot(
+4
View File
@@ -203,6 +203,7 @@ export interface APIConfig {
monthlyBrl?: string;
monthlyDkk?: string;
monthlyInr?: string;
monthlyIsk?: string;
monthlyNok?: string;
monthlyPln?: string;
monthlySek?: string;
@@ -212,6 +213,7 @@ export interface APIConfig {
yearlyBrl?: string;
yearlyDkk?: string;
yearlyInr?: string;
yearlyIsk?: string;
yearlyNok?: string;
yearlyPln?: string;
yearlySek?: string;
@@ -224,6 +226,8 @@ export interface APIConfig {
gift1YearDkk?: string;
gift1MonthNok?: string;
gift1YearNok?: string;
gift1MonthIsk?: string;
gift1YearIsk?: string;
gift1MonthBrl?: string;
gift1MonthInr?: string;
gift1MonthPln?: string;
@@ -274,6 +274,8 @@ export interface UserSettingsRow {
synced_preferences: Nullish<string>;
profile_privacy: Nullish<number>;
default_share_voice_activity: Nullish<boolean>;
privacy_setup_version: Nullish<number>;
privacy_setup_completed_at: Nullish<Date>;
version: number;
}
@@ -513,6 +515,8 @@ export const USER_SETTINGS_COLUMNS = [
'synced_preferences',
'profile_privacy',
'default_share_voice_activity',
'privacy_setup_version',
'privacy_setup_completed_at',
'version',
] as const satisfies ReadonlyArray<keyof UserSettingsRow>;
export const USER_GUILD_SETTINGS_COLUMNS = [
@@ -6,6 +6,7 @@ import type {IDonationRepository} from '@app/api/donation/IDonationRepository';
import type {IEmailDnsValidationService} from '@app/api/infrastructure/IEmailDnsValidationService';
import {Logger} from '@app/api/Logger';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import {shouldDisableAdaptivePricing} from '@app/api/utils/CurrencyUtils';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {DonationAmountInvalidError} from '@fluxer/errors/src/domains/donation/DonationAmountInvalidError';
@@ -173,6 +174,7 @@ export class DonationCheckoutService {
enabled: true,
},
...(isBusiness ? {billing_address_collection: 'required' as const} : {}),
...(shouldDisableAdaptivePricing(params.currency) ? {adaptive_pricing: {enabled: false}} : {}),
...(mode === 'payment'
? {
customer_creation: 'always' as const,
@@ -131,6 +131,8 @@ describe('POST /donations/checkout', () => {
}
| undefined;
expect(lineItem?.price_data?.currency).toBe(currency);
const nordic = ['sek', 'dkk', 'nok'].includes(currency);
expect(session?.adaptive_pricing).toEqual(nordic ? {enabled: 'false'} : undefined);
});
test('accepts monthly interval', async () => {
const response = await createDonationCheckoutBuilder(harness)
@@ -13,7 +13,7 @@ import {
channelThreadsUserActive,
channelThreadsUserExcluded,
compileChannelThreadsConfig,
DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG,
everyoneChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {Context} from 'hono';
@@ -39,19 +39,31 @@ const FETCH_GUILD_THREAD_STATE_CQL = GuildThreadState.selectCql({
const taintCache = new Map<string, {tainted: boolean; checkedAt: number}>();
let compiled: CompiledChannelThreadsConfig = DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG;
const INITIAL_COMPILED_CONFIG = compileChannelThreadsConfig(everyoneChannelThreadsConfig(0));
let compiled: CompiledChannelThreadsConfig = INITIAL_COMPILED_CONFIG;
let compiledSource: string | null | undefined;
let compiledParser: ((raw: string | null) => ChannelThreadsConfig) | undefined;
let pinnedForTesting = false;
export function syncChannelThreadsConfig(
raw: string | null,
parse: (raw: string | null) => ChannelThreadsConfig,
): CompiledChannelThreadsConfig {
if (raw === compiledSource) return compiled;
compiled = raw === null ? DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG : compileChannelThreadsConfig(parse(raw));
if (pinnedForTesting || (raw === compiledSource && parse === compiledParser)) return compiled;
compiled = compileChannelThreadsConfig(parse(raw));
compiledSource = raw;
compiledParser = parse;
return compiled;
}
export function pinChannelThreadsConfigForTesting(config: ChannelThreadsConfig | null): void {
compiled = config === null ? INITIAL_COMPILED_CONFIG : compileChannelThreadsConfig(config);
compiledSource = undefined;
compiledParser = undefined;
pinnedForTesting = config !== null;
}
export function getCompiledChannelThreadsConfig(): CompiledChannelThreadsConfig {
return compiled;
}
@@ -11,7 +11,6 @@ import {entityTagMatches} from '@app/api/utils/EntityTag';
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
import {resolveChannelThreadsAssignment} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {resolvePlutoniumPageAssignment} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
export function ExperimentController(app: HonoApp) {
@@ -31,21 +30,20 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, domainMigrationConfig, channelThreadsConfig, plutoniumPageConfig] = await Promise.all([
const [delivery, domainMigrationConfig, channelThreadsConfig] = await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getCompiledChannelThreadsConfig(),
instanceConfigRepository.getPlutoniumPageConfig(),
]);
const user = ctx.get('user');
const userId = user.id.toString();
const targeting = await resolveExperimentTargeting(user, [domainMigrationConfig, plutoniumPageConfig]);
const targeting = await resolveExperimentTargeting(user, domainMigrationConfig);
const body: ExperimentAssignmentsResponse = {
poll_interval_seconds: delivery.poll_interval_seconds,
poll_jitter_percent: delivery.poll_jitter_percent,
assignments: {
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
plutonium_page: resolvePlutoniumPageAssignment(plutoniumPageConfig, userId, targeting),
plutonium_page: {enabled: true},
},
};
const channelThreads = resolveChannelThreadsAssignment(channelThreadsConfig, userId);
@@ -13,9 +13,9 @@ const NO_GUILDS: ReadonlySet<string> = new Set();
export async function resolveExperimentTargeting(
user: User,
configs: ReadonlyArray<TargetableExperimentConfig>,
config: TargetableExperimentConfig,
): Promise<ExperimentTargeting> {
const needsGuilds = configs.some((config) => config.enabled && config.included_guild_ids.length > 0);
const needsGuilds = config.enabled && config.included_guild_ids.length > 0;
const memberGuildIds = needsGuilds
? new Set((await getUserRepository().getUserGuildIds(user.id)).map((guildId) => guildId.toString()))
: NO_GUILDS;
@@ -25,6 +25,7 @@ import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import {
type ChannelThreadsConfig,
ChannelThreadsConfigSchema,
everyoneChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
@@ -65,17 +66,17 @@ describe('ChannelThreadsGate', () => {
});
afterEach(() => {
syncChannelThreadsConfig(null, () => ChannelThreadsConfigSchema.parse({}));
load({});
setCassandraQueryExecutorForTesting(null);
});
it('is off for everyone while the config is missing', () => {
syncChannelThreadsConfig(null, () => {
throw new Error('a missing config is never parsed');
});
expect(guildActive(GUILD)).toBe(false);
expect(userActive(USER)).toBe(false);
expect(viewerActive(SYSTEM_THREAD_VIEWER, GUILD)).toBe(false);
it('hands a missing config to the parser', () => {
load({enabled: false});
const compiled = syncChannelThreadsConfig(null, (raw) => everyoneChannelThreadsConfig(raw === null ? 3 : 0));
expect(compiled.config.config_version).toBe(3);
expect(guildActive(GUILD)).toBe(true);
expect(userActive(USER)).toBe(true);
expect(viewerActive(SYSTEM_THREAD_VIEWER, GUILD)).toBe(true);
});
it('parses a snapshot once and reuses the compiled value while the string is unchanged', () => {
@@ -2,7 +2,6 @@
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
@@ -10,18 +9,11 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest
import {grantPremium} from '@app/api/user/tests/UserTestUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {
applyChannelThreadsConfigUpdate,
type ChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {DEFAULT_CHANNEL_THREADS_CONFIG} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {
DEFAULT_DOMAIN_MIGRATION_CONFIG,
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
DEFAULT_PLUTONIUM_PAGE_CONFIG,
INERT_PLUTONIUM_PAGE_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
import {
DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
@@ -31,7 +23,7 @@ import {
readDomainMigrationAssignment,
readPlutoniumPageAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const NOT_MODIFIED = 304;
const ENDPOINT = '/experiments';
@@ -47,10 +39,6 @@ describe('GET /experiments', () => {
await harness.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness.shutdown();
});
@@ -59,7 +47,7 @@ describe('GET /experiments', () => {
await createBuilderWithoutAuth(harness).get(ENDPOINT).expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
it('returns the default delivery cadence and the inert assignment while the feature is disabled', async () => {
it('returns the default delivery cadence and the default assignments', async () => {
const account = await createTestAccount(harness);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
@@ -69,7 +57,8 @@ describe('GET /experiments', () => {
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
assignments: {
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
plutonium_page: INERT_PLUTONIUM_PAGE_ASSIGNMENT,
channel_threads: {active: true, config_version: 0},
plutonium_page: {enabled: true},
},
});
});
@@ -83,13 +72,16 @@ describe('GET /experiments', () => {
expect(readDomainMigrationAssignment(body).enabled).toBe(false);
});
it('populates the plutonium page assignment key even when the rollout is disabled', async () => {
it('serves the plutonium page to everyone and ignores a leftover rollout row', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setConfig(
'plutonium_page_config',
JSON.stringify({enabled: false, config_version: 3, rollout_basis_points: 0}),
);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(Object.hasOwn(body.assignments, 'plutonium_page')).toBe(true);
expect(readPlutoniumPageAssignment(body).enabled).toBe(false);
expect(readPlutoniumPageAssignment(body)).toEqual({enabled: true});
});
it('resolves the domain migration caller through the allowlist', async () => {
@@ -129,54 +121,7 @@ describe('GET /experiments', () => {
expect(body.assignments.domain_migration).toEqual({enabled: false});
});
it('resolves the plutonium page caller through the allowlist and the exclusion list', async () => {
const targeted = await createTestAccount(harness);
const untargeted = await createTestAccount(harness);
const excluded = await createTestAccount(harness);
await getInstanceConfigRepository().setPlutoniumPageConfig({
...DEFAULT_PLUTONIUM_PAGE_CONFIG,
enabled: true,
rollout_basis_points: 0,
included_user_ids: [targeted.userId, excluded.userId],
excluded_user_ids: [excluded.userId],
});
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.plutonium_page).toEqual({enabled: true});
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
.get(ENDPOINT)
.execute();
expect(untargetedBody.assignments.plutonium_page).toEqual({enabled: false});
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
.get(ENDPOINT)
.execute();
expect(excludedBody.assignments.plutonium_page).toEqual({enabled: false});
});
it('keeps the plutonium page exclusion ahead of a full rollout', async () => {
const excluded = await createTestAccount(harness);
const other = await createTestAccount(harness);
await getInstanceConfigRepository().setPlutoniumPageConfig({
...DEFAULT_PLUTONIUM_PAGE_CONFIG,
enabled: true,
rollout_basis_points: 10000,
excluded_user_ids: [excluded.userId],
});
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
.get(ENDPOINT)
.execute();
expect(excludedBody.assignments.plutonium_page).toEqual({enabled: false});
const otherBody = await createBuilder<ExperimentAssignmentsResponse>(harness, other.token).get(ENDPOINT).execute();
expect(otherBody.assignments.plutonium_page).toEqual({enabled: true});
});
it('enrols members of an included guild in every experiment and leaves everyone else out', async () => {
it('enrols members of an included guild in domain migration and leaves everyone else out', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const outsider = await createTestAccount(harness);
@@ -184,29 +129,21 @@ describe('GET /experiments', () => {
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, member.token, invite.code);
const repository = getInstanceConfigRepository();
await repository.setDomainMigrationConfig({
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setPlutoniumPageConfig({
...DEFAULT_PLUTONIUM_PAGE_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
.get(ENDPOINT)
.execute();
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
expect(memberBody.assignments.plutonium_page).toEqual({enabled: true});
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
.get(ENDPOINT)
.execute();
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
expect(outsiderBody.assignments.plutonium_page).toEqual({enabled: false});
});
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
@@ -215,17 +152,11 @@ describe('GET /experiments', () => {
const free = await createTestAccount(harness);
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
await grantPremium(harness, visionary.userId, UserPremiumTypes.LIFETIME);
const repository = getInstanceConfigRepository();
await repository.setDomainMigrationConfig({
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
include_premium_users: true,
});
await repository.setPlutoniumPageConfig({
...DEFAULT_PLUTONIUM_PAGE_CONFIG,
enabled: true,
include_premium_users: true,
});
for (const [account, expected] of [
[subscriber, true],
[visionary, true],
@@ -233,30 +164,24 @@ describe('GET /experiments', () => {
] as const) {
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(body.assignments.domain_migration).toEqual({enabled: expected});
expect(body.assignments.plutonium_page).toEqual({enabled: expected});
}
});
it('stores the guild ids and premium switch an admin sets for each experiment', async () => {
it('stores the guild ids and premium switch an admin sets for domain migration', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const guildIds = ['1500000000000000001', '1500000000000000002'];
const body = await createBuilder<
Record<'domain_migration' | 'plutonium_page', {included_guild_ids: Array<string>; include_premium_users: boolean}>
>(harness, admin.token)
const body = await createBuilder<{
domain_migration: {included_guild_ids: Array<string>; include_premium_users: boolean};
}>(harness, admin.token)
.patch('/admin/instance/config')
.body({
domain_migration: {included_guild_ids: guildIds, include_premium_users: true},
plutonium_page: {included_guild_ids: guildIds, include_premium_users: true},
})
.body({domain_migration: {included_guild_ids: guildIds, include_premium_users: true}})
.execute();
for (const section of [body.domain_migration, body.plutonium_page]) {
expect(section.included_guild_ids).toEqual(guildIds);
expect(section.include_premium_users).toBe(true);
}
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
expect(body.domain_migration.include_premium_users).toBe(true);
});
it('revalidates with a strong etag and answers 304 when nothing changed', async () => {
@@ -411,38 +336,23 @@ describe('GET /experiments', () => {
expect(afterUndefined.domain_migration).toMatchObject({config_version: 1, enabled: true});
});
it('bumps the plutonium page config version on every admin update without the client sending one', async () => {
it('no longer exposes or accepts plutonium page and channel threads settings in the admin config', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const afterFirst = await createBuilder<{plutonium_page: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
const updated = await createBuilder<Record<string, unknown>>(harness, admin.token)
.patch('/admin/instance/config')
.body({plutonium_page: {enabled: true, included_user_ids: [admin.userId]}})
.body({plutonium_page: {enabled: false}, channel_threads: {enabled: false}})
.execute();
expect(afterFirst.plutonium_page).toMatchObject({config_version: 1, enabled: true});
const afterSecond = await createBuilder<{
plutonium_page: {config_version: number; rollout_basis_points: number};
}>(harness, admin.token)
.patch('/admin/instance/config')
.body({plutonium_page: {rollout_basis_points: 2500}})
.execute();
expect(afterSecond.plutonium_page).toMatchObject({config_version: 2, rollout_basis_points: 2500});
const afterEmpty = await createBuilder<{plutonium_page: {config_version: number}}>(harness, admin.token)
.patch('/admin/instance/config')
.body({plutonium_page: {}})
.execute();
expect(afterEmpty.plutonium_page).toMatchObject({config_version: 2});
expect(Object.hasOwn(updated, 'plutonium_page')).toBe(false);
expect(Object.hasOwn(updated, 'channel_threads')).toBe(false);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(body.assignments.plutonium_page).toEqual({enabled: true});
expect(readChannelThreadsAssignment(body)).toEqual({active: true, config_version: 0});
});
it('serves the delivery cadence an admin set through the instance config', async () => {
@@ -466,67 +376,20 @@ describe('GET /experiments', () => {
expect(body.poll_jitter_percent).toBe(DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT);
});
it('keeps the control body and etag for a user outside the channel threads experiment', async () => {
it('assigns channel threads to every user with the stored config version, even from a disabled row', async () => {
const account = await createTestAccount(harness);
const before = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
.get(ENDPOINT)
.executeWithResponse();
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled: true, included_user_ids: ['1']}),
);
const after = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
.get(ENDPOINT)
.executeWithResponse();
expect(Object.hasOwn(after.json.assignments, 'channel_threads')).toBe(false);
expect(readChannelThreadsAssignment(after.json)).toBeNull();
expect(after.response.headers.get('etag')).toBe(before.response.headers.get('etag'));
});
it('assigns channel threads to an included user with the config version', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled: true, included_user_ids: [account.userId]}),
await getInstanceConfigRepository().setConfig(
'channel_threads_config',
JSON.stringify({
...DEFAULT_CHANNEL_THREADS_CONFIG,
enabled: false,
config_version: 7,
excluded_user_ids: [account.userId],
}),
);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(readChannelThreadsAssignment(body)).toEqual({active: true, config_version: 1});
});
it('bumps the channel threads version, keeps ever_enabled sticky and publishes every admin update', async () => {
const publish = vi.spyOn(ChannelThreadsConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const patch = (body: Record<string, unknown>) =>
createBuilder<{channel_threads: ChannelThreadsConfig}>(harness, admin.token)
.patch('/admin/instance/config')
.body({channel_threads: body})
.execute();
const initial = await createBuilder<{channel_threads: ChannelThreadsConfig}>(harness, admin.token)
.get('/admin/instance/config')
.execute();
expect(initial.channel_threads).toMatchObject({enabled: false, config_version: 0, ever_enabled: false});
const enabled = await patch({enabled: true, included_user_ids: [admin.userId], ever_enabled: false});
expect(enabled.channel_threads).toMatchObject({enabled: true, config_version: 1, ever_enabled: true});
const disabled = await patch({enabled: false, config_version: 99});
expect(disabled.channel_threads).toMatchObject({enabled: false, config_version: 2, ever_enabled: true});
const unchanged = await patch({});
expect(unchanged.channel_threads.config_version).toBe(2);
expect(publish.mock.calls.map(([config]) => [config.config_version, config.enabled])).toEqual([
[1, true],
[2, false],
]);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(readChannelThreadsAssignment(body)).toBeNull();
expect(readChannelThreadsAssignment(body)).toEqual({active: true, config_version: 7});
});
});
@@ -67,6 +67,10 @@ const EXPECTED_SITES: Readonly<Record<string, {count: number; decision: string}>
count: 2,
decision: UNCHANGED,
},
'fluxer_app/src/features/gateway/snapshot/SnapshotChannelReducer.ts TEXT_BASED_CHANNEL_TYPES': {
count: 1,
decision: THREAD_TYPES_SEPARATE,
},
'fluxer_app/src/features/guild/components/modals/guild_tabs/GuildWebhooksTab.tsx GUILD_TEXT_BASED_CHANNEL_TYPES': {
count: 1,
decision: WEBHOOK_TARGETS,
@@ -1,8 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createGuildID, createUserID, type GuildID} from '@app/api/BrandedTypes';
import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository';
import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes';
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
import {
clearChannelThreadsTaintCacheForTesting,
isTainted,
@@ -16,7 +14,6 @@ import {
resolveThreadPermissionMode,
shouldMaskThreadPermissionBits,
} from '@app/api/guild/services/ThreadPermissionBits';
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {
applyProtectedOverwriteBits,
@@ -26,14 +23,7 @@ import {
protectedThreadBits,
} from '@app/api/utils/featureUtils';
import {computePermissionsDiff} from '@app/api/utils/PermissionUtils';
import {
enabledThreadGuildIds,
enqueueThreadPermissionSeeds,
newlyEnabledThreadGuildIds,
seedThreadOverwriteBits,
seedThreadOverwriteValue,
} from '@app/api/worker/tasks/SeedThreadPermissions';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import {seedThreadOverwriteBits, seedThreadOverwriteValue} from '@app/api/worker/tasks/SeedThreadPermissions';
import {ALL_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import {
@@ -43,13 +33,9 @@ import {
ThreadPermissionFlags,
} from '@fluxer/constants/src/ThreadPermissionUtils';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import {
ChannelThreadsConfigSchema,
DEFAULT_CHANNEL_THREADS_CONFIG,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {afterEach, describe, expect, it, vi} from 'vitest';
import {afterEach, describe, expect, it} from 'vitest';
const NONE: ReadonlySet<string> = new Set();
const CAPABLE: ReadonlySet<string> = new Set(['channel_threads']);
@@ -254,61 +240,4 @@ describe('thread permission seeding rules', () => {
Permissions.SEND_MESSAGES | DEFAULT_THREAD_PERMISSIONS,
);
});
it('targets enabled, non-disabled guilds of an enabled config', () => {
const config = {
...DEFAULT_CHANNEL_THREADS_CONFIG,
enabled: true,
enabled_guild_ids: ['1', '2', '3'],
disabled_guild_ids: ['3'],
};
expect(enabledThreadGuildIds(config)).toEqual(['1', '2']);
expect(enabledThreadGuildIds({...config, enabled: false})).toEqual([]);
});
it('lists only guilds that became active in the landed config', () => {
const previous = {...DEFAULT_CHANNEL_THREADS_CONFIG, enabled: true, enabled_guild_ids: ['1', '2', '3']};
expect(newlyEnabledThreadGuildIds(previous, previous)).toEqual([]);
expect(newlyEnabledThreadGuildIds(previous, {...previous, enabled_guild_ids: ['1', '2', '3', '4']})).toEqual(['4']);
expect(newlyEnabledThreadGuildIds({...previous, disabled_guild_ids: ['2']}, previous)).toEqual(['2']);
expect(newlyEnabledThreadGuildIds({...previous, enabled: false}, previous)).toEqual(['1', '2', '3']);
expect(newlyEnabledThreadGuildIds(previous, {...previous, enabled: false})).toEqual([]);
});
it('enqueues seeds for every targeted guild without a seeded marker, so a retried update still seeds', async () => {
const addJob = vi.fn(async () => {});
setInjectedWorkerService({addJob} as unknown as IWorkerService<WorkerTaskName>);
const markers = new Map<string, GuildThreadStateRow>([
[
'1',
{
guild_id: createGuildID(1n),
first_active_at: new Date(),
perms_seeded_at: new Date(),
search_backfilled_at: null,
},
],
[
'2',
{guild_id: createGuildID(2n), first_active_at: new Date(), perms_seeded_at: null, search_backfilled_at: null},
],
]);
const threads = {
getGuildMarker: async (guildId: GuildID) => markers.get(guildId.toString()) ?? null,
} as unknown as IThreadRepository;
const config = {...DEFAULT_CHANNEL_THREADS_CONFIG, enabled: true, enabled_guild_ids: ['1', '2', '3']};
try {
await enqueueThreadPermissionSeeds(threads, config);
await enqueueThreadPermissionSeeds(threads, config);
} finally {
setInjectedWorkerService(undefined);
}
const keys = addJob.mock.calls.map((call) => (call as unknown as [string, unknown, {jobKey: string}])[2].jobKey);
expect(keys).toEqual([
'seed-thread-permissions-2',
'seed-thread-permissions-3',
'seed-thread-permissions-2',
'seed-thread-permissions-3',
]);
});
});
@@ -4,12 +4,9 @@ import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createChannelID, createGuildID, createRoleID} from '@app/api/BrandedTypes';
import {authorizeBot, createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
import {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
import {fetchOne} from '@app/api/database/CassandraQueryExecution';
import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes';
import {
clearChannelThreadsTaintCacheForTesting,
syncChannelThreadsConfig,
} from '@app/api/experiment/ChannelThreadsGate';
import {GuildRoleRepository} from '@app/api/guild/repositories/GuildRoleRepository';
import {
acceptInvite,
@@ -20,7 +17,6 @@ import {
getChannel,
updateRolePositions,
} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {GuildThreadState} from '@app/api/Tables';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
@@ -31,10 +27,7 @@ import {
THREAD_PERMISSIONS,
ThreadPermissionFlags,
} from '@fluxer/constants/src/ThreadPermissionUtils';
import {
type ChannelThreadsConfig,
ChannelThreadsConfigSchema,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
@@ -50,17 +43,6 @@ const ACTIVE: Partial<ChannelThreadsConfig> = {
const FETCH_MARKER = GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1});
async function setConfig(patch: Partial<ChannelThreadsConfig>): Promise<void> {
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
ChannelThreadsConfigSchema.parse({
...patch,
ever_enabled: current.ever_enabled || patch.enabled === true,
config_version: current.config_version + 1,
}),
);
clearChannelThreadsTaintCacheForTesting();
}
function bits(value: string | undefined): bigint {
return BigInt(value ?? '0');
}
@@ -74,13 +56,11 @@ describe('thread permission bits across guild modes', () => {
beforeEach(async () => {
await harness.reset();
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
clearChannelThreadsTaintCacheForTesting();
resetChannelThreadsConfig();
});
afterEach(() => {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
clearChannelThreadsTaintCacheForTesting();
resetChannelThreadsConfig();
});
afterAll(async () => {
@@ -124,7 +104,7 @@ describe('thread permission bits across guild modes', () => {
});
test('active guilds seed @everyone, write bits for capable users and restore them for others', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Active');
expect(await roleBits(owner.token, guild.id, guild.id)).toBe(DEFAULT_PERMISSIONS | DEFAULT_THREAD_PERMISSIONS);
@@ -155,7 +135,7 @@ describe('thread permission bits across guild modes', () => {
});
test('bots write thread bits and bot invites keep them only for capable active users in active guilds', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Bots');
const bot = await createTestBotAccount(harness);
@@ -185,7 +165,7 @@ describe('thread permission bits across guild modes', () => {
.execute();
expect(bits(vcm.permissions)).toBe(0n);
await setConfig({enabled: false});
await setChannelThreadsConfig({enabled: false});
const controlOwner = await createTestAccount(harness);
const controlGuild = await createGuild(harness, controlOwner.token, 'Bots control');
const controlBot = await createTestBotAccount(harness);
@@ -197,7 +177,7 @@ describe('thread permission bits across guild modes', () => {
});
test('retired guilds keep stored thread bits read-only without escalation errors', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Retired');
@@ -222,7 +202,7 @@ describe('thread permission bits across guild modes', () => {
await acceptInvite(harness, member.token, invite.code);
await addMemberRole(harness, owner.token, guild.id, member.userId, mod.id);
await setConfig({enabled: false});
await setChannelThreadsConfig({enabled: false});
const updated = await createBuilder<GuildRoleResponse>(harness, member.token)
.patch(`/guilds/${guild.id}/roles/${target.id}`)
.header(FEATURES, CAPABLE)
@@ -268,7 +248,7 @@ describe('thread permission bits across guild modes', () => {
});
test('active channel creates store thread-aware overwrites and mask them for non-viewers', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Overwrites');
const allow = Permissions.SEND_MESSAGES | ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
@@ -309,7 +289,7 @@ describe('thread permission bits across guild modes', () => {
});
test('overwrite removals keep thread bits the actor cannot write, without escalation errors', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Removals');
@@ -352,7 +332,7 @@ describe('thread permission bits across guild modes', () => {
expect([after.everyone?.allow, after.everyone?.deny]).toEqual([inThreads, 0n]);
expect([after.target?.allow, after.target?.deny]).toEqual([0n, manageThreads]);
await setConfig({enabled: false});
await setChannelThreadsConfig({enabled: false});
await createBuilder(harness, member.token)
.patch(`/channels/${channel.id}`)
.header(FEATURES, CAPABLE)
@@ -368,7 +348,7 @@ describe('thread permission bits across guild modes', () => {
});
test('deleting a forum removes its thread-only index row', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Forum delete');
const guildId = createGuildID(BigInt(guild.id));
@@ -390,7 +370,7 @@ describe('thread permission bits across guild modes', () => {
expect(await repository.channelData.countGuildChannels(guildId)).toBe(before);
});
test('category sync and delete in a retired guild update forums without dispatching them', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Retired category');
const guildId = createGuildID(BigInt(guild.id));
@@ -407,7 +387,7 @@ describe('thread permission bits across guild modes', () => {
const forumId = createChannelID(BigInt(text.id) + 1n);
await repository.upsert({...source.toRow(), channel_id: forumId, type: ChannelTypes.GUILD_FORUM, name: 'forum'});
expect(await fetchOne<GuildThreadStateRow>(FETCH_MARKER, {guild_id: guildId})).not.toBeNull();
await setConfig({enabled: false});
await setChannelThreadsConfig({enabled: false});
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
const updatedIds = () =>
dispatchSpy.mock.calls
@@ -431,7 +411,7 @@ describe('thread permission bits across guild modes', () => {
}
});
test('channel overwrite edits keep thread bits for clients that cannot write them', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Overwrite edits');
const inThreads = ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
@@ -474,7 +454,7 @@ describe('thread permission bits across guild modes', () => {
expect(await storedAllow()).toBeUndefined();
});
test('lock_permissions moves copy parent thread bits only for writers', async () => {
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Lock sync');
const inThreads = ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
@@ -545,7 +525,7 @@ describe('thread permission bits across guild modes', () => {
const controlRoles = await listRoles(controlOwner.token, controlGuild.id, true);
expect(controlRoles.map((role) => bits(role.permissions) & THREAD_PERMISSIONS)).toEqual([0n, 0n]);
await setConfig(ACTIVE);
await setChannelThreadsConfig(ACTIVE);
const owner = await createTestAccount(harness);
const guild = await templated(owner.token);
const roles = await listRoles(owner.token, guild.id, true);
@@ -1,42 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
import {
clearChannelThreadsTaintCacheForTesting,
syncChannelThreadsConfig,
} from '@app/api/experiment/ChannelThreadsGate';
import {acceptInvite, createChannelInvite, createGuild} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {
type ChannelThreadsConfig,
ChannelThreadsConfigSchema,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
const FEATURES = 'X-Fluxer-Features';
const CAPABLE = 'channel_threads';
async function setConfig(patch: Partial<ChannelThreadsConfig>): Promise<void> {
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
ChannelThreadsConfigSchema.parse({
...patch,
ever_enabled: current.ever_enabled || patch.enabled === true,
config_version: current.config_version + 1,
}),
);
clearChannelThreadsTaintCacheForTesting();
}
function resetGate(): void {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
clearChannelThreadsTaintCacheForTesting();
}
describe('threads_active on GET /users/@me/guilds', () => {
let harness: ApiTestHarness;
@@ -46,12 +23,12 @@ describe('threads_active on GET /users/@me/guilds', () => {
beforeEach(async () => {
await harness.reset();
resetGate();
resetChannelThreadsConfig();
});
afterEach(() => {
vi.restoreAllMocks();
resetGate();
resetChannelThreadsConfig();
});
afterAll(async () => {
@@ -93,7 +70,7 @@ describe('threads_active on GET /users/@me/guilds', () => {
test('flags only the enrolled guild for an enrolled capable viewer', async () => {
const {owner, enrolled, plain, ownerBaseline} = await setup();
await setConfig(enrol(enrolled, [owner.userId]));
await setChannelThreadsConfig(enrol(enrolled, [owner.userId]));
const guilds = await listGuilds(owner.token, true);
const byId = new Map(guilds.map((guild) => [guild.id, guild]));
const baseline = new Map(ownerBaseline.map((guild) => [guild.id, guild]));
@@ -109,17 +86,17 @@ describe('threads_active on GET /users/@me/guilds', () => {
for (const guild of guilds) expect(guild).not.toHaveProperty('threads_active');
};
await setConfig(enrol(enrolled, [owner.userId]));
await setChannelThreadsConfig(enrol(enrolled, [owner.userId]));
expectUnchanged(await listGuilds(member.token, true), memberBaseline);
expectUnchanged(await listGuilds(owner.token, false), ownerBaseline);
await setConfig({enabled: true, enabled_guild_ids: [], included_user_ids: [owner.userId]});
await setChannelThreadsConfig({enabled: true, enabled_guild_ids: [], included_user_ids: [owner.userId]});
expectUnchanged(await listGuilds(owner.token, true), ownerBaseline);
await setConfig({...enrol(enrolled, [owner.userId]), excluded_user_ids: [owner.userId]});
await setChannelThreadsConfig({...enrol(enrolled, [owner.userId]), excluded_user_ids: [owner.userId]});
expectUnchanged(await listGuilds(owner.token, true), ownerBaseline);
await setConfig({...enrol(enrolled, [owner.userId]), enabled: false});
await setChannelThreadsConfig({...enrol(enrolled, [owner.userId]), enabled: false});
expectUnchanged(await listGuilds(owner.token, true), ownerBaseline);
});
@@ -70,7 +70,7 @@ describe('KVThreadAutoArchiveQueueService', () => {
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
kv = new MockKVProvider();
repositories = new ChannelRepository();
queue = new KVThreadAutoArchiveQueueService(kv, repositories.threads, repositories.channelData);
queue = new KVThreadAutoArchiveQueueService(kv);
base = Date.now() - 10 * HOUR;
await upsertOne(Channels.upsertAll({...threadRow(PARENT_ID), type: ChannelTypes.GUILD_TEXT, parent_id: null}));
});
@@ -134,14 +134,4 @@ describe('KVThreadAutoArchiveQueueService', () => {
await queue.schedule(transition!.state, null);
expect(await kv.zcard(threadArchiveQueueKey(GUILD_ID))).toBe(0);
});
it('rebuilds a guild from its active partition', async () => {
const first = await createThread(0, 60);
const second = await createThread(1000, 60);
await createThread(2000, 60, ChannelFlags.PINNED);
await kv.zadd(threadArchiveQueueKey(GUILD_ID), 1, '123');
expect(await queue.rebuildGuild(GUILD_ID)).toBe(2);
expect(await queue.getDue(GUILD_ID, Date.now(), 200)).toEqual([first.threadId, second.threadId]);
expect(await queue.getDue(GUILD_ID, Date.now(), 1)).toEqual([first.threadId]);
});
});
@@ -1,8 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type ChannelID, createChannelID, createGuildID, type GuildID, type MessageID} from '@app/api/BrandedTypes';
import type {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository';
import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository';
import type {ThreadState} from '@app/api/models/ThreadState';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
@@ -20,11 +18,7 @@ export function threadAutoArchiveDueAt(state: ThreadState, lastMessageId: Messag
}
export class KVThreadAutoArchiveQueueService {
constructor(
private readonly kvClient: IKVProvider,
private readonly threads: IThreadRepository,
private readonly channelData: IChannelDataRepository,
) {}
constructor(private readonly kvClient: IKVProvider) {}
async schedule(state: ThreadState, lastMessageId: MessageID | null): Promise<void> {
if (state.archived || state.isPinned) {
@@ -63,23 +57,4 @@ export class KVThreadAutoArchiveQueueService {
const members = await this.kvClient.smembers(THREAD_ARCHIVE_GUILDS_KEY);
return members.flatMap((member) => (/^\d+$/.test(member) ? [createGuildID(BigInt(member))] : []));
}
async rebuildGuild(guildId: GuildID): Promise<number> {
const key = threadArchiveQueueKey(guildId);
const states = (await this.threads.listActiveThreads(guildId)).filter((state) => !state.isPinned);
const channels = await this.channelData.listChannels(states.map((state) => state.threadId));
const lastMessageIds = new Map(channels.map((channel) => [channel.id, channel.lastMessageId]));
await this.kvClient.del(key);
if (states.length === 0) {
await this.kvClient.srem(THREAD_ARCHIVE_GUILDS_KEY, guildId.toString());
return 0;
}
const scoreMembers = states.flatMap((state) => [
threadAutoArchiveDueAt(state, lastMessageIds.get(state.threadId) ?? null),
state.threadId.toString(),
]);
await this.kvClient.zadd(key, ...scoreMembers);
await this.kvClient.sadd(THREAD_ARCHIVE_GUILDS_KEY, guildId.toString());
return states.length;
}
}
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const BRANDING_ASSET_FIELDS = ['icon_url', 'symbol_url', 'logo_url', 'wordmark_url', 'favicon_url'] as const;
export type BrandingAssetField = (typeof BRANDING_ASSET_FIELDS)[number];
const BRANDING_ASSET_REFERENCE = /^branding\/\d{1,20}\/(?:a_)?[0-9a-f]{8}\.[a-z0-9]{2,5}$/;
const BRANDING_ASSET_PATH = /\/(branding\/\d{1,20}\/(?:a_)?[0-9a-f]{8}\.[a-z0-9]{2,5})$/;
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/u, '');
}
export function isBrandingAssetReference(value: string): boolean {
return BRANDING_ASSET_REFERENCE.test(value);
}
export function brandingAssetReferenceFromOwnUrl(value: string, mediaEndpoint: string): string | null {
const prefix = `${trimTrailingSlashes(mediaEndpoint)}/`;
if (!value.startsWith(prefix)) return null;
const remainder = value.slice(prefix.length);
return isBrandingAssetReference(remainder) ? remainder : null;
}
export function brandingAssetReferenceFromAnyUrl(value: string): string | null {
let url: URL;
try {
url = new URL(value);
} catch {
return null;
}
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.search !== '' || url.hash !== '') return null;
return BRANDING_ASSET_PATH.exec(url.pathname)?.[1] ?? null;
}
export function brandingAssetStorageKey(reference: string): string {
const slash = reference.lastIndexOf('/');
const file = reference.slice(slash + 1);
const hash = file.slice(0, file.indexOf('.')).replace(/^a_/u, '');
return `${reference.slice(0, slash)}/${hash}`;
}
export function toStoredBrandingAsset(
value: string | null | undefined,
mediaEndpoint: string,
): string | null | undefined {
if (value === undefined || value === null) return value;
return brandingAssetReferenceFromOwnUrl(value, mediaEndpoint) ?? value;
}
export function resolveBrandingAsset(value: string | null, mediaEndpoint: string): string | null {
if (value === null || !isBrandingAssetReference(value)) return value;
return `${trimTrailingSlashes(mediaEndpoint)}/${value}`;
}
export function mapBrandingAssets<T extends Partial<Record<BrandingAssetField, string | null | undefined>>>(
branding: T,
map: (value: string | null | undefined) => string | null | undefined,
): T {
const next = {...branding};
for (const field of BRANDING_ASSET_FIELDS) {
if (field in next) {
next[field] = map(next[field]) as T[BrandingAssetField];
}
}
return next;
}
@@ -1,30 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
const textEncoder = new TextEncoder();
export const CHANNEL_THREADS_CONFIG_NATS_SUBJECT = 'config.channel.threads';
interface ChannelThreadsConfigNatsMessage {
type: 'channel_threads_config';
config: ChannelThreadsConfig;
}
export class ChannelThreadsConfigPublisher {
constructor(private readonly connectionManager: INatsConnectionManager) {}
async publish(config: ChannelThreadsConfig): Promise<void> {
if (this.connectionManager.isClosed()) {
await this.connectionManager.connect();
}
const connection = this.connectionManager.getConnection();
const message: ChannelThreadsConfigNatsMessage = {
type: 'channel_threads_config',
config,
};
connection.publish(CHANNEL_THREADS_CONFIG_NATS_SUBJECT, textEncoder.encode(JSON.stringify(message)));
await connection.flush();
}
}
@@ -3,6 +3,7 @@
import {spawnSync} from 'node:child_process';
import {createHash} from 'node:crypto';
import {createServer} from 'node:net';
import {Config} from '@app/api/Config';
import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
@@ -18,6 +19,10 @@ import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceC
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {
DEFAULT_CHANNEL_THREADS_CONFIG,
everyoneChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {
DEFAULT_DOMAIN_MIGRATION_CONFIG,
type DomainMigrationConfig,
@@ -35,6 +40,7 @@ import {
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
const CHANNEL_THREADS_CONFIG_KEY = 'channel_threads_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
@@ -217,6 +223,55 @@ describe('InstanceConfigRepository', () => {
expect(config.branding.product_name).toBe('Kept');
});
it('stores uploaded branding assets as references and resolves them against the current media endpoint', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
const media = Config.endpoints.media;
const foreign = 'https://cdn.example.com/favicon.ico';
await repository.setAppPublicConfig({
branding: {favicon_url: `${media}/branding/0/0123abcd.png`, logo_url: foreign},
});
const stored = JSON.parse((await repository.getConfig(APP_PUBLIC_CONFIG_KEY)) ?? '{}');
expect(stored.branding.favicon_url).toBe('branding/0/0123abcd.png');
expect(stored.branding.logo_url).toBe(foreign);
Config.endpoints.media = 'https://media.moved.example';
try {
const config = await repository.getAppPublicConfig();
expect(config.branding.favicon_url).toBe('https://media.moved.example/branding/0/0123abcd.png');
expect(config.branding.logo_url).toBe(foreign);
} finally {
Config.endpoints.media = media;
}
});
it('normalises legacy branding URLs from an old domain only when the object is ours', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await repository.setConfig(
APP_PUBLIC_CONFIG_KEY,
JSON.stringify({
branding: {
favicon_url: 'https://old.example/media/branding/0/a_0123abcd.gif',
icon_url: 'https://other.example/branding/0/89abcdef.png',
},
}),
);
const storage = {
getObjectMetadata: vi.fn(async (_bucket: string, key: string) =>
key === 'branding/0/0123abcd' ? {contentLength: 1, contentType: 'image/gif'} : null,
),
};
expect(await repository.normalizeStoredBrandingAssets(storage as never)).toBe(1);
expect(await repository.normalizeStoredBrandingAssets(storage as never)).toBe(0);
const config = await repository.getAppPublicConfig();
expect(config.branding.favicon_url).toBe(`${Config.endpoints.media}/branding/0/a_0123abcd.gif`);
expect(config.branding.icon_url).toBe('https://other.example/branding/0/89abcdef.png');
});
it('keeps valid stored instance policy flags when one field is invalid', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
@@ -301,6 +356,37 @@ describe('InstanceConfigRepository', () => {
expect(domains).not.toContain('example.com');
});
it('serves the everyone channel threads config at version zero when the key is absent', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await expect(repository.getChannelThreadsConfig()).resolves.toEqual(everyoneChannelThreadsConfig(0));
});
it.each([
{
name: 'a disabled row',
stored: JSON.stringify({
...DEFAULT_CHANNEL_THREADS_CONFIG,
enabled: false,
config_version: 9,
disabled_guild_ids: ['1400000000000000001'],
excluded_user_ids: ['1400000000000000002'],
}),
version: 9,
},
{name: 'a partial rollout row', stored: '{"enabled":true,"config_version":4,"guild_basis_points":100}', version: 4},
{name: 'a row with an invalid version', stored: '{"enabled":false,"config_version":-1}', version: 0},
{name: 'unparseable text', stored: 'not-json', version: 0},
])('serves the everyone channel threads config for $name and keeps the stored version', async ({stored, version}) => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await repository.setConfig(CHANNEL_THREADS_CONFIG_KEY, stored);
await expect(repository.getChannelThreadsConfig()).resolves.toEqual(everyoneChannelThreadsConfig(version));
});
it('returns the default domain migration config when the key is absent', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
@@ -7,12 +7,23 @@ import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/datab
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
import {syncChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {
type AccountIdentity,
resolveAccountIdentity,
type StoredAccountIdentity,
setCachedAccountIdentity,
} from '@app/api/instance/AccountIdentityModeCache';
import {
BRANDING_ASSET_FIELDS,
type BrandingAssetField,
brandingAssetReferenceFromAnyUrl,
brandingAssetStorageKey,
isBrandingAssetReference,
mapBrandingAssets,
resolveBrandingAsset,
toStoredBrandingAsset,
} from '@app/api/instance/BrandingAssetReferences';
import {
getDefaultDateOfBirthCollection,
setCachedDateOfBirthCollection,
@@ -53,8 +64,8 @@ import {
} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
import {
type ChannelThreadsConfig,
ChannelThreadsConfigSchema,
type CompiledChannelThreadsConfig,
everyoneChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {
type DomainMigrationConfig,
@@ -69,10 +80,6 @@ import {
type StoredBillingConfig,
StoredBillingConfigSchema,
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
import {
type PlutoniumPageConfig,
PlutoniumPageConfigSchema,
} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
import {
type LegacyPushServiceDeliveryWire,
type PushRelayConfig,
@@ -100,7 +107,6 @@ import {z} from 'zod';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
const PLUTONIUM_PAGE_CONFIG_KEY = 'plutonium_page_config';
const CAPTCHA_CONFIG_KEY = 'captcha_config';
const CHANNEL_THREADS_CONFIG_KEY = 'channel_threads_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
@@ -433,7 +439,6 @@ type StoredConfigSection =
| 'gateway rollout'
| 'push relay'
| 'domain migration'
| 'plutonium page'
| 'captcha'
| 'channel threads'
| 'experiment delivery'
@@ -653,16 +658,15 @@ function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationCo
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
}
function parseStoredPlutoniumPageConfig(raw: string | null): PlutoniumPageConfig {
return parseStoredConfigOrDefault(PlutoniumPageConfigSchema, raw, 'plutonium page');
}
function parseStoredCaptchaConfig(raw: string | null): CaptchaConfig {
return parseStoredConfigOrDefault(CaptchaConfigSchema, raw, 'captcha');
}
const StoredChannelThreadsVersionSchema = z.object({config_version: z.number().int().min(0)});
function parseStoredChannelThreadsConfig(raw: string | null): ChannelThreadsConfig {
return parseStoredConfigOrDefault(ChannelThreadsConfigSchema, raw, 'channel threads');
const stored = StoredChannelThreadsVersionSchema.safeParse(readStoredConfigValue(raw, 'channel threads'));
return everyoneChannelThreadsConfig(stored.success ? stored.data.config_version : 0);
}
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
@@ -691,11 +695,26 @@ const StoredInstanceAppPublicSchema = InstanceAppPublicSchema.extend({
});
function parseStoredAppPublicConfig(raw: string | null): InstanceAppPublicConfig {
return buildAppPublicConfig(
salvageStoredConfig(StoredInstanceAppPublicSchema, readStoredConfigValue(raw, 'app public'), 'app public'),
return resolveAppPublicConfig(
buildAppPublicConfig(
salvageStoredConfig(StoredInstanceAppPublicSchema, readStoredConfigValue(raw, 'app public'), 'app public'),
),
);
}
function resolveAppPublicConfig(config: InstanceAppPublicConfig): InstanceAppPublicConfig {
return {
...config,
branding: mapBrandingAssets(config.branding, (value) =>
resolveBrandingAsset(value ?? null, Config.endpoints.media),
),
};
}
function toStoredBranding<T extends Partial<Record<BrandingAssetField, string | null>>>(branding: T): T {
return mapBrandingAssets(branding, (value) => toStoredBrandingAsset(value, Config.endpoints.media));
}
function buildAppPublicConfig(config: z.infer<typeof StoredInstanceAppPublicSchema>): InstanceAppPublicConfig {
const defaults = getDefaultAppPublicConfig();
const {branding = {}, setup = {}, legal = {}, registration = {}} = config;
@@ -1327,7 +1346,6 @@ export class InstanceConfigRepository {
);
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
parseStoredPlutoniumPageConfig(snapshot.get(PLUTONIUM_PAGE_CONFIG_KEY) ?? null);
parseStoredCaptchaConfig(snapshot.get(CAPTCHA_CONFIG_KEY) ?? null);
syncChannelThreadsConfig(snapshot.get(CHANNEL_THREADS_CONFIG_KEY) ?? null, parseStoredChannelThreadsConfig);
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
@@ -1571,23 +1589,6 @@ export class InstanceConfigRepository {
);
}
async getPlutoniumPageConfig(): Promise<PlutoniumPageConfig> {
const raw = await this.getConfig(PLUTONIUM_PAGE_CONFIG_KEY);
return parseStoredPlutoniumPageConfig(raw);
}
async setPlutoniumPageConfig(config: PlutoniumPageConfig): Promise<void> {
await this.updatePlutoniumPageConfig(() => config);
}
updatePlutoniumPageConfig(
update: (current: PlutoniumPageConfig) => PlutoniumPageConfig,
): Promise<PlutoniumPageConfig> {
return this.updateStoredConfig(PLUTONIUM_PAGE_CONFIG_KEY, (raw) =>
validateStoredConfig(PlutoniumPageConfigSchema, update(parseStoredPlutoniumPageConfig(raw)), 'plutonium page'),
);
}
async getCaptchaConfig(): Promise<CaptchaConfig> {
const raw = await this.getConfig(CAPTCHA_CONFIG_KEY);
return parseStoredCaptchaConfig(raw);
@@ -1613,16 +1614,6 @@ export class InstanceConfigRepository {
return syncChannelThreadsConfig(raw, parseStoredChannelThreadsConfig);
}
async updateChannelThreadsConfig(
update: (current: ChannelThreadsConfig) => ChannelThreadsConfig,
): Promise<ChannelThreadsConfig> {
const landed = await this.updateStoredConfig(CHANNEL_THREADS_CONFIG_KEY, (raw) =>
validateStoredConfig(ChannelThreadsConfigSchema, update(parseStoredChannelThreadsConfig(raw)), 'channel threads'),
);
syncChannelThreadsConfig(JSON.stringify(landed), parseStoredChannelThreadsConfig);
return landed;
}
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
return parseStoredExperimentDeliveryConfig(raw);
@@ -1693,8 +1684,8 @@ export class InstanceConfigRepository {
StoredInstanceAppPublicSchema,
{
branding: {
...current.branding,
...(config.branding ?? {}),
...toStoredBranding(current.branding),
...toStoredBranding(config.branding ?? {}),
premium_product_name: premiumProductName,
},
setup: {
@@ -1712,13 +1703,68 @@ export class InstanceConfigRepository {
},
'app public',
);
return {value: JSON.stringify(merged), result: buildAppPublicConfig(merged)};
return {value: JSON.stringify(merged), result: resolveAppPublicConfig(buildAppPublicConfig(merged))};
});
await this.publishRefresh(cache.sourceId);
setCachedDateOfBirthCollection(next.registration.collect_date_of_birth);
return next;
}
async normalizeStoredBrandingAssets(storageService: IStorageService): Promise<number> {
const cache = this.configCache;
await cache.getSnapshot();
cache.assertActive();
const raw = await this.fetchConfigFromDatabase(APP_PUBLIC_CONFIG_KEY);
if (raw === null) return 0;
const stored = salvageStoredConfig(
StoredInstanceAppPublicSchema,
readStoredConfigValue(raw, 'app public'),
'app public',
);
const rewrites = new Map<string, string>();
for (const field of BRANDING_ASSET_FIELDS) {
const value = stored.branding?.[field];
if (typeof value !== 'string' || isBrandingAssetReference(value) || rewrites.has(value)) continue;
const reference = toStoredBrandingAsset(value, Config.endpoints.media);
if (reference !== value && typeof reference === 'string') {
rewrites.set(value, reference);
continue;
}
const candidate = brandingAssetReferenceFromAnyUrl(value);
if (candidate === null) continue;
const metadata = await storageService.getObjectMetadata(
Config.s3.buckets.cdn,
brandingAssetStorageKey(candidate),
);
if (metadata !== null) {
rewrites.set(value, candidate);
}
}
if (rewrites.size === 0) return 0;
const {result} = await this.compareAndSetStoredValue(cache, APP_PUBLIC_CONFIG_KEY, (current) => {
const latest = salvageStoredConfig(
StoredInstanceAppPublicSchema,
readStoredConfigValue(current, 'app public'),
'app public',
);
if (!latest.branding) return {value: null, result: 0};
let changed = 0;
const branding = mapBrandingAssets(latest.branding, (value) => {
const rewrite = typeof value === 'string' ? rewrites.get(value) : undefined;
if (rewrite === undefined) return value;
changed++;
return rewrite;
});
if (changed === 0) return {value: null, result: 0};
const next = validateStoredConfig(StoredInstanceAppPublicSchema, {...latest, branding}, 'app public');
return {value: JSON.stringify(next), result: changed};
});
if (result > 0) {
await this.publishRefresh(cache.sourceId);
}
return result;
}
async getInstancePolicyConfig(): Promise<InstancePolicyConfig> {
const raw = await this.getConfig(INSTANCE_POLICY_CONFIG_KEY);
return parseStoredInstancePolicyConfig(raw);
@@ -65,6 +65,7 @@ function buildDiscoveryStaticInput(
api_public: apiPublicEndpoint,
gateway: Config.endpoints.gateway,
media: Config.endpoints.media,
upload_relay: Config.mediaProxy.uploadRelay.endpoint,
static_cdn: Config.endpoints.staticCdn,
marketing: Config.endpoints.marketing,
admin: Config.endpoints.admin,
@@ -47,7 +47,6 @@ import {createStorageService} from '@app/api/infrastructure/StorageServiceFactor
import {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClient';
import {VirusScanService} from '@app/api/infrastructure/VirusScanService';
import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher';
import {GatewayRolloutConfigPublisher} from '@app/api/instance/GatewayRolloutConfigPublisher';
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
@@ -175,17 +174,6 @@ export const getPushRelayConfigPublisher = singleton(
),
);
export const getChannelThreadsConfigPublisher = singleton(
() =>
new ChannelThreadsConfigPublisher(
new NatsConnectionManager({
url: Config.nats.coreUrl,
token: Config.nats.authToken || undefined,
name: 'fluxer-api-channel-threads-config',
}),
),
);
export const getVisionarySlotRepository = singleton(() => new VisionarySlotRepository());
export const getCacheService: () => ICacheService = singleton(() => new KVCacheProvider({client: getKVClient()}));
export const getRateLimitService = singleton(() => new RateLimitService(getKVClient()));
@@ -278,8 +266,7 @@ let threadAutoArchiveQueue: KVThreadAutoArchiveQueueService | null = null;
export function getKVThreadAutoArchiveQueue(): KVThreadAutoArchiveQueueService {
const kvClient = getKVClient();
if (!threadAutoArchiveQueue || threadAutoArchiveQueueClient !== kvClient) {
const channels = getChannelRepository();
threadAutoArchiveQueue = new KVThreadAutoArchiveQueueService(kvClient, channels.threads, channels.channelData);
threadAutoArchiveQueue = new KVThreadAutoArchiveQueueService(kvClient);
threadAutoArchiveQueueClient = kvClient;
}
return threadAutoArchiveQueue;
+9 -1
View File
@@ -68,6 +68,8 @@ export class UserSettings {
readonly syncedPreferences: string;
readonly profilePrivacy: ProfilePrivacyLevel;
readonly defaultShareVoiceActivity: boolean;
readonly privacySetupVersion: number;
readonly privacySetupCompletedAt: Date | null;
readonly version: number;
constructor(row: UserSettingsRow) {
@@ -115,6 +117,8 @@ export class UserSettings {
this.syncedPreferences = normalizeStoredSyncedPreferences(row.synced_preferences);
this.profilePrivacy = (row.profile_privacy ?? ProfilePrivacyLevels.ALL_GUILDS) as ProfilePrivacyLevel;
this.defaultShareVoiceActivity = row.default_share_voice_activity ?? true;
this.privacySetupVersion = row.privacy_setup_version ?? 0;
this.privacySetupCompletedAt = row.privacy_setup_completed_at ?? null;
this.version = row.version;
}
@@ -171,6 +175,8 @@ export class UserSettings {
synced_preferences: this.syncedPreferences === '' ? null : this.syncedPreferences,
profile_privacy: this.profilePrivacy,
default_share_voice_activity: this.defaultShareVoiceActivity,
privacy_setup_version: this.privacySetupVersion,
privacy_setup_completed_at: this.privacySetupCompletedAt,
version: this.version,
};
}
@@ -216,7 +222,7 @@ export class UserSettings {
friend_source_flags: friendSourceFlags,
incoming_call_flags: IncomingCallFlags.FRIENDS_ONLY,
group_dm_add_permission_flags: GroupDmAddPermissionFlags.FRIENDS_ONLY,
default_guilds_restricted: true,
default_guilds_restricted: false,
bot_default_guilds_restricted: false,
restricted_guilds: new Set(),
bot_restricted_guilds: new Set(),
@@ -244,6 +250,8 @@ export class UserSettings {
synced_preferences: null,
profile_privacy: ProfilePrivacyLevels.ALL_GUILDS,
default_share_voice_activity: true,
privacy_setup_version: 0,
privacy_setup_completed_at: null,
version: 1,
};
}
+210 -8
View File
@@ -308,6 +308,14 @@
"summary": "Complete handoff",
"tags": ["Auth"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {"anyOf": [{"$ref": "#/components/schemas/HandoffCompleteResponse"}, {"type": "null"}]}
}
}
},
"204": {"description": "No Content"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
@@ -340,7 +348,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Complete the handoff process and authenticate on the target device using the handoff code.",
"description": "Complete the handoff process and authenticate on the target device using the handoff code. With the deep_link return method, responds with the deep link that hands the one-time grant back to the initiating app. Otherwise responds with no content.",
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HandoffCompleteRequest"}}}
@@ -388,7 +396,11 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking."
"description": "Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking. A desktop app that registers a return deep link can be signed in without the user typing the code.",
"requestBody": {
"required": false,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HandoffInitiateRequest"}}}
}
}
},
"/auth/handoff/{code}": {
@@ -445,6 +457,56 @@
}
}
},
"/auth/handoff/{code}/deny": {
"post": {
"operationId": "deny_handoff",
"summary": "Deny handoff",
"tags": ["Auth"],
"responses": {
"204": {"description": "No Content"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Decline a handoff request after looking it up. The initiating device sees the denied status and the code can no longer be approved.",
"parameters": [
{
"name": "code",
"in": "path",
"required": true,
"schema": {"description": "The handoff code", "type": "string"},
"description": "The handoff code"
}
]
}
},
"/auth/handoff/{code}/info": {
"get": {
"operationId": "get_handoff_info",
@@ -24921,6 +24983,12 @@
"type": "boolean",
"description": "Default share_voice_activity applied to new friend relationships"
},
"privacy_setup_version": {
"type": "integer",
"minimum": 1,
"maximum": 1000,
"description": "Version of the privacy setup the user just reviewed. The server also records privacy_setup_completed_at as the current time."
},
"synced_preferences": {
"description": "Account-wide client preferences as a base64-encoded protobuf snapshot. Replaces the entire stored snapshot; pass null to clear it.",
"anyOf": [{"type": "string", "maxLength": 699052}, {"type": "null"}]
@@ -25074,6 +25142,23 @@
"default_share_voice_activity": {
"type": "boolean",
"description": "Default value of share_voice_activity applied to newly accepted friend relationships. Read-only here; mutated via PUT /users/@me/settings/voice-activity-sharing."
},
"privacy_setup_version": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"description": "Latest privacy setup version the user has reviewed, 0 if never reviewed"
},
"privacy_setup_completed_at": {
"anyOf": [
{
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
{"type": "null"}
],
"description": "When the user last completed the privacy setup, or null if never"
}
},
"required": [
@@ -25111,7 +25196,9 @@
"staff_dm_access_user_ids",
"synced_preferences",
"profile_privacy",
"default_share_voice_activity"
"default_share_voice_activity",
"privacy_setup_version",
"privacy_setup_completed_at"
],
"additionalProperties": false
},
@@ -32794,14 +32881,18 @@
"HandoffStatusRequest": {
"type": "object",
"properties": {
"poll_secret": {"description": "The poll secret issued when the handoff was initiated", "type": "string"}
"poll_secret": {"description": "The poll secret issued when the handoff was initiated", "type": "string"},
"grant": {"description": "The one-time grant delivered to the app through the deep link", "type": "string"}
},
"required": ["poll_secret"]
},
"HandoffStatusResponse": {
"type": "object",
"properties": {
"status": {"type": "string", "description": "Current status of the handoff (pending, completed, expired)"},
"status": {
"type": "string",
"description": "Current status of the handoff (pending, completed, denied, expired)"
},
"token": {"description": "Authentication token if handoff is complete", "type": ["string", "null"]},
"user_id": {
"description": "User ID if handoff is complete",
@@ -32842,6 +32933,10 @@
},
{"type": "null"}
]
},
"return_method": {
"description": "How the approving browser hands the sign-in back. deep_link opens the initiating app, code relies on the user comparing the code",
"$ref": "#/components/schemas/DesktopHandoffReturnMethod"
}
},
"required": ["status"],
@@ -32854,6 +32949,20 @@
},
"required": ["poll_secret"]
},
"HandoffInitiateRequest": {
"anyOf": [
{
"type": "object",
"properties": {
"return_uri": {
"description": "Deep link the approving browser opens to return the sign-in to the initiating desktop app",
"type": "string"
}
}
},
{"type": "null"}
]
},
"HandoffInitiateResponse": {
"type": "object",
"properties": {
@@ -32867,6 +32976,10 @@
"poll_secret": {
"description": "Secret the initiating device must present to retrieve the token",
"type": "string"
},
"return_method": {
"description": "deep_link when the approving browser will hand the sign-in back through the return deep link, code otherwise",
"$ref": "#/components/schemas/DesktopHandoffReturnMethod"
}
},
"required": ["code", "expires_at"],
@@ -32877,10 +32990,25 @@
"properties": {
"code": {"description": "The handoff code from the initiating session", "type": "string"},
"token": {"description": "The authentication token to transfer", "type": "string"},
"user_id": {"description": "The user ID associated with the authenticated session", "type": "string"}
"user_id": {"description": "The user ID associated with the authenticated session", "type": "string"},
"return_method": {
"description": "deep_link returns a one-time grant the initiating app must present, code releases the token to the poll secret alone",
"$ref": "#/components/schemas/DesktopHandoffReturnMethod"
}
},
"required": ["code", "user_id"]
},
"HandoffCompleteResponse": {
"type": "object",
"properties": {
"return_url": {
"type": "string",
"description": "Deep link that returns the sign-in to the initiating app, with its one-time grant"
}
},
"required": ["return_url"],
"additionalProperties": false
},
"ForgotPasswordRequest": {
"type": "object",
"properties": {
@@ -33000,6 +33128,7 @@
"WellKnownFluxerResponse": {
"type": "object",
"properties": {
"codename": {"description": "Protocol generation spoken by this instance", "type": "string"},
"api_code_version": {
"type": "integer",
"minimum": -9007199254740991,
@@ -33104,6 +33233,7 @@
"description": "Public application configuration for client-side features",
"$ref": "#/components/schemas/InstanceAppPublicSchema"
},
"age_policy": {"$ref": "#/components/schemas/InstanceAgePolicySchema"},
"domain_migration": {
"description": "Web domain migration switch and anonymous rollout, only acted on by official instance clients",
"$ref": "#/components/schemas/DomainMigrationDiscoveryResponse"
@@ -33150,6 +33280,19 @@
"required": ["enabled", "anonymous_rollout_basis_points", "rollout_salt", "standalone_forwarding"],
"additionalProperties": false
},
"InstanceAgePolicySchema": {
"type": "object",
"properties": {
"geos": {
"type": "array",
"items": {"$ref": "#/components/schemas/InstanceAgePolicyGeoSchema"},
"description": "Regions with an age policy"
}
},
"required": ["geos"],
"additionalProperties": false,
"description": "Regional age policy for this instance"
},
"InstanceAppPublicSchema": {
"type": "object",
"properties": {
@@ -33253,7 +33396,23 @@
"InstancePushSchema": {
"type": "object",
"properties": {
"public_vapid_key": {"description": "VAPID public key for web push notifications", "type": ["string", "null"]}
"public_vapid_key": {
"description": "VAPID public key for web push notifications",
"type": ["string", "null"]
},
"delivery_mode": {
"description": "How push notifications reach devices",
"$ref": "#/components/schemas/InstancePushDeliveryModeSchema"
},
"event_resolution_path": {
"description": "API path clients call to resolve the payload of a push event",
"type": "string"
},
"external_relay_url": {"description": "URL of the external push relay, if any", "type": ["string", "null"]},
"relay_signing_key_hashes": {
"description": "Hashes of the keys the external push relay signs with",
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]
}
},
"required": ["public_vapid_key"],
"additionalProperties": false,
@@ -33327,7 +33486,8 @@
"enabled": {"type": "boolean", "description": "Whether SSO is enabled for this instance"},
"enforced": {"type": "boolean", "description": "Whether SSO is required for all users"},
"display_name": {"description": "Display name of the SSO provider", "type": ["string", "null"]},
"redirect_uri": {"type": "string", "description": "OAuth redirect URI for SSO"}
"redirect_uri": {"type": "string", "description": "OAuth redirect URI for SSO"},
"available": {"description": "Whether an SSO provider is configured for this instance", "type": "boolean"}
},
"required": ["enabled", "enforced", "display_name", "redirect_uri"],
"additionalProperties": false,
@@ -33373,6 +33533,22 @@
"description": "Whether the instance sends emails (verification, password reset, etc.)"
},
"phone_verification_enabled": {"type": "boolean", "description": "Deprecated. Always false."},
"desktop_modules_enabled": {
"description": "Whether desktop clients may load downloadable modules",
"type": "boolean"
},
"account_deletion_grace_period_hours": {
"description": "Hours between a deletion request and the permanent deletion of the account",
"type": "integer",
"minimum": 1,
"maximum": 8760
},
"max_background_gateway_connections": {
"description": "Maximum gateway connections a client may keep open for background accounts",
"type": "integer",
"minimum": 0,
"maximum": 64
},
"account_identity": {
"description": "How people sign in on this instance. Clients treat a missing value as email",
"$ref": "#/components/schemas/AccountIdentityModeSchema"
@@ -33415,8 +33591,10 @@
"api_public": {"type": "string", "description": "Base URL for public API requests"},
"gateway": {"type": "string", "description": "WebSocket URL for the gateway"},
"media": {"type": "string", "description": "Base URL for the media proxy"},
"upload_relay": {"description": "Base URL for proxied attachment and preview uploads", "type": "string"},
"static_cdn": {"type": "string", "description": "Base URL for static assets (avatars, emojis, etc.)"},
"marketing": {"type": "string", "description": "Base URL for the marketing website"},
"docs": {"description": "Base URL for the documentation website", "type": "string"},
"admin": {"type": "string", "description": "Base URL for the admin panel"},
"invite": {"type": "string", "description": "Base URL for invite links"},
"gift": {"type": "string", "description": "Base URL for gift links"},
@@ -33467,6 +33645,29 @@
"enum": ["open", "approval", "closed"],
"type": "string"
},
"InstancePushDeliveryModeSchema": {"type": "string", "enum": ["direct", "external_relay", "hybrid"]},
"InstanceAgePolicyGeoSchema": {
"type": "object",
"properties": {
"country_code": {"type": "string", "description": "ISO 3166-1 alpha-2 country code"},
"region_code": {
"description": "ISO 3166-2 subdivision code, or null for the whole country",
"type": ["string", "null"]
},
"action": {
"description": "Whether the region restricts or blocks access",
"$ref": "#/components/schemas/InstanceAgePolicyActionSchema"
},
"card_verification_available": {
"type": "boolean",
"description": "Whether card age verification is available in the region"
}
},
"required": ["country_code", "region_code", "action", "card_verification_available"],
"additionalProperties": false,
"description": "Age policy for one region"
},
"InstanceAgePolicyActionSchema": {"type": "string", "enum": ["restrict", "block"]},
"BotFlags": {
"type": "integer",
"minimum": 0,
@@ -33536,6 +33737,7 @@
},
"PasswordType": {"type": "string"},
"EmailType": {"type": "string"},
"DesktopHandoffReturnMethod": {"type": "string", "enum": ["deep_link", "code"]},
"AuthSessionLocation": {
"type": "object",
"properties": {
@@ -132,6 +132,10 @@ export const AuthRateLimitConfigs = {
bucket: 'auth:handoff:status',
config: {limit: 60, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
AUTH_HANDOFF_DENY: {
bucket: 'auth:handoff:deny',
config: {limit: 10, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
AUTH_HANDOFF_CANCEL: {
bucket: 'auth:handoff:cancel',
config: {limit: 10, windowMs: ms('1 minute')},
@@ -5,9 +5,9 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {
applyChannelThreadsConfigUpdate,
type ChannelThreadsConfig,
DEFAULT_CHANNEL_THREADS_CONFIG,
everyoneChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
@@ -38,29 +38,27 @@ describe('RpcService get_channel_threads_config', () => {
.expect(HTTP_STATUS.OK)
.execute();
test('serves the disabled default before any admin write', async () => {
test('serves the everyone config at version zero while no row is stored', async () => {
expect(await fetchConfig()).toEqual({
type: 'get_channel_threads_config',
data: {config: DEFAULT_CHANNEL_THREADS_CONFIG},
data: {config: everyoneChannelThreadsConfig(0)},
});
});
test('serves the stored config with its version and sticky ever_enabled', async () => {
const repository = getInstanceConfigRepository();
await repository.updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled: true, enabled_guild_ids: ['123']}),
);
await repository.updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled: false}),
test('serves the everyone config at the stored version for a disabled partial row', async () => {
await getInstanceConfigRepository().setConfig(
'channel_threads_config',
JSON.stringify({
...DEFAULT_CHANNEL_THREADS_CONFIG,
enabled: false,
ever_enabled: true,
config_version: 12,
guild_basis_points: 2500,
enabled_guild_ids: ['123'],
excluded_user_ids: ['456'],
}),
);
const response = await fetchConfig();
expect(response.data.config).toMatchObject({
enabled: false,
ever_enabled: true,
config_version: 2,
enabled_guild_ids: ['123'],
});
expect((await fetchConfig()).data.config).toEqual(everyoneChannelThreadsConfig(12));
});
});
@@ -2,36 +2,28 @@
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createChannelID, createGuildID} from '@app/api/BrandedTypes';
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
import {executeConditional, fetchOne} from '@app/api/database/CassandraQueryExecution';
import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes';
import {
clearChannelThreadsTaintCacheForTesting,
getCompiledChannelThreadsConfig,
insertGuildThreadMarker,
isTainted,
syncChannelThreadsConfig,
} from '@app/api/experiment/ChannelThreadsGate';
import {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
import {getWorkerService} from '@app/api/middleware/ServiceRegistry';
import {getChannelRepository, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {getChannelRepository} from '@app/api/middleware/ServiceSingletons';
import {GuildThreadState} from '@app/api/Tables';
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 {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {
applyChannelThreadsConfigUpdate,
ChannelThreadsConfigSchema,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
const FETCH_MARKER = GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1});
function resetConfig(): void {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
clearChannelThreadsTaintCacheForTesting();
}
describe('RpcService guild load thread permission seeding', () => {
let harness: ApiTestHarness;
@@ -41,12 +33,12 @@ describe('RpcService guild load thread permission seeding', () => {
beforeEach(async () => {
await harness.reset();
resetConfig();
resetChannelThreadsConfig();
});
afterEach(() => {
vi.restoreAllMocks();
resetConfig();
resetChannelThreadsConfig();
});
afterAll(async () => {
@@ -60,10 +52,7 @@ describe('RpcService guild load thread permission seeding', () => {
.expect(HTTP_STATUS.OK)
.execute();
const loadGuild = (guildId: string) => loadCollection(guildId, 'guild');
const setEnabled = (enabled: boolean) =>
getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled, guild_basis_points: 10000}),
);
const setEnabled = (enabled: boolean) => setChannelThreadsConfig({enabled, guild_basis_points: 10000});
test('marks and seeds a bucketed guild on its first active load, and leaves control guilds alone', async () => {
const owner = await createTestAccount(harness);
@@ -75,16 +64,14 @@ describe('RpcService guild load thread permission seeding', () => {
expect(await fetchOne<GuildThreadStateRow>(FETCH_MARKER, {guild_id: guildId})).toBeNull();
expect(addJob).not.toHaveBeenCalled();
const landed = await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
applyChannelThreadsConfigUpdate(current, {enabled: true, guild_basis_points: 10000}),
);
await setEnabled(true);
await loadGuild(guild.id);
const marker = await fetchOne<GuildThreadStateRow>(FETCH_MARKER, {guild_id: guildId});
expect(marker?.first_active_at).toBeInstanceOf(Date);
expect(marker?.perms_seeded_at).toBeNull();
expect(addJob).toHaveBeenCalledWith(
'seedThreadPermissions',
{guildId: guild.id, configVersion: landed.config_version},
{guildId: guild.id, configVersion: getCompiledChannelThreadsConfig().config.config_version},
{jobKey: `seed-thread-permissions-${guild.id}`},
);
@@ -1,21 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
clearChannelThreadsTaintCacheForTesting,
syncChannelThreadsConfig,
} from '@app/api/experiment/ChannelThreadsGate';
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
import {markGuildChannelsAsIndexed, sendMessage} from '@app/api/message/tests/MessageTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils';
import {
type ChannelThreadsConfig,
ChannelThreadsConfigSchema,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {MessageSearchResultsResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
@@ -23,37 +15,21 @@ import {afterEach, beforeEach, describe, expect, test} from 'vitest';
const FEATURES = 'X-Fluxer-Features';
const CAPABLE = 'channel_threads';
async function setConfig(patch: Partial<ChannelThreadsConfig>): Promise<void> {
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
ChannelThreadsConfigSchema.parse({
...patch,
ever_enabled: current.ever_enabled || patch.enabled === true,
config_version: current.config_version + 1,
}),
);
clearChannelThreadsTaintCacheForTesting();
}
function resetConfig(): void {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
clearChannelThreadsTaintCacheForTesting();
}
describe('message search thread permission bits', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness({search: 'enabled'});
resetConfig();
resetChannelThreadsConfig();
});
afterEach(async () => {
resetConfig();
resetChannelThreadsConfig();
await harness.shutdown();
});
test('search channel overwrites are masked for non-viewers in tainted guilds', async () => {
await setConfig({enabled: true, guild_basis_points: 10000, user_basis_points: 10000});
await setChannelThreadsConfig({enabled: true, guild_basis_points: 10000, user_basis_points: 10000});
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Search masking');
const allow = Permissions.SEND_MESSAGES | ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
@@ -89,7 +65,7 @@ describe('message search thread permission bits', () => {
expect(await searchAllow(scope, false)).toBe(Permissions.SEND_MESSAGES);
}
await setConfig({enabled: false});
await setChannelThreadsConfig({enabled: false});
for (const scope of scopes) {
expect(await searchAllow(scope, true)).toBe(Permissions.SEND_MESSAGES);
}
@@ -10,6 +10,7 @@ import {Logger} from '@app/api/Logger';
import type {User} from '@app/api/models/User';
import {selectActiveStoreSubscription} from '@app/api/store_billing/StoreBillingMappers';
import type {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository';
import {shiftGiftExtensionEnd} from '@app/api/user/GiftExtensionShift';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {clearPerksSanitizedFlag} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
@@ -77,9 +78,7 @@ function buildGrantPatch(user: User, row: StorePurchaseRow, now: Date): Partial<
user.premiumGraceEndsAt.getTime() > now.getTime();
const anchorMs = inStoreGrace ? oldUntil.getTime() : Math.max(now.getTime(), oldUntil?.getTime() ?? 0);
const giftEnd = user.premiumGiftExtensionEndsAt;
const shiftMs = expiresAt.getTime() - anchorMs;
const shiftedGiftEnd =
giftEnd && shiftMs > 0 && giftEnd.getTime() > anchorMs ? new Date(giftEnd.getTime() + shiftMs) : giftEnd;
const shiftedGiftEnd = shiftGiftExtensionEnd(giftEnd, anchorMs, expiresAt);
const startedAt = row.started_at ?? now;
const premiumSince = user.premiumSince && user.premiumSince <= startedAt ? user.premiumSince : startedAt;
setIfChanged(patch, 'premium_type', user.premiumType, UserPremiumTypes.SUBSCRIPTION);
@@ -74,6 +74,13 @@ const ENV_CATALOG: ReadonlyArray<{
gift_1_month: 'gift1MonthInr',
gift_1_year: 'gift1YearInr',
},
{
currency: 'ISK',
monthly: 'monthlyIsk',
yearly: 'yearlyIsk',
gift_1_month: 'gift1MonthIsk',
gift_1_year: 'gift1YearIsk',
},
{
currency: 'NOK',
monthly: 'monthlyNok',
@@ -64,6 +64,7 @@ const LEGACY_SLOT_CURRENCIES: Record<string, Currency | undefined> = {
brl: 'BRL',
dkk: 'DKK',
inr: 'INR',
isk: 'ISK',
nok: 'NOK',
pln: 'PLN',
sek: 'SEK',
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type Stripe from 'stripe';
const GIFT_TRIAL_KEY_PREFIX = 'gtrial_';
const GIFT_TRIAL_PAID_UNTIL_KEY = `${GIFT_TRIAL_KEY_PREFIX}paid_until`;
export function getGiftTrialMetadataKey(giftCode: string): string {
return `${GIFT_TRIAL_KEY_PREFIX}${giftCode}`;
}
function isGiftTrialRunning(subscription: Stripe.Subscription, nowMs: number): boolean {
return subscription.trial_end != null && subscription.trial_end * 1000 > nowMs;
}
export function buildGiftTrialMetadata(
subscription: Stripe.Subscription,
giftCode: string,
baseUnix: number,
newTrialEndUnix: number,
nowMs: number,
): Record<string, string> {
const metadata: Record<string, string> = {};
if (!isGiftTrialRunning(subscription, nowMs)) {
for (const key of Object.keys(subscription.metadata ?? {})) {
if (key.startsWith(GIFT_TRIAL_KEY_PREFIX)) {
metadata[key] = '';
}
}
metadata[GIFT_TRIAL_PAID_UNTIL_KEY] = String(baseUnix);
}
metadata[getGiftTrialMetadataKey(giftCode)] = String(newTrialEndUnix - baseUnix);
return metadata;
}
export function getGiftTrialSeconds(subscription: Stripe.Subscription, giftCode: string): number | null {
const seconds = Number(subscription.metadata?.[getGiftTrialMetadataKey(giftCode)]);
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
}
export function getGiftTrialPaidUntil(subscription: Stripe.Subscription, nowMs: number): Date | null {
if (!isGiftTrialRunning(subscription, nowMs)) {
return null;
}
const paidUntilUnix = Number(subscription.metadata?.[GIFT_TRIAL_PAID_UNTIL_KEY]);
return Number.isFinite(paidUntilUnix) && paidUntilUnix > 0 ? new Date(paidUntilUnix * 1000) : null;
}
+1 -1
View File
@@ -192,7 +192,7 @@ export class StripeService {
durationType: GiftCodeDurationType,
durationQuantity: number,
idempotencyKey: string,
): Promise<void> {
): Promise<Stripe.Subscription | null> {
return this.subscriptionService.extendSubscriptionWithGiftTrialDuration(
user,
durationType,
@@ -63,6 +63,19 @@ function getSubscriptionCurrentPeriodEnd(subscription: Stripe.Subscription): Dat
return latestPeriodEnd == null ? null : new Date(latestPeriodEnd * 1000);
}
export function getSubscriptionCurrentPeriodStart(subscription: Stripe.Subscription): Date | null {
let latestPeriodStart: number | null = null;
for (const item of subscription.items?.data ?? []) {
if (item.current_period_start == null) {
continue;
}
if (latestPeriodStart == null || item.current_period_start > latestPeriodStart) {
latestPeriodStart = item.current_period_start;
}
}
return latestPeriodStart == null ? null : new Date(latestPeriodStart * 1000);
}
export function getSubscriptionPremiumPeriodEnd(subscription: Stripe.Subscription): Date | null {
if (subscription.cancel_at) {
return new Date(subscription.cancel_at * 1000);
@@ -79,6 +92,17 @@ export function getSubscriptionStartDate(subscription: Stripe.Subscription): Dat
return new Date((subscription.start_date ?? subscription.created) * 1000);
}
export function getInvoiceLatestLinePeriodStart(invoice: Stripe.Invoice): Date | null {
let latestPeriodStartUnix: number | null = null;
for (const line of invoice.lines?.data ?? []) {
const periodStart = line.period?.start ?? null;
if (periodStart && (latestPeriodStartUnix === null || periodStart > latestPeriodStartUnix)) {
latestPeriodStartUnix = periodStart;
}
}
return latestPeriodStartUnix ? new Date(latestPeriodStartUnix * 1000) : null;
}
export function getInvoiceLatestLinePeriodEnd(invoice: Stripe.Invoice): Date | null {
const lines = invoice.lines?.data ?? [];
let latestPeriodEndUnix: number | null = null;
@@ -19,16 +19,19 @@ import {
} from '@app/api/stripe/StripeSubscriptionAccessPolicy';
import {
getPrimarySubscriptionItem,
getSubscriptionCurrentPeriodStart,
getSubscriptionPremiumPeriodEnd,
getSubscriptionStartDate,
} from '@app/api/stripe/StripeSubscriptionPeriod';
import {extractId} from '@app/api/stripe/StripeUtils';
import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {
type Currency,
getCurrencyPreferences,
getGiftCurrencyPreferences,
isLocalizedCurrency,
shouldDisableAdaptivePricing,
} from '@app/api/utils/CurrencyUtils';
import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea';
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
@@ -232,6 +235,7 @@ export class StripeCheckoutService {
},
billing_address_collection: isBusiness ? 'required' : 'auto',
allow_promotion_codes: true,
...(shouldDisableAdaptivePricing(productInfo.currency) ? {adaptive_pricing: {enabled: false}} : {}),
...(checkoutMode === 'subscription'
? {
subscription_data: {
@@ -539,6 +543,15 @@ export class StripeCheckoutService {
}
if (premiumUntil && user.premiumUntil?.getTime() !== premiumUntil.getTime()) {
patch.premium_until = premiumUntil;
const giftEnd = shiftGiftExtensionPastPremiumUntil(
{premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt},
premiumUntil,
new Date(),
getSubscriptionCurrentPeriodStart(subscription),
);
if (giftEnd !== user.premiumGiftExtensionEndsAt) {
patch.premium_gift_extension_ends_at = giftEnd;
}
}
if (user.premiumWillCancel !== premiumWillCancel) {
patch.premium_will_cancel = premiumWillCancel;
@@ -14,6 +14,7 @@ import type {ProductInfo, ProductRegistry} from '@app/api/stripe/ProductRegistry
import {
getFirstInvoicePaymentIntentId,
getPrimarySubscriptionItem,
getSubscriptionCurrentPeriodStart,
getSubscriptionItemPeriodEnd,
getSubscriptionPremiumPeriodEnd,
} from '@app/api/stripe/StripeSubscriptionPeriod';
@@ -370,14 +371,16 @@ export class StripeCheckoutWebhookHandler {
return 'granted';
}
if (this.productRegistry.isRecurringSubscription(productInfo)) {
const periodEnd = await this.resolveCheckoutSubscriptionPeriodEnd(session, productInfo);
recovery.expectedPremiumUntil = periodEnd;
const period = await this.resolveCheckoutSubscriptionPeriod(session, productInfo);
recovery.expectedPremiumUntil = period.end;
await this.premiumService.setPremiumFromSubscriptionPeriod(
payment.userId,
productInfo.premiumType,
periodEnd,
period.end,
productInfo.billingCycle || null,
true,
null,
period.start,
);
return 'granted';
}
@@ -522,17 +525,17 @@ export class StripeCheckoutWebhookHandler {
}
}
private async resolveCheckoutSubscriptionPeriodEnd(
private async resolveCheckoutSubscriptionPeriod(
session: Stripe.Checkout.Session,
productInfo: ProductInfo,
): Promise<Date> {
): Promise<{start: Date | null; end: Date}> {
const subscriptionId = extractId(session.subscription);
if (subscriptionId && this.stripe) {
try {
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
const candidate = getSubscriptionPremiumPeriodEnd(subscription);
if (candidate) {
return candidate;
return {start: getSubscriptionCurrentPeriodStart(subscription), end: candidate};
}
} catch (error) {
Logger.warn(
@@ -546,7 +549,7 @@ export class StripeCheckoutWebhookHandler {
{sessionId: session.id, subscriptionId, durationMonths: productInfo.durationMonths},
'Using product duration fallback for subscription period_end on checkout fulfilment',
);
return fallback;
return {start: null, end: fallback};
}
private didCheckoutPremiumGrantApply(
@@ -5,19 +5,37 @@ import type {UserRow} from '@app/api/database/types/UserTypes';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService';
import {Logger} from '@app/api/Logger';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import {addGiftCodeDuration, type GiftCode} from '@app/api/models/GiftCode';
import type {User} from '@app/api/models/User';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import {getStripeClient} from '@app/api/stripe/StripeClient';
import {
getGiftTrialMetadataKey,
getGiftTrialPaidUntil,
getGiftTrialSeconds,
} from '@app/api/stripe/StripeGiftTrialMetadata';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {clearPerksSanitizedFlag} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
import Stripe from 'stripe';
interface RemainingGiftEntitlement {
hasLifetimeGift: boolean;
giftExtensionEndsAt: Date | null;
}
interface GiftTrialCut {
subscriptionId: string;
trialEnd: Date | null;
cancel: boolean;
premiumUntil: Date;
}
const MIN_REMAINING_TRIAL_MS = 60_000;
export class StripeGiftReversalHandler {
constructor(
private userRepository: IUserRepository,
@@ -48,20 +66,26 @@ export class StripeGiftReversalHandler {
let newGiftEnd: Date | null;
let needsAdjustment: boolean;
let reapplyIfMarked = true;
let trialCut: GiftTrialCut | null = null;
if (await this.storeEntitlementService?.getActiveStoreEntitlement(redeemer.id)) {
const reduced = this.reduceStackedGiftExtension(redeemer, giftCode, redeemedGifts, new Date());
newGiftEnd = reduced.giftExtensionEndsAt;
needsAdjustment = reduced.changed;
reapplyIfMarked = false;
} else {
newGiftEnd = this.computeRemainingGiftEntitlement(redeemedGifts, giftCode.code).giftExtensionEndsAt;
newGiftEnd = this.removeGiftKeepingPaidShift(currentGiftEnd, giftCode, redeemedGifts);
needsAdjustment =
(newGiftEnd?.getTime() ?? 0) !== (currentGiftEnd?.getTime() ?? 0) &&
(currentGiftEnd == null || newGiftEnd == null || currentGiftEnd.getTime() > newGiftEnd.getTime());
currentGiftEnd != null && (newGiftEnd == null || currentGiftEnd.getTime() > newGiftEnd.getTime());
trialCut = await this.planGiftTrialCut(redeemer, giftCode, new Date());
}
if (
needsAdjustment &&
(await this.commitReversal(redeemer, giftCode, {premium_gift_extension_ends_at: newGiftEnd}, {reapplyIfMarked}))
(needsAdjustment || trialCut) &&
(await this.commitReversal(
redeemer,
giftCode,
needsAdjustment ? {premium_gift_extension_ends_at: newGiftEnd} : {},
{reapplyIfMarked, trialCut},
))
) {
Logger.info(
{
@@ -69,8 +93,10 @@ export class StripeGiftReversalHandler {
redeemerId: redeemer.id,
chargeId: context.chargeId,
reason: context.reason,
adjustedGiftEnd: newGiftEnd?.toISOString() ?? null,
adjustedGiftEnd: needsAdjustment ? (newGiftEnd?.toISOString() ?? null) : undefined,
previousGiftEnd: currentGiftEnd?.toISOString() ?? null,
shortenedTrialEnd: trialCut ? (trialCut.trialEnd?.toISOString() ?? 'now') : undefined,
cancelledSubscription: trialCut?.cancel,
},
'Reduced gift extension after gift reversal for user with Stripe identity',
);
@@ -223,6 +249,20 @@ export class StripeGiftReversalHandler {
return reduced.giftExtensionEndsAt;
}
private removeGiftKeepingPaidShift(
currentGiftEnd: Date | null,
giftCode: GiftCode,
redeemedGifts: Array<GiftCode>,
): Date | null {
const remainingEnd = this.computeRemainingGiftEntitlement(redeemedGifts, giftCode.code).giftExtensionEndsAt;
if (!currentGiftEnd || !remainingEnd) {
return null;
}
const rebuiltEnd = this.computeRemainingGiftEntitlement(redeemedGifts, null).giftExtensionEndsAt;
const paidShiftMs = rebuiltEnd ? Math.max(0, currentGiftEnd.getTime() - rebuiltEnd.getTime()) : 0;
return new Date(remainingEnd.getTime() + paidShiftMs);
}
private reduceStackedGiftExtension(
redeemer: User,
giftCode: GiftCode,
@@ -287,15 +327,91 @@ export class StripeGiftReversalHandler {
await this.dispatchUser(updatedUser);
}
private async planGiftTrialCut(redeemer: User, giftCode: GiftCode, now: Date): Promise<GiftTrialCut | null> {
const stripe = getStripeClient();
if (!stripe || !redeemer.stripeSubscriptionId) {
return null;
}
let subscription: Stripe.Subscription;
try {
subscription = await stripe.subscriptions.retrieve(redeemer.stripeSubscriptionId);
} catch (error) {
if (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === 'resource_missing') {
return null;
}
throw error;
}
if (subscription.status === 'canceled' || subscription.status === 'incomplete_expired') {
return null;
}
if (!subscription.trial_end || subscription.trial_end * 1000 <= now.getTime()) {
return null;
}
const giftSeconds = getGiftTrialSeconds(subscription, giftCode.code);
if (giftSeconds === null) {
return null;
}
const cutEndMs = Math.max(
subscription.trial_end * 1000 - giftSeconds * 1000,
getGiftTrialPaidUntil(subscription, now.getTime())?.getTime() ?? 0,
);
const endsNow = cutEndMs <= now.getTime() + MIN_REMAINING_TRIAL_MS;
const premiumUntilMs = Math.min(
endsNow ? now.getTime() : cutEndMs,
redeemer.premiumUntil?.getTime() ?? Number.POSITIVE_INFINITY,
);
return {
subscriptionId: subscription.id,
trialEnd: endsNow ? null : new Date(cutEndMs),
cancel: endsNow && (subscription.cancel_at_period_end || subscription.cancel_at != null),
premiumUntil: new Date(premiumUntilMs),
};
}
private async applyGiftTrialCut(redeemer: User, giftCode: GiftCode, cut: GiftTrialCut): Promise<void> {
const stripe = getStripeClient();
if (!stripe) {
throw new StripeError('Stripe client not available for gift trial reversal');
}
const idempotencyKey = `gift_trial_reverse:${redeemer.id}:${giftCode.code}`;
const subscription = cut.cancel
? await stripe.subscriptions.cancel(
cut.subscriptionId,
{invoice_now: false, prorate: false, cancellation_details: {comment: 'gift_reversal'}},
{idempotencyKey},
)
: await stripe.subscriptions.update(
cut.subscriptionId,
{
trial_end: cut.trialEnd ? Math.floor(cut.trialEnd.getTime() / 1000) : 'now',
proration_behavior: 'none',
metadata: {[getGiftTrialMetadataKey(giftCode.code)]: ''},
},
{idempotencyKey},
);
try {
await getBillingRepository().subscriptions.upsertFromStripe(subscription, {
knownUserId: redeemer.id,
snapshotCapturedAt: new Date(),
});
} catch (mirrorErr) {
Logger.error(
{mirrorErr, subId: subscription.id},
'Mirror upsert failed after Stripe write; reconciler will heal',
);
}
}
private async commitReversal(
redeemer: User,
giftCode: GiftCode,
patch: Partial<UserRow>,
{reapplyIfMarked}: {reapplyIfMarked: boolean},
{reapplyIfMarked, trialCut = null}: {reapplyIfMarked: boolean; trialCut?: GiftTrialCut | null},
): Promise<boolean> {
const seconds = this.computeRemovedSeconds(redeemer, patch, Date.now());
const fullPatch: Partial<UserRow> = trialCut ? {...patch, premium_until: trialCut.premiumUntil} : patch;
const seconds = this.computeRemovedSeconds(redeemer, fullPatch, Date.now());
if (!(await this.userRepository.markGiftPremiumReversed(giftCode, seconds))) {
if (!reapplyIfMarked) {
if (!reapplyIfMarked || Object.keys(patch).length === 0) {
Logger.info(
{giftCode: giftCode.code, redeemerId: redeemer.id},
'Skipped a gift premium reversal that was already applied',
@@ -305,17 +421,20 @@ export class StripeGiftReversalHandler {
await this.dispatchUser(await this.userRepository.patchUpsert(redeemer.id, patch, redeemer.toRow()));
return true;
}
if (trialCut) {
try {
await this.applyGiftTrialCut(redeemer, giftCode, trialCut);
} catch (error) {
await this.releaseReversalClaim(redeemer, giftCode, seconds);
throw error;
}
}
let updatedUser: User;
try {
updatedUser = await this.userRepository.patchUpsert(redeemer.id, patch, redeemer.toRow());
updatedUser = await this.userRepository.patchUpsert(redeemer.id, fullPatch, redeemer.toRow());
} catch (error) {
try {
await this.userRepository.clearGiftPremiumReversed(giftCode.code, seconds);
} catch (clearError) {
Logger.error(
{giftCode: giftCode.code, redeemerId: redeemer.id, clearError},
'Failed to release gift reversal marker',
);
if (!trialCut) {
await this.releaseReversalClaim(redeemer, giftCode, seconds);
}
throw error;
}
@@ -323,6 +442,17 @@ export class StripeGiftReversalHandler {
return true;
}
private async releaseReversalClaim(redeemer: User, giftCode: GiftCode, seconds: number): Promise<void> {
try {
await this.userRepository.clearGiftPremiumReversed(giftCode.code, seconds);
} catch (clearError) {
Logger.error(
{giftCode: giftCode.code, redeemerId: redeemer.id, clearError},
'Failed to release gift reversal marker',
);
}
}
private computeRemovedSeconds(redeemer: User, patch: Partial<UserRow>, nowMs: number): number {
const nextUntil = 'premium_until' in patch ? patch.premium_until : redeemer.premiumUntil;
const nextGiftEnd =
@@ -337,7 +467,10 @@ export class StripeGiftReversalHandler {
return Math.max(0, Math.ceil((beforeMs - afterMs) / 1000));
}
computeRemainingGiftEntitlement(redeemedGifts: Array<GiftCode>, excludedCode: string): RemainingGiftEntitlement {
computeRemainingGiftEntitlement(
redeemedGifts: Array<GiftCode>,
excludedCode: string | null,
): RemainingGiftEntitlement {
const sortedGifts = redeemedGifts
.filter(
(giftCode) =>
@@ -140,23 +140,19 @@ export class StripeGiftService {
);
await this.cancelStripeSubscriptionImmediately(user);
}
let stackedOntoStripeSubscription = false;
if (premiumType === UserPremiumTypes.SUBSCRIPTION) {
stackedOntoStripeSubscription = await this.tryStackSubscriptionGiftOntoStripeSubscription(
user,
giftCode,
code,
);
}
const stackedSubscription =
premiumType === UserPremiumTypes.SUBSCRIPTION
? await this.tryStackSubscriptionGiftOntoStripeSubscription(user, giftCode, code)
: null;
Logger.debug(
{
userId,
giftCode: code,
stackedOntoStripeSubscription,
stackedOntoStripeSubscription: stackedSubscription !== null,
},
'Gift redemption stacking decision completed',
);
if (stackedOntoStripeSubscription) {
if (stackedSubscription) {
Logger.debug(
{
userId,
@@ -166,13 +162,9 @@ export class StripeGiftService {
},
'Recording gift extension in gift bucket alongside Stripe trial stacking',
);
await this.premiumService.extendPremiumByGift(
userId,
premiumType,
giftCode.durationType,
giftCode.durationQuantity,
true,
);
if (stackedSubscription.subscription) {
await this.premiumService.recordGiftTrialExtension(userId, stackedSubscription.subscription);
}
} else if (premiumType === UserPremiumTypes.LIFETIME && giftCode.visionarySequenceNumber != null) {
const GIFT_CODE_SENTINEL_USER_ID = createUserID(-1n);
Logger.debug(
@@ -368,7 +360,7 @@ export class StripeGiftService {
user: User,
giftCode: GiftCode,
code: string,
): Promise<boolean> {
): Promise<{subscription: Stripe.Subscription | null} | null> {
const hasCurrentSubscriptionState = this.hasCurrentSubscriptionState(user);
if (!hasCurrentSubscriptionState || !user.stripeSubscriptionId || !this.stripe) {
Logger.debug(
@@ -381,7 +373,7 @@ export class StripeGiftService {
},
'Skipping Stripe subscription stacking for gift redemption',
);
return false;
return null;
}
Logger.debug(
{
@@ -394,14 +386,14 @@ export class StripeGiftService {
'Attempting to stack gift duration onto active Stripe subscription',
);
try {
await this.subscriptionService.extendSubscriptionWithGiftTrialDuration(
const subscription = await this.subscriptionService.extendSubscriptionWithGiftTrialDuration(
user,
giftCode.durationType,
giftCode.durationQuantity,
code,
);
Logger.debug({userId: user.id, giftCode: code}, 'Stacked gift duration onto Stripe subscription');
return true;
return {subscription};
} catch (error: unknown) {
if (!this.shouldFallbackToPremiumFieldGrant(error)) {
Logger.error(
@@ -431,7 +423,7 @@ export class StripeGiftService {
},
'Falling back to premium-field gift grant after missing or inactive Stripe subscription',
);
return false;
return null;
}
}
@@ -13,12 +13,18 @@ import {Logger} from '@app/api/Logger';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {addGiftCodeDuration} from '@app/api/models/GiftCode';
import type {User} from '@app/api/models/User';
import {
getSubscriptionCurrentPeriodStart,
getSubscriptionPremiumPeriodEnd,
} from '@app/api/stripe/StripeSubscriptionPeriod';
import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
import type Stripe from 'stripe';
export class StripePremiumService {
constructor(
@@ -35,6 +41,7 @@ export class StripePremiumService {
billingCycle: string | null = null,
hasEverPurchased: boolean = false,
premiumSinceAnchor: Date | null = null,
periodStart: Date | null = null,
): Promise<void> {
const user = await this.userRepository.findUnique(userId);
if (!user) {
@@ -42,14 +49,15 @@ export class StripePremiumService {
}
const now = new Date();
let visionarySequence: number | null = user.premiumLifetimeSequence;
if (premiumType === UserPremiumTypes.LIFETIME && !visionarySequence) {
if (premiumType === UserPremiumTypes.LIFETIME && visionarySequence == null) {
visionarySequence = await this.allocateVisionarySequence(userId);
}
const shiftMs = user.premiumUntil ? periodEnd.getTime() - user.premiumUntil.getTime() : 0;
const adjustedGiftEnd =
user.premiumGiftExtensionEndsAt && shiftMs > 0
? new Date(user.premiumGiftExtensionEndsAt.getTime() + shiftMs)
: user.premiumGiftExtensionEndsAt;
const adjustedGiftEnd = shiftGiftExtensionPastPremiumUntil(
{premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt},
periodEnd,
now,
periodStart,
);
const updatedUser = await this.userRepository.patchUpsert(
userId,
{
@@ -68,7 +76,7 @@ export class StripePremiumService {
);
await this.dispatchUser(updatedUser);
Logger.debug(
{userId, premiumType, periodEnd, shiftMs, adjustedGiftEnd, billingCycle},
{userId, premiumType, periodEnd, adjustedGiftEnd, billingCycle},
'Premium set from subscription period',
);
}
@@ -140,6 +148,39 @@ export class StripePremiumService {
Logger.debug({userId, premiumType, durationType, durationQuantity, newGiftEnd}, 'Premium extended by gift');
}
async recordGiftTrialExtension(userId: UserID, subscription: Stripe.Subscription): Promise<void> {
const user = await this.userRepository.findUnique(userId);
if (!user) {
throw new StripeError('User not found for premium grant');
}
const now = new Date();
const periodEnd = getSubscriptionPremiumPeriodEnd(subscription);
const premiumUntil =
periodEnd && (!user.premiumUntil || periodEnd > user.premiumUntil) ? periodEnd : user.premiumUntil;
const shiftedGiftEnd = premiumUntil
? shiftGiftExtensionPastPremiumUntil(
{premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt},
premiumUntil,
now,
getSubscriptionCurrentPeriodStart(subscription),
)
: user.premiumGiftExtensionEndsAt;
const trialEnd = subscription.trial_end ? new Date(subscription.trial_end * 1000) : null;
const giftEnd = trialEnd && (!shiftedGiftEnd || trialEnd > shiftedGiftEnd) ? trialEnd : shiftedGiftEnd;
const updatedUser = await this.userRepository.patchUpsert(
userId,
{
premium_until: premiumUntil,
premium_gift_extension_ends_at: giftEnd,
premium_grace_ends_at: null,
has_ever_purchased: true,
},
user.toRow(),
);
await this.dispatchUser(updatedUser);
Logger.debug({userId, premiumUntil, giftEnd}, 'Premium extended by gift stacked onto Stripe trial');
}
async grantPremiumFromGiftWithDuration(
userId: UserID,
premiumType: 1 | 2,
@@ -10,6 +10,7 @@ import {addGiftCodeDuration} from '@app/api/models/GiftCode';
import type {User} from '@app/api/models/User';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import type {ProductInfo, RecurringBillingCycle} from '@app/api/stripe/ProductRegistry';
import {buildGiftTrialMetadata, getGiftTrialPaidUntil} from '@app/api/stripe/StripeGiftTrialMetadata';
import {
getPrimarySubscriptionItem,
getSubscriptionEntitlementPeriodEndUnix,
@@ -233,12 +234,15 @@ export class StripeSubscriptionService {
premium_grace_ends_at: null,
};
if (user.premiumType !== UserPremiumTypes.LIFETIME) {
const hasActiveGift =
user.premiumGiftExtensionEndsAt != null && user.premiumGiftExtensionEndsAt.getTime() > Date.now();
const now = new Date();
const paidUntil = getGiftTrialPaidUntil(canceledSubscription, now.getTime()) ?? user.premiumUntil;
const giftEnd = this.pullGiftExtensionBackToNow(user.premiumGiftExtensionEndsAt, paidUntil, now);
const hasActiveGift = giftEnd != null && giftEnd.getTime() > now.getTime();
Object.assign(patch, {
premium_type: hasActiveGift ? user.premiumType : UserPremiumTypes.NONE,
premium_since: hasActiveGift ? user.premiumSince : null,
premium_until: new Date(),
premium_until: now,
premium_gift_extension_ends_at: giftEnd,
});
}
const updatedUser = await this.userRepository.patchUpsert(userId, patch, user.toRow());
@@ -255,6 +259,13 @@ export class StripeSubscriptionService {
await this.storeEntitlementService?.reapplyAfterStripeChange(userId);
}
private pullGiftExtensionBackToNow(giftEnd: Date | null, paidUntil: Date | null, now: Date): Date | null {
if (!giftEnd || !paidUntil || paidUntil.getTime() <= now.getTime() || giftEnd.getTime() < paidUntil.getTime()) {
return giftEnd;
}
return new Date(giftEnd.getTime() - (paidUntil.getTime() - now.getTime()));
}
async reactivateSubscription(userId: UserID): Promise<void> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
@@ -1215,7 +1226,7 @@ export class StripeSubscriptionService {
durationType: GiftCodeDurationType,
durationQuantity: number,
idempotencyKey: string,
): Promise<void> {
): Promise<Stripe.Subscription | null> {
if (!this.stripe || !user.stripeSubscriptionId) {
Logger.debug(
{
@@ -1246,11 +1257,11 @@ export class StripeSubscriptionService {
);
if (await this.cacheService.get<boolean>(appliedKey)) {
Logger.debug({userId: user.id, idempotencyKey}, 'Gift trial extension already applied (idempotent hit)');
return;
return null;
}
if (await this.cacheService.get<boolean>(inflightKey)) {
Logger.debug({userId: user.id, idempotencyKey}, 'Gift trial extension in-flight; skipping duplicate');
return;
return null;
}
await this.cacheService.set(inflightKey, true, seconds('1 minute'));
Logger.debug({userId: user.id, idempotencyKey, inflightKey}, 'Gift trial inflight sentinel set');
@@ -1268,7 +1279,7 @@ export class StripeSubscriptionService {
try {
if (await this.cacheService.get<boolean>(appliedKey)) {
Logger.debug({userId: user.id, idempotencyKey}, 'Gift trial extension already applied after lock acquisition');
return;
return null;
}
const subscription = await this.stripe.subscriptions.retrieve(user.stripeSubscriptionId);
Logger.debug(
@@ -1320,6 +1331,7 @@ export class StripeSubscriptionService {
{
trial_end: newTrialEndUnix,
proration_behavior: 'none',
metadata: buildGiftTrialMetadata(subscription, idempotencyKey, baseUnix, newTrialEndUnix, Date.now()),
},
{idempotencyKey: stripeIdempotencyKey},
);
@@ -1357,6 +1369,7 @@ export class StripeSubscriptionService {
},
'Extended subscription with gift trial period',
);
return trialExtendedSubscription;
} catch (error: unknown) {
Logger.error(
{error, userId: user.id, subscriptionId: user.stripeSubscriptionId, idempotencyKey},
@@ -20,7 +20,9 @@ import {
} from '@app/api/stripe/StripeSubscriptionAccessPolicy';
import {
getInvoiceLatestLinePeriodEnd,
getInvoiceLatestLinePeriodStart,
getPrimarySubscriptionItem,
getSubscriptionCurrentPeriodStart,
getSubscriptionItemPeriodEnd,
getSubscriptionPremiumPeriodEnd,
getSubscriptionStartDate,
@@ -28,6 +30,7 @@ import {
import {extractId} from '@app/api/stripe/StripeUtils';
import type {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
import type {StripeSubscriptionReconciler} from '@app/api/stripe/services/StripeSubscriptionReconciler';
import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {PaymentRepository} from '@app/api/user/repositories/PaymentRepository';
import {getPremiumPaymentRecoveryGraceMs} from '@app/api/user/UserHelpers';
@@ -151,6 +154,8 @@ export class StripeSubscriptionWebhookHandler {
renewalContext.productInfo.billingCycle || null,
true,
premiumSinceAnchor,
(subscriptionSnapshot ? getSubscriptionCurrentPeriodStart(subscriptionSnapshot) : null) ??
getInvoiceLatestLinePeriodStart(invoice),
);
} catch (error) {
const latestUser = await this.userRepository.findUnique(renewalContext.userId);
@@ -223,6 +228,15 @@ export class StripeSubscriptionWebhookHandler {
}
if (premiumUntil && user.premiumUntil?.getTime() !== premiumUntil.getTime()) {
patch.premium_until = premiumUntil;
const giftEnd = shiftGiftExtensionPastPremiumUntil(
{premiumUntil: user.premiumUntil, giftEnd: user.premiumGiftExtensionEndsAt},
premiumUntil,
new Date(),
getSubscriptionCurrentPeriodStart(subscription),
);
if (giftEnd !== user.premiumGiftExtensionEndsAt) {
patch.premium_gift_extension_ends_at = giftEnd;
}
}
if (user.premiumWillCancel !== willCancel) {
patch.premium_will_cancel = willCancel;
@@ -556,6 +570,7 @@ export class StripeSubscriptionWebhookHandler {
const result = await this.userRepository.updateSubscriptionStatus(targetUser.id, {
premiumWillCancel: willCancel,
computedPremiumUntil,
periodStart: getSubscriptionCurrentPeriodStart(canonicalSubscription),
});
if (result.finalVersion === null) {
Logger.error(
@@ -38,6 +38,10 @@ const ENV_PRICES = {
yearlyInr: 'price_yearly_inr',
gift1MonthInr: 'price_gift_month_inr',
gift1YearInr: 'price_gift_year_inr',
monthlyIsk: 'price_monthly_isk',
yearlyIsk: 'price_yearly_isk',
gift1MonthIsk: 'price_gift_month_isk',
gift1YearIsk: 'price_gift_year_isk',
monthlyNok: 'price_monthly_nok',
yearlyNok: 'price_yearly_nok',
gift1MonthNok: 'price_gift_month_nok',
@@ -58,7 +62,7 @@ const ENV_PRICES = {
function expectedEnvCatalog(): Record<string, Record<string, string>> {
const catalog: Record<string, Record<string, string>> = {};
for (const currency of ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY']) {
for (const currency of ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'ISK', 'NOK', 'PLN', 'SEK', 'TRY']) {
const suffix = currency.toLowerCase();
catalog[currency] = {
monthly: `price_monthly_${suffix}`,
@@ -37,7 +37,7 @@ import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {HttpResponse, http} from 'msw';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
const ENV_CURRENCIES = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY'] as const;
const ENV_CURRENCIES = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'ISK', 'NOK', 'PLN', 'SEK', 'TRY'] as const;
function buildFullEnvPrices(): NonNullable<typeof Config.stripe.prices> {
const prices: Record<string, string> = {};
@@ -479,6 +479,18 @@ describe('operator billing catalog', () => {
.execute();
});
test('leaves adaptive pricing unset for an operator SEK checkout', async () => {
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: 'price_opsekmonthly', country_code: 'SE'})
.expect(HTTP_STATUS.OK)
.execute();
const session = stripeHandlers.spies.createdCheckoutSessions[0];
expect(session?.line_items?.[0]?.price).toBe('price_opsekmonthly');
expect(session?.adaptive_pricing).toBeUndefined();
});
test('accepts an operator price that does not match the buyer country', async () => {
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
@@ -121,6 +121,7 @@ describe('ProductRegistry - legacy prices', () => {
monthly_brl: ['legacy_brl'],
monthly_dkk: ['legacy_dkk'],
monthly_inr: ['legacy_inr'],
monthly_isk: ['legacy_isk'],
monthly_nok: ['legacy_nok'],
monthly_pln: ['legacy_pln'],
monthly_sek: ['legacy_sek'],
@@ -132,6 +133,7 @@ describe('ProductRegistry - legacy prices', () => {
expect(registry.getProduct('legacy_brl')?.currency).toBe('BRL');
expect(registry.getProduct('legacy_dkk')?.currency).toBe('DKK');
expect(registry.getProduct('legacy_inr')?.currency).toBe('INR');
expect(registry.getProduct('legacy_isk')?.currency).toBe('ISK');
expect(registry.getProduct('legacy_nok')?.currency).toBe('NOK');
expect(registry.getProduct('legacy_pln')?.currency).toBe('PLN');
expect(registry.getProduct('legacy_sek')?.currency).toBe('SEK');
@@ -211,7 +213,7 @@ describe('ProductRegistry - legacy prices', () => {
test('no legacy price id is ever returned by either price getter, for any cycle or currency', () => {
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
const legacyIds = new Set(Object.values(MOCK_LEGACY_PRICES).flatMap((ids) => ids ?? []));
const currencies = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY'];
const currencies = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'ISK', 'NOK', 'PLN', 'SEK', 'TRY'];
const offered: Array<string> = [];
for (const currency of currencies) {
@@ -0,0 +1,753 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {UserRow} from '@app/api/database/types/UserTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '@app/api/guild/services/GuildService';
import {getCacheService, getPremiumStateReconciliationQueueService} from '@app/api/middleware/ServiceSingletons';
import {findUser} from '@app/api/store_billing/tests/StoreBillingTestUtils';
import {getStripeClient} from '@app/api/stripe/StripeClient';
import {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler';
import {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
import {StripeSubscriptionService} from '@app/api/stripe/services/StripeSubscriptionService';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createStripeApiHandlers, type StripeApiHandlers} from '@app/api/test/msw/handlers/StripeApiHandlers';
import {server} from '@app/api/test/msw/server';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import type {GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
import {ms} from 'itty-time';
import {HttpResponse, http} from 'msw';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
const {lookupGeoipMock} = vi.hoisted(() => ({
lookupGeoipMock: vi.fn(),
}));
vi.mock('@app/api/utils/IpUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('@app/api/utils/IpUtils')>()),
lookupGeoip: lookupGeoipMock,
}));
const MOCK_PRICES = {
monthlyUsd: 'price_shift_monthly_usd',
yearlyUsd: 'price_shift_yearly_usd',
gift1MonthUsd: 'price_shift_gift_1_month_usd',
gift1YearUsd: 'price_shift_gift_1_year_usd',
};
const MOCK_PRICE_SEEDS = {
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
};
const CLOCK_TOLERANCE_MS = ms('10 seconds');
type WriteOrder = 'checkout first' | 'webhook first';
const WRITE_ORDERS: Array<WriteOrder> = ['checkout first', 'webhook first'];
function geoipCountry(countryCode: string | null): GeoipResult {
return {countryCode, normalizedIp: '203.0.113.10', city: null, region: null, countryName: null};
}
function giftTrialMetadata(code: string, days: number, paidUntilUnix?: number): Record<string, string> {
return {
[`gtrial_${code}`]: String(days * 24 * 60 * 60),
...(paidUntilUnix ? {gtrial_paid_until: String(paidUntilUnix)} : {}),
};
}
function expectNear(actual: Date | null | undefined, expectedMs: number): void {
expect(actual).not.toBeNull();
expect(Math.abs(actual!.getTime() - expectedMs)).toBeLessThanOrEqual(CLOCK_TOLERANCE_MS);
}
describe('gift time around a Stripe subscription', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let originalPrices: typeof Config.stripe.prices | undefined;
const users = new UserRepository();
const premiumService = new StripePremiumService(
users,
new NoopGatewayService(),
{} as IGuildRepositoryAggregate,
{} as GuildService,
);
beforeAll(async () => {
originalPrices = Config.stripe.prices;
Config.stripe.prices = MOCK_PRICES;
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness.shutdown();
Config.stripe.prices = originalPrices;
});
beforeEach(async () => {
await harness.resetData();
Config.stripe.prices = MOCK_PRICES;
lookupGeoipMock.mockReset();
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
useStripe();
});
afterEach(() => {
server.resetHandlers();
});
function useStripe(config: Parameters<typeof createStripeApiHandlers>[0] = {}): void {
stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS, ...config});
server.use(...stripeHandlers.handlers);
}
async function createUser(patch: Partial<UserRow>): Promise<{account: TestAccount; userId: UserID}> {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const user = await findUser(account.userId);
await users.patchUpsert(userId, patch, user.toRow());
return {account, userId};
}
async function checkoutWrite(userId: UserID, periodEnd: Date, periodStart: Date | null = null): Promise<void> {
await premiumService.setPremiumFromSubscriptionPeriod(
userId,
UserPremiumTypes.SUBSCRIPTION,
periodEnd,
'monthly',
true,
null,
periodStart,
);
}
async function webhookWrite(userId: UserID, periodEnd: Date, periodStart: Date | null = null): Promise<void> {
await users.updateSubscriptionStatus(userId, {
premiumWillCancel: false,
computedPremiumUntil: periodEnd,
periodStart,
});
}
async function writeBoth(
order: WriteOrder,
userId: UserID,
periodEnd: Date,
periodStart: Date | null = null,
): Promise<void> {
if (order === 'checkout first') {
await checkoutWrite(userId, periodEnd, periodStart);
await webhookWrite(userId, periodEnd, periodStart);
return;
}
await webhookWrite(userId, periodEnd, periodStart);
await checkoutWrite(userId, periodEnd, periodStart);
}
function newGiftCode(): string {
return `giftshift${randomUUID().replaceAll('-', '').slice(0, 20)}`;
}
async function createRedeemedGift(
redeemerId: UserID,
redeemedAt: Date,
duration: {type: 'days' | 'months'; quantity: number},
code: string = newGiftCode(),
): Promise<string> {
const gifter = await createTestAccount(harness);
await users.createGiftCode({
code,
duration_months: null,
duration_type: duration.type,
duration_quantity: duration.quantity,
created_at: redeemedAt,
created_by_user_id: createUserID(BigInt(gifter.userId)),
redeemed_at: redeemedAt,
redeemed_by_user_id: redeemerId,
stripe_payment_intent_id: `pi_${code}`,
visionary_sequence_number: null,
checkout_session_id: null,
version: 1,
});
return code;
}
async function reverseGift(code: string): Promise<void> {
const handler = new StripeGiftReversalHandler(
users,
new NoopGatewayService(),
getPremiumStateReconciliationQueueService(),
);
const gift = await users.findGiftCode(code);
await handler.handleGiftPremiumReversal(gift!, {reason: 'gift_chargeback'});
}
describe('subscribing while gift time is left', () => {
test.each(WRITE_ORDERS)(
'moves an active gift past the paid period when premium_until is empty (%s)',
async (order) => {
const giftEnd = new Date(Date.now() + ms('10 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: null,
premium_gift_extension_ends_at: giftEnd,
});
const periodEnd = new Date(Date.now() + ms('30 days'));
await writeBoth(order, userId, periodEnd);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(periodEnd.getTime());
expectNear(after.premiumGiftExtensionEndsAt, periodEnd.getTime() + ms('10 days'));
},
);
test.each(WRITE_ORDERS)('anchors at now when premium_until is a stale past date (%s)', async (order) => {
const giftEnd = new Date(Date.now() + ms('10 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: new Date(Date.now() - ms('60 days')),
premium_gift_extension_ends_at: giftEnd,
});
const periodEnd = new Date(Date.now() + ms('30 days'));
await writeBoth(order, userId, periodEnd);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(periodEnd.getTime());
expectNear(after.premiumGiftExtensionEndsAt, periodEnd.getTime() + ms('10 days'));
});
test.each(WRITE_ORDERS)('leaves an expired gift alone when premium_until is stale (%s)', async (order) => {
const giftEnd = new Date(Date.now() - ms('5 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: new Date(Date.now() - ms('60 days')),
premium_gift_extension_ends_at: giftEnd,
});
const periodEnd = new Date(Date.now() + ms('30 days'));
await writeBoth(order, userId, periodEnd);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(periodEnd.getTime());
expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime());
});
test('shifts the gift once when checkout.session.completed is replayed', async () => {
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_gift_extension_ends_at: new Date(Date.now() + ms('10 days')),
});
const periodEnd = new Date(Date.now() + ms('30 days'));
await checkoutWrite(userId, periodEnd);
const first = await findUser(account.userId);
await checkoutWrite(userId, periodEnd);
const replayed = await findUser(account.userId);
expect(replayed.premiumGiftExtensionEndsAt?.getTime()).toBe(first.premiumGiftExtensionEndsAt?.getTime());
});
test.each(WRITE_ORDERS)('moves a stacked gift along on renewal (%s)', async (order) => {
const premiumUntil = new Date(Date.now() + ms('1 hour'));
const giftEnd = new Date(premiumUntil.getTime() + ms('10 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: premiumUntil,
premium_gift_extension_ends_at: giftEnd,
});
const renewedUntil = new Date(premiumUntil.getTime() + ms('30 days'));
await writeBoth(order, userId, renewedUntil);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(renewedUntil.getTime());
expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime() + ms('30 days'));
});
test.each(WRITE_ORDERS)('keeps every gift day when a renewal is paid late (%s)', async (order) => {
const premiumUntil = new Date(Date.now() - ms('6 days'));
const giftEnd = new Date(premiumUntil.getTime() + ms('60 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: premiumUntil,
premium_gift_extension_ends_at: giftEnd,
premium_grace_ends_at: new Date(premiumUntil.getTime() + ms('14 days')),
});
const renewedUntil = new Date(premiumUntil.getTime() + ms('30 days'));
await writeBoth(order, userId, renewedUntil, premiumUntil);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(renewedUntil.getTime());
expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime() + ms('30 days'));
});
test.each(WRITE_ORDERS)(
'moves the gift by the whole period when the first payment lands late (%s)',
async (order) => {
const periodStart = new Date(Date.now() - ms('2 days'));
const giftEnd = new Date(Date.now() + ms('10 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: null,
premium_gift_extension_ends_at: giftEnd,
});
const periodEnd = new Date(periodStart.getTime() + ms('30 days'));
await writeBoth(order, userId, periodEnd, periodStart);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(periodEnd.getTime());
expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(giftEnd.getTime() + ms('30 days'));
},
);
test('lets a user with gift time left start a subscription checkout', async () => {
const {account} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_until: new Date(Date.now() - ms('60 days')),
premium_gift_extension_ends_at: new Date(Date.now() + ms('10 days')),
});
useStripe({subscriptionsListEmpty: true});
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
await createBuilder(harness, account.token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd})
.expect(HTTP_STATUS.OK)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('still refuses a recurring checkout for a Visionary', async () => {
const {account} = await createUser({premium_type: UserPremiumTypes.LIFETIME, premium_lifetime_sequence: 7});
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
await createBuilder(harness, account.token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.PREMIUM_PURCHASE_BLOCKED)
.execute();
});
test('still refuses a recurring checkout while a past_due subscription exists', async () => {
const {account} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_customer_id: 'cus_shift_past_due',
premium_gift_extension_ends_at: new Date(Date.now() + ms('10 days')),
});
useStripe({subscriptions: {sub_shift_past_due: {customer: 'cus_shift_past_due', status: 'past_due'}}});
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
const error = await createBuilder<{data?: {reason?: string}}>(harness, account.token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.PREMIUM_PURCHASE_BLOCKED)
.execute();
expect(JSON.stringify(error)).toContain('existing_subscription');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
});
describe('refunds', () => {
test('a refund keeps only the gift part of a gift trial', async () => {
const subscriptionId = 'sub_shift_refund_trial';
const paidUntil = Math.floor((Date.now() + ms('28 days')) / 1000);
const trialEnd = paidUntil + 30 * 24 * 60 * 60;
useStripe({
subscriptions: {
[subscriptionId]: {
trial_end: trialEnd,
status: 'trialing',
metadata: giftTrialMetadata(newGiftCode(), 30, paidUntil),
},
},
});
const {account} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_billing_cycle: 'monthly',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
const subscriptionService = new StripeSubscriptionService(
getStripeClient(),
users,
{getRecurringSubscriptionPriceId: () => null, getProduct: () => null},
getCacheService(),
new NoopGatewayService(),
);
await subscriptionService.cancelSubscriptionImmediately(
createUserID(BigInt(account.userId)),
'self_serve_refund',
);
const after = await findUser(account.userId);
expectNear(after.premiumUntil, Date.now());
expectNear(after.premiumGiftExtensionEndsAt, Date.now() + ms('30 days'));
});
test('a refund right after subscribing keeps only the real gift remainder', async () => {
const subscriptionId = 'sub_shift_refund';
const premiumUntil = new Date(Date.now() + ms('28 days'));
const {account} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_billing_cycle: 'monthly',
premium_until: premiumUntil,
premium_gift_extension_ends_at: new Date(premiumUntil.getTime() + ms('10 days')),
});
const subscriptionService = new StripeSubscriptionService(
getStripeClient(),
users,
{getRecurringSubscriptionPriceId: () => null, getProduct: () => null},
getCacheService(),
new NoopGatewayService(),
);
await subscriptionService.cancelSubscriptionImmediately(
createUserID(BigInt(account.userId)),
'self_serve_refund',
);
const after = await findUser(account.userId);
expect(stripeHandlers.spies.cancelledSubscriptions).toContain(subscriptionId);
expect(after.premiumType).toBe(UserPremiumTypes.SUBSCRIPTION);
expectNear(after.premiumUntil, Date.now());
expectNear(after.premiumGiftExtensionEndsAt, Date.now() + ms('10 days'));
});
});
describe('gift reversals', () => {
test('removes a gift that was moved past the paid period', async () => {
const premiumUntil = new Date(Date.now() + ms('30 days'));
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: 'sub_shift_charge_now',
stripe_customer_id: 'cus_test_1',
premium_until: premiumUntil,
premium_gift_extension_ends_at: new Date(premiumUntil.getTime() + ms('20 days')),
});
const code = await createRedeemedGift(userId, new Date(Date.now() - ms('10 days')), {type: 'days', quantity: 30});
await reverseGift(code);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(premiumUntil.getTime());
expect(after.premiumGiftExtensionEndsAt).toBeNull();
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
});
test('shortens the Stripe trial by the reversed gift once', async () => {
const subscriptionId = 'sub_shift_gift_trial';
const trialEnd = Math.floor((Date.now() + ms('50 days')) / 1000);
const code = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {trial_end: trialEnd, status: 'trialing', metadata: giftTrialMetadata(code, 30)},
},
});
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'days', quantity: 30}, code);
await reverseGift(code);
await reverseGift(code);
const shortenedEnd = trialEnd - 30 * 24 * 60 * 60;
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(1);
expect(stripeHandlers.spies.updatedSubscriptions[0]?.id).toBe(subscriptionId);
expect(Number(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end)).toBe(shortenedEnd);
expect(stripeHandlers.spies.updatedSubscriptions[0]?.params.metadata).toEqual({[`gtrial_${code}`]: ''});
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(shortenedEnd * 1000);
expect(after.premiumGiftExtensionEndsAt).toBeNull();
const gift = await users.findGiftCode(code);
expect(gift?.premiumReversedSeconds).toBe(30 * 24 * 60 * 60);
});
test('ends the trial now when the reversed gift is already being used', async () => {
const subscriptionId = 'sub_shift_gift_trial_used';
const trialEnd = Math.floor((Date.now() + ms('10 days')) / 1000);
const code = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {trial_end: trialEnd, status: 'trialing', metadata: giftTrialMetadata(code, 30)},
},
});
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
await createRedeemedGift(userId, new Date(Date.now() - ms('20 days')), {type: 'days', quantity: 30}, code);
await reverseGift(code);
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(1);
expect(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end).toBe('now');
expect(stripeHandlers.spies.cancelledSubscriptions).toHaveLength(0);
const after = await findUser(account.userId);
expectNear(after.premiumUntil, Date.now());
});
test('cancels a cancelling subscription instead of charging when the gift is already being used', async () => {
const subscriptionId = 'sub_shift_gift_trial_cancelling';
const trialEnd = Math.floor((Date.now() + ms('10 days')) / 1000);
const code = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {
trial_end: trialEnd,
status: 'trialing',
cancel_at_period_end: true,
metadata: giftTrialMetadata(code, 30),
},
},
});
const {userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_will_cancel: true,
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
await createRedeemedGift(userId, new Date(Date.now() - ms('20 days')), {type: 'days', quantity: 30}, code);
await reverseGift(code);
expect(stripeHandlers.spies.cancelledSubscriptions).toEqual([subscriptionId]);
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
});
test('releases the reversal claim when Stripe refuses the trial change', async () => {
const subscriptionId = 'sub_shift_gift_trial_fail';
const trialEnd = Math.floor((Date.now() + ms('50 days')) / 1000);
const code = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {trial_end: trialEnd, status: 'trialing', metadata: giftTrialMetadata(code, 30)},
},
});
server.use(
http.post('https://api.stripe.com/v1/subscriptions/:id', () =>
HttpResponse.json(
{error: {type: 'invalid_request_error', message: 'Mock trial update failure'}},
{status: 400},
),
),
);
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'days', quantity: 30}, code);
await expect(reverseGift(code)).rejects.toThrow();
expect((await users.findGiftCode(code))?.premiumReversedSeconds).toBeNull();
expect((await findUser(account.userId)).premiumUntil?.getTime()).toBe(trialEnd * 1000);
});
test('cuts a month gift back to exactly the paid period end', async () => {
const subscriptionId = 'sub_shift_gift_trial_month';
const paidUntil = Math.floor((Date.now() + ms('15 days')) / 1000);
const trialEnd = paidUntil + 28 * 24 * 60 * 60;
const code = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {
trial_end: trialEnd,
status: 'trialing',
metadata: giftTrialMetadata(code, 28, paidUntil),
},
},
});
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'months', quantity: 1}, code);
await reverseGift(code);
expect(Number(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end)).toBe(paidUntil);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(paidUntil * 1000);
expect(after.premiumGiftExtensionEndsAt).toBeNull();
});
test('never cuts the trial below the paid period end', async () => {
const subscriptionId = 'sub_shift_gift_trial_floor';
const paidUntil = Math.floor((Date.now() + ms('15 days')) / 1000);
const trialEnd = paidUntil + 20 * 24 * 60 * 60;
const code = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {
trial_end: trialEnd,
status: 'trialing',
metadata: giftTrialMetadata(code, 30, paidUntil),
},
},
});
const {userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(trialEnd * 1000),
});
await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'days', quantity: 30}, code);
await reverseGift(code);
expect(Number(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end)).toBe(paidUntil);
});
test('leaves the trial alone when the reversed gift was not stacked onto it', async () => {
const subscriptionId = 'sub_shift_gift_trial_other';
const paidUntil = Math.floor((Date.now() + ms('10 days')) / 1000);
const trialEnd = paidUntil + 30 * 24 * 60 * 60;
const stackedCode = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {
trial_end: trialEnd,
status: 'trialing',
metadata: giftTrialMetadata(stackedCode, 30, paidUntil),
},
},
});
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(Date.now() + ms('55 days')),
});
const plainCode = await createRedeemedGift(userId, new Date(Date.now() - ms('5 days')), {
type: 'days',
quantity: 30,
});
await createRedeemedGift(userId, new Date(Date.now() - ms('1 hour')), {type: 'days', quantity: 30}, stackedCode);
await reverseGift(plainCode);
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
expect(stripeHandlers.spies.cancelledSubscriptions).toHaveLength(0);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(trialEnd * 1000);
expect(after.premiumGiftExtensionEndsAt!.getTime()).toBeLessThanOrEqual(trialEnd * 1000);
});
test("keeps the paid shift of the user's other gifts", async () => {
const subscriptionId = 'sub_shift_gift_trial_keep_shift';
const now = Date.now();
const paidUntil = Math.floor((now + ms('10 days')) / 1000);
const trialEnd = paidUntil + 30 * 24 * 60 * 60;
const stackedCode = newGiftCode();
useStripe({
subscriptions: {
[subscriptionId]: {
trial_end: trialEnd,
status: 'trialing',
metadata: giftTrialMetadata(stackedCode, 30, paidUntil),
},
},
});
const firstRedeemedAt = new Date(now - ms('150 days'));
const firstGiftBaseEnd = firstRedeemedAt.getTime() + ms('365 days');
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_until: new Date(trialEnd * 1000),
premium_gift_extension_ends_at: new Date(firstGiftBaseEnd + ms('120 days') + ms('30 days')),
});
await createRedeemedGift(userId, firstRedeemedAt, {type: 'days', quantity: 365});
await createRedeemedGift(userId, new Date(now - ms('1 hour')), {type: 'days', quantity: 30}, stackedCode);
await reverseGift(stackedCode);
expect(Number(stripeHandlers.spies.updatedSubscriptions[0]?.params.trial_end)).toBe(paidUntil);
const after = await findUser(account.userId);
expect(after.premiumUntil?.getTime()).toBe(paidUntil * 1000);
expect(after.premiumGiftExtensionEndsAt?.getTime()).toBe(firstGiftBaseEnd + ms('120 days'));
});
});
describe('redeeming a gift onto a running subscription', () => {
test('records the stacked trial once whichever write lands first', async () => {
const subscriptionId = 'sub_shift_stack';
const periodEnd = Math.floor((Date.now() + ms('20 days')) / 1000);
useStripe({
subscriptions: {
[subscriptionId]: {current_period_start: periodEnd - 30 * 24 * 60 * 60, current_period_end: periodEnd},
},
});
const {account, userId} = await createUser({
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_billing_cycle: 'monthly',
premium_until: new Date(periodEnd * 1000),
});
const gifter = await createTestAccount(harness);
await createBuilder(harness, gifter.token)
.post('/test/gifts/SHIFTSTACKGIFT')
.body({duration_type: 'days', duration_quantity: 30, created_by_user_id: gifter.userId})
.execute();
await createBuilder(harness, account.token).post('/gifts/SHIFTSTACKGIFT/redeem').expect(204).execute();
const trialEnd = new Date((periodEnd + 30 * 24 * 60 * 60) * 1000);
expect(stripeHandlers.spies.updatedSubscriptions[0]?.params.metadata).toEqual({
gtrial_SHIFTSTACKGIFT: String(30 * 24 * 60 * 60),
gtrial_paid_until: String(periodEnd),
});
const redeemed = await findUser(account.userId);
expect(redeemed.premiumUntil?.getTime()).toBe(trialEnd.getTime());
expect(redeemed.premiumGiftExtensionEndsAt?.getTime()).toBe(trialEnd.getTime());
await webhookWrite(userId, trialEnd);
const afterWebhook = await findUser(account.userId);
expect(afterWebhook.premiumGiftExtensionEndsAt?.getTime()).toBe(trialEnd.getTime());
});
});
});
@@ -37,10 +37,20 @@ const MOCK_PRICES = {
yearlyPln: 'price_nordic_yearly_pln',
monthlySek: 'price_nordic_monthly_sek',
yearlySek: 'price_nordic_yearly_sek',
monthlyIsk: 'price_nordic_monthly_isk',
yearlyIsk: 'price_nordic_yearly_isk',
gift1MonthUsd: 'price_nordic_gift_1_month_usd',
gift1YearUsd: 'price_nordic_gift_1_year_usd',
gift1MonthEur: 'price_nordic_gift_1_month_eur',
gift1YearEur: 'price_nordic_gift_1_year_eur',
gift1MonthDkk: 'price_nordic_gift_1_month_dkk',
gift1YearDkk: 'price_nordic_gift_1_year_dkk',
gift1MonthNok: 'price_nordic_gift_1_month_nok',
gift1YearNok: 'price_nordic_gift_1_year_nok',
gift1MonthSek: 'price_nordic_gift_1_month_sek',
gift1YearSek: 'price_nordic_gift_1_year_sek',
gift1MonthIsk: 'price_nordic_gift_1_month_isk',
gift1YearIsk: 'price_nordic_gift_1_year_isk',
};
const MOCK_PRICE_SEEDS = {
@@ -48,14 +58,24 @@ const MOCK_PRICE_SEEDS = {
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
[MOCK_PRICES.monthlyEur]: {unit_amount: 499, currency: 'eur', interval: 'month' as const},
[MOCK_PRICES.yearlyEur]: {unit_amount: 4999, currency: 'eur', interval: 'year' as const},
[MOCK_PRICES.monthlyDkk]: {unit_amount: 3700, currency: 'dkk', interval: 'month' as const},
[MOCK_PRICES.yearlyDkk]: {unit_amount: 37000, currency: 'dkk', interval: 'year' as const},
[MOCK_PRICES.monthlyNok]: {unit_amount: 5400, currency: 'nok', interval: 'month' as const},
[MOCK_PRICES.yearlyNok]: {unit_amount: 54000, currency: 'nok', interval: 'year' as const},
[MOCK_PRICES.monthlyDkk]: {unit_amount: 3500, currency: 'dkk', interval: 'month' as const},
[MOCK_PRICES.yearlyDkk]: {unit_amount: 35000, currency: 'dkk', interval: 'year' as const},
[MOCK_PRICES.monthlyNok]: {unit_amount: 4900, currency: 'nok', interval: 'month' as const},
[MOCK_PRICES.yearlyNok]: {unit_amount: 49000, currency: 'nok', interval: 'year' as const},
[MOCK_PRICES.monthlyPln]: {unit_amount: 1900, currency: 'pln', interval: 'month' as const},
[MOCK_PRICES.yearlyPln]: {unit_amount: 19000, currency: 'pln', interval: 'year' as const},
[MOCK_PRICES.monthlySek]: {unit_amount: 5400, currency: 'sek', interval: 'month' as const},
[MOCK_PRICES.yearlySek]: {unit_amount: 54000, currency: 'sek', interval: 'year' as const},
[MOCK_PRICES.monthlySek]: {unit_amount: 4900, currency: 'sek', interval: 'month' as const},
[MOCK_PRICES.yearlySek]: {unit_amount: 49000, currency: 'sek', interval: 'year' as const},
[MOCK_PRICES.monthlyIsk]: {unit_amount: 59000, currency: 'isk', interval: 'month' as const},
[MOCK_PRICES.yearlyIsk]: {unit_amount: 590000, currency: 'isk', interval: 'year' as const},
[MOCK_PRICES.gift1MonthDkk]: {unit_amount: 3500, currency: 'dkk'},
[MOCK_PRICES.gift1YearDkk]: {unit_amount: 35000, currency: 'dkk'},
[MOCK_PRICES.gift1MonthNok]: {unit_amount: 4900, currency: 'nok'},
[MOCK_PRICES.gift1YearNok]: {unit_amount: 49000, currency: 'nok'},
[MOCK_PRICES.gift1MonthSek]: {unit_amount: 4900, currency: 'sek'},
[MOCK_PRICES.gift1YearSek]: {unit_amount: 49000, currency: 'sek'},
[MOCK_PRICES.gift1MonthIsk]: {unit_amount: 59000, currency: 'isk'},
[MOCK_PRICES.gift1YearIsk]: {unit_amount: 590000, currency: 'isk'},
};
const EUR_SUBSCRIPTION_ID = 'sub_nordic_legacy_eur';
@@ -125,20 +145,35 @@ describe('Nordic localized currencies', () => {
expect(priceIds.currency).toBe('SEK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlySek);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlySek);
expect(priceIds.monthly_amount_minor).toBe(4900);
expect(priceIds.yearly_amount_minor).toBe(49000);
});
test('Denmark resolves to the DKK subscription catalog', async () => {
const priceIds = await getPriceIds('DK');
test.each(['DK', 'FO', 'GL'])('%s resolves to the DKK subscription catalog', async (country) => {
const priceIds = await getPriceIds(country);
expect(priceIds.currency).toBe('DKK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyDkk);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyDkk);
expect(priceIds.monthly_amount_minor).toBe(3500);
expect(priceIds.yearly_amount_minor).toBe(35000);
});
test('Norway resolves to the NOK subscription catalog', async () => {
const priceIds = await getPriceIds('NO');
test.each(['NO', 'SJ'])('%s resolves to the NOK subscription catalog', async (country) => {
const priceIds = await getPriceIds(country);
expect(priceIds.currency).toBe('NOK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyNok);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyNok);
expect(priceIds.monthly_amount_minor).toBe(4900);
expect(priceIds.yearly_amount_minor).toBe(49000);
});
test('Iceland resolves to the ISK subscription catalog', async () => {
const priceIds = await getPriceIds('IS');
expect(priceIds.currency).toBe('ISK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyIsk);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyIsk);
expect(priceIds.monthly_amount_minor).toBe(59000);
expect(priceIds.yearly_amount_minor).toBe(590000);
});
test('Poland still resolves to the PLN subscription catalog', async () => {
@@ -147,23 +182,45 @@ describe('Nordic localized currencies', () => {
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyPln);
});
test.each(['DE', 'NL', 'FI', 'IE', 'IS'])('%s still resolves to the EUR subscription catalog', async (country) => {
test.each(['DE', 'NL', 'FI', 'IE', 'AX'])('%s still resolves to the EUR subscription catalog', async (country) => {
const priceIds = await getPriceIds(country);
expect(priceIds.currency).toBe('EUR');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyEur);
});
test.each(['SE', 'DK', 'NO'])('%s keeps gifts on the base currency', async (country) => {
test.each([
['SE', 'SEK', MOCK_PRICES.gift1MonthSek, MOCK_PRICES.gift1YearSek, 4900],
['DK', 'DKK', MOCK_PRICES.gift1MonthDkk, MOCK_PRICES.gift1YearDkk, 3500],
['NO', 'NOK', MOCK_PRICES.gift1MonthNok, MOCK_PRICES.gift1YearNok, 4900],
['IS', 'ISK', MOCK_PRICES.gift1MonthIsk, MOCK_PRICES.gift1YearIsk, 59000],
])('%s sells gifts in %s at the subscription price', async (country, currency, giftMonth, giftYear, amount) => {
const priceIds = await getPriceIds(country);
expect(priceIds.gift_currency).toBe('EUR');
expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthEur);
expect(priceIds.gift_1_year).toBe(MOCK_PRICES.gift1YearEur);
expect(priceIds.gift_currency).toBe(currency);
expect(priceIds.gift_1_month).toBe(giftMonth);
expect(priceIds.gift_1_year).toBe(giftYear);
expect(priceIds.gift_1_month_amount_minor).toBe(amount);
expect(priceIds.gift_1_month_amount_minor).toBe(priceIds.monthly_amount_minor);
expect(priceIds.gift_1_year_amount_minor).toBe(priceIds.yearly_amount_minor);
});
test('a Nordic gift falls through to USD when no EUR gift price is configured', async () => {
test('a Nordic gift falls back to EUR while the native gift prices are unconfigured', async () => {
Config.stripe.prices = {
...MOCK_PRICES,
gift1MonthSek: undefined,
gift1YearSek: undefined,
};
const priceIds = await getPriceIds('SE');
expect(priceIds.currency).toBe('SEK');
expect(priceIds.gift_currency).toBe('EUR');
expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthEur);
});
test('a Nordic gift falls through to USD when no native or EUR gift price is configured', async () => {
Config.stripe.prices = {
...MOCK_PRICES,
gift1MonthSek: undefined,
gift1YearSek: undefined,
gift1MonthEur: undefined,
gift1YearEur: undefined,
};
@@ -182,7 +239,23 @@ describe('Nordic localized currencies', () => {
const priceIds = await getPriceIds('SE');
expect(priceIds.currency).toBe('EUR');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur);
expect(priceIds.gift_currency).toBe('SEK');
});
test('Iceland falls back to EUR while the ISK prices are still unconfigured', async () => {
Config.stripe.prices = {
...MOCK_PRICES,
monthlyIsk: undefined,
yearlyIsk: undefined,
gift1MonthIsk: undefined,
gift1YearIsk: undefined,
};
const priceIds = await getPriceIds('IS');
expect(priceIds.currency).toBe('EUR');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyEur);
expect(priceIds.gift_currency).toBe('EUR');
expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthEur);
});
});
@@ -232,18 +305,52 @@ describe('Nordic localized currencies', () => {
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('accepts the base gift price from inside Sweden', async () => {
test('accepts the SEK gift price from inside Sweden', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
const response = await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.gift1MonthEur, country_code: 'SE'})
.body({price_id: MOCK_PRICES.gift1MonthSek, country_code: 'SE'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.url).toContain('checkout.stripe.com');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('rejects the EUR gift price from inside Sweden once SEK gifts exist', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.gift1MonthEur, country_code: 'SE'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('accepts the ISK subscription price for a purchase that geolocates to Iceland', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('IS'));
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.yearlyIsk, country_code: 'IS'})
.expect(HTTP_STATUS.OK)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
expect(stripeHandlers.spies.createdCheckoutSessions[0]?.line_items?.[0]?.price).toBe(MOCK_PRICES.yearlyIsk);
});
test('marks Åland as EEA so the withdrawal waiver applies', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('AX'));
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyEur, country_code: 'AX'})
.expect(HTTP_STATUS.OK)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions[0]?.metadata?.eu_withdrawal_waiver_required).toBe('true');
});
test('rejects the SEK subscription price on the gift endpoint', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
@@ -256,6 +363,43 @@ describe('Nordic localized currencies', () => {
});
});
describe('adaptive pricing', () => {
async function checkout(path: 'subscription' | 'gift', priceId: string, country: string) {
lookupGeoipMock.mockResolvedValue(geoipCountry(country));
const token = await createPurchaser();
await createBuilder<{url: string}>(harness, token)
.post(`/stripe/checkout/${path}`)
.body({price_id: priceId, country_code: country})
.expect(HTTP_STATUS.OK)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
return stripeHandlers.spies.createdCheckoutSessions[0];
}
test.each([
['subscription', MOCK_PRICES.monthlySek, 'SE'],
['subscription', MOCK_PRICES.yearlySek, 'SE'],
['gift', MOCK_PRICES.gift1MonthSek, 'SE'],
['subscription', MOCK_PRICES.monthlyNok, 'NO'],
['subscription', MOCK_PRICES.monthlyDkk, 'DK'],
['subscription', MOCK_PRICES.monthlyIsk, 'IS'],
['gift', MOCK_PRICES.gift1YearIsk, 'IS'],
] as const)('turns adaptive pricing off for a %s checkout with %s', async (path, priceId, country) => {
const session = await checkout(path, priceId, country);
expect(session?.adaptive_pricing).toEqual({enabled: 'false'});
});
test.each([
['subscription', MOCK_PRICES.monthlyEur, 'DE'],
['gift', MOCK_PRICES.gift1MonthEur, 'FI'],
['subscription', MOCK_PRICES.monthlyUsd, 'US'],
['subscription', MOCK_PRICES.monthlyPln, 'PL'],
] as const)('leaves adaptive pricing unset for a %s checkout with %s', async (path, priceId, country) => {
const session = await checkout(path, priceId, country);
expect(session?.adaptive_pricing).toBeUndefined();
});
});
describe('existing subscribers', () => {
test('a Swedish customer billed in EUR keeps the EUR price and is not repriced to SEK', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
@@ -9,6 +9,7 @@ import {createGuild, createRole, getMember} from '@app/api/guild/tests/GuildTest
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
@@ -159,5 +160,30 @@ describe('StripePremiumService', () => {
expect(me.premium_type).toBe(UserPremiumTypes.LIFETIME);
expect(me.premium_lifetime_sequence).toBe(5);
});
test('keeps Visionary number 0 when a lifetime grant comes from a subscription period', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
premium_type: UserPremiumTypes.LIFETIME,
premium_lifetime_sequence: 0,
})
.execute();
const premiumService = new StripePremiumService(
new UserRepository(),
new NoopGatewayService(),
{} as IGuildRepositoryAggregate,
{} as GuildService,
);
await premiumService.setPremiumFromSubscriptionPeriod(
createUserID(BigInt(account.userId)),
UserPremiumTypes.LIFETIME,
new Date(Date.now() + 86_400_000),
);
const me = await createBuilder<{premium_lifetime_sequence: number | null}>(harness, account.token)
.get('/users/@me')
.execute();
expect(me.premium_lifetime_sequence).toBe(0);
});
});
});
@@ -147,7 +147,7 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
expect(user.premium_will_cancel).toBe(true);
expect(user.premium_until).not.toBeNull();
});
test('preserves gifted extension when updating subscription', async () => {
test('moves the gifted extension along when the subscription period moves forward', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const subscriptionId = 'sub_test_gifted';
@@ -207,7 +207,9 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
expect(premiumUntil.getTime()).toBeGreaterThan(new Date(currentPeriodEnd * 1000).getTime());
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser?.premiumUntil?.toISOString()).toBe(new Date(currentPeriodEnd * 1000).toISOString());
expect(updatedUser?.premiumGiftExtensionEndsAt?.toISOString()).toBe(giftExtensionEndsAt.toISOString());
expect(updatedUser?.premiumGiftExtensionEndsAt?.getTime()).toBe(
giftExtensionEndsAt.getTime() + (currentPeriodEnd * 1000 - initialPremiumUntil.getTime()),
);
});
test('does not grant unpaid future time for past_due subscriptions and disables grace', async () => {
const account = await createTestAccount(harness);
+2
View File
@@ -11,6 +11,7 @@ import {
setCassandraQueryExecutorForTesting,
shutdownCassandraQueryExecutorForTesting,
} from '@app/api/database/CassandraQueryExecution';
import {pinChannelThreadsConfigForTesting} from '@app/api/experiment/ChannelThreadsGate';
import type {IUsersServiceClient} from '@app/api/infrastructure/UsersServiceClient';
import {setInjectedUsersServiceClient} from '@app/api/infrastructure/UsersServiceClient';
import {initializeLogger} from '@app/api/Logger';
@@ -227,6 +228,7 @@ afterEach(async () => {
});
afterAll(async () => {
pinChannelThreadsConfigForTesting(null);
server.close();
await shutdownCassandraQueryExecutorForTesting();
});
@@ -6,6 +6,9 @@ import {HttpResponse, http, type RequestHandler} from 'msw';
const STRIPE_API_BASE = 'https://api.stripe.com';
interface CheckoutSessionParams {
adaptive_pricing?: {
enabled?: string;
};
billing_address_collection?: string;
customer?: string;
customer_email?: string;
@@ -322,6 +325,7 @@ interface MockStripeSubscriptionState {
latest_invoice: string | null;
status: 'active' | 'canceled' | 'incomplete' | 'past_due' | 'trialing';
schedule_id: string | null;
metadata: Record<string, string>;
}
interface MockStripeSubscriptionSchedule {
@@ -355,7 +359,12 @@ interface MockStripeSubscriptionSchedule {
const PRICE_ID_CURRENCY_MARKERS = ['eur', 'brl', 'dkk', 'inr', 'nok', 'pln', 'sek', 'try'] as const;
const ISK_PRICE_ID_PATTERN = /(?:^|_)isk(?:_|$)/;
function inferPriceIdCurrency(normalizedPriceId: string): string {
if (ISK_PRICE_ID_PATTERN.test(normalizedPriceId)) {
return 'isk';
}
return PRICE_ID_CURRENCY_MARKERS.find((marker) => normalizedPriceId.includes(marker)) ?? 'usd';
}
@@ -610,6 +619,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
subscriptionStore.set(subscriptionId, {
...createDefaultSubscriptionState(),
...overrides,
metadata: {...overrides.metadata},
});
}
}
@@ -829,6 +839,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
latest_invoice: null,
status: 'active',
schedule_id: null,
metadata: {},
};
}
function getOrCreateSubscriptionState(subscriptionId: string): MockStripeSubscriptionState {
@@ -893,7 +904,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
? getPaymentMethod(subState.default_payment_method)
: null,
livemode: false,
metadata: {},
metadata: {...subState.metadata},
schedule: subState.schedule_id,
start_date: Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60,
};
@@ -1389,7 +1400,12 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
}
spies.updatedSubscriptions.push({id: id as string, params: updateParams});
const subState = getOrCreateSubscriptionState(id as string);
if (updateParams.trial_end) {
if (updateParams.trial_end === 'now') {
const nowUnix = Math.floor(Date.now() / 1000);
subState.trial_end = null;
subState.current_period_start = nowUnix;
subState.current_period_end = nowUnix + (subState.interval === 'year' ? 365 : 30) * 24 * 60 * 60;
} else if (updateParams.trial_end) {
subState.trial_end = Number(updateParams.trial_end);
}
if ('cancel_at' in updateParams) {
@@ -1404,6 +1420,15 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
if (typeof updateParams.default_payment_method === 'string') {
subState.default_payment_method = updateParams.default_payment_method;
}
if (updateParams.metadata && typeof updateParams.metadata === 'object') {
for (const [key, value] of Object.entries(updateParams.metadata as Record<string, string>)) {
if (value === '') {
delete subState.metadata[key];
} else {
subState.metadata[key] = value;
}
}
}
const updatedItem = Array.isArray(updateParams.items) ? updateParams.items[0] : null;
if (updatedItem && typeof updatedItem === 'object') {
if (typeof updatedItem.id === 'string') {
@@ -1421,10 +1446,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
}
}
subscriptionStore.set(id as string, subState);
return HttpResponse.json({
...mapSubscriptionStateToStripeSubscription(id as string, subState),
metadata: updateParams.metadata || {},
});
return HttpResponse.json(mapSubscriptionStateToStripeSubscription(id as string, subState));
}),
http.delete(`${STRIPE_API_BASE}/v1/subscriptions/:id`, ({params}) => {
const {id} = params;
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export function shiftGiftExtensionEnd(
giftEnd: Date | null | undefined,
anchorMs: number,
nextPremiumUntil: Date,
): Date | null {
if (!giftEnd) {
return null;
}
const shiftMs = nextPremiumUntil.getTime() - anchorMs;
if (shiftMs <= 0 || giftEnd.getTime() <= anchorMs) {
return giftEnd;
}
return new Date(giftEnd.getTime() + shiftMs);
}
export function shiftGiftExtensionPastPremiumUntil(
current: {premiumUntil: Date | null | undefined; giftEnd: Date | null | undefined},
nextPremiumUntil: Date,
now: Date,
periodStart: Date | null,
): Date | null {
const paidFromMs = Math.min(now.getTime(), periodStart?.getTime() ?? now.getTime());
const anchorMs = Math.max(paidFromMs, current.premiumUntil?.getTime() ?? 0);
return shiftGiftExtensionEnd(current.giftEnd, anchorMs, nextPremiumUntil);
}
+2
View File
@@ -353,6 +353,8 @@ export function mapUserSettingsToResponse(params: {settings: UserSettings}): Use
synced_preferences: settings.syncedPreferences,
profile_privacy: settings.profilePrivacy,
default_share_voice_activity: settings.defaultShareVoiceActivity,
privacy_setup_version: settings.privacySetupVersion,
privacy_setup_completed_at: settings.privacySetupCompletedAt?.toISOString() ?? null,
};
}
@@ -71,6 +71,7 @@ export interface IUserAccountRepository {
updates: {
premiumWillCancel: boolean;
computedPremiumUntil: Date | null;
periodStart: Date | null;
},
): Promise<{
finalVersion: number | null;
@@ -188,6 +188,7 @@ export class UserAccountRepository implements IUserAccountRepository {
updates: {
premiumWillCancel: boolean;
computedPremiumUntil: Date | null;
periodStart: Date | null;
},
): Promise<{
finalVersion: number | null;
@@ -225,6 +225,7 @@ export class UserRepository implements IUserRepositoryAggregate {
updates: {
premiumWillCancel: boolean;
computedPremiumUntil: Date | null;
periodStart: Date | null;
},
): Promise<{
finalVersion: number | null;
@@ -252,6 +252,7 @@ export class UserAccountRepository {
updates: {
premiumWillCancel: boolean;
computedPremiumUntil: Date | null;
periodStart: Date | null;
},
): Promise<{
finalVersion: number | null;
@@ -10,6 +10,7 @@ import {EMPTY_USER_ROW, USER_COLUMNS} from '@app/api/database/types/UserTypes';
import {emitAccountChangedIfRelevant} from '@app/api/infrastructure/activity/AccountChangeEvents';
import {User} from '@app/api/models/User';
import {Users} from '@app/api/Tables';
import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift';
import {isPendingDeletionBlocked} from '@app/api/user/services/PendingDeletionCoordinator';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {DELETED_USER_ID, UserFlags} from '@fluxer/constants/src/UserConstants';
@@ -320,6 +321,7 @@ export class UserDataRepository {
updates: {
premiumWillCancel: boolean;
computedPremiumUntil: Date | null;
periodStart: Date | null;
},
): Promise<{
finalVersion: number | null;
@@ -329,12 +331,24 @@ export class UserDataRepository {
async () => {
return fetchOne<UserRow>(FETCH_USER_BY_ID_CQL, {user_id: userId});
},
(_current) => {
(current) => {
const computedPremiumUntil = updates.computedPremiumUntil;
const patch: UserPatch = {
premium_will_cancel: Db.set(updates.premiumWillCancel),
premium_until: computedPremiumUntil ? Db.set(computedPremiumUntil) : Db.clear(),
};
const giftEnd = current?.premium_gift_extension_ends_at ?? null;
if (computedPremiumUntil && giftEnd) {
const shiftedGiftEnd = shiftGiftExtensionPastPremiumUntil(
{premiumUntil: current?.premium_until, giftEnd},
computedPremiumUntil,
new Date(),
updates.periodStart,
);
if (shiftedGiftEnd && shiftedGiftEnd.getTime() !== giftEnd.getTime()) {
patch.premium_gift_extension_ends_at = Db.set(shiftedGiftEnd);
}
}
return {
pk: {user_id: userId},
patch,
@@ -137,6 +137,10 @@ export class UserAccountSettingsService {
if (data.bot_default_guilds_restricted !== undefined) {
updatedRowData.bot_default_guilds_restricted = data.bot_default_guilds_restricted;
}
if (data.privacy_setup_version !== undefined) {
updatedRowData.privacy_setup_version = data.privacy_setup_version;
updatedRowData.privacy_setup_completed_at = new Date();
}
if (data.inline_attachment_media !== undefined) {
updatedRowData.inline_attachment_media = data.inline_attachment_media;
}
@@ -27,7 +27,7 @@ import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domain
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {afterEach, describe, expect, it, vi} from 'vitest';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const {isUnreachableEntityError} = UserContentServiceTestHooks;
@@ -255,7 +255,7 @@ describe('getRecentMentions', () => {
const THREAD = createChannelID(400n);
const viewer = {kind: 'user', userId: VIEWER_ID, bot: false, capable: false} as const;
afterEach(() => {
beforeEach(() => {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
});
@@ -447,7 +447,7 @@ describe('getSavedMessages', () => {
);
}
afterEach(() => {
beforeEach(() => {
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
});
Loaded 100 of 1614 files, more files were not shown because too many files have changed in this diff. Show more