mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
905af5dd5a | ||
|
|
5fea319f4e | ||
|
|
01fd11fea9 | ||
|
|
d028679b90 | ||
|
|
4a93b677af | ||
|
|
d79cd99050 | ||
|
|
167862a8a6 | ||
|
|
48b569b9d4 | ||
|
|
75be6aa492 | ||
|
|
9fe65d5036 | ||
|
|
bfa9bf221d | ||
|
|
184eeb0846 | ||
|
|
0eff26a1c9 | ||
|
|
d729f641ff | ||
|
|
044a2c101d | ||
|
|
38e2c8db3e | ||
|
|
1b22d14f3d | ||
|
|
98cceae59d | ||
|
|
3e32414849 | ||
|
|
7707b9531c | ||
|
|
86745e01e9 | ||
|
|
098830a95a | ||
|
|
cf83f66911 | ||
|
|
c577b97f35 | ||
|
|
8cc485cf81 | ||
|
|
6c36d934f7 | ||
|
|
63e3be5750 | ||
|
|
cdecda7f78 | ||
|
|
4ad2858773 | ||
|
|
fda41bb57a | ||
|
|
ce08f82a92 | ||
|
|
ef067f36c6 | ||
|
|
fc2b6b5299 | ||
|
|
55846b24ea | ||
|
|
20a15ac11d | ||
|
|
667ac7da8e | ||
|
|
1b81c14c48 | ||
|
|
ceec183d38 | ||
|
|
69ddc07ebb | ||
|
|
2f008b8653 | ||
|
|
3d38d3f694 | ||
|
|
ad86a04e67 | ||
|
|
600c15e17d | ||
|
|
08c9fe9886 | ||
|
|
43924e3ac5 |
@@ -36,8 +36,7 @@ body:
|
||||
label: Build information
|
||||
description: >-
|
||||
Open User Settings, scroll to the bottom of the left sidebar, and select
|
||||
the build information. Fluxer copies it to the clipboard. On mobile,
|
||||
select the build information at the bottom of the settings list.
|
||||
the build information. Fluxer copies it to the clipboard.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Mobile client bugs
|
||||
url: https://github.com/fluxerapp/flutter_client#bug-reporting
|
||||
about: Read the reporting instructions for the Fluxer mobile client.
|
||||
- name: Account and billing support
|
||||
url: https://fluxer.app/help
|
||||
about: Find account help and support contact details.
|
||||
- name: Feature proposals
|
||||
url: https://github.com/orgs/fluxerapp/discussions
|
||||
about: Propose a feature in a discussion.
|
||||
- name: Security vulnerabilities
|
||||
url: https://github.com/fluxerapp/fluxer/security/advisories/new
|
||||
about: Submit a private vulnerability report.
|
||||
- name: Translations
|
||||
url: https://weblate.fluxer.tools
|
||||
about: Improve an existing locale or start a new one.
|
||||
|
||||
@@ -314,8 +314,8 @@ jobs:
|
||||
path: |
|
||||
~/.cache/rebar3
|
||||
fluxer_gateway/_build
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
||||
key: >-
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||
'fluxer_gateway/rebar.config') }}
|
||||
@@ -345,8 +345,8 @@ jobs:
|
||||
path: |
|
||||
~/.cache/rebar3
|
||||
fluxer_gateway/_build
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
||||
key: >-
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||
'fluxer_gateway/rebar.config') }}
|
||||
@@ -426,6 +426,31 @@ jobs:
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check formatting and lint
|
||||
run: pnpm exec biome ci .
|
||||
|
||||
- name: Lint JSX for browser-translation safety
|
||||
run: pnpm exec eslint . --max-warnings 0
|
||||
|
||||
i18n:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
|
||||
+23
-1
@@ -84,7 +84,18 @@
|
||||
},
|
||||
"useConst": "error",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "off"
|
||||
"noParameterAssign": "off",
|
||||
"noRestrictedImports": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"paths": {
|
||||
"@lingui/react": {
|
||||
"importNames": ["I18nProvider"],
|
||||
"message": "Use AppI18nProvider from @app/features/i18n/components/AppI18nProvider so <Trans> output stays safe under page translation."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"a11y": {
|
||||
"recommended": true,
|
||||
@@ -116,10 +127,21 @@
|
||||
},
|
||||
"assist": {"actions": {"source": {"organizeImports": "on"}}},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["fluxer_app/src/**/*.tsx"],
|
||||
"plugins": ["./tools/lint/no-adjacent-jsx-text.grit"]
|
||||
},
|
||||
{
|
||||
"includes": ["fluxer_docs/scripts/VerifyDocsCoverage.ts"],
|
||||
"linter": {"rules": {"suspicious": {"noTemplateCurlyInString": "off"}}}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"fluxer_app/src/features/i18n/components/AppI18nProvider.tsx",
|
||||
"fluxer_app/src/features/i18n/components/AppI18nProvider.test.tsx"
|
||||
],
|
||||
"linter": {"rules": {"style": {"noRestrictedImports": "off"}}}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.astro"],
|
||||
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
# A name absent from this file is one Compose does not forward, and it reaches a
|
||||
# service only through a Compose override file that adds it to that service's
|
||||
# environment. packages/config/src/__tests__/DeployEnvCoverage.test.ts fails when
|
||||
# a Compose edit forgets the matching line here.
|
||||
# a Compose edit forgets the matching line here. Compose expands this file from
|
||||
# top to bottom, so a line written with ${...} has to sit below every name it
|
||||
# reads.
|
||||
|
||||
FLUXER_DOMAIN=chat.example.com
|
||||
FLUXER_PUBLIC_SCHEME=https
|
||||
FLUXER_PUBLIC_PORT=443
|
||||
|
||||
# The three lines above are the address browsers use, and every endpoint the
|
||||
# services advertise carries the port from FLUXER_PUBLIC_PORT. They do not move
|
||||
# what the host publishes. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT further down
|
||||
# do that, and a non-default port needs the matching one set as well. Both
|
||||
# complete recipes are written out beside them.
|
||||
|
||||
# How browsers reach this instance.
|
||||
#
|
||||
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
|
||||
@@ -33,50 +41,71 @@ FLUXER_PUBLIC_PORT=443
|
||||
# address if it reaches Fluxer from a public IP.
|
||||
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
|
||||
|
||||
# The public origin browsers use, without a trailing slash. Derived from the three
|
||||
# values above and correct for the usual https-on-443 setup, so leave it alone
|
||||
# unless you serve Fluxer on a non-default port, where the port must appear here.
|
||||
# The origin browsers see, without a trailing slash. Leave it unset and each
|
||||
# service builds one from the three values at the top of this file. Set it and it
|
||||
# wins: every service reads the host, the scheme and the port out of it and
|
||||
# ignores those three names. Use it when browsers reach the instance on a host
|
||||
# FLUXER_DOMAIN does not name. It has to be a bare origin, a scheme and a host
|
||||
# and an optional port and nothing after them, or the services refuse to start.
|
||||
# It does not move the edge listener or the published ports either, so set the
|
||||
# publish below to the port written here.
|
||||
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
|
||||
|
||||
# Overrides the address Fluxer's edge listens on. Honoured in the default mode
|
||||
# only: docker-compose.proxy.yml sets the literal :8080 and Compose lets the last
|
||||
# file win, so a value here is discarded under the proxy overlay with no warning.
|
||||
# Set it only for an unusual default-mode layout, such as serving several
|
||||
# hostnames or binding a non-default TLS port.
|
||||
#FLUXER_EDGE_SITE_ADDRESS=chat.example.com
|
||||
# Overrides the address the edge listens on inside its container. Compose builds
|
||||
# it from FLUXER_PUBLIC_SCHEME and FLUXER_DOMAIN with no port, and the edge keeps
|
||||
# its container ports at 80 and 443 whatever the public port is. Caddy matches a
|
||||
# site by host and ignores the port in the Host header, so a request arriving on
|
||||
# a non-default published port still lands on this site. Put a port in this value
|
||||
# only if you also publish that same container port below, or nothing will be
|
||||
# listening where the publish points. Honoured in the default mode only:
|
||||
# docker-compose.proxy.yml sets the literal :8080 and tunnel.compose.yml the
|
||||
# literal :80, and Compose lets the last file win, so a value here is discarded
|
||||
# under either overlay with no warning. Set it for an unusual default-mode
|
||||
# layout, such as serving several hostnames. Write the scheme into it: a bare
|
||||
# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says.
|
||||
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
|
||||
|
||||
# The old name for the value above. It is read only when
|
||||
# FLUXER_EDGE_SITE_ADDRESS is unset, so an existing .env keeps the listener
|
||||
# it already had. Rename it to FLUXER_EDGE_SITE_ADDRESS at your convenience.
|
||||
#FLUXER_CADDY_SITE_ADDRESS=
|
||||
|
||||
# FLUXER_PUBLIC_ORIGIN is the origin browsers see. It must carry the port
|
||||
# whenever FLUXER_PUBLIC_PORT is not the default for its scheme, because an
|
||||
# origin written with a default port never matches a browser Origin header.
|
||||
# Serving on any other port means setting all three, plus the published port
|
||||
# below, and pointing FLUXER_EDGE_SITE_ADDRESS at the same scheme and host.
|
||||
# Compose expands this file from top to bottom, so FLUXER_PUBLIC_ORIGIN has to
|
||||
# stay below the two values it reads. Above them it silently expands to a bare
|
||||
# host with a trailing colon.
|
||||
#FLUXER_PUBLIC_SCHEME=http
|
||||
#FLUXER_PUBLIC_PORT=19080
|
||||
#FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT}
|
||||
#FLUXER_HTTP_PORT=19080
|
||||
|
||||
# Ports Caddy publishes on the host. Caddy still listens on 80 and 443 inside
|
||||
# the container, so change only these when something else already owns the
|
||||
# standard ports or another proxy sits in front. Both take an optional bind
|
||||
# address in front of the port, and 127.0.0.1 keeps the publish off every
|
||||
# public interface. FLUXER_HTTPS_PORT moves the TCP and the UDP publish
|
||||
# together, because HTTP/3 needs both on the same port.
|
||||
# Host side of the edge's publishes, and the only two names that decide which
|
||||
# host ports Fluxer binds. The container side is fixed. Container 80 carries the
|
||||
# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https
|
||||
# scheme, and the site itself under an http one. Container 443 carries the TLS
|
||||
# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because
|
||||
# HTTP/3 needs both on the same port. Both take an optional bind address in front
|
||||
# of the port, and 127.0.0.1 keeps the publish off every public interface. Give
|
||||
# them different host ports: the same host port on both is two publishes of one
|
||||
# port and the edge refuses to start.
|
||||
#FLUXER_HTTP_PORT=80
|
||||
#FLUXER_HTTPS_PORT=443
|
||||
#FLUXER_HTTP_PORT=127.0.0.1:80
|
||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||
|
||||
# HTTPS on 8443, complete. Host 80 stays published and still answers the ACME
|
||||
# challenge. Let's Encrypt only ever connects to the public 80 or 443, so the
|
||||
# certificate is issued if a router in front forwards public 80 to this host and
|
||||
# is not issued otherwise. Serve your own certificate from the Caddyfile when it
|
||||
# cannot.
|
||||
#FLUXER_PUBLIC_PORT=8443
|
||||
#FLUXER_HTTPS_PORT=8443
|
||||
|
||||
# Plain HTTP on 19080, complete. The port 80 publish moves to 19080, so nothing
|
||||
# binds host 80. Under an http scheme nothing listens on container 443, so the
|
||||
# last line parks that publish on loopback for a host that wants 443 for
|
||||
# something else. Drop it and 443 is published and idle, which is what earlier
|
||||
# releases did.
|
||||
#FLUXER_PUBLIC_SCHEME=http
|
||||
#FLUXER_PUBLIC_PORT=19080
|
||||
#FLUXER_HTTP_PORT=19080
|
||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||
|
||||
# A tunnel or another proxy in front of the stack needs no HTTPS publish at all.
|
||||
# tunnel.compose.yml ships beside this file and replaces Caddy's published ports
|
||||
# with a single loopback HTTP publish, so nothing binds 443. FLUXER_HTTP_PORT
|
||||
# with a single loopback HTTP publish, so nothing binds 443, and points the edge
|
||||
# at plain HTTP on that publish so it stops redirecting to https. FLUXER_HTTP_PORT
|
||||
# still moves that one publish. Set the line below and plain docker compose
|
||||
# commands pick the file up, or add it to your own -f flags if you pass any. The
|
||||
# file uses the !override tag, which needs Compose 2.24.4 or newer.
|
||||
@@ -88,6 +117,60 @@ FLUXER_IMAGE_TAG=v1
|
||||
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
MEILI_MASTER_KEY=CHANGE_ME
|
||||
# The stack ships its own Postgres and its own object store, and points at both
|
||||
# by service name. Set these to run either one outside the stack. Leave them
|
||||
# unset and the bundled services are used. Taking a service out of the stack
|
||||
# means an upgrade skips the backup step that reaches into it, and backing that
|
||||
# store up belongs to whoever runs it.
|
||||
#FLUXER_POSTGRES_HOST=db.example.com
|
||||
#FLUXER_POSTGRES_PORT=5432
|
||||
#FLUXER_POSTGRES_DATABASE=fluxer
|
||||
#FLUXER_POSTGRES_USERNAME=fluxer
|
||||
#FLUXER_POSTGRES_SSL=true
|
||||
#FLUXER_S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
|
||||
#FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com
|
||||
#FLUXER_S3_REGION=eu-central-1
|
||||
#FLUXER_S3_FORCE_PATH_STYLE=false
|
||||
# Bucket names. The bundled object store creates whichever names these hold, so
|
||||
# the two stay in step. An object store outside the stack needs the buckets to
|
||||
# exist already.
|
||||
#FLUXER_S3_BUCKET_CDN=fluxer
|
||||
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||
#FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
|
||||
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
|
||||
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||
|
||||
# The rest of the bundled services, pointed somewhere else the same way. Leave a
|
||||
# line unset and the service in the stack is used. Taking a service out of the
|
||||
# stack goes in an override file listed in COMPOSE_FILE, because an upgrade
|
||||
# replaces docker-compose.yml.
|
||||
#FLUXER_KV_URL=redis://cache.example.com:6379/0
|
||||
#FLUXER_NATS_URL=nats://mq.example.com:4222
|
||||
#FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222
|
||||
#FLUXER_SVC_NATS_URL=nats://mq.example.com:4222
|
||||
#FLUXER_SEARCH_URL=https://search.example.com
|
||||
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
|
||||
|
||||
# Voice off. The livekit service still runs until an override file takes it out.
|
||||
#FLUXER_LIVEKIT_ENABLED=false
|
||||
|
||||
# Optional systems, each off unless the instance is configured for it.
|
||||
#FLUXER_SMS_ENABLED=false
|
||||
#FLUXER_STRIPE_ENABLED=false
|
||||
#FLUXER_NCMEC_ENABLED=false
|
||||
#FLUXER_CLAMAV_ENABLED=false
|
||||
|
||||
# The client address. Set the header name a proxy in front actually writes, and
|
||||
# turn the trust off when nothing sits in front, because a trusted header an
|
||||
# attacker can set is a spoofed client address.
|
||||
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
|
||||
#FLUXER_TRUST_CLIENT_IP_HEADER=true
|
||||
|
||||
# How much the services write. trace, debug, info, warn, error or fatal. Every
|
||||
# service names the object storage endpoint and its addressing at info on start,
|
||||
# so a bucket that answers 404 is visible without raising this.
|
||||
#LOG_LEVEL=debug
|
||||
|
||||
FLUXER_S3_ACCESS_KEY=fluxer
|
||||
FLUXER_S3_SECRET_KEY=CHANGE_ME
|
||||
|
||||
@@ -100,6 +183,12 @@ FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
|
||||
|
||||
# The token every service sends to NATS. The bundled NATS runs without
|
||||
# authentication, so this stays empty unless a Compose override points the stack
|
||||
# at an external NATS that requires a token. Compose forwards the name to every
|
||||
# container that connects.
|
||||
#FLUXER_NATS_AUTH_TOKEN=
|
||||
|
||||
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
|
||||
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
|
||||
@@ -147,9 +236,11 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
LIVEKIT_API_KEY=fluxer
|
||||
LIVEKIT_API_SECRET=CHANGE_ME
|
||||
|
||||
# The URL browsers use for voice signalling. Derived from FLUXER_PUBLIC_SCHEME,
|
||||
# FLUXER_DOMAIN and FLUXER_PUBLIC_PORT as wss://host[:port]/livekit when empty.
|
||||
# Set it only when LiveKit is served from another host.
|
||||
# The URL browsers use for voice signalling. Compose builds it from
|
||||
# FLUXER_PUBLIC_ORIGIN, or from FLUXER_PUBLIC_SCHEME, FLUXER_DOMAIN and
|
||||
# FLUXER_PUBLIC_PORT, as that origin followed by /livekit. The client rewrites a
|
||||
# leading http to ws itself. Set it only when LiveKit is served from another
|
||||
# host.
|
||||
#FLUXER_LIVEKIT_URL=
|
||||
|
||||
# Media ports. LiveKit advertises these in ICE candidates, so the host must
|
||||
@@ -274,10 +365,16 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_ERLANG_SCHEDULERS_MAX=16
|
||||
|
||||
# In-flight request ceiling for the four services Compose forwards it to: the
|
||||
# users and messages routers and their shards. The Rust built-in defaults are 192
|
||||
# for messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=20
|
||||
# users and messages routers and their shards. Leave it unset and each service
|
||||
# uses its own built-in default, which is what the numbers below describe. Set it
|
||||
# and the one value replaces the built-in default on all four, so size it for the
|
||||
# busiest of them rather than for the smallest. The built-in defaults are 192 for
|
||||
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to. A router holds a slot for the whole round
|
||||
# trip to its shard, so this is a ceiling on requests in flight at once and not a
|
||||
# rate: too low a value does not slow requests down, it rejects them, and the api
|
||||
# turns that rejection into a 503.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
|
||||
|
||||
# The api and the Rust services name their fixed Postgres statement shapes so the
|
||||
# server can reuse their plans. Named prepared statements require a session that
|
||||
|
||||
@@ -2,61 +2,63 @@ name: fluxer
|
||||
|
||||
x-fluxer-postgres-env: &fluxer-postgres-env
|
||||
FLUXER_DATABASE_BACKEND: postgres
|
||||
FLUXER_POSTGRES_HOST: postgres
|
||||
FLUXER_POSTGRES_PORT: "5432"
|
||||
FLUXER_POSTGRES_DATABASE: fluxer
|
||||
FLUXER_POSTGRES_USERNAME: fluxer
|
||||
FLUXER_POSTGRES_HOST: ${FLUXER_POSTGRES_HOST:-postgres}
|
||||
FLUXER_POSTGRES_PORT: "${FLUXER_POSTGRES_PORT:-5432}"
|
||||
FLUXER_POSTGRES_DATABASE: ${FLUXER_POSTGRES_DATABASE:-fluxer}
|
||||
FLUXER_POSTGRES_USERNAME: ${FLUXER_POSTGRES_USERNAME:-fluxer}
|
||||
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
FLUXER_POSTGRES_SSL: "false"
|
||||
FLUXER_POSTGRES_SSL: "${FLUXER_POSTGRES_SSL:-false}"
|
||||
FLUXER_POSTGRES_PREPARED_STATEMENTS: ${FLUXER_POSTGRES_PREPARED_STATEMENTS:-true}
|
||||
|
||||
x-fluxer-env: &fluxer-env
|
||||
<<: *fluxer-postgres-env
|
||||
FLUXER_ENV: production
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
FLUXER_SELF_HOSTED: "true"
|
||||
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
|
||||
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "true"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: x-forwarded-for
|
||||
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
|
||||
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
|
||||
|
||||
FLUXER_KV_URL: redis://valkey:6379/0
|
||||
FLUXER_NATS_URL: nats://nats:4222
|
||||
FLUXER_NATS_JETSTREAM_URL: nats://nats:4222
|
||||
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
|
||||
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
|
||||
FLUXER_NATS_JETSTREAM_URL: ${FLUXER_NATS_JETSTREAM_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
|
||||
FLUXER_NATS_AUTH_TOKEN: ${FLUXER_NATS_AUTH_TOKEN:-}
|
||||
FLUXER_SVC_NATS_URL: nats://nats:4222
|
||||
FLUXER_SVC_NATS_URL: ${FLUXER_SVC_NATS_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
|
||||
FLUXER_SVC_SHARD_COUNT: "1"
|
||||
|
||||
FLUXER_SEARCH_ENGINE: meilisearch
|
||||
FLUXER_SEARCH_URL: http://meilisearch:7700
|
||||
FLUXER_SEARCH_URL: ${FLUXER_SEARCH_URL:-http://meilisearch:7700}
|
||||
FLUXER_SEARCH_API_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
|
||||
|
||||
FLUXER_S3_ENDPOINT: http://seaweedfs:8333
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: http://seaweedfs:8333
|
||||
FLUXER_S3_REGION: us-east-1
|
||||
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}}
|
||||
FLUXER_S3_REGION: ${FLUXER_S3_REGION:-us-east-1}
|
||||
FLUXER_S3_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_FORCE_PATH_STYLE: "true"
|
||||
FLUXER_S3_BUCKET_CDN: fluxer
|
||||
FLUXER_S3_BUCKET_UPLOADS: fluxer-uploads
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: fluxer-downloads
|
||||
FLUXER_S3_BUCKET_REPORTS: fluxer-reports
|
||||
FLUXER_S3_BUCKET_HARVESTS: fluxer-harvests
|
||||
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
|
||||
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
AWS_DEFAULT_REGION: ${FLUXER_S3_REGION:-us-east-1}
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
|
||||
FLUXER_LIVEKIT_ENABLED: "true"
|
||||
FLUXER_LIVEKIT_ENABLED: "${FLUXER_LIVEKIT_ENABLED:-true}"
|
||||
FLUXER_LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
|
||||
FLUXER_LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: ${FLUXER_LIVEKIT_INTERNAL_URL:-http://livekit:7880}
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
|
||||
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/livekit}
|
||||
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}/livekit}
|
||||
|
||||
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
|
||||
|
||||
@@ -71,16 +73,16 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
|
||||
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-true}
|
||||
|
||||
FLUXER_SMS_ENABLED: "false"
|
||||
FLUXER_SMS_ENABLED: "${FLUXER_SMS_ENABLED:-false}"
|
||||
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
|
||||
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-}
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY:-}
|
||||
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SITE_KEY:-}
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY:-}
|
||||
FLUXER_STRIPE_ENABLED: "false"
|
||||
FLUXER_NCMEC_ENABLED: "false"
|
||||
FLUXER_CLAMAV_ENABLED: "false"
|
||||
FLUXER_STRIPE_ENABLED: "${FLUXER_STRIPE_ENABLED:-false}"
|
||||
FLUXER_NCMEC_ENABLED: "${FLUXER_NCMEC_ENABLED:-false}"
|
||||
FLUXER_CLAMAV_ENABLED: "${FLUXER_CLAMAV_ENABLED:-false}"
|
||||
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-true}
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
|
||||
@@ -132,7 +134,7 @@ services:
|
||||
- "${FLUXER_HTTPS_PORT:-443}:443"
|
||||
- "${FLUXER_HTTPS_PORT:-443}:443/udp"
|
||||
environment:
|
||||
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
|
||||
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
|
||||
FLUXER_EDGE_TRUSTED_PROXIES: ${FLUXER_EDGE_TRUSTED_PROXIES:-private_ranges}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
@@ -287,11 +289,16 @@ services:
|
||||
environment:
|
||||
FLUXER_S3_ACCESS_KEY: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- >
|
||||
buckets="fluxer fluxer-uploads fluxer-downloads fluxer-reports fluxer-harvests";
|
||||
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
|
||||
missing="$$buckets";
|
||||
for attempt in $$(seq 1 60); do
|
||||
if ! nc -z seaweedfs 9333 2>/dev/null; then
|
||||
@@ -494,6 +501,10 @@ services:
|
||||
environment:
|
||||
FLUXER_APP_PROXY_HOST: 0.0.0.0
|
||||
FLUXER_APP_PROXY_PORT: "8080"
|
||||
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
|
||||
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api
|
||||
@@ -554,7 +565,7 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: users
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -572,7 +583,7 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -622,7 +633,7 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: messages
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -640,7 +651,7 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
@@ -2,3 +2,5 @@ services:
|
||||
edge:
|
||||
ports: !override
|
||||
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
|
||||
environment:
|
||||
FLUXER_EDGE_SITE_ADDRESS: ":80"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import reactGoogleTranslate from 'eslint-plugin-react-google-translate';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/coverage/**',
|
||||
'**/*.generated.*',
|
||||
'fluxer_app/src/features/i18n/locales/*/messages.mjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['fluxer_app/src/**/*.tsx'],
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'error',
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
project: './fluxer_app/tsconfig.json',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {'react-google-translate': reactGoogleTranslate},
|
||||
rules: {
|
||||
'react-google-translate/no-conditional-text-nodes-with-siblings': [
|
||||
'error',
|
||||
{ignoreParents: ['Trans', 'Plural', 'Select', 'SelectOrdinal']},
|
||||
],
|
||||
'react-google-translate/no-return-text-nodes': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -12287,14 +12287,7 @@
|
||||
"max_concurrent_guild_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"},
|
||||
"gateway_dispatch_relay_shards": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"},
|
||||
"gateway_dispatch_relay_max_queue": {"type": "integer", "minimum": 0, "maximum": 1000000, "format": "int32"},
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"},
|
||||
"voice_reconciliation_v3_percentage": {"type": "number", "minimum": 0, "maximum": 100},
|
||||
"voice_reconciliation_v3_interval_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 500,
|
||||
"maximum": 60000,
|
||||
"format": "int32"
|
||||
}
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"}
|
||||
}
|
||||
},
|
||||
"InstanceConfigUpdateRequest": {
|
||||
@@ -12534,14 +12527,7 @@
|
||||
"max_concurrent_guild_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"},
|
||||
"gateway_dispatch_relay_shards": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"},
|
||||
"gateway_dispatch_relay_max_queue": {"type": "integer", "minimum": 0, "maximum": 1000000, "format": "int32"},
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"},
|
||||
"voice_reconciliation_v3_percentage": {"type": "number", "minimum": 0, "maximum": 100},
|
||||
"voice_reconciliation_v3_interval_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 500,
|
||||
"maximum": 60000,
|
||||
"format": "int32"
|
||||
}
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"}
|
||||
}
|
||||
},
|
||||
"BrandingAssetUploadRequest": {
|
||||
|
||||
@@ -129,6 +129,11 @@ impl AdminConfig {
|
||||
pub fn secure_cookies(&self) -> bool {
|
||||
self.admin_endpoint.starts_with("https://")
|
||||
}
|
||||
|
||||
pub fn admin_origin(&self) -> Option<String> {
|
||||
let origin = url::Url::parse(&self.admin_endpoint).ok()?.origin();
|
||||
origin.is_tuple().then(|| origin.ascii_serialization())
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeEnv {
|
||||
@@ -202,6 +207,7 @@ mod tests {
|
||||
unsafe { env::remove_var(name) };
|
||||
}
|
||||
unsafe { env::remove_var("FLUXER_PUBLIC_PORT") };
|
||||
unsafe { env::remove_var("FLUXER_PUBLIC_ORIGIN") };
|
||||
unsafe { env::set_var("FLUXER_ADMIN_SECRET_KEY_BASE", "test-secret") };
|
||||
for (name, value) in vars {
|
||||
unsafe { env::set_var(name, value) };
|
||||
@@ -350,7 +356,7 @@ mod tests {
|
||||
("FLUXER_BASE_DOMAIN", "fluxer.example"),
|
||||
("FLUXER_PUBLIC_PORT", "19080"),
|
||||
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
|
||||
("FLUXER_APP_ENDPOINT", "http://fluxer.example:19080"),
|
||||
("FLUXER_APP_ENDPOINT", "http://fluxer.example"),
|
||||
("FLUXER_MEDIA_ENDPOINT", "http://fluxer.example/media"),
|
||||
("FLUXER_STATIC_CDN_ENDPOINT", "https://cdn.example.net"),
|
||||
(
|
||||
|
||||
@@ -27,7 +27,6 @@ pub async fn csrf_protection(
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let secret = config.secret_key_base.clone();
|
||||
let admin_endpoint = config.admin_endpoint.clone();
|
||||
let secure_cookies = config.secure_cookies();
|
||||
|
||||
let user_id = request
|
||||
@@ -50,7 +49,7 @@ pub async fn csrf_protection(
|
||||
.iter()
|
||||
.any(|suffix| path.ends_with(suffix));
|
||||
if !is_ignored {
|
||||
if !is_same_site_request(&request, &admin_endpoint) {
|
||||
if !is_same_site_request(&request, config.admin_origin().as_deref()) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let header_token = extract_csrf_header(&request);
|
||||
@@ -167,7 +166,7 @@ async fn extract_csrf_from_form_body(
|
||||
Ok((request, token))
|
||||
}
|
||||
|
||||
fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
|
||||
fn is_same_site_request(request: &Request, admin_origin: Option<&str>) -> bool {
|
||||
if let Some(site) = request
|
||||
.headers()
|
||||
.get("sec-fetch-site")
|
||||
@@ -180,7 +179,7 @@ fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
|
||||
.get(header::ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
Some(origin) => origin == admin_endpoint,
|
||||
Some(origin) => admin_origin.is_some_and(|expected| origin == expected),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
@@ -275,6 +274,98 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
async fn action_status(admin_endpoint: &str, origin: &str) -> StatusCode {
|
||||
let state = state_with_admin_endpoint(admin_endpoint);
|
||||
let app = Router::new()
|
||||
.route("/", get(|| async { "ok" }).post(|| async { "ok" }))
|
||||
.layer(from_fn_with_state(state, csrf_protection));
|
||||
let issued = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.expect("router responds");
|
||||
let cookie = issued
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.filter_map(|value| value.split(';').next())
|
||||
.find(|pair| pair.contains("csrf_token=") && !pair.ends_with('='))
|
||||
.expect("a csrf cookie is issued")
|
||||
.to_owned();
|
||||
let token = cookie.split_once('=').expect("a cookie value").1.to_owned();
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/")
|
||||
.header(header::COOKIE, cookie.as_str())
|
||||
.header(header::ORIGIN, origin)
|
||||
.header(CSRF_HEADER_NAME, token.as_str())
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("router responds");
|
||||
response.status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_matching_origin_passes_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test/admin",
|
||||
"https://admin.example.test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_matching_origin_on_a_non_default_port_passes_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:19080/admin",
|
||||
"https://admin.example.test:19080",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_foreign_origin_fails_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:19080/admin",
|
||||
"https://evil.example.test:19080",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn another_port_on_the_admin_host_fails_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:19080/admin",
|
||||
"https://admin.example.test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unparseable_admin_endpoint_fails_closed() {
|
||||
let status = action_status("not-an-endpoint", "https://admin.example.test").await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_explicit_default_port_matches_a_portless_origin() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:443/admin",
|
||||
"https://admin.example.test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth2_callback_is_exempt() {
|
||||
let exempt = IGNORED_PATH_SUFFIXES
|
||||
|
||||
@@ -560,6 +560,8 @@ fn traits_form(
|
||||
}
|
||||
}
|
||||
|
||||
const DERIVED_TRAITS: [&str; 1] = ["premium"];
|
||||
|
||||
fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&str> {
|
||||
limit_config
|
||||
.map(|response| {
|
||||
@@ -569,6 +571,7 @@ fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&s
|
||||
.iter()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter(|value| !DERIVED_TRAITS.contains(value))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
@@ -579,5 +582,6 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|trait_name| !trait_definitions.contains(trait_name))
|
||||
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use tower::ServiceExt;
|
||||
const SECRET_KEY: &str = "legacy-csrf-cookie-test-secret";
|
||||
const ADMIN_ORIGIN: &str = "https://admin.example.test";
|
||||
const LEGACY_HEX_TOKEN: &str = "8f14e45fceea167a5a36dedd4bea25438f14e45fceea167a5a36dedd4bea2543";
|
||||
const CREATED_KEY_SECRET: &str = "fa_1900000000000000001_OneTimeSecretForTests";
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
@@ -128,6 +129,10 @@ async fn load_page(app: &TestApp, cookie: &str) -> (String, String) {
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{text}");
|
||||
assert!(
|
||||
text.contains("AdminUser"),
|
||||
"the page did not render the admin the mock API returns"
|
||||
);
|
||||
let cookie_token = host_csrf_cookie(&headers)
|
||||
.unwrap_or_else(|| panic!("no __Host-csrf_token in Set-Cookie: {headers:?}"));
|
||||
let page_token = form_csrf_value(&text).expect("no _csrf hidden input rendered");
|
||||
@@ -166,7 +171,16 @@ async fn submit_action(app: &TestApp, cookie: &str, form_token: &str) -> StatusC
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response.status()
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||
if status == StatusCode::OK {
|
||||
assert!(
|
||||
text.contains(CREATED_KEY_SECRET),
|
||||
"the action did not render the key the mock API creates"
|
||||
);
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
fn host_csrf_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
@@ -207,11 +221,11 @@ async fn spawn_mock_api() -> String {
|
||||
|
||||
async fn mock_api(method: Method, uri: Uri) -> Response {
|
||||
match (method, uri.path()) {
|
||||
(Method::GET, "/admin/users/me") => Json(json!({ "user": admin_user() })).into_response(),
|
||||
(Method::GET, "/admin/users/@me") => Json(json!({ "user": admin_user() })).into_response(),
|
||||
(Method::GET, "/admin/api-keys") => Json(json!([])).into_response(),
|
||||
(Method::POST, "/admin/api-keys") => Json(json!({
|
||||
"key_id": "1900000000000000001",
|
||||
"key": "fa_1900000000000000001_OneTimeSecretForTests",
|
||||
"key": CREATED_KEY_SECRET,
|
||||
"name": "Legacy Cookie Key",
|
||||
"created_at": "2026-07-10T15:00:00.000Z",
|
||||
"expires_at": null,
|
||||
|
||||
@@ -81,6 +81,22 @@ function withUploadRelaySecret(master: MasterConfig, secretBase64: string): Mast
|
||||
};
|
||||
}
|
||||
|
||||
function withStripeLegacyPrices(
|
||||
master: MasterConfig,
|
||||
legacyPrices: Record<string, Array<string> | undefined> | undefined,
|
||||
): MasterConfig {
|
||||
return {
|
||||
...master,
|
||||
integrations: {
|
||||
...master.integrations,
|
||||
stripe: {
|
||||
...master.integrations.stripe,
|
||||
legacy_prices: legacyPrices,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAPIConfigFromMaster upload relay secret', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
@@ -111,3 +127,42 @@ describe('buildAPIConfigFromMaster upload relay secret', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAPIConfigFromMaster stripe legacy prices', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('carries the retired stripe price map from master config onto the api config', () => {
|
||||
const legacyPrices = {
|
||||
monthly_brl: ['price_retired_monthly_brl'],
|
||||
yearly_brl: ['price_retired_yearly_brl_a', 'price_retired_yearly_brl_b'],
|
||||
monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up'],
|
||||
};
|
||||
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, legacyPrices)).stripe.legacyPrices).toEqual(
|
||||
legacyPrices,
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the retired price map even when no live prices are configured', () => {
|
||||
const withoutPrices: MasterConfig = {
|
||||
...master,
|
||||
integrations: {
|
||||
...master.integrations,
|
||||
stripe: {
|
||||
...master.integrations.stripe,
|
||||
prices: undefined,
|
||||
legacy_prices: {monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up']},
|
||||
},
|
||||
},
|
||||
};
|
||||
const config = buildAPIConfigFromMaster(withoutPrices);
|
||||
expect(config.stripe.prices).toBeUndefined();
|
||||
expect(config.stripe.legacyPrices).toEqual({monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up']});
|
||||
});
|
||||
|
||||
it('leaves the retired price map undefined when master config does not set one', () => {
|
||||
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, undefined)).stripe.legacyPrices).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,6 +395,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
gift1YearTry: master.integrations.stripe.prices.gift_1_year_try,
|
||||
}
|
||||
: undefined,
|
||||
legacyPrices: master.integrations.stripe.legacy_prices,
|
||||
},
|
||||
bunny: {
|
||||
purgeEnabled: master.integrations.bunny.purge_enabled,
|
||||
|
||||
@@ -30,8 +30,8 @@ import {phraseBlocklistCache} from '../../middleware/PhraseBlocklistCache';
|
||||
import {profileSubstringBlocklistCache} from '../../middleware/ProfileSubstringBlocklistCache';
|
||||
import {urlBlocklistCache} from '../../middleware/UrlBlocklistCache';
|
||||
import {
|
||||
getIpBanBlastRadiusVerdict,
|
||||
getSuspiciousIpSkipReason,
|
||||
hasHighCgnatBlastRadiusRisk,
|
||||
isSingleIpBanCandidate,
|
||||
} from '../../risk/IpBanCgnatGuard';
|
||||
import {isIpBanExempt} from '../../risk/IpBanExemptions';
|
||||
@@ -292,7 +292,7 @@ export class AdminBanManagementService {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const highRisk = await hasHighCgnatBlastRadiusRisk(ip, this.deps.ipInfoService, {
|
||||
const {cgnat: highRisk} = await getIpBanBlastRadiusVerdict(ip, this.deps.ipInfoService, {
|
||||
source: 'admin.ip_ban',
|
||||
reason: 'pre_write_cgnat_guard',
|
||||
});
|
||||
|
||||
@@ -229,14 +229,11 @@ export class AdminMessageService {
|
||||
hitsPerPage: limit,
|
||||
page: 1,
|
||||
});
|
||||
const messageEntries = result.hits.map((hit) => ({
|
||||
channelId: createChannelID(BigInt(hit.channelId)),
|
||||
messageId: createMessageID(BigInt(hit.id)),
|
||||
}));
|
||||
const resolvedMessages = await Promise.all(
|
||||
messageEntries.map(({channelId, messageId}) => this.getMessageResponseForAdmin(channelId, messageId)),
|
||||
);
|
||||
const messageResponses = resolvedMessages.filter((message): message is MessageResponse => message !== null);
|
||||
const messageResponses = await createMessageResponseDataService().buildMessages({
|
||||
userId: createUserID(0n),
|
||||
messages: result.messages,
|
||||
access: await this.getMessageResponseAccessForAdmin(channelId),
|
||||
});
|
||||
const attachmentStatuses = await this.getAttachmentStatusesForMessages(messageResponses);
|
||||
const priorReports = await this.getPriorReportsForMessages(messageResponses);
|
||||
const adminMessages = messageResponses.map((message) =>
|
||||
@@ -282,19 +279,6 @@ export class AdminMessageService {
|
||||
});
|
||||
}
|
||||
|
||||
private async getMessageResponseForAdmin(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
): Promise<MessageResponse | null> {
|
||||
const access = await this.getMessageResponseAccessForAdmin(channelId);
|
||||
return createMessageResponseDataService().getMessage({
|
||||
userId: createUserID(0n),
|
||||
channelId,
|
||||
messageId,
|
||||
access,
|
||||
});
|
||||
}
|
||||
|
||||
private async getPriorReportsForMessages(messages: Array<MessageResponse>): Promise<Map<string, Array<string>>> {
|
||||
const authorIds = messages.map((message) => createUserID(BigInt(message.author.id)));
|
||||
return this.deps.ncmecSubmissionService.getUserPriorReportIds(authorIds);
|
||||
|
||||
@@ -45,6 +45,7 @@ import {Logger} from '../../Logger';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository';
|
||||
import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes';
|
||||
import {resolveAssignedTraits} from '../../user/UserTraits';
|
||||
import {getIpAddressReverse, getLocationLabelFromIp} from '../../utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
|
||||
import {mapUserToAdminResponse} from '../models/UserTypes';
|
||||
@@ -383,7 +384,8 @@ export class AdminUserSecurityService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const traitSet = data.traits.length > 0 ? new Set(data.traits) : null;
|
||||
const assigned = resolveAssignedTraits(user.traits ?? [], data.traits);
|
||||
const traitSet = assigned.size > 0 ? assigned : null;
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
|
||||
@@ -209,7 +209,7 @@ export async function forgotPassword(ctx: ApiContext, {data, request}: ForgotPas
|
||||
}
|
||||
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(data.email);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const user = await users.findByEmail(data.email);
|
||||
if (!user) {
|
||||
|
||||
@@ -187,7 +187,7 @@ export async function register(
|
||||
contactDomain = normalizePolicyContactDomain(extractEmailDomain(rawEmail));
|
||||
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(rawEmail);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
contactDomainBlocked = accountPolicyEvaluator.isBlockedRegistrationEmailDomain(contactDomain);
|
||||
if (contactDomainBlocked) {
|
||||
|
||||
@@ -308,7 +308,7 @@ export async function createMessageSnapshotsForForward(
|
||||
return [new MessageSnapshotModel(snapshotData)];
|
||||
}
|
||||
|
||||
function collectEmbedReferencedAttachmentCdnKeys(message: Message): Array<string> {
|
||||
function collectEmbedReferencedAttachmentCdnKeys(message: Message, ownKeys: ReadonlySet<string>): Array<string> {
|
||||
const mediaPrefix = `${Config.endpoints.media}/`;
|
||||
const keys = new Set<string>();
|
||||
const consider = (url: string | null | undefined): void => {
|
||||
@@ -316,7 +316,7 @@ function collectEmbedReferencedAttachmentCdnKeys(message: Message): Array<string
|
||||
return;
|
||||
}
|
||||
const key = url.slice(mediaPrefix.length);
|
||||
if (key.startsWith('attachments/')) {
|
||||
if (ownKeys.has(key)) {
|
||||
keys.add(key);
|
||||
}
|
||||
};
|
||||
@@ -342,6 +342,11 @@ export async function purgeMessageAttachments(
|
||||
): Promise<void> {
|
||||
const cdnKeys = new Set<string>();
|
||||
const cdnUrls: Array<string> = [];
|
||||
const ownedCdnKeys = new Set<string>(
|
||||
collectMessageAttachments(message).map((attachment) =>
|
||||
makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename),
|
||||
),
|
||||
);
|
||||
for (const attachment of collectMessageAttachments(message)) {
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
if (cdnKeys.has(cdnKey)) {
|
||||
@@ -352,7 +357,7 @@ export async function purgeMessageAttachments(
|
||||
cdnUrls.push(makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename));
|
||||
}
|
||||
}
|
||||
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message)) {
|
||||
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message, ownedCdnKeys)) {
|
||||
if (cdnKeys.has(embedKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import type {MessageSearchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import type {MessageResponse, MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {AttachmentDecayService} from '../../../attachment/AttachmentDecayService';
|
||||
import type {AttachmentID, ChannelID, MessageID, UserID} from '../../../BrandedTypes';
|
||||
import {createChannelID, createMessageID} from '../../../BrandedTypes';
|
||||
import type {UserCacheService} from '../../../infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '../../../models/Channel';
|
||||
@@ -192,29 +191,19 @@ export class MessageRetrievalService {
|
||||
hitsPerPage,
|
||||
page,
|
||||
});
|
||||
const messageEntries = result.hits.map((hit) => ({
|
||||
channelId: createChannelID(BigInt(hit.channelId)),
|
||||
messageId: createMessageID(BigInt(hit.id)),
|
||||
}));
|
||||
const access = {
|
||||
sourceGuildId: channel.guildId,
|
||||
messageHistoryCutoff: !hasReadHistory ? (authChannel.guild?.message_history_cutoff ?? null) : null,
|
||||
canReadMessageHistory: hasReadHistory,
|
||||
};
|
||||
const responseDataService = createMessageResponseDataService();
|
||||
const foundMessages = await Promise.all(
|
||||
messageEntries.map(({channelId, messageId}) =>
|
||||
responseDataService.getMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
access,
|
||||
}),
|
||||
),
|
||||
const builtMessages = await createMessageResponseDataService().buildMessages({
|
||||
userId,
|
||||
messages: result.messages,
|
||||
access,
|
||||
});
|
||||
const messageResponses = builtMessages.map(
|
||||
({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage,
|
||||
);
|
||||
const messageResponses = foundMessages
|
||||
.filter((message): message is MessageResponse => message !== null)
|
||||
.map(({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage);
|
||||
return {
|
||||
channels: messageResponses.length > 0 ? [await this.mapSearchChannelResponse(channel, userId, requestCache)] : [],
|
||||
messages: messageResponses,
|
||||
|
||||
@@ -248,6 +248,7 @@ export interface APIConfig {
|
||||
gift1YearPln?: string;
|
||||
gift1YearTry?: string;
|
||||
};
|
||||
legacyPrices?: Record<string, Array<string> | undefined>;
|
||||
};
|
||||
bunny: {
|
||||
purgeEnabled: boolean;
|
||||
|
||||
@@ -40,7 +40,7 @@ export class DonationCheckoutService {
|
||||
}
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(params.email);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const isRecurring = params.interval !== null;
|
||||
const existingDonor = await this.donationRepository.findDonorByEmail(params.email);
|
||||
|
||||
@@ -24,7 +24,7 @@ export class DonationMagicLinkService {
|
||||
async sendMagicLink(email: string): Promise<void> {
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(email);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const donor = await this.donationRepository.findDonorByEmail(email);
|
||||
if (!donor) {
|
||||
|
||||
@@ -401,7 +401,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
const language =
|
||||
params.primaryLanguage && isValidDiscoveryLanguage(params.primaryLanguage) ? params.primaryLanguage : undefined;
|
||||
const tag = params.tag && params.tag.trim().length > 0 ? normalizeDiscoveryTag(params.tag) : undefined;
|
||||
const sortBy = params.sortBy === 'member_count' ? 'memberCount' : 'relevance';
|
||||
const sortBy = params.sortBy === 'relevance' ? 'relevance' : 'memberCount';
|
||||
const filters: GuildSearchFilters = {
|
||||
isDiscoverable: true,
|
||||
discoveryCategory: params.categoryId,
|
||||
@@ -444,7 +444,6 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
for (const guild of guilds) {
|
||||
const counts = freshCounts.get(BigInt(guild.id) as GuildID);
|
||||
if (counts) {
|
||||
guild.member_count = counts.memberCount;
|
||||
guild.online_count = counts.onlineCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {UserCacheService} from '../../infrastructure/UserCacheService';
|
||||
import {Logger} from '../../Logger';
|
||||
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
|
||||
import type {GuildBan} from '../../models/GuildBan';
|
||||
import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../risk/IpBanCgnatGuard';
|
||||
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '../../risk/IpBanCgnatGuard';
|
||||
import {isIpBanExempt} from '../../risk/IpBanExemptions';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
|
||||
@@ -237,19 +237,20 @@ export class GuildModerationService {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const highRisk = await hasHighCgnatBlastRadiusRisk(userIp, this.ipInfoService, {
|
||||
const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, {
|
||||
source: 'guild.ip_ban',
|
||||
reason: 'join_cgnat_guard',
|
||||
});
|
||||
const highRisk = cgnat || sharedAccess;
|
||||
if (highRisk) {
|
||||
Logger.warn(
|
||||
{userIp, bannedIp},
|
||||
'Skipping guild IP ban match because IPInfo indicates high CGNAT blast-radius risk',
|
||||
'Skipping guild IP ban match because IPInfo indicates high shared-network blast-radius risk',
|
||||
);
|
||||
}
|
||||
return !highRisk;
|
||||
} catch (error) {
|
||||
Logger.warn({error, userIp, bannedIp}, 'IPInfo CGNAT guard failed while checking guild IP ban');
|
||||
Logger.warn({error, userIp, bannedIp}, 'IPInfo blast-radius guard failed while checking guild IP ban');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,23 +68,10 @@ export class GuildSearchService {
|
||||
const includeNsfwRequested = searchParams.include_nsfw ?? false;
|
||||
const canUserAccessNsfw =
|
||||
guildIsAgeRestricted || includeNsfwRequested ? await this.getCanUserAccessNsfw(userId) : false;
|
||||
if (guildIsAgeRestricted) {
|
||||
if (!canUserAccessNsfw) {
|
||||
throw new NsfwContentRequiresAgeVerificationError();
|
||||
}
|
||||
if (!includeNsfwRequested) {
|
||||
const hitsPerPage = searchParams.hits_per_page ?? 25;
|
||||
const page = searchParams.page ?? 1;
|
||||
return {
|
||||
channels: [],
|
||||
messages: [],
|
||||
total: 0,
|
||||
hits_per_page: hitsPerPage,
|
||||
page,
|
||||
};
|
||||
}
|
||||
if (guildIsAgeRestricted && !canUserAccessNsfw) {
|
||||
throw new NsfwContentRequiresAgeVerificationError();
|
||||
}
|
||||
const canIncludeNsfw = includeNsfwRequested && canUserAccessNsfw;
|
||||
const canIncludeNsfw = canUserAccessNsfw && (includeNsfwRequested || guildIsAgeRestricted);
|
||||
const guildNsfw = guildData?.nsfw ?? false;
|
||||
const channels = await this.channelRepository.listChannels(channelIds);
|
||||
const channelMap = new Map<string, Channel>();
|
||||
@@ -169,7 +156,7 @@ export class GuildSearchService {
|
||||
page,
|
||||
cursor,
|
||||
});
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
|
||||
return {
|
||||
messages: mappedResponses.messages,
|
||||
channels: mappedResponses.channels,
|
||||
@@ -251,7 +238,7 @@ export class GuildSearchService {
|
||||
page,
|
||||
cursor,
|
||||
});
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
|
||||
return {
|
||||
messages: mappedResponses.messages,
|
||||
channels: mappedResponses.channels,
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {GuildID, RoleID, UserID} from '../../../BrandedTypes';
|
||||
import {guildIdToRoleId} from '../../../BrandedTypes';
|
||||
import {Logger} from '../../../Logger';
|
||||
import type {GuildMember} from '../../../models/GuildMember';
|
||||
import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../../risk/IpBanCgnatGuard';
|
||||
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '../../../risk/IpBanCgnatGuard';
|
||||
import {isIpBanExempt} from '../../../risk/IpBanExemptions';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import type {IGuildRepositoryAggregate} from '../../repositories/IGuildRepositoryAggregate';
|
||||
@@ -119,14 +119,15 @@ export class GuildMemberValidationService {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const highRisk = await hasHighCgnatBlastRadiusRisk(userIp, this.ipInfoService, {
|
||||
const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, {
|
||||
source: 'guild.member_ip_ban',
|
||||
reason: 'join_cgnat_guard',
|
||||
});
|
||||
const highRisk = cgnat || sharedAccess;
|
||||
if (highRisk) {
|
||||
Logger.warn(
|
||||
{userIp, bannedIp},
|
||||
'Skipping guild member IP ban match because IPInfo indicates high CGNAT blast-radius risk',
|
||||
'Skipping guild member IP ban match because IPInfo indicates high shared-network blast-radius risk',
|
||||
);
|
||||
}
|
||||
return !highRisk;
|
||||
|
||||
@@ -8,13 +8,21 @@ import type {
|
||||
DiscoveryCategoryResponse,
|
||||
DiscoveryGuildListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
||||
import type {GuildID} from '../../BrandedTypes';
|
||||
import {createTestBotAccount} from '../../bot/tests/BotTestUtils';
|
||||
import {setInjectedGatewayService} from '../../middleware/ServiceRegistry';
|
||||
import {getGuildRepository} from '../../middleware/ServiceSingletons';
|
||||
import {banUser} from '../../moderation/tests/ModerationTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {NoopLogger} from '../../test/mocks/NoopLogger';
|
||||
import {NoopGatewayService} from '../../test/NoopGatewayService';
|
||||
import {HTTP_STATUS, TEST_IDS} from '../../test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
import syncDiscoveryIndex from '../../worker/tasks/SyncDiscoveryIndex';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '../../worker/WorkerContext';
|
||||
import {createGuild, getUserGuilds} from './GuildTestUtils';
|
||||
|
||||
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
|
||||
@@ -24,6 +32,30 @@ async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, mem
|
||||
.execute();
|
||||
}
|
||||
|
||||
interface LiveGuildCounts {
|
||||
memberCount: number;
|
||||
onlineCount: number;
|
||||
}
|
||||
|
||||
const WORKER_HELPERS = {logger: new NoopLogger()} as unknown as WorkerTaskHelpers;
|
||||
|
||||
class LiveCountsGatewayService extends NoopGatewayService {
|
||||
constructor(private readonly liveCounts: Map<string, LiveGuildCounts>) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async getDiscoveryGuildCounts(guildIds: Array<GuildID>): Promise<Map<GuildID, LiveGuildCounts>> {
|
||||
const counts = new Map<GuildID, LiveGuildCounts>();
|
||||
for (const guildId of guildIds) {
|
||||
const live = this.liveCounts.get(guildId.toString());
|
||||
if (live) {
|
||||
counts.set(guildId, live);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAndApprove(
|
||||
harness: ApiTestHarness,
|
||||
ownerToken: string,
|
||||
@@ -44,12 +76,39 @@ async function applyAndApprove(
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function createApprovedDiscoveryGuild(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
name: string,
|
||||
memberCount: number,
|
||||
): Promise<string> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, name);
|
||||
await setGuildMemberCount(harness, guild.id, memberCount);
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
owner.token,
|
||||
adminToken,
|
||||
guild.id,
|
||||
`${name} welcomes everyone`,
|
||||
DiscoveryCategories.GAMING,
|
||||
);
|
||||
return guild.id;
|
||||
}
|
||||
|
||||
function expectNonIncreasing(counts: Array<number>): void {
|
||||
for (let index = 1; index < counts.length; index++) {
|
||||
expect(counts[index]).toBeLessThanOrEqual(counts[index - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Discovery Search and Join', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness({search: 'enabled'});
|
||||
});
|
||||
afterEach(async () => {
|
||||
clearWorkerDependencies();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
describe('categories', () => {
|
||||
@@ -257,6 +316,82 @@ describe('Discovery Search and Join', () => {
|
||||
.execute();
|
||||
expect(results.guilds.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
test('should order results by the member count it reports back', async () => {
|
||||
const liveCounts = new Map<string, LiveGuildCounts>();
|
||||
setInjectedGatewayService(new LiveCountsGatewayService(liveCounts));
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const guildIds: Array<string> = [];
|
||||
for (const memberCount of [50, 40, 30, 20, 10]) {
|
||||
guildIds.push(
|
||||
await createApprovedDiscoveryGuild(harness, admin.token, `Ordered Guild ${memberCount}`, memberCount),
|
||||
);
|
||||
}
|
||||
liveCounts.set(guildIds[0], {memberCount: 5, onlineCount: 3});
|
||||
liveCounts.set(guildIds[4], {memberCount: 500, onlineCount: 7});
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=48')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(results.guilds.map((guild) => guild.id)).toEqual(guildIds);
|
||||
expectNonIncreasing(results.guilds.map((guild) => guild.member_count));
|
||||
expect(results.guilds[0].online_count).toBe(3);
|
||||
expect(results.guilds[4].online_count).toBe(7);
|
||||
});
|
||||
test('should rank by member count when the client omits sort_by', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const guildsByCount = new Map<number, string>();
|
||||
for (const memberCount of [30, 10, 20]) {
|
||||
guildsByCount.set(
|
||||
memberCount,
|
||||
await createApprovedDiscoveryGuild(harness, admin.token, `Unsorted Guild ${memberCount}`, memberCount),
|
||||
);
|
||||
}
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?limit=48')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(results.guilds.map((guild) => guild.id)).toEqual([
|
||||
guildsByCount.get(30),
|
||||
guildsByCount.get(20),
|
||||
guildsByCount.get(10),
|
||||
]);
|
||||
expectNonIncreasing(results.guilds.map((guild) => guild.member_count));
|
||||
});
|
||||
test('should not repeat guilds across pages when the discovery index is resynced', async () => {
|
||||
const liveCounts = new Map<string, LiveGuildCounts>();
|
||||
const gatewayService = new LiveCountsGatewayService(liveCounts);
|
||||
setInjectedGatewayService(gatewayService);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const guildIds: Array<string> = [];
|
||||
for (const [index, memberCount] of [60, 50, 40, 40, 30, 30].entries()) {
|
||||
guildIds.push(await createApprovedDiscoveryGuild(harness, admin.token, `Paged Guild ${index}`, memberCount));
|
||||
}
|
||||
const searcher = await createTestAccount(harness);
|
||||
const firstPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=0')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(firstPage.guilds.map((guild) => guild.id)).toEqual([guildIds[0], guildIds[1]]);
|
||||
liveCounts.set(guildIds[0], {memberCount: 5, onlineCount: 0});
|
||||
setWorkerDependenciesForTest({guildRepository: getGuildRepository(), gatewayService});
|
||||
await syncDiscoveryIndex({}, WORKER_HELPERS);
|
||||
const secondPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=2')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const thirdPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=4')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const paged = [...firstPage.guilds, ...secondPage.guilds, ...thirdPage.guilds].map((guild) => guild.id);
|
||||
expect(new Set(paged).size).toBe(paged.length);
|
||||
expect([...paged].sort()).toEqual([...guildIds].sort());
|
||||
});
|
||||
test('should require login to search', async () => {
|
||||
await createBuilderWithoutAuth(harness).get('/discovery/guilds').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
|
||||
@@ -27,25 +27,51 @@ interface EmailDnsValidationServiceOptions {
|
||||
enforceInTestMode?: boolean;
|
||||
positiveTtlMs?: number;
|
||||
negativeTtlMs?: number;
|
||||
lookupTimeoutMs?: number;
|
||||
maxCachedDomains?: number;
|
||||
isEmailEnabled?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
type DnsResolutionResult = 'valid' | 'invalid' | 'fallback' | 'transient_error';
|
||||
type DomainVerdict = 'valid' | 'invalid' | 'unverified';
|
||||
|
||||
const DOMAIN_NOT_FOUND_CODES = new Set(['ENOTFOUND', 'ENONAME', 'EAI_NONAME', 'NXDOMAIN']);
|
||||
const DOMAIN_NO_RECORD_CODES = new Set(['ENODATA', 'ENOENT', 'NODATA']);
|
||||
const DNS_LOOKUP_TIMEOUT_MS = 2000;
|
||||
const DNS_LOOKUP_TRIES = 1;
|
||||
const MAX_CACHED_DOMAINS = 10000;
|
||||
|
||||
async function isInstanceEmailEnabled(): Promise<boolean> {
|
||||
const {getInstanceConfigRepository} = await import('../middleware/ServiceSingletons');
|
||||
return getInstanceConfigRepository().isEmailEnabled();
|
||||
}
|
||||
|
||||
function createLookupTimeoutError(): NodeJS.ErrnoException {
|
||||
const error: NodeJS.ErrnoException = new Error('Email DNS lookup timed out');
|
||||
error.code = 'ETIMEOUT';
|
||||
return error;
|
||||
}
|
||||
|
||||
export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
private readonly resolver: IDnsResolver;
|
||||
private readonly enforceInTestMode: boolean;
|
||||
private readonly positiveTtlMs: number;
|
||||
private readonly negativeTtlMs: number;
|
||||
private readonly lookupTimeoutMs: number;
|
||||
private readonly maxCachedDomains: number;
|
||||
private readonly isEmailEnabled: () => Promise<boolean>;
|
||||
private readonly domainCache = new Map<string, DomainValidationCacheEntry>();
|
||||
|
||||
constructor(options: EmailDnsValidationServiceOptions = {}) {
|
||||
this.resolver = options.resolver ?? new Resolver();
|
||||
this.lookupTimeoutMs = options.lookupTimeoutMs ?? DNS_LOOKUP_TIMEOUT_MS;
|
||||
this.resolver =
|
||||
options.resolver ??
|
||||
new Resolver({timeout: this.lookupTimeoutMs, tries: DNS_LOOKUP_TRIES, maxTimeout: this.lookupTimeoutMs});
|
||||
this.enforceInTestMode = options.enforceInTestMode ?? false;
|
||||
this.positiveTtlMs = options.positiveTtlMs ?? ms('30 minutes');
|
||||
this.negativeTtlMs = options.negativeTtlMs ?? ms('5 minutes');
|
||||
this.maxCachedDomains = options.maxCachedDomains ?? MAX_CACHED_DOMAINS;
|
||||
this.isEmailEnabled = options.isEmailEnabled ?? isInstanceEmailEnabled;
|
||||
}
|
||||
|
||||
async hasValidDnsRecords(email: string): Promise<boolean> {
|
||||
@@ -56,13 +82,19 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
if (!domain) {
|
||||
return false;
|
||||
}
|
||||
if (!(await this.isEmailEnabled())) {
|
||||
return true;
|
||||
}
|
||||
const cached = this.getCachedDomainResult(domain);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
const isValid = await this.resolveDomain(domain);
|
||||
this.setCachedDomainResult(domain, isValid);
|
||||
return isValid;
|
||||
const verdict = await this.resolveDomain(domain);
|
||||
if (verdict === 'invalid') {
|
||||
Logger.warn({domain}, 'Email domain publishes no mail exchange or address records, rejecting the address');
|
||||
}
|
||||
this.setCachedDomainResult(domain, verdict);
|
||||
return verdict !== 'invalid';
|
||||
}
|
||||
|
||||
private extractDomain(email: string): string | null {
|
||||
@@ -82,41 +114,49 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
this.domainCache.delete(domain);
|
||||
return null;
|
||||
}
|
||||
this.domainCache.delete(domain);
|
||||
this.domainCache.set(domain, cached);
|
||||
return cached.valid;
|
||||
}
|
||||
|
||||
private setCachedDomainResult(domain: string, isValid: boolean): void {
|
||||
const ttlMs = isValid ? this.positiveTtlMs : this.negativeTtlMs;
|
||||
private setCachedDomainResult(domain: string, verdict: DomainVerdict): void {
|
||||
const ttlMs = verdict === 'valid' ? this.positiveTtlMs : this.negativeTtlMs;
|
||||
if (this.domainCache.size >= this.maxCachedDomains && !this.domainCache.has(domain)) {
|
||||
const oldestDomain = this.domainCache.keys().next().value;
|
||||
if (oldestDomain !== undefined) {
|
||||
this.domainCache.delete(oldestDomain);
|
||||
}
|
||||
}
|
||||
this.domainCache.set(domain, {
|
||||
valid: isValid,
|
||||
valid: verdict !== 'invalid',
|
||||
expiresAtMs: Date.now() + ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveDomain(domain: string): Promise<boolean> {
|
||||
private async resolveDomain(domain: string): Promise<DomainVerdict> {
|
||||
const mxResult = await this.resolveMx(domain);
|
||||
if (mxResult === 'valid') {
|
||||
return true;
|
||||
return 'valid';
|
||||
}
|
||||
if (mxResult === 'invalid') {
|
||||
return false;
|
||||
return 'invalid';
|
||||
}
|
||||
if (mxResult === 'transient_error') {
|
||||
return true;
|
||||
return 'unverified';
|
||||
}
|
||||
const addressResult = await this.resolveAddressRecords(domain);
|
||||
if (addressResult === 'valid') {
|
||||
return true;
|
||||
return 'valid';
|
||||
}
|
||||
if (addressResult === 'invalid') {
|
||||
return false;
|
||||
return 'invalid';
|
||||
}
|
||||
return true;
|
||||
return 'unverified';
|
||||
}
|
||||
|
||||
private async resolveMx(domain: string): Promise<DnsResolutionResult> {
|
||||
try {
|
||||
const records = await this.resolver.resolveMx(domain);
|
||||
const records = await this.withLookupDeadline(this.resolver.resolveMx(domain));
|
||||
if (records.length > 0) {
|
||||
return 'valid';
|
||||
}
|
||||
@@ -128,8 +168,8 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
|
||||
private async resolveAddressRecords(domain: string): Promise<DnsResolutionResult> {
|
||||
const [ipv4Result, ipv6Result] = await Promise.allSettled([
|
||||
this.resolver.resolve4(domain),
|
||||
this.resolver.resolve6(domain),
|
||||
this.withLookupDeadline(this.resolver.resolve4(domain)),
|
||||
this.withLookupDeadline(this.resolver.resolve6(domain)),
|
||||
]);
|
||||
if (ipv4Result.status === 'fulfilled' && ipv4Result.value.length > 0) {
|
||||
return 'valid';
|
||||
@@ -147,6 +187,20 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
private async withLookupDeadline<T>(lookup: Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
lookup,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(createLookupTimeoutError()), this.lookupTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private classifyResolverError(
|
||||
error: unknown,
|
||||
domain: string,
|
||||
|
||||
@@ -324,9 +324,6 @@ export class LiveKitService extends ILiveKitService {
|
||||
participants: participants.map((participant) => ({identity: participant.identity})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (LiveKitService.isHttp404(error)) {
|
||||
return {status: 'ok', participants: []};
|
||||
}
|
||||
Logger.warn({error, regionId, serverId, roomName}, 'LiveKit listParticipants failed');
|
||||
const status = LiveKitService.getHttpStatus(error);
|
||||
const isRetryable = status != null && status >= 500;
|
||||
|
||||
@@ -143,6 +143,32 @@ describe('StorageService.getPresignedUploadURL', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('gives both clients the configured addressing rather than pinning one to path style', async () => {
|
||||
await withS3Config(
|
||||
{
|
||||
endpoint: 'https://s3.example.test',
|
||||
presignedUrlBase: '',
|
||||
forcePathStyle: false,
|
||||
region: 'eu-central-1',
|
||||
accessKeyId: 'fluxer',
|
||||
secretAccessKey: 'fluxer-secret',
|
||||
buckets: {uploads: 'fluxer-uploads'},
|
||||
},
|
||||
async () => {
|
||||
const service = new StorageService();
|
||||
const probe = service as unknown as {
|
||||
client: {config: {forcePathStyle?: unknown}};
|
||||
presignClient: {config: {forcePathStyle?: unknown}};
|
||||
};
|
||||
const resolve = async (value: unknown): Promise<unknown> =>
|
||||
typeof value === 'function' ? await (value as () => Promise<unknown>)() : value;
|
||||
|
||||
expect(await resolve(probe.client.config.forcePathStyle)).toBe(false);
|
||||
expect(await resolve(probe.presignClient.config.forcePathStyle)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorageService.copyObjectWithMetadataStripping', () => {
|
||||
|
||||
@@ -127,7 +127,7 @@ export class StorageService implements IStorageService {
|
||||
region: this.provider.region,
|
||||
accessKeyId: this.provider.accessKeyId,
|
||||
secretAccessKey: this.provider.secretAccessKey,
|
||||
forcePathStyle: true,
|
||||
forcePathStyle: this.provider.forcePathStyle,
|
||||
});
|
||||
this.presignClient = buildPooledS3Client({
|
||||
endpoint: this.resolvePresignEndpoint(),
|
||||
@@ -136,6 +136,15 @@ export class StorageService implements IStorageService {
|
||||
secretAccessKey: this.provider.secretAccessKey,
|
||||
forcePathStyle: this.provider.forcePathStyle,
|
||||
});
|
||||
Logger.info(
|
||||
{
|
||||
endpoint: this.provider.endpoint,
|
||||
presignEndpoint: this.resolvePresignEndpoint(),
|
||||
region: this.provider.region,
|
||||
addressing: this.provider.forcePathStyle ? 'path' : 'virtual-host',
|
||||
},
|
||||
'Object storage client ready',
|
||||
);
|
||||
}
|
||||
|
||||
private resolvePresignEndpoint(): string {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ms} from 'itty-time';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {Config} from '../../Config';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import {EmailDnsValidationService} from '../EmailDnsValidationService';
|
||||
|
||||
interface MxRecord {
|
||||
exchange: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
function dnsError(code: string): NodeJS.ErrnoException {
|
||||
const error: NodeJS.ErrnoException = new Error(`dns lookup failed with ${code}`);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
class FakeDnsResolver {
|
||||
readonly lookups: Array<string> = [];
|
||||
mxRecords: Array<MxRecord> = [{exchange: 'mx.example.com', priority: 10}];
|
||||
mxErrorCode: string | null = null;
|
||||
addressErrorCode: string | null = 'ENOTFOUND';
|
||||
addresses: Array<string> = [];
|
||||
stall = false;
|
||||
|
||||
async resolveMx(domain: string): Promise<Array<MxRecord>> {
|
||||
this.lookups.push(`mx:${domain}`);
|
||||
if (this.stall) {
|
||||
return new Promise<Array<MxRecord>>(() => {});
|
||||
}
|
||||
if (this.mxErrorCode) {
|
||||
throw dnsError(this.mxErrorCode);
|
||||
}
|
||||
return this.mxRecords;
|
||||
}
|
||||
|
||||
async resolve4(domain: string): Promise<Array<string>> {
|
||||
this.lookups.push(`a:${domain}`);
|
||||
if (this.addressErrorCode) {
|
||||
throw dnsError(this.addressErrorCode);
|
||||
}
|
||||
return this.addresses;
|
||||
}
|
||||
|
||||
async resolve6(domain: string): Promise<Array<string>> {
|
||||
this.lookups.push(`aaaa:${domain}`);
|
||||
if (this.addressErrorCode) {
|
||||
throw dnsError(this.addressErrorCode);
|
||||
}
|
||||
return this.addresses;
|
||||
}
|
||||
}
|
||||
|
||||
describe('EmailDnsValidationService', () => {
|
||||
it('skips the lookup when the instance sends no mail', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENOTFOUND';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => false,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual([]);
|
||||
});
|
||||
|
||||
it('looks the domain up when the instance sends mail', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:gmail.com']);
|
||||
});
|
||||
|
||||
it('follows the env email flag when no gate is supplied and the operator set nothing', async () => {
|
||||
expect(Config.email.enabled).toBe(true);
|
||||
const resolver = new FakeDnsResolver();
|
||||
const service = new EmailDnsValidationService({resolver, enforceInTestMode: true});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:gmail.com']);
|
||||
});
|
||||
|
||||
it('follows the runtime instance email setting over the env flag', async () => {
|
||||
expect(Config.email.enabled).toBe(true);
|
||||
await getInstanceConfigRepository().setInstanceIntegrationsConfig({email: {enabled: false}});
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENOTFOUND';
|
||||
const service = new EmailDnsValidationService({resolver, enforceInTestMode: true});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual([]);
|
||||
});
|
||||
|
||||
it('still rejects a syntactically broken address when the instance sends no mail', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => false,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('probe@')).toBe(false);
|
||||
expect(resolver.lookups).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a domain that does not exist', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENOTFOUND';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(resolver.lookups).toEqual(['mx:asdf.asdf']);
|
||||
});
|
||||
|
||||
it('accepts a domain that publishes address records but no mail records', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENODATA';
|
||||
resolver.addressErrorCode = null;
|
||||
resolver.addresses = ['198.51.100.10'];
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:mail-less.test', 'a:mail-less.test', 'aaaa:mail-less.test']);
|
||||
});
|
||||
|
||||
it('rejects a domain that publishes neither mail nor address records', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENODATA';
|
||||
resolver.addressErrorCode = 'ENODATA';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(resolver.lookups).toEqual(['mx:no-records.test', 'a:no-records.test', 'aaaa:no-records.test']);
|
||||
});
|
||||
|
||||
it('allows the address when the resolver fails transiently', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ESERVFAIL';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not cache a transient failure as a verified domain', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ESERVFAIL';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
positiveTtlMs: ms('30 minutes'),
|
||||
negativeTtlMs: 0,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:asdf.asdf', 'mx:asdf.asdf']);
|
||||
});
|
||||
|
||||
it('caches a verified domain for the positive ttl', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
positiveTtlMs: ms('30 minutes'),
|
||||
negativeTtlMs: 0,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:gmail.com']);
|
||||
});
|
||||
|
||||
it('gives up on a stalled resolver instead of hanging', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.stall = true;
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
lookupTimeoutMs: 10,
|
||||
});
|
||||
const startedAtMs = Date.now();
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(Date.now() - startedAtMs).toBeLessThan(ms('5 seconds'));
|
||||
expect(resolver.lookups).toEqual(['mx:stalled.test']);
|
||||
});
|
||||
|
||||
it('bounds the domain cache', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENOTFOUND';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
maxCachedDomains: 2,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(resolver.lookups).toEqual(['mx:first.test', 'mx:second.test', 'mx:third.test']);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(resolver.lookups).toEqual(['mx:first.test', 'mx:second.test', 'mx:third.test', 'mx:first.test']);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import {AccessToken, TrackSource} from 'livekit-server-sdk';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {computeLiveKitPublishSources, VOICE_TOKEN_TTL_SECONDS} from '../LiveKitService';
|
||||
import {createChannelID, createGuildID} from '../../BrandedTypes';
|
||||
import {computeLiveKitPublishSources, LiveKitService, VOICE_TOKEN_TTL_SECONDS} from '../LiveKitService';
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> {
|
||||
const [, payload] = token.split('.');
|
||||
@@ -57,3 +58,78 @@ describe('LiveKitService publish permissions', () => {
|
||||
expect(exp - nowSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
class FakeTwirpError extends Error {
|
||||
status: number;
|
||||
code?: string;
|
||||
constructor(message: string, status: number, code?: string) {
|
||||
super(message);
|
||||
this.name = 'TwirpError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function createServiceWithRoomServiceClient(roomServiceClient: unknown): LiveKitService {
|
||||
const service = Object.create(LiveKitService.prototype) as LiveKitService;
|
||||
Reflect.set(
|
||||
service,
|
||||
'serverClients',
|
||||
new Map([
|
||||
[
|
||||
'region-1',
|
||||
new Map([
|
||||
[
|
||||
'region-1-server-1',
|
||||
{
|
||||
endpoint: 'ws://livekit.test/livekit',
|
||||
apiKey: 'test-key',
|
||||
apiSecret: 'test-secret',
|
||||
isActive: true,
|
||||
roomServiceClient,
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
]),
|
||||
);
|
||||
return service;
|
||||
}
|
||||
|
||||
describe('LiveKitService listParticipants', () => {
|
||||
const params = {
|
||||
guildId: createGuildID(1n),
|
||||
channelId: createChannelID(2n),
|
||||
regionId: 'region-1',
|
||||
serverId: 'region-1-server-1',
|
||||
};
|
||||
|
||||
it('reports a 404 as an unreadable room instead of an empty one', async () => {
|
||||
const service = createServiceWithRoomServiceClient({
|
||||
listParticipants: async () => {
|
||||
throw new FakeTwirpError('not_found', 404, 'not_found');
|
||||
},
|
||||
});
|
||||
const result = await service.listParticipants(params);
|
||||
expect(result.status).toBe('error');
|
||||
});
|
||||
|
||||
it('reports a bad_route 404 as an unreadable room instead of an empty one', async () => {
|
||||
const service = createServiceWithRoomServiceClient({
|
||||
listParticipants: async () => {
|
||||
throw new FakeTwirpError('invalid path prefix', 404, 'bad_route');
|
||||
},
|
||||
});
|
||||
const result = await service.listParticipants(params);
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.status === 'error' && result.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('still reports a genuinely empty room as empty', async () => {
|
||||
const service = createServiceWithRoomServiceClient({
|
||||
listParticipants: async () => [],
|
||||
});
|
||||
const result = await service.listParticipants(params);
|
||||
expect(result).toEqual({status: 'ok', participants: []});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,8 +63,6 @@ describe('GatewayRolloutConfigPublisher', () => {
|
||||
gateway_dispatch_relay_shards: 32,
|
||||
gateway_dispatch_relay_max_queue: 50000,
|
||||
voice_e2ee_scope: 'guild_feature_only',
|
||||
voice_reconciliation_v3_percentage: 100,
|
||||
voice_reconciliation_v3_interval_ms: 2000,
|
||||
};
|
||||
|
||||
await publisher.publish(config);
|
||||
|
||||
@@ -42,8 +42,6 @@ const DEFAULT_GATEWAY_ROLLOUT_CONFIG: GatewayRolloutConfig = {
|
||||
gateway_dispatch_relay_shards: 32,
|
||||
gateway_dispatch_relay_max_queue: 50000,
|
||||
voice_e2ee_scope: 'guild_feature_only',
|
||||
voice_reconciliation_v3_percentage: 100,
|
||||
voice_reconciliation_v3_interval_ms: 2000,
|
||||
};
|
||||
export type InstanceRegistrationMode = 'open' | 'approval' | 'closed';
|
||||
export interface InstanceRegistrationConfig {
|
||||
|
||||
@@ -13628,6 +13628,73 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/premium/switch-to-list-price": {
|
||||
"post": {
|
||||
"operationId": "switch_subscription_to_list_price",
|
||||
"summary": "Switch subscription to the current list price",
|
||||
"tags": ["Premium"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SwitchToListPriceResponse"}}}
|
||||
},
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
|
||||
"message": {"type": "string"},
|
||||
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
|
||||
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
|
||||
},
|
||||
"required": ["code", "message", "retry_after"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"}}}
|
||||
}
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Switch subscription to the current list price"}},
|
||||
"description": "Moves the authenticated user's grandfathered premium subscription down to the current list price for the same currency and billing cycle, effective at the end of the current billing period. The target price is resolved on the server and the switch is refused unless it lowers the amount charged.",
|
||||
"security": [{"sessionToken": []}]
|
||||
}
|
||||
},
|
||||
"/premium/visionary/rejoin": {
|
||||
"post": {
|
||||
"operationId": "rejoin_visionary_guild",
|
||||
@@ -32194,6 +32261,7 @@
|
||||
"pending_subscription_change": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/PendingSubscriptionChangeResponse"}, {"type": "null"}]
|
||||
},
|
||||
"list_price_switch": {"$ref": "#/components/schemas/ListPriceSwitchState"},
|
||||
"subscription": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/PremiumBillingSubscriptionResponse"}, {"type": "null"}]
|
||||
},
|
||||
@@ -32209,6 +32277,7 @@
|
||||
"stripe_customer_id",
|
||||
"current_subscription_price",
|
||||
"pending_subscription_change",
|
||||
"list_price_switch",
|
||||
"subscription",
|
||||
"invoices",
|
||||
"invoices_has_more",
|
||||
@@ -32241,6 +32310,12 @@
|
||||
"type": "string",
|
||||
"description": "Stripe subscription schedule ID managing the pending change"
|
||||
},
|
||||
"change_kind": {
|
||||
"enum": ["billing_cycle", "price"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["billing_cycle", "price"],
|
||||
"description": "Whether the pending change moves the billing cycle or only the price within the same cycle"
|
||||
},
|
||||
"current_billing_cycle": {
|
||||
"anyOf": [
|
||||
{"enum": ["monthly", "yearly"], "type": "string", "x-enumNames": ["monthly", "yearly"]},
|
||||
@@ -32263,6 +32338,10 @@
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Stripe price ID that will be used after the change"
|
||||
},
|
||||
"target_amount_minor": {
|
||||
"anyOf": [{"type": "integer", "format": "int53"}, {"type": "null"}],
|
||||
"description": "Unit amount of the price that will be used after the change, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -32289,11 +32368,13 @@
|
||||
},
|
||||
"required": [
|
||||
"schedule_id",
|
||||
"change_kind",
|
||||
"current_billing_cycle",
|
||||
"target_billing_cycle",
|
||||
"effective_at",
|
||||
"current_price_id",
|
||||
"target_price_id",
|
||||
"target_amount_minor",
|
||||
"currency",
|
||||
"initial_amount_minor",
|
||||
"recurring_amount_minor",
|
||||
@@ -32303,6 +32384,104 @@
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"ListPriceSwitchState": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"available": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the authenticated user can move their subscription down to the current list price right now"
|
||||
},
|
||||
"reason": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/ListPriceSwitchIneligibilityReason"}, {"type": "null"}],
|
||||
"description": "Why the switch is unavailable, when available is false"
|
||||
},
|
||||
"pending": {
|
||||
"type": "boolean",
|
||||
"description": "Whether a switch to the current list price is already scheduled"
|
||||
},
|
||||
"current_price_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Stripe price ID the subscription is billed against today"
|
||||
},
|
||||
"current_amount_minor": {
|
||||
"anyOf": [{"type": "integer", "format": "int53"}, {"type": "null"}],
|
||||
"description": "Amount the subscription is billed today, in the currency minor unit"
|
||||
},
|
||||
"list_price_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Current list Stripe price ID for the same cycle and currency"
|
||||
},
|
||||
"list_amount_minor": {
|
||||
"anyOf": [{"type": "integer", "format": "int53"}, {"type": "null"}],
|
||||
"description": "Current list price for the same cycle and currency, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "Currency of both the current and the list amount"
|
||||
},
|
||||
"billing_cycle": {
|
||||
"anyOf": [
|
||||
{"enum": ["monthly", "yearly"], "type": "string", "x-enumNames": ["monthly", "yearly"]},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "Recurring billing cycle the switch applies to"
|
||||
},
|
||||
"effective_at": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "ISO timestamp the switch takes effect, which is the end of the current billing period"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"available",
|
||||
"reason",
|
||||
"pending",
|
||||
"current_price_id",
|
||||
"current_amount_minor",
|
||||
"list_price_id",
|
||||
"list_amount_minor",
|
||||
"currency",
|
||||
"billing_cycle",
|
||||
"effective_at"
|
||||
]
|
||||
},
|
||||
"ListPriceSwitchIneligibilityReason": {
|
||||
"enum": [
|
||||
"feature_unavailable",
|
||||
"no_active_subscription",
|
||||
"subscription_not_chargeable",
|
||||
"unsupported_subscription",
|
||||
"no_list_price",
|
||||
"already_on_list_price",
|
||||
"not_a_price_decrease",
|
||||
"subscription_cancelling",
|
||||
"cancellation_managed_by_schedule",
|
||||
"conflicting_pending_change",
|
||||
"missing_period_end",
|
||||
"switch_in_progress"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enumNames": [
|
||||
"feature_unavailable",
|
||||
"no_active_subscription",
|
||||
"subscription_not_chargeable",
|
||||
"unsupported_subscription",
|
||||
"no_list_price",
|
||||
"already_on_list_price",
|
||||
"not_a_price_decrease",
|
||||
"subscription_cancelling",
|
||||
"cancellation_managed_by_schedule",
|
||||
"conflicting_pending_change",
|
||||
"missing_period_end",
|
||||
"switch_in_progress"
|
||||
]
|
||||
},
|
||||
"PremiumBillingSubscriptionResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -32544,6 +32723,134 @@
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"SwitchToListPriceResponse": {
|
||||
"oneOf": [
|
||||
{"$ref": "#/components/schemas/ScheduledSwitchToListPriceResponse"},
|
||||
{"$ref": "#/components/schemas/AlreadyScheduledSwitchToListPriceResponse"},
|
||||
{"$ref": "#/components/schemas/IneligibleSwitchToListPriceResponse"}
|
||||
]
|
||||
},
|
||||
"ScheduledSwitchToListPriceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["scheduled"],
|
||||
"description": "The switch was scheduled for the end of the current billing period"
|
||||
},
|
||||
"effective_at": {"type": "string", "description": "ISO timestamp the switch takes effect"},
|
||||
"target_price_id": {
|
||||
"type": "string",
|
||||
"description": "Stripe price ID the subscription will be billed against after the switch"
|
||||
},
|
||||
"target_amount_minor": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount billed after the switch, in the currency minor unit"
|
||||
},
|
||||
"current_amount_minor": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount billed before the switch, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"description": "Currency of both amounts"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"effective_at",
|
||||
"target_price_id",
|
||||
"target_amount_minor",
|
||||
"current_amount_minor",
|
||||
"currency"
|
||||
]
|
||||
},
|
||||
"AlreadyScheduledSwitchToListPriceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["already_scheduled"],
|
||||
"description": "The switch was already scheduled by an earlier request"
|
||||
},
|
||||
"effective_at": {"type": "string", "description": "ISO timestamp the switch takes effect"},
|
||||
"target_price_id": {
|
||||
"type": "string",
|
||||
"description": "Stripe price ID the subscription will be billed against after the switch"
|
||||
},
|
||||
"target_amount_minor": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount billed after the switch, in the currency minor unit"
|
||||
},
|
||||
"current_amount_minor": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount billed before the switch, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"description": "Currency of both amounts"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"effective_at",
|
||||
"target_price_id",
|
||||
"target_amount_minor",
|
||||
"current_amount_minor",
|
||||
"currency"
|
||||
]
|
||||
},
|
||||
"IneligibleSwitchToListPriceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["ineligible"],
|
||||
"description": "The subscription cannot be moved to the current list price"
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"feature_unavailable",
|
||||
"no_active_subscription",
|
||||
"subscription_not_chargeable",
|
||||
"unsupported_subscription",
|
||||
"no_list_price",
|
||||
"already_on_list_price",
|
||||
"not_a_price_decrease",
|
||||
"subscription_cancelling",
|
||||
"cancellation_managed_by_schedule",
|
||||
"conflicting_pending_change",
|
||||
"missing_period_end",
|
||||
"switch_in_progress"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enumNames": [
|
||||
"feature_unavailable",
|
||||
"no_active_subscription",
|
||||
"subscription_not_chargeable",
|
||||
"unsupported_subscription",
|
||||
"no_list_price",
|
||||
"already_on_list_price",
|
||||
"not_a_price_decrease",
|
||||
"subscription_cancelling",
|
||||
"cancellation_managed_by_schedule",
|
||||
"conflicting_pending_change",
|
||||
"missing_period_end",
|
||||
"switch_in_progress"
|
||||
],
|
||||
"description": "Why the switch was refused"
|
||||
}
|
||||
},
|
||||
"required": ["status", "reason"]
|
||||
},
|
||||
"ReadStateAckResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -343,7 +343,7 @@ export class ReportService {
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(normalizedEmail);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const verificationCode = this.generateDsaVerificationCode();
|
||||
const expiresAt = new Date(Date.now() + ms('10 minutes'));
|
||||
|
||||
@@ -7,9 +7,14 @@ import {isTrustedCommercialPrivacyProvider} from './TrustedPrivacyProviders';
|
||||
|
||||
const VERDICT_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
interface IpBanBlastRadiusVerdict {
|
||||
cgnat: boolean;
|
||||
sharedAccess: boolean;
|
||||
}
|
||||
|
||||
interface CachedVerdict {
|
||||
expiresAtMs: number;
|
||||
highRisk: boolean;
|
||||
verdict: IpBanBlastRadiusVerdict;
|
||||
}
|
||||
|
||||
const verdictCache = new Map<string, CachedVerdict>();
|
||||
@@ -48,7 +53,7 @@ export function getSuspiciousIpSkipReason(result: IpInfoLookupResult): Suspiciou
|
||||
return null;
|
||||
}
|
||||
|
||||
function isHighSharedAccessBlastRadiusRisk(result: IpInfoLookupResult): boolean {
|
||||
export function isHighSharedAccessBlastRadiusRisk(result: IpInfoLookupResult): boolean {
|
||||
if (result.flags.isHosting || isAnonymousAccess(result)) {
|
||||
return false;
|
||||
}
|
||||
@@ -60,29 +65,32 @@ export function isSingleIpBanCandidate(value: string): boolean {
|
||||
return parseIpBanEntry(value)?.type === 'single';
|
||||
}
|
||||
|
||||
export async function hasHighCgnatBlastRadiusRisk(
|
||||
export async function getIpBanBlastRadiusVerdict(
|
||||
ip: string,
|
||||
ipInfoService: IpInfoService,
|
||||
context: {
|
||||
source: string;
|
||||
reason: string;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
): Promise<IpBanBlastRadiusVerdict> {
|
||||
const now = Date.now();
|
||||
const cacheKey = getSameIpDecisionKey(ip) ?? ip;
|
||||
const cached = verdictCache.get(cacheKey);
|
||||
if (cached && cached.expiresAtMs > now) {
|
||||
return cached.highRisk;
|
||||
return cached.verdict;
|
||||
}
|
||||
const result = await ipInfoService.lookup(ip, {
|
||||
source: context.source,
|
||||
reason: context.reason,
|
||||
metadata: {policy: 'ip_ban_cgnat_guard'},
|
||||
});
|
||||
const highRisk = isHighCgnatBlastRadiusRisk(result);
|
||||
const verdict: IpBanBlastRadiusVerdict = {
|
||||
cgnat: isHighCgnatBlastRadiusRisk(result),
|
||||
sharedAccess: isHighSharedAccessBlastRadiusRisk(result),
|
||||
};
|
||||
verdictCache.set(cacheKey, {
|
||||
highRisk,
|
||||
verdict,
|
||||
expiresAtMs: now + VERDICT_CACHE_TTL_MS,
|
||||
});
|
||||
return highRisk;
|
||||
return verdict;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {isHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../IpBanCgnatGuard';
|
||||
import {
|
||||
isHighCgnatBlastRadiusRisk,
|
||||
isHighSharedAccessBlastRadiusRisk,
|
||||
isSingleIpBanCandidate,
|
||||
} from '../IpBanCgnatGuard';
|
||||
|
||||
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
|
||||
return {
|
||||
@@ -96,4 +100,63 @@ describe('IpBanCgnatGuard', () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it('flags satellite, anycast and education networks as high blast-radius risk', () => {
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: true, isHosting: false, isMobile: false, isSatellite: false},
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({asn: {asn: 'AS64500', number: 64500, name: 'Test University', domain: null, type: 'education'}}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it('does not flag ordinary residential networks as shared-access risk', () => {
|
||||
expect(isHighSharedAccessBlastRadiusRisk(ipInfoResult())).toBe(false);
|
||||
});
|
||||
it('does not treat shared-access networks as CGNAT risk', () => {
|
||||
expect(
|
||||
isHighCgnatBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it('does not exempt hosting or anonymous shared-access infrastructure', () => {
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: true, isHosting: true, isMobile: false, isSatellite: false},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
anonymous: {
|
||||
isAnonymous: true,
|
||||
providerName: 'Example VPN',
|
||||
isVpn: true,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
},
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,7 +253,7 @@ export class GlobalSearchService {
|
||||
page,
|
||||
cursor,
|
||||
});
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
|
||||
return {
|
||||
messages: mappedResponses.messages,
|
||||
channels: mappedResponses.channels,
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
MessageResponse,
|
||||
MessageSearchResultsResponse,
|
||||
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {MessageSearchResultsResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {UserID} from '../BrandedTypes';
|
||||
import {createChannelID, createMessageID} from '../BrandedTypes';
|
||||
import {createChannelID} from '../BrandedTypes';
|
||||
import {mapChannelToResponse} from '../channel/ChannelMappers';
|
||||
import type {IChannelRepository} from '../channel/IChannelRepository';
|
||||
import {
|
||||
createMessageResponseDataService,
|
||||
messageResponseAccessForChannel,
|
||||
} from '../channel/services/message/MessageResponseDataService';
|
||||
import {createMessageResponseDataService} from '../channel/services/message/MessageResponseDataService';
|
||||
import type {UserCacheService} from '../infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '../models/Channel';
|
||||
import type {Message} from '../models/Message';
|
||||
import {mapWithConcurrency} from '../utils/ConcurrencyUtils';
|
||||
|
||||
const CHANNEL_LOOKUP_CONCURRENCY = 16;
|
||||
|
||||
export class MessageSearchResponseMapper {
|
||||
constructor(
|
||||
@@ -23,71 +21,46 @@ export class MessageSearchResponseMapper {
|
||||
) {}
|
||||
|
||||
async mapSearchResultToResponses(
|
||||
result: {
|
||||
hits: Array<{
|
||||
channelId: string;
|
||||
id: string;
|
||||
}>;
|
||||
total: number;
|
||||
},
|
||||
messages: Array<Message>,
|
||||
userId: UserID,
|
||||
requestCache: RequestCache,
|
||||
): Promise<{
|
||||
messages: Array<MessageSearchResultsResponse['messages'][number]>;
|
||||
channels: Array<MessageSearchResultsResponse['channels'][number]>;
|
||||
}> {
|
||||
const messageEntries = result.hits.map((hit) => ({
|
||||
channelId: createChannelID(BigInt(hit.channelId)),
|
||||
messageId: createMessageID(BigInt(hit.id)),
|
||||
}));
|
||||
const orderedChannelIds = new Set<string>();
|
||||
for (const entry of messageEntries) {
|
||||
orderedChannelIds.add(entry.channelId.toString());
|
||||
}
|
||||
const channels = await Promise.all(
|
||||
Array.from(orderedChannelIds).map((channelId) =>
|
||||
this.channelRepository.findUnique(createChannelID(BigInt(channelId))),
|
||||
),
|
||||
const orderedChannelIds = Array.from(new Set(messages.map((message) => message.channelId.toString())));
|
||||
const channels = await mapWithConcurrency(orderedChannelIds, CHANNEL_LOOKUP_CONCURRENCY, (channelId) =>
|
||||
this.channelRepository.findUnique(createChannelID(BigInt(channelId))),
|
||||
);
|
||||
const validChannels = channels.filter((channel): channel is Channel => channel !== null);
|
||||
const channelById = new Map(validChannels.map((channel) => [channel.id.toString(), channel] as const));
|
||||
const responseDataService = createMessageResponseDataService();
|
||||
const messageResponsesWithEntries = await Promise.all(
|
||||
messageEntries.map(async (entry) => {
|
||||
const channel = channelById.get(entry.channelId.toString());
|
||||
if (!channel) return null;
|
||||
const message = await responseDataService.getMessage({
|
||||
userId,
|
||||
channelId: entry.channelId,
|
||||
messageId: entry.messageId,
|
||||
access: messageResponseAccessForChannel(channel),
|
||||
});
|
||||
return message ? {message, channelId: entry.channelId.toString()} : null;
|
||||
}),
|
||||
const channelById = new Map(
|
||||
channels
|
||||
.filter((channel): channel is Channel => channel !== null)
|
||||
.map((channel) => [channel.id.toString(), channel] as const),
|
||||
);
|
||||
const validMessageResponseEntries = messageResponsesWithEntries.filter(
|
||||
(entry): entry is {message: MessageResponse; channelId: string} => entry !== null,
|
||||
);
|
||||
const messageResponses = validMessageResponseEntries.map((entry) => {
|
||||
const {referenced_message: _referencedMessage, ...searchMessage} = entry.message;
|
||||
return searchMessage;
|
||||
const renderableMessages = messages.filter((message) => channelById.has(message.channelId.toString()));
|
||||
const messageResponses = await createMessageResponseDataService().buildMessagesForChannels({
|
||||
userId,
|
||||
messages: renderableMessages,
|
||||
channelById,
|
||||
});
|
||||
const orderedResponseChannelIds = new Set(validMessageResponseEntries.map((entry) => entry.channelId));
|
||||
const orderedChannels = Array.from(orderedResponseChannelIds)
|
||||
const searchMessages = messageResponses.map(
|
||||
({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage,
|
||||
);
|
||||
const respondedChannelIds = new Set(searchMessages.map((message) => message.channel_id));
|
||||
const orderedChannels = orderedChannelIds
|
||||
.filter((channelId) => respondedChannelIds.has(channelId))
|
||||
.map((channelId) => channelById.get(channelId))
|
||||
.filter((channel): channel is Channel => channel !== undefined);
|
||||
const channelResponses = await Promise.all(
|
||||
orderedChannels.map((channel) =>
|
||||
mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
}),
|
||||
),
|
||||
const channelResponses = await mapWithConcurrency(orderedChannels, CHANNEL_LOOKUP_CONCURRENCY, (channel) =>
|
||||
mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
messages: messageResponses,
|
||||
messages: searchMessages,
|
||||
channels: channelResponses,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {type ChannelID, createChannelID, createMessageID, type MessageID} from '
|
||||
import type {IMessageRepository} from '../channel/repositories/IMessageRepository';
|
||||
import {Logger} from '../Logger';
|
||||
import type {Message} from '../models/Message';
|
||||
import {mapWithConcurrency} from '../utils/ConcurrencyUtils';
|
||||
import type {IMessageSearchService} from './IMessageSearchService';
|
||||
import {deleteMessageSearchDocuments} from './MessageSearchIndexCleanup';
|
||||
|
||||
@@ -13,11 +14,16 @@ const RECONCILE_BATCH_SIZE = 250;
|
||||
const MAX_RECONCILE_PAGES = 40;
|
||||
const MAX_STALE_DELETE_ABSOLUTE = 250;
|
||||
const MAX_STALE_DELETE_RATIO = 0.5;
|
||||
const HIT_LOOKUP_CONCURRENCY = 32;
|
||||
|
||||
interface MessageLookupRepository {
|
||||
readonly messages: Pick<IMessageRepository, 'getMessage'>;
|
||||
}
|
||||
|
||||
interface MessageSearchLookupResult extends SearchResult<SearchableMessage> {
|
||||
messages: Array<Message>;
|
||||
}
|
||||
|
||||
interface SearchExistingMessagesParams {
|
||||
searchService: IMessageSearchService;
|
||||
messageRepository: MessageLookupRepository;
|
||||
@@ -28,12 +34,21 @@ interface SearchExistingMessagesParams {
|
||||
cursor?: Array<string>;
|
||||
}
|
||||
|
||||
interface ValidatedHit {
|
||||
hit: SearchableMessage;
|
||||
message: Message | null;
|
||||
}
|
||||
|
||||
interface ValidatedHits {
|
||||
validHits: Array<SearchableMessage>;
|
||||
validHits: Array<ValidatedHit>;
|
||||
staleMessageIds: Array<MessageID>;
|
||||
lookupErrorCount: number;
|
||||
}
|
||||
|
||||
function resolvedMessages(validHits: Array<ValidatedHit>): Array<Message> {
|
||||
return validHits.map((entry) => entry.message).filter((message): message is Message => message !== null);
|
||||
}
|
||||
|
||||
export async function searchExistingMessages({
|
||||
searchService,
|
||||
messageRepository,
|
||||
@@ -42,7 +57,7 @@ export async function searchExistingMessages({
|
||||
hitsPerPage,
|
||||
page,
|
||||
cursor,
|
||||
}: SearchExistingMessagesParams): Promise<SearchResult<SearchableMessage>> {
|
||||
}: SearchExistingMessagesParams): Promise<MessageSearchLookupResult> {
|
||||
const result = await searchService.searchMessages(query, filters, {
|
||||
hitsPerPage,
|
||||
page: cursor?.length ? undefined : page,
|
||||
@@ -50,7 +65,7 @@ export async function searchExistingMessages({
|
||||
});
|
||||
const validated = await validateSearchHits(messageRepository, result.hits);
|
||||
if (validated.staleMessageIds.length === 0) {
|
||||
return result;
|
||||
return {...result, messages: resolvedMessages(validated.validHits)};
|
||||
}
|
||||
if (cursor?.length) {
|
||||
if (validated.lookupErrorCount === 0) {
|
||||
@@ -58,8 +73,9 @@ export async function searchExistingMessages({
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
hits: validated.validHits,
|
||||
hits: validated.validHits.map((entry) => entry.hit),
|
||||
total: Math.max(validated.validHits.length, result.total - validated.staleMessageIds.length),
|
||||
messages: resolvedMessages(validated.validHits),
|
||||
};
|
||||
}
|
||||
return reconcileOffsetSearchResult({
|
||||
@@ -79,9 +95,9 @@ async function reconcileOffsetSearchResult({
|
||||
filters,
|
||||
hitsPerPage,
|
||||
page,
|
||||
}: Omit<SearchExistingMessagesParams, 'cursor'>): Promise<SearchResult<SearchableMessage>> {
|
||||
}: Omit<SearchExistingMessagesParams, 'cursor'>): Promise<MessageSearchLookupResult> {
|
||||
const requestedOffset = (page - 1) * hitsPerPage;
|
||||
const pageHits: Array<SearchableMessage> = [];
|
||||
const pageHits: Array<ValidatedHit> = [];
|
||||
const staleMessageIds: Array<MessageID> = [];
|
||||
let lookupErrorCount = 0;
|
||||
let examinedCount = 0;
|
||||
@@ -102,9 +118,9 @@ async function reconcileOffsetSearchResult({
|
||||
lookupErrorCount += validated.lookupErrorCount;
|
||||
examinedCount += result.hits.length;
|
||||
staleMessageIds.push(...validated.staleMessageIds);
|
||||
for (const hit of validated.validHits) {
|
||||
for (const entry of validated.validHits) {
|
||||
if (validTotal >= requestedOffset && pageHits.length < hitsPerPage) {
|
||||
pageHits.push(hit);
|
||||
pageHits.push(entry);
|
||||
}
|
||||
validTotal += 1;
|
||||
}
|
||||
@@ -121,8 +137,9 @@ async function reconcileOffsetSearchResult({
|
||||
await deleteStaleSearchDocuments(searchService, staleMessageIds, examinedCount);
|
||||
}
|
||||
return {
|
||||
hits: pageHits,
|
||||
hits: pageHits.map((entry) => entry.hit),
|
||||
total: Math.max(pageHits.length, corpusTotal - staleMessageIds.length),
|
||||
messages: resolvedMessages(pageHits),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,38 +147,36 @@ async function validateSearchHits(
|
||||
messageRepository: MessageLookupRepository,
|
||||
hits: Array<SearchableMessage>,
|
||||
): Promise<ValidatedHits> {
|
||||
const checked = await Promise.all(
|
||||
hits.map(async (hit) => {
|
||||
let channelId: ChannelID;
|
||||
let messageId: MessageID;
|
||||
try {
|
||||
channelId = createChannelID(BigInt(hit.channelId));
|
||||
messageId = createMessageID(BigInt(hit.id));
|
||||
} catch (_invalidId) {
|
||||
return {hit: null, staleMessageId: null, lookupError: false};
|
||||
}
|
||||
let message: Message | null;
|
||||
try {
|
||||
message = await messageRepository.messages.getMessage(channelId, messageId);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, messageId: hit.id, channelId: hit.channelId},
|
||||
'Search read repair lookup failed; keeping document',
|
||||
);
|
||||
return {hit, staleMessageId: null, lookupError: true};
|
||||
}
|
||||
if (message && message.channelId.toString() === hit.channelId) {
|
||||
return {hit, staleMessageId: null, lookupError: false};
|
||||
}
|
||||
return {hit: null, staleMessageId: messageId, lookupError: false};
|
||||
}),
|
||||
);
|
||||
const validHits: Array<SearchableMessage> = [];
|
||||
const checked = await mapWithConcurrency(hits, HIT_LOOKUP_CONCURRENCY, async (hit) => {
|
||||
let channelId: ChannelID;
|
||||
let messageId: MessageID;
|
||||
try {
|
||||
channelId = createChannelID(BigInt(hit.channelId));
|
||||
messageId = createMessageID(BigInt(hit.id));
|
||||
} catch (_invalidId) {
|
||||
return {entry: null, staleMessageId: null, lookupError: false};
|
||||
}
|
||||
let message: Message | null;
|
||||
try {
|
||||
message = await messageRepository.messages.getMessage(channelId, messageId);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, messageId: hit.id, channelId: hit.channelId},
|
||||
'Search read repair lookup failed; keeping document',
|
||||
);
|
||||
return {entry: {hit, message: null}, staleMessageId: null, lookupError: true};
|
||||
}
|
||||
if (message && message.channelId.toString() === hit.channelId) {
|
||||
return {entry: {hit, message}, staleMessageId: null, lookupError: false};
|
||||
}
|
||||
return {entry: null, staleMessageId: messageId, lookupError: false};
|
||||
});
|
||||
const validHits: Array<ValidatedHit> = [];
|
||||
const staleMessageIds: Array<MessageID> = [];
|
||||
let lookupErrorCount = 0;
|
||||
for (const item of checked) {
|
||||
if (item.hit) {
|
||||
validHits.push(item.hit);
|
||||
if (item.entry) {
|
||||
validHits.push(item.entry);
|
||||
}
|
||||
if (item.staleMessageId) {
|
||||
staleMessageIds.push(item.staleMessageId);
|
||||
|
||||
@@ -344,7 +344,7 @@ describe('Message Search Permissions', () => {
|
||||
}
|
||||
expect(result.messages.length).toBe(0);
|
||||
});
|
||||
test('age-restricted guild requires include_nsfw: true', async () => {
|
||||
test('age-restricted guild is searchable by an adult member without include_nsfw', async () => {
|
||||
const owner = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
|
||||
const guild = await createGuild(harness, owner.token, 'Age Restricted Search Guild');
|
||||
const systemChannelId = guild.system_channel_id!;
|
||||
@@ -363,7 +363,7 @@ describe('Message Search Permissions', () => {
|
||||
if (!isSearchResult(excluded)) {
|
||||
expect.fail('Expected search result but got indexing response');
|
||||
}
|
||||
expect(excluded.messages.length).toBe(0);
|
||||
expect(excluded.messages.some((m) => m.channel_id === systemChannelId)).toBe(true);
|
||||
const included = await createBuilder<MessageSearchResponse>(harness, owner.token)
|
||||
.post('/search/messages')
|
||||
.body({
|
||||
@@ -379,6 +379,30 @@ describe('Message Search Permissions', () => {
|
||||
expect(included.messages.length).toBeGreaterThan(0);
|
||||
expect(included.messages.some((m) => m.channel_id === systemChannelId)).toBe(true);
|
||||
});
|
||||
test('age-restricted guild is searchable in a channel pinned to nsfw_override: false', async () => {
|
||||
const owner = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
|
||||
const guild = await createGuild(harness, owner.token, 'Age Restricted Override Guild');
|
||||
const channel = await createBuilder<{id: string; nsfw_override?: boolean | null}>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'override-channel', type: ChannelTypes.GUILD_TEXT, nsfw: false})
|
||||
.execute();
|
||||
expect(channel.nsfw_override).toBe(false);
|
||||
await sendChannelMessage(harness, owner.token, channel.id, 'age restricted override searchable message');
|
||||
await updateGuild(harness, owner.token, guild.id, {nsfw_level: GuildNSFWLevel.AGE_RESTRICTED});
|
||||
await markGuildChannelsAsIndexed(harness, owner.token, guild.id);
|
||||
const result = await createBuilder<MessageSearchResponse>(harness, owner.token)
|
||||
.post('/search/messages')
|
||||
.body({
|
||||
content: 'age restricted override searchable',
|
||||
context_guild_id: guild.id,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
if (!isSearchResult(result)) {
|
||||
expect.fail('Expected search result but got indexing response');
|
||||
}
|
||||
expect(result.messages.some((m) => m.channel_id === channel.id)).toBe(true);
|
||||
});
|
||||
test('underage user cannot search messages in an age-restricted guild', async () => {
|
||||
const owner = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
|
||||
const underageMember = await createTestAccount(harness, {dateOfBirth: '2012-01-01'});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {Config} from '../Config';
|
||||
import {Logger} from '../Logger';
|
||||
import type {Currency} from '../utils/CurrencyUtils';
|
||||
|
||||
export enum ProductType {
|
||||
@@ -22,10 +23,66 @@ export interface ProductInfo {
|
||||
billingCycle?: RecurringBillingCycle;
|
||||
}
|
||||
|
||||
const LEGACY_SLOT_SHAPES: Record<string, Omit<ProductInfo, 'currency'> | undefined> = {
|
||||
monthly: {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
billingCycle: 'monthly',
|
||||
},
|
||||
yearly: {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
billingCycle: 'yearly',
|
||||
},
|
||||
gift_1_month: {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
},
|
||||
gift_1_year: {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
},
|
||||
};
|
||||
|
||||
const LEGACY_SLOT_CURRENCIES: Record<string, Currency | undefined> = {
|
||||
usd: 'USD',
|
||||
eur: 'EUR',
|
||||
brl: 'BRL',
|
||||
inr: 'INR',
|
||||
pln: 'PLN',
|
||||
try: 'TRY',
|
||||
};
|
||||
|
||||
function parseLegacySlot(slot: string): ProductInfo | null {
|
||||
const separatorIndex = slot.lastIndexOf('_');
|
||||
if (separatorIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
const shape = LEGACY_SLOT_SHAPES[slot.slice(0, separatorIndex)];
|
||||
const currency = LEGACY_SLOT_CURRENCIES[slot.slice(separatorIndex + 1).toLowerCase()];
|
||||
if (!shape || !currency) {
|
||||
return null;
|
||||
}
|
||||
return {...shape, currency};
|
||||
}
|
||||
|
||||
export class ProductRegistry {
|
||||
private products = new Map<string, ProductInfo>();
|
||||
|
||||
constructor() {
|
||||
this.registerConfiguredProducts();
|
||||
this.registerLegacyProducts();
|
||||
}
|
||||
|
||||
private registerConfiguredProducts(): void {
|
||||
const prices = Config.stripe.prices;
|
||||
if (!prices) return;
|
||||
this.registerProduct(prices.monthlyUsd, {
|
||||
@@ -210,6 +267,32 @@ export class ProductRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
private registerLegacyProducts(): void {
|
||||
const legacyPrices = Config.stripe.legacyPrices;
|
||||
if (!legacyPrices) return;
|
||||
if (typeof legacyPrices !== 'object' || Array.isArray(legacyPrices)) {
|
||||
Logger.warn({}, 'Ignoring legacy Stripe price configuration that is not an object of slot names to price ids');
|
||||
return;
|
||||
}
|
||||
for (const [slot, priceIds] of Object.entries(legacyPrices)) {
|
||||
if (!Array.isArray(priceIds)) {
|
||||
Logger.warn({slot}, 'Ignoring legacy Stripe price slot that is not a list of price IDs');
|
||||
continue;
|
||||
}
|
||||
const info = parseLegacySlot(slot);
|
||||
if (!info) {
|
||||
Logger.warn({slot}, 'Ignoring legacy Stripe price slot with an unrecognised name or currency');
|
||||
continue;
|
||||
}
|
||||
for (const priceId of priceIds) {
|
||||
if (!priceId || this.products.has(priceId)) {
|
||||
continue;
|
||||
}
|
||||
this.products.set(priceId, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private registerProduct(priceId: string | undefined, info: ProductInfo): void {
|
||||
if (priceId) {
|
||||
this.products.set(priceId, info);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
PriceIdsResponse,
|
||||
SelfServeRefundEligibilityResponse,
|
||||
SelfServeRefundResponse,
|
||||
SwitchToListPriceResponse,
|
||||
UrlResponse,
|
||||
WebhookReceivedResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
@@ -494,6 +495,27 @@ export function StripeController(app: HonoApp) {
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/premium/switch-to-list-price',
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CHANGE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'switch_subscription_to_list_price',
|
||||
summary: 'Switch subscription to the current list price',
|
||||
description:
|
||||
"Moves the authenticated user's grandfathered premium subscription down to the current list price for the same currency and billing cycle, effective at the end of the current billing period. The target price is resolved on the server and the switch is refused unless it lowers the amount charged.",
|
||||
responseSchema: SwitchToListPriceResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Premium',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const result = await ctx.get('stripeService').switchSubscriptionToCurrentListPrice(userId);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/premium/cancel-pending-subscription-change',
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_SUBSCRIPTION_CHANGE),
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PricingMode,
|
||||
SelfServeRefundEligibilityResponse,
|
||||
SelfServeRefundResponse,
|
||||
SwitchToListPriceResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import Stripe from 'stripe';
|
||||
@@ -201,6 +202,10 @@ export class StripeService {
|
||||
return this.subscriptionService.changeBillingCycle(userId, billingCycle, effectiveAt);
|
||||
}
|
||||
|
||||
async switchSubscriptionToCurrentListPrice(userId: UserID): Promise<SwitchToListPriceResponse> {
|
||||
return this.subscriptionService.switchToCurrentListPrice(userId);
|
||||
}
|
||||
|
||||
async cancelPendingSubscriptionChange(userId: UserID): Promise<void> {
|
||||
return this.subscriptionService.cancelPendingSubscriptionChange(userId);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstant
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
CurrentSubscriptionPriceResponse,
|
||||
ListPriceSwitchState,
|
||||
PendingSubscriptionChangeKind,
|
||||
PendingSubscriptionChangeResponse,
|
||||
PremiumBillingInvoiceResponse,
|
||||
PremiumBillingPaymentMethodResponse,
|
||||
@@ -264,6 +266,9 @@ export class PremiumStateService {
|
||||
this.resolvePricing(countryCode),
|
||||
]);
|
||||
const refundEligibilityState = await this.resolveRefundEligibility(user, invoices.allRows);
|
||||
const listPriceSwitch = this.resolveListPriceSwitch(subscription, subscriptionPrice, pendingSubscriptionChange);
|
||||
const pendingBillingCycleChange =
|
||||
pendingSubscriptionChange?.change_kind === 'billing_cycle' ? pendingSubscriptionChange : null;
|
||||
const activePaidPremium = checkHasActivePaidPremium(user);
|
||||
const isEffectivePremium = user.isPremium();
|
||||
const actualPremiumEndAt = user.effectivePremiumUntil;
|
||||
@@ -298,7 +303,8 @@ export class PremiumStateService {
|
||||
billing: {
|
||||
stripe_customer_id: customerIds[0] ?? user.stripeCustomerId ?? null,
|
||||
current_subscription_price: subscriptionPrice,
|
||||
pending_subscription_change: pendingSubscriptionChange,
|
||||
pending_subscription_change: pendingBillingCycleChange,
|
||||
list_price_switch: listPriceSwitch,
|
||||
subscription: subscription ? await this.mapSubscription(subscription) : null,
|
||||
invoices: invoices.rows.map(mapInvoice),
|
||||
invoices_has_more: invoices.hasMore,
|
||||
@@ -576,8 +582,16 @@ export class PremiumStateService {
|
||||
const targetItem = futurePhase.items[0] ?? null;
|
||||
const targetPriceDetails = await this.resolveStripePriceDetails(targetItem?.price ?? null);
|
||||
const metadataTargetBillingCycle = normalizeBillingCycle(schedule.metadata?.pending_billing_cycle);
|
||||
const targetBillingCycle = targetPriceDetails.billingCycle ?? metadataTargetBillingCycle;
|
||||
if (!targetBillingCycle || targetBillingCycle === currentBillingCycle) {
|
||||
const targetBillingCycle = targetPriceDetails.billingCycle ?? metadataTargetBillingCycle ?? currentBillingCycle;
|
||||
if (!targetBillingCycle) {
|
||||
return null;
|
||||
}
|
||||
const changeKind: PendingSubscriptionChangeKind =
|
||||
targetBillingCycle === currentBillingCycle ? 'price' : 'billing_cycle';
|
||||
if (
|
||||
changeKind === 'price' &&
|
||||
(targetPriceDetails.priceId == null || targetPriceDetails.priceId === currentPriceDetails.priceId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const quantity = targetItem?.quantity ?? currentItem?.quantity ?? 1;
|
||||
@@ -598,11 +612,13 @@ export class PremiumStateService {
|
||||
const creditAmountMinor = firstInvoiceAdjustmentTotal < 0 ? -firstInvoiceAdjustmentTotal : null;
|
||||
return {
|
||||
schedule_id: schedule.id,
|
||||
change_kind: changeKind,
|
||||
current_billing_cycle: currentBillingCycle,
|
||||
target_billing_cycle: targetBillingCycle,
|
||||
effective_at: new Date(futurePhase.start_date * 1000).toISOString(),
|
||||
current_price_id: currentPriceDetails.priceId,
|
||||
target_price_id: targetPriceDetails.priceId,
|
||||
target_amount_minor: targetPriceDetails.amountMinor,
|
||||
currency: targetPriceDetails.currency,
|
||||
initial_amount_minor: initialAmountMinor,
|
||||
recurring_amount_minor: recurringAmountMinor,
|
||||
@@ -610,6 +626,62 @@ export class PremiumStateService {
|
||||
};
|
||||
}
|
||||
|
||||
private resolveListPriceSwitch(
|
||||
subscription: BillingSubscriptionRow | null,
|
||||
subscriptionPrice: CurrentSubscriptionPriceResponse,
|
||||
pendingChange: PendingSubscriptionChangeResponse,
|
||||
): ListPriceSwitchState {
|
||||
const base = {
|
||||
pending: false,
|
||||
current_price_id: subscriptionPrice?.price_id ?? null,
|
||||
current_amount_minor: subscriptionPrice?.amount_minor ?? null,
|
||||
list_price_id: subscriptionPrice?.list_price_id ?? null,
|
||||
list_amount_minor: subscriptionPrice?.list_amount_minor ?? null,
|
||||
currency: subscriptionPrice?.currency ?? null,
|
||||
billing_cycle: subscriptionPrice?.billing_cycle ?? null,
|
||||
effective_at: toIso(subscription?.current_period_end),
|
||||
};
|
||||
if (Config.instance.selfHosted || !Config.stripe.enabled || !Config.stripe.secretKey) {
|
||||
return {...base, available: false, reason: 'feature_unavailable'};
|
||||
}
|
||||
if (!subscription) {
|
||||
return {...base, available: false, reason: 'no_active_subscription'};
|
||||
}
|
||||
if (!subscriptionPrice) {
|
||||
return {...base, available: false, reason: 'unsupported_subscription'};
|
||||
}
|
||||
if (!subscription.status || !ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)) {
|
||||
return {...base, available: false, reason: 'subscription_not_chargeable'};
|
||||
}
|
||||
if (subscription.cancel_at != null || subscription.cancel_at_period_end === true) {
|
||||
return {...base, available: false, reason: 'subscription_cancelling'};
|
||||
}
|
||||
if (pendingChange) {
|
||||
const targetsListPrice =
|
||||
subscriptionPrice.list_price_id != null && pendingChange.target_price_id === subscriptionPrice.list_price_id;
|
||||
return {
|
||||
...base,
|
||||
available: false,
|
||||
reason: targetsListPrice ? null : 'conflicting_pending_change',
|
||||
pending: targetsListPrice,
|
||||
effective_at: pendingChange.effective_at,
|
||||
};
|
||||
}
|
||||
if (subscriptionPrice.list_price_id == null || subscriptionPrice.list_amount_minor == null) {
|
||||
return {...base, available: false, reason: 'no_list_price'};
|
||||
}
|
||||
if (subscriptionPrice.list_price_id === subscriptionPrice.price_id) {
|
||||
return {...base, available: false, reason: 'already_on_list_price'};
|
||||
}
|
||||
if (subscriptionPrice.list_amount_minor >= subscriptionPrice.amount_minor) {
|
||||
return {...base, available: false, reason: 'not_a_price_decrease'};
|
||||
}
|
||||
if (base.effective_at == null) {
|
||||
return {...base, available: false, reason: 'missing_period_end'};
|
||||
}
|
||||
return {...base, available: true, reason: null};
|
||||
}
|
||||
|
||||
private async resolveStripePriceDetails(
|
||||
priceRef: Stripe.Price | Stripe.DeletedPrice | string | null | undefined,
|
||||
): Promise<StripePriceDetails> {
|
||||
|
||||
@@ -5,11 +5,13 @@ import {PremiumFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
|
||||
import type {IEmailService} from '@pkgs/email/src/IEmailService';
|
||||
import type Stripe from 'stripe';
|
||||
import type {UserRow} from '../../database/types/UserTypes';
|
||||
import type {IDonationRepository} from '../../donation/IDonationRepository';
|
||||
import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import type {KVAccountDeletionQueueService} from '../../infrastructure/KVAccountDeletionQueueService';
|
||||
import type {UserCacheService} from '../../infrastructure/UserCacheService';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
import type {GiftCode} from '../../models/GiftCode';
|
||||
import type {User} from '../../models/User';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
@@ -19,6 +21,8 @@ import {extractId} from '../StripeUtils';
|
||||
import type {StripeGiftReversalHandler} from './StripeGiftReversalHandler';
|
||||
import type {StripePaymentFraudService} from './StripePaymentFraudService';
|
||||
|
||||
export const REFUND_ALLOWANCE_CLAIM_PREFIX = 'refund-allowance';
|
||||
|
||||
export class StripeDisputeWebhookHandler {
|
||||
constructor(
|
||||
private userRepository: IUserRepository,
|
||||
@@ -105,7 +109,7 @@ export class StripeDisputeWebhookHandler {
|
||||
}
|
||||
Logger.debug(
|
||||
{userId: payment.userId},
|
||||
'User unsuspended after chargeback withdrawal - 30 day purchase block applied',
|
||||
'User unsuspended after chargeback withdrawal - 30 day self-serve refund cooldown applied',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -167,25 +171,94 @@ export class StripeDisputeWebhookHandler {
|
||||
);
|
||||
user = foundUser;
|
||||
}
|
||||
if (!user.firstRefundAt) {
|
||||
const updatedUser = await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
|
||||
await this.dispatchUser(updatedUser);
|
||||
const claimKeys = await this.resolveRefundAllowanceClaimKeys(charge);
|
||||
if (claimKeys.length === 0) {
|
||||
Logger.debug(
|
||||
{userId: user.id, chargeId: charge.id, paymentIntentId},
|
||||
'First refund recorded - 30 day purchase block applied',
|
||||
);
|
||||
} else {
|
||||
const updatedUser = await this.userRepository.patchUpsert(
|
||||
user.id,
|
||||
{premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED},
|
||||
user.toRow(),
|
||||
);
|
||||
await this.dispatchUser(updatedUser);
|
||||
Logger.debug(
|
||||
{userId: user.id, chargeId: charge.id, paymentIntentId},
|
||||
'Second refund recorded - permanent purchase block applied',
|
||||
'Refund was issued by Fluxer - not counted against the user refund allowance',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const claimedKeys: Array<string> = [];
|
||||
let alreadyCounted = false;
|
||||
for (const claimKey of claimKeys) {
|
||||
const claim = await getBillingRepository().webhookEvents.tryClaim(claimKey);
|
||||
if (claim === 'claimed') {
|
||||
claimedKeys.push(claimKey);
|
||||
} else {
|
||||
alreadyCounted = true;
|
||||
}
|
||||
}
|
||||
if (alreadyCounted) {
|
||||
for (const claimKey of claimedKeys) {
|
||||
await getBillingRepository().webhookEvents.markProcessed(claimKey);
|
||||
}
|
||||
Logger.debug(
|
||||
{userId: user.id, chargeId: charge.id, paymentIntentId, claimKeys},
|
||||
'Refund already counted against the user refund allowance',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const isFirstRefund = !user.firstRefundAt;
|
||||
const patch: Partial<UserRow> = isFirstRefund
|
||||
? {first_refund_at: new Date()}
|
||||
: {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
|
||||
let updatedUser: User;
|
||||
try {
|
||||
updatedUser = await this.userRepository.patchUpsert(user.id, patch, user.toRow());
|
||||
} catch (error) {
|
||||
for (const claimKey of claimedKeys) {
|
||||
await getBillingRepository().webhookEvents.releaseClaim(claimKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
for (const claimKey of claimedKeys) {
|
||||
await getBillingRepository().webhookEvents.markProcessed(claimKey);
|
||||
}
|
||||
await this.dispatchUser(updatedUser);
|
||||
Logger.debug(
|
||||
{userId: user.id, chargeId: charge.id, paymentIntentId},
|
||||
isFirstRefund
|
||||
? 'First refund recorded - 30 day self-serve refund cooldown applied'
|
||||
: 'Second refund recorded - permanent purchase block applied',
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRefundAllowanceClaimKeys(charge: Stripe.Charge): Promise<Array<string>> {
|
||||
const chargeClaimKey = `${REFUND_ALLOWANCE_CLAIM_PREFIX}:${charge.id}`;
|
||||
const inlined = charge.refunds?.data ?? [];
|
||||
const refunds = (
|
||||
inlined.length > 0
|
||||
? inlined.map((refund) => ({
|
||||
id: refund.id,
|
||||
createdAtMs: refund.created * 1000,
|
||||
status: refund.status,
|
||||
rejectionReason: refund.metadata?.rejection_reason ?? null,
|
||||
}))
|
||||
: (await getBillingRepository().refunds.listByCharge(charge.id)).map((row) => ({
|
||||
id: row.provider_id,
|
||||
createdAtMs: row.stripe_created_at?.getTime() ?? 0,
|
||||
status: row.status,
|
||||
rejectionReason: row.metadata?.get('rejection_reason') ?? null,
|
||||
}))
|
||||
).filter((refund) => refund.status !== 'failed' && refund.status !== 'canceled');
|
||||
if (refunds.length === 0) {
|
||||
Logger.warn(
|
||||
{chargeId: charge.id},
|
||||
'No refund records found for refunded charge; counting the charge once against the user refund allowance',
|
||||
);
|
||||
return [chargeClaimKey];
|
||||
}
|
||||
const customerRefunds = refunds
|
||||
.filter((refund) => refund.rejectionReason === null)
|
||||
.sort((left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id));
|
||||
const earliest = customerRefunds[0];
|
||||
const latest = customerRefunds[customerRefunds.length - 1];
|
||||
if (!earliest || !latest) {
|
||||
return [];
|
||||
}
|
||||
const latestClaimKey = `${REFUND_ALLOWANCE_CLAIM_PREFIX}:${latest.id}`;
|
||||
return latest.id === earliest.id ? [chargeClaimKey, latestClaimKey] : [latestClaimKey];
|
||||
}
|
||||
|
||||
private async handleGiftChargeback(giftCode: GiftCode, dispute: Stripe.Dispute): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PremiumFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {FeatureNotAvailableSelfHostedError} from '@fluxer/errors/src/domains/core/FeatureNotAvailableSelfHostedError';
|
||||
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
|
||||
import {StripeNoPurchaseHistoryError} from '@fluxer/errors/src/domains/payment/StripeNoPurchaseHistoryError';
|
||||
@@ -15,11 +16,13 @@ import type {
|
||||
import type Stripe from 'stripe';
|
||||
import {createUserID, type UserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import type {UserRow} from '../../database/types/UserTypes';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
import type {User} from '../../models/User';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {extractId} from '../StripeUtils';
|
||||
import {REFUND_ALLOWANCE_CLAIM_PREFIX} from './StripeDisputeWebhookHandler';
|
||||
import type {StripeSubscriptionService} from './StripeSubscriptionService';
|
||||
|
||||
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
@@ -260,16 +263,35 @@ export class StripeRefundService {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
Logger.error(
|
||||
{error, userId: user.id.toString(), subscriptionId},
|
||||
'Self-serve refund confirmed but subscription cancellation failed; will reconcile via webhook',
|
||||
'Self-serve refund confirmed but subscription cancellation failed; the subscription is still active and will keep billing until it is cancelled manually',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
|
||||
const claimKey = `${REFUND_ALLOWANCE_CLAIM_PREFIX}:${refund.id}`;
|
||||
const claim = await getBillingRepository().webhookEvents.tryClaim(claimKey);
|
||||
if (claim !== 'claimed') {
|
||||
Logger.debug(
|
||||
{userId: user.id.toString(), refundId: refund.id, claim},
|
||||
'Self-serve refund already counted against the user refund allowance',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const isFirstRefund = !user.firstRefundAt;
|
||||
const patch: Partial<UserRow> = isFirstRefund
|
||||
? {first_refund_at: new Date()}
|
||||
: {premium_flags: user.premiumFlags | PremiumFlags.PURCHASE_DISABLED};
|
||||
try {
|
||||
await this.userRepository.patchUpsert(user.id, patch, user.toRow());
|
||||
} catch (error) {
|
||||
await getBillingRepository().webhookEvents.releaseClaim(claimKey);
|
||||
throw error;
|
||||
}
|
||||
await getBillingRepository().webhookEvents.markProcessed(claimKey);
|
||||
Logger.info(
|
||||
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null},
|
||||
'Self-serve refund confirmed succeeded; cooldown and cancellation finalized',
|
||||
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null, isFirstRefund},
|
||||
'Self-serve refund confirmed succeeded; refund allowance and cancellation finalized',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ import {StripePaymentNotAvailableError} from '@fluxer/errors/src/domains/payment
|
||||
import {StripeSubscriptionAlreadyCancelingError} from '@fluxer/errors/src/domains/payment/StripeSubscriptionAlreadyCancelingError';
|
||||
import {StripeSubscriptionNotCancelingError} from '@fluxer/errors/src/domains/payment/StripeSubscriptionNotCancelingError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {CurrentSubscriptionPriceResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type {
|
||||
CurrentSubscriptionPriceResponse,
|
||||
SwitchToListPriceResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {seconds} from 'itty-time';
|
||||
import type Stripe from 'stripe';
|
||||
@@ -25,7 +28,7 @@ import type {User} from '../../models/User';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {mapUserToPrivateResponse} from '../../user/UserMappers';
|
||||
import type {Currency} from '../../utils/CurrencyUtils';
|
||||
import type {RecurringBillingCycle} from '../ProductRegistry';
|
||||
import type {ProductInfo, RecurringBillingCycle} from '../ProductRegistry';
|
||||
import {
|
||||
getPrimarySubscriptionItem,
|
||||
getSubscriptionEntitlementPeriodEndUnix,
|
||||
@@ -36,12 +39,80 @@ import {extractId} from '../StripeUtils';
|
||||
|
||||
type BillingCycleChangeEffectiveAt = 'now' | 'period_end';
|
||||
|
||||
const CHARGEABLE_SUBSCRIPTION_STATUSES = new Set<Stripe.Subscription.Status>(['active', 'trialing']);
|
||||
|
||||
function mapStripeIds(values: Array<string | {id: string}> | null | undefined): Array<string> | null {
|
||||
const ids = (values ?? []).map((value) => extractId(value)).filter((id): id is string => id !== null);
|
||||
return ids.length > 0 ? ids : null;
|
||||
}
|
||||
|
||||
function mapSchedulePhaseDiscounts(
|
||||
discounts:
|
||||
| Array<{
|
||||
coupon: string | {id: string} | null;
|
||||
discount: unknown;
|
||||
promotion_code: string | {id: string} | null;
|
||||
}>
|
||||
| null
|
||||
| undefined,
|
||||
): Array<Stripe.SubscriptionScheduleUpdateParams.Phase.Discount> | null {
|
||||
const mapped = (discounts ?? [])
|
||||
.map((discount): Stripe.SubscriptionScheduleUpdateParams.Phase.Discount | null => {
|
||||
const coupon = extractId(discount.coupon);
|
||||
if (coupon) {
|
||||
return {coupon};
|
||||
}
|
||||
const promotionCode = extractId(discount.promotion_code);
|
||||
if (promotionCode) {
|
||||
return {promotion_code: promotionCode};
|
||||
}
|
||||
return typeof discount.discount === 'string' ? {discount: discount.discount} : null;
|
||||
})
|
||||
.filter((discount): discount is Stripe.SubscriptionScheduleUpdateParams.Phase.Discount => discount !== null);
|
||||
return mapped.length > 0 ? mapped : null;
|
||||
}
|
||||
|
||||
function mapSchedulePhaseAddInvoiceItems(
|
||||
addInvoiceItems: Array<Stripe.SubscriptionSchedule.Phase.AddInvoiceItem> | null | undefined,
|
||||
): Array<Stripe.SubscriptionScheduleUpdateParams.Phase.AddInvoiceItem> | null {
|
||||
const mapped = (addInvoiceItems ?? [])
|
||||
.map((addInvoiceItem) => {
|
||||
const price = extractId(addInvoiceItem.price);
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
const mappedItem: Stripe.SubscriptionScheduleUpdateParams.Phase.AddInvoiceItem = {
|
||||
price,
|
||||
quantity: addInvoiceItem.quantity ?? 1,
|
||||
period: addInvoiceItem.period,
|
||||
};
|
||||
const taxRates = mapStripeIds(addInvoiceItem.tax_rates);
|
||||
if (taxRates) {
|
||||
mappedItem.tax_rates = taxRates;
|
||||
}
|
||||
const discounts = mapSchedulePhaseDiscounts(addInvoiceItem.discounts);
|
||||
if (discounts) {
|
||||
mappedItem.discounts = discounts;
|
||||
}
|
||||
if (addInvoiceItem.metadata) {
|
||||
mappedItem.metadata = addInvoiceItem.metadata;
|
||||
}
|
||||
return mappedItem;
|
||||
})
|
||||
.filter(
|
||||
(addInvoiceItem): addInvoiceItem is Stripe.SubscriptionScheduleUpdateParams.Phase.AddInvoiceItem =>
|
||||
addInvoiceItem !== null,
|
||||
);
|
||||
return mapped.length > 0 ? mapped : null;
|
||||
}
|
||||
|
||||
export class StripeSubscriptionService {
|
||||
constructor(
|
||||
private stripe: Stripe | null,
|
||||
private userRepository: IUserRepository,
|
||||
private productRegistry: {
|
||||
getRecurringSubscriptionPriceId: (billingCycle: RecurringBillingCycle, currency: string) => string | null;
|
||||
getProduct: (priceId: string) => ProductInfo | null;
|
||||
},
|
||||
private cacheService: ICacheService,
|
||||
private gatewayService: IGatewayService,
|
||||
@@ -251,7 +322,7 @@ export class StripeSubscriptionService {
|
||||
return;
|
||||
}
|
||||
await this.stripe.subscriptionSchedules.release(schedule.id, {
|
||||
preserve_cancel_date: false,
|
||||
preserve_cancel_date: Boolean(subscription.cancel_at || subscription.cancel_at_period_end),
|
||||
});
|
||||
const releasedSubscription = await this.stripe.subscriptions.retrieve(subscription.id, {
|
||||
expand: ['items.data.price'],
|
||||
@@ -268,7 +339,7 @@ export class StripeSubscriptionService {
|
||||
);
|
||||
}
|
||||
const patch: Record<string, unknown> = {
|
||||
premium_will_cancel: false,
|
||||
premium_will_cancel: Boolean(releasedSubscription.cancel_at || releasedSubscription.cancel_at_period_end),
|
||||
};
|
||||
const computedPremiumUntil = getSubscriptionPremiumPeriodEnd(releasedSubscription);
|
||||
if (computedPremiumUntil) {
|
||||
@@ -433,6 +504,142 @@ export class StripeSubscriptionService {
|
||||
}
|
||||
}
|
||||
|
||||
async switchToCurrentListPrice(userId: UserID): Promise<SwitchToListPriceResponse> {
|
||||
if (!this.stripe) {
|
||||
throw new StripePaymentNotAvailableError();
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (!user.stripeSubscriptionId) {
|
||||
throw new StripeNoActiveSubscriptionError();
|
||||
}
|
||||
const lockKey = `list_price_switch_lock:${user.id}`;
|
||||
const lockToken = await this.cacheService.acquireLock(
|
||||
lockKey,
|
||||
StripeSubscriptionService.LIST_PRICE_SWITCH_LOCK_TTL_SECONDS,
|
||||
);
|
||||
if (!lockToken) {
|
||||
Logger.debug(
|
||||
{userId, subscriptionId: user.stripeSubscriptionId, lockKey},
|
||||
'List price switch skipped because another switch is already in flight',
|
||||
);
|
||||
return {status: 'ineligible', reason: 'switch_in_progress'};
|
||||
}
|
||||
try {
|
||||
const subscription = await this.stripe.subscriptions.retrieve(user.stripeSubscriptionId, {
|
||||
expand: ['items.data.price', 'schedule'],
|
||||
});
|
||||
const item = getPrimarySubscriptionItem(subscription);
|
||||
if (!item?.id || !item.price?.recurring || !item.price.currency || item.price.unit_amount == null) {
|
||||
return {status: 'ineligible', reason: 'unsupported_subscription'};
|
||||
}
|
||||
if (!CHARGEABLE_SUBSCRIPTION_STATUSES.has(subscription.status)) {
|
||||
return {status: 'ineligible', reason: 'subscription_not_chargeable'};
|
||||
}
|
||||
if (subscription.cancel_at != null || subscription.cancel_at_period_end) {
|
||||
return {status: 'ineligible', reason: 'subscription_cancelling'};
|
||||
}
|
||||
const billingCycle = this.getBillingCycleFromInterval(item.price.recurring.interval);
|
||||
if (!billingCycle) {
|
||||
return {status: 'ineligible', reason: 'unsupported_subscription'};
|
||||
}
|
||||
const currency = item.price.currency.toUpperCase() as Currency;
|
||||
const targetPriceId = this.productRegistry.getRecurringSubscriptionPriceId(billingCycle, currency);
|
||||
if (!targetPriceId) {
|
||||
return {status: 'ineligible', reason: 'no_list_price'};
|
||||
}
|
||||
if (targetPriceId === item.price.id) {
|
||||
return {status: 'ineligible', reason: 'already_on_list_price'};
|
||||
}
|
||||
const targetProduct = this.productRegistry.getProduct(targetPriceId);
|
||||
if (!targetProduct || targetProduct.currency !== currency) {
|
||||
return {status: 'ineligible', reason: 'no_list_price'};
|
||||
}
|
||||
const targetAmountMinor = await this.getListPriceAmountMinor(targetPriceId);
|
||||
const currentAmountMinor = item.price.unit_amount;
|
||||
if (targetAmountMinor == null) {
|
||||
return {status: 'ineligible', reason: 'no_list_price'};
|
||||
}
|
||||
if (targetAmountMinor >= currentAmountMinor) {
|
||||
return {status: 'ineligible', reason: 'not_a_price_decrease'};
|
||||
}
|
||||
const periodEnd = getSubscriptionEntitlementPeriodEndUnix(subscription, item);
|
||||
if (!periodEnd || periodEnd <= Math.floor(Date.now() / 1000)) {
|
||||
return {status: 'ineligible', reason: 'missing_period_end'};
|
||||
}
|
||||
const switched = {
|
||||
effective_at: new Date(periodEnd * 1000).toISOString(),
|
||||
target_price_id: targetPriceId,
|
||||
target_amount_minor: targetAmountMinor,
|
||||
current_amount_minor: currentAmountMinor,
|
||||
currency,
|
||||
};
|
||||
const schedule = await this.loadSubscriptionSchedule(subscription.schedule);
|
||||
const pendingSchedule =
|
||||
schedule && (schedule.status === 'active' || schedule.status === 'not_started') ? schedule : null;
|
||||
if (pendingSchedule) {
|
||||
if (this.subscriptionScheduleHasFutureTargetPrice(pendingSchedule, targetPriceId)) {
|
||||
Logger.debug(
|
||||
{userId, subscriptionId: subscription.id, scheduleId: pendingSchedule.id, targetPriceId},
|
||||
'List price switch already scheduled for period end',
|
||||
);
|
||||
return {status: 'already_scheduled', ...switched};
|
||||
}
|
||||
if (pendingSchedule.end_behavior === 'cancel') {
|
||||
return {status: 'ineligible', reason: 'cancellation_managed_by_schedule'};
|
||||
}
|
||||
if (this.subscriptionScheduleHasPendingBillingCycleChange(pendingSchedule, subscription)) {
|
||||
return {status: 'ineligible', reason: 'conflicting_pending_change'};
|
||||
}
|
||||
}
|
||||
await this.scheduleBillingCycleChangeAtPeriodEnd({
|
||||
user,
|
||||
subscription,
|
||||
item,
|
||||
currentBillingCycle: billingCycle,
|
||||
targetBillingCycle: billingCycle,
|
||||
targetPriceId,
|
||||
clearCancellation: false,
|
||||
});
|
||||
Logger.debug(
|
||||
{
|
||||
userId,
|
||||
subscriptionId: subscription.id,
|
||||
billingCycle,
|
||||
currency,
|
||||
currentPriceId: item.price.id,
|
||||
targetPriceId,
|
||||
currentAmountMinor,
|
||||
targetAmountMinor,
|
||||
periodEnd,
|
||||
},
|
||||
'Subscription switch to current list price scheduled for period end',
|
||||
);
|
||||
return {status: 'scheduled', ...switched};
|
||||
} catch (error: unknown) {
|
||||
Logger.error(
|
||||
{error, userId, subscriptionId: user.stripeSubscriptionId},
|
||||
'Failed to switch subscription to the current list price',
|
||||
);
|
||||
if (error instanceof StripeError || error instanceof StripeInvalidProductConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Failed to switch subscription to the list price';
|
||||
throw new StripeError(message);
|
||||
} finally {
|
||||
try {
|
||||
const released = await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
if (!released) {
|
||||
Logger.warn({userId, lockKey}, 'List price switch lock token no longer matched on release');
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, lockKey}, 'Failed to release list price switch lock');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async scheduleBillingCycleChangeAtPeriodEnd({
|
||||
user,
|
||||
subscription,
|
||||
@@ -440,6 +647,7 @@ export class StripeSubscriptionService {
|
||||
currentBillingCycle,
|
||||
targetBillingCycle,
|
||||
targetPriceId,
|
||||
clearCancellation = true,
|
||||
}: {
|
||||
user: User;
|
||||
subscription: Stripe.Subscription;
|
||||
@@ -447,6 +655,7 @@ export class StripeSubscriptionService {
|
||||
currentBillingCycle: RecurringBillingCycle;
|
||||
targetBillingCycle: RecurringBillingCycle;
|
||||
targetPriceId: string;
|
||||
clearCancellation?: boolean;
|
||||
}): Promise<void> {
|
||||
if (!this.stripe) {
|
||||
throw new StripePaymentNotAvailableError();
|
||||
@@ -456,7 +665,7 @@ export class StripeSubscriptionService {
|
||||
throw new StripeError('Subscription is missing a future period end for scheduled billing cycle change');
|
||||
}
|
||||
let currentSubscription = subscription;
|
||||
if (subscription.cancel_at || subscription.cancel_at_period_end) {
|
||||
if (clearCancellation && (subscription.cancel_at || subscription.cancel_at_period_end)) {
|
||||
currentSubscription = await this.stripe.subscriptions.update(
|
||||
subscription.id,
|
||||
this.getClearCancellationUpdateParams(subscription) ?? {proration_behavior: 'none'},
|
||||
@@ -491,7 +700,6 @@ export class StripeSubscriptionService {
|
||||
firstInvoiceCredit?.price_data?.unit_amount != null ? -firstInvoiceCredit.price_data.unit_amount : null;
|
||||
const targetPhase: Stripe.SubscriptionScheduleUpdateParams.Phase = {
|
||||
start_date: periodEnd,
|
||||
billing_cycle_anchor: 'phase_start',
|
||||
items: [
|
||||
{
|
||||
price: targetPriceId,
|
||||
@@ -500,6 +708,9 @@ export class StripeSubscriptionService {
|
||||
],
|
||||
proration_behavior: 'none',
|
||||
};
|
||||
if (currentBillingCycle !== targetBillingCycle) {
|
||||
targetPhase.billing_cycle_anchor = 'phase_start';
|
||||
}
|
||||
if (firstInvoiceCredit) {
|
||||
targetPhase.add_invoice_items = [firstInvoiceCredit];
|
||||
targetPhase.metadata = {
|
||||
@@ -508,7 +719,7 @@ export class StripeSubscriptionService {
|
||||
};
|
||||
}
|
||||
await this.stripe.subscriptionSchedules.update(schedule.id, {
|
||||
end_behavior: 'release',
|
||||
end_behavior: clearCancellation ? 'release' : schedule.end_behavior,
|
||||
proration_behavior: 'none',
|
||||
metadata: {
|
||||
user_id: user.id.toString(),
|
||||
@@ -516,14 +727,33 @@ export class StripeSubscriptionService {
|
||||
},
|
||||
phases: [currentPhase, targetPhase],
|
||||
});
|
||||
let scheduledSubscription = currentSubscription;
|
||||
if (!clearCancellation) {
|
||||
scheduledSubscription = await this.stripe.subscriptions.retrieve(currentSubscription.id, {
|
||||
expand: ['items.data.price'],
|
||||
});
|
||||
try {
|
||||
await getBillingRepository().subscriptions.upsertFromStripe(scheduledSubscription, {
|
||||
knownUserId: user.id,
|
||||
snapshotCapturedAt: new Date(),
|
||||
});
|
||||
} catch (mirrorErr) {
|
||||
Logger.error(
|
||||
{mirrorErr, subId: scheduledSubscription.id},
|
||||
'Mirror upsert failed after scheduling a period-end price change; reconciler will heal',
|
||||
);
|
||||
}
|
||||
}
|
||||
const patch: Record<string, unknown> = {
|
||||
premium_will_cancel: false,
|
||||
premium_will_cancel: clearCancellation
|
||||
? false
|
||||
: Boolean(scheduledSubscription.cancel_at || scheduledSubscription.cancel_at_period_end),
|
||||
};
|
||||
const computedPremiumUntil = getSubscriptionPremiumPeriodEnd(currentSubscription);
|
||||
const computedPremiumUntil = getSubscriptionPremiumPeriodEnd(scheduledSubscription);
|
||||
if (computedPremiumUntil) {
|
||||
patch['premium_until'] = computedPremiumUntil;
|
||||
}
|
||||
const updatedCustomerId = extractId(currentSubscription.customer);
|
||||
const updatedCustomerId = extractId(scheduledSubscription.customer);
|
||||
if (updatedCustomerId && updatedCustomerId !== user.stripeCustomerId) {
|
||||
patch['stripe_customer_id'] = updatedCustomerId;
|
||||
}
|
||||
@@ -532,7 +762,7 @@ export class StripeSubscriptionService {
|
||||
Logger.debug(
|
||||
{
|
||||
userId: user.id,
|
||||
subscriptionId: currentSubscription.id,
|
||||
subscriptionId: scheduledSubscription.id,
|
||||
scheduleId: schedule.id,
|
||||
fromBillingCycle: currentBillingCycle,
|
||||
toBillingCycle: targetBillingCycle,
|
||||
@@ -742,12 +972,28 @@ export class StripeSubscriptionService {
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
const mappedItem: Stripe.SubscriptionScheduleUpdateParams.Phase.Item = {
|
||||
price,
|
||||
quantity: phaseItem.quantity ?? 1,
|
||||
};
|
||||
const itemTaxRates = mapStripeIds(phaseItem.tax_rates);
|
||||
if (itemTaxRates) {
|
||||
mappedItem.tax_rates = itemTaxRates;
|
||||
}
|
||||
const itemDiscounts = mapSchedulePhaseDiscounts(phaseItem.discounts);
|
||||
if (itemDiscounts) {
|
||||
mappedItem.discounts = itemDiscounts;
|
||||
}
|
||||
if (phaseItem.metadata) {
|
||||
mappedItem.metadata = phaseItem.metadata;
|
||||
}
|
||||
if (phaseItem.billing_thresholds?.usage_gte != null) {
|
||||
mappedItem.billing_thresholds = {usage_gte: phaseItem.billing_thresholds.usage_gte};
|
||||
}
|
||||
return mappedItem;
|
||||
})
|
||||
.filter((phaseItem): phaseItem is {price: string; quantity: number} => phaseItem !== null) ?? [];
|
||||
.filter((phaseItem): phaseItem is Stripe.SubscriptionScheduleUpdateParams.Phase.Item => phaseItem !== null) ??
|
||||
[];
|
||||
const currentPhase: Stripe.SubscriptionScheduleUpdateParams.Phase = {
|
||||
start_date: phase?.start_date ?? subscription.start_date ?? subscription.created,
|
||||
end_date: periodEnd,
|
||||
@@ -762,6 +1008,99 @@ export class StripeSubscriptionService {
|
||||
],
|
||||
proration_behavior: 'none',
|
||||
};
|
||||
if (phase) {
|
||||
const phaseDiscounts = mapSchedulePhaseDiscounts(phase.discounts);
|
||||
if (phaseDiscounts) {
|
||||
currentPhase.discounts = phaseDiscounts;
|
||||
}
|
||||
const defaultTaxRates = mapStripeIds(phase.default_tax_rates);
|
||||
if (defaultTaxRates) {
|
||||
currentPhase.default_tax_rates = defaultTaxRates;
|
||||
}
|
||||
if (phase.metadata) {
|
||||
currentPhase.metadata = phase.metadata;
|
||||
}
|
||||
if (phase.currency) {
|
||||
currentPhase.currency = phase.currency;
|
||||
}
|
||||
if (phase.description != null) {
|
||||
currentPhase.description = phase.description;
|
||||
}
|
||||
if (phase.collection_method) {
|
||||
currentPhase.collection_method = phase.collection_method;
|
||||
}
|
||||
if (phase.application_fee_percent != null) {
|
||||
currentPhase.application_fee_percent = phase.application_fee_percent;
|
||||
}
|
||||
const defaultPaymentMethod = extractId(phase.default_payment_method);
|
||||
if (defaultPaymentMethod) {
|
||||
currentPhase.default_payment_method = defaultPaymentMethod;
|
||||
}
|
||||
const onBehalfOf = extractId(phase.on_behalf_of);
|
||||
if (onBehalfOf) {
|
||||
currentPhase.on_behalf_of = onBehalfOf;
|
||||
}
|
||||
if (phase.automatic_tax) {
|
||||
const automaticTax: Stripe.SubscriptionScheduleUpdateParams.Phase.AutomaticTax = {
|
||||
enabled: phase.automatic_tax.enabled,
|
||||
};
|
||||
if (phase.automatic_tax.liability) {
|
||||
automaticTax.liability = {type: phase.automatic_tax.liability.type};
|
||||
const liabilityAccount = extractId(phase.automatic_tax.liability.account);
|
||||
if (liabilityAccount) {
|
||||
automaticTax.liability.account = liabilityAccount;
|
||||
}
|
||||
}
|
||||
currentPhase.automatic_tax = automaticTax;
|
||||
}
|
||||
if (phase.invoice_settings) {
|
||||
const invoiceSettings: Stripe.SubscriptionScheduleUpdateParams.Phase.InvoiceSettings = {};
|
||||
const accountTaxIds = mapStripeIds(phase.invoice_settings.account_tax_ids);
|
||||
if (accountTaxIds) {
|
||||
invoiceSettings.account_tax_ids = accountTaxIds;
|
||||
}
|
||||
if (phase.invoice_settings.days_until_due != null) {
|
||||
invoiceSettings.days_until_due = phase.invoice_settings.days_until_due;
|
||||
}
|
||||
if (phase.invoice_settings.issuer) {
|
||||
invoiceSettings.issuer = {type: phase.invoice_settings.issuer.type};
|
||||
const issuerAccount = extractId(phase.invoice_settings.issuer.account);
|
||||
if (issuerAccount) {
|
||||
invoiceSettings.issuer.account = issuerAccount;
|
||||
}
|
||||
}
|
||||
if (Object.keys(invoiceSettings).length > 0) {
|
||||
currentPhase.invoice_settings = invoiceSettings;
|
||||
}
|
||||
}
|
||||
if (phase.billing_thresholds) {
|
||||
const billingThresholds: Stripe.SubscriptionScheduleUpdateParams.Phase.BillingThresholds = {};
|
||||
if (phase.billing_thresholds.amount_gte != null) {
|
||||
billingThresholds.amount_gte = phase.billing_thresholds.amount_gte;
|
||||
}
|
||||
if (phase.billing_thresholds.reset_billing_cycle_anchor != null) {
|
||||
billingThresholds.reset_billing_cycle_anchor = phase.billing_thresholds.reset_billing_cycle_anchor;
|
||||
}
|
||||
if (Object.keys(billingThresholds).length > 0) {
|
||||
currentPhase.billing_thresholds = billingThresholds;
|
||||
}
|
||||
}
|
||||
if (phase.transfer_data) {
|
||||
const destination = extractId(phase.transfer_data.destination);
|
||||
if (destination) {
|
||||
currentPhase.transfer_data = {destination};
|
||||
if (phase.transfer_data.amount_percent != null) {
|
||||
currentPhase.transfer_data.amount_percent = phase.transfer_data.amount_percent;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase.start_date > now) {
|
||||
const addInvoiceItems = mapSchedulePhaseAddInvoiceItems(phase.add_invoice_items);
|
||||
if (addInvoiceItems) {
|
||||
currentPhase.add_invoice_items = addInvoiceItems;
|
||||
}
|
||||
}
|
||||
}
|
||||
const trialEnd = subscription.trial_end;
|
||||
if (trialEnd != null && trialEnd > now) {
|
||||
if (trialEnd >= periodEnd) {
|
||||
@@ -770,6 +1109,10 @@ export class StripeSubscriptionService {
|
||||
currentPhase.trial_end = trialEnd;
|
||||
}
|
||||
}
|
||||
const phaseIsTrial = currentPhase.trial === true || currentPhase.trial_end != null;
|
||||
if (phase?.billing_cycle_anchor && !(phaseIsTrial && phase.billing_cycle_anchor === 'phase_start')) {
|
||||
currentPhase.billing_cycle_anchor = phase.billing_cycle_anchor;
|
||||
}
|
||||
return currentPhase;
|
||||
}
|
||||
|
||||
@@ -1026,6 +1369,7 @@ export class StripeSubscriptionService {
|
||||
private static readonly CURRENT_PRICE_CACHE_TTL_SECONDS = seconds('5 minutes');
|
||||
private static readonly LIST_PRICE_CACHE_TTL_SECONDS = seconds('1 hour');
|
||||
private static readonly PRICE_CACHE_PRODUCE_TIMEOUT_MS = 90000;
|
||||
private static readonly LIST_PRICE_SWITCH_LOCK_TTL_SECONDS = seconds('30 seconds');
|
||||
private static readonly USER_TRIAL_LOCK_TTL_SECONDS = seconds('30 seconds');
|
||||
private static readonly USER_TRIAL_LOCK_MAX_WAIT_MS = 15000;
|
||||
private static readonly USER_TRIAL_LOCK_RETRY_DELAY_MS = 100;
|
||||
|
||||
@@ -65,6 +65,14 @@ export class StripeSubscriptionWebhookHandler {
|
||||
Logger.error({invoiceId: invoice.id, billingReason}, 'No subscription ID found in subscription invoice');
|
||||
throw new StripeError('Invoice missing subscription id');
|
||||
}
|
||||
const donor = await this.donationRepository.findDonorByStripeSubscriptionId(subscriptionId);
|
||||
if (donor) {
|
||||
Logger.debug(
|
||||
{invoiceId: invoice.id, eventId, subscriptionId, donorEmail: donor.email},
|
||||
'Skipping invoice payment for donation subscription',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.isSubscriptionUpdateInvoice(invoice)) {
|
||||
Logger.debug(
|
||||
{
|
||||
|
||||
@@ -309,6 +309,10 @@ export class StripeWebhookService {
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.paymentMethods.markDetached(pm.id, new Date()));
|
||||
break;
|
||||
}
|
||||
case 'mandate.updated': {
|
||||
await this.handleMandateUpdated(event.data.object as Stripe.Mandate);
|
||||
break;
|
||||
}
|
||||
case 'payment_intent.created':
|
||||
case 'payment_intent.processing':
|
||||
case 'payment_intent.succeeded':
|
||||
@@ -369,6 +373,18 @@ export class StripeWebhookService {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMandateUpdated(mandate: Stripe.Mandate): Promise<void> {
|
||||
if (mandate.status !== 'inactive') {
|
||||
return;
|
||||
}
|
||||
const paymentMethodId =
|
||||
typeof mandate.payment_method === 'string' ? mandate.payment_method : (mandate.payment_method?.id ?? null);
|
||||
Logger.warn(
|
||||
{mandateId: mandate.id, paymentMethodId, status: mandate.status},
|
||||
'Stripe mandate is no longer active; recurring payments on this payment method will fail',
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRefundCustomerId(refund: Stripe.Refund): Promise<string | null> {
|
||||
const chargeId = typeof refund.charge === 'string' ? refund.charge : (refund.charge?.id ?? null);
|
||||
if (chargeId !== null) {
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, describe, expect, test} from 'vitest';
|
||||
import {Config} from '../../Config';
|
||||
import {ProductRegistry, ProductType} from '../ProductRegistry';
|
||||
|
||||
// Mirrors the live BRL/TRY configuration after the repricing: the BRL slots point at the new list
|
||||
// prices, TRY is not configured at all (production has no TRY prices), and every retired price id
|
||||
// lives only in the legacy map.
|
||||
const MOCK_PRICES = {
|
||||
monthlyUsd: 'price_monthly_usd',
|
||||
monthlyBrl: 'price_1TMbqsFPC94Os7FdfElrIIaZ',
|
||||
yearlyUsd: 'price_yearly_usd',
|
||||
yearlyBrl: 'price_1TMbqtFPC94Os7Fd7VJqmDyt',
|
||||
gift1MonthBrl: 'price_gift_1_month_brl',
|
||||
gift1YearBrl: 'price_gift_1_year_brl',
|
||||
};
|
||||
|
||||
const LEGACY_MONTHLY_BRL = 'price_legacy_monthly_brl';
|
||||
const LEGACY_YEARLY_BRL = 'price_legacy_yearly_brl';
|
||||
const LEGACY_GIFT_1_MONTH_BRL = 'price_legacy_gift_1_month_brl';
|
||||
const LEGACY_GIFT_1_YEAR_BRL = 'price_legacy_gift_1_year_brl';
|
||||
const LEGACY_MONTHLY_TRY = 'price_1TMYpdFPC94Os7FdZVRx98Up';
|
||||
|
||||
const MOCK_LEGACY_PRICES: Record<string, Array<string> | undefined> = {
|
||||
monthly_brl: [LEGACY_MONTHLY_BRL],
|
||||
yearly_brl: [LEGACY_YEARLY_BRL],
|
||||
gift_1_month_brl: [LEGACY_GIFT_1_MONTH_BRL],
|
||||
gift_1_year_brl: [LEGACY_GIFT_1_YEAR_BRL],
|
||||
monthly_try: [LEGACY_MONTHLY_TRY],
|
||||
};
|
||||
|
||||
describe('ProductRegistry - legacy prices', () => {
|
||||
let originalPrices: typeof Config.stripe.prices | undefined;
|
||||
let originalLegacyPrices: typeof Config.stripe.legacyPrices | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
originalPrices = Config.stripe.prices;
|
||||
originalLegacyPrices = Config.stripe.legacyPrices;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Config.stripe.prices = originalPrices;
|
||||
Config.stripe.legacyPrices = originalLegacyPrices;
|
||||
});
|
||||
|
||||
function buildRegistry(
|
||||
prices: typeof Config.stripe.prices,
|
||||
legacyPrices: typeof Config.stripe.legacyPrices,
|
||||
): ProductRegistry {
|
||||
Config.stripe.prices = prices;
|
||||
Config.stripe.legacyPrices = legacyPrices;
|
||||
return new ProductRegistry();
|
||||
}
|
||||
|
||||
describe('slot shapes', () => {
|
||||
test('a legacy monthly slot resolves to a monthly recurring product in its currency', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)).toEqual({
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'BRL',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
});
|
||||
|
||||
test('a legacy yearly slot resolves to a yearly recurring product in its currency', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
expect(registry.getProduct(LEGACY_YEARLY_BRL)).toEqual({
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'BRL',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
});
|
||||
|
||||
test('a legacy gift_1_month slot resolves to a one-month gift with no billing cycle', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
const info = registry.getProduct(LEGACY_GIFT_1_MONTH_BRL);
|
||||
expect(info).toEqual({
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'BRL',
|
||||
});
|
||||
expect(info?.billingCycle).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a legacy gift_1_year slot resolves to a one-year gift with no billing cycle', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
const info = registry.getProduct(LEGACY_GIFT_1_YEAR_BRL);
|
||||
expect(info).toEqual({
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'BRL',
|
||||
});
|
||||
expect(info?.billingCycle).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the currency comes from the slot suffix, not from the price id', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
// Same shape of id, different slot: only the slot suffix decides the currency.
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_TRY)?.currency).toBe('TRY');
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('every supported currency suffix is recognised', () => {
|
||||
const registry = buildRegistry(
|
||||
{},
|
||||
{
|
||||
monthly_usd: ['legacy_usd'],
|
||||
monthly_eur: ['legacy_eur'],
|
||||
monthly_brl: ['legacy_brl'],
|
||||
monthly_inr: ['legacy_inr'],
|
||||
monthly_pln: ['legacy_pln'],
|
||||
monthly_try: ['legacy_try'],
|
||||
},
|
||||
);
|
||||
expect(registry.getProduct('legacy_usd')?.currency).toBe('USD');
|
||||
expect(registry.getProduct('legacy_eur')?.currency).toBe('EUR');
|
||||
expect(registry.getProduct('legacy_brl')?.currency).toBe('BRL');
|
||||
expect(registry.getProduct('legacy_inr')?.currency).toBe('INR');
|
||||
expect(registry.getProduct('legacy_pln')?.currency).toBe('PLN');
|
||||
expect(registry.getProduct('legacy_try')?.currency).toBe('TRY');
|
||||
});
|
||||
|
||||
test('an uppercased currency suffix is still recognised', () => {
|
||||
const registry = buildRegistry({}, {monthly_BRL: ['legacy_upper_brl']});
|
||||
expect(registry.getProduct('legacy_upper_brl')?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('a slot may retire more than one price id', () => {
|
||||
const registry = buildRegistry({}, {monthly_brl: ['legacy_one', 'legacy_two', 'legacy_three']});
|
||||
expect(registry.getProduct('legacy_one')?.type).toBe(ProductType.MONTHLY_SUBSCRIPTION);
|
||||
expect(registry.getProduct('legacy_two')?.type).toBe(ProductType.MONTHLY_SUBSCRIPTION);
|
||||
expect(registry.getProduct('legacy_three')?.type).toBe(ProductType.MONTHLY_SUBSCRIPTION);
|
||||
});
|
||||
|
||||
test('legacy prices register even when no prices are configured at all', () => {
|
||||
const registry = buildRegistry(undefined, MOCK_LEGACY_PRICES);
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.type).toBe(ProductType.MONTHLY_SUBSCRIPTION);
|
||||
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)).toBeNull();
|
||||
});
|
||||
|
||||
test('an unknown price id still resolves to null', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
expect(registry.getProduct('price_never_seen')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('honoured but unpurchasable', () => {
|
||||
test('a legacy recurring price resolves through getProduct but is never the checkout price', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
|
||||
// Direction 1: the retired price is honoured on renewal.
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)).not.toBeNull();
|
||||
expect(registry.getProduct(LEGACY_YEARLY_BRL)).not.toBeNull();
|
||||
|
||||
// Direction 2: checkout only ever offers the authored list price.
|
||||
expect(registry.getRecurringSubscriptionPriceId('monthly', 'BRL')).toBe(MOCK_PRICES.monthlyBrl);
|
||||
expect(registry.getRecurringSubscriptionPriceId('monthly', 'BRL')).not.toBe(LEGACY_MONTHLY_BRL);
|
||||
expect(registry.getRecurringSubscriptionPriceId('yearly', 'BRL')).toBe(MOCK_PRICES.yearlyBrl);
|
||||
expect(registry.getRecurringSubscriptionPriceId('yearly', 'BRL')).not.toBe(LEGACY_YEARLY_BRL);
|
||||
});
|
||||
|
||||
test('a legacy gift price resolves through getProduct but is never the gift checkout price', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
|
||||
expect(registry.getProduct(LEGACY_GIFT_1_MONTH_BRL)).not.toBeNull();
|
||||
expect(registry.getProduct(LEGACY_GIFT_1_YEAR_BRL)).not.toBeNull();
|
||||
|
||||
expect(registry.getGiftPriceId('gift_1_month', 'BRL')).toBe(MOCK_PRICES.gift1MonthBrl);
|
||||
expect(registry.getGiftPriceId('gift_1_month', 'BRL')).not.toBe(LEGACY_GIFT_1_MONTH_BRL);
|
||||
expect(registry.getGiftPriceId('gift_1_year', 'BRL')).toBe(MOCK_PRICES.gift1YearBrl);
|
||||
expect(registry.getGiftPriceId('gift_1_year', 'BRL')).not.toBe(LEGACY_GIFT_1_YEAR_BRL);
|
||||
});
|
||||
|
||||
test('an archived price in a currency the instance does not configure renews but cannot be bought', () => {
|
||||
// The real production case: one live subscription sits on an archived TRY price that
|
||||
// production never configured. getProduct must resolve it; checkout must still refuse TRY.
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_TRY)).toEqual({
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'TRY',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
expect(registry.getRecurringSubscriptionPriceId('monthly', 'TRY')).toBeNull();
|
||||
expect(registry.getRecurringSubscriptionPriceId('yearly', 'TRY')).toBeNull();
|
||||
expect(registry.getGiftPriceId('gift_1_month', 'TRY')).toBeNull();
|
||||
expect(registry.getGiftPriceId('gift_1_year', 'TRY')).toBeNull();
|
||||
});
|
||||
|
||||
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', 'INR', 'PLN', 'TRY'];
|
||||
|
||||
const offered: Array<string> = [];
|
||||
for (const currency of currencies) {
|
||||
for (const cycle of ['monthly', 'yearly'] as const) {
|
||||
const priceId = registry.getRecurringSubscriptionPriceId(cycle, currency);
|
||||
if (priceId) offered.push(priceId);
|
||||
}
|
||||
for (const duration of ['gift_1_month', 'gift_1_year'] as const) {
|
||||
const priceId = registry.getGiftPriceId(duration, currency);
|
||||
if (priceId) offered.push(priceId);
|
||||
}
|
||||
}
|
||||
|
||||
expect(offered.length).toBeGreaterThan(0);
|
||||
expect(offered.filter((priceId) => legacyIds.has(priceId))).toEqual([]);
|
||||
// And every legacy id is still honoured by the registry.
|
||||
for (const legacyId of legacyIds) {
|
||||
expect(registry.getProduct(legacyId)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('a legacy recurring product is still classified as a recurring subscription', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
const recurring = registry.getProduct(LEGACY_MONTHLY_BRL);
|
||||
const gift = registry.getProduct(LEGACY_GIFT_1_YEAR_BRL);
|
||||
expect(recurring).not.toBeNull();
|
||||
expect(gift).not.toBeNull();
|
||||
expect(registry.isRecurringSubscription(recurring!)).toBe(true);
|
||||
expect(registry.isRecurringSubscription(gift!)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('precedence', () => {
|
||||
test('an authored price wins over a legacy entry claiming the same id', () => {
|
||||
// Deliberate collision: the live monthly BRL list price is also listed as a retired yearly
|
||||
// USD price. The authored ProductInfo must survive.
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
yearly_usd: [MOCK_PRICES.monthlyBrl],
|
||||
});
|
||||
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)).toEqual({
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'BRL',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
});
|
||||
|
||||
test('an authored gift price wins over a legacy entry claiming the same id', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
monthly_try: [MOCK_PRICES.gift1YearBrl],
|
||||
});
|
||||
expect(registry.getProduct(MOCK_PRICES.gift1YearBrl)).toEqual({
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'BRL',
|
||||
});
|
||||
});
|
||||
|
||||
test('the first legacy slot to claim an id wins over a later one', () => {
|
||||
const registry = buildRegistry(
|
||||
{},
|
||||
{
|
||||
monthly_brl: ['legacy_shared'],
|
||||
yearly_usd: ['legacy_shared'],
|
||||
},
|
||||
);
|
||||
expect(registry.getProduct('legacy_shared')).toEqual({
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'BRL',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed configuration', () => {
|
||||
test('a slot whose value is a bare string is ignored and the rest still registers', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
monthly_brl: 'price_legacy_bare_string' as unknown as Array<string>,
|
||||
yearly_brl: [LEGACY_YEARLY_BRL],
|
||||
});
|
||||
expect(registry.getProduct('price_legacy_bare_string')).toBeNull();
|
||||
// Not accidentally registered character by character either.
|
||||
expect(registry.getProduct('p')).toBeNull();
|
||||
expect(registry.getProduct(LEGACY_YEARLY_BRL)?.type).toBe(ProductType.YEARLY_SUBSCRIPTION);
|
||||
});
|
||||
|
||||
test('a slot with an unrecognised name is ignored and the rest still registers', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
weekly_brl: ['legacy_weekly'],
|
||||
visionary_usd: ['legacy_visionary'],
|
||||
brl: ['legacy_no_separator'],
|
||||
_brl: ['legacy_leading_separator'],
|
||||
yearly_brl: [LEGACY_YEARLY_BRL],
|
||||
});
|
||||
expect(registry.getProduct('legacy_weekly')).toBeNull();
|
||||
expect(registry.getProduct('legacy_visionary')).toBeNull();
|
||||
expect(registry.getProduct('legacy_no_separator')).toBeNull();
|
||||
expect(registry.getProduct('legacy_leading_separator')).toBeNull();
|
||||
expect(registry.getProduct(LEGACY_YEARLY_BRL)?.type).toBe(ProductType.YEARLY_SUBSCRIPTION);
|
||||
});
|
||||
|
||||
test('a slot with an unknown currency is ignored and the rest still registers', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
monthly_jpy: ['legacy_jpy'],
|
||||
gift_1_year_gbp: ['legacy_gbp'],
|
||||
monthly_brl: [LEGACY_MONTHLY_BRL],
|
||||
});
|
||||
expect(registry.getProduct('legacy_jpy')).toBeNull();
|
||||
expect(registry.getProduct('legacy_gbp')).toBeNull();
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('empty, blank and undefined entries inside a slot are skipped', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
monthly_brl: ['', undefined as unknown as string, LEGACY_MONTHLY_BRL],
|
||||
});
|
||||
expect(registry.getProduct('')).toBeNull();
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('an undefined slot value is ignored', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, {
|
||||
monthly_try: undefined,
|
||||
monthly_brl: [LEGACY_MONTHLY_BRL],
|
||||
});
|
||||
expect(registry.getProduct(LEGACY_MONTHLY_BRL)?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('an array-valued legacy map is ignored without throwing', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, [
|
||||
'price_legacy_array',
|
||||
] as unknown as typeof Config.stripe.legacyPrices);
|
||||
expect(registry.getProduct('price_legacy_array')).toBeNull();
|
||||
// The authored prices are untouched.
|
||||
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
|
||||
expect(registry.getRecurringSubscriptionPriceId('monthly', 'BRL')).toBe(MOCK_PRICES.monthlyBrl);
|
||||
});
|
||||
|
||||
test('a string-valued legacy map is ignored without throwing', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, 'nonsense' as unknown as typeof Config.stripe.legacyPrices);
|
||||
expect(registry.getProduct('nonsense')).toBeNull();
|
||||
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('a number-valued legacy map is ignored without throwing', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, 42 as unknown as typeof Config.stripe.legacyPrices);
|
||||
expect(registry.getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
|
||||
});
|
||||
|
||||
test('an absent or empty legacy map leaves the authored registry intact', () => {
|
||||
expect(buildRegistry(MOCK_PRICES, undefined).getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
|
||||
expect(buildRegistry(MOCK_PRICES, {}).getProduct(MOCK_PRICES.monthlyBrl)?.currency).toBe('BRL');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {PremiumFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {
|
||||
SelfServeRefundEligibilityResponse,
|
||||
SelfServeRefundResponse,
|
||||
@@ -9,10 +11,17 @@ import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {
|
||||
createMockWebhookPayload,
|
||||
createStripeApiHandlers,
|
||||
type StripeWebhookEventData,
|
||||
} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
import {UserRepository} from '../../user/repositories/UserRepository';
|
||||
import {setupSyncStripeWebhookWorker} from './StripeWebhookTestUtils';
|
||||
|
||||
const MOCK_CUSTOMER_ID = 'cus_self_serve_refund';
|
||||
const MOCK_SUBSCRIPTION_ID = 'sub_self_serve_refund';
|
||||
@@ -107,6 +116,7 @@ function invoiceListHandler(invoices: ReadonlyArray<MockStripeInvoice>) {
|
||||
}
|
||||
|
||||
function refundCreateHandler(opts?: {
|
||||
refundId?: string;
|
||||
status?: 'succeeded' | 'pending' | 'failed';
|
||||
failureReason?: string;
|
||||
onRequest?: (idempotencyKey: string | null) => void;
|
||||
@@ -121,7 +131,7 @@ function refundCreateHandler(opts?: {
|
||||
if (match) metadata[match[1]] = value as string;
|
||||
}
|
||||
return HttpResponse.json({
|
||||
id: 're_test_self_serve',
|
||||
id: opts?.refundId ?? 're_test_self_serve',
|
||||
object: 'refund',
|
||||
amount: Number.parseInt((params.amount as string) ?? '0', 10),
|
||||
currency: 'usd',
|
||||
@@ -156,7 +166,27 @@ describe('StripeRefundService self-serve refund', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
setupSyncStripeWebhookWorker();
|
||||
});
|
||||
function createWebhookSignature(payload: string, timestamp: number, secret: string): string {
|
||||
const signedPayload = `${timestamp}.${payload}`;
|
||||
const signature = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
|
||||
return `t=${timestamp},v1=${signature}`;
|
||||
}
|
||||
async function sendWebhook(eventData: StripeWebhookEventData): Promise<{
|
||||
received: boolean;
|
||||
}> {
|
||||
const {payload, timestamp} = createMockWebhookPayload(eventData);
|
||||
const signature = createWebhookSignature(payload, timestamp, Config.stripe.webhookSecret!);
|
||||
return createBuilder<{
|
||||
received: boolean;
|
||||
}>(harness, '')
|
||||
.post('/stripe/webhook')
|
||||
.header('stripe-signature', signature)
|
||||
.header('content-type', 'application/json')
|
||||
.body(payload)
|
||||
.execute();
|
||||
}
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
@@ -345,6 +375,102 @@ describe('StripeRefundService self-serve refund', () => {
|
||||
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
test('counts one self-serve refund once even when charge.refunded arrives afterwards and is retried', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
const invoice = buildInvoice({
|
||||
id: 'in_cross_path',
|
||||
paidAtSecondsAgo: SECONDS_PER_DAY,
|
||||
subscriptionId: null,
|
||||
});
|
||||
server.use(invoiceListHandler([invoice]), refundCreateHandler({refundId: 're_cross_path'}));
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
|
||||
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
|
||||
.post('/premium/refund-latest')
|
||||
.execute();
|
||||
expect(response.refund_id).toBe('re_cross_path');
|
||||
expect(response.status).toBe('succeeded');
|
||||
const userRepository = new UserRepository();
|
||||
const afterSelfServe = await userRepository.findUnique(userId);
|
||||
expect(afterSelfServe!.firstRefundAt).not.toBeNull();
|
||||
expect(afterSelfServe!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
const chargeEvent = {
|
||||
type: 'charge.refunded' as const,
|
||||
data: {
|
||||
object: {
|
||||
id: invoice.chargeId,
|
||||
payment_intent: invoice.paymentIntentId,
|
||||
customer: MOCK_CUSTOMER_ID,
|
||||
amount_refunded: 2500,
|
||||
refunds: {
|
||||
object: 'list',
|
||||
has_more: false,
|
||||
url: `/v1/charges/${invoice.chargeId}/refunds`,
|
||||
data: [
|
||||
{
|
||||
id: 're_cross_path',
|
||||
object: 'refund',
|
||||
charge: invoice.chargeId,
|
||||
payment_intent: invoice.paymentIntentId,
|
||||
amount: 2500,
|
||||
currency: 'usd',
|
||||
status: 'succeeded',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId,
|
||||
invoice_id: invoice.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await sendWebhook({...chargeEvent, id: 'evt_cross_path_1'});
|
||||
await sendWebhook({...chargeEvent, id: 'evt_cross_path_2'});
|
||||
const afterWebhook = await userRepository.findUnique(userId);
|
||||
expect(afterWebhook!.firstRefundAt!.getTime()).toBe(afterSelfServe!.firstRefundAt!.getTime());
|
||||
expect(afterWebhook!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
});
|
||||
test('counts one self-serve refund once even when refund.updated confirms it after the endpoint already did', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
const invoice = buildInvoice({
|
||||
id: 'in_double_confirm',
|
||||
paidAtSecondsAgo: SECONDS_PER_DAY,
|
||||
subscriptionId: null,
|
||||
});
|
||||
server.use(invoiceListHandler([invoice]), refundCreateHandler({refundId: 're_double_confirm'}));
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
|
||||
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
|
||||
const userRepository = new UserRepository();
|
||||
const afterSelfServe = await userRepository.findUnique(userId);
|
||||
expect(afterSelfServe!.firstRefundAt).not.toBeNull();
|
||||
await sendWebhook({
|
||||
id: 'evt_double_confirm_1',
|
||||
type: 'refund.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: 're_double_confirm',
|
||||
object: 'refund',
|
||||
status: 'succeeded',
|
||||
amount: 2500,
|
||||
currency: 'usd',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId,
|
||||
invoice_id: invoice.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const afterWebhook = await userRepository.findUnique(userId);
|
||||
expect(afterWebhook!.firstRefundAt!.getTime()).toBe(afterSelfServe!.firstRefundAt!.getTime());
|
||||
expect(afterWebhook!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
});
|
||||
test('retries with a fresh idempotency key once a prior attempt has failed at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]));
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {
|
||||
ListPriceSwitchIneligibilityReason,
|
||||
PremiumStateResponse,
|
||||
SwitchToListPriceResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import Stripe from 'stripe';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID, type UserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createPwnedPasswordsRangeHandler} from '../../test/msw/handlers/PwnedPasswordsHandlers';
|
||||
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
import {STRIPE_API_VERSION} from '../StripeApiVersion';
|
||||
|
||||
const MOCK_PRICES = {
|
||||
monthlyUsd: 'price_list_monthly_usd',
|
||||
yearlyUsd: 'price_list_yearly_usd',
|
||||
monthlyBrl: 'price_list_monthly_brl',
|
||||
yearlyBrl: 'price_list_yearly_brl',
|
||||
};
|
||||
|
||||
const RETIRED_MONTHLY_BRL = 'price_retired_monthly_brl';
|
||||
const RETIRED_CHEAP_MONTHLY_BRL = 'price_retired_cheap_monthly_brl';
|
||||
const RETIRED_MONTHLY_TRY = 'price_retired_monthly_try';
|
||||
const RETIRED_EQUAL_MONTHLY_BRL = 'price_retired_equal_monthly_brl';
|
||||
|
||||
const LIST_MONTHLY_BRL_MINOR = 1890;
|
||||
const LIST_YEARLY_BRL_MINOR = 18900;
|
||||
const RETIRED_MONTHLY_BRL_MINOR = 2499;
|
||||
const RETIRED_CHEAP_MONTHLY_BRL_MINOR = 990;
|
||||
const RETIRED_MONTHLY_TRY_MINOR = 2999;
|
||||
|
||||
const MOCK_PRICE_SEEDS: Record<
|
||||
string,
|
||||
{
|
||||
unit_amount: number;
|
||||
currency: string;
|
||||
interval: 'month' | 'year';
|
||||
}
|
||||
> = {
|
||||
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month'},
|
||||
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year'},
|
||||
[MOCK_PRICES.monthlyBrl]: {unit_amount: LIST_MONTHLY_BRL_MINOR, currency: 'brl', interval: 'month'},
|
||||
[MOCK_PRICES.yearlyBrl]: {unit_amount: LIST_YEARLY_BRL_MINOR, currency: 'brl', interval: 'year'},
|
||||
[RETIRED_MONTHLY_BRL]: {unit_amount: RETIRED_MONTHLY_BRL_MINOR, currency: 'brl', interval: 'month'},
|
||||
[RETIRED_CHEAP_MONTHLY_BRL]: {unit_amount: RETIRED_CHEAP_MONTHLY_BRL_MINOR, currency: 'brl', interval: 'month'},
|
||||
[RETIRED_MONTHLY_TRY]: {unit_amount: RETIRED_MONTHLY_TRY_MINOR, currency: 'try', interval: 'month'},
|
||||
[RETIRED_EQUAL_MONTHLY_BRL]: {unit_amount: LIST_MONTHLY_BRL_MINOR, currency: 'brl', interval: 'month'},
|
||||
};
|
||||
|
||||
interface SubscriberFixture {
|
||||
subscriptionId: string;
|
||||
priceId: string;
|
||||
status?: 'active' | 'trialing' | 'past_due';
|
||||
cancelAt?: number | null;
|
||||
cancelAtPeriodEnd?: boolean;
|
||||
periodStart?: number;
|
||||
periodEnd?: number;
|
||||
}
|
||||
|
||||
interface ResolvedSubscriber {
|
||||
token: string;
|
||||
userId: UserID;
|
||||
subscriptionId: string;
|
||||
priceId: string;
|
||||
periodStart: number;
|
||||
periodEnd: number;
|
||||
}
|
||||
|
||||
describe('StripeSubscriptionListPriceSwitch', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let stripeHandlers: StripeApiHandlers;
|
||||
let originalPrices: typeof Config.stripe.prices | undefined;
|
||||
|
||||
function applyStripeHandlers(fixture: Required<SubscriberFixture>): void {
|
||||
const seed = MOCK_PRICE_SEEDS[fixture.priceId];
|
||||
if (!seed) {
|
||||
throw new Error(`Missing explicit price seed for ${fixture.priceId}`);
|
||||
}
|
||||
stripeHandlers = createStripeApiHandlers({
|
||||
subscriptions: {
|
||||
[fixture.subscriptionId]: {
|
||||
customer: `cus_${fixture.subscriptionId}`,
|
||||
price_id: fixture.priceId,
|
||||
unit_amount: seed.unit_amount,
|
||||
currency: seed.currency,
|
||||
interval: seed.interval,
|
||||
item_id: `si_${fixture.subscriptionId}`,
|
||||
current_period_start: fixture.periodStart,
|
||||
current_period_end: fixture.periodEnd,
|
||||
status: fixture.status,
|
||||
cancel_at: fixture.cancelAt,
|
||||
cancel_at_period_end: fixture.cancelAtPeriodEnd,
|
||||
},
|
||||
},
|
||||
prices: MOCK_PRICE_SEEDS,
|
||||
});
|
||||
server.use(...stripeHandlers.handlers, createPwnedPasswordsRangeHandler());
|
||||
}
|
||||
|
||||
async function mirrorStripeState(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
const stripe = new Stripe(Config.stripe.secretKey ?? 'sk_test_fluxer', {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
httpClient: Stripe.createFetchHttpClient(),
|
||||
});
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {expand: ['items.data.price']});
|
||||
await getBillingRepository().subscriptions.upsertFromStripe(subscription, {
|
||||
knownUserId: userId,
|
||||
snapshotCapturedAt: new Date(),
|
||||
});
|
||||
for (const priceId of Object.keys(MOCK_PRICE_SEEDS)) {
|
||||
const price = await stripe.prices.retrieve(priceId);
|
||||
await getBillingRepository().prices.upsertFromStripe(price);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSubscriber(fixture: SubscriberFixture): Promise<ResolvedSubscriber> {
|
||||
const periodStart = fixture.periodStart ?? Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60;
|
||||
const periodEnd = fixture.periodEnd ?? periodStart + 30 * 24 * 60 * 60;
|
||||
const resolved: Required<SubscriberFixture> = {
|
||||
subscriptionId: fixture.subscriptionId,
|
||||
priceId: fixture.priceId,
|
||||
status: fixture.status ?? 'active',
|
||||
cancelAt: fixture.cancelAt ?? null,
|
||||
cancelAtPeriodEnd: fixture.cancelAtPeriodEnd ?? false,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
};
|
||||
const account = await createTestAccount(harness);
|
||||
applyStripeHandlers(resolved);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({
|
||||
stripe_subscription_id: resolved.subscriptionId,
|
||||
premium_type: 1,
|
||||
premium_billing_cycle: MOCK_PRICE_SEEDS[resolved.priceId]?.interval === 'year' ? 'yearly' : 'monthly',
|
||||
premium_until: new Date(periodEnd * 1000).toISOString(),
|
||||
premium_will_cancel: Boolean(resolved.cancelAt) || resolved.cancelAtPeriodEnd,
|
||||
})
|
||||
.execute();
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await mirrorStripeState(userId, resolved.subscriptionId);
|
||||
return {
|
||||
token: account.token,
|
||||
userId,
|
||||
subscriptionId: resolved.subscriptionId,
|
||||
priceId: resolved.priceId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
};
|
||||
}
|
||||
|
||||
function switchToListPrice(subscriber: ResolvedSubscriber): Promise<SwitchToListPriceResponse> {
|
||||
return createBuilder<SwitchToListPriceResponse>(harness, subscriber.token)
|
||||
.post('/premium/switch-to-list-price')
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
|
||||
function getPremiumState(subscriber: ResolvedSubscriber): Promise<PremiumStateResponse> {
|
||||
return createBuilder<PremiumStateResponse>(harness, subscriber.token).get('/premium/state').expect(200).execute();
|
||||
}
|
||||
|
||||
function expectNoStripeWrites(): void {
|
||||
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.cancelledSubscriptions).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.releasedSubscriptionSchedules).toHaveLength(0);
|
||||
}
|
||||
|
||||
async function expectRefusal(
|
||||
subscriber: ResolvedSubscriber,
|
||||
reason: ListPriceSwitchIneligibilityReason,
|
||||
): Promise<void> {
|
||||
const result = await switchToListPrice(subscriber);
|
||||
expect(result).toEqual({status: 'ineligible', reason});
|
||||
const state = await getPremiumState(subscriber);
|
||||
expect(state.billing.list_price_switch.available).toBe(false);
|
||||
expect(state.billing.list_price_switch.reason).toBe(reason);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
originalPrices = Config.stripe.prices;
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
harness = await createApiTestHarness();
|
||||
stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
Config.stripe.prices = originalPrices;
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.resetData();
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
stripeHandlers.resetAll();
|
||||
server.use(...stripeHandlers.handlers, createPwnedPasswordsRangeHandler());
|
||||
});
|
||||
|
||||
describe('POST /premium/switch-to-list-price', () => {
|
||||
test('schedules a grandfathered BRL monthly subscriber onto the current list price at period end', async () => {
|
||||
const moneyMoves: Array<string> = [];
|
||||
server.use(
|
||||
http.post('https://api.stripe.com/v1/invoices', () => {
|
||||
moneyMoves.push('invoices.create');
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.post('https://api.stripe.com/v1/charges', () => {
|
||||
moneyMoves.push('charges.create');
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.post('https://api.stripe.com/v1/payment_intents', () => {
|
||||
moneyMoves.push('payment_intents.create');
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
);
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_grandfathered',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
});
|
||||
const result = await switchToListPrice(subscriber);
|
||||
expect(result).toEqual({
|
||||
status: 'scheduled',
|
||||
effective_at: new Date(subscriber.periodEnd * 1000).toISOString(),
|
||||
target_price_id: MOCK_PRICES.monthlyBrl,
|
||||
target_amount_minor: LIST_MONTHLY_BRL_MINOR,
|
||||
current_amount_minor: RETIRED_MONTHLY_BRL_MINOR,
|
||||
currency: 'BRL',
|
||||
});
|
||||
expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(1);
|
||||
expect(stripeHandlers.spies.createdSubscriptionSchedules[0]?.from_subscription).toBe('sub_brl_grandfathered');
|
||||
expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(1);
|
||||
const scheduleUpdate = stripeHandlers.spies.updatedSubscriptionSchedules[0];
|
||||
expect(scheduleUpdate?.params.end_behavior).toBe('release');
|
||||
expect(scheduleUpdate?.params.proration_behavior).toBe('none');
|
||||
expect(scheduleUpdate?.params.phases).toHaveLength(2);
|
||||
expect(scheduleUpdate?.params.phases?.[0]?.end_date).toBe(String(subscriber.periodEnd));
|
||||
expect(scheduleUpdate?.params.phases?.[0]?.items?.[0]?.price).toBe(RETIRED_MONTHLY_BRL);
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.start_date).toBe(String(subscriber.periodEnd));
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.items?.[0]?.price).toBe(MOCK_PRICES.monthlyBrl);
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.proration_behavior).toBe('none');
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.billing_cycle_anchor).toBeUndefined();
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.add_invoice_items).toBeUndefined();
|
||||
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
|
||||
expect(moneyMoves).toEqual([]);
|
||||
const me = await createBuilder<{
|
||||
premium_will_cancel: boolean;
|
||||
}>(harness, subscriber.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_will_cancel).toBe(false);
|
||||
const state = await getPremiumState(subscriber);
|
||||
expect(state.billing.pending_subscription_change).toBeNull();
|
||||
expect(state.billing.list_price_switch).toEqual(
|
||||
expect.objectContaining({
|
||||
available: false,
|
||||
reason: null,
|
||||
pending: true,
|
||||
effective_at: new Date(subscriber.periodEnd * 1000).toISOString(),
|
||||
current_price_id: RETIRED_MONTHLY_BRL,
|
||||
current_amount_minor: RETIRED_MONTHLY_BRL_MINOR,
|
||||
list_price_id: MOCK_PRICES.monthlyBrl,
|
||||
list_amount_minor: LIST_MONTHLY_BRL_MINOR,
|
||||
currency: 'BRL',
|
||||
billing_cycle: 'monthly',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves the phase-level settings Stripe would otherwise unset on the live subscription', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_phase_settings',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
});
|
||||
const scheduleId = 'sub_sched_phase_settings';
|
||||
const richSchedule = {
|
||||
id: scheduleId,
|
||||
object: 'subscription_schedule',
|
||||
status: 'active',
|
||||
subscription: subscriber.subscriptionId,
|
||||
end_behavior: 'release',
|
||||
metadata: {},
|
||||
current_phase: {start_date: subscriber.periodStart, end_date: subscriber.periodEnd},
|
||||
phases: [
|
||||
{
|
||||
start_date: subscriber.periodStart,
|
||||
end_date: subscriber.periodEnd,
|
||||
currency: 'brl',
|
||||
collection_method: 'send_invoice',
|
||||
description: 'Grandfathered Plutonium',
|
||||
metadata: {campaign: 'brl_2026'},
|
||||
discounts: [{coupon: {id: 'co_grandfather_10'}, discount: null, promotion_code: null}],
|
||||
default_tax_rates: [{id: 'txr_br_icms'}],
|
||||
automatic_tax: {enabled: true, disabled_reason: null, liability: {type: 'self'}},
|
||||
invoice_settings: {account_tax_ids: null, days_until_due: 14, issuer: null},
|
||||
billing_cycle_anchor: 'automatic',
|
||||
add_invoice_items: [],
|
||||
items: [
|
||||
{
|
||||
price: RETIRED_MONTHLY_BRL,
|
||||
quantity: 1,
|
||||
metadata: {seat: 'primary'},
|
||||
discounts: [{coupon: null, discount: 'di_existing', promotion_code: null}],
|
||||
tax_rates: [{id: 'txr_br_iss'}],
|
||||
billing_thresholds: null,
|
||||
},
|
||||
],
|
||||
proration_behavior: 'none',
|
||||
},
|
||||
],
|
||||
livemode: false,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
const scheduleUpdates: Array<Record<string, string>> = [];
|
||||
server.use(
|
||||
http.post('https://api.stripe.com/v1/subscription_schedules', () => HttpResponse.json(richSchedule)),
|
||||
http.post(`https://api.stripe.com/v1/subscription_schedules/${scheduleId}`, async ({request}) => {
|
||||
const formData = await request.formData();
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
entries[key] = String(value);
|
||||
}
|
||||
scheduleUpdates.push(entries);
|
||||
return HttpResponse.json(richSchedule);
|
||||
}),
|
||||
);
|
||||
const result = await switchToListPrice(subscriber);
|
||||
expect(result.status).toBe('scheduled');
|
||||
expect(scheduleUpdates).toHaveLength(1);
|
||||
const update = scheduleUpdates[0]!;
|
||||
expect(update['phases[0][items][0][price]']).toBe(RETIRED_MONTHLY_BRL);
|
||||
expect(update['phases[0][discounts][0][coupon]']).toBe('co_grandfather_10');
|
||||
expect(update['phases[0][default_tax_rates][0]']).toBe('txr_br_icms');
|
||||
expect(update['phases[0][items][0][tax_rates][0]']).toBe('txr_br_iss');
|
||||
expect(update['phases[0][items][0][discounts][0][discount]']).toBe('di_existing');
|
||||
expect(update['phases[0][items][0][metadata][seat]']).toBe('primary');
|
||||
expect(update['phases[0][metadata][campaign]']).toBe('brl_2026');
|
||||
expect(update['phases[0][collection_method]']).toBe('send_invoice');
|
||||
expect(update['phases[0][description]']).toBe('Grandfathered Plutonium');
|
||||
expect(update['phases[0][currency]']).toBe('brl');
|
||||
expect(update['phases[0][automatic_tax][enabled]']).toBe('true');
|
||||
expect(update['phases[0][automatic_tax][liability][type]']).toBe('self');
|
||||
expect(update['phases[0][invoice_settings][days_until_due]']).toBe('14');
|
||||
expect(update['phases[0][billing_cycle_anchor]']).toBe('automatic');
|
||||
expect(update['phases[0][end_date]']).toBe(String(subscriber.periodEnd));
|
||||
expect(update['phases[1][items][0][price]']).toBe(MOCK_PRICES.monthlyBrl);
|
||||
expect(update['phases[1][discounts][0][coupon]']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('refuses to move a subscriber up to a more expensive list price', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_below_list',
|
||||
priceId: RETIRED_CHEAP_MONTHLY_BRL,
|
||||
});
|
||||
await expectRefusal(subscriber, 'not_a_price_decrease');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
|
||||
test('refuses a subscriber who is cancelling at period end', async () => {
|
||||
const periodStart = Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60;
|
||||
const periodEnd = periodStart + 30 * 24 * 60 * 60;
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_cancel_at_period_end',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
cancelAtPeriodEnd: true,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
});
|
||||
await expectRefusal(subscriber, 'subscription_cancelling');
|
||||
expectNoStripeWrites();
|
||||
const me = await createBuilder<{
|
||||
premium_will_cancel: boolean;
|
||||
}>(harness, subscriber.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_will_cancel).toBe(true);
|
||||
});
|
||||
|
||||
test('refuses a subscriber who is cancelling on an explicit cancel_at date', async () => {
|
||||
const periodStart = Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60;
|
||||
const periodEnd = periodStart + 30 * 24 * 60 * 60;
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_cancel_at',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
cancelAt: periodEnd,
|
||||
cancelAtPeriodEnd: false,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
});
|
||||
await expectRefusal(subscriber, 'subscription_cancelling');
|
||||
expectNoStripeWrites();
|
||||
const me = await createBuilder<{
|
||||
premium_will_cancel: boolean;
|
||||
}>(harness, subscriber.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_will_cancel).toBe(true);
|
||||
});
|
||||
|
||||
test('is idempotent and reports an already scheduled switch on the second call', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_idempotent',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
});
|
||||
const first = await switchToListPrice(subscriber);
|
||||
expect(first.status).toBe('scheduled');
|
||||
const second = await switchToListPrice(subscriber);
|
||||
expect(second).toEqual({
|
||||
status: 'already_scheduled',
|
||||
effective_at: new Date(subscriber.periodEnd * 1000).toISOString(),
|
||||
target_price_id: MOCK_PRICES.monthlyBrl,
|
||||
target_amount_minor: LIST_MONTHLY_BRL_MINOR,
|
||||
current_amount_minor: RETIRED_MONTHLY_BRL_MINOR,
|
||||
currency: 'BRL',
|
||||
});
|
||||
expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(1);
|
||||
expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('refuses a retired price that costs exactly the same as the list price', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_equal_price',
|
||||
priceId: RETIRED_EQUAL_MONTHLY_BRL,
|
||||
});
|
||||
await expectRefusal(subscriber, 'not_a_price_decrease');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
|
||||
test('lets a scheduled switch be withdrawn again and restores eligibility', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_withdrawn',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
});
|
||||
expect((await switchToListPrice(subscriber)).status).toBe('scheduled');
|
||||
await createBuilder(harness, subscriber.token)
|
||||
.post('/premium/cancel-pending-subscription-change')
|
||||
.expect(204)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.releasedSubscriptionSchedules).toHaveLength(1);
|
||||
expect(stripeHandlers.spies.releasedSubscriptionSchedules[0]?.params.preserve_cancel_date).toBe('false');
|
||||
const state = await getPremiumState(subscriber);
|
||||
expect(state.billing.pending_subscription_change).toBeNull();
|
||||
expect(state.billing.list_price_switch).toEqual(
|
||||
expect.objectContaining({
|
||||
available: true,
|
||||
reason: null,
|
||||
pending: false,
|
||||
current_price_id: RETIRED_MONTHLY_BRL,
|
||||
current_amount_minor: RETIRED_MONTHLY_BRL_MINOR,
|
||||
list_price_id: MOCK_PRICES.monthlyBrl,
|
||||
list_amount_minor: LIST_MONTHLY_BRL_MINOR,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a subscriber who is already on the current list price', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_on_list_price',
|
||||
priceId: MOCK_PRICES.monthlyBrl,
|
||||
});
|
||||
await expectRefusal(subscriber, 'already_on_list_price');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
|
||||
test('refuses a currency that has no configured list price', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_try_unconfigured',
|
||||
priceId: RETIRED_MONTHLY_TRY,
|
||||
});
|
||||
await expectRefusal(subscriber, 'no_list_price');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
|
||||
test('refuses a subscription that is not chargeable', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_past_due',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
status: 'past_due',
|
||||
});
|
||||
await expectRefusal(subscriber, 'subscription_not_chargeable');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
|
||||
test('refuses a subscriber whose cancellation is managed by a subscription schedule', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_schedule_cancel',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
});
|
||||
await createBuilder(harness, subscriber.token)
|
||||
.post('/premium/change-subscription')
|
||||
.body({billing_cycle: 'yearly', effective_at: 'period_end'})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await createBuilder(harness, subscriber.token).post('/premium/cancel-subscription').expect(204).execute();
|
||||
stripeHandlers.spies.updatedSubscriptions.length = 0;
|
||||
stripeHandlers.spies.createdSubscriptionSchedules.length = 0;
|
||||
stripeHandlers.spies.updatedSubscriptionSchedules.length = 0;
|
||||
stripeHandlers.spies.releasedSubscriptionSchedules.length = 0;
|
||||
const result = await switchToListPrice(subscriber);
|
||||
expect(result).toEqual({status: 'ineligible', reason: 'subscription_cancelling'});
|
||||
expectNoStripeWrites();
|
||||
const me = await createBuilder<{
|
||||
premium_will_cancel: boolean;
|
||||
}>(harness, subscriber.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_will_cancel).toBe(true);
|
||||
});
|
||||
|
||||
test('refuses when a billing cycle change is already pending', async () => {
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_pending_cycle_change',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
});
|
||||
await createBuilder(harness, subscriber.token)
|
||||
.post('/premium/change-subscription')
|
||||
.body({billing_cycle: 'yearly', effective_at: 'period_end'})
|
||||
.expect(204)
|
||||
.execute();
|
||||
stripeHandlers.spies.updatedSubscriptions.length = 0;
|
||||
stripeHandlers.spies.createdSubscriptionSchedules.length = 0;
|
||||
stripeHandlers.spies.updatedSubscriptionSchedules.length = 0;
|
||||
await expectRefusal(subscriber, 'conflicting_pending_change');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
|
||||
test('rejects a user without an active subscription and mirrors that in premium state', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/premium/switch-to-list-price')
|
||||
.expect(400, APIErrorCodes.STRIPE_NO_ACTIVE_SUBSCRIPTION)
|
||||
.execute();
|
||||
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
|
||||
.get('/premium/state')
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(state.billing.list_price_switch.available).toBe(false);
|
||||
expect(state.billing.list_price_switch.reason).toBe('no_active_subscription');
|
||||
expectNoStripeWrites();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /premium/change-subscription (unchanged behaviour)', () => {
|
||||
test('still clears a pending cancellation and re-anchors billing when the cycle actually changes', async () => {
|
||||
const periodStart = Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60;
|
||||
const periodEnd = periodStart + 30 * 24 * 60 * 60;
|
||||
const subscriber = await createSubscriber({
|
||||
subscriptionId: 'sub_brl_cycle_change',
|
||||
priceId: RETIRED_MONTHLY_BRL,
|
||||
cancelAt: periodEnd,
|
||||
cancelAtPeriodEnd: false,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
});
|
||||
await createBuilder(harness, subscriber.token)
|
||||
.post('/premium/change-subscription')
|
||||
.body({billing_cycle: 'yearly', effective_at: 'period_end'})
|
||||
.expect(204)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(1);
|
||||
const clearCancelUpdate = stripeHandlers.spies.updatedSubscriptions[0];
|
||||
expect(clearCancelUpdate?.id).toBe('sub_brl_cycle_change');
|
||||
expect(clearCancelUpdate?.params.cancel_at).toBe('');
|
||||
expect(clearCancelUpdate?.params.proration_behavior).toBe('none');
|
||||
expect(clearCancelUpdate?.params.items).toBeUndefined();
|
||||
expect(stripeHandlers.spies.updatedSubscriptionSchedules).toHaveLength(1);
|
||||
const scheduleUpdate = stripeHandlers.spies.updatedSubscriptionSchedules[0];
|
||||
expect(scheduleUpdate?.params.end_behavior).toBe('release');
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.start_date).toBe(String(periodEnd));
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.billing_cycle_anchor).toBe('phase_start');
|
||||
expect(scheduleUpdate?.params.phases?.[1]?.items?.[0]?.price).toBe(MOCK_PRICES.yearlyBrl);
|
||||
const me = await createBuilder<{
|
||||
premium_will_cancel: boolean;
|
||||
}>(harness, subscriber.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_will_cancel).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,14 @@ import crypto from 'node:crypto';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {NoopLogger} from '../../test/mocks/NoopLogger';
|
||||
import {
|
||||
createInvoiceFinalizationFailedEvent,
|
||||
createInvoicePaidEvent,
|
||||
@@ -41,16 +43,24 @@ const MOCK_PRICES = {
|
||||
gift1YearEur: 'price_gift_1_year_eur',
|
||||
};
|
||||
|
||||
const LEGACY_MONTHLY_BRL_PRICE = 'price_legacy_monthly_brl';
|
||||
const LEGACY_YEARLY_BRL_PRICE = 'price_legacy_yearly_brl';
|
||||
const UNMAPPED_PRICE = 'price_retired_unmapped_brl';
|
||||
const MANDATE_REVOKED_WARNING =
|
||||
'Stripe mandate is no longer active; recurring payments on this payment method will fail';
|
||||
|
||||
describe('Stripe Webhook - Invoice Events', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let stripeHandlers: StripeApiHandlers;
|
||||
let originalWebhookSecret: string | undefined;
|
||||
let originalPrices: typeof Config.stripe.prices | undefined;
|
||||
let originalLegacyPrices: typeof Config.stripe.legacyPrices | undefined;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
setupSyncStripeWebhookWorker();
|
||||
originalWebhookSecret = Config.stripe.webhookSecret;
|
||||
originalPrices = Config.stripe.prices;
|
||||
originalLegacyPrices = Config.stripe.legacyPrices;
|
||||
Config.stripe.webhookSecret = 'whsec_test_secret';
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
stripeHandlers = createStripeApiHandlers();
|
||||
@@ -60,9 +70,11 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
await harness.shutdown();
|
||||
Config.stripe.webhookSecret = originalWebhookSecret;
|
||||
Config.stripe.prices = originalPrices;
|
||||
Config.stripe.legacyPrices = originalLegacyPrices;
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.resetData();
|
||||
Config.stripe.legacyPrices = undefined;
|
||||
stripeHandlers.reset();
|
||||
server.use(...stripeHandlers.handlers);
|
||||
});
|
||||
@@ -104,7 +116,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
subscriptionId: string;
|
||||
priceId: string;
|
||||
productType: string;
|
||||
}): Promise<void> {
|
||||
}): Promise<string> {
|
||||
const {userId, subscriptionId, priceId, productType} = params;
|
||||
const checkoutSessionId = `cs_test_${crypto.randomUUID()}`;
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
@@ -127,6 +139,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
currency: 'usd',
|
||||
completed_at: new Date(),
|
||||
});
|
||||
return checkoutSessionId;
|
||||
}
|
||||
async function setSubscriptionUserState(params: {
|
||||
accountUserId: string;
|
||||
@@ -856,4 +869,365 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
expect(me.premium_until).not.toBeNull();
|
||||
});
|
||||
});
|
||||
describe('renewals on retired prices', () => {
|
||||
test('renews a subscription whose price is only known to the legacy price map', async () => {
|
||||
Config.stripe.legacyPrices = {monthly_brl: [LEGACY_MONTHLY_BRL_PRICE]};
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = 'sub_legacy_brl_renewal';
|
||||
const customerId = 'cus_legacy_brl_renewal';
|
||||
const invoiceId = 'in_legacy_brl_renewal';
|
||||
const checkoutSessionId = await createPaymentRecord({
|
||||
userId: account.userId,
|
||||
subscriptionId,
|
||||
priceId: LEGACY_MONTHLY_BRL_PRICE,
|
||||
productType: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
});
|
||||
const currentPeriodStart = Math.floor(Date.now() / 1000) - 2 * 24 * 60 * 60;
|
||||
server.use(
|
||||
...createStripeApiHandlers({
|
||||
subscriptions: {
|
||||
[subscriptionId]: {
|
||||
customer: customerId,
|
||||
price_id: LEGACY_MONTHLY_BRL_PRICE,
|
||||
currency: 'brl',
|
||||
interval: 'month',
|
||||
item_id: 'si_legacy_brl_renewal',
|
||||
current_period_start: currentPeriodStart,
|
||||
current_period_end: currentPeriodStart + 30 * 24 * 60 * 60,
|
||||
},
|
||||
},
|
||||
}).handlers,
|
||||
);
|
||||
const result = await sendWebhook({
|
||||
type: 'invoice.payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: invoiceId,
|
||||
billing_reason: 'subscription_cycle',
|
||||
customer: customerId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
const me = await createBuilder<{
|
||||
premium_billing_cycle: string | null;
|
||||
premium_type: number | null;
|
||||
premium_until: string | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
|
||||
expect(me.premium_billing_cycle).toBe('monthly');
|
||||
expect(me.premium_until).not.toBeNull();
|
||||
expect(new Date(me.premium_until!).toISOString()).toBe(
|
||||
new Date((currentPeriodStart + 30 * 24 * 60 * 60) * 1000).toISOString(),
|
||||
);
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const payment = await new PaymentRepository().getPaymentByCheckoutSession(checkoutSessionId);
|
||||
expect(payment?.invoiceId).toBe(invoiceId);
|
||||
});
|
||||
test('renews a subscription whose retired price sits in a legacy slot alongside other price ids', async () => {
|
||||
Config.stripe.legacyPrices = {
|
||||
monthly_brl: ['price_legacy_monthly_brl_older', LEGACY_MONTHLY_BRL_PRICE],
|
||||
yearly_brl: [LEGACY_YEARLY_BRL_PRICE],
|
||||
};
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = 'sub_legacy_brl_yearly_renewal';
|
||||
const customerId = 'cus_legacy_brl_yearly_renewal';
|
||||
await createPaymentRecord({
|
||||
userId: account.userId,
|
||||
subscriptionId,
|
||||
priceId: LEGACY_YEARLY_BRL_PRICE,
|
||||
productType: ProductType.YEARLY_SUBSCRIPTION,
|
||||
});
|
||||
const currentPeriodStart = Math.floor(Date.now() / 1000) - 2 * 24 * 60 * 60;
|
||||
server.use(
|
||||
...createStripeApiHandlers({
|
||||
subscriptions: {
|
||||
[subscriptionId]: {
|
||||
customer: customerId,
|
||||
price_id: LEGACY_YEARLY_BRL_PRICE,
|
||||
currency: 'brl',
|
||||
interval: 'year',
|
||||
item_id: 'si_legacy_brl_yearly_renewal',
|
||||
current_period_start: currentPeriodStart,
|
||||
current_period_end: currentPeriodStart + 365 * 24 * 60 * 60,
|
||||
},
|
||||
},
|
||||
}).handlers,
|
||||
);
|
||||
const result = await sendWebhook({
|
||||
type: 'invoice.payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: 'in_legacy_brl_yearly_renewal',
|
||||
billing_reason: 'subscription_cycle',
|
||||
customer: customerId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
const me = await createBuilder<{
|
||||
premium_billing_cycle: string | null;
|
||||
premium_type: number | null;
|
||||
premium_until: string | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
|
||||
expect(me.premium_billing_cycle).toBe('yearly');
|
||||
expect(new Date(me.premium_until!).toISOString()).toBe(
|
||||
new Date((currentPeriodStart + 365 * 24 * 60 * 60) * 1000).toISOString(),
|
||||
);
|
||||
});
|
||||
test('still rejects a renewal whose price is in neither the configured nor the legacy price map', async () => {
|
||||
Config.stripe.legacyPrices = {monthly_brl: [LEGACY_MONTHLY_BRL_PRICE]};
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = 'sub_unmapped_price_renewal';
|
||||
const customerId = 'cus_unmapped_price_renewal';
|
||||
await createPaymentRecord({
|
||||
userId: account.userId,
|
||||
subscriptionId,
|
||||
priceId: UNMAPPED_PRICE,
|
||||
productType: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
});
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({
|
||||
stripe_subscription_id: subscriptionId,
|
||||
stripe_customer_id: customerId,
|
||||
})
|
||||
.execute();
|
||||
const currentPeriodStart = Math.floor(Date.now() / 1000) - 2 * 24 * 60 * 60;
|
||||
server.use(
|
||||
...createStripeApiHandlers({
|
||||
subscriptions: {
|
||||
[subscriptionId]: {
|
||||
customer: customerId,
|
||||
price_id: UNMAPPED_PRICE,
|
||||
currency: 'brl',
|
||||
interval: 'month',
|
||||
item_id: 'si_unmapped_price_renewal',
|
||||
current_period_start: currentPeriodStart,
|
||||
current_period_end: currentPeriodStart + 30 * 24 * 60 * 60,
|
||||
},
|
||||
},
|
||||
}).handlers,
|
||||
);
|
||||
await sendWebhookExpectStripeError({
|
||||
type: 'invoice.payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: 'in_unmapped_price_renewal',
|
||||
billing_reason: 'subscription_cycle',
|
||||
customer: customerId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
});
|
||||
const me = await createBuilder<{
|
||||
premium_type: number | null;
|
||||
premium_until: string | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.NONE);
|
||||
expect(me.premium_until).toBeNull();
|
||||
});
|
||||
test('lets an authored price win over a legacy entry that claims the same price id', async () => {
|
||||
Config.stripe.legacyPrices = {yearly_brl: [MOCK_PRICES.monthlyUsd]};
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = 'sub_authored_wins_over_legacy';
|
||||
await createPaymentRecord({
|
||||
userId: account.userId,
|
||||
subscriptionId,
|
||||
priceId: MOCK_PRICES.monthlyUsd,
|
||||
productType: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
});
|
||||
const result = await sendWebhook({
|
||||
type: 'invoice.payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: 'in_authored_wins_over_legacy',
|
||||
billing_reason: 'subscription_cycle',
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
const me = await createBuilder<{
|
||||
premium_billing_cycle: string | null;
|
||||
premium_type: number | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
|
||||
expect(me.premium_billing_cycle).toBe('monthly');
|
||||
});
|
||||
});
|
||||
describe('donation subscription guard', () => {
|
||||
test('does not extend premium for an invoice on a donation subscription', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = 'sub_donation_recurring';
|
||||
await createPaymentRecord({
|
||||
userId: account.userId,
|
||||
subscriptionId,
|
||||
priceId: MOCK_PRICES.monthlyUsd,
|
||||
productType: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
});
|
||||
const {DonationRepository} = await import('../../donation/DonationRepository');
|
||||
await new DonationRepository().createDonor({
|
||||
email: '[email protected]',
|
||||
stripeCustomerId: 'cus_donation_recurring',
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
subscriptionAmountCents: 1000,
|
||||
subscriptionCurrency: 'usd',
|
||||
subscriptionInterval: 'month',
|
||||
subscriptionCurrentPeriodEnd: null,
|
||||
});
|
||||
const result = await sendWebhook({
|
||||
type: 'invoice.payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: 'in_donation_recurring',
|
||||
billing_reason: 'subscription_cycle',
|
||||
customer: 'cus_donation_recurring',
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
const me = await createBuilder<{
|
||||
premium_type: number | null;
|
||||
premium_until: string | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.NONE);
|
||||
expect(me.premium_until).toBeNull();
|
||||
});
|
||||
test('still renews when only the donor customer id matches and the subscription id does not', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const sharedCustomerId = 'cus_donor_and_subscriber';
|
||||
const premiumSubscriptionId = 'sub_premium_beside_donation';
|
||||
await createPaymentRecord({
|
||||
userId: account.userId,
|
||||
subscriptionId: premiumSubscriptionId,
|
||||
priceId: MOCK_PRICES.monthlyUsd,
|
||||
productType: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
});
|
||||
const {DonationRepository} = await import('../../donation/DonationRepository');
|
||||
await new DonationRepository().createDonor({
|
||||
email: '[email protected]',
|
||||
stripeCustomerId: sharedCustomerId,
|
||||
stripeSubscriptionId: 'sub_donation_beside_premium',
|
||||
subscriptionAmountCents: 1000,
|
||||
subscriptionCurrency: 'usd',
|
||||
subscriptionInterval: 'month',
|
||||
subscriptionCurrentPeriodEnd: null,
|
||||
});
|
||||
const result = await sendWebhook({
|
||||
type: 'invoice.payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: 'in_premium_beside_donation',
|
||||
billing_reason: 'subscription_cycle',
|
||||
customer: sharedCustomerId,
|
||||
parent: {subscription_details: {subscription: premiumSubscriptionId}},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
const me = await createBuilder<{
|
||||
premium_type: number | null;
|
||||
premium_until: string | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
|
||||
expect(me.premium_until).not.toBeNull();
|
||||
});
|
||||
});
|
||||
describe('mandate.updated', () => {
|
||||
function captureLoggerWarnings(): {
|
||||
messages: Array<unknown>;
|
||||
restore: () => void;
|
||||
} {
|
||||
const activeLogger = Logger.child({}) as unknown as NoopLogger;
|
||||
expect(activeLogger).toBeInstanceOf(NoopLogger);
|
||||
const messages: Array<unknown> = [];
|
||||
const spy = vi.spyOn(activeLogger, 'warn').mockImplementation((...args: Array<unknown>) => {
|
||||
messages.push(args[args.length - 1]);
|
||||
});
|
||||
return {messages, restore: () => spy.mockRestore()};
|
||||
}
|
||||
function revocationWarnings(messages: Array<unknown>): Array<unknown> {
|
||||
return messages.filter((message) => message === MANDATE_REVOKED_WARNING);
|
||||
}
|
||||
test('logs the revocation warning and leaves premium untouched when a mandate goes inactive', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const premiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({
|
||||
premium_type: UserPremiumTypes.SUBSCRIPTION,
|
||||
premium_until: premiumUntil.toISOString(),
|
||||
stripe_subscription_id: 'sub_pix_mandate',
|
||||
stripe_customer_id: 'cus_pix_mandate',
|
||||
})
|
||||
.execute();
|
||||
const warnings = captureLoggerWarnings();
|
||||
try {
|
||||
const result = await sendWebhook({
|
||||
type: 'mandate.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: 'mandate_test_inactive',
|
||||
object: 'mandate',
|
||||
status: 'inactive',
|
||||
payment_method: 'pm_pix_mandate',
|
||||
type: 'multi_use',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
expect(revocationWarnings(warnings.messages)).toHaveLength(1);
|
||||
} finally {
|
||||
warnings.restore();
|
||||
}
|
||||
const me = await createBuilder<{
|
||||
premium_type: number | null;
|
||||
premium_until: string | null;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
|
||||
expect(me.premium_until).toBe(premiumUntil.toISOString());
|
||||
});
|
||||
test('does not log the revocation warning for a pending or still-active mandate', async () => {
|
||||
const warnings = captureLoggerWarnings();
|
||||
try {
|
||||
for (const status of ['pending', 'active'] as const) {
|
||||
const result = await sendWebhook({
|
||||
type: 'mandate.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: `mandate_test_${status}`,
|
||||
object: 'mandate',
|
||||
status,
|
||||
payment_method: {id: 'pm_pix_mandate', object: 'payment_method', type: 'pix'},
|
||||
type: 'multi_use',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.received).toBe(true);
|
||||
}
|
||||
expect(revocationWarnings(warnings.messages)).toHaveLength(0);
|
||||
} finally {
|
||||
warnings.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,14 +49,14 @@ describe('Stripe Webhook Refund', () => {
|
||||
.body(payload)
|
||||
.execute();
|
||||
}
|
||||
function useRefundListHandler(chargeId: string): void {
|
||||
function useRefundListHandler(chargeId: string, refunds: Array<Record<string, unknown>> = []): void {
|
||||
server.use(
|
||||
http.get(
|
||||
({request}) => request.url === `https://api.stripe.com/v1/refunds?charge=${chargeId}&limit=100`,
|
||||
() =>
|
||||
HttpResponse.json({
|
||||
object: 'list',
|
||||
data: [],
|
||||
data: refunds,
|
||||
has_more: false,
|
||||
url: '/v1/refunds',
|
||||
}),
|
||||
@@ -155,6 +155,366 @@ describe('Stripe Webhook Refund', () => {
|
||||
expect(updatedPayment).not.toBeNull();
|
||||
expect(updatedPayment!.status).toBe('refunded');
|
||||
});
|
||||
test('counts a refund once when the same charge.refunded event is redelivered', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
const paymentIntentId = 'pi_test_refund_retry_123';
|
||||
const checkoutSessionId = 'cs_test_refund_retry_123';
|
||||
const chargeId = 'ch_test_refund_retry_123';
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
user_id: userId,
|
||||
price_id: 'price_test_monthly',
|
||||
product_type: 'monthly_subscription',
|
||||
status: 'completed',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
await paymentRepository.updatePayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
payment_intent_id: paymentIntentId,
|
||||
completed_at: new Date(),
|
||||
});
|
||||
useRefundListHandler(chargeId, [
|
||||
{
|
||||
id: 're_test_refund_retry_123',
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 2500,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
metadata: {},
|
||||
},
|
||||
]);
|
||||
const chargeEvent = {
|
||||
type: 'charge.refunded' as const,
|
||||
data: {
|
||||
object: {
|
||||
id: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount_refunded: 2500,
|
||||
},
|
||||
},
|
||||
};
|
||||
await sendWebhook({...chargeEvent, id: 'evt_test_refund_retry_1'});
|
||||
await sendWebhook({...chargeEvent, id: 'evt_test_refund_retry_2'});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).not.toBeNull();
|
||||
expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
});
|
||||
test('counts a refund once when charge.refunded lands before the refund record exists', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
const paymentIntentId = 'pi_test_refund_late_record';
|
||||
const checkoutSessionId = 'cs_test_refund_late_record';
|
||||
const chargeId = 'ch_test_refund_late_record';
|
||||
const refundId = 're_test_refund_late_record';
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
user_id: userId,
|
||||
price_id: 'price_test_monthly',
|
||||
product_type: 'monthly_subscription',
|
||||
status: 'completed',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
await paymentRepository.updatePayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
payment_intent_id: paymentIntentId,
|
||||
completed_at: new Date(),
|
||||
});
|
||||
const refund = {
|
||||
id: refundId,
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 2500,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
metadata: {},
|
||||
};
|
||||
const chargeEvent = {
|
||||
type: 'charge.refunded' as const,
|
||||
data: {
|
||||
object: {
|
||||
id: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount_refunded: 2500,
|
||||
},
|
||||
},
|
||||
};
|
||||
useRefundListHandler(chargeId);
|
||||
await sendWebhook({...chargeEvent, id: 'evt_test_refund_late_record_1'});
|
||||
const afterFallback = await userRepository.findUnique(userId);
|
||||
expect(afterFallback!.firstRefundAt).not.toBeNull();
|
||||
expect(afterFallback!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
await sendWebhook({
|
||||
id: 'evt_test_refund_late_record_2',
|
||||
type: 'refund.created',
|
||||
data: {object: refund},
|
||||
});
|
||||
useRefundListHandler(chargeId, [refund]);
|
||||
await sendWebhook({...chargeEvent, id: 'evt_test_refund_late_record_3'});
|
||||
const afterRecord = await userRepository.findUnique(userId);
|
||||
expect(afterRecord!.firstRefundAt!.getTime()).toBe(afterFallback!.firstRefundAt!.getTime());
|
||||
expect(afterRecord!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
});
|
||||
test('does not count a refund Fluxer issued itself against the refund allowance', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
const paymentIntentId = 'pi_test_refund_system_123';
|
||||
const checkoutSessionId = 'cs_test_refund_system_123';
|
||||
const chargeId = 'ch_test_refund_system_123';
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
user_id: userId,
|
||||
price_id: 'price_test_monthly',
|
||||
product_type: 'monthly_subscription',
|
||||
status: 'completed',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
await paymentRepository.updatePayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
payment_intent_id: paymentIntentId,
|
||||
completed_at: new Date(),
|
||||
});
|
||||
useRefundListHandler(chargeId, [
|
||||
{
|
||||
id: 're_test_refund_system_123',
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 2500,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
metadata: {rejection_reason: 'duplicate_active_subscription'},
|
||||
},
|
||||
]);
|
||||
await sendWebhook({
|
||||
type: 'charge.refunded',
|
||||
data: {
|
||||
object: {
|
||||
id: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount_refunded: 2500,
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
});
|
||||
test('does not count a refund Fluxer issued for a localized card country mismatch', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
const paymentIntentId = 'pi_test_refund_card_mismatch';
|
||||
const checkoutSessionId = 'cs_test_refund_card_mismatch';
|
||||
const chargeId = 'ch_test_refund_card_mismatch';
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
user_id: userId,
|
||||
price_id: 'price_test_monthly',
|
||||
product_type: 'monthly_subscription',
|
||||
status: 'completed',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
await paymentRepository.updatePayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
payment_intent_id: paymentIntentId,
|
||||
completed_at: new Date(),
|
||||
});
|
||||
useRefundListHandler(chargeId, [
|
||||
{
|
||||
id: 're_test_refund_card_mismatch',
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 2500,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
metadata: {rejection_reason: 'localized_card_country_mismatch'},
|
||||
},
|
||||
]);
|
||||
await sendWebhook({
|
||||
type: 'charge.refunded',
|
||||
data: {
|
||||
object: {
|
||||
id: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount_refunded: 2500,
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
expect(updatedUser!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
const updatedPayment = await userRepository.getPaymentByPaymentIntent(paymentIntentId);
|
||||
expect(updatedPayment!.status).toBe('refunded');
|
||||
});
|
||||
test('counts only the customer refund when Fluxer issued a later system refund on the same charge', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
const paymentIntentId = 'pi_test_refund_mixed';
|
||||
const checkoutSessionId = 'cs_test_refund_mixed';
|
||||
const chargeId = 'ch_test_refund_mixed';
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
user_id: userId,
|
||||
price_id: 'price_test_monthly',
|
||||
product_type: 'monthly_subscription',
|
||||
status: 'completed',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
await paymentRepository.updatePayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
payment_intent_id: paymentIntentId,
|
||||
completed_at: new Date(),
|
||||
});
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const chargeEvent = {
|
||||
type: 'charge.refunded' as const,
|
||||
data: {
|
||||
object: {
|
||||
id: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount_refunded: 2500,
|
||||
refunds: {
|
||||
object: 'list',
|
||||
has_more: false,
|
||||
url: `/v1/charges/${chargeId}/refunds`,
|
||||
data: [
|
||||
{
|
||||
id: 're_test_refund_mixed_customer',
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 1500,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: nowSeconds - 600,
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: 're_test_refund_mixed_system',
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 1000,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: nowSeconds,
|
||||
metadata: {rejection_reason: 'duplicate_active_subscription'},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await sendWebhook({...chargeEvent, id: 'evt_test_refund_mixed_1'});
|
||||
const afterFirst = await userRepository.findUnique(userId);
|
||||
expect(afterFirst!.firstRefundAt).not.toBeNull();
|
||||
expect(afterFirst!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
await sendWebhook({...chargeEvent, id: 'evt_test_refund_mixed_2'});
|
||||
const afterRetry = await userRepository.findUnique(userId);
|
||||
expect(afterRetry!.firstRefundAt!.getTime()).toBe(afterFirst!.firstRefundAt!.getTime());
|
||||
expect(afterRetry!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
});
|
||||
test('counts a second, distinct refund against the allowance after the first one was already counted', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const {PaymentRepository} = await import('../../user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
const paymentIntentId = 'pi_test_refund_ladder';
|
||||
const checkoutSessionId = 'cs_test_refund_ladder';
|
||||
const chargeId = 'ch_test_refund_ladder';
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
user_id: userId,
|
||||
price_id: 'price_test_monthly',
|
||||
product_type: 'monthly_subscription',
|
||||
status: 'completed',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
await paymentRepository.updatePayment({
|
||||
checkout_session_id: checkoutSessionId,
|
||||
payment_intent_id: paymentIntentId,
|
||||
completed_at: new Date(),
|
||||
});
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
function chargeEventWithRefunds(refunds: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
type: 'charge.refunded' as const,
|
||||
data: {
|
||||
object: {
|
||||
id: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount_refunded: 2500,
|
||||
refunds: {
|
||||
object: 'list',
|
||||
has_more: false,
|
||||
url: `/v1/charges/${chargeId}/refunds`,
|
||||
data: refunds,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const firstRefund = {
|
||||
id: 're_test_refund_ladder_1',
|
||||
object: 'refund',
|
||||
charge: chargeId,
|
||||
payment_intent: paymentIntentId,
|
||||
amount: 1200,
|
||||
currency: 'brl',
|
||||
status: 'succeeded',
|
||||
created: nowSeconds - 600,
|
||||
metadata: {},
|
||||
};
|
||||
const secondRefund = {
|
||||
...firstRefund,
|
||||
id: 're_test_refund_ladder_2',
|
||||
amount: 1300,
|
||||
created: nowSeconds,
|
||||
};
|
||||
await sendWebhook({...chargeEventWithRefunds([firstRefund]), id: 'evt_test_refund_ladder_1'});
|
||||
const afterFirst = await userRepository.findUnique(userId);
|
||||
expect(afterFirst!.firstRefundAt).not.toBeNull();
|
||||
expect(afterFirst!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(0);
|
||||
await sendWebhook({...chargeEventWithRefunds([firstRefund, secondRefund]), id: 'evt_test_refund_ladder_2'});
|
||||
const afterSecond = await userRepository.findUnique(userId);
|
||||
expect(afterSecond!.premiumFlags & PremiumFlags.PURCHASE_DISABLED).toBe(PremiumFlags.PURCHASE_DISABLED);
|
||||
});
|
||||
test('falls back to customer ID when payment intent is not indexed (subscription mode)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
|
||||
@@ -64,6 +64,14 @@ interface StripeApiMockConfig {
|
||||
paymentMethods?: Record<string, Partial<MockStripePaymentMethod>>;
|
||||
setupIntents?: Record<string, Partial<MockStripeSetupIntent>>;
|
||||
subscriptions?: Record<string, Partial<MockStripeSubscriptionState>>;
|
||||
prices?: Record<string, MockStripePriceOverrides>;
|
||||
}
|
||||
|
||||
interface MockStripePriceOverrides {
|
||||
unit_amount?: number;
|
||||
currency?: string;
|
||||
interval?: 'month' | 'year';
|
||||
product?: string;
|
||||
}
|
||||
|
||||
interface SubscriptionScheduleParams {
|
||||
@@ -302,6 +310,7 @@ interface MockStripeSubscriptionState {
|
||||
customer: string;
|
||||
trial_end: number | null;
|
||||
price_id: string;
|
||||
unit_amount: number;
|
||||
currency: string;
|
||||
interval: 'month' | 'year';
|
||||
item_id: string;
|
||||
@@ -808,6 +817,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
customer: 'cus_test_1',
|
||||
trial_end: null,
|
||||
price_id: 'price_test_1',
|
||||
unit_amount: 2500,
|
||||
currency: 'usd',
|
||||
interval: 'month',
|
||||
item_id: 'si_test_1',
|
||||
@@ -856,7 +866,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
price: {
|
||||
id: subState.price_id,
|
||||
object: 'price',
|
||||
unit_amount: 2500,
|
||||
unit_amount: subState.unit_amount,
|
||||
currency: subState.currency,
|
||||
recurring: {
|
||||
interval: subState.interval,
|
||||
@@ -1703,28 +1713,31 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
http.get(`${STRIPE_API_BASE}/v1/prices/:id`, ({params}) => {
|
||||
const {id} = params;
|
||||
const normalizedPriceId = String(id).toLowerCase();
|
||||
const overrides = config.prices?.[String(id)];
|
||||
return HttpResponse.json({
|
||||
id,
|
||||
object: 'price',
|
||||
active: true,
|
||||
currency: normalizedPriceId.includes('eur')
|
||||
? 'eur'
|
||||
: normalizedPriceId.includes('brl')
|
||||
? 'brl'
|
||||
: normalizedPriceId.includes('inr')
|
||||
? 'inr'
|
||||
: normalizedPriceId.includes('pln')
|
||||
? 'pln'
|
||||
: normalizedPriceId.includes('try')
|
||||
? 'try'
|
||||
: 'usd',
|
||||
unit_amount: normalizedPriceId.includes('year') ? 4999 : 499,
|
||||
currency:
|
||||
overrides?.currency ??
|
||||
(normalizedPriceId.includes('eur')
|
||||
? 'eur'
|
||||
: normalizedPriceId.includes('brl')
|
||||
? 'brl'
|
||||
: normalizedPriceId.includes('inr')
|
||||
? 'inr'
|
||||
: normalizedPriceId.includes('pln')
|
||||
? 'pln'
|
||||
: normalizedPriceId.includes('try')
|
||||
? 'try'
|
||||
: 'usd'),
|
||||
unit_amount: overrides?.unit_amount ?? (normalizedPriceId.includes('year') ? 4999 : 499),
|
||||
type: 'recurring',
|
||||
recurring: {
|
||||
interval: normalizedPriceId.includes('year') ? 'year' : 'month',
|
||||
interval: overrides?.interval ?? (normalizedPriceId.includes('year') ? 'year' : 'month'),
|
||||
interval_count: 1,
|
||||
},
|
||||
product: 'prod_test_1',
|
||||
product: overrides?.product ?? 'prod_test_1',
|
||||
livemode: false,
|
||||
created: Math.floor(Date.now() / 1000) - 365 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
@@ -441,16 +441,25 @@ function collectGuildText(doc: SearchableGuild): Array<string | null> {
|
||||
return [doc.name, doc.vanityUrlCode, doc.discoveryDescription, ...doc.discoveryTags];
|
||||
}
|
||||
|
||||
const sortGuildsByCreatedAt = sortNumericField<SearchableGuild, GuildSearchFilters>('createdAt', 'asc');
|
||||
const sortGuildsByMemberCount = sortNumericField<SearchableGuild, GuildSearchFilters>('memberCount', 'desc');
|
||||
|
||||
function sortGuilds(left: SearchableGuild, right: SearchableGuild, filters: GuildSearchFilters, query: string): number {
|
||||
const sorter = filters.sortBy === 'memberCount' ? sortGuildsByMemberCount : sortGuildsByCreatedAt;
|
||||
const delta = sorter(left, right, filters, query);
|
||||
if (delta !== 0) return delta;
|
||||
const leftId = BigInt(left.id);
|
||||
const rightId = BigInt(right.id);
|
||||
if (leftId === rightId) return 0;
|
||||
return leftId > rightId ? -1 : 1;
|
||||
}
|
||||
|
||||
class InMemoryGuildSearchService
|
||||
extends InMemorySearchServiceBase<GuildSearchFilters, SearchableGuild>
|
||||
implements IGuildSearchService
|
||||
{
|
||||
constructor() {
|
||||
super(
|
||||
matchesGuildFilters,
|
||||
collectGuildText,
|
||||
sortNumericField<SearchableGuild, GuildSearchFilters>('createdAt', 'asc'),
|
||||
);
|
||||
super(matchesGuildFilters, collectGuildText, sortGuilds);
|
||||
}
|
||||
|
||||
async indexGuild(guild: Guild, discovery?: GuildDiscoveryContext): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {isDerivedTrait, isServerManagedTrait, resolveAssignedTraits} from './UserTraits';
|
||||
|
||||
describe('UserTraits', () => {
|
||||
it('treats premium as derived rather than assignable', () => {
|
||||
expect(isDerivedTrait('premium')).toBe(true);
|
||||
expect(isDerivedTrait('beta-tester')).toBe(false);
|
||||
});
|
||||
|
||||
it('recognises every trait shape the server manages', () => {
|
||||
expect(isServerManagedTrait('sso')).toBe(true);
|
||||
expect(isServerManagedTrait('sso:acme')).toBe(true);
|
||||
expect(isServerManagedTrait('sso_provider:0123456789abcdef')).toBe(true);
|
||||
expect(isServerManagedTrait('sso_identity:0123456789abcdef')).toBe(true);
|
||||
expect(isServerManagedTrait('registration_pending_approval')).toBe(true);
|
||||
expect(isServerManagedTrait('registration_rejected')).toBe(true);
|
||||
expect(isServerManagedTrait('beta-tester')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the operator traits it was given', () => {
|
||||
expect([...resolveAssignedTraits([], ['beta-tester', 'experimental'])]).toEqual(['beta-tester', 'experimental']);
|
||||
});
|
||||
|
||||
it('drops premium because nothing reads the stored value', () => {
|
||||
expect([...resolveAssignedTraits([], ['premium'])]).toEqual([]);
|
||||
expect([...resolveAssignedTraits([], ['beta-tester', 'premium'])]).toEqual(['beta-tester']);
|
||||
});
|
||||
|
||||
it('keeps a premium user premium when the operator saves other traits', () => {
|
||||
expect([...resolveAssignedTraits(['premium'], ['beta-tester'])]).toEqual(['beta-tester']);
|
||||
});
|
||||
|
||||
it('preserves server managed traits the operator did not send', () => {
|
||||
const resolved = resolveAssignedTraits(
|
||||
['sso', 'sso:acme', 'sso_identity:0123456789abcdef', 'beta-tester'],
|
||||
['experimental'],
|
||||
);
|
||||
expect([...resolved].sort()).toEqual(['experimental', 'sso', 'sso:acme', 'sso_identity:0123456789abcdef']);
|
||||
});
|
||||
|
||||
it('keeps a pending approval user pending when their traits are cleared', () => {
|
||||
expect([...resolveAssignedTraits(['registration_pending_approval'], [])]).toEqual([
|
||||
'registration_pending_approval',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses to let an operator forge a server managed trait', () => {
|
||||
expect([...resolveAssignedTraits([], ['sso', 'sso_identity:forged'])]).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores empty trait names', () => {
|
||||
expect([...resolveAssignedTraits([], ['', 'beta-tester'])]).toEqual(['beta-tester']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const DERIVED_TRAITS = new Set(['premium']);
|
||||
|
||||
const SERVER_MANAGED_TRAITS = new Set(['sso', 'registration_pending_approval', 'registration_rejected']);
|
||||
|
||||
const SERVER_MANAGED_TRAIT_PREFIXES = ['sso:', 'sso_provider:', 'sso_identity:'];
|
||||
|
||||
export function isDerivedTrait(trait: string): boolean {
|
||||
return DERIVED_TRAITS.has(trait);
|
||||
}
|
||||
|
||||
export function isServerManagedTrait(trait: string): boolean {
|
||||
return SERVER_MANAGED_TRAITS.has(trait) || SERVER_MANAGED_TRAIT_PREFIXES.some((prefix) => trait.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function resolveAssignedTraits(current: Iterable<string>, requested: Iterable<string>): Set<string> {
|
||||
const next = new Set<string>();
|
||||
for (const trait of requested) {
|
||||
if (!trait || isDerivedTrait(trait) || isServerManagedTrait(trait)) {
|
||||
continue;
|
||||
}
|
||||
next.add(trait);
|
||||
}
|
||||
for (const trait of current) {
|
||||
if (isServerManagedTrait(trait)) {
|
||||
next.add(trait);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -178,7 +178,7 @@ export class EmailChangeService {
|
||||
}
|
||||
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(trimmedEmail);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const existing = await users.findByEmail(trimmedEmail.toLowerCase());
|
||||
if (existing && existing.id !== user.id) {
|
||||
|
||||
@@ -125,12 +125,13 @@ describe('User Settings synced_preferences', () => {
|
||||
});
|
||||
test('reports TOO_LARGE for a snapshot inside the encoded-length bound but over the byte cap', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const baseEntries = Math.floor((SYNCED_PREFERENCES_MAX_BYTES - 8192) / 1006);
|
||||
const build = (tailLength: number) =>
|
||||
encodeSyncedPreferences(
|
||||
create(SyncedPreferencesSchema, {
|
||||
localSpamOverrides: create(LocalUserSpamOverridesSchema, {
|
||||
spammerUserIds: [
|
||||
...Array.from({length: 260}, (_, i) => `${i}-${'x'.repeat(1000)}`),
|
||||
...Array.from({length: baseEntries}, (_, i) => `${i}-${'x'.repeat(1000)}`),
|
||||
'y'.repeat(tailLength),
|
||||
],
|
||||
}),
|
||||
@@ -138,7 +139,13 @@ describe('User Settings synced_preferences', () => {
|
||||
);
|
||||
let tail = 1;
|
||||
let encoded = build(tail);
|
||||
while (encodedSyncedPreferencesByteLength(encoded) < SYNCED_PREFERENCES_MAX_BYTES + 1 && tail < 8000) {
|
||||
while (encodedSyncedPreferencesByteLength(encoded) <= SYNCED_PREFERENCES_MAX_BYTES) {
|
||||
tail += 512;
|
||||
encoded = build(tail);
|
||||
}
|
||||
tail = Math.max(1, tail - 512);
|
||||
encoded = build(tail);
|
||||
while (encodedSyncedPreferencesByteLength(encoded) <= SYNCED_PREFERENCES_MAX_BYTES) {
|
||||
tail += 1;
|
||||
encoded = build(tail);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {type ChannelID, createChannelID, createMessageID, createUserID, type MessageID} from '../../BrandedTypes';
|
||||
import {harvestMessages} from './HarvestUserData';
|
||||
|
||||
const AUTHOR = createUserID(1000000000000000000n);
|
||||
|
||||
function makeRepository(refs: Array<{channelId: ChannelID; messageId: MessageID}>, missing = new Set<string>()) {
|
||||
let pages = 0;
|
||||
return {
|
||||
pages: () => pages,
|
||||
listMessagesByAuthor: async (_userId: typeof AUTHOR, limit: number, lastMessageId?: MessageID) => {
|
||||
pages++;
|
||||
const start = lastMessageId ? refs.findIndex((r) => r.messageId === lastMessageId) + 1 : 0;
|
||||
return refs.slice(start, start + limit);
|
||||
},
|
||||
getMessage: async (_channelId: ChannelID, messageId: MessageID) =>
|
||||
missing.has(messageId.toString()) ? null : {content: `body ${messageId.toString()}`, attachments: undefined},
|
||||
};
|
||||
}
|
||||
|
||||
function refsAcross(channelCount: number, perChannel: number) {
|
||||
const out: Array<{channelId: ChannelID; messageId: MessageID}> = [];
|
||||
let id = 1500000000000000000n;
|
||||
for (let c = 0; c < channelCount; c++) {
|
||||
const channelId = createChannelID(2000000000000000000n + BigInt(c));
|
||||
for (let m = 0; m < perChannel; m++) {
|
||||
out.push({channelId, messageId: createMessageID(id)});
|
||||
id += 1n;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('harvestMessages', () => {
|
||||
it('reads past a single page instead of stopping at one query', async () => {
|
||||
const refs = refsAcross(1, 2500);
|
||||
const repo = makeRepository(refs);
|
||||
const result = await harvestMessages(repo, AUTHOR, Date.now(), null);
|
||||
expect(result.totalMessages).toBe(2500);
|
||||
expect(repo.pages()).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('groups every message under its own channel', async () => {
|
||||
const repo = makeRepository(refsAcross(3, 4));
|
||||
const result = await harvestMessages(repo, AUTHOR, Date.now(), null);
|
||||
expect(result.channelMessagesMap.size).toBe(3);
|
||||
for (const messages of result.channelMessagesMap.values()) {
|
||||
expect(messages).toHaveLength(4);
|
||||
}
|
||||
expect(result.totalMessages).toBe(12);
|
||||
});
|
||||
|
||||
it('leaves out a message the repository cannot return', async () => {
|
||||
const refs = refsAcross(1, 5);
|
||||
const repo = makeRepository(refs, new Set([refs[2].messageId.toString()]));
|
||||
const result = await harvestMessages(repo, AUTHOR, Date.now(), null);
|
||||
expect(result.totalMessages).toBe(4);
|
||||
});
|
||||
|
||||
it('returns an empty map for an account with no messages', async () => {
|
||||
const repo = makeRepository([]);
|
||||
const result = await harvestMessages(repo, AUTHOR, Date.now(), null);
|
||||
expect(result.totalMessages).toBe(0);
|
||||
expect(result.channelMessagesMap.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -171,6 +171,10 @@ interface ArchiveResult {
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
// A harvest is every message the account wrote, so the read pages to the end of
|
||||
// the account rather than stopping at a count. The page size is what bounds one
|
||||
// query, not what bounds the archive.
|
||||
const HARVEST_MESSAGE_CHUNK_SIZE = 1000;
|
||||
const CONCURRENT_MESSAGE_LIMIT = 10;
|
||||
const INITIAL_PROGRESS = 5;
|
||||
const MESSAGES_PROGRESS_MAX = 55;
|
||||
@@ -226,11 +230,12 @@ interface HarvestMessagesFilterArgs {
|
||||
findChannel: (channelId: ChannelID) => Promise<Channel | null>;
|
||||
}
|
||||
|
||||
async function harvestMessages(
|
||||
export async function harvestMessages(
|
||||
channelRepository: {
|
||||
listMessagesByAuthor: (
|
||||
userId: UserID,
|
||||
limit: number,
|
||||
lastMessageId?: MessageID,
|
||||
) => Promise<
|
||||
Array<{
|
||||
channelId: ChannelID;
|
||||
@@ -250,24 +255,78 @@ async function harvestMessages(
|
||||
filterArgs: HarvestMessagesFilterArgs | null,
|
||||
): Promise<HarvestMessageResult> {
|
||||
const channelMessagesMap = new Map<string, Array<HarvestedMessage>>();
|
||||
const channelEligibility = filterArgs ? new Map<string, boolean>() : null;
|
||||
Logger.debug('Fetching all user messages');
|
||||
const startFetchTime = Date.now();
|
||||
const messageRefs = await channelRepository.listMessagesByAuthor(userId, 100000);
|
||||
Logger.debug(
|
||||
{
|
||||
totalMessages: messageRefs.length,
|
||||
fetchElapsed: Date.now() - startFetchTime,
|
||||
totalElapsed: Date.now() - startTime,
|
||||
},
|
||||
'All messages retrieved',
|
||||
);
|
||||
if (messageRefs.length === 0) {
|
||||
return {channelMessagesMap, totalMessages: 0};
|
||||
}
|
||||
const channelEligibility = filterArgs ? new Map<string, boolean>() : null;
|
||||
const filteredRefs: Array<{channelId: ChannelID; messageId: MessageID}> = [];
|
||||
if (filterArgs) {
|
||||
for (const ref of messageRefs) {
|
||||
let lastMessageId: MessageID | undefined;
|
||||
let scannedMessages = 0;
|
||||
let totalMessages = 0;
|
||||
|
||||
const readMessage = async ({
|
||||
channelId,
|
||||
messageId,
|
||||
}: {
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}): Promise<ChannelHarvestResult | null> => {
|
||||
try {
|
||||
const message = await channelRepository.getMessage(channelId, messageId);
|
||||
if (!message) {
|
||||
Logger.warn(
|
||||
{channelId: channelId.toString(), messageId: messageId.toString()},
|
||||
'Message not found during harvest',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const timestamp = snowflakeToDate(messageId);
|
||||
const attachments: Array<HarvestedAttachment> = [];
|
||||
if (message.attachments) {
|
||||
for (const attachment of message.attachments) {
|
||||
attachments.push({
|
||||
attachment_id: attachment.id.toString(),
|
||||
filename: attachment.filename,
|
||||
size: attachment.size.toString(),
|
||||
content_type: attachment.contentType,
|
||||
content_hash: null,
|
||||
archive_path: null,
|
||||
cdn_url: makeAttachmentCdnUrl(channelId, attachment.id, attachment.filename),
|
||||
width: attachment.width,
|
||||
height: attachment.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
channelId: channelId.toString(),
|
||||
messageData: {
|
||||
id: messageId.toString(),
|
||||
timestamp: timestamp.toISOString(),
|
||||
content: message.content ?? '',
|
||||
attachments,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, channelId: channelId.toString(), messageId: messageId.toString()},
|
||||
'Failed to process message during harvest',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const page = await channelRepository.listMessagesByAuthor(userId, HARVEST_MESSAGE_CHUNK_SIZE, lastMessageId);
|
||||
if (page.length === 0) {
|
||||
break;
|
||||
}
|
||||
scannedMessages += page.length;
|
||||
lastMessageId = page[page.length - 1].messageId;
|
||||
|
||||
const pageRefs: Array<{channelId: ChannelID; messageId: MessageID}> = [];
|
||||
for (const ref of page) {
|
||||
if (!filterArgs) {
|
||||
pageRefs.push(ref);
|
||||
continue;
|
||||
}
|
||||
const ts = snowflakeToDate(ref.messageId).getTime();
|
||||
if (!isTimestampInWindow(ts, filterArgs.filter)) {
|
||||
continue;
|
||||
@@ -280,76 +339,41 @@ async function harvestMessages(
|
||||
channelEligibility!.set(channelIdStr, eligible);
|
||||
}
|
||||
if (eligible) {
|
||||
filteredRefs.push(ref);
|
||||
pageRefs.push(ref);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filteredRefs.push(...messageRefs);
|
||||
}
|
||||
if (filteredRefs.length === 0) {
|
||||
return {channelMessagesMap, totalMessages: 0};
|
||||
}
|
||||
const messages: Array<ChannelHarvestResult> = [];
|
||||
for (let i = 0; i < filteredRefs.length; i += CONCURRENT_MESSAGE_LIMIT) {
|
||||
const batch = filteredRefs.slice(i, i + CONCURRENT_MESSAGE_LIMIT);
|
||||
const batchPromises = batch.map(async ({channelId, messageId}): Promise<ChannelHarvestResult | null> => {
|
||||
try {
|
||||
const message = await channelRepository.getMessage(channelId, messageId);
|
||||
if (!message) {
|
||||
Logger.warn(
|
||||
{channelId: channelId.toString(), messageId: messageId.toString()},
|
||||
'Message not found during harvest',
|
||||
);
|
||||
return null;
|
||||
|
||||
for (let i = 0; i < pageRefs.length; i += CONCURRENT_MESSAGE_LIMIT) {
|
||||
const batchResults = await Promise.all(pageRefs.slice(i, i + CONCURRENT_MESSAGE_LIMIT).map(readMessage));
|
||||
for (const result of batchResults) {
|
||||
if (result === null) {
|
||||
continue;
|
||||
}
|
||||
const timestamp = snowflakeToDate(messageId);
|
||||
const attachments: Array<HarvestedAttachment> = [];
|
||||
if (message.attachments) {
|
||||
for (const attachment of message.attachments) {
|
||||
attachments.push({
|
||||
attachment_id: attachment.id.toString(),
|
||||
filename: attachment.filename,
|
||||
size: attachment.size.toString(),
|
||||
content_type: attachment.contentType,
|
||||
content_hash: null,
|
||||
archive_path: null,
|
||||
cdn_url: makeAttachmentCdnUrl(channelId, attachment.id, attachment.filename),
|
||||
width: attachment.width,
|
||||
height: attachment.height,
|
||||
});
|
||||
}
|
||||
let bucket = channelMessagesMap.get(result.channelId);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
channelMessagesMap.set(result.channelId, bucket);
|
||||
}
|
||||
return {
|
||||
channelId: channelId.toString(),
|
||||
messageData: {
|
||||
id: messageId.toString(),
|
||||
timestamp: timestamp.toISOString(),
|
||||
content: message.content ?? '',
|
||||
attachments,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, channelId: channelId.toString(), messageId: messageId.toString()},
|
||||
'Failed to process message during harvest',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
for (const result of batchResults) {
|
||||
if (result !== null) {
|
||||
messages.push(result);
|
||||
bucket.push(result.messageData);
|
||||
totalMessages++;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const {channelId, messageData} of messages) {
|
||||
if (!channelMessagesMap.has(channelId)) {
|
||||
channelMessagesMap.set(channelId, []);
|
||||
|
||||
if (page.length < HARVEST_MESSAGE_CHUNK_SIZE) {
|
||||
break;
|
||||
}
|
||||
channelMessagesMap.get(channelId)!.push(messageData);
|
||||
}
|
||||
return {channelMessagesMap, totalMessages: messages.length};
|
||||
|
||||
Logger.debug(
|
||||
{
|
||||
scannedMessages,
|
||||
totalMessages,
|
||||
fetchElapsed: Date.now() - startFetchTime,
|
||||
totalElapsed: Date.now() - startTime,
|
||||
},
|
||||
'All messages retrieved',
|
||||
);
|
||||
return {channelMessagesMap, totalMessages};
|
||||
}
|
||||
|
||||
function buildUserDataJson(params: UserDataJsonParams) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {DiscoveryApplicationStatus} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import type {GuildID} from '../../BrandedTypes';
|
||||
import {GuildDiscoveryRepository} from '../../guild/repositories/GuildDiscoveryRepository';
|
||||
import {getGuildSearchService} from '../../SearchFactory';
|
||||
import {mapWithConcurrency} from '../../utils/ConcurrencyUtils';
|
||||
@@ -17,7 +16,7 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
helpers.logger.warn('Search service not available, skipping discovery index sync');
|
||||
return;
|
||||
}
|
||||
const {guildRepository, gatewayService} = getWorkerDependencies();
|
||||
const {guildRepository} = getWorkerDependencies();
|
||||
const discoveryRepository = new GuildDiscoveryRepository();
|
||||
const approvedRows = await discoveryRepository.listByStatus(DiscoveryApplicationStatus.APPROVED);
|
||||
if (approvedRows.length === 0) {
|
||||
@@ -25,21 +24,6 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
return;
|
||||
}
|
||||
const guildIds = approvedRows.map((row) => row.guild_id);
|
||||
let freshCounts = new Map<
|
||||
GuildID,
|
||||
{
|
||||
memberCount: number;
|
||||
onlineCount: number;
|
||||
}
|
||||
>();
|
||||
try {
|
||||
freshCounts = await gatewayService.getDiscoveryGuildCounts(guildIds);
|
||||
} catch (error) {
|
||||
helpers.logger.warn(
|
||||
{error: error instanceof Error ? error.message : String(error)},
|
||||
'Failed to fetch fresh guild counts from gateway, using database values',
|
||||
);
|
||||
}
|
||||
let synced = 0;
|
||||
for (let i = 0; i < guildIds.length; i += BATCH_SIZE) {
|
||||
const batch = guildIds.slice(i, i + BATCH_SIZE);
|
||||
@@ -54,7 +38,7 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
if (!guild) return null;
|
||||
const discoveryRow = discoveryRows[index];
|
||||
if (!discoveryRow || discoveryRow.status !== DiscoveryApplicationStatus.APPROVED) return null;
|
||||
return {guild, discoveryRow, counts: freshCounts.get(guildId)};
|
||||
return {guild, discoveryRow};
|
||||
})
|
||||
.filter((update): update is NonNullable<typeof update> => update != null);
|
||||
await mapWithConcurrency(updates, UPDATE_CONCURRENCY, (update) =>
|
||||
@@ -63,7 +47,6 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
categoryId: update.discoveryRow.category_type,
|
||||
primaryLanguage: update.discoveryRow.primary_language ?? null,
|
||||
tags: update.discoveryRow.custom_tags ?? [],
|
||||
memberCount: update.counts?.memberCount,
|
||||
}),
|
||||
);
|
||||
synced += updates.length;
|
||||
|
||||
@@ -28,6 +28,12 @@ const MODULE_REGISTRY_TEST_FILES = [
|
||||
'src/api/risk/__tests__/AccountPolicyService.test.ts',
|
||||
];
|
||||
|
||||
const INSTANCE_POLICY_TEST_FILES = [
|
||||
'src/api/admin/tests/InstanceConfigPendingRegistrationApproval.test.ts',
|
||||
'src/api/auth/tests/DeferredPhoneGate.test.ts',
|
||||
'src/api/instance/tests/SingleCommunityService.test.ts',
|
||||
];
|
||||
|
||||
const sharedExclude = [
|
||||
...configDefaults.exclude,
|
||||
'pkgs/**',
|
||||
@@ -76,7 +82,7 @@ export default defineConfig({
|
||||
...sharedTestConfig,
|
||||
name: 'api',
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: [...sharedExclude, ...MODULE_REGISTRY_TEST_FILES],
|
||||
exclude: [...sharedExclude, ...MODULE_REGISTRY_TEST_FILES, ...INSTANCE_POLICY_TEST_FILES],
|
||||
isolate: false,
|
||||
},
|
||||
},
|
||||
@@ -90,6 +96,16 @@ export default defineConfig({
|
||||
isolate: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
...sharedTestConfig,
|
||||
name: 'api-instance-policy',
|
||||
include: INSTANCE_POLICY_TEST_FILES,
|
||||
exclude: sharedExclude,
|
||||
isolate: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,114 +7,65 @@
|
||||
|
||||
## Fluxer modifications
|
||||
|
||||
Changes applied on top of the upstream v2.17.2 source. Previously maintained as
|
||||
a pnpm patch at `patches/[email protected]`; now maintained as regular
|
||||
source edits in this package.
|
||||
Changes applied on top of the upstream v2.17.2 source. These were previously a pnpm patch at `patches/[email protected]` and are now plain source edits in this package.
|
||||
|
||||
1. **AV1 E2EE support** (`src/e2ee/worker/av1Crypto.ts`, `FrameCryptor.ts`, `e2ee.worker.ts`)
|
||||
OBU-level AV1 encryption and decryption for end-to-end encrypted voice/video.
|
||||
|
||||
OBU-level AV1 encryption and decryption for end-to-end encrypted voice and video.
|
||||
|
||||
2. **UpdateTrackContext message** (`src/e2ee/types.ts`, worker dispatch)
|
||||
Replaced `updateCodec` with richer `updateTrackContext` carrying participant
|
||||
identity and track ID, preventing codec mismatch on track reuse.
|
||||
|
||||
`updateCodec` replaced with `updateTrackContext`, which carries participant identity and track id so a reused track cannot pick up the wrong codec.
|
||||
|
||||
3. **E2EEManager state tracking** (`src/e2ee/E2eeManager.ts`)
|
||||
Added `getE2EETransformState()` / `setE2EETransformState()` for proper
|
||||
transform lifecycle management.
|
||||
|
||||
4. **Screenshare scalability mode** (`src/room/participant/LocalParticipant.ts`)
|
||||
Preserve caller-supplied `scalabilityMode` for screen shares instead of forcing
|
||||
`L3T3_KEY`, so VP9/AV1 screen shares can use the browser's compatible default
|
||||
unless Fluxer explicitly requests an SVC layer layout.
|
||||
`getE2EETransformState()` and `setE2EETransformState()` for transform lifecycle management.
|
||||
|
||||
4. **Screen share scalability mode** (`src/room/participant/LocalParticipant.ts`)
|
||||
|
||||
A caller-supplied `scalabilityMode` is preserved for screen shares instead of being forced to `L3T3_KEY`, so VP9 and AV1 screen shares use the browser default unless Fluxer asks for a specific SVC layout.
|
||||
|
||||
5. **E2EE frame layout guards** (`src/e2ee/worker/FrameCryptor.ts`)
|
||||
Validate encrypted frame trailer, IV, tag, and clear-prefix bounds before
|
||||
constructing typed-array views, and drop malformed encrypted frames without
|
||||
tearing down the transform stream.
|
||||
|
||||
Encrypted frame trailer, IV, tag and clear-prefix bounds are validated before any typed-array view is constructed, and malformed frames are dropped without tearing down the transform stream.
|
||||
|
||||
6. **Encrypted backup codec publishing** (`src/room/participant/LocalParticipant.ts`, `src/e2ee/E2eeManager.ts`)
|
||||
Allows backup codec tracks to be advertised and published while E2EE is
|
||||
enabled, and attaches sender transforms to backup codec senders using their
|
||||
cloned media track ID and codec.
|
||||
|
||||
Backup codec tracks can be advertised and published while E2EE is on, with sender transforms attached using the cloned media track id and codec.
|
||||
|
||||
7. **Publisher codec preferences** (`src/room/RTCEngine.ts`)
|
||||
Applies `RTCRtpTransceiver.setCodecPreferences()` to publisher transceivers
|
||||
so browser SDP follows the selected primary or backup codec, and prefers
|
||||
H.264 profiles that use Chromium's external/hardware encoder before the
|
||||
OpenH264 software profile.
|
||||
|
||||
`setCodecPreferences()` is applied to publisher transceivers so the browser's SDP follows the selected primary or backup codec. H.264 profiles rank Baseline `42001f` first, then Constrained Baseline `42e01f`, then everything else. Main, High and Constrained High rank last on purpose.
|
||||
|
||||
livekit-server registers H.264 High `640032` on the publisher peer connection but filters it off the subscriber peer connection, and its `CodecParametersFuzzySearch` falls back to a mime-only match. A High publication therefore reaches subscribers under their `42e01f` payload type and decodes to nothing on a Constrained-Baseline-only decoder such as Firefox's OpenH264 GMP. `42001f` is the one profile Chromium's accelerated encoder factory advertises that such a decoder can still handle, because Chromium's VAAPI encoder and OpenH264 both write a Constrained Baseline SPS for `H264PROFILE_BASELINE`.
|
||||
|
||||
The trade is that livekit-server does not register `42001f` either, so Windows and macOS negotiate `42e01f`, which Chromium's accelerated encoder factory does not advertise there (`kPlatformH264CbpEncoding` is off by default on Windows, and `IsH264ConstrainedBaselineProfileAvailableForAcceleratedEncoder` returns false on Apple). Those publishers fall back to software H.264, which is what an unpatched browser does anyway. Linux, ChromeOS and Android keep hardware encoding.
|
||||
|
||||
8. **Media publishing defaults** (`src/room/defaults.ts`, `src/room/utils.ts`, `src/room/track/options.ts`)
|
||||
Falls back to H.264, then VP9, VP8, AV1, and HEVC/H.265 according to actual
|
||||
sender capabilities, pairs advanced codecs with H.264 backup simulcast, and
|
||||
uses maintain-resolution screen-share defaults with a 4K60-ready bitrate cap.
|
||||
The order puts AV1 and HEVC last because both are opt-in in Fluxer, so a
|
||||
fallback inside `publishTrack` must not land on a codec the user did not
|
||||
enable. Fluxer picks the codec itself before publishing, so this list only
|
||||
applies when the client overrides the request, such as the reconnect
|
||||
republish that runs outside Fluxer's own flows.
|
||||
|
||||
Codec fallback follows actual sender capabilities in the order H.264, VP9, VP8, AV1, HEVC. Advanced codecs are paired with an H.264 backup simulcast, and screen shares default to maintain-resolution with a 4K60-ready bitrate cap. AV1 and HEVC come last because both are opt-in in Fluxer, so a fallback inside `publishTrack` must not land on a codec the user did not enable. Fluxer picks the codec itself before publishing, so this order only applies when the client overrides the request, such as the reconnect republish that runs outside Fluxer's own flows.
|
||||
|
||||
9. **High-fidelity Opus SDP munging** (`src/room/PCTransport.ts`)
|
||||
Forces Opus RED/FEC, stereo signaling, 10 ms packet time, no DTX, and a
|
||||
510 kbps maximum average bitrate in local offers and remote answers.
|
||||
|
||||
Local offers and remote answers are munged to force Opus RED and FEC, 10 ms packet time, no DTX, and a 510 kbps maximum average bitrate. Stereo signalling stays opt-in. `stereo=1` and `sprop-stereo=1` are added only for publications whose `TrackBitrateInfo.stereo` is set (studio mode above the stereo bitrate threshold, and screen-share audio) and for the subscriber mids the server advertised as stereo, so a mono microphone is not encoded and decoded as a two-channel stream.
|
||||
|
||||
10. **Remote audio volume restore at exactly zero** (`src/room/track/RemoteAudioTrack.ts`)
|
||||
`attach()`, `connectWebAudio()` and `getVolume()` guarded the remembered
|
||||
`elementVolume` with a truthiness check, so a track deliberately held at `0`
|
||||
came back at full volume whenever it was re-attached or its Web Audio graph
|
||||
was rebuilt. All three guards now test `!== undefined`. Note that remote
|
||||
gains above `1.0` are only legal because `setVolume()` takes the Web Audio
|
||||
`gainNode` branch; the `el.volume` branch would throw `IndexSizeError`.
|
||||
`webAudioMix` must stay unconditional.
|
||||
|
||||
`attach()`, `connectWebAudio()` and `getVolume()` guarded the remembered `elementVolume` with a truthiness check, so a track deliberately held at `0` came back at full volume whenever it was re-attached or its Web Audio graph was rebuilt. All three now test `!== undefined`. Remote gains above `1.0` are only legal because `setVolume()` takes the Web Audio `gainNode` branch, as the `el.volume` branch would throw `IndexSizeError`, so `webAudioMix` must stay unconditional.
|
||||
|
||||
11. **Processor teardown before source stop** (`src/room/track/LocalTrack.ts`)
|
||||
`stop()` called `super.stop()` first, killing the source `MediaStreamTrack`
|
||||
and closing the readable feeding a track processor before `processor.destroy()`
|
||||
ran. A camera-effect worker therefore saw input EOF before its owner's stop
|
||||
command and reported an operational failure during an ordinary camera-off.
|
||||
The processor is now captured, detached, and its teardown initiated before
|
||||
`super.stop()`.
|
||||
|
||||
12. **Transactional source and processor swaps** (`src/room/track/LocalTrack.ts`,
|
||||
`LocalVideoTrack.ts`, `LocalAudioTrack.ts`)
|
||||
`setMediaStreamTrack()` applied the new source, restarted the processor and
|
||||
re-armed the sender with no unwind path, so a failure anywhere in the middle
|
||||
left a half-applied track: listeners moved, elements detached, sender pointing
|
||||
at a dead track. It now takes `SetMediaStreamTrackOptions`
|
||||
(`force`, `deferEndedListener`, `preservePreviousTrack`) and, on failure,
|
||||
restores the previous source, constraints, `enabled` state, listeners,
|
||||
processor and sender, throwing `TrackInvalidError` when the previous source is
|
||||
no longer `live` because an ended track cannot be restored. Both errors are
|
||||
surfaced together as an `AggregateError` when the unwind itself fails.
|
||||
`stageTrackReplacement()` / `commitStagedTrackReplacement()` expose a two-phase
|
||||
swap: the candidate becomes the active source with its `ended` listener
|
||||
deferred and the previous source preserved, and only the commit adopts the
|
||||
`ended` listener and clears the staged identity, so a caller can validate its
|
||||
publication before the swap is observable. `replaceTrack()` and `restart()`
|
||||
guard the `providedByUser` flip behind a `replacementCommitted` flag.
|
||||
`restart()` still detaches and stops the previous source before calling
|
||||
`getUserMedia()`, as upstream does, because Safari ends a freshly acquired
|
||||
track with a capture failure while the old track for the same device is
|
||||
live. `setSimulcastTrackSender()` routes an already-installed processor's
|
||||
`processedTrack` to a newly registered secondary sender so a backup codec
|
||||
never publishes raw frames while the primary is processed.
|
||||
Processor install and teardown in all three classes roll the processed/raw
|
||||
sender track back, including `LocalVideoTrack`'s secondary simulcast senders,
|
||||
and aggregate every cleanup failure instead of discarding it.
|
||||
`stop()` called `super.stop()` first, killing the source `MediaStreamTrack` and closing the readable that feeds a track processor before `processor.destroy()` ran. A camera-effect worker therefore saw input EOF before its owner's stop command and reported an operational failure during an ordinary camera-off. The processor is now captured, detached and torn down before `super.stop()`.
|
||||
|
||||
13. **Start bitrate for every video codec** (`src/room/PCTransport.ts`,
|
||||
`src/room/participant/LocalParticipant.ts`, `src/room/participant/publishUtils.ts`)
|
||||
`x-google-start-bitrate` was reachable only by AV1 and VP9, gated twice: the
|
||||
publish path registered a track bitrate only for SVC codecs, and the offer
|
||||
munging returned early for everything else. H264, H265 and VP8 therefore
|
||||
opened at the Chromium default and had to ramp, which showed up as a 3000 kbps
|
||||
screen share encoding at 346 kbps twenty seconds in. The bitrate is now
|
||||
registered for every video codec from the highest encoding
|
||||
(`maxEncodingBitrate()`, so a simulcast ladder contributes its top layer), and
|
||||
the offer munging applies the start bitrate whenever a max bitrate is known.
|
||||
The dependency descriptor extension stays SVC-only.
|
||||
`appendStartBitrateToFmtp()` holds the fmtp edit so it can be tested, and
|
||||
`setTrackCodecBitrate()` now replaces an entry for the same cid or transceiver
|
||||
instead of appending, since `trackBitrates` is never cleared.
|
||||
12. **Transactional source and processor swaps** (`src/room/track/LocalTrack.ts`, `LocalVideoTrack.ts`, `LocalAudioTrack.ts`)
|
||||
|
||||
`setMediaStreamTrack()` applied the new source, restarted the processor and re-armed the sender with no unwind path, so a failure part-way through left a half-applied track with listeners moved, elements detached and the sender pointing at a dead track. It now takes `SetMediaStreamTrackOptions` (`force`, `deferEndedListener`, `preservePreviousTrack`) and, on failure, restores the previous source, constraints, `enabled` state, listeners, processor and sender. It throws `TrackInvalidError` when the previous source is no longer `live`, because an ended track cannot be restored, and surfaces both failures as an `AggregateError` when the unwind itself fails.
|
||||
|
||||
`stageTrackReplacement()` and `commitStagedTrackReplacement()` add a two-phase swap. The candidate becomes the active source with its `ended` listener deferred and the previous source preserved, and only the commit adopts the `ended` listener and clears the staged identity, so a caller can validate its publication before the swap is observable. `replaceTrack()` and `restart()` guard the `providedByUser` flip behind a `replacementCommitted` flag. `restart()` still detaches and stops the previous source before calling `getUserMedia()`, as upstream does, because Safari ends a freshly acquired track with a capture failure while the old track for the same device is still live. `setSimulcastTrackSender()` routes an installed processor's `processedTrack` to a newly registered secondary sender so a backup codec never publishes raw frames while the primary is processed. Processor install and teardown in all three classes roll the processed and raw sender track back, including `LocalVideoTrack`'s secondary simulcast senders, and aggregate every cleanup failure instead of discarding it.
|
||||
|
||||
13. **Start bitrate for every video codec** (`src/room/PCTransport.ts`, `src/room/participant/LocalParticipant.ts`, `src/room/participant/publishUtils.ts`)
|
||||
|
||||
`x-google-start-bitrate` was reachable only by AV1 and VP9 because it was gated twice. The publish path registered a track bitrate only for SVC codecs, and the offer munging returned early for everything else. H.264, H.265 and VP8 therefore opened at the Chromium default and had to ramp, which showed up as a 3000 kbps screen share encoding at 346 kbps twenty seconds in. The bitrate is now registered for every video codec from the highest encoding (`maxEncodingBitrate()`, so a simulcast ladder contributes its top layer), and the offer munging applies it whenever a max bitrate is known. The dependency descriptor extension stays SVC-only. `appendStartBitrateToFmtp()` holds the fmtp edit so it can be tested, and `setTrackCodecBitrate()` replaces an entry for the same cid or transceiver instead of appending, since `trackBitrates` is never cleared.
|
||||
|
||||
## Updating from upstream
|
||||
|
||||
|
||||
@@ -3,12 +3,43 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import type {MediaDescription} from 'sdp-transform';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {appendStartBitrateToFmtp} from './PCTransport.ts';
|
||||
import type {TrackBitrateInfo} from './PCTransport.ts';
|
||||
import {appendStartBitrateToFmtp, collectStereoMids, ensureAudioNackAndStereo, ensureOpusFmtp} from './PCTransport.ts';
|
||||
|
||||
function mediaWithFmtp(entries: Array<{payload: number; config: string}>): MediaDescription {
|
||||
return {fmtp: entries} as unknown as MediaDescription;
|
||||
}
|
||||
|
||||
function opusMedia(config: string, mid = '0'): MediaDescription {
|
||||
return {
|
||||
type: 'audio',
|
||||
mid,
|
||||
port: 9,
|
||||
protocol: 'UDP/TLS/RTP/SAVPF',
|
||||
rtp: [{payload: 109, codec: 'opus', rate: 48000, encoding: 2}],
|
||||
fmtp: [{payload: 109, config}],
|
||||
} as unknown as MediaDescription;
|
||||
}
|
||||
|
||||
function offerMedia(mid: string, trackId: string): MediaDescription {
|
||||
const media = opusMedia('useinbandfec=1', mid);
|
||||
media.msid = `- ${trackId}`;
|
||||
return media;
|
||||
}
|
||||
|
||||
function audioBitrateInfo(mid: string | null, trackId: string, stereo: boolean): TrackBitrateInfo {
|
||||
return {
|
||||
transceiver: {mid, sender: {track: {id: trackId}}} as unknown as RTCRtpTransceiver,
|
||||
codec: 'opus',
|
||||
maxbr: 320,
|
||||
stereo,
|
||||
};
|
||||
}
|
||||
|
||||
function opusConfig(media: MediaDescription): string {
|
||||
return media.fmtp.find((fmtp) => fmtp.payload === 109)?.config ?? '';
|
||||
}
|
||||
|
||||
describe('appendStartBitrateToFmtp', () => {
|
||||
it('appends the start bitrate to a non-SVC codec fmtp line', () => {
|
||||
const media = mediaWithFmtp([
|
||||
@@ -44,3 +75,72 @@ describe('appendStartBitrateToFmtp', () => {
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureOpusFmtp', () => {
|
||||
it('does not force stereo on a mono publication', () => {
|
||||
const media = opusMedia('maxplaybackrate=48000;stereo=0;useinbandfec=1');
|
||||
ensureOpusFmtp(media, 48000, false);
|
||||
const config = opusConfig(media);
|
||||
expect(config).toContain('minptime=10');
|
||||
expect(config).toContain('useinbandfec=1');
|
||||
expect(config).toContain('usedtx=0');
|
||||
expect(config).toContain('maxaveragebitrate=48000');
|
||||
expect(config).not.toContain('stereo=1');
|
||||
});
|
||||
|
||||
it('keeps stereo for a stereo publication', () => {
|
||||
const media = opusMedia('maxplaybackrate=48000;useinbandfec=1');
|
||||
ensureOpusFmtp(media, 320000, true);
|
||||
const config = opusConfig(media);
|
||||
expect(config).toContain('stereo=1');
|
||||
expect(config).toContain('sprop-stereo=1');
|
||||
expect(config).toContain('maxaveragebitrate=320000');
|
||||
});
|
||||
|
||||
it('preserves a stereo parameter the server negotiated', () => {
|
||||
const media = opusMedia('minptime=10;stereo=1');
|
||||
ensureOpusFmtp(media, 48000, false);
|
||||
expect(opusConfig(media)).toContain('stereo=1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureAudioNackAndStereo', () => {
|
||||
it('only stamps stereo on the listed mids', () => {
|
||||
const mono = opusMedia('useinbandfec=1', '0');
|
||||
ensureAudioNackAndStereo(mono as never, ['1'], []);
|
||||
expect(opusConfig(mono)).not.toContain('stereo=1');
|
||||
|
||||
const stereo = opusMedia('useinbandfec=1', '1');
|
||||
ensureAudioNackAndStereo(stereo as never, ['1'], []);
|
||||
expect(opusConfig(stereo)).toContain('stereo=1');
|
||||
expect(opusConfig(stereo)).toContain('sprop-stereo=1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectStereoMids', () => {
|
||||
it('matches the offer media section by msid before the transceiver has a mid', () => {
|
||||
const media = [offerMedia('0', 'mic-track'), offerMedia('1', 'screenshare-track')];
|
||||
expect(collectStereoMids([audioBitrateInfo(null, 'screenshare-track', true)], media)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('stamps stereo on the first offer for a new stereo publication', () => {
|
||||
const media = [offerMedia('0', 'mic-track'), offerMedia('1', 'screenshare-track')];
|
||||
const stereoMids = collectStereoMids([audioBitrateInfo(null, 'screenshare-track', true)], media);
|
||||
for (const m of media) {
|
||||
ensureAudioNackAndStereo(m as never, stereoMids, []);
|
||||
}
|
||||
expect(opusConfig(media[0]!)).not.toContain('stereo=1');
|
||||
expect(opusConfig(media[1]!)).toContain('stereo=1');
|
||||
expect(opusConfig(media[1]!)).toContain('sprop-stereo=1');
|
||||
});
|
||||
|
||||
it('uses the assigned mid once renegotiation has one', () => {
|
||||
const media = [offerMedia('0', 'mic-track'), offerMedia('1', 'screenshare-track')];
|
||||
expect(collectStereoMids([audioBitrateInfo('1', 'screenshare-track', true)], media)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('leaves mono publications out', () => {
|
||||
const media = [offerMedia('0', 'mic-track')];
|
||||
expect(collectStereoMids([audioBitrateInfo(null, 'mic-track', false)], media)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,11 +11,12 @@ import {NegotiationError, UnexpectedConnectionState} from './errors.ts';
|
||||
import type {LoggerOptions} from './types.ts';
|
||||
import {ddExtensionURI, isFireFox, isSafari, isSVCCodec} from './utils.ts';
|
||||
|
||||
interface TrackBitrateInfo {
|
||||
export interface TrackBitrateInfo {
|
||||
cid?: string;
|
||||
transceiver?: RTCRtpTransceiver;
|
||||
codec: string;
|
||||
maxbr: number;
|
||||
stereo?: boolean;
|
||||
}
|
||||
|
||||
const startBitrateFraction = 0.7;
|
||||
@@ -25,8 +26,6 @@ const requiredOpusFmtpParameters = {
|
||||
minptime: '10',
|
||||
useinbandfec: '1',
|
||||
usedtx: '0',
|
||||
stereo: '1',
|
||||
'sprop-stereo': '1',
|
||||
};
|
||||
const debounceInterval = 20;
|
||||
export const PCEvents = {
|
||||
@@ -156,20 +155,18 @@ export default class PCTransport extends EventEmitter {
|
||||
sdpParsed.media.forEach((media) => {
|
||||
const mid = getMidString(media.mid!);
|
||||
if (media.type === 'audio') {
|
||||
ensureOpusFmtp(media);
|
||||
this.trackBitrates.some((trackbr): boolean => {
|
||||
if (!trackbr.transceiver || mid !== trackbr.transceiver.mid) {
|
||||
return false;
|
||||
}
|
||||
const codecPayload = getCodecPayload(media, trackbr.codec);
|
||||
if (codecPayload === 0) {
|
||||
return true;
|
||||
}
|
||||
if (trackbr.codec.toLowerCase() === 'opus') {
|
||||
ensureOpusFmtp(media, trackbr.maxbr > 0 ? trackbr.maxbr * 1000 : opusMaxAverageBitrateBps);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const trackbr = this.trackBitrates.find(
|
||||
(br) => br.transceiver !== undefined && mid === br.transceiver.mid && br.codec.toLowerCase() === 'opus',
|
||||
);
|
||||
if (trackbr && getCodecPayload(media, trackbr.codec) !== 0) {
|
||||
ensureOpusFmtp(
|
||||
media,
|
||||
trackbr.maxbr > 0 ? trackbr.maxbr * 1000 : opusMaxAverageBitrateBps,
|
||||
trackbr.stereo === true,
|
||||
);
|
||||
} else {
|
||||
ensureOpusFmtp(media);
|
||||
}
|
||||
}
|
||||
});
|
||||
mungedSDP = write(sdpParsed);
|
||||
@@ -240,10 +237,11 @@ export default class PCTransport extends EventEmitter {
|
||||
const offer = await this.pc.createOffer(options);
|
||||
this.log.debug('original offer', {sdp: offer.sdp, ...this.logContext});
|
||||
const sdpParsed = parse(offer.sdp ?? '');
|
||||
const stereoMids = collectStereoMids(this.trackBitrates, sdpParsed.media);
|
||||
sdpParsed.media.forEach((media) => {
|
||||
ensureIPAddrMatchVersion(media);
|
||||
if (media.type === 'audio') {
|
||||
ensureAudioNackAndStereo(media, ['all'], []);
|
||||
ensureAudioNackAndStereo(media, stereoMids, []);
|
||||
} else if (media.type === 'video') {
|
||||
this.trackBitrates.some((trackbr): boolean => {
|
||||
if (!media.msid || !trackbr.cid || !media.msid.includes(trackbr.cid)) {
|
||||
@@ -587,7 +585,11 @@ function ensureAudioRedFmtp(media: MediaDescription, opusPayload: number): void
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOpusFmtp(media: MediaDescription, maxAverageBitrateBps: number = opusMaxAverageBitrateBps): number {
|
||||
export function ensureOpusFmtp(
|
||||
media: MediaDescription,
|
||||
maxAverageBitrateBps: number = opusMaxAverageBitrateBps,
|
||||
stereo = false,
|
||||
): number {
|
||||
const opusPayload = getCodecPayload(media, 'opus');
|
||||
if (opusPayload <= 0) return 0;
|
||||
media.ptime = opusPacketTimeMs;
|
||||
@@ -596,6 +598,10 @@ function ensureOpusFmtp(media: MediaDescription, maxAverageBitrateBps: number =
|
||||
for (const [key, value] of Object.entries(requiredOpusFmtpParameters)) {
|
||||
config = setFmtpParameter(config, key, value);
|
||||
}
|
||||
if (stereo) {
|
||||
config = setFmtpParameter(config, 'stereo', '1');
|
||||
config = setFmtpParameter(config, 'sprop-stereo', '1');
|
||||
}
|
||||
if (maxAverageBitrateBps > 0) {
|
||||
config = setFmtpParameter(config, 'maxaveragebitrate', String(maxAverageBitrateBps));
|
||||
}
|
||||
@@ -604,18 +610,18 @@ function ensureOpusFmtp(media: MediaDescription, maxAverageBitrateBps: number =
|
||||
return opusPayload;
|
||||
}
|
||||
|
||||
function ensureAudioNackAndStereo(
|
||||
export function ensureAudioNackAndStereo(
|
||||
media: {
|
||||
type: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
payloads?: string | undefined;
|
||||
} & MediaDescription,
|
||||
_stereoMids: Array<string>,
|
||||
stereoMids: Array<string>,
|
||||
nackMids: Array<string>,
|
||||
) {
|
||||
const mid = getMidString(media.mid!);
|
||||
const opusPayload = ensureOpusFmtp(media);
|
||||
const opusPayload = ensureOpusFmtp(media, opusMaxAverageBitrateBps, stereoMids.includes(mid));
|
||||
if (opusPayload > 0) {
|
||||
if (!media.rtcpFb) {
|
||||
media.rtcpFb = [];
|
||||
@@ -629,6 +635,32 @@ function ensureAudioNackAndStereo(
|
||||
}
|
||||
}
|
||||
|
||||
export function collectStereoMids(
|
||||
trackBitrates: Array<TrackBitrateInfo>,
|
||||
media: Array<MediaDescription>,
|
||||
): Array<string> {
|
||||
const stereoMids: Array<string> = [];
|
||||
for (const trackbr of trackBitrates) {
|
||||
if (trackbr.stereo !== true || !trackbr.transceiver) {
|
||||
continue;
|
||||
}
|
||||
if (trackbr.transceiver.mid) {
|
||||
stereoMids.push(getMidString(trackbr.transceiver.mid));
|
||||
continue;
|
||||
}
|
||||
const trackId = trackbr.transceiver.sender.track?.id;
|
||||
if (trackId === undefined) {
|
||||
continue;
|
||||
}
|
||||
for (const m of media) {
|
||||
if (m.type === 'audio' && m.mid !== undefined && m.msid?.includes(trackId)) {
|
||||
stereoMids.push(getMidString(m.mid));
|
||||
}
|
||||
}
|
||||
}
|
||||
return stereoMids;
|
||||
}
|
||||
|
||||
function extractStereoAndNackAudioFromOffer(offer: RTCSessionDescriptionInit): {
|
||||
stereoMids: Array<string>;
|
||||
nackMids: Array<string>;
|
||||
|
||||
@@ -27,7 +27,59 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
const highProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f');
|
||||
const rtx = codec('video/rtx');
|
||||
const preferences = selectPublisherCodecPreferences('h264', [openH264, rtx, externalBaseline, highProfile]);
|
||||
expect(preferences).toEqual([externalBaseline, highProfile, openH264, rtx]);
|
||||
expect(preferences).toEqual([externalBaseline, openH264, highProfile, rtx]);
|
||||
});
|
||||
|
||||
it('ranks Constrained Baseline above Main, High and Constrained High', () => {
|
||||
const constrainedBaseline = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const mainProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f');
|
||||
const highProfileLevel31 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f',
|
||||
);
|
||||
const highProfileLevel51 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033',
|
||||
);
|
||||
const constrainedHigh = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c1f',
|
||||
);
|
||||
const preferences = selectPublisherCodecPreferences('h264', [
|
||||
mainProfile,
|
||||
highProfileLevel31,
|
||||
highProfileLevel51,
|
||||
constrainedHigh,
|
||||
constrainedBaseline,
|
||||
]);
|
||||
expect(preferences).toEqual([
|
||||
constrainedBaseline,
|
||||
mainProfile,
|
||||
highProfileLevel31,
|
||||
highProfileLevel51,
|
||||
constrainedHigh,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps Constrained Baseline packetization-mode=1 ahead of Constrained Baseline packetization-mode=0 and High', () => {
|
||||
const constrainedBaselineMode0 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f',
|
||||
);
|
||||
const constrainedBaselineMode1 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const highProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033');
|
||||
const preferences = selectPublisherCodecPreferences('h264', [
|
||||
highProfile,
|
||||
constrainedBaselineMode0,
|
||||
constrainedBaselineMode1,
|
||||
]);
|
||||
expect(preferences).toEqual([constrainedBaselineMode1, constrainedBaselineMode0, highProfile]);
|
||||
});
|
||||
|
||||
it('keeps non-H.264 codecs in browser capability order and appends RTX', () => {
|
||||
|
||||
@@ -96,7 +96,7 @@ const videoCodecMimeTypes: Record<VideoCodec, Array<string>> = {
|
||||
vp8: ['video/vp8'],
|
||||
};
|
||||
const h264OpenH264ProfileLevelId = '42e01f';
|
||||
const h264PreferredHardwareProfileLevelIds = new Set(['42001f', '4d001f', '64001f']);
|
||||
const h264PreferredHardwareProfileLevelIds = new Set(['42001f']);
|
||||
type RtpCodecCapability = RTCRtpCapabilities['codecs'][number] & {sdpFmtpLine?: string};
|
||||
|
||||
enum PCState {
|
||||
@@ -1656,8 +1656,8 @@ function getH264PublisherCodecScore(codec: RtpCodecCapability): number {
|
||||
const packetizationMode = getFmtpParameter(codec.sdpFmtpLine, 'packetization-mode');
|
||||
const packetizationScore = packetizationMode === '1' ? 0 : 1;
|
||||
if (profileLevelId && h264PreferredHardwareProfileLevelIds.has(profileLevelId)) return packetizationScore;
|
||||
if (profileLevelId && profileLevelId !== h264OpenH264ProfileLevelId) return 10 + packetizationScore;
|
||||
if (profileLevelId === h264OpenH264ProfileLevelId) return 20 + packetizationScore;
|
||||
if (profileLevelId === h264OpenH264ProfileLevelId) return 10 + packetizationScore;
|
||||
if (profileLevelId) return 20 + packetizationScore;
|
||||
return 30 + packetizationScore;
|
||||
}
|
||||
|
||||
|
||||
@@ -1040,6 +1040,7 @@ export default class LocalParticipant extends Participant {
|
||||
transceiver: trackTransceiver,
|
||||
codec: 'opus',
|
||||
maxbr: encodings[0]?.maxBitrate ? encodings[0].maxBitrate / 1000 : 0,
|
||||
stereo: isStereo,
|
||||
});
|
||||
}
|
||||
} else if (track.codec) {
|
||||
|
||||
@@ -11,6 +11,7 @@ const JSX_EXTENSIONS = new Set(['.tsx', '.jsx']);
|
||||
const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.cache', '.swc']);
|
||||
const GENERATED_FILES = new Set([join(SOURCE_DIR, 'features', 'ui', 'components', 'SVGMasks.tsx')]);
|
||||
const NON_THEMEABLE_IDENTIFIERS = new Set([
|
||||
'AppI18nProvider',
|
||||
'Fragment',
|
||||
'I18nProvider',
|
||||
'Outlet',
|
||||
|
||||
@@ -23,6 +23,7 @@ import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state
|
||||
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
|
||||
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import MemberSidebar from '@app/features/member/state/MemberSidebar';
|
||||
import {startDeepLinkHandling} from '@app/features/navigation/utils/DeepLinkUtils';
|
||||
import {Outlet, RouterProvider} from '@app/features/platform/components/router/RouterReact';
|
||||
@@ -60,7 +61,6 @@ import {useStopFlashFrameOnFocus} from '@app/features/window/hooks/useStopFlashF
|
||||
import {useWindowEventListeners} from '@app/features/window/hooks/useWindowEventListeners';
|
||||
import {i18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {I18nProvider} from '@lingui/react';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {IconContext} from '@phosphor-icons/react';
|
||||
import {reaction} from 'mobx';
|
||||
@@ -239,7 +239,7 @@ export const App = observer((): React.ReactElement => {
|
||||
return () => detach?.();
|
||||
}, []);
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<IconContext.Provider value={{color: 'currentColor', weight: 'fill'}}>
|
||||
<PremiumCheckoutReturnWatcher data-flx="app.app.premium-checkout-return-watcher" />
|
||||
<DndContext data-flx="app.app.dnd-context">
|
||||
@@ -250,6 +250,6 @@ export const App = observer((): React.ReactElement => {
|
||||
</RouterProvider>
|
||||
</DndContext>
|
||||
</IconContext.Provider>
|
||||
</I18nProvider>
|
||||
</AppI18nProvider>
|
||||
);
|
||||
});
|
||||
|
||||
Vendored
+2
@@ -18,6 +18,8 @@ interface FluxerDebugApi {
|
||||
getClientInfoObjectSync?: () => unknown;
|
||||
getVoiceSubscriptionDebug?: () => Promise<unknown>;
|
||||
getVoiceSubscriptionDebugJson?: () => Promise<string>;
|
||||
getTranslationDomGuardStats?: () => unknown;
|
||||
uninstallTranslationDomGuard?: () => boolean;
|
||||
}
|
||||
|
||||
type FluxerDebugGlobal = Record<string, unknown> & FluxerDebugApi;
|
||||
|
||||
+165
-6
@@ -2,6 +2,7 @@
|
||||
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {PerksButton} from '@app/features/app/components/dialogs/components/PerksButton';
|
||||
import {useSubscriptionActions} from '@app/features/app/components/dialogs/components/plutonium/hooks/useSubscriptionActions';
|
||||
import type {GracePeriodInfo} from '@app/features/app/components/dialogs/components/plutonium/hooks/useSubscriptionStatus';
|
||||
import statusStyles from '@app/features/app/components/dialogs/components/plutonium/PurchaseHistoryStatus.module.css';
|
||||
import styles from '@app/features/app/components/dialogs/components/plutonium/SubscriptionCard.module.css';
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
PREMIUM_PRODUCT_NAME,
|
||||
} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {JOIN_COMMUNITY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {
|
||||
CLAIM_ACCOUNT_TO_PURCHASE_OR_REDEEM_PREMIUM_DESCRIPTOR,
|
||||
MANAGE_SUBSCRIPTION_DESCRIPTOR,
|
||||
@@ -21,6 +23,7 @@ import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {getFormattedLongDate} from '@fluxer/date_utils/src/DateFormatting';
|
||||
import type {PendingSubscriptionChangeResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -55,6 +58,14 @@ const SWITCH_TO_MONTHLY_DESCRIPTOR = msg({
|
||||
message: 'Switch to monthly',
|
||||
comment: 'Button confirming a change to monthly subscription billing.',
|
||||
});
|
||||
const SWITCH_TO_THE_NEW_PRICE_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Switch to the new price?',
|
||||
comment: 'Billing confirmation title for moving an active subscription down to the current price.',
|
||||
});
|
||||
const SWITCH_PRICE_DESCRIPTOR = msg({
|
||||
message: 'Switch price',
|
||||
comment: 'Button confirming a move of an active subscription down to the current price.',
|
||||
});
|
||||
const NOT_NOW_DESCRIPTOR = msg({
|
||||
message: 'Not now',
|
||||
comment: 'Button that cancels a subscription billing change.',
|
||||
@@ -171,6 +182,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
purchaseDisabledTooltip,
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const {loadingSwitchToListPrice, handleSwitchToListPrice} = useSubscriptionActions();
|
||||
const {isInGracePeriod, isExpired: isFullyExpired, graceEndDate} = gracePeriodInfo;
|
||||
const tooltipText: string | (() => React.ReactNode) =
|
||||
purchaseDisabledTooltip != null
|
||||
@@ -184,9 +196,26 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
const effectiveYearlyPrice =
|
||||
billingCycle === 'yearly' && currentSubscriptionPriceLabel ? currentSubscriptionPriceLabel : yearlyPrice;
|
||||
const hasPendingSubscriptionChange = pendingSubscriptionChange != null && !premiumWillCancel;
|
||||
const currentUserId = Users.currentUser?.id;
|
||||
const listPriceSwitch =
|
||||
currentUserId != null && PremiumState.loadedForUserId === currentUserId
|
||||
? (PremiumState.state?.billing.list_price_switch ?? null)
|
||||
: null;
|
||||
const listPriceNewLabel = listPriceSwitch
|
||||
? formatMinorUnitPrice(listPriceSwitch.list_amount_minor, listPriceSwitch.currency, locale)
|
||||
: null;
|
||||
const listPriceCurrentLabel = listPriceSwitch
|
||||
? formatMinorUnitPrice(listPriceSwitch.current_amount_minor, listPriceSwitch.currency, locale)
|
||||
: null;
|
||||
const listPriceEffectiveDate = listPriceSwitch?.effective_at
|
||||
? getFormattedLongDate(listPriceSwitch.effective_at, locale)
|
||||
: null;
|
||||
const hasPendingListPriceSwitch = listPriceSwitch?.pending === true && !premiumWillCancel;
|
||||
const pendingChangeDate = pendingSubscriptionChange
|
||||
? getFormattedLongDate(new Date(pendingSubscriptionChange.effective_at), locale)
|
||||
: null;
|
||||
: hasPendingListPriceSwitch
|
||||
? listPriceEffectiveDate
|
||||
: null;
|
||||
const pendingInitialPriceLabel = pendingSubscriptionChange
|
||||
? formatMinorUnitPrice(pendingSubscriptionChange.initial_amount_minor, pendingSubscriptionChange.currency, locale)
|
||||
: null;
|
||||
@@ -200,6 +229,26 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
const pendingCreditPriceLabel = pendingSubscriptionChange
|
||||
? formatMinorUnitPrice(pendingSubscriptionChange.credit_amount_minor, pendingSubscriptionChange.currency, locale)
|
||||
: null;
|
||||
const canSwitchToListPrice =
|
||||
listPriceSwitch?.available === true &&
|
||||
!premiumWillCancel &&
|
||||
!hasPendingSubscriptionChange &&
|
||||
listPriceNewLabel != null &&
|
||||
listPriceCurrentLabel != null &&
|
||||
listPriceEffectiveDate != null;
|
||||
const shouldSuggestCancelingPendingChange =
|
||||
listPriceSwitch?.available === false &&
|
||||
listPriceSwitch.reason === 'conflicting_pending_change' &&
|
||||
listPriceSwitch.list_amount_minor != null &&
|
||||
listPriceSwitch.current_amount_minor != null &&
|
||||
listPriceSwitch.list_amount_minor < listPriceSwitch.current_amount_minor;
|
||||
const shouldMentionListPriceWhileCancelling =
|
||||
listPriceSwitch?.available === false &&
|
||||
listPriceSwitch.reason === 'subscription_cancelling' &&
|
||||
listPriceSwitch.list_amount_minor != null &&
|
||||
listPriceSwitch.current_amount_minor != null &&
|
||||
listPriceSwitch.list_amount_minor < listPriceSwitch.current_amount_minor;
|
||||
const pendingTargetPriceLabel = hasPendingListPriceSwitch ? listPriceNewLabel : null;
|
||||
const grandfatheredTooltip =
|
||||
isCurrentSubscriptionGrandfathered && currentSubscriptionListPriceLabel
|
||||
? i18n._(LEGACY_RATE_WITH_PRICE_DESCRIPTOR, {currentSubscriptionListPriceLabel})
|
||||
@@ -314,6 +363,32 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
yearlyAmountMinor,
|
||||
],
|
||||
);
|
||||
const handleConfirmSwitchToListPrice = useCallback(() => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(SWITCH_TO_THE_NEW_PRICE_TITLE_DESCRIPTOR)}
|
||||
description={
|
||||
<Trans comment="Billing confirmation body for moving an active subscription down to the current price. {listPriceEffectiveDate} is a date, and {listPriceCurrentLabel} and {listPriceNewLabel} are currency amounts, all already formatted and localized by code; never write a date or an amount into the translation.">
|
||||
On{' '}
|
||||
<strong data-flx="app.plutonium.subscription-card.handle-confirm-switch-to-list-price.strong">
|
||||
{listPriceEffectiveDate}
|
||||
</strong>{' '}
|
||||
your subscription moves from {listPriceCurrentLabel} to {listPriceNewLabel}. Nothing is charged today,
|
||||
and the rest of your subscription stays exactly as it is.
|
||||
</Trans>
|
||||
}
|
||||
primaryText={i18n._(SWITCH_PRICE_DESCRIPTOR)}
|
||||
primaryVariant="primary"
|
||||
secondaryText={i18n._(NOT_NOW_DESCRIPTOR)}
|
||||
onPrimary={async () => {
|
||||
await handleSwitchToListPrice();
|
||||
}}
|
||||
data-flx="app.plutonium.subscription-card.handle-confirm-switch-to-list-price.confirm-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}, [handleSwitchToListPrice, i18n, listPriceCurrentLabel, listPriceEffectiveDate, listPriceNewLabel]);
|
||||
const wrapIfDisabled = (element: React.ReactElement, key: string, disabled: boolean) =>
|
||||
disabled ? (
|
||||
<Tooltip key={key} text={tooltipText} data-flx="app.plutonium.subscription-card.wrap-if-disabled.tooltip">
|
||||
@@ -498,8 +573,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
)}
|
||||
</div>
|
||||
{!isVisionary &&
|
||||
hasPendingSubscriptionChange &&
|
||||
pendingSubscriptionChange &&
|
||||
(hasPendingSubscriptionChange || hasPendingListPriceSwitch) &&
|
||||
pendingChangeDate &&
|
||||
!isInGracePeriod &&
|
||||
!isFullyExpired &&
|
||||
@@ -508,7 +582,20 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
className={styles.pendingChangeInfo}
|
||||
data-flx="app.plutonium.subscription-card.pending-change-info"
|
||||
>
|
||||
{pendingSubscriptionChange.target_billing_cycle === 'yearly' ? (
|
||||
{hasPendingListPriceSwitch ? (
|
||||
pendingTargetPriceLabel ? (
|
||||
<Trans comment="Plutonium subscription card line shown when a move to a lower price is scheduled. {pendingChangeDate} is a date and {pendingTargetPriceLabel} is a currency amount, both already formatted and localized by code; never write a date or an amount into the translation.">
|
||||
New price scheduled for{' '}
|
||||
<strong data-flx="app.plutonium.subscription-card.strong--13">{pendingChangeDate}</strong>.
|
||||
Renewals will be {pendingTargetPriceLabel} from then on.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Plutonium subscription card line shown when a move to a lower price is scheduled but the new amount is unknown. {pendingChangeDate} is a date already formatted and localized by code; never write a date into the translation.">
|
||||
New price scheduled for{' '}
|
||||
<strong data-flx="app.plutonium.subscription-card.strong--13">{pendingChangeDate}</strong>.
|
||||
</Trans>
|
||||
)
|
||||
) : pendingSubscriptionChange?.target_billing_cycle === 'yearly' ? (
|
||||
pendingInitialPriceLabel && pendingRecurringPriceLabel ? (
|
||||
pendingCreditPriceLabel ? (
|
||||
<Trans>
|
||||
@@ -546,6 +633,59 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isVisionary &&
|
||||
shouldSuggestCancelingPendingChange &&
|
||||
!isInGracePeriod &&
|
||||
!isFullyExpired &&
|
||||
!isGiftSubscription && (
|
||||
<div
|
||||
className={styles.pendingChangeInfo}
|
||||
data-flx="app.plutonium.subscription-card.list-price-switch-blocked-info"
|
||||
>
|
||||
<Trans comment="Plutonium subscription card hint shown to a subscriber who could move to a lower price but has another billing change already scheduled.">
|
||||
Cancel the scheduled change to move to the current price instead.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
{!isVisionary &&
|
||||
shouldMentionListPriceWhileCancelling &&
|
||||
listPriceNewLabel &&
|
||||
premiumUntil &&
|
||||
!isInGracePeriod &&
|
||||
!isFullyExpired &&
|
||||
!isGiftSubscription &&
|
||||
(() => {
|
||||
const cancelDate = getFormattedLongDate(premiumUntil, locale);
|
||||
return (
|
||||
<div
|
||||
className={styles.pendingChangeInfo}
|
||||
data-flx="app.plutonium.subscription-card.list-price-switch-cancelling-info"
|
||||
>
|
||||
<Trans comment="Plutonium subscription card line shown to a subscriber on a legacy price whose subscription is already set to cancel. {cancelDate} is a date and {listPriceNewLabel} is a currency amount, both already formatted and localized by code; never write a date or an amount into the translation.">
|
||||
Your subscription still ends on{' '}
|
||||
<strong data-flx="app.plutonium.subscription-card.strong--15">{cancelDate}</strong>. The current
|
||||
price is now {listPriceNewLabel}. If you reactivate, you can switch to it from here.
|
||||
</Trans>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{!isVisionary &&
|
||||
canSwitchToListPrice &&
|
||||
listPriceEffectiveDate &&
|
||||
!isInGracePeriod &&
|
||||
!isFullyExpired &&
|
||||
!isGiftSubscription && (
|
||||
<div
|
||||
className={styles.pendingChangeInfo}
|
||||
data-flx="app.plutonium.subscription-card.list-price-switch-info"
|
||||
>
|
||||
<Trans comment="Plutonium subscription card line offering a move to a lower price. {listPriceEffectiveDate} is a date already formatted and localized by code; never write a date into the translation.">
|
||||
A lower price is available. Switching takes effect on{' '}
|
||||
<strong data-flx="app.plutonium.subscription-card.strong--14">{listPriceEffectiveDate}</strong> and
|
||||
changes nothing else.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
{!isVisionary &&
|
||||
premiumUntil &&
|
||||
!premiumWillCancel &&
|
||||
@@ -628,6 +768,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
{shouldUseChangePlanQuickAction &&
|
||||
targetBillingCycle &&
|
||||
!hasPendingSubscriptionChange &&
|
||||
!hasPendingListPriceSwitch &&
|
||||
wrapIfDisabled(
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -651,7 +792,21 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
'change-plan',
|
||||
purchaseDisabled,
|
||||
)}
|
||||
{hasPendingSubscriptionChange && (
|
||||
{canSwitchToListPrice && listPriceNewLabel && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleConfirmSwitchToListPrice}
|
||||
submitting={loadingSwitchToListPrice}
|
||||
small
|
||||
className={styles.actionButton}
|
||||
data-flx="app.plutonium.subscription-card.action-button.confirm-switch-to-list-price"
|
||||
>
|
||||
<Trans comment="Billing button that opens confirmation to move the subscription down to the current price. {listPriceNewLabel} is the localized new price.">
|
||||
Switch to {listPriceNewLabel}
|
||||
</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{(hasPendingSubscriptionChange || hasPendingListPriceSwitch) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancelPendingSubscriptionChange}
|
||||
@@ -660,7 +815,11 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
className={styles.actionButton}
|
||||
data-flx="app.plutonium.subscription-card.action-button.cancel-pending-subscription-change"
|
||||
>
|
||||
{pendingSubscriptionChange?.target_billing_cycle === 'yearly' ? (
|
||||
{hasPendingListPriceSwitch ? (
|
||||
<Trans comment="Billing button that cancels a scheduled move to a lower subscription price.">
|
||||
Cancel price change
|
||||
</Trans>
|
||||
) : pendingSubscriptionChange?.target_billing_cycle === 'yearly' ? (
|
||||
<Trans comment="Billing button that cancels a scheduled yearly upgrade.">
|
||||
Cancel yearly upgrade
|
||||
</Trans>
|
||||
|
||||
+4
-1
@@ -106,6 +106,9 @@ export const usePremiumData = (
|
||||
currentSubscriptionPrice,
|
||||
currentSubscriptionPriceLabel,
|
||||
currentSubscriptionListPriceLabel,
|
||||
isCurrentSubscriptionGrandfathered: currentSubscriptionPrice?.is_grandfathered ?? false,
|
||||
isCurrentSubscriptionGrandfathered:
|
||||
currentSubscriptionPrice?.is_grandfathered === true &&
|
||||
(currentSubscriptionPrice.list_amount_minor == null ||
|
||||
currentSubscriptionPrice.list_amount_minor > currentSubscriptionPrice.amount_minor),
|
||||
};
|
||||
};
|
||||
|
||||
+60
@@ -5,6 +5,8 @@ import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
|
||||
import {getFormattedLongDate} from '@fluxer/date_utils/src/DateFormatting';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {useCallback, useState} from 'react';
|
||||
@@ -91,6 +93,31 @@ const CANCEL_PENDING_CHANGE_FAILED_MESSAGE_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Body of the generic fallback error modal shown when canceling a scheduled Plutonium billing cycle change fails.',
|
||||
});
|
||||
const YOUR_SUBSCRIPTION_WILL_MOVE_TO_THE_NEW_PRICE_DESCRIPTOR = msg({
|
||||
message: 'Your subscription moves to the new price on {effectiveDate}.',
|
||||
comment:
|
||||
'Toast success shown after a Plutonium subscription is scheduled to move down to the current price. {effectiveDate} is the localized date the new price starts.',
|
||||
});
|
||||
const THE_NEW_PRICE_IS_ALREADY_SCHEDULED_DESCRIPTOR = msg({
|
||||
message: 'The new price is already scheduled for {effectiveDate}.',
|
||||
comment:
|
||||
'Toast success shown when a move down to the current price was already scheduled by an earlier request. {effectiveDate} is the localized date the new price starts.',
|
||||
});
|
||||
const SWITCH_TO_LIST_PRICE_UNAVAILABLE_DESCRIPTOR = msg({
|
||||
message: "Your subscription can't move to the new price right now.",
|
||||
comment:
|
||||
'Toast error shown when the server refuses to move a Plutonium subscription down to the current price, whatever the reason.',
|
||||
});
|
||||
const SWITCH_TO_LIST_PRICE_FAILED_TITLE_DESCRIPTOR = msg({
|
||||
message: "Couldn't change your price",
|
||||
comment:
|
||||
'Title of the generic fallback error modal shown when moving a Plutonium subscription down to the current price fails.',
|
||||
});
|
||||
const SWITCH_TO_LIST_PRICE_FAILED_MESSAGE_DESCRIPTOR = msg({
|
||||
message: 'Something went wrong while changing your price. Please try again in a moment.',
|
||||
comment:
|
||||
'Body of the generic fallback error modal shown when moving a Plutonium subscription down to the current price fails.',
|
||||
});
|
||||
const logger = new Logger('useSubscriptionActions');
|
||||
export const useSubscriptionActions = (countryCode?: string | null) => {
|
||||
const {i18n} = useLingui();
|
||||
@@ -100,6 +127,7 @@ export const useSubscriptionActions = (countryCode?: string | null) => {
|
||||
const [loadingEndGrace, setLoadingEndGrace] = useState(false);
|
||||
const [loadingChangeBillingCycle, setLoadingChangeBillingCycle] = useState<'monthly' | 'yearly' | null>(null);
|
||||
const [loadingCancelPendingChange, setLoadingCancelPendingChange] = useState(false);
|
||||
const [loadingSwitchToListPrice, setLoadingSwitchToListPrice] = useState(false);
|
||||
const handleOpenCustomerPortal = useCallback(async () => {
|
||||
setLoadingPortal(true);
|
||||
try {
|
||||
@@ -213,6 +241,36 @@ export const useSubscriptionActions = (countryCode?: string | null) => {
|
||||
},
|
||||
[countryCode, i18n],
|
||||
);
|
||||
const handleSwitchToListPrice = useCallback(async () => {
|
||||
setLoadingSwitchToListPrice(true);
|
||||
try {
|
||||
const result = await PremiumCommands.switchSubscriptionToListPrice();
|
||||
await PremiumCommands.refreshPremiumState(countryCode ?? undefined);
|
||||
if (result.status === 'ineligible') {
|
||||
logger.warn('List price switch refused', {reason: result.reason});
|
||||
ToastCommands.error(i18n._(SWITCH_TO_LIST_PRICE_UNAVAILABLE_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
const effectiveDate = getFormattedLongDate(result.effective_at, LocaleUtils.getCurrentLocale());
|
||||
ToastCommands.success(
|
||||
result.status === 'already_scheduled'
|
||||
? i18n._(THE_NEW_PRICE_IS_ALREADY_SCHEDULED_DESCRIPTOR, {effectiveDate})
|
||||
: i18n._(YOUR_SUBSCRIPTION_WILL_MOVE_TO_THE_NEW_PRICE_DESCRIPTOR, {effectiveDate}),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to switch subscription to the current list price', error);
|
||||
showPremiumActionErrorModal(
|
||||
error,
|
||||
{
|
||||
fallbackTitle: SWITCH_TO_LIST_PRICE_FAILED_TITLE_DESCRIPTOR,
|
||||
fallbackMessage: SWITCH_TO_LIST_PRICE_FAILED_MESSAGE_DESCRIPTOR,
|
||||
},
|
||||
'app.plutonium.use-subscription-actions.switch-to-list-price.generic-error-modal',
|
||||
);
|
||||
} finally {
|
||||
setLoadingSwitchToListPrice(false);
|
||||
}
|
||||
}, [countryCode, i18n]);
|
||||
const handleCancelPendingSubscriptionChange = useCallback(async () => {
|
||||
setLoadingCancelPendingChange(true);
|
||||
try {
|
||||
@@ -240,11 +298,13 @@ export const useSubscriptionActions = (countryCode?: string | null) => {
|
||||
loadingEndGrace,
|
||||
loadingChangeBillingCycle,
|
||||
loadingCancelPendingChange,
|
||||
loadingSwitchToListPrice,
|
||||
handleOpenCustomerPortal,
|
||||
handleCancelSubscription,
|
||||
handleEndPremiumGracePeriod,
|
||||
handleReactivateSubscription,
|
||||
handleChangeSubscriptionBillingCycle,
|
||||
handleCancelPendingSubscriptionChange,
|
||||
handleSwitchToListPrice,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -25,7 +25,6 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
|
||||
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import {MockAvatar} from '@app/features/ui/components/MockAvatar';
|
||||
import {StatusIndicator} from '@app/features/ui/components/StatusIndicator';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
@@ -52,9 +51,8 @@ import Users from '@app/features/user/state/Users';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
import * as ProfileDisplayUtils from '@app/features/user/utils/ProfileDisplayUtils';
|
||||
import {createMockProfile} from '@app/features/user/utils/ProfileUtils';
|
||||
import {COPIED_STATS_JSON_DESCRIPTOR} from '@app/features/voice/components/StatsForNerdsCopyDescriptors';
|
||||
import {copyVoiceDiagnostics} from '@app/features/voice/commands/VoiceDiagnosticsCommands';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {buildStatsForNerdsCopyPayload, collectStatsForNerdsSnapshot} from '@app/features/voice/utils/StatsForNerdsCopy';
|
||||
import {MEDIA_PROXY_PROFILE_BANNER_SIZE_POPOUT} from '@fluxer/constants/src/MediaProxyAssetSizes';
|
||||
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
@@ -462,12 +460,7 @@ export const UserAreaPopout = observer(() => {
|
||||
});
|
||||
}, [i18n]);
|
||||
const handleCopyStats = useCallback(() => {
|
||||
const data = collectStatsForNerdsSnapshot();
|
||||
void buildStatsForNerdsCopyPayload(data).then((payload) => {
|
||||
void navigator.clipboard.writeText(JSON.stringify(payload, null, 2)).then(() => {
|
||||
ToastCommands.createToast({type: 'success', children: i18n._(COPIED_STATS_JSON_DESCRIPTOR)});
|
||||
});
|
||||
});
|
||||
void copyVoiceDiagnostics(i18n);
|
||||
}, [i18n]);
|
||||
const handleCopyUserTag = useCallback(() => {
|
||||
if (!currentUser) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {AuthCardContainer} from '@app/features/auth/flow/AuthCardContainer';
|
||||
import {useAuthBackground} from '@app/features/auth/hooks/useAuthBackground';
|
||||
import {type AuthCardVariant, AuthLayoutContext} from '@app/features/auth/state/AuthLayoutContext';
|
||||
import {AuthRegisterDraftContext, type AuthRegisterFormDraft} from '@app/features/auth/state/AuthRegisterDraftContext';
|
||||
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import {FluxerWordmark} from '@app/features/ui/components/icons/FluxerWordmark';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
@@ -21,7 +22,6 @@ import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
|
||||
import foodPatternUrl from '@app/media/images/i-like-food.svg';
|
||||
import type {GuildSplashCardAlignmentValue} from '@fluxer/constants/src/GuildConstants';
|
||||
import {GuildSplashCardAlignment} from '@fluxer/constants/src/GuildConstants';
|
||||
import {I18nProvider} from '@lingui/react';
|
||||
import clsx from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
@@ -258,8 +258,8 @@ export const AuthLayout = observer(function AuthLayout({children}: {children?: R
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<AppI18nProvider i18n={i18n}>
|
||||
<AuthLayoutContent data-flx="app.auth-layout.auth-layout-content">{children}</AuthLayoutContent>
|
||||
</I18nProvider>
|
||||
</AppI18nProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1275,8 +1275,11 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
|
||||
[NagbarType.PREMIUM_GRACE_PERIOD]: {tone: SkeletonNagbarTone.PREMIUM, hasActions: true},
|
||||
[NagbarType.PREMIUM_EXPIRED]: {tone: SkeletonNagbarTone.DANGER, hasActions: true},
|
||||
[NagbarType.PREMIUM_ONBOARDING]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.PRICE_ANNOUNCEMENT]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.LEGACY_PRICE_OPT_IN]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.GIFT_INVENTORY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.DESKTOP_DOWNLOAD]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.DESKTOP_UPDATE_READY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.GUILD_MEMBERSHIP_CTA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.VISIONARY_MFA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.VOICE_SESSION_RESTORE]: {tone: SkeletonNagbarTone.VOICE, hasActions: true},
|
||||
|
||||
@@ -11,12 +11,15 @@ import Config from '@app/features/app/config/Config';
|
||||
import {isClientReconnecting} from '@app/features/app/state/ClientReadiness';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Updater from '@app/features/app/state/Updater';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import * as NotificationUtils from '@app/features/notification/utils/NotificationUtils';
|
||||
import NativePermission from '@app/features/permissions/system/state/NativePermission';
|
||||
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
|
||||
import Nagbar from '@app/features/ui/state/Nagbar';
|
||||
import {hasUnavailableElectronNativeContext, isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
@@ -150,6 +153,27 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
user?.isPremium() && !user?.hasDismissedPremiumOnboarding && !nagbarState.premiumOnboardingDismissed,
|
||||
);
|
||||
})();
|
||||
const premiumState = PremiumState.loadedForUserId === user?.id ? PremiumState.state : null;
|
||||
const priceAnnouncementCampaign = resolvePriceAnnouncementCampaign(premiumState);
|
||||
const hasPurchaseReadyAccount = Boolean(user?.isClaimed() && (!RuntimeConfig.emailsEnabled || user.verified));
|
||||
const canShowPriceAnnouncement = (() => {
|
||||
if (isSelfHosted) return false;
|
||||
if (!hasPurchaseReadyAccount) return false;
|
||||
if (!premiumState || !priceAnnouncementCampaign) return false;
|
||||
if (premiumState.actual.has_active_paid_premium) return false;
|
||||
return !nagbarState.getPriceAnnouncementDismissed(priceAnnouncementCampaign.campaign.id);
|
||||
})();
|
||||
const canShowLegacyPriceOptIn = (() => {
|
||||
if (isSelfHosted) return false;
|
||||
if (!hasPurchaseReadyAccount) return false;
|
||||
if (!premiumState || !priceAnnouncementCampaign) return false;
|
||||
const listPriceSwitch = premiumState.billing.list_price_switch ?? null;
|
||||
if (!listPriceSwitch?.available || listPriceSwitch.pending) return false;
|
||||
if (listPriceSwitch.currency !== priceAnnouncementCampaign.currency) return false;
|
||||
if (listPriceSwitch.current_amount_minor == null || listPriceSwitch.list_amount_minor == null) return false;
|
||||
if (listPriceSwitch.effective_at == null || listPriceSwitch.billing_cycle == null) return false;
|
||||
return !nagbarState.getLegacyPriceOptInDismissed(priceAnnouncementCampaign.campaign.id);
|
||||
})();
|
||||
const isNativeDesktop = isDesktop();
|
||||
const hasBrokenElectronNativeContext = hasUnavailableElectronNativeContext();
|
||||
const isMobileDevice = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
@@ -230,6 +254,7 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
const canShowLinuxInputAccess = NativePermission.shouldShowLinuxInputAccessNagbar;
|
||||
const canShowSoftwareEncoder = SoftwareEncoderWarning.showWarning;
|
||||
const canShowStreamerMode = StreamerMode.shouldShowNagbar;
|
||||
const canShowDesktopUpdateReady = Updater.shouldShowUpdateReadyNagbar;
|
||||
const canShowBuildEnvironment =
|
||||
!BUILD_ENVIRONMENT_HIDDEN_RELEASE_CHANNELS.has(Config.PUBLIC_RELEASE_CHANNEL) &&
|
||||
!nagbarState.buildEnvironmentDismissedThisSession;
|
||||
@@ -279,6 +304,8 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
canShowPremiumGracePeriod,
|
||||
canShowPremiumExpired,
|
||||
canShowPremiumOnboarding,
|
||||
canShowPriceAnnouncement,
|
||||
canShowLegacyPriceOptIn,
|
||||
canShowGiftInventory,
|
||||
canShowDesktopDownload,
|
||||
canShowGuildMembershipCta,
|
||||
@@ -288,6 +315,7 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
canShowLinuxInputAccess,
|
||||
canShowSoftwareEncoder,
|
||||
canShowStreamerMode,
|
||||
canShowDesktopUpdateReady,
|
||||
};
|
||||
};
|
||||
export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarState> => {
|
||||
@@ -353,6 +381,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
||||
visible: conditions.canShowVoiceSessionRestore,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.LEGACY_PRICE_OPT_IN,
|
||||
priority: 2,
|
||||
visible: conditions.canShowLegacyPriceOptIn,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.PREMIUM_ONBOARDING,
|
||||
priority: 4,
|
||||
@@ -365,6 +399,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
||||
visible: conditions.canShowGiftInventory,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.PRICE_ANNOUNCEMENT,
|
||||
priority: 5.5,
|
||||
visible: conditions.canShowPriceAnnouncement,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.GUILD_MEMBERSHIP_CTA,
|
||||
priority: 6,
|
||||
@@ -407,6 +447,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
||||
visible: conditions.canShowStreamerMode,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.DESKTOP_UPDATE_READY,
|
||||
priority: -1.5,
|
||||
visible: conditions.canShowDesktopUpdateReady,
|
||||
dismissible: true,
|
||||
},
|
||||
];
|
||||
return selectVisibleNagbars(nagbars);
|
||||
}, [conditions]);
|
||||
|
||||
@@ -13,8 +13,11 @@ export const NagbarType = {
|
||||
PREMIUM_GRACE_PERIOD: 'premium-grace-period',
|
||||
PREMIUM_EXPIRED: 'premium-expired',
|
||||
PREMIUM_ONBOARDING: 'premium-onboarding',
|
||||
PRICE_ANNOUNCEMENT: 'price-announcement',
|
||||
LEGACY_PRICE_OPT_IN: 'legacy-price-opt-in',
|
||||
GIFT_INVENTORY: 'gift-inventory',
|
||||
DESKTOP_DOWNLOAD: 'desktop-download',
|
||||
DESKTOP_UPDATE_READY: 'desktop-update-ready',
|
||||
GUILD_MEMBERSHIP_CTA: 'guild-membership-cta',
|
||||
VISIONARY_MFA: 'visionary-mfa',
|
||||
VOICE_SESSION_RESTORE: 'voice-session-restore',
|
||||
@@ -48,8 +51,11 @@ export interface NagbarConditions {
|
||||
canShowPremiumGracePeriod: boolean;
|
||||
canShowPremiumExpired: boolean;
|
||||
canShowPremiumOnboarding: boolean;
|
||||
canShowPriceAnnouncement: boolean;
|
||||
canShowLegacyPriceOptIn: boolean;
|
||||
canShowGiftInventory: boolean;
|
||||
canShowDesktopDownload: boolean;
|
||||
canShowDesktopUpdateReady: boolean;
|
||||
canShowGuildMembershipCta: boolean;
|
||||
canShowVisionaryMfa: boolean;
|
||||
canShowVoiceSessionRestore: boolean;
|
||||
|
||||
@@ -7,13 +7,16 @@ import {ConnectionNagbar} from '@app/features/app/components/layout/app_layout/n
|
||||
import {CorruptedInstallationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/CorruptedInstallationNagbar';
|
||||
import {DesktopDownloadNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopDownloadNagbar';
|
||||
import {DesktopNotificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopNotificationNagbar';
|
||||
import {DesktopUpdateReadyNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopUpdateReadyNagbar';
|
||||
import {EmailVerificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/EmailVerificationNagbar';
|
||||
import {GiftInventoryNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GiftInventoryNagbar';
|
||||
import {GuildMembershipCtaNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GuildMembershipCtaNagbar';
|
||||
import {LegacyPriceOptInNagbar} from '@app/features/app/components/layout/app_layout/nagbars/LegacyPriceOptInNagbar';
|
||||
import {LinuxInputAccessNagbar} from '@app/features/app/components/layout/app_layout/nagbars/LinuxInputAccessNagbar';
|
||||
import {PremiumExpiredNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumExpiredNagbar';
|
||||
import {PremiumGracePeriodNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumGracePeriodNagbar';
|
||||
import {PremiumOnboardingNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumOnboardingNagbar';
|
||||
import {PriceAnnouncementNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PriceAnnouncementNagbar';
|
||||
import {ScheduledMaintenanceNagbar} from '@app/features/app/components/layout/app_layout/nagbars/ScheduledMaintenanceNagbar';
|
||||
import {StreamerModeNagbar} from '@app/features/app/components/layout/app_layout/nagbars/StreamerModeNagbar';
|
||||
import {TermsAcceptanceNagbar} from '@app/features/app/components/layout/app_layout/nagbars/TermsAcceptanceNagbar';
|
||||
@@ -136,6 +139,24 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
data-flx="app.app-layout.nagbar-container.premium-onboarding-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.PRICE_ANNOUNCEMENT:
|
||||
if (!showPremiumFeatures) return null;
|
||||
return (
|
||||
<PriceAnnouncementNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.price-announcement-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.LEGACY_PRICE_OPT_IN:
|
||||
if (!showPremiumFeatures) return null;
|
||||
return (
|
||||
<LegacyPriceOptInNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.legacy-price-opt-in-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.GIFT_INVENTORY:
|
||||
if (!showPremiumFeatures) return null;
|
||||
return (
|
||||
@@ -153,6 +174,14 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
data-flx="app.app-layout.nagbar-container.desktop-download-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.DESKTOP_UPDATE_READY:
|
||||
return (
|
||||
<DesktopUpdateReadyNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.desktop-update-ready-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.GUILD_MEMBERSHIP_CTA:
|
||||
return (
|
||||
<GuildMembershipCtaNagbar
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import Updater from '@app/features/app/state/Updater';
|
||||
import {
|
||||
DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR,
|
||||
RESTART_FLUXER_DESCRIPTOR,
|
||||
THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR,
|
||||
} from '@app/features/updater/commands/UpdaterModalCommands';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
|
||||
export const DesktopUpdateReadyNagbar = observer(({isMobile}: {isMobile: boolean}) => {
|
||||
const {i18n} = useLingui();
|
||||
const version = Updater.updateInfo.native.version;
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={Updater.dismissUpdateReadyNagbar}
|
||||
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
onDismiss={Updater.dismissUpdateReadyNagbar}
|
||||
message={
|
||||
version
|
||||
? i18n._(DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR, {version, productName: PRODUCT_NAME})
|
||||
: i18n._(THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR, {productName: PRODUCT_NAME})
|
||||
}
|
||||
actions={
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={() => void Updater.applyUpdate()}
|
||||
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.restart-button"
|
||||
>
|
||||
{i18n._(RESTART_FLUXER_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
</NagbarButton>
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
});
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {MANAGE_SUBSCRIPTION_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {formatMinorUnitPrice} from '@app/features/premium/utils/PricingUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import NagbarState from '@app/features/ui/state/Nagbar';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
|
||||
import {getFormattedLongDate} from '@fluxer/date_utils/src/DateFormatting';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
export const LegacyPriceOptInNagbar = observer(function LegacyPriceOptInNagbar({isMobile}: {isMobile: boolean}) {
|
||||
const {i18n} = useLingui();
|
||||
const currentUser = Users.currentUser;
|
||||
const premiumState = PremiumState.loadedForUserId === currentUser?.id ? PremiumState.state : null;
|
||||
const resolved = resolvePriceAnnouncementCampaign(premiumState);
|
||||
const campaignId = resolved?.campaign.id ?? null;
|
||||
const handleOpenPremiumSettings = useCallback(() => {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.handle-open-premium-settings.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}, []);
|
||||
const handleDismiss = useCallback(() => {
|
||||
if (campaignId == null) return;
|
||||
NagbarState.dismissLegacyPriceOptIn(campaignId);
|
||||
}, [campaignId]);
|
||||
if (!premiumState || !resolved) return null;
|
||||
const listPriceSwitch = premiumState.billing.list_price_switch ?? null;
|
||||
if (!listPriceSwitch?.available || listPriceSwitch.pending) return null;
|
||||
if (listPriceSwitch.currency !== resolved.currency || listPriceSwitch.effective_at == null) return null;
|
||||
if (listPriceSwitch.billing_cycle == null) return null;
|
||||
const locale = LocaleUtils.getCurrentLocale();
|
||||
const currentPrice = formatMinorUnitPrice(listPriceSwitch.current_amount_minor, listPriceSwitch.currency, locale);
|
||||
const newPrice = formatMinorUnitPrice(listPriceSwitch.list_amount_minor, listPriceSwitch.currency, locale);
|
||||
if (currentPrice == null || newPrice == null) return null;
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
onDismiss={handleDismiss}
|
||||
message={i18n._(
|
||||
listPriceSwitch.billing_cycle === 'monthly'
|
||||
? resolved.campaign.optInMonthlyMessage
|
||||
: resolved.campaign.optInYearlyMessage,
|
||||
{
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
currentPrice,
|
||||
newPrice,
|
||||
effectiveDate: getFormattedLongDate(listPriceSwitch.effective_at, locale),
|
||||
},
|
||||
)}
|
||||
actions={
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={handleOpenPremiumSettings}
|
||||
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.nagbar-button.open-premium-settings"
|
||||
>
|
||||
{i18n._(MANAGE_SUBSCRIPTION_DESCRIPTOR)}
|
||||
</NagbarButton>
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
});
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {VIEW_PLANS_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
|
||||
import {formatMinorUnitPrice} from '@app/features/premium/utils/PricingUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import NagbarState from '@app/features/ui/state/Nagbar';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
export const PriceAnnouncementNagbar = observer(function PriceAnnouncementNagbar({isMobile}: {isMobile: boolean}) {
|
||||
const {i18n} = useLingui();
|
||||
const currentUser = Users.currentUser;
|
||||
const premiumState = PremiumState.loadedForUserId === currentUser?.id ? PremiumState.state : null;
|
||||
const resolved = resolvePriceAnnouncementCampaign(premiumState);
|
||||
const campaignId = resolved?.campaign.id ?? null;
|
||||
const handleOpenPlans = useCallback(() => {
|
||||
if (campaignId != null) {
|
||||
NagbarState.dismissPriceAnnouncement(campaignId);
|
||||
}
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<UserSettingsModal
|
||||
initialTab="plutonium"
|
||||
data-flx="app.app-layout.nagbars.price-announcement-nagbar.handle-open-plans.user-settings-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}, [campaignId]);
|
||||
const handleDismiss = useCallback(() => {
|
||||
if (campaignId == null) return;
|
||||
NagbarState.dismissPriceAnnouncement(campaignId);
|
||||
}, [campaignId]);
|
||||
if (!premiumState || !resolved || premiumState.actual.has_active_paid_premium) return null;
|
||||
const locale = LocaleUtils.getCurrentLocale();
|
||||
const monthlyPrice = formatMinorUnitPrice(resolved.monthlyAmountMinor, resolved.currency, locale);
|
||||
const yearlyPrice = formatMinorUnitPrice(resolved.yearlyAmountMinor, resolved.currency, locale);
|
||||
if (monthlyPrice == null || yearlyPrice == null) return null;
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.price-announcement-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
onDismiss={handleDismiss}
|
||||
message={i18n._(resolved.campaign.announcementMessage, {
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
monthlyPrice,
|
||||
yearlyPrice,
|
||||
})}
|
||||
actions={
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={handleOpenPlans}
|
||||
data-flx="app.app-layout.nagbars.price-announcement-nagbar.nagbar-button.open-plans"
|
||||
>
|
||||
{i18n._(VIEW_PLANS_DESCRIPTOR)}
|
||||
</NagbarButton>
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.price-announcement-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import styles from '@app/features/app/components/setup/SelfHostedSetupWizardGate.module.css';
|
||||
import {
|
||||
classifySetupUnauthorized,
|
||||
fetchInstanceConfig,
|
||||
type SetupBrandingAssetKind,
|
||||
testSmtpConfig,
|
||||
@@ -55,6 +56,7 @@ import {fileToBase64} from '@app/features/user/utils/AvatarUtils';
|
||||
import * as FormUtils from '@app/lib/forms';
|
||||
import {type ThemeType, ThemeTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowLeftIcon, ArrowRightIcon, CheckIcon, WrenchIcon} from '@phosphor-icons/react';
|
||||
@@ -92,6 +94,11 @@ const LOAD_ERROR_DESCRIPTOR = msg({
|
||||
message: 'Could not load the instance configuration. Try reloading the page.',
|
||||
comment: 'Error shown when the setup wizard fails to load the instance configuration.',
|
||||
});
|
||||
const ORIGIN_MISMATCH_DESCRIPTOR = msg({
|
||||
message:
|
||||
'The API is on a different origin than this page, so setup requests are sent without your session. Check the public origin and port this instance is configured with, then reload.',
|
||||
comment: 'Error shown when the setup wizard cannot load because the API origin differs from the page origin.',
|
||||
});
|
||||
const ASSET_UPLOAD_ERROR_DESCRIPTOR = msg({
|
||||
message: 'That image could not be used. Try a different file.',
|
||||
comment: 'Error shown when a branding image fails to upload in the setup wizard.',
|
||||
@@ -425,7 +432,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
const stepNavigationUnlockTimerRef = useRef<number | null>(null);
|
||||
|
||||
const [config, setConfig] = useState<InstanceConfigResponse | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [loadError, setLoadError] = useState<MessageDescriptor | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [stepNavigationLocked, setStepNavigationLocked] = useState(false);
|
||||
@@ -531,11 +538,11 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
}, [authStoreAuthenticated, forceUnauthenticatedSetup]);
|
||||
|
||||
const resetStaleSetupSession = useCallback(async () => {
|
||||
logger.warn('Instance config fetch returned 401 during setup; clearing stale local setup session');
|
||||
logger.warn('The setup session token was rejected. Clearing the stale local setup session.');
|
||||
setForceUnauthenticatedSetup(true);
|
||||
registerFormDraftsRef.current.clear();
|
||||
setConfig(null);
|
||||
setLoadError(false);
|
||||
setLoadError(null);
|
||||
setSubmitError(null);
|
||||
setSubmitting(false);
|
||||
setWizardSnapshot(createSetupWizardSnapshot());
|
||||
@@ -605,7 +612,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || config) return;
|
||||
let cancelled = false;
|
||||
setLoadError(false);
|
||||
setLoadError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await fetchInstanceConfig();
|
||||
@@ -613,12 +620,15 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
hydrateFromConfig(next);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
const cause =
|
||||
error instanceof HttpError && error.status === 401 ? await classifySetupUnauthorized() : 'unknown';
|
||||
if (cancelled) return;
|
||||
if (cause === 'stale_session') {
|
||||
await resetStaleSetupSession();
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to load instance configuration', error);
|
||||
setLoadError(true);
|
||||
setLoadError(cause === 'origin_mismatch' ? ORIGIN_MISMATCH_DESCRIPTOR : LOAD_ERROR_DESCRIPTOR);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -868,7 +878,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
role="alert"
|
||||
data-flx="app.self-hosted-setup-wizard-gate.load-error"
|
||||
>
|
||||
{i18n._(LOAD_ERROR_DESCRIPTOR)}
|
||||
{i18n._(loadError)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import type {
|
||||
BrandingAssetUploadRequest,
|
||||
InstanceConfigResponse,
|
||||
@@ -10,6 +12,8 @@ import type {
|
||||
InstanceEmailSmtpTestResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
const logger = new Logger('SetupWizardClient');
|
||||
|
||||
export type SetupBrandingAssetKind = BrandingAssetUploadRequest['kind'];
|
||||
|
||||
export async function fetchInstanceConfig(): Promise<InstanceConfigResponse> {
|
||||
@@ -35,3 +39,17 @@ export async function testSmtpConfig(body: InstanceEmailSmtpTestRequest): Promis
|
||||
const response = await http.post<InstanceEmailSmtpTestResponse>(Endpoints.ADMIN_INSTANCE_CONFIG_SMTP_TESTS, {body});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
export type SetupUnauthorizedCause = 'stale_session' | 'origin_mismatch' | 'unknown';
|
||||
|
||||
export async function classifySetupUnauthorized(): Promise<SetupUnauthorizedCause> {
|
||||
if (!SessionManager.token) return 'unknown';
|
||||
if (!http.carriesAuthorization()) return 'origin_mismatch';
|
||||
try {
|
||||
const response = await http.get(Endpoints.USER_ME, {mode: 'silent'});
|
||||
return response.status === 401 ? 'stale_session' : 'unknown';
|
||||
} catch (error) {
|
||||
logger.warn('Could not confirm whether the setup session is still valid', error);
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ export const Endpoints = {
|
||||
PREMIUM_REACTIVATE_SUBSCRIPTION: '/premium/reactivate-subscription',
|
||||
PREMIUM_CHANGE_SUBSCRIPTION: '/premium/change-subscription',
|
||||
PREMIUM_CANCEL_PENDING_SUBSCRIPTION_CHANGE: '/premium/cancel-pending-subscription-change',
|
||||
PREMIUM_SWITCH_TO_LIST_PRICE: '/premium/switch-to-list-price',
|
||||
PREMIUM_GRACE_END: '/premium/grace/end',
|
||||
PREMIUM_REFUND_ELIGIBILITY: '/premium/refund-eligibility',
|
||||
PREMIUM_REFUND_LATEST: '/premium/refund-latest',
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
|
||||
import type {UpdaterEvent} from '@app/types/electron.d';
|
||||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const {pushUpdateReadyModal} = vi.hoisted(() => ({pushUpdateReadyModal: vi.fn()}));
|
||||
|
||||
vi.mock('@app/features/updater/commands/UpdaterModalCommands', () => ({
|
||||
pushDesktopUpdateDownloadFailedModal: vi.fn(),
|
||||
pushDesktopUpdateInstallFailedModal: vi.fn(),
|
||||
pushManualUpdateAvailableModal: vi.fn(),
|
||||
pushUnsupportedUpdateModal: vi.fn(),
|
||||
pushUpdateAvailableModal: vi.fn(),
|
||||
pushUpdateCheckFailedModal: vi.fn(),
|
||||
pushUpdateReadyModal,
|
||||
pushUpToDateModal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@lingui/core/macro', () => ({
|
||||
msg: (descriptor: {message: string}) => descriptor,
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/platform/utils/ClientInfo', () => ({
|
||||
getClientInfo: () =>
|
||||
Promise.resolve({desktopVersion: '1.0.0', desktopChannel: 'canary', desktopArch: 'x64', arch: 'x64'}),
|
||||
}));
|
||||
|
||||
installVoiceMenuTestBootstrap();
|
||||
|
||||
let nativeEventListener: ((event: UpdaterEvent) => void) | null = null;
|
||||
let onUpdaterCheck: (() => void) | null = null;
|
||||
let loadedUpdater: {dispose: () => void} | null = null;
|
||||
|
||||
function installElectronApi(): void {
|
||||
nativeEventListener = null;
|
||||
onUpdaterCheck = null;
|
||||
(window as unknown as {electron: unknown}).electron = {
|
||||
platform: 'win32',
|
||||
buildChannel: 'canary',
|
||||
onUpdaterEvent: (listener: (event: UpdaterEvent) => void) => {
|
||||
nativeEventListener = listener;
|
||||
return () => {
|
||||
nativeEventListener = null;
|
||||
};
|
||||
},
|
||||
updaterCheck: () => {
|
||||
onUpdaterCheck?.();
|
||||
return Promise.resolve();
|
||||
},
|
||||
updaterDownload: () => Promise.resolve(),
|
||||
updaterInstall: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
function emit(event: UpdaterEvent): void {
|
||||
if (!nativeEventListener) throw new Error('Updater never subscribed to native updater events');
|
||||
nativeEventListener(event);
|
||||
}
|
||||
|
||||
async function loadUpdater() {
|
||||
vi.resetModules();
|
||||
installElectronApi();
|
||||
const {default: Updater} = await import('@app/features/app/state/Updater');
|
||||
loadedUpdater = Updater;
|
||||
await vi.waitFor(() => {
|
||||
expect(nativeEventListener).not.toBeNull();
|
||||
expect(Updater.lastCheckedAt).not.toBeNull();
|
||||
});
|
||||
pushUpdateReadyModal.mockClear();
|
||||
return Updater;
|
||||
}
|
||||
|
||||
function emitUserDownloadCompletion(version: string): void {
|
||||
emit({type: 'available', context: 'user', version, downloadSize: 1000, downloadStarted: true});
|
||||
emit({type: 'downloaded', context: 'user', version});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
loadedUpdater?.dispose();
|
||||
loadedUpdater = null;
|
||||
});
|
||||
|
||||
describe('updater update-ready surface', () => {
|
||||
test('does not push a blocking modal when a user-initiated download finishes outside a check', async () => {
|
||||
const Updater = await loadUpdater();
|
||||
emitUserDownloadCompletion('2.0.0');
|
||||
expect(Updater.nativeUpdateReady).toBe(true);
|
||||
expect(pushUpdateReadyModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('announces the ready update through a dismissible nagbar instead', async () => {
|
||||
const Updater = await loadUpdater();
|
||||
expect(Updater.shouldShowUpdateReadyNagbar).toBe(false);
|
||||
emitUserDownloadCompletion('2.0.0');
|
||||
expect(Updater.shouldShowUpdateReadyNagbar).toBe(true);
|
||||
Updater.dismissUpdateReadyNagbar();
|
||||
expect(Updater.shouldShowUpdateReadyNagbar).toBe(false);
|
||||
emit({type: 'downloaded', context: 'background', version: '2.1.0'});
|
||||
expect(Updater.shouldShowUpdateReadyNagbar).toBe(true);
|
||||
});
|
||||
|
||||
test('still answers a user-initiated check with the update ready modal', async () => {
|
||||
const Updater = await loadUpdater();
|
||||
emitUserDownloadCompletion('2.0.0');
|
||||
pushUpdateReadyModal.mockClear();
|
||||
onUpdaterCheck = () => emit({type: 'available', context: 'user', version: '2.0.0', downloadStarted: false});
|
||||
await Updater.checkForUpdates(true, true);
|
||||
expect(pushUpdateReadyModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -131,6 +131,7 @@ class Updater {
|
||||
private backgroundCheckInterval: number | null = null;
|
||||
private backgroundCheckCleanups: Array<() => void> = [];
|
||||
private unsubscribeNativeEvents: (() => void) | null = null;
|
||||
private updateReadyNagbarDismissedVersion: string | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -193,6 +194,18 @@ class Updater {
|
||||
return this.updateInfo.native.available && this.updateInfo.native.downloaded;
|
||||
}
|
||||
|
||||
get shouldShowUpdateReadyNagbar(): boolean {
|
||||
return (
|
||||
this.nativeUpdateReady &&
|
||||
!this.updateInfo.native.installing &&
|
||||
this.updateReadyNagbarDismissedVersion !== this.updateReadyNagbarVersionKey
|
||||
);
|
||||
}
|
||||
|
||||
private get updateReadyNagbarVersionKey(): string {
|
||||
return this.updateInfo.native.version ?? 'unknown';
|
||||
}
|
||||
|
||||
get nativeDownloadInFlight(): boolean {
|
||||
return this.updateInfo.native.downloading && !this.updateInfo.native.downloaded;
|
||||
}
|
||||
@@ -348,9 +361,6 @@ class Updater {
|
||||
break;
|
||||
}
|
||||
this.transition({type: 'native.downloaded', version: event.version ?? null});
|
||||
if (shouldShowImmediateUserResult) {
|
||||
this.showCurrentUpdateState();
|
||||
}
|
||||
break;
|
||||
case 'progress':
|
||||
if (!shouldSurfaceNativeDesktopUpdate || !this.nativeDownloadProgressSupported) {
|
||||
@@ -661,6 +671,10 @@ class Updater {
|
||||
}
|
||||
}
|
||||
|
||||
dismissUpdateReadyNagbar(): void {
|
||||
this.updateReadyNagbarDismissedVersion = this.updateReadyNagbarVersionKey;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.transition({type: 'reset'});
|
||||
}
|
||||
|
||||
@@ -165,9 +165,11 @@ export const TermsAcceptanceModal = observer(() => {
|
||||
ref={primaryRef}
|
||||
data-flx="auth.terms-acceptance-modal.button.accept"
|
||||
>
|
||||
{kind === 'terms' && i18n._(I_AGREE_TO_THE_UPDATED_TERMS_DESCRIPTOR)}
|
||||
{kind === 'privacy' && i18n._(I_AGREE_TO_THE_UPDATED_PRIVACY_POLICY_DESCRIPTOR)}
|
||||
{kind === 'both' && i18n._(I_AGREE_TO_THE_UPDATED_POLICIES_DESCRIPTOR)}
|
||||
{kind === 'terms'
|
||||
? i18n._(I_AGREE_TO_THE_UPDATED_TERMS_DESCRIPTOR)
|
||||
: kind === 'privacy'
|
||||
? i18n._(I_AGREE_TO_THE_UPDATED_PRIVACY_POLICY_DESCRIPTOR)
|
||||
: i18n._(I_AGREE_TO_THE_UPDATED_POLICIES_DESCRIPTOR)}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
|
||||
+1
@@ -83,6 +83,7 @@ const EMAIL_VALIDATION_CODES = new Set<string>([
|
||||
ValidationErrorCodes.INVALID_EMAIL_FORMAT,
|
||||
ValidationErrorCodes.INVALID_EMAIL_LOCAL_PART,
|
||||
ValidationErrorCodes.INVALID_EMAIL_ADDRESS,
|
||||
ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL,
|
||||
]);
|
||||
const CODE_SESSION_VALIDATION_CODES = new Set<string>([
|
||||
ValidationErrorCodes.EMAIL_TOKEN_EXPIRED,
|
||||
|
||||
@@ -20,7 +20,7 @@ export function AuthBottomLink({variant, to}: AuthBottomLinkProps) {
|
||||
return (
|
||||
<div className={styles.bottomLink} data-flx="auth.flow.auth-bottom-link.bottom-link">
|
||||
<span className={styles.bottomLinkText} data-flx="auth.flow.auth-bottom-link.bottom-link-text">
|
||||
{variant === 'login' ? i18n._(ALREADY_HAVE_ACCOUNT_DESCRIPTOR) : i18n._(NEED_ACCOUNT_DESCRIPTOR)}{' '}
|
||||
{`${variant === 'login' ? i18n._(ALREADY_HAVE_ACCOUNT_DESCRIPTOR) : i18n._(NEED_ACCOUNT_DESCRIPTOR)} `}
|
||||
</span>
|
||||
<AuthRouterLink
|
||||
to={to}
|
||||
|
||||
@@ -142,7 +142,9 @@ const IpAuthorizationScreen = ({challenge, onAuthorized, onBack}: IpAuthorizatio
|
||||
data-flx="auth.flow.ip-authorization-screen.button.resend"
|
||||
>
|
||||
{resendUsed ? <Trans>Resent</Trans> : <Trans>Resend email</Trans>}
|
||||
{resendIn > 0 ? ` (${resendIn}s)` : ''}
|
||||
<flx-i18n data-flx="auth.flow.ip-authorization-screen.flx-i18n">
|
||||
{resendIn > 0 ? ` (${resendIn}s)` : ''}
|
||||
</flx-i18n>
|
||||
</Button>
|
||||
)}
|
||||
{onBack ? (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user