mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcc3273889 | ||
|
|
e9a4f33a7e | ||
|
|
ae820ba4ea | ||
|
|
94f1239050 | ||
|
|
acc1392a53 | ||
|
|
a6a9789091 | ||
|
|
fdb7410976 | ||
|
|
b146b8ee33 | ||
|
|
4b1afc953f | ||
|
|
4ecbe9603c | ||
|
|
847d449882 | ||
|
|
bef20cba85 | ||
|
|
ebf91ccc76 | ||
|
|
abfaff16c2 | ||
|
|
89fa895a0a | ||
|
|
334fef52e1 | ||
|
|
aa42bf13ce | ||
|
|
c486c2d3d0 | ||
|
|
0ec1cee99e | ||
|
|
ccbe4857a1 | ||
|
|
7f8b0afedb | ||
|
|
e94b7d7ad8 | ||
|
|
27baa38fd1 | ||
|
|
4af10dd3e3 | ||
|
|
269d33cab2 | ||
|
|
170e12595b | ||
|
|
b82bbaa2dd | ||
|
|
b8f3dbddbf | ||
|
|
663572fd10 | ||
|
|
5033cf7203 | ||
|
|
0fcab2f8f0 | ||
|
|
c3d790f664 | ||
|
|
0906a85d6f | ||
|
|
4da8dec4ac | ||
|
|
d6e3254a16 | ||
|
|
6dc698ff5e | ||
|
|
39e48458da | ||
|
|
c5453f83e8 | ||
|
|
510cc1b916 | ||
|
|
e7859fefb1 | ||
|
|
46a356cc40 | ||
|
|
bd56174870 | ||
|
|
965bb5f634 | ||
|
|
7937d5f802 | ||
|
|
f1373a63c3 | ||
|
|
e0d7625c39 | ||
|
|
f8505d19f7 | ||
|
|
1515a8487f | ||
|
|
7a4aa42d4b | ||
|
|
0ef1cd14c3 | ||
|
|
795e190bda | ||
|
|
d00389ab30 | ||
|
|
3071bc7525 | ||
|
|
6de6be2358 | ||
|
|
c51c222c47 | ||
|
|
e218ba21de | ||
|
|
de358c0def | ||
|
|
4b88d64b2d | ||
|
|
ee1c861eb2 | ||
|
|
6a24ac3f4e | ||
|
|
8b312c610e | ||
|
|
2e3e3a1536 | ||
|
|
5c63d81ffd | ||
|
|
c402c52a8a | ||
|
|
317fedeef9 |
@@ -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
@@ -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"}},
|
||||
|
||||
@@ -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!({})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"))
|
||||
}))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user