Compare commits

..
Author SHA1 Message Date
HampusandGitHub dcc3273889 feat(app): ask everyone to review who can message them (#3323) 2026-10-08 20:47:44 +02:00
HampusandGitHub e9a4f33a7e feat(api): track privacy setup and open community DMs by default (#3322) 2026-10-08 20:47:22 +02:00
HampusandGitHub ae820ba4ea chore(i18n): refresh catalog references (#3320) 2026-10-08 20:34:03 +02:00
HampusandGitHub 94f1239050 fix(premium): reflect limit config flags in plan comparisons (#3319) 2026-10-08 20:33:20 +02:00
HampusandGitHub acc1392a53 fix(ui): reopen a popout clicked while it is closing (#3318) 2026-10-08 20:33:04 +02:00
HampusandGitHub a6a9789091 fix(ui): stop clicks passing through the user area footer (#3317) 2026-10-08 20:32:49 +02:00
HampusandGitHub fdb7410976 fix(threads): light up joined threads in muted communities (#3316) 2026-10-08 20:32:33 +02:00
HampusandGitHub b146b8ee33 fix(desktop): trust locally added CAs in Chromium on Linux (#3315) 2026-10-08 20:21:04 +02:00
HampusandGitHub 4b1afc953f fix(app): redraw the favicon badge when branding changes (#3314) 2026-10-08 20:20:49 +02:00
HampusandGitHub 4ecbe9603c fix(api): store branding images as media references (#3313) 2026-10-08 20:20:34 +02:00
HampusandGitHub 847d449882 chore(i18n): refresh catalog references (#3312) 2026-10-08 20:06:41 +02:00
HampusandGitHub bef20cba85 fix(links): warn on unhandled middle clicks and desktop links (#3311) 2026-10-08 20:05:54 +02:00
JiraliteandGitHub ebf91ccc76 fix(links): warn on embed link middle clicks (#3305) 2026-10-08 20:05:27 +02:00
HampusandGitHub abfaff16c2 fix(guild): fit text icon initials to the icon by measured width (#3310) 2026-10-08 20:05:09 +02:00
HampusandGitHub 89fa895a0a fix(forum): keep closed posts visible without a reload (#3309) 2026-10-08 20:04:53 +02:00
HampusandGitHub 334fef52e1 fix(desktop): keep slow connects racing instead of aborting them (#3308) 2026-10-08 20:04:39 +02:00
HampusandGitHub aa42bf13ce fix(threads): announce threads above the latest five messages (#3307) 2026-10-08 20:04:23 +02:00
HampusandGitHub c486c2d3d0 revert(admin): restore user type toggles (#3306) 2026-10-08 20:04:01 +02:00
HampusandGitHub 0ec1cee99e test(gateway): keep the recheck timer out of the eunit process (#3304) 2026-10-08 18:14:47 +02:00
HampusandGitHub ccbe4857a1 docs(auth): document the desktop handoff deep link and deny (#3303) 2026-10-08 18:00:17 +02:00
HampusandGitHub 7f8b0afedb chore(i18n): refresh catalog references (#3302) 2026-10-08 17:58:50 +02:00
JiraliteandGitHub e94b7d7ad8 fix(threads): add a debug thread button (#3276) 2026-10-08 17:57:47 +02:00
HampusandGitHub 27baa38fd1 fix(threads): open the thread menu on browser right-click (#3301) 2026-10-08 17:45:33 +02:00
HampusandGitHub 4af10dd3e3 fix(threads): keep header actions clear of the channel name (#3300) 2026-10-08 17:44:48 +02:00
HampusandGitHub 269d33cab2 fix(threads): confirm before leaving a private thread (#3299) 2026-10-08 17:44:00 +02:00
HampusandGitHub 170e12595b fix(threads): let the thread members popout scroll (#3298) 2026-10-08 17:42:56 +02:00
HampusandGitHub b82bbaa2dd fix(ui): drop the switch label tab stop with no focus ring (#3297) 2026-10-08 17:42:11 +02:00
HampusandGitHub b8f3dbddbf fix(threads): keep Tab and focus inside the thread create pane (#3296) 2026-10-08 17:41:14 +02:00
HampusandGitHub 663572fd10 fix(forum): keep attachments on forum and media posts (#3295) 2026-10-08 17:40:29 +02:00
HampusandGitHub 5033cf7203 fix(app): follow theme library changes made in other windows (#3294) 2026-10-08 17:39:34 +02:00
HampusandGitHub 0fcab2f8f0 fix(app): hold the switched view until its route commits (#3293) 2026-10-08 17:38:36 +02:00
HampusandGitHub c3d790f664 fix(app): refocus the editor before text context menu actions (#3292) 2026-10-08 17:37:40 +02:00
HampusandGitHub 0906a85d6f fix(app): keep mature content consent across account switches (#3291) 2026-10-08 17:36:55 +02:00
HampusandGitHub 4da8dec4ac fix(app): ignore leftover mentions in unseen channels (#3290) 2026-10-08 17:35:59 +02:00
HampusandGitHub d6e3254a16 feat(app): show instance branding in switchers and pickers (#3289) 2026-10-08 17:34:56 +02:00
HampusandGitHub 6dc698ff5e fix(app): follow instance branding in tab title and favicon (#3288) 2026-10-08 17:33:28 +02:00
HampusandGitHub 39e48458da fix(app): keep a global define from breaking bundled modules (#3287) 2026-10-08 17:32:40 +02:00
HampusandGitHub c5453f83e8 feat(desktop): sign in with the browser without typing a code (#3286) 2026-10-08 17:31:45 +02:00
HampusandGitHub 510cc1b916 fix(desktop): show the text context menu on password fields (#3285) 2026-10-08 17:30:11 +02:00
HampusandGitHub e7859fefb1 fix(desktop): leave full screen before hiding on macOS close (#3284) 2026-10-08 17:28:58 +02:00
HampusandGitHub 46a356cc40 fix(media): keep self-host media loading after restarts (#3283) 2026-10-08 17:28:18 +02:00
HampusandGitHub bd56174870 fix(desktop): frame streamed request bodies for every method (#3282) 2026-10-08 17:26:41 +02:00
HampusandGitHub 965bb5f634 fix(desktop): trust the system CA store in main requests (#3281) 2026-10-08 17:26:12 +02:00
HampusandGitHub 7937d5f802 fix(desktop): update from the splash only after the user clicks (#3280) 2026-10-08 17:25:34 +02:00
HampusandGitHub f1373a63c3 feat(ci): publish renderer modules without a shell build (#3279) 2026-10-08 17:23:59 +02:00
HampusandGitHub e0d7625c39 feat(api): hand desktop sign-in back with a deep link grant (#3278) 2026-10-08 17:23:28 +02:00
HampusandGitHub f8505d19f7 feat(threads): ship threads and the Plutonium page to everyone (#3277) 2026-10-08 16:30:39 +02:00
HampusandGitHub 1515a8487f fix(desktop): fall back across resolved addresses on connect (#3275) 2026-10-08 05:08:00 +02:00
HampusandGitHub 7a4aa42d4b feat(premium): native Nordic prices and subscribing during grace (#3274) 2026-10-08 04:21:54 +02:00
HampusandGitHub 0ef1cd14c3 feat(desktop): show download progress and diagnostics on splash (#3273) 2026-10-08 03:06:06 +02:00
HampusandGitHub 795e190bda fix(desktop): let the Theme Studio popout resolve its runtime (#3272) 2026-10-08 03:05:37 +02:00
HampusandGitHub d00389ab30 fix(desktop): reach split-horizon instances on their LAN IP (#3271) 2026-10-08 03:05:15 +02:00
HampusandGitHub 3071bc7525 fix(desktop): honour the system proxy in main-process requests (#3270) 2026-10-08 03:04:55 +02:00
HampusandGitHub 6de6be2358 fix(threads): open the thread menu on thread chip right-click (#3269) 2026-10-08 03:04:35 +02:00
HampusandGitHub c51c222c47 fix(voice): stop stale mute echoes from toggling mute (#3268) 2026-10-08 03:04:10 +02:00
HampusandGitHub e218ba21de fix(app): load saved account avatars from their own instance (#3267) 2026-10-08 03:03:47 +02:00
HampusandGitHub de358c0def fix(app): let action toolbar overrides win over its defaults (#3266) 2026-10-08 03:03:26 +02:00
HampusandGitHub 4b88d64b2d fix(app): show the official badge and a visible account menu (#3265) 2026-10-08 03:03:05 +02:00
HampusandGitHub ee1c861eb2 perf(desktop): cache display media and retain shown images (#3264) 2026-10-08 03:02:41 +02:00
HampusandGitHub 6a24ac3f4e fix(desktop): fetch media bytes via the local resource proxy (#3263) 2026-10-08 03:02:15 +02:00
HampusandGitHub 8b312c610e fix(app): tag only desktop proxy uploads with a local upload id (#3262) 2026-10-08 00:40:51 +02:00
HampusandGitHub 2e3e3a1536 fix(desktop): fix manual update wording and retry Velopack (#3261) 2026-10-08 00:04:58 +02:00
HampusandGitHub 5c63d81ffd fix(app): show the setup wizard on new instances, not sign-in (#3260) 2026-10-07 23:28:04 +02:00
HampusandGitHub c402c52a8a fix(app): read instance config safely in startup skeletons (#3259) 2026-10-07 23:27:59 +02:00
HampusandGitHub 317fedeef9 fix(ci): resolve a bare drive WORKDIR to the drive root (#3258) 2026-10-07 21:44:18 +02:00
521 changed files with 38174 additions and 27364 deletions
@@ -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[@]}"
+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 -170
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,19 +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" => instance_config_result(
client
.update_instance_config(&build_channel_threads_update(&form))
.await,
),
"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),
@@ -619,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 {
@@ -677,28 +632,6 @@ fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateReq
})
}
fn build_channel_threads_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let channel_threads = if form.bool_value("channel_threads_everyone") {
ChannelThreadsConfigUpdateRequest {
enabled: Some(true),
guild_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX),
user_basis_points: Some(EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX),
disabled_guild_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
..Default::default()
}
} else {
ChannelThreadsConfigUpdateRequest {
enabled: Some(false),
..Default::default()
}
};
InstanceConfigUpdateRequest {
channel_threads: Some(channel_threads),
..Default::default()
}
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -1464,30 +1397,6 @@ mod tests {
}
}
#[test]
fn build_channel_threads_update_turns_threads_on_for_everyone() {
let form = MultiValueForm::parse(b"_csrf=token&channel_threads_everyone=true");
assert_eq!(
serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"),
serde_json::json!({"channel_threads": {
"enabled": true,
"guild_basis_points": 10000,
"user_basis_points": 10000,
"disabled_guild_ids": [],
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_channel_threads_update_only_turns_threads_off_when_unchecked() {
let form = MultiValueForm::parse(b"_csrf=token");
assert_eq!(
serde_json::to_value(build_channel_threads_update(&form)).expect("serializable update"),
serde_json::json!({"channel_threads": {"enabled": false}})
);
}
#[test]
fn build_push_relay_update_reads_the_consent_checkbox() {
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
@@ -1568,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! {
@@ -4,13 +4,12 @@ use crate::{
api::types::{
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
ChannelThreadsConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
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,
@@ -193,10 +192,6 @@ pub fn instance_config_page(
@if !instance_config.self_hosted {
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
}
(channel_threads_section(base, csrf_token, &instance_config.channel_threads))
@if !instance_config.self_hosted {
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page))
}
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -1281,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
}
@@ -1489,48 +1343,6 @@ fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse
)
}
fn channel_threads_available_to_everyone(channel_threads: &ChannelThreadsConfigResponse) -> bool {
channel_threads.enabled
&& channel_threads.guild_basis_points >= 10_000
&& channel_threads.user_basis_points >= 10_000
&& channel_threads.disabled_guild_ids.is_empty()
&& channel_threads.excluded_user_ids.is_empty()
}
fn channel_threads_section(
base: &str,
csrf_token: &str,
channel_threads: &ChannelThreadsConfigResponse,
) -> Markup {
let everyone = channel_threads_available_to_everyone(channel_threads);
section_card_with_description(
"Threads and forums",
"Threads, forum channels and media channels in every community.",
html! {
form method="post" action={(base) "/instance-config?action=update_channel_threads"} {
(csrf_input(csrf_token))
div class="space-y-6" {
(checkbox(
"channel_threads_everyone",
"true",
"Available to everyone",
everyone,
true,
))
@if channel_threads.enabled && !everyone {
p class="text-xs text-neutral-500" {
"Currently on for part of this instance. Saving applies the setting above to everyone."
}
}
(form_actions(html! {
(submit_button("Save"))
}))
}
}
},
)
}
fn experiment_delivery_section(
base: &str,
csrf_token: &str,
@@ -2274,66 +2086,6 @@ mod tests {
assert!(!markup.contains("at the cap"));
}
#[test]
fn channel_threads_section_is_a_single_everyone_toggle() {
let everyone = ChannelThreadsConfigResponse {
enabled: true,
guild_basis_points: 10_000,
user_basis_points: 10_000,
..ChannelThreadsConfigResponse::default()
};
let on = channel_threads_section("/admin", "csrf", &everyone).into_string();
assert!(on.contains("action=update_channel_threads"));
assert!(on.contains("name=\"channel_threads_everyone\""));
assert!(on.contains("checked"));
assert!(!on.contains("basis_points"));
assert!(!on.contains("part of this instance"));
let partial = ChannelThreadsConfigResponse {
enabled: true,
enabled_guild_ids: vec!["1600000000000000001".to_owned()],
user_basis_points: 10_000,
..ChannelThreadsConfigResponse::default()
};
let partial = channel_threads_section("/admin", "csrf", &partial).into_string();
assert!(!partial.contains("checked"));
assert!(partial.contains("part of this instance"));
let off =
channel_threads_section("/admin", "csrf", &ChannelThreadsConfigResponse::default())
.into_string();
assert!(!off.contains("checked"));
assert!(!off.contains("part of this instance"));
}
#[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 -62
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,47 +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("Threads and forums"), "{body}");
assert!(body.contains("Available to everyone"), "{body}");
assert!(
body.contains(r#"name="channel_threads_everyone""#),
"{body}"
);
assert!(
!body.contains("channel_threads_guild_basis_points"),
"{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 in [
format!("_csrf={csrf_token}&channel_threads_everyone=true"),
format!("_csrf={csrf_token}"),
] {
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("Instance config updated"), "{toast}");
}
assert!(!body.contains("Threads and forums"), "{body}");
assert!(!body.contains("update_channel_threads"), "{body}");
assert!(!body.contains("update_plutonium_page"), "{body}");
}
#[tokio::test]
@@ -1238,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',
+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});
});
});
@@ -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);
@@ -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,
};
}
+135 -6
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": {
@@ -33609,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) : {}));
});
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
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 {fetchUserSettings, updateUserSettings} from '@app/api/user/tests/UserTestUtils';
import {beforeEach, describe, expect, test} from 'vitest';
describe('User settings privacy setup', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('new accounts allow community DMs and have not reviewed the privacy setup', async () => {
const account = await createTestAccount(harness);
const {json} = await fetchUserSettings(harness, account.token);
expect(json.default_guilds_restricted).toBe(false);
expect(json.privacy_setup_version).toBe(0);
expect(json.privacy_setup_completed_at).toBeNull();
});
test('completing the privacy setup stores the version, the time and the chosen setting in one update', async () => {
const account = await createTestAccount(harness);
const before = Date.now();
const {json} = await updateUserSettings(harness, account.token, {
default_guilds_restricted: true,
privacy_setup_version: 1,
});
expect(json.default_guilds_restricted).toBe(true);
expect(json.privacy_setup_version).toBe(1);
expect(Date.parse(json.privacy_setup_completed_at ?? '')).toBeGreaterThanOrEqual(before - 1000);
const {json: reread} = await fetchUserSettings(harness, account.token);
expect(reread.privacy_setup_version).toBe(1);
expect(reread.privacy_setup_completed_at).toBe(json.privacy_setup_completed_at);
await createBuilder(harness, account.token)
.patch('/users/@me/settings')
.body({privacy_setup_version: 0})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});
+25 -4
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {
type EffectiveBillingConfig,
getEffectiveBillingConfig,
@@ -32,13 +33,16 @@ function getEnvCurrencyPreferences(countryCode: string | null | undefined): Arra
if (upperCode === 'BR') {
return ['BRL', 'USD', 'EUR'];
}
if (upperCode === 'DK') {
if (upperCode === 'DK' || upperCode === 'FO' || upperCode === 'GL') {
return ['DKK', 'EUR', 'USD'];
}
if (upperCode === 'IN') {
return ['INR', 'USD', 'EUR'];
}
if (upperCode === 'NO') {
if (upperCode === 'IS') {
return ['ISK', 'EUR', 'USD'];
}
if (upperCode === 'NO' || upperCode === 'SJ') {
return ['NOK', 'EUR', 'USD'];
}
if (upperCode === 'PL') {
@@ -56,9 +60,22 @@ function getEnvCurrencyPreferences(countryCode: string | null | undefined): Arra
return ['USD', 'EUR'];
}
const GIFT_ELIGIBLE_LOCALIZED_CURRENCIES = new Set<Currency>(['DKK', 'NOK', 'SEK']);
const GIFT_ELIGIBLE_LOCALIZED_CURRENCIES = new Set<Currency>(['DKK', 'ISK', 'NOK', 'SEK']);
const ENV_CATALOG_CURRENCIES = new Set<Currency>(['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY']);
const ADAPTIVE_PRICING_DISABLED_CURRENCIES = new Set<Currency>(['DKK', 'ISK', 'NOK', 'SEK']);
const ENV_CATALOG_CURRENCIES = new Set<Currency>([
'USD',
'EUR',
'BRL',
'DKK',
'INR',
'ISK',
'NOK',
'PLN',
'SEK',
'TRY',
]);
const OPERATOR_CURRENCY_PATTERN = /^[A-Z]{3}$/;
@@ -77,6 +94,10 @@ export function getGiftCurrencyPreferences(
);
}
export function shouldDisableAdaptivePricing(currency: string): boolean {
return !Config.instance.selfHosted && ADAPTIVE_PRICING_DISABLED_CURRENCIES.has(currency.toUpperCase());
}
export function isLocalizedCurrency(
currency: Currency,
config: EffectiveBillingConfig = getEffectiveBillingConfig(),
@@ -1,6 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getCurrency, getCurrencyPreferences, getGiftCurrencyPreferences} from '@app/api/utils/CurrencyUtils';
import {Config} from '@app/api/Config';
import {
getCurrency,
getCurrencyPreferences,
getGiftCurrencyPreferences,
shouldDisableAdaptivePricing,
} from '@app/api/utils/CurrencyUtils';
import {describe, expect, it} from 'vitest';
describe('getCurrency', () => {
@@ -79,8 +85,8 @@ describe('getCurrency', () => {
it('returns NOK for Norway (EEA but not EU)', () => {
expect(getCurrency('NO')).toBe('NOK');
});
it('returns EUR for Iceland (EEA but not EU)', () => {
expect(getCurrency('IS')).toBe('EUR');
it('returns ISK for Iceland (EEA but not EU)', () => {
expect(getCurrency('IS')).toBe('ISK');
});
it('returns EUR for Liechtenstein (EEA but not EU)', () => {
expect(getCurrency('LI')).toBe('EUR');
@@ -146,8 +152,8 @@ describe('getCurrency', () => {
'SK',
'SI',
'ES',
'IS',
'LI',
'AX',
];
for (const country of eeaCountries) {
it(`returns EUR for ${country}`, () => {
@@ -166,19 +172,62 @@ describe('getCurrency', () => {
it('uses local currency for Norway', () => {
expect(getCurrency('NO')).toBe('NOK');
});
it('uses local currency for Iceland', () => {
expect(getCurrency('IS')).toBe('ISK');
});
});
describe('maps Nordic territories to their home currency', () => {
it('returns DKK for the Faroe Islands and Greenland', () => {
expect(getCurrencyPreferences('FO')).toEqual(['DKK', 'EUR', 'USD']);
expect(getCurrencyPreferences('GL')).toEqual(['DKK', 'EUR', 'USD']);
});
it('returns NOK for Svalbard and Jan Mayen', () => {
expect(getCurrencyPreferences('SJ')).toEqual(['NOK', 'EUR', 'USD']);
});
it('returns ISK with EUR as the fallback for Iceland', () => {
expect(getCurrencyPreferences('IS')).toEqual(['ISK', 'EUR', 'USD']);
});
it('returns EUR for Åland', () => {
expect(getCurrencyPreferences('AX')).toEqual(['EUR', 'USD']);
});
});
});
describe('shouldDisableAdaptivePricing', () => {
it('disables adaptive pricing for the native Nordic currencies', () => {
for (const currency of ['SEK', 'NOK', 'DKK', 'ISK', 'sek']) {
expect(shouldDisableAdaptivePricing(currency)).toBe(true);
}
});
it('leaves adaptive pricing alone for every other currency', () => {
for (const currency of ['USD', 'EUR', 'BRL', 'INR', 'PLN', 'TRY']) {
expect(shouldDisableAdaptivePricing(currency)).toBe(false);
}
});
it('leaves adaptive pricing alone on a self-hosted instance', () => {
const originalSelfHosted = Config.instance.selfHosted;
Config.instance.selfHosted = true;
try {
expect(shouldDisableAdaptivePricing('SEK')).toBe(false);
} finally {
Config.instance.selfHosted = originalSelfHosted;
}
});
});
describe('getGiftCurrencyPreferences', () => {
it('never offers a localized currency that is cheaper than the base price', () => {
it('never offers BRL, INR, PLN or TRY gifts', () => {
for (const country of ['BR', 'IN', 'PL', 'TR']) {
expect(getGiftCurrencyPreferences(country)).not.toContain(getCurrencyPreferences(country)[0]);
}
});
it('offers the localized currency where it is not cheaper than the base price', () => {
it('offers the Nordic localized currencies for gifts', () => {
expect(getGiftCurrencyPreferences('SE')).toEqual(['SEK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('DK')).toEqual(['DKK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('NO')).toEqual(['NOK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('IS')).toEqual(['ISK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('FO')).toEqual(['DKK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('SJ')).toEqual(['NOK', 'EUR', 'USD']);
});
it('uses EUR for other EEA countries', () => {
expect(getGiftCurrencyPreferences('DE')).toEqual(['EUR', 'USD']);
@@ -87,7 +87,6 @@ const LANE_CONFIG = {
'refreshSearchIndex',
'seedThreadPermissions',
'archiveInactiveThreads',
'rebuildThreadAutoArchiveQueue',
'repairThreadIndexes',
'deleteChannelThreads',
'removeThreadMembershipsForGuildMember',
+8
View File
@@ -294,6 +294,14 @@ export async function startWorkerMain(): Promise<void> {
await startContentBlocklistCaches({kvClient: dependencies.kvClient, storageService: dependencies.storageService});
Logger.info('Content blocklist caches initialised for worker backend');
await queueBlocklistFeedStartupJobs(dependencies.kvClient, workerService, Config.blocklistFeeds.enabled);
try {
const normalized = await getInstanceConfigRepository().normalizeStoredBrandingAssets(dependencies.storageService);
if (normalized > 0) {
Logger.info({normalized}, 'Normalised stored instance branding assets to references');
}
} catch (error) {
Logger.warn({err: error}, 'Failed to normalise stored instance branding assets');
}
setActivityProcessChannel('worker');
await startActivityEvents({
publisher: jetStreamActivityPublisher(jsConnectionManager.getJetStreamClient()),
@@ -55,7 +55,6 @@ import syncUrlBlocklists from '@app/api/worker/tasks/SyncUrlBlocklists';
import {
archiveInactiveThreads,
deleteChannelThreads,
rebuildThreadAutoArchiveQueue,
removeThreadMembershipsForGuildMember,
repairThreadIndexes,
} from '@app/api/worker/tasks/ThreadMaintenanceTasks';
@@ -69,7 +68,6 @@ export const workerTasks: Record<WorkerTaskName, WorkerTaskHandler> = {
applicationProcessDeletion,
archiveInactiveThreads,
deleteChannelThreads,
rebuildThreadAutoArchiveQueue,
removeThreadMembershipsForGuildMember,
repairThreadIndexes,
syncThreadSearchDocument,
@@ -569,6 +569,8 @@ export function buildUserDataJson(params: UserDataJsonParams) {
staff_dm_access_user_ids: Array.from(userSettings.staffDmAccessUserIds).map((id) => id.toString()),
synced_preferences: syncedPreferencesToJson(decodeSyncedPreferencesLenient(userSettings.syncedPreferences)),
profile_privacy: userSettings.profilePrivacy,
privacy_setup_version: userSettings.privacySetupVersion,
privacy_setup_completed_at: userSettings.privacySetupCompletedAt?.toISOString() ?? null,
}
: null,
guild_memberships: guildMemberships
@@ -11,9 +11,11 @@ import {canProvisionPremiumFromSubscriptionStatus} from '@app/api/stripe/StripeS
import {
getInvoiceLatestLinePeriodEnd,
getPrimarySubscriptionItem,
getSubscriptionCurrentPeriodStart,
getSubscriptionPremiumPeriodEnd,
getSubscriptionStartDate,
} from '@app/api/stripe/StripeSubscriptionPeriod';
import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift';
import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
@@ -71,6 +73,15 @@ function buildStripePremiumRepairPatch(user: User, subscription: Stripe.Subscrip
}
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;
@@ -13,11 +13,6 @@ import type {Channel} from '@app/api/models/Channel';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {DEFAULT_THREAD_PERMISSIONS, THREAD_PERMISSIONS} from '@fluxer/constants/src/ThreadPermissionUtils';
import {
type ChannelThreadsConfig,
channelThreadsGuildActive,
compileChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import {z} from 'zod';
@@ -27,7 +22,6 @@ const PayloadSchema = z.object({
});
const SEEDED_GUILD_CACHE_MAX_ENTRIES = 10_000;
const SEED_MARKER_READ_CHUNK = 100;
const seededGuildIds = new Set<string>();
export function seedThreadOverwriteBits(value: bigint): bigint {
@@ -39,33 +33,6 @@ export function seedThreadOverwriteValue(value: bigint): bigint {
return (value & Permissions.SEND_MESSAGES) !== 0n ? cleared | DEFAULT_THREAD_PERMISSIONS : cleared;
}
export function enabledThreadGuildIds(config: ChannelThreadsConfig): Array<string> {
const compiled = compileChannelThreadsConfig(config);
return config.enabled_guild_ids.filter((id) => channelThreadsGuildActive(compiled, id));
}
export function newlyEnabledThreadGuildIds(
previous: ChannelThreadsConfig,
landed: ChannelThreadsConfig,
): Array<string> {
const before = new Set(enabledThreadGuildIds(previous));
return enabledThreadGuildIds(landed).filter((id) => !before.has(id));
}
export async function enqueueThreadPermissionSeeds(
threads: IThreadRepository,
config: ChannelThreadsConfig,
): Promise<void> {
const guildIds = enabledThreadGuildIds(config);
for (let index = 0; index < guildIds.length; index += SEED_MARKER_READ_CHUNK) {
const chunk = guildIds.slice(index, index + SEED_MARKER_READ_CHUNK);
const markers = await Promise.all(chunk.map((id) => threads.getGuildMarker(createGuildID(BigInt(id)))));
for (const [position, guildId] of chunk.entries()) {
if (!markers[position]?.perms_seeded_at) await enqueueThreadPermissionSeed(guildId);
}
}
}
async function enqueueThreadPermissionSeed(guildId: string): Promise<void> {
await getWorkerService().addJob(
'seedThreadPermissions',
@@ -22,14 +22,12 @@ import {
import {Logger} from '@app/api/Logger';
import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
import {deleteThreadSearchDocuments} from '@app/api/search/thread/ThreadSearchService';
import {ensureChannelThreadsConfigVersion} from '@app/api/worker/tasks/SeedThreadPermissions';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import {MAX_THREAD_ARCHIVES_PER_SWEEP_TICK} from '@fluxer/constants/src/ThreadConstants';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import {z} from 'zod';
const SnowflakeString = z.string().regex(/^\d{1,20}$/);
const GuildPayload = z.object({guildId: SnowflakeString, configVersion: z.number().int().min(0).optional()});
const RepairPayload = z.object({threadIds: z.array(SnowflakeString).min(1).max(1000)});
const ParentPayload = z.object({guildId: SnowflakeString, parentId: SnowflakeString});
const MemberPayload = z.object({guildId: SnowflakeString, userId: SnowflakeString});
@@ -37,8 +35,8 @@ const MemberPayload = z.object({guildId: SnowflakeString, userId: SnowflakeStrin
const PARENT_PAGE_SIZE = 100;
function archiveQueue(): KVThreadAutoArchiveQueueService {
const {kvClient, channelRepository} = getWorkerDependencies();
return new KVThreadAutoArchiveQueueService(kvClient, channelRepository.threads, channelRepository.channelData);
const {kvClient} = getWorkerDependencies();
return new KVThreadAutoArchiveQueueService(kvClient);
}
async function archiveDueThreads(
@@ -109,15 +107,6 @@ export const archiveInactiveThreads: WorkerTaskHandler = async () => {
if (archived > 0) Logger.info({archived}, 'Archived inactive threads');
};
export const rebuildThreadAutoArchiveQueue: WorkerTaskHandler = async (payload) => {
const validated = GuildPayload.parse(payload);
await ensureChannelThreadsConfigVersion(validated.configVersion);
const guildId = createGuildID(BigInt(validated.guildId));
if (!guildActive(guildId)) return;
const scheduled = await archiveQueue().rebuildGuild(guildId);
Logger.info({guildId: guildId.toString(), scheduled}, 'Rebuilt thread auto-archive queue');
};
export const repairThreadIndexes: WorkerTaskHandler = async (payload) => {
const {threadIds} = RepairPayload.parse(payload);
const {channelRepository} = getWorkerDependencies();

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