Compare commits

...
Author SHA1 Message Date
HampusandGitHub 18c303abf6 feat(push): relay notifications as encrypted web push (#2906) 2026-09-23 14:04:55 +02:00
JiraliteandGitHub 7021a58090 fix: allow copying message snapshots (#2905) 2026-09-23 14:01:10 +02:00
WagnerandGitHub 320725a587 fix(desktop): capture full pipewire quantum on linux (#2481) 2026-09-22 21:37:20 +02:00
fluxer-weblate[bot]andGitHub 8450edc072 chore(i18n): update translations from Weblate (#2895) 2026-09-22 21:28:24 +02:00
omsterandGitHub a1e2bf2c8d feat(dev/linux): select the wayland backend when reachable in the native desktop app (#2899)
Signed-off-by: omstr <[email protected]>
2026-09-22 21:27:59 +02:00
HampusandGitHub 82b2f4ec5e fix(app): put jxl and other image attachments in the mosaic (#2902) 2026-09-22 21:18:39 +02:00
HampusandGitHub c92e5d03a7 fix(api): accept any image or video attachment as embed media (#2901) 2026-09-22 21:18:35 +02:00
HampusandGitHub 91340c5c84 fix(markdown): compile the parser wasm asynchronously (#2900) 2026-09-22 19:58:38 +02:00
HampusandGitHub 045dd5d027 test(api): make the harvest token tamper test deterministic (#2894) 2026-09-22 02:20:18 +02:00
HampusandGitHub a21b9c4659 docs(readme): clean up the download prose (#2893) 2026-09-22 02:08:42 +02:00
HampusandGitHub 4b1b869802 docs(readme): point Linux installs at Flathub (#2892) 2026-09-22 02:04:06 +02:00
HampusandGitHub 1ab7e7dfcc fix(api): unfurl links to a self-hosted instance's own domain (#2891) 2026-09-22 02:00:51 +02:00
HampusandGitHub 31c53d2dff fix(app): stop pending stickers from reloading the channel (#2890) 2026-09-22 02:00:26 +02:00
HampusandGitHub 412a1ae79d perf(api): stop ledgering session payment reconciliation (#2889) 2026-09-22 01:37:21 +02:00
HampusandGitHub 0b2306ec3d fix(api): honour default TTLs and expire stale job ledger rows (#2887) 2026-09-21 23:16:39 +02:00
HampusandGitHub 242ed3a934 fix(desktop): drop orphaned Squirrel uninstall entry (#2885) 2026-09-21 20:03:33 +02:00
HampusandGitHub 70e1ce682a feat(emoji): add Unicode 17 emoji and fix mixed skin tones (#2883) 2026-09-21 16:26:06 +02:00
205 changed files with 16468 additions and 1639 deletions
+3
View File
@@ -28,6 +28,9 @@ f:media_proxy:
f:messages:
- changed-files:
- any-glob-to-any-file: fluxer_messages/**/*
f:push:
- changed-files:
- any-glob-to-any-file: fluxer_push/**/*
f:snowflakes:
- changed-files:
- any-glob-to-any-file: fluxer_snowflakes/**/*
+36
View File
@@ -0,0 +1,36 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: build push
on:
workflow_dispatch:
inputs:
build-version:
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
type: string
required: false
default: ""
permissions:
actions: read
contents: write
packages: write
jobs:
approve:
name: approve build release
permissions: {}
runs-on: ubuntu-24.04
environment: builds
timeout-minutes: 5
steps:
- name: approved
run: echo "Build release approved."
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-push
dockerfile: fluxer_push/Dockerfile
build-version: ${{ inputs['build-version'] }}
Generated
+36
View File
@@ -1607,6 +1607,7 @@ dependencies = [
"ff",
"generic-array",
"group",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
@@ -1881,6 +1882,31 @@ dependencies = [
"url",
]
[[package]]
name = "fluxer-push"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"base64 0.23.1",
"clap",
"fluxer-svc",
"futures",
"hmac 0.13.0",
"p256",
"rand 0.10.2",
"reqwest",
"ring",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror",
"tokio",
"tracing",
"tracing-subscriber",
"url",
]
[[package]]
name = "fluxer-snowflakes"
version = "0.1.0"
@@ -2308,6 +2334,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac 0.12.1",
]
[[package]]
name = "hmac"
version = "0.12.1"
@@ -3893,6 +3928,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
+1
View File
@@ -7,6 +7,7 @@ members = [
"fluxer_gifs",
"fluxer_svc",
"fluxer_messages",
"fluxer_push",
"fluxer_snowflakes",
"tools/ci",
"tools/dev",
+26 -14
View File
@@ -16,6 +16,11 @@
<img src="https://img.shields.io/badge/License-AGPLv3-purple" alt="AGPLv3 License" /></a>
</p>
<p align="center">
<a href="https://flathub.org/apps/app.fluxer.Fluxer">
<img src="https://dl.flathub.org/assets/badges/flathub-badge-en.svg" alt="Get it on Flathub" height="60" /></a>
</p>
# Fluxer
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
@@ -28,7 +33,7 @@ Fluxer is a free and open source instant messaging and VoIP chat app built for f
| Windows | macOS | Linux | Android | iOS |
| --- | --- | --- | --- | --- |
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flatpak][flatpak-ref] | [APK][android-apk] | [TestFlight][ios-testflight] |
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [APK][android-apk] | [TestFlight][ios-testflight] |
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [Obtainium][obtainium] | |
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | | |
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
@@ -38,17 +43,23 @@ Fluxer is a free and open source instant messaging and VoIP chat app built for f
| | | [tar.gz (x64)][linux-targz-x64] | | |
| | | [tar.gz (ARM64)][linux-targz-arm64] | | |
The macOS disk image is universal and runs on both Apple silicon and Intel. Windows and Linux need the build that matches your processor.
The macOS disk image runs on both Apple silicon and Intel. Windows and Linux need the build matching your processor.
On Linux, prefer a package repository over a file. Fluxer then updates with the rest of your system.
On Linux, prefer a repository over a single file so Fluxer updates with the rest of your system.
## Linux package repositories
All four repositories serve stable and canary. The package is `fluxer` for stable and `fluxer-canary` for canary.
Every repository serves both channels. The package is `fluxer` for stable, `fluxer-canary` for canary.
### Flatpak
Opening [this reference file][flatpak-ref] hands the install to your desktop software manager. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` pasted into the address bar.
Stable is on [Flathub][flathub], the easiest route on most desktops:
```sh
flatpak install flathub app.fluxer.Fluxer
```
Flathub has stable only. For canary, or to use Fluxer's own repository, open [this reference file][flatpak-ref] and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar.
From a terminal:
@@ -72,11 +83,11 @@ sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/flux
sudo dnf install fluxer
```
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because the base repositories do not ship `libXScrnSaver`. Fedora does not need this.
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because their base repositories lack `libXScrnSaver`. Fedora does not.
### Arch Linux
The repository is signed, so pacman needs the key in its own keyring once:
The repository is signed, so pacman needs the key once:
```sh
sudo pacman-key --init
@@ -85,7 +96,7 @@ sudo pacman-key --add /tmp/fluxer-archive-keyring.asc
sudo pacman-key --lsign-key 09D01339EE128925F75E675C855C5BDE34D205D2
```
`--lsign-key` is the step that makes pacman trust the key. Then add the repository:
`--lsign-key` is what makes pacman trust it. Then add the repository:
```sh
sudo tee -a /etc/pacman.conf >/dev/null <<'REPO'
@@ -97,13 +108,13 @@ REPO
sudo pacman -Syu fluxer
```
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, which is why the heredoc above is quoted.
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, hence the quoted heredoc.
Full setup notes, including the canary channel, live in the [Linux repositories documentation][docs-linux].
Full setup notes, including canary, are in the [Linux repositories documentation][docs-linux].
## Other ways to run it
- [Open Fluxer in a browser](https://web.fluxer.app) with no install at all.
- [Open Fluxer in a browser](https://web.fluxer.app), no install needed.
- [Host your own instance][docs-selfhost] from this repository.
## Documentation
@@ -117,9 +128,9 @@ Full setup notes, including the canary channel, live in the [Linux repositories
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
imagery are copyright Fluxer and all rights reserved, as set out in
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its
own terms, listed in
imagery are copyright Fluxer, all rights reserved, as set out in
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its own
terms, listed in
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
Public availability of this repository does not grant trademark, brand, or
@@ -139,6 +150,7 @@ endorsement rights.
[linux-targz-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/tar_gz
[linux-targz-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/tar_gz
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
[flathub]: https://flathub.org/apps/app.fluxer.Fluxer
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
[obtainium]: https://obtainium.imranr.dev/
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
+8
View File
@@ -147,6 +147,12 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
# Notification jobs the push container holds at once, 1 to 1000000.
#FLUXER_PUSH_SERVICE_QUEUE_CAPACITY=10000
# Provider requests the push container sends at once, 1 to 65536.
#FLUXER_PUSH_SERVICE_SEND_CONCURRENCY=256
# Optional media policies, both off by default. See the operator docs.
#
# CORS limits which web origins may read media. A request with no Origin is
@@ -245,6 +251,7 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
#FLUXER_GATEWAY_MEMORY_RESERVATION=384mb
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
#FLUXER_PUSH_MEMORY_LIMIT=256mb
#FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb
#FLUXER_APP_PROXY_MEMORY_LIMIT=256mb
#FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb
@@ -281,6 +288,7 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_POSTGRES_WORK_MEM=8MB
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
#FLUXER_POSTGRES_SHM_SIZE=1gb
# The bundled Valkey holds durable state as well as cache, so it runs with an
# append-only file and with noeviction, which fails an over-limit write instead
+25 -1
View File
@@ -188,7 +188,7 @@ services:
-c autovacuum_vacuum_cost_limit=2000
-c track_io_timing=on
-c shared_preload_libraries=pg_stat_statements
shm_size: 256mb
shm_size: ${FLUXER_POSTGRES_SHM_SIZE:-1gb}
environment:
POSTGRES_DB: fluxer
POSTGRES_USER: fluxer
@@ -482,6 +482,30 @@ services:
seaweedfs-init: {condition: service_completed_successfully}
nats: {condition: service_healthy}
push:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-push:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_PUSH_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_PUSH_SERVICE_HOST: 0.0.0.0
FLUXER_PUSH_SERVICE_PORT: "8126"
FLUXER_PUSH_SERVICE_QUEUE_CAPACITY: "${FLUXER_PUSH_SERVICE_QUEUE_CAPACITY:-}"
FLUXER_PUSH_SERVICE_SEND_CONCURRENCY: "${FLUXER_PUSH_SERVICE_SEND_CONCURRENCY:-}"
healthcheck:
test: ["CMD", "/usr/local/bin/fluxer-push", "healthcheck"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
depends_on:
nats: {condition: service_healthy}
api: {condition: service_healthy}
static-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1}
+60
View File
@@ -10525,6 +10525,7 @@
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"screen_share_delivery": {"$ref": "#/components/schemas/ScreenShareDeliveryConfigResponse"},
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
"type": "object",
@@ -10953,6 +10954,7 @@
"gateway_rollout",
"voice_noise_suppression",
"screen_share_delivery",
"push_service_delivery",
"experiment_delivery",
"registration",
"self_hosted",
@@ -11091,6 +11093,10 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ScreenShareDeliveryConfigUpdateRequest"}]
},
"push_service_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
},
"experiment_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
@@ -15184,6 +15190,24 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"PushServiceDeliveryConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
}
},
"ScreenShareDeliveryConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15267,6 +15291,42 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"additionalProperties": false
},
"PushServiceDeliveryConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "push-service-delivery-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"excluded_user_ids"
],
"additionalProperties": false
},
"ScreenShareDeliveryConfigResponse": {
"type": "object",
"properties": {
@@ -25,6 +25,8 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub screen_share_delivery: ScreenShareDeliveryConfigResponse,
#[serde(default)]
pub push_service_delivery: PushServiceDeliveryConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
}
@@ -449,6 +451,7 @@ impl VoiceE2eeScope {
}
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1";
pub const SCREEN_SHARE_DELIVERY_DEFAULT_SALT: &str = "screen-share-delivery-v1";
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
@@ -578,6 +581,44 @@ pub struct ScreenShareDeliveryConfigUpdateRequest {
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct PushServiceDeliveryConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
}
impl Default for PushServiceDeliveryConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PushServiceDeliveryConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ExperimentDeliveryConfigResponse {
@@ -696,6 +737,8 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub screen_share_delivery: Option<ScreenShareDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
}
+1 -1
View File
@@ -80,7 +80,7 @@ async fn reports_list(
return reports_error_page(
config,
&auth.0,
"That page is out of range. The reports search returns at most the first 10000 reports, so narrow the filters and start again.",
"That page is out of range. The reports search returns at most the first 10000 reports. Narrow the filters and start again.",
);
}
let search_query = query.q.as_deref().and_then(clean_string);
+55 -3
View File
@@ -18,9 +18,10 @@ use crate::{
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
PremiumMode, RegistrationMode, ScreenShareDeliveryConfigUpdateRequest,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
PremiumMode, PushServiceDeliveryConfigUpdateRequest, RegistrationMode,
ScreenShareDeliveryConfigUpdateRequest, SsoConfigUpdateRequest,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
},
},
config::AdminConfig,
@@ -211,6 +212,10 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_push_service_delivery" => match build_push_service_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
@@ -496,6 +501,21 @@ fn parse_experiment_rollout_salt(
Ok(Some(salt.to_owned()))
}
fn parse_push_service_delivery_rollout_salt(
form: &MultiValueForm,
key: &str,
) -> Result<Option<String>, String> {
let salt = parse_experiment_rollout_salt(form, key)?;
if let Some(value) = salt.as_deref()
&& !value
.bytes()
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
{
return Err("Rollout salt must use printable ASCII".to_owned());
}
Ok(salt)
}
fn is_experiment_snowflake(value: &str) -> bool {
!value.is_empty()
&& value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH
@@ -665,6 +685,38 @@ fn build_screen_share_delivery_update(
})
}
fn build_push_service_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
push_service_delivery: Some(PushServiceDeliveryConfigUpdateRequest {
enabled: Some(form.bool_value("push_service_delivery_enabled")),
rollout_basis_points: parse_form_number(
form,
"push_service_delivery_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_push_service_delivery_rollout_salt(
form,
"push_service_delivery_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -5,10 +5,10 @@ use crate::{
AppPublicConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, NoiseSuppressionBackend, PendingRegistrationResponse,
RegistrationUrlResponse, SCREEN_SHARE_DELIVERY_DEFAULT_SALT,
ScreenShareDeliveryConfigResponse, SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES,
VoiceNoiseSuppressionConfigResponse,
LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
PendingRegistrationResponse, PushServiceDeliveryConfigResponse, RegistrationUrlResponse,
SCREEN_SHARE_DELIVERY_DEFAULT_SALT, ScreenShareDeliveryConfigResponse, SsoConfigResponse,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -150,6 +150,7 @@ pub fn instance_config_page(
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
(screen_share_delivery_section(base, csrf_token, &instance_config.screen_share_delivery))
(push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -1112,7 +1113,7 @@ fn voice_noise_suppression_section(
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save; blank entries and duplicate \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
@@ -1131,7 +1132,7 @@ fn voice_noise_suppression_section(
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage, so this is the per-user kill switch."
percentage. This is the per-user kill switch."
}
}
@@ -1257,7 +1258,7 @@ fn screen_share_delivery_section(
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save; blank entries and duplicate \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
@@ -1276,7 +1277,7 @@ fn screen_share_delivery_section(
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage, so this is the per-user kill switch."
percentage. This is the per-user kill switch."
}
}
@@ -1289,6 +1290,115 @@ fn screen_share_delivery_section(
)
}
fn push_service_delivery_section(
base: &str,
csrf_token: &str,
push_service_delivery: &PushServiceDeliveryConfigResponse,
) -> Markup {
let status = if push_service_delivery.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = push_service_delivery.included_user_ids.join("\n");
let excluded_user_ids = push_service_delivery.excluded_user_ids.join("\n");
section_card_with_description(
"Push Service Delivery",
"Routes push notification delivery for the selected accounts through the push service. \
Accounts the rollout does not select keep the current path.",
html! {
form method="post" action={(base) "/instance-config?action=update_push_service_delivery"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (push_service_delivery.config_version)
}
}
(checkbox(
"push_service_delivery_enabled",
"true",
"Hand push notifications to the push service",
push_service_delivery.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every notification keeps the \
current delivery path, so the rollout and targeting fields below have no \
effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"push_service_delivery_rollout_basis_points",
"Rollout (basis points)",
&push_service_delivery.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"push_service_delivery_rollout_salt",
"Rollout Salt",
&push_service_delivery.rollout_salt,
PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"push_service_delivery_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
push_service_delivery.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"push_service_delivery_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
push_service_delivery.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage. This is the per-user kill switch."
}
}
(form_actions(html! {
(submit_button("Save Push Service Delivery Configuration"))
}))
}
}
},
)
}
fn experiment_delivery_section(
base: &str,
csrf_token: &str,
@@ -418,6 +418,14 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"future_delivery_knob": 9,
"excluded_user_ids": []
},
"push_service_delivery": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1",
"included_user_ids": ["1500000000000000002"],
"excluded_user_ids": []
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
"mode": "open",
+3
View File
@@ -817,6 +817,9 @@ async fn spawn_mock_api() -> String {
async fn mock_api(method: Method, uri: Uri) -> Response {
let path = uri.path().to_owned();
if method == Method::PATCH && path == "/admin/instance/config" {
return json_response(instance_config());
}
match (method, path.as_str()) {
(Method::GET, "/admin/users/@me") => json_response(json!({ "user": admin_user() })),
(Method::GET, "/admin/api-keys") => json_response(json!([])),
+13 -6
View File
@@ -11,6 +11,8 @@ interface PostgresIpInfoOptions {
}
const VALUE_SEPARATOR = '\u001f';
export const IPINFO_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
export const IPINFO_REQUEST_AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60;
function getClient(options: PostgresIpInfoOptions): IPostgresClient | null {
return options.client ?? options.getClient?.() ?? null;
@@ -34,12 +36,9 @@ async function upsertKvRow(
partitionKey: string,
key: string,
row: Record<string, unknown>,
ttlSeconds?: number,
ttlSeconds: number,
): Promise<void> {
const expiresAt =
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0
? new Date(Date.now() + ttlSeconds * 1000)
: null;
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
await client.query(
`INSERT INTO ${table(client)} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, now())
@@ -77,7 +76,14 @@ export function createPostgresIpInfoCache(options: PostgresIpInfoOptions): IpInf
try {
const client = getClient(options);
if (!client) return;
await upsertKvRow(client, 'ipinfo_cache', rowKey([key]), rowKey([key]), {cache_key: key, payload}, ttlSeconds);
await upsertKvRow(
client,
'ipinfo_cache',
rowKey([key]),
rowKey([key]),
{cache_key: key, payload},
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : IPINFO_CACHE_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_cache_set');
}
@@ -124,6 +130,7 @@ export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOp
is_residential_proxy: event.isResidentialProxy,
metadata_json: serializeMetadata(event.metadata),
},
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_request_audit_record');
+5 -3
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import type {Pool, PoolClient, QueryResult, QueryResultRow} from 'pg';
import type {Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow} from 'pg';
import pg from 'pg';
const MAX_DIAGNOSTIC_FIELD_LENGTH = 128;
@@ -131,7 +131,7 @@ class PostgresClient implements IPostgresClient {
}
private async openPool(): Promise<void> {
const pool = new pg.Pool({
const poolConfig: PoolConfig & {scramMaxIterations: number} = {
connectionString: this.config.url || undefined,
host: this.config.url ? undefined : (this.config.host ?? '127.0.0.1'),
port: this.config.url ? undefined : (this.config.port ?? 5432),
@@ -140,7 +140,9 @@ class PostgresClient implements IPostgresClient {
password: this.config.url ? undefined : (this.config.password ?? 'fluxer'),
ssl: this.config.ssl ? {rejectUnauthorized: true, ca: normalizePem(this.config.sslCa)} : undefined,
max: this.config.maxConnections ?? 20,
});
scramMaxIterations: 0,
};
const pool = new pg.Pool(poolConfig);
this.observePoolConnections(pool);
try {
const client = await pool.connect();
-2
View File
@@ -272,8 +272,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
donationProxyKey,
},
hosts: {
invite: extractHostname(master.endpoints.invite),
gift: extractHostname(master.endpoints.gift),
marketing: extractHostname(master.endpoints.marketing),
unfurlIgnored: master.services.api.unfurl_ignored_hosts,
},
+38
View File
@@ -341,6 +341,7 @@ import {
type UsersPendingDeletionRow,
} from '@app/api/database/types/UserTypes';
import {ATTACHMENT_DECAY_COLUMNS, type AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
import {seconds} from 'itty-time';
export const Users = defineTable<UserRow, 'user_id'>({
name: 'users',
@@ -499,16 +500,19 @@ export const GuildAuditLogs = defineTable<GuildAuditLogRow, 'guild_id' | 'log_id
name: 'guild_audit_logs_v2',
columns: GUILD_AUDIT_LOG_COLUMNS,
primaryKey: ['guild_id', 'log_id'],
defaultTtlSeconds: seconds('45 days'),
});
export const GuildAuditLogsByUser = defineTable<GuildAuditLogRow, 'guild_id' | 'user_id' | 'log_id'>({
name: 'guild_audit_logs_v2_by_user',
columns: GUILD_AUDIT_LOG_COLUMNS,
primaryKey: ['guild_id', 'user_id', 'log_id'],
defaultTtlSeconds: seconds('45 days'),
});
export const GuildAuditLogsByAction = defineTable<GuildAuditLogRow, 'guild_id' | 'action_type' | 'log_id'>({
name: 'guild_audit_logs_v2_by_action',
columns: GUILD_AUDIT_LOG_COLUMNS,
primaryKey: ['guild_id', 'action_type', 'log_id'],
defaultTtlSeconds: seconds('45 days'),
});
export const GuildAuditLogsByUserAction = defineTable<
GuildAuditLogRow,
@@ -517,6 +521,7 @@ export const GuildAuditLogsByUserAction = defineTable<
name: 'guild_audit_logs_v2_by_user_action',
columns: GUILD_AUDIT_LOG_COLUMNS,
primaryKey: ['guild_id', 'user_id', 'action_type', 'log_id'],
defaultTtlSeconds: seconds('45 days'),
});
export const GuildMembershipMetadata = defineTable<GuildMembershipMetadataRow, 'guild_id' | 'user_id'>({
name: 'guild_membership_metadata',
@@ -655,6 +660,7 @@ export const RecentMentions = defineTable<RecentMentionRow, 'user_id' | 'message
name: 'recent_mentions',
columns: RECENT_MENTION_COLUMNS,
primaryKey: ['user_id', 'message_id'],
defaultTtlSeconds: seconds('7 days'),
});
interface RecentMentionsByGuildRow {
@@ -678,6 +684,7 @@ export const RecentMentionsByGuild = defineTable<RecentMentionsByGuildRow, 'user
name: 'recent_mentions_by_guild',
columns: RECENT_MENTIONS_BY_GUILD_COLUMNS,
primaryKey: ['user_id', 'guild_id', 'message_id'],
defaultTtlSeconds: seconds('7 days'),
});
export const SavedMessages = defineTable<SavedMessageRow, 'user_id' | 'message_id'>({
name: 'saved_messages',
@@ -688,6 +695,7 @@ export const PushSubscriptions = defineTable<PushSubscriptionRow, 'user_id' | 's
name: 'push_subscriptions',
columns: PUSH_SUBSCRIPTION_COLUMNS,
primaryKey: ['user_id', 'subscription_id'],
defaultTtlSeconds: seconds('90 days'),
});
export const Payments = defineTable<PaymentRow, 'checkout_session_id'>({
name: 'payments',
@@ -854,11 +862,13 @@ export const EmailVerificationTokens = defineTable<EmailVerificationTokenRow, 't
name: 'email_verification_tokens',
columns: EMAIL_VERIFICATION_TOKEN_COLUMNS,
primaryKey: ['token_', 'user_id'],
defaultTtlSeconds: seconds('24 hours'),
});
export const PasswordResetTokens = defineTable<PasswordResetTokenRow, 'token_' | 'user_id'>({
name: 'password_reset_tokens',
columns: PASSWORD_RESET_TOKEN_COLUMNS,
primaryKey: ['token_', 'user_id'],
defaultTtlSeconds: seconds('24 hours'),
});
export const PasswordResetTokensByUserId = defineTable<
{
@@ -870,16 +880,19 @@ export const PasswordResetTokensByUserId = defineTable<
name: 'password_reset_tokens_by_user_id',
columns: ['user_id', 'token_'],
primaryKey: ['user_id', 'token_'],
defaultTtlSeconds: seconds('24 hours'),
});
export const EmailRevertTokens = defineTable<EmailRevertTokenRow, 'token_' | 'user_id'>({
name: 'email_revert_tokens',
columns: EMAIL_REVERT_TOKEN_COLUMNS,
primaryKey: ['token_', 'user_id'],
defaultTtlSeconds: seconds('48 hours'),
});
export const PhoneTokens = defineTable<PhoneTokenRow, 'token_'>({
name: 'phone_tokens',
columns: PHONE_TOKEN_COLUMNS,
primaryKey: ['token_'],
defaultTtlSeconds: seconds('30 days'),
});
export const AuthSessions = defineTable<AuthSessionRow, 'session_id_hash'>({
name: 'auth_sessions',
@@ -901,11 +914,13 @@ export const AuthSessionTombstones = defineTable<AuthSessionTombstoneRow, 'user_
name: 'auth_session_tombstones',
columns: AUTH_SESSION_TOMBSTONE_COLUMNS,
primaryKey: ['user_id', 'session_id_hash'],
defaultTtlSeconds: seconds('30 days'),
});
export const UserCountryHistory = defineTable<UserCountryHistoryRow, 'user_id' | 'country'>({
name: 'user_country_history',
columns: USER_COUNTRY_HISTORY_COLUMNS,
primaryKey: ['user_id', 'country'],
defaultTtlSeconds: seconds('365 days'),
});
export const MfaBackupCodes = defineTable<MfaBackupCodeRow, 'user_id' | 'code'>({
name: 'mfa_backup_codes',
@@ -932,6 +947,7 @@ export const IpAuthorizationTokens = defineTable<IpAuthorizationTokenRow, 'token
name: 'ip_authorization_tokens',
columns: IP_AUTHORIZATION_TOKEN_COLUMNS,
primaryKey: ['token_', 'user_id'],
defaultTtlSeconds: seconds('30 minutes'),
});
export const AuthorizedIps = defineTable<AuthorizedIpRow, 'user_id' | 'ip'>({
name: 'authorized_ips_v2',
@@ -1057,26 +1073,31 @@ export const OAuth2AuthorizationCodes = defineTable<OAuth2AuthorizationCodeRow,
name: 'oauth2_authorization_codes',
columns: OAUTH2_AUTHORIZATION_CODE_COLUMNS,
primaryKey: ['code'],
defaultTtlSeconds: seconds('10 minutes'),
});
export const OAuth2AccessTokens = defineTable<OAuth2AccessTokenRow, 'token_'>({
name: 'oauth2_access_tokens',
columns: OAUTH2_ACCESS_TOKEN_COLUMNS,
primaryKey: ['token_'],
defaultTtlSeconds: seconds('7 days'),
});
export const OAuth2AccessTokensByUser = defineTable<OAuth2AccessTokenByUserRow, 'user_id' | 'token_'>({
name: 'oauth2_access_tokens_by_user',
columns: OAUTH2_ACCESS_TOKENS_BY_USER_COLUMNS,
primaryKey: ['user_id', 'token_'],
defaultTtlSeconds: seconds('7 days'),
});
export const OAuth2RefreshTokens = defineTable<OAuth2RefreshTokenRow, 'token_'>({
name: 'oauth2_refresh_tokens',
columns: OAUTH2_REFRESH_TOKEN_COLUMNS,
primaryKey: ['token_'],
defaultTtlSeconds: seconds('30 days'),
});
export const OAuth2RefreshTokensByUser = defineTable<OAuth2RefreshTokenByUserRow, 'user_id' | 'token_'>({
name: 'oauth2_refresh_tokens_by_user',
columns: OAUTH2_REFRESH_TOKENS_BY_USER_COLUMNS,
primaryKey: ['user_id', 'token_'],
defaultTtlSeconds: seconds('30 days'),
});
interface WebhooksByChannelRow {
@@ -1117,12 +1138,14 @@ export const JobsById = defineTable<JobByIdRow, 'job_id'>({
name: 'jobs_by_id',
columns: JOB_BY_ID_COLUMNS,
primaryKey: ['job_id'],
defaultTtlSeconds: seconds('90 days'),
});
export const JobsByDayBucket = defineTable<JobByDayBucketRow, 'bucket_day' | 'created_at' | 'job_id'>({
name: 'jobs_by_day_bucket',
columns: JOB_BY_DAY_BUCKET_COLUMNS,
primaryKey: ['bucket_day', 'created_at', 'job_id'],
partitionKey: ['bucket_day'],
defaultTtlSeconds: seconds('90 days'),
});
export const JobsActive = defineTable<JobActiveRow, 'job_id'>({
name: 'jobs_active',
@@ -1133,11 +1156,13 @@ export const AttachmentUploadTracesByKey = defineTable<AttachmentUploadTraceByKe
name: 'attachment_upload_traces_by_key',
columns: ATTACHMENT_UPLOAD_TRACE_BY_KEY_COLUMNS,
primaryKey: ['upload_key'],
defaultTtlSeconds: seconds('30 days'),
});
export const AttachmentUploadTracesByAttachment = defineTable<AttachmentUploadTraceByAttachmentRow, 'attachment_id'>({
name: 'attachment_upload_traces_by_attachment',
columns: ATTACHMENT_UPLOAD_TRACE_BY_ATTACHMENT_COLUMNS,
primaryKey: ['attachment_id'],
defaultTtlSeconds: seconds('30 days'),
});
export const NcmecAttachmentSubmissions = defineTable<NcmecAttachmentSubmissionRow, 'attachment_id'>({
name: 'ncmec_attachment_submissions',
@@ -1154,6 +1179,7 @@ export const RegistrationEventsByIp = defineTable<RegistrationEventByIpRow, 'ip'
columns: REGISTRATION_EVENT_BY_IP_COLUMNS,
primaryKey: ['ip', 'created_at', 'user_id'],
partitionKey: ['ip'],
defaultTtlSeconds: seconds('30 days'),
});
export const RegistrationEventsBySubnet = defineTable<
RegistrationEventBySubnetRow,
@@ -1164,6 +1190,7 @@ export const RegistrationEventsBySubnet = defineTable<
columns: REGISTRATION_EVENT_BY_SUBNET_COLUMNS,
primaryKey: ['subnet', 'created_at', 'user_id'],
partitionKey: ['subnet'],
defaultTtlSeconds: seconds('30 days'),
});
export const RegistrationEventsByEmailDomain = defineTable<
RegistrationEventByEmailDomainRow,
@@ -1174,6 +1201,7 @@ export const RegistrationEventsByEmailDomain = defineTable<
columns: REGISTRATION_EVENT_BY_EMAIL_DOMAIN_COLUMNS,
primaryKey: ['email_domain', 'created_at', 'user_id'],
partitionKey: ['email_domain'],
defaultTtlSeconds: seconds('30 days'),
});
export const RegistrationEventsByPlusAddressBase = defineTable<
RegistrationEventByPlusAddressBaseRow,
@@ -1184,6 +1212,7 @@ export const RegistrationEventsByPlusAddressBase = defineTable<
columns: REGISTRATION_EVENT_BY_PLUS_ADDRESS_BASE_COLUMNS,
primaryKey: ['plus_address_base', 'created_at', 'user_id'],
partitionKey: ['plus_address_base'],
defaultTtlSeconds: seconds('30 days'),
});
export const LatestRiskContextByUser = defineTable<LatestRiskContextByUserRow, 'user_id'>({
name: 'latest_risk_context_by_user',
@@ -1194,6 +1223,7 @@ export const SuspiciousIps = defineTable<SuspiciousIpRow, 'ip'>({
name: 'suspicious_ips',
columns: SUSPICIOUS_IP_COLUMNS,
primaryKey: ['ip'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskOutcomesByIp = defineTable<RiskOutcomeByIpRow, 'ip' | 'created_at' | 'user_id' | 'outcome_code', 'ip'>(
{
@@ -1201,6 +1231,7 @@ export const RiskOutcomesByIp = defineTable<RiskOutcomeByIpRow, 'ip' | 'created_
columns: RISK_OUTCOME_BY_IP_COLUMNS,
primaryKey: ['ip', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['ip'],
defaultTtlSeconds: seconds('180 days'),
},
);
export const RiskOutcomesBySubnet = defineTable<
@@ -1212,6 +1243,7 @@ export const RiskOutcomesBySubnet = defineTable<
columns: RISK_OUTCOME_BY_SUBNET_COLUMNS,
primaryKey: ['subnet', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['subnet'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskOutcomesByEmailDomain = defineTable<
RiskOutcomeByEmailDomainRow,
@@ -1222,6 +1254,7 @@ export const RiskOutcomesByEmailDomain = defineTable<
columns: RISK_OUTCOME_BY_EMAIL_DOMAIN_COLUMNS,
primaryKey: ['email_domain', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['email_domain'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskOutcomesByAsn = defineTable<
RiskOutcomeByAsnRow,
@@ -1232,6 +1265,7 @@ export const RiskOutcomesByAsn = defineTable<
columns: RISK_OUTCOME_BY_ASN_COLUMNS,
primaryKey: ['asn', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['asn'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskAssessments = defineTable<RiskAssessmentRow, 'assessment_id'>({
name: 'risk_assessments',
@@ -1248,6 +1282,7 @@ export const InboundSmsChallenges = defineTable<InboundSmsChallengeRow, 'challen
name: 'inbound_sms_challenges',
columns: INBOUND_SMS_CHALLENGE_COLUMNS,
primaryKey: ['challenge_code'],
defaultTtlSeconds: seconds('15 minutes'),
});
export const InboundSmsChallengesByUser = defineTable<
InboundSmsChallengeByUserRow,
@@ -1258,16 +1293,19 @@ export const InboundSmsChallengesByUser = defineTable<
columns: INBOUND_SMS_CHALLENGE_BY_USER_COLUMNS,
primaryKey: ['user_id', 'created_at'],
partitionKey: ['user_id'],
defaultTtlSeconds: seconds('15 minutes'),
});
export const PhoneLookupCache = defineTable<PhoneLookupCacheRow, 'phone'>({
name: 'phone_lookup_cache',
columns: PHONE_LOOKUP_CACHE_COLUMNS,
primaryKey: ['phone'],
defaultTtlSeconds: seconds('7 days'),
});
export const PhoneVerificationAttempts = defineTable<PhoneVerificationAttemptRow, 'attempt_id'>({
name: 'phone_verification_attempts',
columns: PHONE_VERIFICATION_ATTEMPT_COLUMNS,
primaryKey: ['attempt_id'],
defaultTtlSeconds: seconds('90 days'),
});
export const BillingCustomers = defineTable<BillingCustomerRow, 'provider_id'>({
name: 'billing_customers',
+19 -1
View File
@@ -2,7 +2,15 @@
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from '@app/api/admin/IAdminRepository';
import {createUserID} from '@app/api/BrandedTypes';
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {Config} from '@app/api/Config';
import {ContentBlocklistCategory} from '@app/api/constants/ContentModeration';
import {
deleteOneOrMany,
executeConditional,
fetchMany,
fetchOne,
upsertOne,
} from '@app/api/database/CassandraQueryExecution';
import type {
AdminAuditLogRow,
BannedAvatarHashRow,
@@ -282,6 +290,7 @@ export class AdminRepository implements IAdminRepository {
}
async isEmailDomainDisposable(domain: string): Promise<boolean> {
if (!Config.blocklistFeeds.enabled) return false;
const domainLower = domain.toLowerCase();
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
const result = await fetchOne<{
@@ -395,6 +404,15 @@ export class AdminRepository implements IAdminRepository {
await deleteOneOrMany(BannedFileShas.deleteByPk({sha256_hex: sha256Hex.toLowerCase()}));
}
async unbanFeedFileSha(sha256Hex: string): Promise<boolean> {
return executeConditional(
BannedFileShas.conditionalDeleteByPk(
{sha256_hex: sha256Hex.toLowerCase()},
{added_by: null, category: ContentBlocklistCategory.MALWARE_BAZAAR},
),
);
}
async loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>> {
return fetchMany<BannedFileShaRow>(LOAD_ALL_BANNED_FILE_SHAS_QUERY.bind({}));
}
@@ -109,6 +109,8 @@ export abstract class IAdminRepository {
abstract unbanFileSha(sha256Hex: string): Promise<void>;
abstract unbanFeedFileSha(sha256Hex: string): Promise<boolean>;
abstract loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>>;
abstract isAvatarHashBanned(hashShort: string): Promise<boolean>;
@@ -13,7 +13,11 @@ import {deriveSsoRedirectUri, normalizeAndValidateSsoConfig} from '@app/api/inst
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {getGatewayRolloutConfigPublisher, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {
getGatewayRolloutConfigPublisher,
getInstanceConfigRepository,
getPushServiceDeliveryConfigPublisher,
} from '@app/api/middleware/ServiceSingletons';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
@@ -31,6 +35,7 @@ import {
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {PushServiceDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {ScreenShareDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
@@ -61,6 +66,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gatewayRollout,
voiceNoiseSuppression,
screenShareDelivery,
pushServiceDelivery,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -70,6 +76,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getScreenShareDeliveryConfig(),
instanceConfigRepository.getPushServiceDeliveryConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -102,6 +109,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
screen_share_delivery: screenShareDelivery,
push_service_delivery: pushServiceDelivery,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -247,43 +255,54 @@ export function InstanceConfigAdminController(app: HonoApp) {
const shouldGrantSetupCompleterAdmin =
appPublicBeforeUpdate !== null && completesInitialSetup(data, appPublicBeforeUpdate.setup.configured);
if (data.gateway_rollout) {
const currentRollout = await instanceConfigRepository.getGatewayRolloutConfig();
const merged = {...currentRollout, ...data.gateway_rollout};
const validated = GatewayRolloutConfigSchema.parse(merged);
await instanceConfigRepository.setGatewayRolloutConfig(validated);
await getGatewayRolloutConfigPublisher().publish(validated);
const patch = data.gateway_rollout;
const landed = await instanceConfigRepository.updateGatewayRolloutConfig((current) =>
GatewayRolloutConfigSchema.parse({...current, ...patch}),
);
await getGatewayRolloutConfigPublisher().publish(landed);
}
if (data.voice_noise_suppression) {
const patch = omitUndefinedFields(data.voice_noise_suppression);
if (Object.keys(patch).length > 0) {
const currentNoiseSuppression = await instanceConfigRepository.getVoiceNoiseSuppressionConfig();
const validated = VoiceNoiseSuppressionConfigSchema.parse({
...currentNoiseSuppression,
...patch,
config_version: currentNoiseSuppression.config_version + 1,
});
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
await instanceConfigRepository.updateVoiceNoiseSuppressionConfig((current) =>
VoiceNoiseSuppressionConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.screen_share_delivery) {
const patch = omitUndefinedFields(data.screen_share_delivery);
if (Object.keys(patch).length > 0) {
const currentScreenShareDelivery = await instanceConfigRepository.getScreenShareDeliveryConfig();
const validated = ScreenShareDeliveryConfigSchema.parse({
...currentScreenShareDelivery,
...patch,
config_version: currentScreenShareDelivery.config_version + 1,
});
await instanceConfigRepository.setScreenShareDeliveryConfig(validated);
await instanceConfigRepository.updateScreenShareDeliveryConfig((current) =>
ScreenShareDeliveryConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.push_service_delivery) {
const patch = omitUndefinedFields(data.push_service_delivery);
if (Object.keys(patch).length > 0) {
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
PushServiceDeliveryConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
await getPushServiceDeliveryConfigPublisher().publish(landed);
}
}
if (data.experiment_delivery) {
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
const validated = ExperimentDeliveryConfigSchema.parse({
...currentExperimentDelivery,
...data.experiment_delivery,
});
await instanceConfigRepository.setExperimentDeliveryConfig(validated);
const patch = data.experiment_delivery;
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
ExperimentDeliveryConfigSchema.parse({...current, ...patch}),
);
}
if (data.sso) {
const sso = data.sso;
@@ -308,21 +327,22 @@ export function InstanceConfigAdminController(app: HonoApp) {
const validated = await normalizeAndValidateSsoConfig(next, {
testModeEnabled: Config.dev.testModeEnabled,
});
const supplied = <T>(field: keyof typeof sso, value: T): T | undefined =>
readOptionalField(sso, field) === undefined ? undefined : value;
await instanceConfigRepository.setSsoConfig({
enabled: validated.enabled,
enforced: validated.enforced,
displayName: next.displayName,
issuer: validated.issuer,
authorizationUrl: validated.authorizationUrl,
tokenUrl: validated.tokenUrl,
userInfoUrl: validated.userInfoUrl,
jwksUrl: validated.jwksUrl,
clientId: validated.clientId,
enabled: supplied('enabled', validated.enabled),
enforced: supplied('enforced', validated.enforced),
displayName: supplied('display_name', next.displayName),
issuer: supplied('issuer', validated.issuer),
authorizationUrl: supplied('authorization_url', validated.authorizationUrl),
tokenUrl: supplied('token_url', validated.tokenUrl),
userInfoUrl: supplied('userinfo_url', validated.userInfoUrl),
jwksUrl: supplied('jwks_url', validated.jwksUrl),
clientId: supplied('client_id', validated.clientId),
clientSecret: readOptionalField(sso, 'client_secret'),
scope: next.scope,
allowedEmailDomains: validated.allowedEmailDomains,
autoProvision: next.autoProvision,
redirectUri: null,
scope: supplied('scope', next.scope),
allowedEmailDomains: supplied('allowed_domains', validated.allowedEmailDomains),
autoProvision: supplied('auto_provision', next.autoProvision),
});
}
if (data.registration) {
@@ -625,7 +645,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
async (ctx) => {
const userId = ctx.req.valid('param').user_id.toString();
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
await instanceConfigRepository.getPendingRegistrations();
await updatePendingRegistrationUser(ctx, userId, decision);
await instanceConfigRepository.removePendingRegistration(userId);
return ctx.json(await buildInstanceConfigResponse());
@@ -638,27 +657,47 @@ async function applyInstancePolicyUpdate(
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
): Promise<void> {
const instanceConfigRepository = getInstanceConfigRepository();
const [current, appPublic] = await Promise.all([
instanceConfigRepository.getInstancePolicyConfig(),
instanceConfigRepository.getAppPublicConfig(),
]);
const appPublic = await instanceConfigRepository.getAppPublicConfig();
const adminUser =
policy.single_community_enabled === true
? await ctx.get('userRepository').findUnique(ctx.get('adminUserId'))
: null;
let enablesSingleCommunity = false;
await instanceConfigRepository.updateInstancePolicyConfig((current) => {
const planned = planInstancePolicyPatch(policy, current, {
setupConfigured: appPublic.setup.configured,
adminUserFound: adminUser !== null,
});
enablesSingleCommunity = planned.enablesSingleCommunity;
return planned.patch;
});
if (enablesSingleCommunity && adminUser) {
await ctx.get('singleCommunityService').ensureStockCommunity({
owner: adminUser,
name: policy.single_community_name?.trim() || appPublic.branding.product_name,
});
}
if (policy.premium_mode !== undefined) {
await ctx.get('limitConfigService').updatePolicyConfig({premium_mode: policy.premium_mode});
}
}
function planInstancePolicyPatch(
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
current: InstancePolicyConfig,
context: {setupConfigured: boolean; adminUserFound: boolean},
): {patch: Partial<InstancePolicyConfig>; enablesSingleCommunity: boolean} {
const patch: Partial<InstancePolicyConfig> = {};
let enablesSingleCommunity = false;
if (
policy.single_community_enabled !== undefined &&
policy.single_community_enabled !== current.single_community_enabled
) {
if (policy.single_community_enabled) {
if (appPublic.setup.configured && current.single_community_guild_id == null) {
if ((context.setupConfigured && current.single_community_guild_id == null) || !context.adminUserFound) {
throw new InstancePolicyTransitionNotAllowedError();
}
const adminUser = await ctx.get('userRepository').findUnique(ctx.get('adminUserId'));
if (!adminUser) {
throw new InstancePolicyTransitionNotAllowedError();
}
await ctx.get('singleCommunityService').ensureStockCommunity({
owner: adminUser,
name: policy.single_community_name?.trim() || appPublic.branding.product_name,
});
enablesSingleCommunity = true;
} else {
patch.single_community_enabled = false;
}
@@ -679,9 +718,6 @@ async function applyInstancePolicyUpdate(
patch.direct_messages_locked = true;
}
}
if (policy.premium_mode !== undefined) {
patch.premium_mode = policy.premium_mode;
}
if (policy.services) {
if (policy.services.gif_enabled !== undefined) {
patch.gif_enabled = policy.services.gif_enabled ?? null;
@@ -704,11 +740,7 @@ async function applyInstancePolicyUpdate(
patch.deferred_phone_gate_member_threshold = policy.deferred_phone_gate.member_threshold;
}
}
if (patch.premium_mode !== undefined) {
await ctx.get('limitConfigService').updatePolicyConfig(patch);
} else if (Object.keys(patch).length > 0) {
await instanceConfigRepository.setInstancePolicyConfig(patch);
}
return {patch, enablesSingleCommunity};
}
async function updatePendingRegistrationUser(
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
type PushServiceDeliveryConfig,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
describe('instance config admin PATCH under concurrent writes', () => {
let harness: ApiTestHarness;
let executor: InstanceConfigWriteRaceExecutor;
beforeAll(async () => {
harness = await createApiTestHarness();
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
setCassandraQueryExecutorForTesting(executor);
});
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness.shutdown();
});
const createAdmin = async (): Promise<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
const spyOnPushDeliveryPublishes = () =>
vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
async function readStoredPushServiceDelivery(): Promise<PushServiceDeliveryConfig> {
const raw = await executor.readDirectly(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
if (raw === null) throw new Error('push service delivery config was never stored');
return JSON.parse(raw) as PushServiceDeliveryConfig;
}
async function listConfigUpdateAudits(): Promise<Array<AdminAuditLog>> {
const logs = await getAdminRepository().listAllAuditLogsPaginated(100000);
return logs.filter((log) => log.action === 'update_instance_config');
}
it('answers with a conflict and neither writes, publishes nor audits once every attempt has lost the race', async () => {
const publish = spyOnPushDeliveryPublishes();
const admin = await createAdmin();
await patchConfig(admin, {push_service_delivery: {enabled: true, rollout_basis_points: 1000}}).execute();
publish.mockClear();
const auditsBefore = await listConfigUpdateAudits();
executor.watch(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
let competingWrites = 0;
executor.competeBeforeEachWrite(async () => {
competingWrites++;
await executor.writeDirectly(
PUSH_SERVICE_DELIVERY_CONFIG_KEY,
JSON.stringify({
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
enabled: false,
rollout_basis_points: 1000,
config_version: 100 + competingWrites,
}),
);
});
await patchConfig(admin, {push_service_delivery: {rollout_basis_points: 5000}})
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.CONFLICT)
.execute();
expect(executor.events).not.toContain('write');
expect(await readStoredPushServiceDelivery()).toEqual({
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
enabled: false,
rollout_basis_points: 1000,
config_version: 100 + competingWrites,
});
expect(publish).not.toHaveBeenCalled();
expect(await listConfigUpdateAudits()).toHaveLength(auditsBefore.length);
});
});
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
describe('instance config admin PATCH against state another node changed', () => {
let harness: ApiTestHarness;
let executor: InstanceConfigWriteRaceExecutor;
beforeAll(async () => {
harness = await createApiTestHarness();
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
setCassandraQueryExecutorForTesting(executor);
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness.shutdown();
});
const createAdmin = async (): Promise<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
it('keeps an SSO field another node changed when a patch changes a different one', async () => {
const admin = await createAdmin();
await patchConfig(admin, {sso: {display_name: 'Before', client_id: 'client-before'}}).execute();
await executor.writeDirectly('sso_display_name', 'Changed on another node');
await patchConfig(admin, {sso: {client_id: 'client-after'}}).execute();
expect(await executor.readDirectly('sso_display_name')).toBe('Changed on another node');
expect(await executor.readDirectly('sso_client_id')).toBe('client-after');
});
it('refuses to disable direct messages when their lock lands between the read and the write', async () => {
const admin = await createAdmin();
await patchConfig(admin, {policy: {services: {gif_enabled: true}}}).execute();
executor.watch(INSTANCE_POLICY_CONFIG_KEY);
let competed = false;
executor.competeBeforeEachWrite(async () => {
if (competed) return;
competed = true;
await executor.writeDirectly(
INSTANCE_POLICY_CONFIG_KEY,
JSON.stringify({direct_messages_disabled: false, direct_messages_locked: true, gif_enabled: true}),
);
});
await patchConfig(admin, {policy: {direct_messages_disabled: true}})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INSTANCE_POLICY_TRANSITION_NOT_ALLOWED)
.execute();
const stored = JSON.parse((await executor.readDirectly(INSTANCE_POLICY_CONFIG_KEY)) ?? 'null');
expect(stored).toMatchObject({direct_messages_disabled: false, direct_messages_locked: true, gif_enabled: true});
});
it('applies the DM rule and a premium mode change from one request', async () => {
const admin = await createAdmin();
await patchConfig(admin, {policy: {direct_messages_disabled: true}}).execute();
const updated = await patchConfig(admin, {
policy: {direct_messages_disabled: false, premium_mode: 'mirror'},
}).execute();
expect(updated.policy).toMatchObject({
direct_messages_disabled: false,
direct_messages_locked: true,
premium_mode: 'mirror',
});
});
});
+68 -18
View File
@@ -7,12 +7,14 @@ import * as AuthUtility from '@app/api/auth/AuthUtility';
import type {IRegistrationRiskEvaluator} from '@app/api/auth/services/IRegistrationRiskEvaluator';
import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {APIConfig} from '@app/api/config/APIConfig';
import type {UserRow} from '@app/api/database/types/UserTypes';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
import {
type InstanceConfigRepository,
type InstanceRegistrationUrl,
REGISTRATION_PENDING_APPROVAL_TRAIT,
type RegistrationUrlClaim,
} from '@app/api/instance/InstanceConfigRepository';
import type {SingleCommunityService} from '@app/api/instance/SingleCommunityService';
import type {InviteService} from '@app/api/invite/InviteService';
@@ -135,9 +137,6 @@ export async function register(
}
const now = new Date();
const registrationAccess = await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.getPendingRegistrations();
}
const clientIp = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
@@ -228,7 +227,7 @@ export async function register(
const userLocale = parseAcceptLanguage(acceptLanguage);
const passwordHash = data.password ? await AuthPassword.hashPassword(ctx, data.password) : null;
const flags = config.nodeEnv === 'development' ? UserFlags.STAFF : 0n;
let user = await users.create({
const userRow: UserRow = {
user_id: userId,
username,
discriminator,
@@ -287,7 +286,39 @@ export async function register(
mention_flags: null,
last_voice_activity_sharing_change_at: null,
version: 1,
});
};
const registrationUrlUse = await claimRegistrationUrlUse(
instanceConfigRepository,
registrationAccess.registrationUrl,
userId,
);
let user: User;
let createAttempted = false;
try {
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.addPendingRegistration({
user_id: userId.toString(),
username: userRow.username,
discriminator: userRow.discriminator,
global_name: userRow.global_name,
email: rawEmail,
requested_at: now.toISOString(),
registration_url_id: registrationAccess.registrationUrl?.id ?? null,
client_ip: clientIp,
});
}
createAttempted = true;
user = await users.create(userRow);
} catch (error) {
if (!createAttempted) {
await withdrawSignupOfUncreatedAccount(instanceConfigRepository, {
userId,
registrationUrlUse,
pendingApproval: registrationAccess.pendingApproval,
});
}
throw error;
}
await users.upsertSettings(
UserSettings.getDefaultUserSettings({
userId,
@@ -401,20 +432,7 @@ export async function register(
}
if (rawEmail && emailEnabled) await maybeSendVerificationEmail(ctx, {user, email: rawEmail});
await users.createAuthorizedIp(userId, clientIp);
if (registrationAccess.registrationUrl) {
await instanceConfigRepository.recordRegistrationUrlUse(registrationAccess.registrationUrl.id, user.id.toString());
}
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.addPendingRegistration({
user_id: user.id.toString(),
username: user.username,
discriminator: user.discriminator,
global_name: user.globalName,
email: rawEmail,
requested_at: now.toISOString(),
registration_url_id: registrationAccess.registrationUrl?.id ?? null,
client_ip: clientIp,
});
return {
registration_pending_approval: true,
user_id: user.id.toString(),
@@ -469,6 +487,38 @@ function shouldAttemptBootstrapAdminGrant(
);
}
async function claimRegistrationUrlUse(
instanceConfigRepository: InstanceConfigRepository,
registrationUrl: InstanceRegistrationUrl | null,
userId: UserID,
): Promise<RegistrationUrlClaim | null> {
if (registrationUrl === null) return null;
const use = await instanceConfigRepository.claimRegistrationUrlUse(registrationUrl.id, userId.toString());
if (use === null) {
throw new RegistrationUrlInvalidError();
}
return use;
}
async function withdrawSignupOfUncreatedAccount(
instanceConfigRepository: InstanceConfigRepository,
signup: {userId: UserID; registrationUrlUse: RegistrationUrlClaim | null; pendingApproval: boolean},
): Promise<void> {
try {
if (signup.registrationUrlUse !== null) {
await instanceConfigRepository.releaseRegistrationUrlUse(signup.registrationUrlUse);
}
if (signup.pendingApproval) {
await instanceConfigRepository.removePendingRegistration(signup.userId.toString());
}
} catch (error) {
Logger.warn(
{userId: signup.userId.toString(), registrationUrlId: signup.registrationUrlUse?.registration_url_id, error},
'[AuthRegistration] Failed to withdraw the registration URL use or pending approval of an account that was never created',
);
}
}
async function resolveRegistrationAccess(
instanceConfigRepository: InstanceConfigRepository,
registrationUrlCode: string | null | undefined,
+24 -13
View File
@@ -382,21 +382,8 @@ export class SsoService {
throw new RegistrationClosedError();
}
const pendingApproval = registrationConfig.mode === 'approval';
if (pendingApproval) {
await this.instanceConfigRepository.getPendingRegistrations();
}
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval});
if (pendingApproval) {
await this.instanceConfigRepository.addPendingRegistration({
user_id: user.id.toString(),
username: user.username,
discriminator: user.discriminator,
global_name: user.globalName,
email: user.email,
requested_at: new Date().toISOString(),
registration_url_id: null,
client_ip: null,
});
throw new RegistrationPendingApprovalError();
}
return user;
@@ -537,8 +524,22 @@ export class SsoService {
version: 1,
} as const;
await this.claimSsoIdentity(userId, claims.sub, config);
let createAttempted = false;
let userCreated = false;
try {
if (options?.pendingApproval) {
await this.instanceConfigRepository.addPendingRegistration({
user_id: userId.toString(),
username,
discriminator: discriminatorResult.discriminator,
global_name: globalName,
email: userRow.email,
requested_at: now.toISOString(),
registration_url_id: null,
client_ip: null,
});
}
createAttempted = true;
const user = await users.create(userRow);
userCreated = true;
await users.upsertSettings(
@@ -557,6 +558,16 @@ export class SsoService {
await this.ssoIdentityRepository.releaseIdentity(config.providerId, claims.sub).catch((releaseError) => {
getLogger().error({releaseError}, 'Failed to release SSO identity after user provisioning failed');
});
if (options?.pendingApproval && !createAttempted) {
await this.instanceConfigRepository
.removePendingRegistration(userId.toString())
.catch((removeError: unknown) => {
getLogger().error(
{userId: userId.toString(), removeError},
'Failed to withdraw the pending approval of an SSO user that was never created',
);
});
}
}
throw error;
}
@@ -0,0 +1,436 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {
createAuthHarness,
createTestAccount,
createUniqueEmail,
createUniqueUsername,
enableSso,
setUserACLs,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {
InstanceConfigRepository,
REGISTRATION_PENDING_APPROVAL_TRAIT,
} from '@app/api/instance/InstanceConfigRepository';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const REGISTRATION_URLS_KEY = 'registration_urls';
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
interface RegistrationResponse {
user_id?: string;
token?: string;
registration_pending_approval?: true;
code?: string;
}
function registrationBody(prefix: string, registrationUrlCode?: string): Record<string, unknown> {
return {
email: createUniqueEmail(prefix),
username: createUniqueUsername(prefix),
global_name: 'Signup Race',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
...(registrationUrlCode === undefined ? {} : {registration_url_code: registrationUrlCode}),
};
}
describe('signups racing on registration URLs and pending approvals', () => {
let harness: ApiTestHarness;
let admin: TestAccount;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
const register = (prefix: string, registrationUrlCode?: string) =>
createBuilderWithoutAuth<RegistrationResponse>(harness)
.post('/auth/register')
.body(registrationBody(prefix, registrationUrlCode))
.executeRaw();
const readAdminConfig = (): Promise<InstanceConfigResponse> =>
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config').execute();
const completeSso = async (prefix: string) => {
const start = await createBuilderWithoutAuth<{state: string}>(harness)
.post('/auth/sso/start')
.body({redirect_to: '/me'})
.execute();
return createBuilderWithoutAuth(harness)
.post('/auth/sso/complete')
.body({code: createUniqueEmail(prefix), state: start.state})
.executeRaw();
};
const failCreateAfterTheUserRowIsWritten = () => {
const create = UserRepository.prototype.create;
vi.spyOn(UserRepository.prototype, 'create').mockImplementationOnce(async function (
this: UserRepository,
row: UserRow,
) {
await create.call(this, row);
throw new Error('the user indexes could not be written after the user row');
});
};
const failAfterThePendingApprovalIsStored = () => {
const addPendingRegistration = InstanceConfigRepository.prototype.addPendingRegistration;
vi.spyOn(InstanceConfigRepository.prototype, 'addPendingRegistration').mockImplementationOnce(async function (
this: InstanceConfigRepository,
entry: Parameters<InstanceConfigRepository['addPendingRegistration']>[0],
) {
await addPendingRegistration.call(this, entry);
throw new Error('the pending approval could not be published');
});
};
const expectOnePendingAccount = async () => {
const pending = (await readAdminConfig()).registration.pending_registrations;
expect(pending).toHaveLength(1);
const account = await new UserRepository().findUnique(createUserID(BigInt(pending[0]!.user_id)));
expect(account?.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)).toBe(true);
};
it('never lets concurrent signups through a capped registration URL exceed max_uses', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Capped',
createdByUserId: '1',
expiresAt: null,
maxUses: 2,
approvalRequired: false,
});
const attempts = await Promise.all(Array.from({length: 6}, (_, index) => register(`capped${index}`, code)));
const admitted = attempts.filter((attempt) => attempt.response.status === HTTP_STATUS.OK);
const refused = attempts.filter((attempt) => attempt.response.status !== HTTP_STATUS.OK);
expect(admitted).toHaveLength(2);
for (const attempt of refused) {
expect(attempt.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(attempt.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
}
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(2);
expect(admitted.map((attempt) => attempt.json.user_id)).toContain(stored?.last_used_by_user_id);
});
it('admits exactly max_uses when 120 signups race through a registration URL capped at 40', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Capped at 40',
createdByUserId: '1',
expiresAt: null,
maxUses: 40,
approvalRequired: false,
});
const registerUntilDecided = async (prefix: string) => {
for (let attempt = 0; attempt < 20; attempt += 1) {
const result = await register(`${prefix}r${attempt}`, code);
if (result.response.status !== HTTP_STATUS.SERVICE_UNAVAILABLE) return result;
}
throw new Error('a signup never reached a decision');
};
const attempts = await Promise.all(Array.from({length: 120}, (_, index) => registerUntilDecided(`surge${index}`)));
const admitted = attempts.filter((attempt) => attempt.response.status === HTTP_STATUS.OK);
expect(admitted).toHaveLength(40);
for (const attempt of attempts.filter((entry) => entry.response.status !== HTTP_STATUS.OK)) {
expect(attempt.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(attempt.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
}
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(40);
});
it('counts every concurrent signup through an uncapped registration URL', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Uncapped',
createdByUserId: '1',
expiresAt: null,
maxUses: null,
approvalRequired: false,
});
const attempts = await Promise.all(Array.from({length: 5}, (_, index) => register(`uncapped${index}`, code)));
expect(attempts.map((attempt) => attempt.response.status)).toEqual(Array(5).fill(HTTP_STATUS.OK));
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(5);
});
it('gives the seat and the pending entry back when the signup failed before the account was created', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Single use',
createdByUserId: '1',
expiresAt: null,
maxUses: 1,
approvalRequired: true,
});
failAfterThePendingApprovalIsStored();
const failed = await register('seatreleased', code);
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
const withdrawn = await readAdminConfig();
expect(withdrawn.registration.pending_registrations).toEqual([]);
expect(withdrawn.registration.urls.find((url) => url.id === registrationUrl.id)?.use_count).toBe(0);
const retried = await register('seatreleasedretry', code);
expect(retried.response.status).toBe(HTTP_STATUS.OK);
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored).toMatchObject({use_count: 1, last_used_by_user_id: retried.json.user_id});
});
it('keeps the seat when the account create itself failed, because the row may still have landed', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Single use',
createdByUserId: '1',
expiresAt: null,
maxUses: 1,
approvalRequired: false,
});
vi.spyOn(UserRepository.prototype, 'create').mockRejectedValueOnce(new Error('the user row write failed'));
const failed = await register('seatkeptoncreate', code);
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
const second = await register('seatkeptcreate2', code);
expect(second.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(second.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(1);
});
it('keeps the seat of an account whose row was written before its creation failed', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const {code, registrationUrl} = await repository.createRegistrationUrl({
label: 'Single use',
createdByUserId: '1',
expiresAt: null,
maxUses: 1,
approvalRequired: false,
});
failCreateAfterTheUserRowIsWritten();
const failed = await register('seatkept', code);
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
const second = await register('seatkeptsecond', code);
expect(second.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(second.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
const stored = (await readAdminConfig()).registration.urls.find((url) => url.id === registrationUrl.id);
expect(stored?.use_count).toBe(1);
});
it('honours the use count and cap already stored on a registration URL', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'closed', admin_registration_urls_enabled: true});
const id = 'b3c4f0b2-8a6e-4c41-9f55-3f0c2a7d1e90';
await repository.setConfig(
REGISTRATION_URLS_KEY,
JSON.stringify([
{
id,
label: 'Issued earlier',
code_hash: createHash('sha256').update(id).digest('hex'),
created_by_user_id: '1400000000000000001',
created_at: '2026-09-01T00:00:00.000Z',
expires_at: null,
max_uses: 2,
use_count: 1,
revoked_at: null,
approval_required: false,
last_used_at: '2026-09-02T00:00:00.000Z',
last_used_by_user_id: '1400000000000000002',
},
]),
);
const before = (await readAdminConfig()).registration.urls.find((url) => url.id === id);
expect(before).toMatchObject({use_count: 1, max_uses: 2, last_used_by_user_id: '1400000000000000002'});
const first = await register('storedinvite', id);
expect(first.response.status).toBe(HTTP_STATUS.OK);
const second = await register('storedinviteagain', id);
expect(second.response.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(second.json.code).toBe(APIErrorCodes.REGISTRATION_URL_INVALID);
const after = (await readAdminConfig()).registration.urls.find((url) => url.id === id);
expect(after).toMatchObject({use_count: 2, max_uses: 2, last_used_by_user_id: first.json.user_id});
});
it('keeps every pending approval when approval-mode signups race', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const attempts = await Promise.all(Array.from({length: 5}, (_, index) => register(`pending${index}`)));
expect(attempts.map((attempt) => attempt.json.registration_pending_approval)).toEqual(Array(5).fill(true));
const pending = (await readAdminConfig()).registration.pending_registrations.map((entry) => entry.user_id);
expect(pending.toSorted()).toEqual(attempts.map((attempt) => attempt.json.user_id).toSorted());
});
it('lists an approval-mode account whose signup failed after the account was created', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
vi.spyOn(UserRepository.prototype, 'createAuthorizedIp').mockRejectedValueOnce(
new Error('the authorized IP write failed'),
);
const failed = await register('pendingstranded');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
await expectOnePendingAccount();
});
it('lists an approval-mode account whose row was written before its creation failed', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
failCreateAfterTheUserRowIsWritten();
const failed = await register('pendingrowwritten');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
await expectOnePendingAccount();
});
it('keeps the pending approval of an approval-mode signup whose account create failed', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
vi.spyOn(UserRepository.prototype, 'create').mockRejectedValueOnce(new Error('the user row write failed'));
const failed = await register('pendingkept');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toHaveLength(1);
});
it('lists no pending approval for an approval-mode signup that failed before the account was created', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
failAfterThePendingApprovalIsStored();
const failed = await register('pendingnever');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toEqual([]);
});
it('lists an SSO account provisioned in approval mode whose provisioning failed after the account was created', async () => {
await enableSso(harness, admin.token, {enforced: false});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
vi.spyOn(UserRepository.prototype, 'upsertSettings').mockRejectedValueOnce(new Error('the settings write failed'));
const failed = await completeSso('ssopendingstranded');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
await expectOnePendingAccount();
});
it('lists an SSO account provisioned in approval mode whose row was written before its creation failed', async () => {
await enableSso(harness, admin.token, {enforced: false});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
failCreateAfterTheUserRowIsWritten();
const failed = await completeSso('ssopendingrowwritten');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
await expectOnePendingAccount();
});
it('keeps the pending approval of an SSO signup in approval mode whose account create failed', async () => {
await enableSso(harness, admin.token, {enforced: false});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
vi.spyOn(UserRepository.prototype, 'create').mockRejectedValueOnce(new Error('the user row write failed'));
const failed = await completeSso('ssopendingkept');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toHaveLength(1);
});
it('lists no pending approval for an SSO signup in approval mode that failed before the account was created', async () => {
await enableSso(harness, admin.token, {enforced: false});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
failAfterThePendingApprovalIsStored();
const failed = await completeSso('ssopendingnever');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toEqual([]);
});
it('keeps a stored pending approval listed until an admin decides it', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setConfig(
REGISTRATION_PENDING_APPROVALS_KEY,
JSON.stringify([
{
user_id: account.userId,
username: 'stored_pending',
discriminator: 1,
global_name: null,
email: account.email,
requested_at: '2026-09-01T00:00:00.000Z',
registration_url_id: null,
client_ip: '127.0.0.1',
},
]),
);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const fresh = await register('pendingafter');
const listed = (await readAdminConfig()).registration.pending_registrations.map((entry) => entry.user_id);
expect(listed.toSorted()).toEqual([account.userId, fresh.json.user_id].toSorted());
const decided = await createBuilder<InstanceConfigResponse>(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${account.userId}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
expect(decided.registration.pending_registrations.map((entry) => entry.user_id)).toEqual([fresh.json.user_id]);
expect(
JSON.parse(
(await getInstanceConfigRepository().getConfig(REGISTRATION_PENDING_APPROVALS_KEY)) ?? 'null',
) as Array<{user_id: string}>,
).toEqual([expect.objectContaining({user_id: fresh.json.user_id})]);
});
});
@@ -3,7 +3,7 @@
import type {AttachmentID, ChannelID} from '@app/api/BrandedTypes';
import type {AttachmentRequestData} from '@app/api/channel/AttachmentDTOs';
import type {RichEmbedMediaWithMetadata} from '@app/api/channel/EmbedTypes';
import {makeAttachmentCdnUrl} from '@app/api/channel/services/message/MessageHelpers';
import {getContentType, makeAttachmentCdnUrl} from '@app/api/channel/services/message/MessageHelpers';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {RichEmbedRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
@@ -26,7 +26,10 @@ interface RichEmbedRequestWithMetadata extends Omit<RichEmbedRequest, 'image' |
thumbnail?: RichEmbedMediaWithMetadata | null;
}
const SUPPORTED_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif']);
function isEmbeddableMediaType(contentType: string): boolean {
const normalized = contentType.toLowerCase();
return normalized.startsWith('image/') || normalized.startsWith('video/');
}
export class MessageEmbedAttachmentResolver {
validateAttachmentReferences(params: {
@@ -69,8 +72,7 @@ export class MessageEmbedAttachmentResolver {
{filename},
);
}
const extension = filename.split('.').pop()?.toLowerCase();
if (!extension || !SUPPORTED_IMAGE_EXTENSIONS.has(extension)) {
if (!isEmbeddableMediaType(getContentType(filename))) {
throw InputValidationError.fromCode(
`embeds[${embedIndex}].${field}`,
ValidationErrorCodes.ATTACHMENT_MUST_BE_IMAGE,
@@ -137,8 +139,7 @@ export class MessageEmbedAttachmentResolver {
if (!attachmentData) {
throw InputValidationError.fromCode(field, ValidationErrorCodes.REFERENCED_ATTACHMENT_NOT_FOUND, {filename});
}
const extension = filename.split('.').pop()?.toLowerCase();
if (!extension || !SUPPORTED_IMAGE_EXTENSIONS.has(extension)) {
if (!isEmbeddableMediaType(attachmentData.metadata.content_type)) {
throw InputValidationError.fromCode(field, ValidationErrorCodes.ATTACHMENT_MUST_BE_IMAGE, {filename});
}
return attachmentData;
@@ -512,6 +512,34 @@ describe('Embed Attachment URL Resolution', () => {
expect(json.embeds).toHaveLength(1);
expect(json.embeds![0].image?.url).not.toContain('attachment://');
});
it('should accept image and video attachments beyond the legacy image extensions', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Media Type Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const payload = {
content: 'Test with jxl and mp4 embed media',
attachments: [
{id: 0, filename: 'photo.jxl'},
{id: 1, filename: 'clip.mp4'},
],
embeds: [
{
title: 'Media Embed',
image: {url: 'attachment://clip.mp4'},
thumbnail: {url: 'attachment://photo.jxl'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'photo.jxl', data: Buffer.from('jxl bytes')},
{index: 1, filename: 'clip.mp4', data: Buffer.from('mp4 bytes')},
]);
expect(response.status).toBe(200);
expect(json.embeds).toHaveLength(1);
expect(json.embeds![0].image?.url).not.toContain('attachment://');
expect(json.embeds![0].thumbnail?.url).not.toContain('attachment://');
});
});
describe('Multiple Embeds and Files', () => {
it('should handle multiple embeds with different URL types', async () => {
-2
View File
@@ -143,8 +143,6 @@ export interface APIConfig {
donationProxyKey: string;
};
hosts: {
invite: string;
gift: string;
marketing: string;
unfurlIgnored: Array<string>;
};
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {BannedFileShaRow} from '@app/api/database/types/AdminArchiveTypes';
export const BANNED_URLS_REFRESH_CHANNEL = 'banned_urls_refresh';
export const BANNED_URL_DOMAINS_REFRESH_CHANNEL = 'banned_url_domains_refresh';
export const BANNED_FILE_SHAS_REFRESH_CHANNEL = 'banned_file_shas_refresh';
@@ -23,3 +25,7 @@ export const ContentBlocklistCategory = {
GIFCT: 'gifct',
STOP_NCII: 'stop_ncii',
} as const;
export function isBlocklistFeedFileSha(row: Pick<BannedFileShaRow, 'category' | 'added_by'>): boolean {
return row.added_by == null && row.category === ContentBlocklistCategory.MALWARE_BAZAAR;
}
@@ -1,18 +1,18 @@
{
"auth.unknown_location": "Ubicación desconocida",
"billing.donation_description_monthly": "Donación mensual para apoyar a {product_name}",
"billing.donation_description_one_time": "Donación única para apoyar a {product_name}",
"billing.donation_description_yearly": "Donación anual para apoyar a {product_name}",
"billing.donation_name_one_time": "Donación a {product_name}",
"billing.donation_name_recurring": "Donación recurrente a {product_name}",
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
"guild.bulk_create.unknown_error": "Error desconocido.",
"guild.default_category_text": "Canales de texto",
"guild.default_category_voice": "Canales de voz",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
"auth.unknown_location": "Ubicación desconocida",
"billing.donation_description_monthly": "Donación mensual para apoyar a {product_name}",
"billing.donation_description_one_time": "Donación única para apoyar a {product_name}",
"billing.donation_description_yearly": "Donación anual para apoyar a {product_name}",
"billing.donation_name_one_time": "Donación a {product_name}",
"billing.donation_name_recurring": "Donación recurrente a {product_name}",
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
"guild.bulk_create.unknown_error": "Error desconocido.",
"guild.default_category_text": "Canales de texto",
"guild.default_category_voice": "Canales de voz",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
}
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {DEFAULT_TTL_TABLES} from '@app/api/database/PostgresKvDefaultTtlExpiry';
import * as DonationTables from '@app/api/donation/DonationTables';
import * as Tables from '@app/api/Tables';
import {IPINFO_CACHE_TTL_SECONDS, IPINFO_REQUEST_AUDIT_TTL_SECONDS} from '@pkgs/geoip/src/PostgresIpInfoKv';
import {describe, expect, it} from 'vitest';
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(THIS_DIR, '../../../..');
interface SchemaTable {
name: string;
options: string;
}
const SCHEMA = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'tools/dev/cassandra_target_schema.json'), 'utf8')) as {
tables: Array<SchemaTable>;
};
const SCHEMA_DEFAULTS = new Map<string, number>(
SCHEMA.tables.flatMap((table): Array<[string, number]> => {
const match = /default_time_to_live = (\d+)/.exec(table.options);
return match ? [[table.name, Number(match[1])]] : [];
}),
);
const DSL_TABLES = [...Object.values(Tables), ...Object.values(DonationTables)];
const DSL_NAMES = new Set<string>(DSL_TABLES.map((table) => table.name));
const NON_DSL_DEFAULTS: Record<string, number | null> = {
ipinfo_cache: IPINFO_CACHE_TTL_SECONDS,
ipinfo_requests_by_hour: IPINFO_REQUEST_AUDIT_TTL_SECONDS,
billing_webhook_events: null,
forensic_identifier_by_key_day: null,
forensic_identifier_by_request: null,
forensic_request_meta_by_actor_day: null,
forensic_request_meta_by_id: null,
forensic_request_meta_by_route_day_shard: null,
forensic_resource_exposure_by_request: null,
forensic_resource_exposure_by_route_day_shard: null,
forensic_resource_exposure_by_subject_day: null,
};
const OWN_EXPIRY_PASS = new Set(['jobs_by_id', 'jobs_by_day_bucket']);
function schemaDefault(name: string): number {
return SCHEMA_DEFAULTS.get(name) ?? 0;
}
function byName(left: {name: string}, right: {name: string}): number {
return left.name.localeCompare(right.name);
}
describe('Cassandra default TTL parity', () => {
it('declares every Cassandra default TTL on the matching table', () => {
const mismatches = DSL_TABLES.flatMap((table) => {
const declared = table.defaultTtlSeconds ?? 0;
return declared === schemaDefault(table.name)
? []
: [{table: table.name, declared, schema: schemaDefault(table.name)}];
});
expect(mismatches).toEqual([]);
});
it('declares a writer or no writer for every other table with a default', () => {
const undeclared = [...SCHEMA_DEFAULTS]
.filter(([name, ttl]) => ttl > 0 && !DSL_NAMES.has(name) && !Object.hasOwn(NON_DSL_DEFAULTS, name))
.map(([name]) => name);
expect(undeclared).toEqual([]);
const stale = Object.keys(NON_DSL_DEFAULTS).filter((name) => schemaDefault(name) === 0 || DSL_NAMES.has(name));
expect(stale).toEqual([]);
const mismatched = Object.entries(NON_DSL_DEFAULTS)
.filter(([name, ttl]) => ttl !== null && ttl !== schemaDefault(name))
.map(([name]) => name);
expect(mismatched).toEqual([]);
});
it('the Postgres expiry pass covers every table with a default except the job ledger', () => {
const expected = [...SCHEMA_DEFAULTS]
.filter(([name, ttl]) => ttl > 0 && NON_DSL_DEFAULTS[name] !== null && !OWN_EXPIRY_PASS.has(name))
.map(([name, ttl]) => ({name, defaultTtlSeconds: ttl}))
.sort(byName);
expect([...DEFAULT_TTL_TABLES].sort(byName)).toEqual(expected);
});
});
@@ -12,6 +12,7 @@ interface TableMetadata {
columns: ReadonlyArray<string>;
primaryKey: ReadonlyArray<string>;
partitionKey: ReadonlyArray<string>;
defaultTtlSeconds?: number;
}
const kvMetaRegistry = new Map<string, KvQueryMeta<Record<string, unknown>>>();
@@ -24,6 +25,7 @@ export function registerTableSpec<Row extends object>(tableSpec: KvTableSpec<Row
columns: tableSpec.columns as ReadonlyArray<string>,
primaryKey: tableSpec.primaryKey as ReadonlyArray<string>,
partitionKey: tableSpec.partitionKey as ReadonlyArray<string>,
defaultTtlSeconds: tableSpec.defaultTtlSeconds,
};
tableRegistry.set(tableSpec.name, metadata);
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getTableMetadata} from '@app/api/database/CassandraMetaRegistry';
import {defineTable} from '@app/api/database/CassandraTableDsl';
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
import {describe, expect, it} from 'vitest';
@@ -76,3 +77,41 @@ describe('CassandraTableDsl select templates', () => {
expect(longQuery.cql).not.toContain('LIMIT 20');
});
});
describe('CassandraTableDsl default TTL', () => {
it('keeps the CQL of a table with a default TTL free of USING TTL', () => {
const DefaultTtlRows = defineTable<TtlHelperTestRow, 'id'>({
name: 'default_ttl_dsl_rows',
columns: ['id', 'value'],
primaryKey: ['id'],
defaultTtlSeconds: 600,
});
expect(DefaultTtlRows.defaultTtlSeconds).toBe(600);
const queries = [
DefaultTtlRows.insert({id: 'insert', value: 'a'}),
DefaultTtlRows.upsertAll({id: 'upsert', value: 'b'}),
DefaultTtlRows.patchByPk({id: 'patch'}, {value: Db.set('c')}),
];
for (const query of queries) {
expect(query.cql).not.toContain('USING TTL');
expect(query.kvMeta?.table.defaultTtlSeconds).toBe(600);
}
expect(getTableMetadata('default_ttl_dsl_rows')?.defaultTtlSeconds).toBe(600);
expect(TtlHelperTestRows.defaultTtlSeconds).toBeUndefined();
expect(getTableMetadata('ttl_helper_test_rows')?.defaultTtlSeconds).toBeUndefined();
});
it('rejects a default TTL of zero, a fraction or past the maximum', () => {
for (const defaultTtlSeconds of [0, 1.5, 630_720_001]) {
expect(() =>
defineTable<TtlHelperTestRow, 'id'>({
name: 'default_ttl_dsl_rejected_rows',
columns: ['id', 'value'],
primaryKey: ['id'],
defaultTtlSeconds,
}),
).toThrow();
}
expect(getTableMetadata('default_ttl_dsl_rejected_rows')).toBeUndefined();
});
});
@@ -83,6 +83,7 @@ export function defineTable<Row extends object, PK extends ColumnName<Row>, Part
columns: ReadonlyArray<ColumnName<Row>>;
primaryKey: ReadonlyArray<PK>;
partitionKey?: ReadonlyArray<PartKey>;
defaultTtlSeconds?: number;
}): Table<Row, PK, PartKey> {
const columns = [...def.columns];
const pk = [...def.primaryKey];
@@ -91,11 +92,15 @@ export function defineTable<Row extends object, PK extends ColumnName<Row>, Part
for (const c of columns) assertCqlIdentifier(c as string);
for (const k of pk) assertCqlIdentifier(k as string);
for (const k of partitionKey) assertCqlIdentifier(k as string);
if (def.defaultTtlSeconds !== undefined && validateTtlSeconds(def.defaultTtlSeconds) === 0) {
throw new Error(`Table "${def.name}" needs a positive default TTL`);
}
const tableSpec: KvTableSpec<Row> = {
name: def.name,
columns,
primaryKey: pk as ReadonlyArray<ColumnName<Row>>,
partitionKey: partitionKey as ReadonlyArray<ColumnName<Row>>,
defaultTtlSeconds: def.defaultTtlSeconds,
};
registerTableSpec(tableSpec);
const nonPkColumns = columns.filter((c) => !pk.includes(c as PK)) as Array<Exclude<ColumnName<Row>, PK>>;
@@ -685,6 +690,7 @@ WHERE ${pk.map((k) => `${k} = :${k}`).join(' AND ')};
columns: def.columns,
primaryKey: def.primaryKey,
partitionKey: partitionKey,
defaultTtlSeconds: def.defaultTtlSeconds,
selectCql,
select,
updateAllCql() {
@@ -56,6 +56,7 @@ export interface KvTableSpec<Row extends object = Record<string, unknown>> {
columns: ReadonlyArray<ColumnName<Row>>;
primaryKey: ReadonlyArray<ColumnName<Row>>;
partitionKey: ReadonlyArray<ColumnName<Row>>;
defaultTtlSeconds?: number;
}
export interface KvColumnParam<Row extends object = Record<string, unknown>> {
@@ -190,6 +191,7 @@ export interface Table<Row extends object, PK extends ColumnName<Row>, PartKey e
columns: ReadonlyArray<ColumnName<Row>>;
primaryKey: ReadonlyArray<PK>;
partitionKey: ReadonlyArray<PartKey>;
defaultTtlSeconds: number | undefined;
selectCql(opts?: {
columns?: ReadonlyArray<ColumnName<Row>>;
where?: WhereExpr<Row> | ReadonlyArray<WhereExpr<Row>>;
@@ -0,0 +1,490 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {spawnSync} from 'node:child_process';
import {createServer} from 'node:net';
import {defineTable} from '@app/api/database/CassandraTableDsl';
import {Db} from '@app/api/database/CassandraTypes';
import {
DEFAULT_TTL_EXPIRY_RESUME,
DEFAULT_TTL_TABLES,
expireLegacyDefaultTtlRows,
} from '@app/api/database/PostgresKvDefaultTtlExpiry';
import {
ensurePostgresKvSchema,
PostgresKvQueryExecutor,
pruneExpiredPostgresKvRows,
} from '@app/api/database/PostgresKvQueryExecutor';
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
import {
getDefaultPostgresClient,
type IPostgresClient,
initPostgres,
shutdownPostgres,
} from '@pkgs/postgres/src/Client';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const KV_TABLE = 'kv_default_ttl';
const CONTAINER = `fluxer-kvttl-${process.pid.toString(36)}-${Date.now().toString(36)}`;
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
const DEFAULT_TTL_SECONDS = 600;
interface ProbeRow {
id: string;
value: string | null;
note: string | null;
}
interface OwnedProbeRow {
owner: string;
id: string;
value: string | null;
}
const DefaultTtlProbe = defineTable<ProbeRow, 'id'>({
name: 'default_ttl_probe',
columns: ['id', 'value', 'note'],
primaryKey: ['id'],
defaultTtlSeconds: DEFAULT_TTL_SECONDS,
});
const DefaultTtlProbeRows = defineTable<OwnedProbeRow, 'owner' | 'id', 'owner'>({
name: 'default_ttl_probe_rows',
columns: ['owner', 'id', 'value'],
primaryKey: ['owner', 'id'],
partitionKey: ['owner'],
defaultTtlSeconds: DEFAULT_TTL_SECONDS,
});
const NoTtlProbe = defineTable<ProbeRow, 'id'>({
name: 'no_ttl_probe',
columns: ['id', 'value', 'note'],
primaryKey: ['id'],
});
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (typeof address === 'string' || address === null) {
reject(new Error('no port'));
return;
}
const port = address.port;
server.close(() => resolve(port));
});
});
}
function expectExpiresIn(value: Date | number | null, ttlSeconds: number): void {
expect(value).toBeInstanceOf(Date);
const remainingSeconds = ((value as Date).getTime() - Date.now()) / 1000;
expect(remainingSeconds).toBeGreaterThan(ttlSeconds - 60);
expect(remainingSeconds).toBeLessThanOrEqual(ttlSeconds);
}
describe.skipIf(!dockerAvailable)('Postgres KV default TTL', () => {
let raw: IPostgresClient;
let executor: PostgresKvQueryExecutor;
async function stored(table: string, id: string): Promise<{expires_at: Date | number | null; row_data: object}> {
const result = await raw.query<{expires_at: Date | number | null; row_data: object}>(
`SELECT expires_at, row_data FROM ${KV_TABLE} WHERE table_name = $1 AND row_data ->> 'id' = $2`,
[table, id],
);
expect(result.rows).toHaveLength(1);
return result.rows[0]!;
}
async function expiresAt(table: string, id: string): Promise<Date | number | null> {
return (await stored(table, id)).expires_at;
}
async function neverExpires(table: string, id: string): Promise<boolean> {
const result = await raw.query<{forever: boolean}>(
`SELECT expires_at = 'infinity'::timestamptz AS forever FROM ${KV_TABLE} WHERE table_name = $1 AND row_data ->> 'id' = $2`,
[table, id],
);
return result.rows[0]?.forever === true;
}
async function setExpiry(table: string, id: string, expression: string): Promise<void> {
await raw.query(
`UPDATE ${KV_TABLE} SET expires_at = ${expression} WHERE table_name = $1 AND row_data ->> 'id' = $2`,
[table, id],
);
}
async function seed(table: string, key: string, age: string, expires: Date | string | null = null): Promise<string> {
const result = await raw.query<{updated_at: string}>(
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
VALUES ($1, $2, $2, '{}'::jsonb, $3::timestamptz, now() - $4::interval)
RETURNING updated_at::text`,
[table, key, expires, age],
);
return result.rows[0]!.updated_at;
}
async function remaining(): Promise<Array<{table_name: string; row_key: string}>> {
const result = await raw.query<{table_name: string; row_key: string}>(
`SELECT table_name, row_key FROM ${KV_TABLE} WHERE table_name <> '__fluxer_schema_migrations' ORDER BY table_name, row_key`,
);
return result.rows;
}
async function ageMarker(): Promise<void> {
await raw.query(
`UPDATE ${KV_TABLE} SET row_data = jsonb_build_object('applied_at', now() - interval '2 days') WHERE table_name = '__fluxer_schema_migrations' AND row_key = 'default_ttl_expiry_v1'`,
);
}
async function resumePoint(): Promise<object | null> {
const result = await raw.query<{row_data: object}>(
`SELECT row_data FROM ${KV_TABLE} WHERE table_name = '__fluxer_schema_migrations' AND row_key = $1`,
[DEFAULT_TTL_EXPIRY_RESUME],
);
return result.rows[0]?.row_data ?? null;
}
async function markerCount(): Promise<number> {
const result = await raw.query<{n: number}>(
`SELECT count(*)::int AS n FROM ${KV_TABLE} WHERE table_name = '__fluxer_schema_migrations' AND row_key = 'default_ttl_expiry_v1'`,
);
return result.rows[0]!.n;
}
beforeAll(async () => {
const port = await freePort();
startDockerContainer([
'run',
'-d',
'--name',
CONTAINER,
'-e',
'POSTGRES_USER=fluxer',
'-e',
'POSTGRES_PASSWORD=fluxer',
'-e',
'POSTGRES_DB=fluxer',
'-p',
`127.0.0.1:${port}:5432`,
'postgres:16-alpine',
'-c',
'fsync=off',
]);
let ready = false;
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
await sleep(500);
const probe = spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
stdio: 'ignore',
});
if (probe.status !== 0) continue;
try {
await initPostgres({
url: `postgres://fluxer:[email protected]:${port}/fluxer`,
maxConnections: 4,
kvTable: KV_TABLE,
});
await getDefaultPostgresClient().query('SELECT 1');
ready = true;
} catch {
await shutdownPostgres().catch(() => {});
}
}
if (!ready) throw new Error('postgres never came up');
raw = getDefaultPostgresClient();
await ensurePostgresKvSchema(raw);
executor = new PostgresKvQueryExecutor(raw);
}, 900_000);
beforeEach(async () => {
await raw.query(`DELETE FROM ${KV_TABLE}`);
});
afterAll(async () => {
await shutdownPostgres().catch(() => {});
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
});
it('gives every full-row write without a TTL the table default', async () => {
await executor.executeQuery(DefaultTtlProbe.insert({id: 'insert', value: 'a', note: null}));
await executor.executeQuery(DefaultTtlProbe.upsertAll({id: 'upsert', value: 'b', note: 'n'}));
expect(
await executor.executeQuery(DefaultTtlProbe.insertIfNotExists({id: 'claimed', value: 'c', note: null})),
).toEqual([{'[applied]': true}]);
expect(
await executor.executeQuery(
DefaultTtlProbeRows.conditionalBatch([{action: 'insert', row: {owner: 'o', id: 'batched', value: 'd'}}]),
),
).toEqual([{'[applied]': true}]);
for (const id of ['insert', 'upsert', 'claimed']) {
expectExpiresIn(await expiresAt('default_ttl_probe', id), DEFAULT_TTL_SECONDS);
}
expectExpiresIn(await expiresAt('default_ttl_probe_rows', 'batched'), DEFAULT_TTL_SECONDS);
});
it('keeps an explicit TTL ahead of the default', async () => {
await executor.executeQuery(DefaultTtlProbe.insertWithTtl({id: 'short', value: 'a', note: null}, 60));
expectExpiresIn(await expiresAt('default_ttl_probe', 'short'), 60);
await executor.executeQuery(DefaultTtlProbe.insert({id: 'patched', value: 'a', note: null}));
await executor.executeQuery(DefaultTtlProbe.patchByPkWithTtl({id: 'patched'}, {value: Db.set('b')}, 60));
expectExpiresIn(await expiresAt('default_ttl_probe', 'patched'), 60);
});
it('keeps an explicit TTL of zero as no expiry', async () => {
await executor.executeQuery(DefaultTtlProbe.insertWithTtl({id: 'forever', value: 'a', note: null}, 0));
expect(await neverExpires('default_ttl_probe', 'forever')).toBe(true);
expect(
await executor.executeQuery(
DefaultTtlProbe.select({where: DefaultTtlProbe.where.eq('id')}).bind({id: 'forever'}),
),
).toEqual([{id: 'forever', value: 'a', note: null}]);
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'forever'}, {note: Db.set('patched')}));
expect(await neverExpires('default_ttl_probe', 'forever')).toBe(true);
await pruneExpiredPostgresKvRows(raw);
expect(await neverExpires('default_ttl_probe', 'forever')).toBe(true);
});
it('raises a patched row to the default but never lowers it', async () => {
await executor.executeQuery(DefaultTtlProbe.insertWithTtl({id: 'longer', value: 'a', note: null}, 3600));
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'longer'}, {note: Db.set('patched')}));
expectExpiresIn(await expiresAt('default_ttl_probe', 'longer'), 3600);
await executor.executeQuery(DefaultTtlProbe.insert({id: 'soon', value: 'a', note: null}));
await setExpiry('default_ttl_probe', 'soon', "now() + interval '5 seconds'");
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'soon'}, {note: Db.set('patched')}));
expectExpiresIn(await expiresAt('default_ttl_probe', 'soon'), DEFAULT_TTL_SECONDS);
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'missing'}, {note: Db.set('created')}));
expectExpiresIn(await expiresAt('default_ttl_probe', 'missing'), DEFAULT_TTL_SECONDS);
await executor.executeQuery(DefaultTtlProbe.insert({id: 'unset', value: 'a', note: null}));
await setExpiry('default_ttl_probe', 'unset', 'NULL');
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'unset'}, {note: Db.set('patched')}));
expectExpiresIn(await expiresAt('default_ttl_probe', 'unset'), DEFAULT_TTL_SECONDS);
await executor.executeQuery(DefaultTtlProbe.insert({id: 'expired', value: 'a', note: null}));
await setExpiry('default_ttl_probe', 'expired', "now() - interval '1 second'");
await executor.executeQuery(DefaultTtlProbe.patchByPk({id: 'expired'}, {note: Db.set('patched')}));
const revived = await stored('default_ttl_probe', 'expired');
expect(revived.row_data).toEqual({id: 'expired', note: 'patched'});
expectExpiresIn(revived.expires_at, DEFAULT_TTL_SECONDS);
});
it('raises conditional patches the same way', async () => {
await executor.executeQuery(DefaultTtlProbe.insert({id: 'soon', value: 'a', note: null}));
await setExpiry('default_ttl_probe', 'soon', "now() + interval '5 seconds'");
expect(
await executor.executeQuery(
DefaultTtlProbe.conditionalPatchByPk({id: 'soon'}, {note: Db.set('patched')}, {value: 'a'}),
),
).toEqual([{'[applied]': true}]);
expectExpiresIn(await expiresAt('default_ttl_probe', 'soon'), DEFAULT_TTL_SECONDS);
await executor.executeQuery(DefaultTtlProbe.insertWithTtl({id: 'longer', value: 'a', note: null}, 3600));
expect(
await executor.executeQuery(
DefaultTtlProbe.conditionalPatchByPk({id: 'longer'}, {note: Db.set('patched')}, {value: 'a'}),
),
).toEqual([{'[applied]': true}]);
expectExpiresIn(await expiresAt('default_ttl_probe', 'longer'), 3600);
await executor.executeQuery(DefaultTtlProbeRows.insert({owner: 'o', id: 'existing', value: 'old'}));
await setExpiry('default_ttl_probe_rows', 'existing', 'NULL');
expect(
await executor.executeQuery(
DefaultTtlProbeRows.conditionalBatch([
{action: 'insert', row: {owner: 'o', id: 'added', value: 'new'}},
{
action: 'patch',
pk: {owner: 'o', id: 'existing'},
patch: {value: Db.set('updated')},
expected: {value: 'old'},
},
]),
),
).toEqual([{'[applied]': true}]);
expectExpiresIn(await expiresAt('default_ttl_probe_rows', 'added'), DEFAULT_TTL_SECONDS);
expectExpiresIn(await expiresAt('default_ttl_probe_rows', 'existing'), DEFAULT_TTL_SECONDS);
});
it('leaves tables without a default untouched', async () => {
await executor.executeQuery(NoTtlProbe.insert({id: 'plain', value: 'a', note: null}));
expect(await expiresAt('no_ttl_probe', 'plain')).toBeNull();
await executor.executeQuery(NoTtlProbe.patchByPk({id: 'plain'}, {note: Db.set('patched')}));
expect(await expiresAt('no_ttl_probe', 'plain')).toBeNull();
await executor.executeQuery(NoTtlProbe.insertWithTtl({id: 'zero', value: 'a', note: null}, 0));
expect(await expiresAt('no_ttl_probe', 'zero')).toBeNull();
});
it('gives rows an older image wrote the expiry of their last write and deletes the ones past it', async () => {
const mentionWrittenAt = await seed('recent_mentions', 'rm-day', '1 day');
await seed('recent_mentions', 'rm-week', '8 days');
await seed('attachment_upload_traces_by_key', 'at-31', '31 days');
await seed('attachment_upload_traces_by_key', 'at-29', '29 days');
await seed('phone_lookup_cache', 'pl-8', '8 days');
await seed('donor_magic_link_tokens', 'dm-hour', '1 hour');
await seed('ipinfo_requests_by_hour', 'ip-day', '1 day');
await seed('jobs_by_id', 'job', '100 days');
await seed('users', 'user', '100 days');
await seed('recent_mentions', 'rm-forever', '1 day', 'infinity');
await seed('recent_mentions', 'rm-hour', '30 days', new Date(Date.now() + 3_600_000));
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
deleted: 4,
expiring: 3,
complete: true,
});
expect(await remaining()).toEqual([
{table_name: 'attachment_upload_traces_by_key', row_key: 'at-29'},
{table_name: 'ipinfo_requests_by_hour', row_key: 'ip-day'},
{table_name: 'jobs_by_id', row_key: 'job'},
{table_name: 'recent_mentions', row_key: 'rm-day'},
{table_name: 'recent_mentions', row_key: 'rm-forever'},
{table_name: 'recent_mentions', row_key: 'rm-hour'},
{table_name: 'users', row_key: 'user'},
]);
const exact = await raw.query<{row_key: string; exact: boolean; unchanged: boolean | null}>(
`SELECT row_key,
expires_at = updated_at + CASE table_name WHEN 'recent_mentions' THEN interval '7 days' WHEN 'attachment_upload_traces_by_key' THEN interval '30 days' ELSE interval '90 days' END AS exact,
CASE WHEN row_key = 'rm-day' THEN updated_at = $1::timestamptz END AS unchanged
FROM ${KV_TABLE}
WHERE row_key IN ('rm-day', 'at-29', 'ip-day')
ORDER BY row_key`,
[mentionWrittenAt],
);
expect(exact.rows).toEqual([
{row_key: 'at-29', exact: true, unchanged: null},
{row_key: 'ip-day', exact: true, unchanged: null},
{row_key: 'rm-day', exact: true, unchanged: true},
]);
const untouched = await raw.query<{row_key: string; state: string}>(
`SELECT row_key, CASE WHEN expires_at IS NULL THEN 'unset' WHEN expires_at = 'infinity' THEN 'forever' ELSE 'set' END AS state
FROM ${KV_TABLE}
WHERE row_key IN ('job', 'user', 'rm-forever', 'rm-hour')
ORDER BY row_key`,
);
expect(untouched.rows).toEqual([
{row_key: 'job', state: 'unset'},
{row_key: 'rm-forever', state: 'forever'},
{row_key: 'rm-hour', state: 'set'},
{row_key: 'user', state: 'unset'},
]);
expect(await markerCount()).toBe(0);
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
expiring: 0,
complete: true,
});
expect(await markerCount()).toBe(1);
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
});
it('checks again a day after a clean pass', async () => {
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
expiring: 0,
complete: true,
});
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
await seed('recent_mentions', 'rm-rolled-back', '1 day');
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
const before = await raw.query(`SELECT expires_at FROM ${KV_TABLE} WHERE row_key = 'rm-rolled-back'`);
expect(before.rows).toEqual([{expires_at: null}]);
await ageMarker();
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
expiring: 1,
complete: true,
});
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
expiring: 0,
complete: true,
});
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
});
it('pages through more rows than one page holds and stops at its deadline', async () => {
await raw.query(
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data, updated_at)
SELECT 'recent_mentions', 'rm-' || lpad(g::text, 5, '0'), 'rm-' || lpad(g::text, 5, '0'), '{}'::jsonb, now() - interval '1 day'
FROM generate_series(1, 2300) g`,
);
expect(await expireLegacyDefaultTtlRows(raw, Date.now() - 1)).toEqual({
deleted: 0,
expiring: 0,
complete: false,
});
expect(await markerCount()).toBe(0);
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
expiring: 2300,
complete: true,
});
const unset = await raw.query<{n: number}>(
`SELECT count(*)::int AS n FROM ${KV_TABLE} WHERE table_name = 'recent_mentions' AND expires_at IS NULL`,
);
expect(unset.rows[0]).toEqual({n: 0});
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
expiring: 0,
complete: true,
});
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toBeNull();
});
it('saves where a run stopped and starts the next run there', async () => {
const first = DEFAULT_TTL_TABLES[0]!.name;
const last = DEFAULT_TTL_TABLES.at(-1)!.name;
await seed(first, 'a', '1 hour');
await seed(first, 'z', '1 hour');
await seed(last, 'k', '1 hour');
expect(await expireLegacyDefaultTtlRows(raw, Date.now() - 1)).toEqual({deleted: 0, expiring: 0, complete: false});
expect(await resumePoint()).toEqual({table: first, row_key: '', unset: 0});
await raw.query(
`UPDATE ${KV_TABLE} SET row_data = jsonb_build_object('table', $1::text, 'row_key', 'm', 'unset', 0) WHERE table_name = '__fluxer_schema_migrations' AND row_key = $2`,
[first, DEFAULT_TTL_EXPIRY_RESUME],
);
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
expiring: 2,
complete: true,
});
const untouched = await raw.query<{expires_at: Date | null}>(
`SELECT expires_at FROM ${KV_TABLE} WHERE table_name = $1 AND row_key = 'a'`,
[first],
);
expect(untouched.rows).toEqual([{expires_at: null}]);
expect(await resumePoint()).toBeNull();
expect(await markerCount()).toBe(0);
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
expiring: 1,
complete: true,
});
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
expiring: 0,
complete: true,
});
expect(await markerCount()).toBe(1);
});
});
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
POSTGRES_KV_MIGRATION_TABLE,
postgresKvPassIsFresh,
recordPostgresKvCleanPass,
} from '@app/api/database/PostgresKvQueryExecutor';
import * as DonationTables from '@app/api/donation/DonationTables';
import * as Tables from '@app/api/Tables';
import {IPINFO_CACHE_TTL_SECONDS, IPINFO_REQUEST_AUDIT_TTL_SECONDS} from '@pkgs/geoip/src/PostgresIpInfoKv';
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
import {ms} from 'itty-time';
const DEFAULT_TTL_EXPIRY_MARKER = 'default_ttl_expiry_v1';
export const DEFAULT_TTL_EXPIRY_RESUME = 'default_ttl_expiry_v1_resume';
const PAGE_SIZE = 2000;
const CLEAN_PASS_INTERVAL_MS = ms('1 day');
const OWN_EXPIRY_PASS = new Set<string>([Tables.JobsById.name, Tables.JobsByDayBucket.name]);
export const DEFAULT_TTL_TABLES: ReadonlyArray<{name: string; defaultTtlSeconds: number}> = [
...[...Object.values(Tables), ...Object.values(DonationTables)].flatMap((table) =>
table.defaultTtlSeconds === undefined || OWN_EXPIRY_PASS.has(table.name)
? []
: [{name: table.name, defaultTtlSeconds: table.defaultTtlSeconds}],
),
{name: 'ipinfo_cache', defaultTtlSeconds: IPINFO_CACHE_TTL_SECONDS},
{name: 'ipinfo_requests_by_hour', defaultTtlSeconds: IPINFO_REQUEST_AUDIT_TTL_SECONDS},
];
export interface LegacyDefaultTtlExpiryResult {
deleted: number;
expiring: number;
complete: boolean;
}
interface ResumePoint {
table: string;
rowKey: string;
unset: number;
}
async function readResumePoint(client: IPostgresClient, kvTable: string): Promise<ResumePoint | null> {
const result = await client.query<{row_data: Record<string, unknown>}>(
`SELECT row_data FROM ${kvTable} WHERE table_name = $1 AND row_key = $2`,
[POSTGRES_KV_MIGRATION_TABLE, DEFAULT_TTL_EXPIRY_RESUME],
);
const data = result.rows[0]?.row_data;
if (typeof data?.table !== 'string' || typeof data.row_key !== 'string' || typeof data.unset !== 'number') {
return null;
}
return {table: data.table, rowKey: data.row_key, unset: data.unset};
}
async function writeResumePoint(client: IPostgresClient, kvTable: string, point: ResumePoint | null): Promise<void> {
if (point === null) {
await client.query(`DELETE FROM ${kvTable} WHERE table_name = $1 AND row_key = $2`, [
POSTGRES_KV_MIGRATION_TABLE,
DEFAULT_TTL_EXPIRY_RESUME,
]);
return;
}
await client.query(
`INSERT INTO ${kvTable} (table_name, partition_key, row_key, row_data)
VALUES ($1, $2, $2, jsonb_build_object('table', $3::text, 'row_key', $4::text, 'unset', $5::bigint))
ON CONFLICT (table_name, row_key) DO UPDATE SET row_data = EXCLUDED.row_data, updated_at = now()`,
[POSTGRES_KV_MIGRATION_TABLE, DEFAULT_TTL_EXPIRY_RESUME, point.table, point.rowKey, point.unset],
);
}
function pageSql(table: string): string {
return `
WITH page AS (
SELECT kv.row_key, kv.expires_at IS NULL AS unset
FROM ${table} kv
WHERE kv.table_name = $1 AND kv.row_key > $2
ORDER BY kv.row_key
LIMIT $3
), removed AS (
DELETE FROM ${table} kv
USING page
WHERE kv.table_name = $1 AND kv.row_key = page.row_key AND kv.expires_at IS NULL
AND kv.updated_at + make_interval(secs => $4::double precision) <= now()
RETURNING 1
), expiring AS (
UPDATE ${table} kv
SET expires_at = kv.updated_at + make_interval(secs => $4::double precision)
FROM page
WHERE kv.table_name = $1 AND kv.row_key = page.row_key AND kv.expires_at IS NULL
AND kv.updated_at + make_interval(secs => $4::double precision) > now()
RETURNING 1
)
SELECT
(SELECT max(row_key) FROM page) AS last_row_key,
(SELECT count(*) FROM page WHERE unset) AS unset,
(SELECT count(*) FROM removed) AS deleted,
(SELECT count(*) FROM expiring) AS expiring`;
}
export async function expireLegacyDefaultTtlRows(
client: IPostgresClient,
deadlineMs: number,
): Promise<LegacyDefaultTtlExpiryResult | null> {
if (await postgresKvPassIsFresh(client, DEFAULT_TTL_EXPIRY_MARKER, CLEAN_PASS_INTERVAL_MS)) {
return null;
}
const kvTable = quoteIdentifier(client.kvTable());
const sql = pageSql(kvTable);
const resume = await readResumePoint(client, kvTable);
const resumeIndex = resume === null ? -1 : DEFAULT_TTL_TABLES.findIndex((target) => target.name === resume.table);
let unset = resumeIndex < 0 ? 0 : resume!.unset;
let deleted = 0;
let expiring = 0;
for (let index = Math.max(resumeIndex, 0); index < DEFAULT_TTL_TABLES.length; index += 1) {
const target = DEFAULT_TTL_TABLES[index]!;
let cursor = index === resumeIndex ? resume!.rowKey : '';
for (;;) {
if (Date.now() >= deadlineMs) {
await writeResumePoint(client, kvTable, {table: target.name, rowKey: cursor, unset});
return {deleted, expiring, complete: false};
}
const result = await client.query<{
last_row_key: string | null;
unset: string;
deleted: string;
expiring: string;
}>(sql, [target.name, cursor, PAGE_SIZE, target.defaultTtlSeconds]);
const page = result.rows[0];
if (!page || page.last_row_key === null) {
break;
}
unset += Number(page.unset);
deleted += Number(page.deleted);
expiring += Number(page.expiring);
cursor = page.last_row_key;
}
}
await writeResumePoint(client, kvTable, null);
if (unset === 0) {
await recordPostgresKvCleanPass(client, DEFAULT_TTL_EXPIRY_MARKER);
}
return {deleted, expiring, complete: true};
}
@@ -89,6 +89,28 @@ const NUMERIC_ROW_KEY_NUMBER_PATTERN = '^(-?[0-9]+(?:\\.[0-9]+)?(?:[eE][-+]?[0-9
const EXPIRED_STORED_ROW = 'kv.expires_at IS NOT NULL AND kv.expires_at <= now()';
const MERGED_ROW_DATA = `CASE WHEN ${EXPIRED_STORED_ROW} THEN EXCLUDED.row_data ELSE kv.row_data || EXCLUDED.row_data END`;
const KEPT_EXPIRES_AT = `CASE WHEN ${EXPIRED_STORED_ROW} THEN NULL ELSE kv.expires_at END`;
const NO_EXPIRY = 'infinity';
export async function postgresKvPassIsFresh(
client: IPostgresClient,
marker: string,
maxAgeMs: number,
): Promise<boolean> {
const result = await client.query(
`SELECT 1 FROM ${quoteIdentifier(client.kvTable())} WHERE table_name = $1 AND row_key = $2 AND (row_data ->> 'applied_at')::timestamptz > now() - make_interval(secs => $3::double precision)`,
[POSTGRES_KV_MIGRATION_TABLE, marker, maxAgeMs / 1000],
);
return result.rows.length > 0;
}
export async function recordPostgresKvCleanPass(client: IPostgresClient, marker: string): Promise<void> {
await client.query(
`INSERT INTO ${quoteIdentifier(client.kvTable())} (table_name, partition_key, row_key, row_data)
VALUES ($1, $2, $2, jsonb_build_object('applied_at', now()))
ON CONFLICT (table_name, row_key) DO UPDATE SET row_data = EXCLUDED.row_data, updated_at = now()`,
[POSTGRES_KV_MIGRATION_TABLE, marker],
);
}
function numericRowKeyExpr(column: string): string {
return `(COALESCE(substring(${column} from '${NUMERIC_ROW_KEY_BIGINT_PATTERN}'), substring(${column} from '${NUMERIC_ROW_KEY_NUMBER_PATTERN}'))::numeric)`;
@@ -333,20 +355,21 @@ function projectRow(row: Row, columns: ReadonlyArray<string> | undefined): Row {
return projected;
}
function rowComparator(meta: KvQueryMeta): (left: Row, right: Row) => number {
if (meta.orderBy) {
const column = meta.orderBy.col as string;
const direction = meta.orderBy.direction === 'DESC' ? -1 : 1;
return (left, right) => compareValues(left[column], right[column]) * direction;
function compareColumns(columns: ReadonlyArray<string>, left: Row, right: Row): number {
for (const column of columns) {
const cmp = compareValues(left[column], right[column]);
if (cmp !== 0) return cmp;
}
const columns = meta.table.primaryKey as ReadonlyArray<string>;
return (left, right) => {
for (const column of columns) {
const cmp = compareValues(left[column], right[column]);
if (cmp !== 0) return cmp;
}
return 0;
};
return 0;
}
function rowComparator(meta: KvQueryMeta): (left: Row, right: Row) => number {
const primaryKey = meta.table.primaryKey as ReadonlyArray<string>;
if (!meta.orderBy) return (left, right) => compareColumns(primaryKey, left, right);
const column = meta.orderBy.col as string;
const columns = [column, ...primaryKey.slice(primaryKey.indexOf(column) + 1)];
const direction = meta.orderBy.direction === 'DESC' ? -1 : 1;
return (left, right) => compareColumns(columns, left, right) * direction;
}
function sortRows(meta: KvQueryMeta, rows: Array<Row>): Array<Row> {
@@ -679,7 +702,7 @@ function logFullScan(meta: KvQueryMeta): void {
logWarn({table: meta.table.name, action: meta.action, where: shape.summary || 'none'}, 'Postgres KV full table scan');
}
function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | null | undefined {
function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | typeof NO_EXPIRY | null | undefined {
const ttlParam = meta.ttlParamName;
if (!ttlParam) return undefined;
const ttlRaw = params[ttlParam];
@@ -687,7 +710,13 @@ function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | null |
throw new Error(`TTL parameter ${ttlParam} must be a number`);
}
const ttlSeconds = validateTtlSeconds(ttlRaw);
return ttlSeconds === 0 ? null : new Date(Date.now() + ttlSeconds * 1000);
if (ttlSeconds === 0) return meta.table.defaultTtlSeconds === undefined ? null : NO_EXPIRY;
return new Date(Date.now() + ttlSeconds * 1000);
}
function defaultExpiresAt(meta: KvQueryMeta): Date | undefined {
const ttlSeconds = meta.table.defaultTtlSeconds;
return ttlSeconds === undefined ? undefined : new Date(Date.now() + ttlSeconds * 1000);
}
function encodePageState(pageState: PageState): string {
@@ -1191,7 +1220,8 @@ export class PostgresKvQueryExecutor {
'kv_del_expired',
);
}
const expiresAt = ttlExpiresAt(meta, params) ?? null;
const explicit = ttlExpiresAt(meta, params);
const expiresAt = explicit === undefined ? (defaultExpiresAt(meta) ?? null) : explicit;
const result = await db.query(
`INSERT INTO ${this.table} AS kv (table_name, partition_key, row_key, row_data, expires_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, now())
@@ -1244,10 +1274,14 @@ WHERE NOT $6`,
}
bindings.push(JSON.stringify(encodeRow(paramsRow(params, meta.patchKeys))));
const assignments = [`row_data = kv.row_data || $${bindings.length}::jsonb`, 'updated_at = now()'];
const expiresAt = ttlExpiresAt(meta, params);
if (expiresAt !== undefined) {
bindings.push(expiresAt);
const explicit = ttlExpiresAt(meta, params);
const fallback = explicit === undefined ? defaultExpiresAt(meta) : undefined;
if (explicit !== undefined) {
bindings.push(explicit);
assignments.push(`expires_at = $${bindings.length}`);
} else if (fallback !== undefined) {
bindings.push(fallback);
assignments.push(`expires_at = GREATEST(kv.expires_at, $${bindings.length}::timestamptz)`);
}
sql = `UPDATE ${this.table} kv SET ${assignments.join(', ')} WHERE ${where}`;
}
@@ -1346,15 +1380,27 @@ WHERE NOT $6`,
for (const column of meta.patchKeys ?? []) {
incoming[column] = column in params ? params[column] : null;
}
const ttl = ttlExpiresAt(meta, params);
const expiresAtExpr = ttl === undefined ? KEPT_EXPIRES_AT : 'EXCLUDED.expires_at';
const explicit = ttlExpiresAt(meta, params);
const fallback = explicit === undefined ? defaultExpiresAt(meta) : undefined;
const [expiresAtExpr, statementName] =
explicit !== undefined
? ['EXCLUDED.expires_at', 'kv_patch_set_ttl']
: fallback !== undefined
? ['GREATEST(kv.expires_at, EXCLUDED.expires_at)', 'kv_patch_default_ttl']
: [KEPT_EXPIRES_AT, 'kv_patch_keep_ttl'];
await db.query(
`INSERT INTO ${this.table} AS kv (table_name, partition_key, row_key, row_data, expires_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, now())
ON CONFLICT (table_name, row_key)
DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = ${MERGED_ROW_DATA}, expires_at = ${expiresAtExpr}, updated_at = now()`,
[meta.table.name, partitionKey(meta, incoming), key, JSON.stringify(encodeRow(incoming)), ttl ?? null],
ttl === undefined ? 'kv_patch_keep_ttl' : 'kv_patch_set_ttl',
[
meta.table.name,
partitionKey(meta, incoming),
key,
JSON.stringify(encodeRow(incoming)),
explicit ?? fallback ?? null,
],
statementName,
);
}
@@ -52,6 +52,8 @@ const Composite: KvTableSpec<Row> = {
partitionKey: ['owner_id'],
};
const Expiring: KvTableSpec<Row> = {...Composite, name: 'stmt_expiring', defaultTtlSeconds: 600};
const Bucketed: KvTableSpec<Row> = {
name: 'stmt_bucketed',
columns: ['bucket', 'item_id', 'payload'],
@@ -118,6 +120,7 @@ async function runShapes(): Promise<Array<Statement>> {
meta(Composite, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload'], ttlParamName: 'ttl_'}),
{...OWNER_ITEM, ttl_: 600} as CassandraParams,
],
[meta(Expiring, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload']}), OWNER_ITEM],
];
for (const [kvMeta, params] of cases) {
await executor.executeQuery({cql: `__stmt_${kvMeta.action}`, params, kvMeta: kvMeta as KvQueryMeta});
@@ -142,6 +145,7 @@ describe('PostgresKvQueryExecutor statement names', () => {
'kv_del_keys',
'kv_del_rowkeys',
'kv_get_row',
'kv_patch_default_ttl',
'kv_patch_keep_ttl',
'kv_patch_set_ttl',
'kv_sel_range',
@@ -231,6 +235,17 @@ async function exerciseKvShapes(executor: PostgresKvQueryExecutor): Promise<void
kvMeta: meta(Composite, 'select', [eq('owner_id'), eq('item_id')]) as KvQueryMeta,
});
expect(patched.map((row) => row.payload)).toEqual(['patched']);
await executor.executeQuery({
cql: '__stmt_patch_default_ttl',
params: {owner_id: 'o5', item_id: 'i5', payload: 'defaulted'} as CassandraParams,
kvMeta: meta(Expiring, 'patch', [eq('owner_id'), eq('item_id')], {patchKeys: ['payload']}) as KvQueryMeta,
});
const defaulted = await executor.executeQuery<Row>({
cql: '__stmt_point',
params: {owner_id: 'o5', item_id: 'i5'} as CassandraParams,
kvMeta: meta(Expiring, 'select', [eq('owner_id'), eq('item_id')]) as KvQueryMeta,
});
expect(defaulted.map((row) => row.payload)).toEqual(['defaulted']);
await executor.executeQuery({
cql: '__stmt_delete',
params: {owner_id: 'o0', item_id: 'i0'} as CassandraParams,
@@ -323,6 +338,7 @@ describe.skipIf(!dockerAvailable)('PostgresKvQueryExecutor statement names again
'kv_del_expired',
'kv_del_rowkeys',
'kv_get_row',
'kv_patch_default_ttl',
'kv_patch_keep_ttl',
'kv_patch_set_ttl',
'kv_sel_range',
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {spawnSync} from 'node:child_process';
import {createServer} from 'node:net';
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/postgres/src/Client';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
const CONTAINER = `fluxer-kvscram-${process.pid.toString(36)}-${Date.now().toString(36)}`;
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
const SCRAM_ITERATIONS = 200_000;
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (typeof address === 'string' || address === null) {
reject(new Error('no port'));
return;
}
const port = address.port;
server.close(() => resolve(port));
});
});
}
describe.skipIf(!dockerAvailable)('postgres client against a server with raised SCRAM iterations', () => {
let port: number;
beforeAll(async () => {
port = await freePort();
startDockerContainer([
'run',
'-d',
'--name',
CONTAINER,
'-e',
'POSTGRES_USER=fluxer',
'-e',
'POSTGRES_PASSWORD=fluxer',
'-e',
'POSTGRES_DB=fluxer',
'-p',
`127.0.0.1:${port}:5432`,
'postgres:16-alpine',
'-c',
'fsync=off',
'-c',
`scram_iterations=${SCRAM_ITERATIONS}`,
]);
let ready = false;
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
await sleep(500);
const probe = spawnSync(
'docker',
['exec', CONTAINER, 'psql', '-h', '127.0.0.1', '-U', 'fluxer', '-d', 'fluxer', '-Atc', 'SELECT 1'],
{stdio: 'ignore'},
);
ready = probe.status === 0;
}
if (!ready) throw new Error('postgres never came up');
const rehash = spawnSync(
'docker',
['exec', CONTAINER, 'psql', '-U', 'fluxer', '-d', 'fluxer', '-Atc', "ALTER ROLE fluxer PASSWORD 'fluxer'"],
{
stdio: 'ignore',
},
);
if (rehash.status !== 0) throw new Error('could not re-hash the role password');
}, 900_000);
afterAll(async () => {
await shutdownPostgres().catch(() => {});
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
});
it('connects when the role verifier uses more iterations than the driver default allows', async () => {
await initPostgres({url: `postgres://fluxer:[email protected]:${port}/fluxer`, maxConnections: 1});
const verifier = await getDefaultPostgresClient().query<{rolpassword: string}>(
"SELECT rolpassword FROM pg_authid WHERE rolname = 'fluxer'",
);
expect(verifier.rows[0]?.rolpassword.startsWith(`SCRAM-SHA-256$${SCRAM_ITERATIONS}:`)).toBe(true);
});
});
@@ -13,6 +13,7 @@ import {
type DonorMagicLinkTokenRow,
type DonorRow,
} from '@app/api/database/types/DonationTypes';
import {seconds} from 'itty-time';
export const Donors = defineTable<DonorRow, 'email'>({
name: 'donors',
@@ -43,9 +44,11 @@ export const DonorMagicLinkTokens = defineTable<DonorMagicLinkTokenRow, 'token_'
name: 'donor_magic_link_tokens',
columns: DONOR_MAGIC_LINK_TOKEN_COLUMNS,
primaryKey: ['token_'],
defaultTtlSeconds: seconds('15 minutes'),
});
export const DonorMagicLinkTokensByEmail = defineTable<DonorMagicLinkTokenByEmailRow, 'donor_email' | 'token_'>({
name: 'donor_magic_link_tokens_by_email',
columns: DONOR_MAGIC_LINK_TOKEN_BY_EMAIL_COLUMNS,
primaryKey: ['donor_email', 'token_'],
defaultTtlSeconds: seconds('15 minutes'),
});
@@ -15,6 +15,8 @@ import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {ms} from 'itty-time';
const MESSAGE_DELETE_BATCH_DELAY_MS = ms('30 seconds');
interface MessageDeleteBatchGroup {
logs: Array<GuildAuditLog>;
userId: UserID;
@@ -81,14 +83,15 @@ export class GuildAuditLogService {
}
async scheduleMessageDeleteBatchJob(guildId: GuildID): Promise<void> {
const runAt = new Date(Date.now() + ms('30 seconds'));
const batchWindow = Math.floor(Date.now() / MESSAGE_DELETE_BATCH_DELAY_MS);
await this.workerService.addJob(
'batchGuildAuditLogMessageDeletes',
{guildId: guildId.toString()},
{
jobKey: `batch-audit-log-message-deletes:${guildId}`,
runAt,
jobKey: `batch-audit-log-message-deletes:${guildId}:${batchWindow}`,
runAt: new Date((batchWindow + 2) * MESSAGE_DELETE_BATCH_DELAY_MS),
maxAttempts: 3,
skipLedger: true,
},
);
}
@@ -60,7 +60,7 @@ function createService(roleNames: Map<string, string> = new Map()) {
{addJob} as unknown as IWorkerService<WorkerTaskName>,
{dispatchGuild} as unknown as IGatewayService,
);
return {service, createAuditLog, batchDeleteAndCreateAuditLogs, getRole, dispatchGuild};
return {service, createAuditLog, batchDeleteAndCreateAuditLogs, getRole, dispatchGuild, addJob};
}
function overwrites(
@@ -376,3 +376,26 @@ describe('GuildAuditLogService.recordPermissionOverwriteDiff', () => {
expect(dispatchGuild).not.toHaveBeenCalled();
});
});
describe('GuildAuditLogService.scheduleMessageDeleteBatchJob', () => {
it('gives every delete in one 30 second window a single batch job that runs after the window closes, without a ledger row', async () => {
vi.useFakeTimers({toFake: ['Date']});
try {
const {service, addJob} = createService();
for (const at of ['2026-09-21T12:00:00.000Z', '2026-09-21T12:00:29.999Z', '2026-09-21T12:00:40.000Z']) {
vi.setSystemTime(new Date(at));
await service.scheduleMessageDeleteBatchJob(GUILD_ID);
}
const options = addJob.mock.calls.map((call) => call[2] as {jobKey: string; runAt: Date; skipLedger: boolean});
expect(options.every((option) => option.skipLedger)).toBe(true);
expect(options[0]!.jobKey).toBe(options[1]!.jobKey);
expect(options[2]!.jobKey).not.toBe(options[1]!.jobKey);
expect(options[0]!.runAt.getTime()).toBeGreaterThan(new Date('2026-09-21T12:00:29.999Z').getTime());
expect(options[1]!.runAt).toEqual(options[0]!.runAt);
expect(options[2]!.runAt).toEqual(new Date('2026-09-21T12:01:30.000Z'));
expect(options[2]!.runAt.getTime() - options[0]!.runAt.getTime()).toBe(30_000);
} finally {
vi.useRealTimers();
}
});
});
@@ -1,12 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {spawnSync} from 'node:child_process';
import {createHash} from 'node:crypto';
import {createServer} from 'node:net';
import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
import {
INSTANCE_CONFIG_REFRESH_CHANNEL,
INSTANCE_CONFIG_WRITE_ATTEMPTS,
InstanceConfigRepository,
InstanceConfigWriteConflictError,
type InstanceRegistrationConfig,
} from '@app/api/instance/InstanceConfigRepository';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {
@@ -21,7 +30,13 @@ import {
DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
type ExperimentDeliveryConfig,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {afterEach, describe, expect, it, vi} from 'vitest';
import {
getDefaultPostgresClient,
type IPostgresClient,
initPostgres,
shutdownPostgres,
} from '@pkgs/postgres/src/Client';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_config';
@@ -29,6 +44,12 @@ const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
const POSTGRES_KV_TABLE = 'kv_instance_config_races';
const POSTGRES_CONTAINER = `fluxer-instance-config-races-${process.pid.toString(36)}-${Date.now().toString(36)}`;
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
class CountingInMemoryCassandraQueryExecutor extends InMemoryCassandraQueryExecutor {
instanceConfigSelects = 0;
@@ -512,3 +533,466 @@ describe('InstanceConfigRepository', () => {
});
});
});
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (typeof address === 'string' || address === null) {
reject(new Error('no port'));
return;
}
server.close(() => resolve(address.port));
});
});
}
function describeConcurrentInstanceConfigWrites(prepareBase: () => Promise<CassandraQueryExecutorForTesting>): void {
const pods: Array<InstanceConfigRepository> = [];
let executor: InstanceConfigWriteRaceExecutor;
beforeEach(async () => {
executor = new InstanceConfigWriteRaceExecutor(await prepareBase());
setCassandraQueryExecutorForTesting(executor);
});
afterEach(async () => {
await Promise.all(pods.map((pod) => pod.shutdown()));
pods.length = 0;
});
function createPod(): InstanceConfigRepository {
const pod = new InstanceConfigRepository(new MockKVProvider());
pods.push(pod);
return pod;
}
async function readStoredRegistrationConfig(): Promise<unknown> {
const raw = await executor.readDirectly(REGISTRATION_CONFIG_KEY);
return raw === null ? null : JSON.parse(raw);
}
it('applies two concurrent patches on top of each other instead of dropping one', async () => {
const first = createPod();
const second = createPod();
await first.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
await second.getRegistrationConfig();
executor.watch(REGISTRATION_CONFIG_KEY);
executor.pauseWritesUntil(2);
await Promise.all([
first.setRegistrationConfig({mode: 'closed'}),
second.setRegistrationConfig({admin_registration_urls_enabled: false}),
]);
expect(executor.events.filter((event) => event === 'write rejected')).toHaveLength(1);
expect(executor.events.filter((event) => event === 'write')).toHaveLength(2);
expect(await readStoredRegistrationConfig()).toEqual({mode: 'closed', admin_registration_urls_enabled: false});
});
it('lets one of two concurrent first writes create the config and applies the other on top', async () => {
const first = createPod();
const second = createPod();
await first.getRegistrationConfig();
await second.getRegistrationConfig();
executor.watch(REGISTRATION_CONFIG_KEY);
executor.pauseWritesUntil(2);
await Promise.all([
first.setRegistrationConfig({mode: 'closed'}),
second.setRegistrationConfig({admin_registration_urls_enabled: false}),
]);
expect(executor.events.filter((event) => event === 'write rejected')).toHaveLength(1);
expect(executor.events.filter((event) => event === 'write')).toHaveLength(2);
expect(await readStoredRegistrationConfig()).toEqual({mode: 'closed', admin_registration_urls_enabled: false});
});
it('re-reads the database, not its stale cache, when a concurrent write lands between its read and its write', async () => {
const stale = createPod();
const other = createPod();
await stale.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
await stale.getRegistrationConfig();
await other.setRegistrationConfig({mode: 'approval'});
expect(await stale.getRegistrationConfig()).toEqual({mode: 'open', admin_registration_urls_enabled: true});
executor.watch(REGISTRATION_CONFIG_KEY);
let competed = false;
executor.competeBeforeEachWrite(async () => {
if (competed) return;
competed = true;
await executor.writeDirectly(
REGISTRATION_CONFIG_KEY,
JSON.stringify({mode: 'approval', admin_registration_urls_enabled: false}),
);
});
await stale.setRegistrationConfig({mode: 'closed'});
expect(executor.events).toEqual(['read', 'write rejected', 'read', 'write']);
expect(await readStoredRegistrationConfig()).toEqual({mode: 'closed', admin_registration_urls_enabled: false});
});
it('fails loudly and writes nothing once every attempt has lost the race', async () => {
const pod = createPod();
await pod.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
executor.watch(REGISTRATION_CONFIG_KEY);
let competingWrites = 0;
executor.competeBeforeEachWrite(async () => {
competingWrites++;
await executor.writeDirectly(
REGISTRATION_CONFIG_KEY,
JSON.stringify({mode: 'approval', admin_registration_urls_enabled: competingWrites % 2 === 0}),
);
});
const write = pod.setRegistrationConfig({mode: 'closed'});
await expect(write).rejects.toBeInstanceOf(InstanceConfigWriteConflictError);
await expect(write).rejects.toMatchObject({
status: 409,
code: 'CONFLICT',
message: expect.stringContaining(REGISTRATION_CONFIG_KEY),
});
expect(executor.events.filter((event) => event === 'write rejected')).toHaveLength(INSTANCE_CONFIG_WRITE_ATTEMPTS);
expect(executor.events).not.toContain('write');
expect(await readStoredRegistrationConfig()).toEqual({
mode: 'approval',
admin_registration_urls_enabled: INSTANCE_CONFIG_WRITE_ATTEMPTS % 2 === 0,
});
});
it('keeps a pending registration another pod added while this pod held a stale list', async () => {
const first = createPod();
const second = createPod();
await first.getPendingRegistrations();
await second.getPendingRegistrations();
await first.addPendingRegistration(pendingRegistration('1400000000000000011'));
await second.addPendingRegistration(pendingRegistration('1400000000000000012'));
const listed = await createPod().getPendingRegistrations();
expect(listed.map((entry) => entry.user_id)).toEqual(['1400000000000000011', '1400000000000000012']);
});
it('keeps a pending registration another pod stored and removes it once decided', async () => {
const pod = createPod();
await executor.writeDirectly(
REGISTRATION_PENDING_APPROVALS_KEY,
JSON.stringify([pendingRegistration('1400000000000000021')]),
);
await pod.addPendingRegistration(pendingRegistration('1400000000000000022'));
expect((await createPod().getPendingRegistrations()).map((entry) => entry.user_id)).toEqual([
'1400000000000000021',
'1400000000000000022',
]);
await pod.removePendingRegistration('1400000000000000021');
await pod.removePendingRegistration('1400000000000000022');
expect(await createPod().getPendingRegistrations()).toEqual([]);
expect(await executor.readDirectly(REGISTRATION_PENDING_APPROVALS_KEY)).toBe('[]');
});
it('keeps a registration URL another pod created while this pod held a stale list', async () => {
const first = createPod();
const second = createPod();
await first.getRegistrationUrls();
await second.getRegistrationUrls();
const created = [
await first.createRegistrationUrl(registrationUrlParams(null)),
await second.createRegistrationUrl(registrationUrlParams(null)),
];
const listed = await createPod().getRegistrationUrlsForAdmin();
expect(listed.map((url) => url.id).toSorted()).toEqual(created.map((entry) => entry.registrationUrl.id).toSorted());
});
it('refuses a registration URL another pod revoked while this pod held a stale list', async () => {
const admin = createPod();
const signup = createPod();
const {code, registrationUrl} = await admin.createRegistrationUrl(registrationUrlParams(null));
expect(await signup.resolveRegistrationUrlCode(code)).not.toBeNull();
await admin.revokeRegistrationUrl(registrationUrl.id);
await expect(signup.resolveRegistrationUrlCode(code)).resolves.toBeNull();
await expect(signup.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000501')).resolves.toBeNull();
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed?.use_count).toBe(0);
});
it('admits a registration URL another pod created while this pod held a stale list', async () => {
const admin = createPod();
const signup = createPod();
await signup.getRegistrationUrls();
const {code, registrationUrl} = await admin.createRegistrationUrl(registrationUrlParams(1));
await expect(signup.resolveRegistrationUrlCode(code)).resolves.toMatchObject({
id: registrationUrl.id,
approval_required: false,
});
await expect(signup.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000601')).resolves.not.toBeNull();
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed?.use_count).toBe(1);
});
it('never seats more signups than max_uses when pods claim the same registration URL at once', async () => {
const pods = [createPod(), createPod(), createPod()];
const {code} = await pods[0]!.createRegistrationUrl(registrationUrlParams(2));
const registrationUrl = await pods[0]!.resolveRegistrationUrlCode(code);
if (registrationUrl === null) throw new Error('registration URL did not resolve');
const claims = await Promise.all(
Array.from({length: 6}, (_, index) =>
pods[index % pods.length]!.claimRegistrationUrlUse(registrationUrl.id, `14000000000000001${index}0`),
),
);
expect(claims.filter((claim) => claim !== null)).toHaveLength(2);
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed?.use_count).toBe(2);
await expect(createPod().resolveRegistrationUrlCode(code)).resolves.toBeNull();
});
it('refuses a claim whose retry finds the registration URL exhausted, rather than reporting the lost attempt', async () => {
const pod = createPod();
const {code, registrationUrl} = await pod.createRegistrationUrl(registrationUrlParams(1));
expect(await pod.resolveRegistrationUrlCode(code)).not.toBeNull();
executor.watch(REGISTRATION_URLS_KEY);
let competed = false;
executor.competeBeforeEachWrite(async () => {
if (competed) return;
competed = true;
const stored = JSON.parse((await executor.readDirectly(REGISTRATION_URLS_KEY)) ?? 'null') as Array<
Record<string, unknown>
>;
await executor.writeDirectly(
REGISTRATION_URLS_KEY,
JSON.stringify(
stored.map((entry) => ({
...entry,
use_count: 1,
last_used_at: '2026-09-20T00:00:00.000Z',
last_used_by_user_id: '1400000000000000901',
})),
),
);
});
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000902')).resolves.toBeNull();
expect(executor.events).toEqual(['read', 'write rejected', 'read']);
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed).toMatchObject({use_count: 1, last_used_by_user_id: '1400000000000000901'});
});
it('counts concurrent uses of an uncapped registration URL without ever refusing one', async () => {
const pods = [createPod(), createPod()];
const {code} = await pods[0]!.createRegistrationUrl(registrationUrlParams(null));
const registrationUrl = await pods[0]!.resolveRegistrationUrlCode(code);
if (registrationUrl === null) throw new Error('registration URL did not resolve');
const claims = await Promise.all(
Array.from({length: 5}, (_, index) =>
pods[index % pods.length]!.claimRegistrationUrlUse(registrationUrl.id, `14000000000000002${index}0`),
),
);
expect(claims.every((claim) => claim !== null)).toBe(true);
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed?.use_count).toBe(5);
});
it('frees a released seat for the next signup', async () => {
const pod = createPod();
const {code} = await pod.createRegistrationUrl(registrationUrlParams(1));
const registrationUrl = await pod.resolveRegistrationUrlCode(code);
if (registrationUrl === null) throw new Error('registration URL did not resolve');
const failedSignup = await pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000301');
if (failedSignup === null) throw new Error('the first claim was refused');
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000302')).resolves.toBeNull();
await pod.releaseRegistrationUrlUse(failedSignup);
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000303')).resolves.not.toBeNull();
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed).toMatchObject({use_count: 1, last_used_by_user_id: '1400000000000000303'});
});
it('enforces max_uses against the use count already stored in the blob', async () => {
const pod = createPod();
const id = 'b3c4f0b2-8a6e-4c41-9f55-3f0c2a7d1e91';
await executor.writeDirectly(
REGISTRATION_URLS_KEY,
JSON.stringify([
{
id,
label: 'Issued earlier',
code_hash: createHash('sha256').update(id).digest('hex'),
created_by_user_id: '1400000000000000001',
created_at: '2026-09-01T00:00:00.000Z',
expires_at: null,
max_uses: 3,
use_count: 2,
revoked_at: null,
approval_required: true,
last_used_at: '2026-09-02T00:00:00.000Z',
last_used_by_user_id: '1400000000000000002',
},
]),
);
expect(await pod.getRegistrationUrlsForAdmin()).toEqual([
expect.objectContaining({
id,
use_count: 2,
max_uses: 3,
approval_required: true,
last_used_at: '2026-09-02T00:00:00.000Z',
last_used_by_user_id: '1400000000000000002',
}),
]);
const registrationUrl = await pod.resolveRegistrationUrlCode(id);
if (registrationUrl === null) throw new Error('stored registration URL did not resolve');
expect(registrationUrl).toMatchObject({id, approval_required: true});
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000401')).resolves.not.toBeNull();
await expect(pod.claimRegistrationUrlUse(registrationUrl.id, '1400000000000000402')).resolves.toBeNull();
const [listed] = await createPod().getRegistrationUrlsForAdmin();
expect(listed).toMatchObject({use_count: 3, max_uses: 3, last_used_by_user_id: '1400000000000000401'});
await expect(pod.resolveRegistrationUrlCode(id)).resolves.toBeNull();
expect(JSON.parse((await executor.readDirectly(REGISTRATION_URLS_KEY)) ?? 'null')[0]).toMatchObject({
use_count: 3,
max_uses: 3,
});
});
it('keeps an SSO field another pod changed while this pod held a stale snapshot', async () => {
const first = createPod();
const second = createPod();
await first.getSsoConfig();
await second.getSsoConfig();
await first.setSsoConfig({displayName: 'Set by the first pod'});
await second.setSsoConfig({clientId: 'set-by-the-second-pod'});
expect(await createPod().getSsoConfig()).toMatchObject({
displayName: 'Set by the first pod',
clientId: 'set-by-the-second-pod',
});
});
it('leaves an SSO row alone when another pod wrote it between this pod reading and writing it', async () => {
const pod = createPod();
await pod.getSsoConfig();
executor.watch('sso_enforced');
let competed = false;
executor.competeBeforeEachWrite(async () => {
if (competed) return;
competed = true;
await executor.writeDirectly('sso_enforced', 'true');
});
await pod.setSsoConfig({displayName: 'Only the display name'});
expect(await executor.readDirectly('sso_enforced')).toBe('true');
expect(await executor.readDirectly('sso_display_name')).toBe('Only the display name');
});
}
function pendingRegistration(userId: string) {
return {
user_id: userId,
username: `pending_${userId.slice(-3)}`,
discriminator: 1,
global_name: null,
email: `${userId}@example.com`,
requested_at: `2026-09-01T00:00:${userId.slice(-2)}.000Z`,
registration_url_id: null,
client_ip: '127.0.0.1',
};
}
function registrationUrlParams(maxUses: number | null) {
return {
label: maxUses === null ? 'Uncapped' : `Capped at ${maxUses}`,
createdByUserId: '1400000000000000001',
expiresAt: null,
maxUses,
approvalRequired: false,
};
}
describe('InstanceConfigRepository concurrent writes', () => {
describe('in memory', () => {
describeConcurrentInstanceConfigWrites(async () => new InMemoryCassandraQueryExecutor());
});
describe.skipIf(!dockerAvailable)('on postgres', () => {
let client: IPostgresClient;
beforeAll(async () => {
const port = await freePort();
startDockerContainer([
'run',
'-d',
'--name',
POSTGRES_CONTAINER,
'-e',
'POSTGRES_USER=fluxer',
'-e',
'POSTGRES_PASSWORD=fluxer',
'-e',
'POSTGRES_DB=fluxer',
'-p',
`127.0.0.1:${port}:5432`,
'postgres:16-alpine',
'-c',
'fsync=off',
]);
let ready = false;
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
await sleep(500);
const probe = spawnSync('docker', ['exec', POSTGRES_CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
stdio: 'ignore',
});
if (probe.status !== 0) continue;
try {
await initPostgres({
url: `postgres://fluxer:[email protected]:${port}/fluxer`,
maxConnections: 4,
kvTable: POSTGRES_KV_TABLE,
});
await getDefaultPostgresClient().query('SELECT 1');
ready = true;
} catch {
await shutdownPostgres().catch(() => {});
}
}
if (!ready) throw new Error('postgres never came up');
client = getDefaultPostgresClient();
await ensurePostgresKvSchema(client);
}, 900_000);
afterAll(async () => {
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
await shutdownPostgres().catch(() => {});
spawnSync('docker', ['rm', '-f', POSTGRES_CONTAINER], {stdio: 'ignore'});
});
describeConcurrentInstanceConfigWrites(async () => {
await client.query(`DELETE FROM ${POSTGRES_KV_TABLE}`);
return new PostgresKvQueryExecutor(client);
});
});
});
@@ -3,7 +3,8 @@
import crypto from 'node:crypto';
import {Config} from '@app/api/Config';
import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '@app/api/config/APIConfig';
import {fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
import {
getDefaultDateOfBirthCollection,
@@ -17,6 +18,9 @@ import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from
import {InstanceConfiguration} from '@app/api/Tables';
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '@app/api/utils/AttachmentDecay';
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import {
InstanceConfigResponse,
@@ -28,6 +32,10 @@ import {
type GatewayRolloutConfig,
GatewayRolloutConfigSchema,
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {
type PushServiceDeliveryConfig,
PushServiceDeliveryConfigSchema,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {
type ScreenShareDeliveryConfig,
ScreenShareDeliveryConfigSchema,
@@ -59,6 +67,7 @@ import {z} from 'zod';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_config';
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
@@ -72,6 +81,22 @@ const INSTANCE_MEDIA_CONFIG_KEY = 'instance_media_config';
export const INSTANCE_CONFIG_REFRESH_CHANNEL = 'instance-config-refresh';
export const REGISTRATION_PENDING_APPROVAL_TRAIT = 'registration_pending_approval';
export const REGISTRATION_REJECTED_TRAIT = 'registration_rejected';
export const INSTANCE_CONFIG_WRITE_ATTEMPTS = 5;
export class InstanceConfigWriteConflictError extends ConflictError {
constructor(key: string) {
super({
code: APIErrorCodes.CONFLICT,
message: `Instance config "${key}" changed concurrently on all ${INSTANCE_CONFIG_WRITE_ATTEMPTS} write attempts. Nothing was written. Retry the change.`,
});
this.name = 'InstanceConfigWriteConflictError';
}
}
interface StoredValueUpdate<T> {
value: string | null;
result: T;
}
export type InstanceRegistrationConfig = InstanceRegistration;
@@ -277,6 +302,11 @@ export interface InstanceRegistrationUrl extends RegistrationUrlResponse {
type InstanceRegistrationUrlPublic = RegistrationUrlResponse;
type InstancePendingRegistration = PendingRegistrationResponse;
export interface RegistrationUrlClaim {
registration_url_id: string;
user_id: string;
}
const DEFAULT_REGISTRATION_CONFIG: InstanceRegistrationConfig = {
mode: 'open',
admin_registration_urls_enabled: true,
@@ -345,6 +375,7 @@ type StoredConfigSection =
| 'gateway rollout'
| 'voice noise suppression'
| 'screen share delivery'
| 'push service delivery'
| 'experiment delivery'
| 'instance policy'
| 'integrations'
@@ -487,6 +518,10 @@ function parseStoredScreenShareDeliveryConfig(raw: string | null): ScreenShareDe
return parseStoredConfigOrDefault(ScreenShareDeliveryConfigSchema, raw, 'screen share delivery');
}
function parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDeliveryConfig {
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
}
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
}
@@ -910,6 +945,75 @@ function parseStoredSsoAllowedEmailDomains(raw: string | undefined, log = false)
return Array.from(domains).slice(0, MAX_SSO_ALLOWED_DOMAINS);
}
function readStoredSsoConfig(
configs: ReadonlyMap<string, string>,
options?: {includeSecret?: boolean},
): InstanceSsoConfig {
const flags = readStoredSsoFlags(configs);
const read = (key: string): string | null => {
const v = configs.get(key);
if (!v) return null;
const trimmed = v.trim();
return trimmed.length === 0 ? null : trimmed;
};
const allowedDomains = parseStoredSsoAllowedEmailDomains(configs.get('sso_allowed_domains'));
const clientSecret = read('sso_client_secret');
return {
...flags,
displayName: read('sso_display_name'),
issuer: read('sso_issuer'),
authorizationUrl: read('sso_authorization_url'),
tokenUrl: read('sso_token_url'),
userInfoUrl: read('sso_userinfo_url'),
jwksUrl: read('sso_jwks_url'),
clientId: read('sso_client_id'),
clientSecret: options?.includeSecret ? clientSecret : undefined,
clientSecretSet: Boolean(clientSecret),
scope: read('sso_scope'),
allowedEmailDomains: allowedDomains,
redirectUri: null,
};
}
interface SsoRowWrite {
key: string;
value: string | undefined;
unset: string;
}
function ssoRow<T>(key: string, value: T | undefined, current: T, format: (value: T) => string): SsoRowWrite {
return {key, value: value === undefined ? undefined : format(value), unset: format(current)};
}
function nextSsoRowValue(row: SsoRowWrite, raw: string | null): string | null {
const value = row.value ?? raw ?? row.unset;
return value === raw ? null : value;
}
function formatSsoBoolean(value: boolean): string {
return value ? 'true' : 'false';
}
function formatSsoString(value: string | null): string {
return value ?? '';
}
function formatSsoDomains(value: Array<string>): string {
return JSON.stringify(value);
}
function normalizeSsoAllowedEmailDomainsForWrite(domains: Array<string>, enabled: boolean): Array<string> {
try {
return normalizeSsoAllowedEmailDomains(domains);
} catch (error) {
if (enabled) {
throw error;
}
Logger.warn({error}, 'Clearing invalid SSO allowed domain config while SSO is disabled');
return [];
}
}
export class InstanceConfigRepository {
private readonly kvClient: IKVProvider | null;
private configCache: InstanceConfigCache;
@@ -993,6 +1097,57 @@ export class InstanceConfigRepository {
);
}
private async updateStoredConfig<T>(key: string, next: (raw: string | null) => T): Promise<T> {
const cache = this.configCache;
const {result} = await this.compareAndSetStoredValue(cache, key, (raw) => {
const config = next(raw);
return {value: JSON.stringify(config), result: config};
});
await this.publishRefresh(cache.sourceId);
return result;
}
private async compareAndSetStoredValue<T>(
cache: InstanceConfigCache,
key: string,
next: (raw: string | null) => StoredValueUpdate<T>,
): Promise<{result: T; written: boolean}> {
await cache.getSnapshot();
for (let attempt = 0; attempt < INSTANCE_CONFIG_WRITE_ATTEMPTS; attempt++) {
cache.assertActive();
const current = await this.fetchConfigForWrite(key);
cache.assertActive();
const {value, result} = next(current);
if (value === null) return {result, written: false};
if (await executeConditional(this.compareAndSetConfig(key, current, value))) {
cache.update(key, value);
return {result, written: true};
}
}
Logger.error(
{key, attempts: INSTANCE_CONFIG_WRITE_ATTEMPTS},
'Instance config write lost to a concurrent write on every attempt',
);
throw new InstanceConfigWriteConflictError(key);
}
private compareAndSetConfig(key: string, current: string | null, value: string): PreparedQuery {
const updatedAt = new Date();
if (current === null) {
return InstanceConfiguration.insertIfNotExists({key, value, updated_at: updatedAt});
}
return InstanceConfiguration.conditionalPatchByPk(
{key},
{value: Db.set(value), updated_at: Db.set(updatedAt)},
{value: current},
);
}
private async fetchConfigForWrite(key: string): Promise<string | null> {
const [row] = await fetchMany<InstanceConfigurationRow>(FETCH_CONFIG_QUERY, {key}, {consistency: 'serial'});
return row?.value ?? null;
}
private async fetchConfigFromDatabase(key: string): Promise<string | null> {
const row = await fetchOne<InstanceConfigurationRow>(FETCH_CONFIG_QUERY, {key});
return row?.value ?? null;
@@ -1015,6 +1170,7 @@ export class InstanceConfigRepository {
);
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
parseStoredScreenShareDeliveryConfig(snapshot.get(SCREEN_SHARE_DELIVERY_CONFIG_KEY) ?? null);
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null);
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
checkStoredConfig('registration', () =>
@@ -1082,8 +1238,12 @@ export class InstanceConfigRepository {
return parseStoredGatewayRolloutConfig(raw);
}
async setGatewayRolloutConfig(config: GatewayRolloutConfig): Promise<void> {
await this.setConfig(GATEWAY_ROLLOUT_CONFIG_KEY, JSON.stringify(decodeGatewayRolloutConfig(config)));
updateGatewayRolloutConfig(
update: (current: GatewayRolloutConfig) => GatewayRolloutConfig,
): Promise<GatewayRolloutConfig> {
return this.updateStoredConfig(GATEWAY_ROLLOUT_CONFIG_KEY, (raw) =>
decodeGatewayRolloutConfig(update(parseStoredGatewayRolloutConfig(raw))),
);
}
async getVoiceNoiseSuppressionConfig(): Promise<VoiceNoiseSuppressionConfig> {
@@ -1092,8 +1252,19 @@ export class InstanceConfigRepository {
}
async setVoiceNoiseSuppressionConfig(config: VoiceNoiseSuppressionConfig): Promise<void> {
const validated = validateStoredConfig(VoiceNoiseSuppressionConfigSchema, config, 'voice noise suppression');
await this.setConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, JSON.stringify(validated));
await this.updateVoiceNoiseSuppressionConfig(() => config);
}
updateVoiceNoiseSuppressionConfig(
update: (current: VoiceNoiseSuppressionConfig) => VoiceNoiseSuppressionConfig,
): Promise<VoiceNoiseSuppressionConfig> {
return this.updateStoredConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, (raw) =>
validateStoredConfig(
VoiceNoiseSuppressionConfigSchema,
update(parseStoredVoiceNoiseSuppressionConfig(raw)),
'voice noise suppression',
),
);
}
async getScreenShareDeliveryConfig(): Promise<ScreenShareDeliveryConfig> {
@@ -1102,8 +1273,36 @@ export class InstanceConfigRepository {
}
async setScreenShareDeliveryConfig(config: ScreenShareDeliveryConfig): Promise<void> {
const validated = validateStoredConfig(ScreenShareDeliveryConfigSchema, config, 'screen share delivery');
await this.setConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, JSON.stringify(validated));
await this.updateScreenShareDeliveryConfig(() => config);
}
updateScreenShareDeliveryConfig(
update: (current: ScreenShareDeliveryConfig) => ScreenShareDeliveryConfig,
): Promise<ScreenShareDeliveryConfig> {
return this.updateStoredConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, (raw) =>
validateStoredConfig(
ScreenShareDeliveryConfigSchema,
update(parseStoredScreenShareDeliveryConfig(raw)),
'screen share delivery',
),
);
}
async getPushServiceDeliveryConfig(): Promise<PushServiceDeliveryConfig> {
const raw = await this.getConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
return parseStoredPushServiceDeliveryConfig(raw);
}
updatePushServiceDeliveryConfig(
update: (current: PushServiceDeliveryConfig) => PushServiceDeliveryConfig,
): Promise<PushServiceDeliveryConfig> {
return this.updateStoredConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY, (raw) =>
validateStoredConfig(
PushServiceDeliveryConfigSchema,
update(parseStoredPushServiceDeliveryConfig(raw)),
'push service delivery',
),
);
}
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
@@ -1112,8 +1311,19 @@ export class InstanceConfigRepository {
}
async setExperimentDeliveryConfig(config: ExperimentDeliveryConfig): Promise<void> {
const validated = validateStoredConfig(ExperimentDeliveryConfigSchema, config, 'experiment delivery');
await this.setConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, JSON.stringify(validated));
await this.updateExperimentDeliveryConfig(() => config);
}
updateExperimentDeliveryConfig(
update: (current: ExperimentDeliveryConfig) => ExperimentDeliveryConfig,
): Promise<ExperimentDeliveryConfig> {
return this.updateStoredConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, (raw) =>
validateStoredConfig(
ExperimentDeliveryConfigSchema,
update(parseStoredExperimentDeliveryConfig(raw)),
'experiment delivery',
),
);
}
async readLimitConfigInputs(): Promise<LimitConfigInputs> {
@@ -1149,26 +1359,27 @@ export class InstanceConfigRepository {
legal?: Partial<InstanceAppPublicConfig['legal']>;
registration?: Partial<InstanceAppPublicConfig['registration']>;
}): Promise<InstanceAppPublicConfig> {
const current = await this.getAppPublicConfig();
const next = decodeAppPublicConfig({
branding: {
...current.branding,
...(config.branding ?? {}),
},
setup: {
...current.setup,
...(config.setup ?? {}),
},
legal: {
...current.legal,
...(config.legal ?? {}),
},
registration: {
...current.registration,
...(config.registration ?? {}),
},
const next = await this.updateStoredConfig(APP_PUBLIC_CONFIG_KEY, (raw) => {
const current = parseStoredAppPublicConfig(raw);
return decodeAppPublicConfig({
branding: {
...current.branding,
...(config.branding ?? {}),
},
setup: {
...current.setup,
...(config.setup ?? {}),
},
legal: {
...current.legal,
...(config.legal ?? {}),
},
registration: {
...current.registration,
...(config.registration ?? {}),
},
});
});
await this.setConfig(APP_PUBLIC_CONFIG_KEY, JSON.stringify(next));
setCachedDateOfBirthCollection(next.registration.collect_date_of_birth);
return next;
}
@@ -1188,10 +1399,21 @@ export class InstanceConfigRepository {
return parseStoredInstancePolicyConfig(raw);
}
async setInstancePolicyConfig(config: Partial<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
const current = await this.readStoredInstancePolicyConfig();
const next = decodeInstancePolicyConfig({...current, ...config});
await this.setConfig(INSTANCE_POLICY_CONFIG_KEY, JSON.stringify(next));
setInstancePolicyConfig(config: Partial<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
return this.updateInstancePolicyConfig(() => config);
}
async updateInstancePolicyConfig(
plan: (current: InstancePolicyConfig) => Partial<InstancePolicyConfig>,
): Promise<InstancePolicyConfig> {
const cache = this.configCache;
const {result: next, written} = await this.compareAndSetStoredValue(cache, INSTANCE_POLICY_CONFIG_KEY, (raw) => {
const current = parseStoredInstancePolicyConfig(raw);
const patch = plan(current);
const config = decodeInstancePolicyConfig({...current, ...patch});
return {value: Object.keys(patch).length === 0 ? null : JSON.stringify(config), result: config};
});
if (written) await this.publishRefresh(cache.sourceId);
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(next));
return next;
}
@@ -1201,37 +1423,37 @@ export class InstanceConfigRepository {
return parseStoredInstanceIntegrationsConfig(raw);
}
async setInstanceIntegrationsConfig(config: InstanceIntegrationsConfigPatch): Promise<InstanceIntegrationsConfig> {
const current = await this.getInstanceIntegrationsConfig();
const next = decodeInstanceIntegrationsConfig({
gif: {
...current.gif,
...(config.gif ?? {}),
},
youtube: {
...current.youtube,
...(config.youtube ?? {}),
},
captcha: {
...current.captcha,
...(config.captcha ?? {}),
},
email: {
...current.email,
...(config.email ?? {}),
smtp: {
...current.email.smtp,
...(config.email?.smtp ?? {}),
setInstanceIntegrationsConfig(config: InstanceIntegrationsConfigPatch): Promise<InstanceIntegrationsConfig> {
return this.updateStoredConfig(INSTANCE_INTEGRATIONS_CONFIG_KEY, (raw) => {
const current = parseStoredInstanceIntegrationsConfig(raw);
return decodeInstanceIntegrationsConfig({
gif: {
...current.gif,
...(config.gif ?? {}),
},
},
bluesky: {
...current.bluesky,
...(config.bluesky ?? {}),
keys: config.bluesky?.keys ?? current.bluesky.keys,
},
youtube: {
...current.youtube,
...(config.youtube ?? {}),
},
captcha: {
...current.captcha,
...(config.captcha ?? {}),
},
email: {
...current.email,
...(config.email ?? {}),
smtp: {
...current.email.smtp,
...(config.email?.smtp ?? {}),
},
},
bluesky: {
...current.bluesky,
...(config.bluesky ?? {}),
keys: config.bluesky?.keys ?? current.bluesky.keys,
},
});
});
await this.setConfig(INSTANCE_INTEGRATIONS_CONFIG_KEY, JSON.stringify(next));
return next;
}
async getInstanceMediaConfig(): Promise<InstanceMediaConfig> {
@@ -1239,16 +1461,16 @@ export class InstanceConfigRepository {
return parseStoredInstanceMediaConfig(raw);
}
async setInstanceMediaConfig(config: InstanceMediaConfigPatch): Promise<InstanceMediaConfig> {
const current = await this.getInstanceMediaConfig();
const next = decodeInstanceMediaConfig({
attachment_decay: {
...current.attachment_decay,
...(config.attachment_decay ?? {}),
},
setInstanceMediaConfig(config: InstanceMediaConfigPatch): Promise<InstanceMediaConfig> {
return this.updateStoredConfig(INSTANCE_MEDIA_CONFIG_KEY, (raw) => {
const current = parseStoredInstanceMediaConfig(raw);
return decodeInstanceMediaConfig({
attachment_decay: {
...current.attachment_decay,
...(config.attachment_decay ?? {}),
},
});
});
await this.setConfig(INSTANCE_MEDIA_CONFIG_KEY, JSON.stringify(next));
return next;
}
async getEffectiveAttachmentDecayConfig(): Promise<InstanceAttachmentDecayEffectiveConfig> {
@@ -1485,15 +1707,15 @@ export class InstanceConfigRepository {
return parseStoredRegistrationConfig(raw);
}
async setRegistrationConfig(config: Partial<InstanceRegistrationConfig>): Promise<InstanceRegistrationConfig> {
const current = await this.getRegistrationConfig();
const next = decodeRegistrationConfig({
mode: config.mode ?? current.mode,
admin_registration_urls_enabled:
config.admin_registration_urls_enabled ?? current.admin_registration_urls_enabled,
setRegistrationConfig(config: Partial<InstanceRegistrationConfig>): Promise<InstanceRegistrationConfig> {
return this.updateStoredConfig(REGISTRATION_CONFIG_KEY, (raw) => {
const current = parseStoredRegistrationConfig(raw);
return decodeRegistrationConfig({
mode: config.mode ?? current.mode,
admin_registration_urls_enabled:
config.admin_registration_urls_enabled ?? current.admin_registration_urls_enabled,
});
});
await this.setConfig(REGISTRATION_CONFIG_KEY, JSON.stringify(next));
return next;
}
async getRegistrationPublicConfig(): Promise<InstanceRegistrationConfig> {
@@ -1534,16 +1756,20 @@ export class InstanceConfigRepository {
last_used_at: null,
last_used_by_user_id: null,
};
const registrationUrls = await this.getRegistrationUrls();
await this.setRegistrationUrls([registrationUrl, ...registrationUrls]);
await this.updateStoredConfig(REGISTRATION_URLS_KEY, (raw) =>
validateStoredCollection(
StoredRegistrationUrlSchema,
[registrationUrl, ...parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs')],
'registration URLs',
),
);
return {registrationUrl: redactRegistrationUrl(registrationUrl), code};
}
async revokeRegistrationUrl(id: string): Promise<void> {
const now = new Date().toISOString();
const registrationUrls = await this.getRegistrationUrls();
await this.setRegistrationUrls(
registrationUrls.map((registrationUrl) =>
await this.updateStoredConfig(REGISTRATION_URLS_KEY, (raw) =>
parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs').map((registrationUrl) =>
registrationUrl.id === id && !registrationUrl.revoked_at
? {...registrationUrl, revoked_at: now}
: registrationUrl,
@@ -1556,9 +1782,8 @@ export class InstanceConfigRepository {
if (!normalizedCode) return null;
const hash = this.hashRegistrationUrlCode(normalizedCode);
const now = new Date();
const registrationUrls = await this.getRegistrationUrls();
return (
registrationUrls.find(
(await this.fetchRegistrationUrlDefinitions()).find(
(registrationUrl) =>
(registrationUrl.id === normalizedCode || registrationUrl.code_hash === hash) &&
isRegistrationUrlUsable(registrationUrl, now),
@@ -1566,21 +1791,68 @@ export class InstanceConfigRepository {
);
}
async recordRegistrationUrlUse(id: string, userId: string): Promise<void> {
const now = new Date().toISOString();
const registrationUrls = await this.getRegistrationUrls();
await this.setRegistrationUrls(
registrationUrls.map((registrationUrl) =>
registrationUrl.id === id
? {
...registrationUrl,
use_count: registrationUrl.use_count + 1,
last_used_at: now,
last_used_by_user_id: userId,
}
: registrationUrl,
),
);
async claimRegistrationUrlUse(registrationUrlId: string, userId: string): Promise<RegistrationUrlClaim | null> {
const cache = this.configCache;
let claimed: {result: RegistrationUrlClaim | null; written: boolean};
try {
claimed = await this.compareAndSetStoredValue<RegistrationUrlClaim | null>(
cache,
REGISTRATION_URLS_KEY,
(raw) => {
const registrationUrls = parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs');
const now = new Date();
const claimable = registrationUrls.find(
(registrationUrl) =>
registrationUrl.id === registrationUrlId && isRegistrationUrlUsable(registrationUrl, now),
);
if (!claimable) return {value: null, result: null};
const next = registrationUrls.map((registrationUrl) =>
registrationUrl === claimable
? {
...registrationUrl,
use_count: registrationUrl.use_count + 1,
last_used_at: now.toISOString(),
last_used_by_user_id: userId,
}
: registrationUrl,
);
return {
value: JSON.stringify(validateStoredCollection(StoredRegistrationUrlSchema, next, 'registration URLs')),
result: {registration_url_id: registrationUrlId, user_id: userId},
};
},
);
} catch (error) {
if (error instanceof InstanceConfigWriteConflictError) throw new ServiceUnavailableError();
throw error;
}
if (claimed.written) await this.publishRefresh(cache.sourceId);
return claimed.result;
}
async releaseRegistrationUrlUse(claim: RegistrationUrlClaim): Promise<void> {
const cache = this.configCache;
try {
const {written} = await this.compareAndSetStoredValue<null>(cache, REGISTRATION_URLS_KEY, (raw) => {
const registrationUrls = parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs');
const released = registrationUrls.find(
(registrationUrl) => registrationUrl.id === claim.registration_url_id && registrationUrl.use_count > 0,
);
if (!released) return {value: null, result: null};
const next = registrationUrls.map((registrationUrl) =>
registrationUrl === released
? {...registrationUrl, use_count: registrationUrl.use_count - 1}
: registrationUrl,
);
return {value: JSON.stringify(next), result: null};
});
if (written) await this.publishRefresh(cache.sourceId);
} catch (error) {
Logger.warn(
{registrationUrlId: claim.registration_url_id, userId: claim.user_id, error},
'Releasing a registration URL use failed',
);
}
}
async getPendingRegistrations(): Promise<Array<InstancePendingRegistration>> {
@@ -1591,104 +1863,82 @@ export class InstanceConfigRepository {
}
async addPendingRegistration(pendingRegistration: InstancePendingRegistration): Promise<void> {
const pendingRegistrations = await this.getPendingRegistrations();
const next = [
pendingRegistration,
...pendingRegistrations.filter((entry) => entry.user_id !== pendingRegistration.user_id),
];
await this.setPendingRegistrations(next);
await this.updateStoredConfig(REGISTRATION_PENDING_APPROVALS_KEY, (raw) =>
validateStoredCollection(
StoredPendingRegistrationSchema,
[
pendingRegistration,
...parseStoredCollection(StoredPendingRegistrationSchema, raw, 'pending registrations').filter(
(entry) => entry.user_id !== pendingRegistration.user_id,
),
],
'pending registrations',
),
);
}
async removePendingRegistration(userId: string): Promise<void> {
const pendingRegistrations = await this.getPendingRegistrations();
await this.setPendingRegistrations(pendingRegistrations.filter((entry) => entry.user_id !== userId));
await this.updateStoredConfig(REGISTRATION_PENDING_APPROVALS_KEY, (raw) =>
parseStoredCollection(StoredPendingRegistrationSchema, raw, 'pending registrations').filter(
(entry) => entry.user_id !== userId,
),
);
}
async getSsoConfig(options?: {includeSecret?: boolean}): Promise<InstanceSsoConfig> {
const configs = await this.getAllConfigs();
const flags = readStoredSsoFlags(configs);
const read = (key: string): string | null => {
const v = configs.get(key);
if (!v) return null;
const trimmed = v.trim();
return trimmed.length === 0 ? null : trimmed;
};
const allowedDomains = parseStoredSsoAllowedEmailDomains(configs.get('sso_allowed_domains'));
const clientSecret = read('sso_client_secret');
return {
...flags,
displayName: read('sso_display_name'),
issuer: read('sso_issuer'),
authorizationUrl: read('sso_authorization_url'),
tokenUrl: read('sso_token_url'),
userInfoUrl: read('sso_userinfo_url'),
jwksUrl: read('sso_jwks_url'),
clientId: read('sso_client_id'),
clientSecret: options?.includeSecret ? clientSecret : undefined,
clientSecretSet: Boolean(clientSecret),
scope: read('sso_scope'),
allowedEmailDomains: allowedDomains,
redirectUri: null,
};
return readStoredSsoConfig(await this.getAllConfigs(), options);
}
async setSsoConfig(config: Partial<InstanceSsoConfig>): Promise<InstanceSsoConfig> {
const current = await this.getSsoConfig({includeSecret: true});
const definedConfig = Object.fromEntries(
Object.entries(config).filter(([, value]) => value !== undefined),
) as Partial<InstanceSsoConfig>;
const next: InstanceSsoConfig = {
...current,
...definedConfig,
clientSecret: config.clientSecret !== undefined ? config.clientSecret : current.clientSecret,
};
if (config.enabled === true && config.enforced === undefined && !current.enabled) {
next.enforced = true;
}
let allowedEmailDomains: Array<string>;
try {
allowedEmailDomains = normalizeSsoAllowedEmailDomains(next.allowedEmailDomains);
} catch (error) {
if (next.enabled) {
throw error;
}
Logger.warn({error}, 'Clearing invalid SSO allowed domain config while SSO is disabled');
allowedEmailDomains = [];
}
const entries: Array<[string, string]> = [
['sso_enabled', next.enabled ? 'true' : 'false'],
['sso_enforced', next.enforced ? 'true' : 'false'],
['sso_display_name', next.displayName ?? ''],
['sso_issuer', next.issuer ?? ''],
['sso_authorization_url', next.authorizationUrl ?? ''],
['sso_token_url', next.tokenUrl ?? ''],
['sso_userinfo_url', next.userInfoUrl ?? ''],
['sso_jwks_url', next.jwksUrl ?? ''],
['sso_client_id', next.clientId ?? ''],
['sso_scope', next.scope ?? ''],
['sso_allowed_domains', JSON.stringify(allowedEmailDomains)],
['sso_auto_provision', next.autoProvision ? 'true' : 'false'],
['sso_redirect_uri', ''],
const configs = await this.getAllConfigs();
const current = readStoredSsoConfig(configs, {includeSecret: true});
const enabled = config.enabled ?? current.enabled;
const allowedEmailDomains =
config.allowedEmailDomains === undefined
? undefined
: normalizeSsoAllowedEmailDomainsForWrite(config.allowedEmailDomains, enabled);
const rows: Array<SsoRowWrite> = [
ssoRow('sso_enabled', config.enabled, current.enabled, formatSsoBoolean),
ssoRow('sso_enforced', config.enforced, current.enforced, formatSsoBoolean),
ssoRow('sso_display_name', config.displayName, current.displayName, formatSsoString),
ssoRow('sso_issuer', config.issuer, current.issuer, formatSsoString),
ssoRow('sso_authorization_url', config.authorizationUrl, current.authorizationUrl, formatSsoString),
ssoRow('sso_token_url', config.tokenUrl, current.tokenUrl, formatSsoString),
ssoRow('sso_userinfo_url', config.userInfoUrl, current.userInfoUrl, formatSsoString),
ssoRow('sso_jwks_url', config.jwksUrl, current.jwksUrl, formatSsoString),
ssoRow('sso_client_id', config.clientId, current.clientId, formatSsoString),
ssoRow('sso_scope', config.scope, current.scope, formatSsoString),
ssoRow('sso_allowed_domains', allowedEmailDomains, current.allowedEmailDomains, formatSsoDomains),
ssoRow('sso_auto_provision', config.autoProvision, current.autoProvision, formatSsoBoolean),
ssoRow('sso_redirect_uri', undefined, null, formatSsoString),
];
if (config.clientSecret !== undefined) {
entries.push(['sso_client_secret', config.clientSecret ?? '']);
rows.push(ssoRow('sso_client_secret', config.clientSecret, current.clientSecret ?? null, formatSsoString));
}
await this.setConfigs(entries);
const cache = this.configCache;
const results = await Promise.allSettled(
rows
.filter((row) => row.value !== undefined || !configs.has(row.key))
.map((row) =>
this.compareAndSetStoredValue(cache, row.key, (raw) => ({value: nextSsoRowValue(row, raw), result: null})),
),
);
const errors: Array<unknown> = results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : []));
if (results.some((result) => result.status === 'fulfilled' && result.value.written)) {
try {
await this.publishRefresh(cache.sourceId);
} catch (error) {
errors.push(error);
}
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, 'Failed to write or publish the SSO config');
return this.getSsoConfig({includeSecret: true});
}
private async setRegistrationUrls(registrationUrls: Array<InstanceRegistrationUrl>): Promise<void> {
const validated = validateStoredCollection(StoredRegistrationUrlSchema, registrationUrls, 'registration URLs');
await this.setConfig(REGISTRATION_URLS_KEY, JSON.stringify(validated));
}
private async setPendingRegistrations(pendingRegistrations: Array<InstancePendingRegistration>): Promise<void> {
const validated = validateStoredCollection(
StoredPendingRegistrationSchema,
pendingRegistrations,
'pending registrations',
);
await this.setConfig(REGISTRATION_PENDING_APPROVALS_KEY, JSON.stringify(validated));
private async fetchRegistrationUrlDefinitions(): Promise<Array<InstanceRegistrationUrl>> {
const raw = await this.fetchConfigFromDatabase(REGISTRATION_URLS_KEY);
return parseStoredCollection(StoredRegistrationUrlSchema, raw, 'registration URLs');
}
private hashRegistrationUrlCode(code: string): string {
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PushServiceDeliveryConfig} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
const textEncoder = new TextEncoder();
export const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
interface PushServiceDeliveryConfigNatsMessage {
type: 'push_service_delivery_config';
config: PushServiceDeliveryConfig;
}
export class PushServiceDeliveryConfigPublisher {
constructor(private readonly connectionManager: INatsConnectionManager) {}
async publish(config: PushServiceDeliveryConfig): Promise<void> {
if (this.connectionManager.isClosed()) {
await this.connectionManager.connect();
}
const connection = this.connectionManager.getConnection();
const message: PushServiceDeliveryConfigNatsMessage = {
type: 'push_service_delivery_config',
config,
};
connection.publish(PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT, textEncoder.encode(JSON.stringify(message)));
await connection.flush();
}
}
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getKvMeta} from '@app/api/database/CassandraMetaRegistry';
import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import type {CassandraParams, KvQueryMeta, PreparedQuery} from '@app/api/database/CassandraTypes';
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
import {InstanceConfiguration} from '@app/api/Tables';
type InstanceConfigWriteEvent = 'read' | 'write' | 'write rejected';
interface WriteGate {
size: number;
paused: Array<() => void>;
}
const FETCH_ROW_QUERY = InstanceConfiguration.selectCql({
where: InstanceConfiguration.where.eq('key'),
limit: 1,
});
export class InstanceConfigWriteRaceExecutor implements CassandraQueryExecutorForTesting {
readonly events: Array<InstanceConfigWriteEvent> = [];
private watchedKey: string | null = null;
private gate: WriteGate | null = null;
private beforeEachWrite: (() => Promise<void>) | null = null;
constructor(private readonly base: CassandraQueryExecutorForTesting) {}
watch(key: string): void {
this.watchedKey = key;
this.events.length = 0;
}
pauseWritesUntil(size: number): void {
this.gate = {size, paused: []};
}
competeBeforeEachWrite(write: () => Promise<void>): void {
this.beforeEachWrite = write;
}
async writeDirectly(key: string, value: string): Promise<void> {
await this.base.executeQuery(InstanceConfiguration.upsertAll({key, value, updated_at: new Date()}));
}
async readDirectly(key: string): Promise<string | null> {
const [row] = await this.base.executeQuery<InstanceConfigurationRow>({cql: FETCH_ROW_QUERY, params: {key}});
return row?.value ?? null;
}
async executeQuery<T = Record<string, unknown>, P extends CassandraParams = CassandraParams>(
query: PreparedQuery<P>,
): Promise<Array<T>> {
const meta = query.kvMeta ?? getKvMeta(query.cql);
if (this.watchedKey === null || !this.isWatched(meta, query.params)) {
return this.base.executeQuery<T, P>(query);
}
if (meta?.action === 'select') {
this.events.push('read');
return this.base.executeQuery<T, P>(query);
}
await this.passGate();
await this.beforeEachWrite?.();
const rows = await this.base.executeQuery<T, P>(query);
const applied = (rows[0] as {'[applied]'?: unknown} | undefined)?.['[applied]'];
this.events.push(applied === false ? 'write rejected' : 'write');
return rows;
}
executeBatch(queries: Array<{query: string; params: object; meta?: KvQueryMeta}>, atomic?: boolean): Promise<void> {
return this.base.executeBatch(queries, atomic);
}
reset(): void {
this.base.reset?.();
this.watchedKey = null;
this.gate = null;
this.beforeEachWrite = null;
this.events.length = 0;
}
async shutdown(): Promise<void> {
await this.base.shutdown?.();
}
private isWatched(meta: KvQueryMeta | null | undefined, params: CassandraParams): boolean {
return meta?.table.name === InstanceConfiguration.name && params.key === this.watchedKey;
}
private async passGate(): Promise<void> {
const gate = this.gate;
if (gate === null) return;
await new Promise<void>((release) => {
gate.paused.push(release);
if (gate.paused.length < gate.size) return;
this.gate = null;
for (const release of gate.paused) release();
});
}
}
@@ -32,10 +32,12 @@ export interface ListJobsResult {
}
export abstract class IJobLedgerRepository {
abstract createJob(input: CreateJobInput): Promise<void>;
abstract createJob(input: CreateJobInput): Promise<Date>;
abstract getJob(jobId: bigint): Promise<JobByIdRow | null>;
abstract discardJob(jobId: bigint, createdAt: Date): Promise<void>;
abstract markRunning(jobId: bigint, lane: string): Promise<void>;
abstract markSucceeded(jobId: bigint, result: Record<string, unknown> | null): Promise<void>;
@@ -1,9 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {
type CassandraQueryExecutorForTesting,
executeQuery,
fetchMany,
fetchOne,
setCassandraQueryExecutorForTesting,
} from '@app/api/database/CassandraQueryExecution';
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
import type {JobStatus} from '@app/api/database/types/JobLedgerTypes';
import {JobLedgerRepository} from '@app/api/jobs/JobLedgerRepository';
import {
EXPIRED_JOB_ERROR,
JOB_LEDGER_TTL_SECONDS,
JOB_STALE_AFTER_MS,
JobLedgerRepository,
} from '@app/api/jobs/JobLedgerRepository';
import {describeListJobsPaging} from '@app/api/jobs/ListJobsPagingSuite';
import {JobsActive, JobsByDayBucket, JobsById} from '@app/api/Tables';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {createSnowflake} from '@fluxer/snowflake/src/Snowflake';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
let executor: InMemoryCassandraQueryExecutor;
@@ -22,6 +37,20 @@ async function createJob(repository: JobLedgerRepository, jobId: bigint, taskTyp
});
}
async function createJobAt(repository: JobLedgerRepository, jobId: bigint): Promise<Date> {
return repository.createJob({
jobId,
taskType: 'batchGuildAuditLogMessageDeletes',
payload: {},
requestedByUserId: null,
auditLogReason: null,
maxAttempts: 3,
runAt: null,
jetStreamLane: null,
jetStreamSeq: null,
});
}
async function listJobIdsByStatus(repository: JobLedgerRepository, status: JobStatus): Promise<Array<bigint>> {
const result = await repository.listJobs({limit: 50, cursor: null, filters: {status}, maxLookbackDays: 1});
return result.jobs.map((job) => job.job_id);
@@ -130,6 +159,197 @@ describe('JobLedgerRepository listJobs pagination', () => {
maxLookbackDays: 1,
});
expect(result.jobs.map((job) => job.job_id)).toEqual([1_000n, 1_001n, 1_002n, 1_003n, 1_004n]);
expect(result.jobs.map((job) => job.job_id)).toEqual([1_004n, 1_003n, 1_002n, 1_001n, 1_000n]);
});
});
describe('JobLedgerRepository listJobs on the in-memory executor', () => {
beforeEach(() => {
executor = new InMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
});
afterEach(() => {
executor.reset();
setCassandraQueryExecutorForTesting(null);
});
describeListJobsPaging();
});
let staleSequence = 0;
function jobIdAgedDays(days: number): bigint {
staleSequence += 1;
return createSnowflake({timestamp: Date.now() - days * 86_400_000, sequence: staleSequence % 4096, workerId: 1});
}
async function createAgedJob(repository: JobLedgerRepository, days: number): Promise<bigint> {
const jobId = jobIdAgedDays(days);
await createJob(repository, jobId, 'syncUrlBlocklists');
return jobId;
}
async function activeJobIds(repository: JobLedgerRepository): Promise<Array<bigint>> {
return (await repository.listActiveJobs()).map((job) => job.job_id).sort((a, b) => (a < b ? -1 : 1));
}
function sweep(repository: JobLedgerRepository, maxCleared = 100) {
return repository.expireStaleActiveJobs({
staleBeforeMs: Date.now() - JOB_STALE_AFTER_MS,
pageSize: 100,
maxCleared,
});
}
describe('JobLedgerRepository expireStaleActiveJobs', () => {
beforeEach(() => {
executor = new InMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
});
afterEach(() => {
executor.reset();
setCassandraQueryExecutorForTesting(null);
});
it('dead-letters queued and running jobs the jobs stream has outlived and keeps younger ones active', async () => {
const repository = new JobLedgerRepository();
const staleQueued = await createAgedJob(repository, 9);
const staleRunning = await createAgedJob(repository, 9);
await repository.markRunning(staleRunning, 'batch');
const weekOld = await createAgedJob(repository, 7);
const fresh = await createAgedJob(repository, 0);
expect(await sweep(repository)).toEqual({cleared: 2, expired: 2, complete: true});
for (const jobId of [staleQueued, staleRunning]) {
const job = await repository.getJob(jobId);
expect(job?.status).toBe('deadletter');
expect(job?.error_message).toBe(EXPIRED_JOB_ERROR);
expect(job?.completed_at).toBeInstanceOf(Date);
}
expect(await activeJobIds(repository)).toEqual([weekOld, fresh].sort((a, b) => (a < b ? -1 : 1)));
expect((await repository.getJob(weekOld))?.status).toBe('queued');
});
it('drops a stale active row without touching a finished or missing job', async () => {
const repository = new JobLedgerRepository();
const finished = await createAgedJob(repository, 9);
await repository.markSucceeded(finished, null);
const orphan = jobIdAgedDays(9);
for (const jobId of [finished, orphan]) {
await executeQuery(
JobsActive.patchByPkWithTtl({job_id: jobId}, {status: Db.set('running')}, JOB_LEDGER_TTL_SECONDS),
);
}
expect(await sweep(repository)).toEqual({cleared: 2, expired: 0, complete: true});
const job = await repository.getJob(finished);
expect(job?.status).toBe('succeeded');
expect(job?.error_message).toBeNull();
expect(await fetchOne(JobsById.select({where: JobsById.where.eq('job_id')}).bind({job_id: orphan}))).toBeNull();
expect(await fetchMany(JobsActive.select().bind({}))).toEqual([]);
});
it('stops at its per-run cap and picks up the rest on the next run', async () => {
const repository = new JobLedgerRepository();
for (let index = 0; index < 3; index += 1) {
await createAgedJob(repository, 9);
}
expect(await sweep(repository, 2)).toEqual({cleared: 2, expired: 2, complete: false});
expect(await activeJobIds(repository)).toHaveLength(1);
expect(await sweep(repository, 2)).toEqual({cleared: 1, expired: 1, complete: true});
expect(await activeJobIds(repository)).toEqual([]);
});
});
describe('JobLedgerRepository getJob', () => {
beforeEach(() => {
executor = new InMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
});
afterEach(() => {
executor.reset();
setCassandraQueryExecutorForTesting(null);
});
it('hides a job row that lost its status, creation time or task type', async () => {
const repository = new JobLedgerRepository();
await createJob(repository, 9n, 'syncUrlBlocklists');
expect((await repository.getJob(9n))?.status).toBe('queued');
for (const column of ['status', 'created_at', 'task_type'] as const) {
await createJob(repository, 9n, 'syncUrlBlocklists');
await executeQuery(JobsById.patchByPk({job_id: 9n}, {[column]: Db.clear()}));
expect(await repository.getJob(9n)).toBeNull();
}
});
});
describe('JobLedgerRepository discardJob', () => {
let inner: InMemoryCassandraQueryExecutor;
let log: Array<string>;
let failDeleteOn: string | null;
beforeEach(() => {
inner = new InMemoryCassandraQueryExecutor();
log = [];
failDeleteOn = null;
const wrapper: CassandraQueryExecutorForTesting = {
async executeQuery<T>(query: PreparedQuery) {
const meta = query.kvMeta;
if (meta) log.push(`${meta.action} ${meta.table.name}`);
if (meta?.action === 'delete' && meta.table.name === failDeleteOn) {
throw new Error('write timeout');
}
return inner.executeQuery<T>(query);
},
executeBatch: (queries, atomic) => inner.executeBatch(queries, atomic),
};
setCassandraQueryExecutorForTesting(wrapper);
});
afterEach(() => {
inner.reset();
setCassandraQueryExecutorForTesting(null);
});
it('removes every ledger row of a duplicate with three deletes and no read', async () => {
const repository = new JobLedgerRepository();
const createdAt = await createJobAt(repository, 7n);
log.length = 0;
await repository.discardJob(7n, createdAt);
expect([...log].sort()).toEqual(['delete jobs_active', 'delete jobs_by_day_bucket', 'delete jobs_by_id']);
expect(await repository.getJob(7n)).toBeNull();
expect(await repository.listActiveJobs()).toEqual([]);
expect(
await fetchMany(
JobsByDayBucket.select({where: JobsByDayBucket.where.eq('bucket_day')}).bind({
bucket_day: createdAt.toISOString().slice(0, 10),
}),
),
).toEqual([]);
});
it('keeps deleting the other ledger rows when one delete fails', async () => {
const repository = new JobLedgerRepository();
const createdAt = await createJobAt(repository, 8n);
failDeleteOn = 'jobs_by_id';
await expect(repository.discardJob(8n, createdAt)).rejects.toThrow('write timeout');
expect(await repository.listActiveJobs()).toEqual([]);
expect(
await fetchMany(
JobsByDayBucket.select({where: JobsByDayBucket.where.eq('bucket_day')}).bind({
bucket_day: createdAt.toISOString().slice(0, 10),
}),
),
).toEqual([]);
});
});
+180 -66
View File
@@ -1,6 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {
BatchBuilder,
deleteOneOrMany,
fetchMany,
fetchOne,
fetchPage,
type PagedQueryResult,
upsertOne,
} from '@app/api/database/CassandraQueryExecution';
import {Db} from '@app/api/database/CassandraTypes';
import type {JobActiveRow, JobByDayBucketRow, JobByIdRow, JobStatus} from '@app/api/database/types/JobLedgerTypes';
import {
@@ -11,6 +19,17 @@ import {
type ListJobsResult,
} from '@app/api/jobs/IJobLedgerRepository';
import {JobsActive, JobsByDayBucket, JobsById} from '@app/api/Tables';
import {awaitAll} from '@app/api/utils/ConcurrencyUtils';
import {JOBS_STREAM_MAX_AGE_MS} from '@app/api/worker/JetStreamWorkerQueue';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import {ms, seconds} from 'itty-time';
export const JOB_LEDGER_TTL_SECONDS = seconds('90 days');
export const JOB_STALE_AFTER_MS = JOBS_STREAM_MAX_AGE_MS + ms('1 day');
export const EXPIRED_JOB_ERROR = 'Expired from the job queue';
const JOB_LEDGER_RETENTION_DAYS = JOB_LEDGER_TTL_SECONDS / seconds('1 day');
const NEWEST_FIRST = {col: 'created_at', direction: 'DESC'} as const;
const FETCH_JOB_BY_ID_QUERY = JobsById.select({
where: JobsById.where.eq('job_id'),
@@ -19,13 +38,58 @@ const FETCH_CANCEL_REQUESTED_QUERY = JobsById.select({
where: JobsById.where.eq('job_id'),
});
const ACTIVE_JOBS_QUERY = JobsActive.select();
const ACTIVE_JOB_IDS_QUERY = JobsActive.select({columns: ['job_id']});
const JOBS_AFTER_IN_TIE_QUERY = JobsByDayBucket.select({
where: [
JobsByDayBucket.where.eq('bucket_day'),
JobsByDayBucket.where.eq('created_at'),
JobsByDayBucket.where.lt('job_id'),
],
orderBy: NEWEST_FIRST,
});
type LedgerPosition = Pick<ListJobsCursor, 'createdAt' | 'jobId'>;
function bucketDayFor(d: Date): string {
return d.toISOString().slice(0, 10);
}
function previousBucketDay(day: string): string {
const date = new Date(`${day}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() - 1);
return bucketDayFor(date);
}
function dayJobsQuery(olderThanPosition: boolean, limit: number | null) {
return JobsByDayBucket.select({
where: olderThanPosition
? [JobsByDayBucket.where.eq('bucket_day'), JobsByDayBucket.where.lt('created_at')]
: JobsByDayBucket.where.eq('bucket_day'),
orderBy: NEWEST_FIRST,
...(limit === null ? {} : {limit}),
});
}
async function fetchDayAfter(
bucketDay: string,
after: LedgerPosition | null,
limit: number | null,
): Promise<{rows: Array<JobByDayBucketRow>; exhausted: boolean}> {
if (after === null) {
const rows = await fetchMany<JobByDayBucketRow>(dayJobsQuery(false, limit).bind({bucket_day: bucketDay}));
return {rows, exhausted: limit === null || rows.length < limit};
}
const ties = await fetchMany<JobByDayBucketRow>(
JOBS_AFTER_IN_TIE_QUERY.bind({bucket_day: bucketDay, created_at: after.createdAt, job_id: after.jobId}),
);
const older = await fetchMany<JobByDayBucketRow>(
dayJobsQuery(true, limit).bind({bucket_day: bucketDay, created_at: after.createdAt}),
);
return {rows: [...ties, ...older], exhausted: limit === null || older.length < limit};
}
export class JobLedgerRepository extends IJobLedgerRepository {
async createJob(input: CreateJobInput): Promise<void> {
async createJob(input: CreateJobInput): Promise<Date> {
const now = new Date();
const status: JobStatus = 'queued';
const idRow: JobByIdRow = {
@@ -68,39 +132,62 @@ export class JobLedgerRepository extends IJobLedgerRepository {
started_at: null,
};
const batch = new BatchBuilder();
batch.addPrepared(JobsById.insert(idRow));
batch.addPrepared(JobsByDayBucket.insert(bucketRow));
batch.addPrepared(JobsActive.insert(activeRow));
batch.addPrepared(JobsById.insertWithTtl(idRow, JOB_LEDGER_TTL_SECONDS));
batch.addPrepared(JobsByDayBucket.insertWithTtl(bucketRow, JOB_LEDGER_TTL_SECONDS));
batch.addPrepared(JobsActive.insertWithTtl(activeRow, JOB_LEDGER_TTL_SECONDS));
await batch.executeChunked(10, false);
return now;
}
async getJob(jobId: bigint): Promise<JobByIdRow | null> {
return fetchOne<JobByIdRow>(FETCH_JOB_BY_ID_QUERY.bind({job_id: jobId}));
const row = await fetchOne<JobByIdRow>(FETCH_JOB_BY_ID_QUERY.bind({job_id: jobId}));
return row?.created_at && row.task_type && row.status ? row : null;
}
async discardJob(jobId: bigint, createdAt: Date): Promise<void> {
await awaitAll(
[
deleteOneOrMany(
JobsByDayBucket.deleteByPk({bucket_day: bucketDayFor(createdAt), created_at: createdAt, job_id: jobId}),
),
deleteOneOrMany(JobsById.deleteByPk({job_id: jobId})),
deleteOneOrMany(JobsActive.deleteByPk({job_id: jobId})),
],
'Ledger discard left rows behind',
);
}
async markRunning(jobId: bigint, lane: string): Promise<void> {
const startedAt = new Date();
const status: JobStatus = 'running';
await upsertOne(
JobsById.patchByPk(
JobsById.patchByPkWithTtl(
{job_id: jobId},
{status: Db.set(status), started_at: Db.set(startedAt), jet_stream_lane: Db.set(lane)},
JOB_LEDGER_TTL_SECONDS,
),
);
await upsertOne(
JobsActive.patchByPkWithTtl(
{job_id: jobId},
{status: Db.set(status), started_at: Db.set(startedAt)},
JOB_LEDGER_TTL_SECONDS,
),
);
await upsertOne(JobsActive.patchByPk({job_id: jobId}, {status: Db.set(status), started_at: Db.set(startedAt)}));
}
async markSucceeded(jobId: bigint, result: Record<string, unknown> | null): Promise<void> {
const completedAt = new Date();
const status: JobStatus = 'succeeded';
await upsertOne(
JobsById.patchByPk(
JobsById.patchByPkWithTtl(
{job_id: jobId},
{
status: Db.set(status),
completed_at: Db.set(completedAt),
result: result === null ? Db.clear() : Db.set(JSON.stringify(result)),
},
JOB_LEDGER_TTL_SECONDS,
),
);
await deleteOneOrMany(JobsActive.deleteByPk({job_id: jobId}));
@@ -109,7 +196,13 @@ export class JobLedgerRepository extends IJobLedgerRepository {
async markCancelled(jobId: bigint): Promise<void> {
const completedAt = new Date();
const status: JobStatus = 'cancelled';
await upsertOne(JobsById.patchByPk({job_id: jobId}, {status: Db.set(status), completed_at: Db.set(completedAt)}));
await upsertOne(
JobsById.patchByPkWithTtl(
{job_id: jobId},
{status: Db.set(status), completed_at: Db.set(completedAt)},
JOB_LEDGER_TTL_SECONDS,
),
);
await deleteOneOrMany(JobsActive.deleteByPk({job_id: jobId}));
}
@@ -117,9 +210,10 @@ export class JobLedgerRepository extends IJobLedgerRepository {
const completedAt = new Date();
const status: JobStatus = 'deadletter';
await upsertOne(
JobsById.patchByPk(
JobsById.patchByPkWithTtl(
{job_id: jobId},
{status: Db.set(status), completed_at: Db.set(completedAt), error_message: Db.set(errorMessage)},
JOB_LEDGER_TTL_SECONDS,
),
);
await deleteOneOrMany(JobsActive.deleteByPk({job_id: jobId}));
@@ -127,27 +221,30 @@ export class JobLedgerRepository extends IJobLedgerRepository {
async reportProgress(jobId: bigint, current: number, total: number | null, message: string | null): Promise<void> {
await upsertOne(
JobsById.patchByPk(
JobsById.patchByPkWithTtl(
{job_id: jobId},
{
progress_current: Db.set(BigInt(current)),
progress_total: total === null ? Db.clear() : Db.set(BigInt(total)),
progress_message: message === null ? Db.clear() : Db.set(message),
},
JOB_LEDGER_TTL_SECONDS,
),
);
}
async setContextLink(jobId: bigint, link: string): Promise<void> {
await upsertOne(JobsById.patchByPk({job_id: jobId}, {context_link: Db.set(link)}));
await upsertOne(JobsById.patchByPkWithTtl({job_id: jobId}, {context_link: Db.set(link)}, JOB_LEDGER_TTL_SECONDS));
}
async setJetStreamSeq(jobId: bigint, seq: string): Promise<void> {
await upsertOne(JobsById.patchByPk({job_id: jobId}, {jet_stream_seq: Db.set(seq)}));
await upsertOne(JobsById.patchByPkWithTtl({job_id: jobId}, {jet_stream_seq: Db.set(seq)}, JOB_LEDGER_TTL_SECONDS));
}
async requestCancel(jobId: bigint): Promise<void> {
await upsertOne(JobsById.patchByPk({job_id: jobId}, {cancel_requested: Db.set(true)}));
await upsertOne(
JobsById.patchByPkWithTtl({job_id: jobId}, {cancel_requested: Db.set(true)}, JOB_LEDGER_TTL_SECONDS),
);
}
async isCancelRequested(jobId: bigint): Promise<boolean> {
@@ -160,7 +257,9 @@ export class JobLedgerRepository extends IJobLedgerRepository {
async incrementAttempts(jobId: bigint): Promise<void> {
const row = await this.getJob(jobId);
if (!row) return;
await upsertOne(JobsById.patchByPk({job_id: jobId}, {attempts: Db.set(row.attempts + 1)}));
await upsertOne(
JobsById.patchByPkWithTtl({job_id: jobId}, {attempts: Db.set(row.attempts + 1)}, JOB_LEDGER_TTL_SECONDS),
);
}
async listJobs(opts: {
@@ -169,59 +268,74 @@ export class JobLedgerRepository extends IJobLedgerRepository {
filters: ListJobsFilters;
maxLookbackDays: number;
}): Promise<ListJobsResult> {
const {limit, cursor, filters, maxLookbackDays} = opts;
const startBucket = cursor ? new Date(`${cursor.bucketDay}T00:00:00Z`) : new Date();
const hasFilters = Boolean(
filters.status ||
filters.taskType ||
(filters.requestedByUserId !== undefined && filters.requestedByUserId !== null),
);
const collected: Array<JobByIdRow> = [];
let nextCursor: ListJobsCursor | null = null;
for (let dayOffset = 0; dayOffset <= maxLookbackDays && collected.length < limit; dayOffset++) {
const bucketDate = new Date(startBucket);
bucketDate.setUTCDate(bucketDate.getUTCDate() - dayOffset);
const bucketDay = bucketDayFor(bucketDate);
const remaining = limit - collected.length + 1;
const bucketLimit = hasFilters ? {} : {limit: remaining};
const useCursor = dayOffset === 0 && cursor !== null;
let bucketRows: Array<JobByDayBucketRow>;
if (useCursor && cursor) {
const query = JobsByDayBucket.select({
where: [JobsByDayBucket.where.eq('bucket_day'), JobsByDayBucket.where.lt('created_at')],
...bucketLimit,
});
bucketRows = await fetchMany<JobByDayBucketRow>(
query.bind({bucket_day: bucketDay, created_at: cursor.createdAt}),
);
} else {
const query = JobsByDayBucket.select({
where: JobsByDayBucket.where.eq('bucket_day'),
...bucketLimit,
});
bucketRows = await fetchMany<JobByDayBucketRow>(query.bind({bucket_day: bucketDay}));
const {limit, cursor, filters} = opts;
const now = Date.now();
const lookbackDays = Math.min(opts.maxLookbackDays, JOB_LEDGER_RETENTION_DAYS);
const oldestDay = bucketDayFor(new Date(now - lookbackDays * ms('1 day')));
const requestedBy = filters.requestedByUserId ?? null;
const wholeDays = Boolean(filters.status || filters.taskType || requestedBy !== null);
const jobs: Array<JobByIdRow> = [];
let lastRow: JobByDayBucketRow | null = null;
let day = bucketDayFor(new Date(Math.min(cursor ? cursor.createdAt.getTime() : now, now)));
let after: LedgerPosition | null = cursor;
while (jobs.length < limit && day >= oldestDay) {
const {rows, exhausted} = await fetchDayAfter(day, after, wholeDays ? null : limit - jobs.length);
for (const row of rows) {
after = {createdAt: row.created_at, jobId: row.job_id};
if (filters.taskType && row.task_type !== filters.taskType) continue;
if (requestedBy !== null && row.requested_by_user_id !== requestedBy) continue;
const job = await this.getJob(row.job_id);
if (!job || (filters.status && job.status !== filters.status)) continue;
jobs.push(job);
lastRow = row;
if (jobs.length === limit) break;
}
for (const r of bucketRows) {
if (filters.taskType && r.task_type !== filters.taskType) continue;
if (filters.requestedByUserId !== undefined && filters.requestedByUserId !== null) {
if (r.requested_by_user_id !== filters.requestedByUserId) continue;
}
const fullRow = await this.getJob(r.job_id);
if (!fullRow) continue;
if (filters.status && fullRow.status !== filters.status) continue;
if (collected.length >= limit) {
nextCursor = {bucketDay, createdAt: r.created_at, jobId: r.job_id};
break;
}
collected.push(fullRow);
if (exhausted) {
day = previousBucketDay(day);
after = null;
}
if (nextCursor) break;
}
if (nextCursor === null && collected.length >= limit) {
const last = collected[collected.length - 1];
nextCursor = {bucketDay: bucketDayFor(last.created_at), createdAt: last.created_at, jobId: last.job_id};
}
return {jobs: collected, nextCursor};
return {
jobs,
nextCursor:
lastRow && jobs.length === limit
? {bucketDay: lastRow.bucket_day, createdAt: lastRow.created_at, jobId: lastRow.job_id}
: null,
};
}
async expireStaleActiveJobs(opts: {
staleBeforeMs: number;
pageSize: number;
maxCleared: number;
}): Promise<{cleared: number; expired: number; complete: boolean}> {
let cleared = 0;
let expired = 0;
let pageState: string | null = null;
do {
const page: PagedQueryResult<Pick<JobActiveRow, 'job_id'>> = await fetchPage(
ACTIVE_JOB_IDS_QUERY.bind({}),
undefined,
{
pageSize: opts.pageSize,
pageState,
},
);
for (const {job_id: jobId} of page.rows) {
if (snowflakeToDate(jobId).getTime() >= opts.staleBeforeMs) continue;
if (cleared >= opts.maxCleared) return {cleared, expired, complete: false};
const job = await this.getJob(jobId);
if (job?.status === 'queued' || job?.status === 'running') {
await this.markDeadletter(jobId, EXPIRED_JOB_ERROR);
expired += 1;
} else {
await deleteOneOrMany(JobsActive.deleteByPk({job_id: jobId}));
}
cleared += 1;
}
pageState = page.pageState;
} while (pageState !== null);
return {cleared, expired, complete: true};
}
async listActiveJobs(): Promise<Array<JobByIdRow>> {
@@ -0,0 +1,228 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {upsertOne} from '@app/api/database/CassandraQueryExecution';
import type {JobByIdRow, JobStatus} from '@app/api/database/types/JobLedgerTypes';
import type {ListJobsCursor, ListJobsFilters} from '@app/api/jobs/IJobLedgerRepository';
import {JobLedgerRepository} from '@app/api/jobs/JobLedgerRepository';
import {JobsByDayBucket, JobsById} from '@app/api/Tables';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const DAY_MS = 86_400_000;
const HOUR_MS = 3_600_000;
const TODAY_NOON = new Date(`${new Date().toISOString().slice(0, 10)}T12:00:00.000Z`);
const ADMIN_USER_ID = 4_242n;
interface ListedPage {
ids: Array<bigint>;
cursor: ListJobsCursor | null;
}
function at(daysAgo: number, hour: number): Date {
return new Date(TODAY_NOON.getTime() - daysAgo * DAY_MS + (hour - 12) * HOUR_MS);
}
async function seedJob(
jobId: bigint,
createdAt: Date,
opts: {taskType?: string; requestedBy?: bigint | null; status?: JobStatus; bucketOnly?: boolean} = {},
): Promise<void> {
const taskType = opts.taskType ?? 'A';
const requestedBy = opts.requestedBy ?? null;
await upsertOne(
JobsByDayBucket.insert({
bucket_day: createdAt.toISOString().slice(0, 10),
created_at: createdAt,
job_id: jobId,
task_type: taskType,
status: 'queued',
requested_by_user_id: requestedBy,
}),
);
if (opts.bucketOnly) return;
const row: JobByIdRow = {
job_id: jobId,
task_type: taskType,
status: opts.status ?? 'queued',
progress_current: null,
progress_total: null,
progress_message: null,
payload: '{}',
result: null,
error_message: null,
created_at: createdAt,
started_at: null,
completed_at: null,
requested_by_user_id: requestedBy,
audit_log_reason: null,
jet_stream_seq: null,
jet_stream_lane: 'batch',
attempts: 0,
max_attempts: 5,
run_at: null,
cancel_requested: false,
context_link: null,
};
await upsertOne(JobsById.insert(row));
}
async function listPages(opts: {
limit: number;
filters?: ListJobsFilters;
maxLookbackDays?: number;
cursor?: ListJobsCursor | null;
}): Promise<Array<ListedPage>> {
const repository = new JobLedgerRepository();
const pages: Array<ListedPage> = [];
let cursor = opts.cursor ?? null;
for (let page = 0; page < 50; page += 1) {
const result = await repository.listJobs({
limit: opts.limit,
cursor,
filters: opts.filters ?? {},
maxLookbackDays: opts.maxLookbackDays ?? 14,
});
pages.push({ids: result.jobs.map((job) => job.job_id), cursor: result.nextCursor});
if (result.nextCursor === null) return pages;
cursor = {
bucketDay: result.nextCursor.bucketDay,
createdAt: new Date(result.nextCursor.createdAt.toISOString()),
jobId: BigInt(result.nextCursor.jobId.toString()),
};
}
throw new Error('listJobs never stopped paging');
}
function expectPages(pages: Array<ListedPage>, limit: number, expected: Array<Array<bigint>>): void {
expect(pages.map((page) => page.ids)).toEqual(expected);
const ids = pages.flatMap((page) => page.ids);
expect(new Set(ids).size).toBe(ids.length);
for (const page of pages.slice(0, -1)) {
expect(page.ids).toHaveLength(limit);
expect(page.cursor?.jobId).toBe(page.ids.at(-1));
}
expect(pages.at(-1)?.cursor).toBeNull();
}
export function describeListJobsPaging(): void {
describe('listJobs paging', () => {
beforeEach(() => {
vi.useFakeTimers({toFake: ['Date']});
vi.setSystemTime(TODAY_NOON);
});
afterEach(() => {
vi.useRealTimers();
});
it('walks one day newest first through a tie group larger than the page', async () => {
await seedJob(20n, at(0, 11));
for (const jobId of [11n, 12n, 13n, 14n, 15n]) {
await seedJob(jobId, at(0, 10));
}
await seedJob(9n, at(0, 9));
await seedJob(5n, at(0, 8));
await seedJob(6n, at(0, 8));
const pages = await listPages({limit: 2});
expectPages(pages, 2, [[20n, 15n], [14n, 13n], [12n, 11n], [9n, 6n], [5n]]);
expect(pages[0]?.cursor).toEqual({
bucketDay: at(0, 10).toISOString().slice(0, 10),
createdAt: at(0, 10),
jobId: 15n,
});
});
it('crosses days and keeps the lookback window anchored on today', async () => {
await seedJob(41n, at(0, 11));
await seedJob(40n, at(0, 10));
await seedJob(32n, at(1, 11));
await seedJob(31n, at(1, 10));
await seedJob(30n, at(1, 9));
await seedJob(21n, at(2, 11));
await seedJob(20n, at(2, 10));
await seedJob(10n, at(3, 11));
expectPages(await listPages({limit: 2, maxLookbackDays: 2}), 2, [[41n, 40n], [32n, 31n], [30n, 21n], [20n]]);
expectPages(await listPages({limit: 2, maxLookbackDays: 3}), 2, [
[41n, 40n],
[32n, 31n],
[30n, 21n],
[20n, 10n],
[],
]);
const outside = await new JobLedgerRepository().listJobs({
limit: 2,
cursor: {bucketDay: at(3, 12).toISOString().slice(0, 10), createdAt: at(3, 12), jobId: 1n},
filters: {},
maxLookbackDays: 2,
});
expect(outside).toEqual({jobs: [], nextCursor: null});
});
it('starts a cursor dated in the future at today', async () => {
await seedJob(2n, at(0, 2));
await seedJob(1n, at(1, 2));
const farFuture = new Date('9999-12-31T00:00:00.000Z');
expectPages(await listPages({limit: 5, cursor: {bucketDay: '9999-12-31', createdAt: farFuture, jobId: 1n}}), 5, [
[2n, 1n],
]);
}, 2_000);
it('never lists a day past the 90-day retention', async () => {
await seedJob(890n, at(89, 11));
await seedJob(910n, at(91, 11));
expectPages(await listPages({limit: 10, maxLookbackDays: 120}), 10, [[890n]]);
});
it('fills a page past rows whose job record is missing instead of leaving the day', async () => {
for (let hour = 1; hour <= 8; hour += 1) {
await seedJob(BigInt(hour), at(0, hour), {bucketOnly: [3, 6, 7].includes(hour)});
}
expectPages(await listPages({limit: 3}), 3, [
[8n, 5n, 4n],
[2n, 1n],
]);
});
it('fills pages through task type, requester and status filters across days', async () => {
await seedJob(60n, at(0, 11));
await seedJob(59n, at(0, 10.5), {taskType: 'B'});
await seedJob(58n, at(0, 10));
await seedJob(57n, at(0, 9.5), {taskType: 'B', requestedBy: ADMIN_USER_ID});
await seedJob(56n, at(0, 9), {status: 'succeeded'});
await seedJob(55n, at(0, 8.5), {taskType: 'B'});
await seedJob(50n, at(1, 11));
await seedJob(49n, at(1, 10.5), {taskType: 'B', requestedBy: ADMIN_USER_ID});
await seedJob(48n, at(1, 10), {bucketOnly: true});
await seedJob(47n, at(1, 9.5));
expectPages(await listPages({limit: 2, filters: {taskType: 'A'}}), 2, [[60n, 58n], [56n, 50n], [47n]]);
expectPages(await listPages({limit: 1, filters: {status: 'succeeded'}}), 1, [[56n], []]);
expectPages(await listPages({limit: 5, filters: {requestedByUserId: ADMIN_USER_ID}}), 5, [[57n, 49n]]);
});
it('resumes after a cursor whose job was discarded between pages', async () => {
for (let hour = 1; hour <= 5; hour += 1) {
await seedJob(BigInt(hour), at(0, hour));
}
const repository = new JobLedgerRepository();
const first = await repository.listJobs({limit: 2, cursor: null, filters: {}, maxLookbackDays: 14});
expect(first.jobs.map((job) => job.job_id)).toEqual([5n, 4n]);
await repository.discardJob(4n, at(0, 4));
expectPages(await listPages({limit: 2, cursor: first.nextCursor}), 2, [[3n, 2n], [1n]]);
});
it('returns an empty page after a page that filled exactly', async () => {
await seedJob(1n, at(0, 1));
await seedJob(2n, at(0, 2));
expectPages(await listPages({limit: 2}), 2, [[2n, 1n], []]);
});
});
}
@@ -0,0 +1,600 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {spawnSync} from 'node:child_process';
import {createServer} from 'node:net';
import {BatchBuilder, setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {Db} from '@app/api/database/CassandraTypes';
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
import type {JobActiveRow, JobByDayBucketRow, JobByIdRow, JobStatus} from '@app/api/database/types/JobLedgerTypes';
import {
EXPIRED_JOB_ERROR,
JOB_LEDGER_TTL_SECONDS,
JOB_STALE_AFTER_MS,
JobLedgerRepository,
} from '@app/api/jobs/JobLedgerRepository';
import {describeListJobsPaging} from '@app/api/jobs/ListJobsPagingSuite';
import {expireLegacyJobLedgerRows} from '@app/api/jobs/PostgresJobLedgerExpiry';
import {JobsActive, JobsByDayBucket, JobsById} from '@app/api/Tables';
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {createSnowflake} from '@fluxer/snowflake/src/Snowflake';
import {
getDefaultPostgresClient,
type IPostgresClient,
initPostgres,
shutdownPostgres,
} from '@pkgs/postgres/src/Client';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const KV_TABLE = 'kv_job_ledger_expiry';
const CONTAINER = `fluxer-kvjobs-${process.pid.toString(36)}-${Date.now().toString(36)}`;
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
const DAY_MS = 86_400_000;
const ADMIN_USER_ID = 1_234_567_890_123n;
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (typeof address === 'string' || address === null) {
reject(new Error('no port'));
return;
}
const port = address.port;
server.close(() => resolve(port));
});
});
}
let sequence = 0;
function jobIdAgedDays(days: number): bigint {
sequence += 1;
return createSnowflake({timestamp: Date.now() - days * DAY_MS, sequence: sequence % 4096, workerId: 1});
}
interface LegacyJob {
jobId: bigint;
createdAt: Date;
}
async function seedLegacyJob(
executor: PostgresKvQueryExecutor,
opts: {ageDays: number; status: JobStatus; requestedBy: bigint | null; active: boolean},
): Promise<LegacyJob> {
const jobId = jobIdAgedDays(opts.ageDays);
const createdAt = new Date(Date.now() - opts.ageDays * DAY_MS);
const idRow: JobByIdRow = {
job_id: jobId,
task_type: opts.requestedBy === null ? 'flushUserActivityBuffer' : 'refreshSearchIndex',
status: opts.status,
progress_current: null,
progress_total: null,
progress_message: null,
payload: '{}',
result: null,
error_message: null,
created_at: createdAt,
started_at: null,
completed_at: opts.status === 'succeeded' ? createdAt : null,
requested_by_user_id: opts.requestedBy,
audit_log_reason: null,
jet_stream_seq: '1',
jet_stream_lane: 'batch',
attempts: 0,
max_attempts: 5,
run_at: null,
cancel_requested: false,
context_link: null,
};
const bucketRow: JobByDayBucketRow = {
bucket_day: createdAt.toISOString().slice(0, 10),
created_at: createdAt,
job_id: jobId,
task_type: idRow.task_type,
status: 'queued',
requested_by_user_id: opts.requestedBy,
};
await executor.executeQuery(JobsById.insert(idRow));
await executor.executeQuery(JobsByDayBucket.insert(bucketRow));
if (opts.active) {
const activeRow: JobActiveRow = {
job_id: jobId,
task_type: idRow.task_type,
status: opts.status,
requested_by_user_id: opts.requestedBy,
created_at: createdAt,
started_at: null,
};
await executor.executeQuery(JobsActive.insert(activeRow));
}
return {jobId, createdAt};
}
describe.skipIf(!dockerAvailable)('job ledger expiry against postgres', () => {
let raw: IPostgresClient;
let executor: PostgresKvQueryExecutor;
async function jobRows(
jobId: bigint,
): Promise<Array<{table_name: string; expires_at: Date | null; row_data: never}>> {
const result = await raw.query<{table_name: string; expires_at: Date | null; row_data: never}>(
`SELECT table_name, expires_at, row_data FROM ${KV_TABLE}
WHERE table_name IN ('jobs_by_id', 'jobs_active', 'jobs_by_day_bucket')
AND (row_key = $1 OR split_part(row_key, chr(31), 3) = $1)
ORDER BY table_name`,
[JSON.stringify({__fluxer_type: 'bigint', value: jobId.toString()})],
);
return result.rows;
}
async function waitForLockWait(): Promise<void> {
for (let attempt = 0; attempt < 400; attempt += 1) {
const waiting = await raw.query<{n: number}>(
`SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock'`,
);
if (waiting.rows[0]!.n > 0) return;
await sleep(25);
}
throw new Error('the pass never waited on the writer');
}
async function forgetLedgerExpiry(): Promise<void> {
await raw.query(
`UPDATE ${KV_TABLE} SET expires_at = NULL WHERE table_name IN ('jobs_by_id', 'jobs_by_day_bucket')`,
);
}
async function forgetExpiryOf(...jobIds: Array<bigint>): Promise<void> {
const keys = jobIds.map((jobId) => JSON.stringify({__fluxer_type: 'bigint', value: jobId.toString()}));
await raw.query(
`UPDATE ${KV_TABLE} SET expires_at = NULL
WHERE table_name IN ('jobs_by_id', 'jobs_by_day_bucket')
AND (row_key = ANY($1::text[]) OR split_part(row_key, chr(31), 3) = ANY($1::text[]))`,
[keys],
);
}
beforeAll(async () => {
const port = await freePort();
startDockerContainer([
'run',
'-d',
'--name',
CONTAINER,
'-e',
'POSTGRES_USER=fluxer',
'-e',
'POSTGRES_PASSWORD=fluxer',
'-e',
'POSTGRES_DB=fluxer',
'-p',
`127.0.0.1:${port}:5432`,
'postgres:16-alpine',
'-c',
'fsync=off',
]);
let ready = false;
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
await sleep(500);
const probe = spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
stdio: 'ignore',
});
if (probe.status !== 0) continue;
try {
await initPostgres({
url: `postgres://fluxer:[email protected]:${port}/fluxer`,
maxConnections: 4,
kvTable: KV_TABLE,
});
await getDefaultPostgresClient().query('SELECT 1');
ready = true;
} catch {
await shutdownPostgres().catch(() => {});
}
}
if (!ready) throw new Error('postgres never came up');
raw = getDefaultPostgresClient();
await ensurePostgresKvSchema(raw);
executor = new PostgresKvQueryExecutor(raw);
}, 900_000);
beforeEach(async () => {
await raw.query(`DELETE FROM ${KV_TABLE}`);
setCassandraQueryExecutorForTesting(executor);
});
afterAll(async () => {
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
await shutdownPostgres().catch(() => {});
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
});
it('writes every ledger row with an expiry and keeps it through the job lifecycle', async () => {
const repository = new JobLedgerRepository();
const jobId = jobIdAgedDays(0);
await repository.createJob({
jobId,
taskType: 'refreshSearchIndex',
payload: {},
requestedByUserId: ADMIN_USER_ID,
auditLogReason: null,
maxAttempts: 5,
runAt: null,
jetStreamLane: 'batch',
jetStreamSeq: null,
});
await repository.setJetStreamSeq(jobId, '7');
await repository.markRunning(jobId, 'batch');
await repository.reportProgress(jobId, 1, 2, 'half');
const running = await jobRows(jobId);
expect(running.map((row) => row.table_name)).toEqual(['jobs_active', 'jobs_by_day_bucket', 'jobs_by_id']);
for (const row of running) {
expect(row.expires_at).not.toBeNull();
const remainingSeconds = (row.expires_at!.getTime() - Date.now()) / 1000;
expect(remainingSeconds).toBeGreaterThan(JOB_LEDGER_TTL_SECONDS - 60);
expect(remainingSeconds).toBeLessThanOrEqual(JOB_LEDGER_TTL_SECONDS);
}
await repository.markSucceeded(jobId, {ok: true});
const done = await jobRows(jobId);
expect(done.map((row) => row.table_name)).toEqual(['jobs_by_day_bucket', 'jobs_by_id']);
expect(done.every((row) => row.expires_at !== null)).toBe(true);
expect((await repository.getJob(jobId))?.status).toBe('succeeded');
});
it('never leaves a row without an expiry when a patch lands on a job that is gone', async () => {
const repository = new JobLedgerRepository();
const patches: Array<(jobId: bigint) => Promise<void>> = [
(jobId) => repository.markRunning(jobId, 'batch'),
(jobId) => repository.markSucceeded(jobId, null),
(jobId) => repository.markCancelled(jobId),
(jobId) => repository.markDeadletter(jobId, 'boom'),
(jobId) => repository.reportProgress(jobId, 1, null, null),
(jobId) => repository.setContextLink(jobId, '/admin/jobs'),
(jobId) => repository.setJetStreamSeq(jobId, '1'),
(jobId) => repository.requestCancel(jobId),
];
for (const patch of patches) {
const jobId = jobIdAgedDays(0);
await patch(jobId);
const rows = await jobRows(jobId);
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((row) => row.expires_at !== null)).toBe(true);
expect(await repository.getJob(jobId)).toBeNull();
}
expect(await repository.listActiveJobs()).toEqual([]);
});
it('discards every row of a job that never reached the stream', async () => {
const repository = new JobLedgerRepository();
const jobId = jobIdAgedDays(0);
const createdAt = await repository.createJob({
jobId,
taskType: 'batchGuildAuditLogMessageDeletes',
payload: {guildId: '1'},
requestedByUserId: null,
auditLogReason: null,
maxAttempts: 3,
runAt: new Date(Date.now() + 30_000),
jetStreamLane: 'batch',
jetStreamSeq: null,
});
expect(await jobRows(jobId)).toHaveLength(3);
await repository.discardJob(jobId, createdAt);
expect(await jobRows(jobId)).toEqual([]);
});
it('clears legacy rows by the same rules the expiry now enforces', async () => {
const repository = new JobLedgerRepository();
const cronDone = await seedLegacyJob(executor, {
ageDays: 20,
status: 'succeeded',
requestedBy: null,
active: false,
});
const cronStuck = await seedLegacyJob(executor, {ageDays: 20, status: 'queued', requestedBy: null, active: true});
const partialId = jobIdAgedDays(20);
await executor.executeQuery(
JobsById.patchByPk({job_id: partialId}, {status: Db.set('succeeded'), completed_at: Db.set(new Date())}),
);
const adminDone = await seedLegacyJob(executor, {
ageDays: 20,
status: 'succeeded',
requestedBy: ADMIN_USER_ID,
active: false,
});
const adminStuck = await seedLegacyJob(executor, {
ageDays: 20,
status: 'queued',
requestedBy: ADMIN_USER_ID,
active: true,
});
const adminRunning = await seedLegacyJob(executor, {
ageDays: 20,
status: 'running',
requestedBy: ADMIN_USER_ID,
active: true,
});
const adminAncient = await seedLegacyJob(executor, {
ageDays: 100,
status: 'succeeded',
requestedBy: ADMIN_USER_ID,
active: false,
});
const cronInFlight = await seedLegacyJob(executor, {
ageDays: 2,
status: 'queued',
requestedBy: null,
active: true,
});
await forgetLedgerExpiry();
const fresh = jobIdAgedDays(0);
await repository.createJob({
jobId: fresh,
taskType: 'syncUrlBlocklists',
payload: {},
requestedByUserId: null,
auditLogReason: null,
maxAttempts: 5,
runAt: null,
jetStreamLane: 'batch',
jetStreamSeq: null,
});
const freshBefore = await jobRows(fresh);
await raw.query(
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data) VALUES ('users', 'u1', 'u1', '{}'::jsonb)`,
);
expect(await repository.getJob(partialId)).toBeNull();
const first = await expireLegacyJobLedgerRows(raw, Date.now() + 60_000);
expect(first).toEqual({deleted: 10, expiring: 6, complete: true});
expect(await jobRows(cronDone.jobId)).toEqual([]);
expect(await jobRows(cronStuck.jobId)).toEqual([]);
expect(await jobRows(partialId)).toEqual([]);
expect(await jobRows(adminAncient.jobId)).toEqual([]);
for (const kept of [adminDone, adminStuck, adminRunning]) {
const rows = await jobRows(kept.jobId);
expect(rows.map((row) => row.table_name)).toEqual(['jobs_by_day_bucket', 'jobs_by_id']);
for (const row of rows) {
const expected = kept.createdAt.getTime() + JOB_LEDGER_TTL_SECONDS * 1000;
expect(Math.abs(row.expires_at!.getTime() - expected)).toBeLessThan(2000);
}
}
expect((await repository.getJob(adminDone.jobId))?.status).toBe('succeeded');
for (const stuck of [adminStuck, adminRunning]) {
const expired = await repository.getJob(stuck.jobId);
expect(expired?.status).toBe('deadletter');
expect(expired?.error_message).toBe('Expired from the job queue');
expect(expired?.completed_at).toBeInstanceOf(Date);
}
const inFlight = await jobRows(cronInFlight.jobId);
expect(inFlight.map((row) => row.table_name)).toEqual(['jobs_active', 'jobs_by_day_bucket', 'jobs_by_id']);
expect(inFlight.every((row) => row.expires_at === null)).toBe(true);
expect((await repository.getJob(cronInFlight.jobId))?.status).toBe('queued');
expect(await jobRows(fresh)).toEqual(freshBefore);
const users = await raw.query(`SELECT expires_at FROM ${KV_TABLE} WHERE table_name = 'users'`);
expect(users.rows).toEqual([{expires_at: null}]);
const listed = await repository.listJobs({limit: 50, cursor: null, filters: {}, maxLookbackDays: 30});
expect(listed.jobs.map((job) => job.job_id).sort()).toEqual(
[adminDone.jobId, adminStuck.jobId, adminRunning.jobId, cronInFlight.jobId, fresh].sort(),
);
for (let pass = 0; pass < 2; pass += 1) {
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
expiring: 0,
complete: true,
});
}
});
it('runs again a day after a clean pass and clears rows an older image wrote in between', async () => {
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
expiring: 0,
complete: true,
});
const rolledBack = await seedLegacyJob(executor, {ageDays: 30, status: 'queued', requestedBy: null, active: true});
await forgetLedgerExpiry();
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toBeNull();
await raw.query(
`UPDATE ${KV_TABLE} SET row_data = jsonb_build_object('applied_at', now() - interval '2 days') WHERE row_key = 'job_ledger_expiry_v1'`,
);
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toEqual({
deleted: 3,
expiring: 0,
complete: true,
});
expect(await jobRows(rolledBack.jobId)).toEqual([]);
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
expiring: 0,
complete: true,
});
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toBeNull();
});
it('expires stale active jobs page by page without fighting the legacy pass', async () => {
const repository = new JobLedgerRepository();
const legacyFirst = await seedLegacyJob(executor, {
ageDays: 20,
status: 'queued',
requestedBy: ADMIN_USER_ID,
active: true,
});
await forgetExpiryOf(legacyFirst.jobId);
await expireLegacyJobLedgerRows(raw, Date.now() + 60_000);
const legacyFirstRows = await jobRows(legacyFirst.jobId);
const stale: Array<bigint> = [];
for (let index = 0; index < 4; index += 1) {
const jobId = jobIdAgedDays(9);
await repository.createJob({
jobId,
taskType: 'syncUrlBlocklists',
payload: {},
requestedByUserId: null,
auditLogReason: null,
maxAttempts: 5,
runAt: null,
jetStreamLane: 'batch',
jetStreamSeq: null,
});
stale.push(jobId);
}
await repository.markRunning(stale[0]!, 'batch');
const fresh = jobIdAgedDays(0);
await repository.createJob({
jobId: fresh,
taskType: 'syncUrlBlocklists',
payload: {},
requestedByUserId: null,
auditLogReason: null,
maxAttempts: 5,
runAt: null,
jetStreamLane: 'batch',
jetStreamSeq: null,
});
const legacyStale = await seedLegacyJob(executor, {
ageDays: 20,
status: 'running',
requestedBy: ADMIN_USER_ID,
active: true,
});
const legacyYoung = await seedLegacyJob(executor, {ageDays: 2, status: 'queued', requestedBy: null, active: true});
await forgetExpiryOf(legacyStale.jobId, legacyYoung.jobId);
const orphan = jobIdAgedDays(9);
await executor.executeQuery(
JobsActive.patchByPkWithTtl({job_id: orphan}, {status: Db.set('running')}, JOB_LEDGER_TTL_SECONDS),
);
const sweep = () =>
repository.expireStaleActiveJobs({staleBeforeMs: Date.now() - JOB_STALE_AFTER_MS, pageSize: 2, maxCleared: 100});
expect(await sweep()).toEqual({cleared: 6, expired: 5, complete: true});
const active = (await repository.listActiveJobs()).map((job) => job.job_id).sort();
expect(active).toEqual([fresh, legacyYoung.jobId].sort());
for (const jobId of [...stale, legacyStale.jobId]) {
const job = await repository.getJob(jobId);
expect(job?.status).toBe('deadletter');
expect(job?.error_message).toBe(EXPIRED_JOB_ERROR);
const byId = (await jobRows(jobId)).find((row) => row.table_name === 'jobs_by_id');
expect(Math.abs(byId!.expires_at!.getTime() - (Date.now() + JOB_LEDGER_TTL_SECONDS * 1000))).toBeLessThan(60_000);
}
expect(await jobRows(orphan)).toEqual([]);
expect(await jobRows(legacyFirst.jobId)).toEqual(legacyFirstRows);
const legacyStaleById = (await jobRows(legacyStale.jobId)).find((row) => row.table_name === 'jobs_by_id');
await expireLegacyJobLedgerRows(raw, Date.now() + 60_000);
expect((await jobRows(legacyStale.jobId)).find((row) => row.table_name === 'jobs_by_id')).toEqual(legacyStaleById);
expect((await repository.listActiveJobs()).map((job) => job.job_id)).toContain(legacyYoung.jobId);
expect(await sweep()).toEqual({cleared: 0, expired: 0, complete: true});
});
it('leaves rows alone when a live writer gives them an expiry while the pass waits on them', async () => {
const cronDone = await seedLegacyJob(executor, {
ageDays: 20,
status: 'succeeded',
requestedBy: null,
active: false,
});
const adminDone = await seedLegacyJob(executor, {
ageDays: 20,
status: 'succeeded',
requestedBy: ADMIN_USER_ID,
active: false,
});
await forgetLedgerExpiry();
const liveKeys = [cronDone.jobId, adminDone.jobId].map((jobId) =>
JSON.stringify({__fluxer_type: 'bigint', value: jobId.toString()}),
);
let written!: () => void;
const writerHoldsRows = new Promise<void>((resolve) => {
written = resolve;
});
let release!: () => void;
const released = new Promise<void>((resolve) => {
release = resolve;
});
const writer = raw.transaction(async (db) => {
await db.query(
`UPDATE ${KV_TABLE} SET expires_at = now() + interval '1 hour', updated_at = now() WHERE table_name = 'jobs_by_id' AND row_key = ANY($1::text[])`,
[liveKeys],
);
written();
await released;
});
await writerHoldsRows;
const pass = expireLegacyJobLedgerRows(raw, Date.now() + 60_000);
await waitForLockWait();
release();
await writer;
expect(await pass).toEqual({deleted: 1, expiring: 1, complete: true});
for (const job of [cronDone, adminDone]) {
const byId = (await jobRows(job.jobId)).find((row) => row.table_name === 'jobs_by_id');
expect(byId).toBeDefined();
const remainingSeconds = (byId!.expires_at!.getTime() - Date.now()) / 1000;
expect(remainingSeconds).toBeGreaterThan(3000);
expect(remainingSeconds).toBeLessThanOrEqual(3660);
}
expect((await new JobLedgerRepository().getJob(adminDone.jobId))?.status).toBe('succeeded');
});
it('pages through more legacy rows than one page holds and stops at its deadline', async () => {
const batch = new BatchBuilder();
for (let index = 0; index < 2300; index += 1) {
const jobId = jobIdAgedDays(30);
const createdAt = new Date(Date.now() - 30 * DAY_MS);
batch.addPrepared(
JobsByDayBucket.insert({
bucket_day: createdAt.toISOString().slice(0, 10),
created_at: createdAt,
job_id: jobId,
task_type: 'flushUserActivityBuffer',
status: 'queued',
requested_by_user_id: null,
}),
);
}
await batch.executeChunked(500, false);
await forgetLedgerExpiry();
expect(await expireLegacyJobLedgerRows(raw, Date.now() - 1)).toEqual({
deleted: 0,
expiring: 0,
complete: false,
});
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toEqual({
deleted: 2300,
expiring: 0,
complete: true,
});
const left = await raw.query(`SELECT count(*)::int AS n FROM ${KV_TABLE} WHERE table_name = 'jobs_by_day_bucket'`);
expect(left.rows[0]).toEqual({n: 0});
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
expiring: 0,
complete: true,
});
expect(await expireLegacyJobLedgerRows(raw, Date.now() + 60_000)).toBeNull();
});
describeListJobsPaging();
});
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {postgresKvPassIsFresh, recordPostgresKvCleanPass} from '@app/api/database/PostgresKvQueryExecutor';
import {EXPIRED_JOB_ERROR, JOB_LEDGER_TTL_SECONDS, JOB_STALE_AFTER_MS} from '@app/api/jobs/JobLedgerRepository';
import {FLUXER_EPOCH} from '@fluxer/constants/src/Core';
import {TIMESTAMP_SHIFT} from '@fluxer/snowflake/src/Snowflake';
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
import {ms} from 'itty-time';
const LEGACY_JOB_LEDGER_MARKER = 'job_ledger_expiry_v1';
const PAGE_SIZE = 2000;
const CLEAN_PASS_INTERVAL_MS = ms('1 day');
const BIGINT_KEY_PREFIX = '{"__fluxer_type":"bigint","value":"';
const BIGINT_KEY_SUFFIX = '"}';
function bigintKeyExpr(key: string): string {
const prefixLength = BIGINT_KEY_PREFIX.length;
const affixLength = prefixLength + BIGINT_KEY_SUFFIX.length;
return `(CASE WHEN left(${key}, ${prefixLength}) = '${BIGINT_KEY_PREFIX}' AND right(${key}, ${BIGINT_KEY_SUFFIX.length}) = '${BIGINT_KEY_SUFFIX}' THEN substr(${key}, ${prefixLength + 1}, length(${key}) - ${affixLength})::numeric END)`;
}
interface LedgerTable {
name: string;
jobIdExpr: string;
deleteStale: boolean;
markStaleDeadletter: boolean;
}
const LEDGER_TABLES: ReadonlyArray<LedgerTable> = [
{
name: 'jobs_active',
jobIdExpr: bigintKeyExpr('kv.row_key'),
deleteStale: true,
markStaleDeadletter: false,
},
{
name: 'jobs_by_id',
jobIdExpr: bigintKeyExpr('kv.row_key'),
deleteStale: false,
markStaleDeadletter: true,
},
{
name: 'jobs_by_day_bucket',
jobIdExpr: bigintKeyExpr('split_part(kv.row_key, chr(31), 3)'),
deleteStale: false,
markStaleDeadletter: false,
},
];
export interface LegacyJobLedgerExpiryResult {
deleted: number;
expiring: number;
complete: boolean;
}
function pageSql(table: string, target: LedgerTable): string {
const staleRemovable = target.deleteStale ? 'c.created_at < $7' : '(c.created_at < $7 AND c.system_job)';
const removable = `c.job_id IS NULL OR c.created_at < $6 OR ${staleRemovable}`;
const rowData = target.markStaleDeadletter
? `CASE WHEN c.unfinished THEN kv.row_data || jsonb_build_object('status', 'deadletter', 'error_message', $9::text, 'completed_at', jsonb_build_object('__fluxer_type', 'date', 'value', $10::text)) ELSE kv.row_data END`
: 'kv.row_data';
return `
WITH page AS (
SELECT kv.row_key, kv.row_data, ${target.jobIdExpr} AS job_id
FROM ${table} kv
WHERE kv.table_name = $1 AND kv.expires_at IS NULL AND kv.row_key > $2
ORDER BY kv.row_key
LIMIT $3
), classified AS (
SELECT
page.row_key,
page.job_id,
to_timestamp(((div(page.job_id, $4::numeric) + $5::numeric) / 1000)::double precision) AS created_at,
COALESCE(page.row_data -> 'requested_by_user_id', 'null'::jsonb) = 'null'::jsonb AS system_job,
COALESCE(page.row_data ->> 'status' IN ('queued', 'running'), false) AS unfinished
FROM page
), removed AS (
DELETE FROM ${table} kv
USING classified c
WHERE kv.table_name = $1 AND kv.row_key = c.row_key AND kv.expires_at IS NULL AND (${removable})
RETURNING kv.row_key
), expiring AS (
UPDATE ${table} kv
SET expires_at = c.created_at + make_interval(secs => $8::double precision), updated_at = now(), row_data = ${rowData}
FROM classified c
WHERE kv.table_name = $1 AND kv.row_key = c.row_key AND kv.expires_at IS NULL AND c.created_at < $7 AND NOT (${removable})
RETURNING kv.row_key
)
SELECT
(SELECT max(row_key) FROM page) AS last_row_key,
(SELECT count(*) FROM page) AS scanned,
(SELECT count(*) FROM removed) AS deleted,
(SELECT count(*) FROM expiring) AS expiring`;
}
export async function expireLegacyJobLedgerRows(
client: IPostgresClient,
deadlineMs: number,
): Promise<LegacyJobLedgerExpiryResult | null> {
const table = quoteIdentifier(client.kvTable());
if (await postgresKvPassIsFresh(client, LEGACY_JOB_LEDGER_MARKER, CLEAN_PASS_INTERVAL_MS)) {
return null;
}
const now = Date.now();
const retentionCutoff = new Date(now - JOB_LEDGER_TTL_SECONDS * 1000);
const staleCutoff = new Date(now - JOB_STALE_AFTER_MS);
const completedAt = new Date(now).toISOString();
let scanned = 0;
let deleted = 0;
let expiring = 0;
for (const target of LEDGER_TABLES) {
const sql = pageSql(table, target);
const deadletterValues = target.markStaleDeadletter ? [EXPIRED_JOB_ERROR, completedAt] : [];
let cursor = '';
for (;;) {
if (Date.now() >= deadlineMs) {
return {deleted, expiring, complete: false};
}
const result = await client.query<{
last_row_key: string | null;
scanned: string;
deleted: string;
expiring: string;
}>(sql, [
target.name,
cursor,
PAGE_SIZE,
(1n << TIMESTAMP_SHIFT).toString(),
FLUXER_EPOCH.toString(),
retentionCutoff,
staleCutoff,
JOB_LEDGER_TTL_SECONDS,
...deadletterValues,
]);
const page = result.rows[0];
if (!page || page.last_row_key === null) {
break;
}
scanned += Number(page.scanned);
deleted += Number(page.deleted);
expiring += Number(page.expiring);
cursor = page.last_row_key;
}
}
if (scanned === 0) {
await recordPostgresKvCleanPass(client, LEGACY_JOB_LEDGER_MARKER);
}
return {deleted, expiring, complete: true};
}
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminRepository} from '@app/api/admin/AdminRepository';
import {BANNED_FILE_SHAS_REFRESH_CHANNEL} from '@app/api/constants/ContentModeration';
import {Config} from '@app/api/Config';
import {BANNED_FILE_SHAS_REFRESH_CHANNEL, isBlocklistFeedFileSha} from '@app/api/constants/ContentModeration';
import {Logger} from '@app/api/Logger';
import {RefreshSubscription} from '@app/api/utils/RefreshSubscription';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
@@ -38,8 +39,11 @@ class FileShaCache {
async refresh(): Promise<void> {
const rows = await this.adminRepository.loadAllBannedFileShas();
const next = new Set<string>();
const includeFeedRows = Config.blocklistFeeds.enabled;
for (const row of rows) {
if (row.sha256_hex) next.add(row.sha256_hex.toLowerCase());
if (!row.sha256_hex) continue;
if (!includeFeedRows && isBlocklistFeedFileSha(row)) continue;
next.add(row.sha256_hex.toLowerCase());
}
this.banned = next;
this.consecutiveFailures = 0;
@@ -51,6 +51,7 @@ import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClie
import {VirusScanService} from '@app/api/infrastructure/VirusScanService';
import {GatewayRolloutConfigPublisher} from '@app/api/instance/GatewayRolloutConfigPublisher';
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
import {InviteRepository} from '@app/api/invite/InviteRepository';
import {Logger} from '@app/api/Logger';
import {LimitConfigService} from '@app/api/limits/LimitConfigService';
@@ -155,6 +156,18 @@ export const getGatewayRolloutConfigPublisher = singleton(
}),
),
);
export const getPushServiceDeliveryConfigPublisher = singleton(
() =>
new PushServiceDeliveryConfigPublisher(
new NatsConnectionManager({
url: Config.nats.coreUrl,
token: Config.nats.authToken || undefined,
name: 'fluxer-api-push-service-delivery-config',
}),
),
);
export const getVisionarySlotRepository = singleton(() => new VisionarySlotRepository());
export const getCacheService: () => ICacheService = singleton(() => new KVCacheProvider({client: getKVClient()}));
export const getRateLimitService = singleton(() => new RateLimitService(getKVClient()));
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminRepository} from '@app/api/admin/AdminRepository';
import {Config} from '@app/api/Config';
import {BANNED_URL_DOMAINS_REFRESH_CHANNEL, BANNED_URLS_REFRESH_CHANNEL} from '@app/api/constants/ContentModeration';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {Logger} from '@app/api/Logger';
@@ -68,7 +69,7 @@ class UrlBlocklistCache {
}
private async loadFeedUrls(): Promise<Set<string>> {
if (!this.storageService) return new Set();
if (!this.storageService || !Config.blocklistFeeds.enabled) return new Set();
const lines = await readLinesFromS3(this.storageService, RISK_S3_KEYS.feedUrls);
return new Set(lines);
}
+11 -5
View File
@@ -17030,7 +17030,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Registers a mobile push device token for APNs, Firebase Cloud Messaging, or UnifiedPush. UnifiedPush registrations include the endpoint URL plus Web Push encryption keys.",
"description": "Registers a mobile push device for APNs, Firebase Cloud Messaging, or UnifiedPush. A Web Push registration sends the endpoint URL with encryption_key and auth_secret. A raw registration sends the platform push token with no keys.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": true,
@@ -22744,7 +22744,10 @@
"enum": ["android_fcm", "ios_apns", "android_unified_push"],
"type": "string"
},
"token": {"description": "The platform-specific push notification token to unregister", "type": "string"},
"token": {
"description": "The Web Push endpoint URL or raw platform push token used at registration",
"type": "string"
},
"app_id": {
"description": "Client app channel or bundle mapping identifier, such as stable, beta, or canary",
"type": "string"
@@ -22808,7 +22811,10 @@
"enum": ["android_fcm", "ios_apns", "android_unified_push"],
"type": "string"
},
"token": {"description": "The platform-specific push notification token or endpoint URL", "type": "string"},
"token": {
"description": "The Web Push endpoint URL when encryption keys are supplied, otherwise the raw platform push token",
"type": "string"
},
"user_agent": {"description": "The user agent string identifying the device", "type": "string"},
"app_id": {
"description": "Client app channel or bundle mapping identifier, such as stable, beta, or canary",
@@ -22825,11 +22831,11 @@
"type": "string"
},
"encryption_key": {
"description": "The P-256 ECDH public key for UnifiedPush encryption (base64url)",
"description": "The P-256 ECDH public key for Web Push encryption (base64url)",
"type": "string"
},
"auth_secret": {
"description": "The authentication secret for UnifiedPush encryption (base64url)",
"description": "The authentication secret for Web Push encryption (base64url)",
"type": "string"
}
},
@@ -47,3 +47,7 @@ export async function readLinesFromS3(storage: IStorageService, key: string): Pr
return [];
}
}
export async function deleteRiskS3Object(storage: IStorageService, key: string): Promise<void> {
await storage.deleteObject(RISK_S3_BUCKET, key);
}
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IpInfoRequestAuditEvent} from '@pkgs/geoip/src/IpInfoService';
import {
createPostgresIpInfoCache,
createPostgresIpInfoRequestAuditLogger,
IPINFO_CACHE_TTL_SECONDS,
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
} from '@pkgs/geoip/src/PostgresIpInfoKv';
import type {IPostgresClient} from '@pkgs/postgres/src/Client';
import {describe, expect, it} from 'vitest';
function recordingClient(writes: Array<Array<unknown>>): IPostgresClient {
return {
async query(_text: string, values?: Array<unknown>) {
writes.push(values ?? []);
return {rows: [], rowCount: 1};
},
kvTable() {
return 'kv';
},
} as never;
}
function expectExpiresIn(values: Array<unknown> | undefined, ttlSeconds: number): void {
const expiresAt = values?.[4];
expect(expiresAt).toBeInstanceOf(Date);
const remainingSeconds = ((expiresAt as Date).getTime() - Date.now()) / 1000;
expect(remainingSeconds).toBeGreaterThan(ttlSeconds - 10);
expect(remainingSeconds).toBeLessThanOrEqual(ttlSeconds);
}
const EVENT: IpInfoRequestAuditEvent = {
requestedAt: new Date('2026-09-21T12:00:00.000Z'),
ip: '192.0.2.1',
cacheKey: 'ip:192.0.2.1',
source: 'test',
reason: null,
outcome: 'http_success',
httpStatus: 200,
available: true,
riskNote: 'none',
latencyMs: 12,
requestUrl: 'https://ipinfo.test/192.0.2.1',
responseIp: '192.0.2.1',
countryCode: 'SE',
asnNumber: 64500,
isAnonymous: false,
isTor: false,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
};
describe('Postgres ipinfo KV expiry', () => {
it('expires request audit rows after 90 days', async () => {
const writes: Array<Array<unknown>> = [];
await createPostgresIpInfoRequestAuditLogger({client: recordingClient(writes)}).record(EVENT);
expect(writes).toHaveLength(1);
expect(writes[0]?.[0]).toBe('ipinfo_requests_by_hour');
expectExpiresIn(writes[0], IPINFO_REQUEST_AUDIT_TTL_SECONDS);
});
it('falls back to the 14-day cache default', async () => {
const writes: Array<Array<unknown>> = [];
const cache = createPostgresIpInfoCache({client: recordingClient(writes)});
await cache.set('fallback', {ok: true});
await cache.set('zero', {ok: true}, 0);
await cache.set('short', {ok: true}, 60);
expect(writes.map((values) => values[0])).toEqual(['ipinfo_cache', 'ipinfo_cache', 'ipinfo_cache']);
expectExpiresIn(writes[0], IPINFO_CACHE_TTL_SECONDS);
expectExpiresIn(writes[1], IPINFO_CACHE_TTL_SECONDS);
expectExpiresIn(writes[2], 60);
});
});
+16 -1
View File
@@ -97,6 +97,7 @@ import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {pushServiceDeliveryEnrols} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {VoiceStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
@@ -430,6 +431,13 @@ export class RpcService {
}),
};
case 'send_apns_push': {
const deliveryConfig = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
if (pushServiceDeliveryEnrols(deliveryConfig, request.user_id.toString())) {
Logger.warn(
{userId: request.user_id.toString(), configVersion: deliveryConfig.config_version},
'push service delivery path mismatch',
);
}
const result = await sendApnsPush({
userId: request.user_id.toString(),
subscriptionId: request.subscription_id,
@@ -635,6 +643,13 @@ export class RpcService {
data: {config: rolloutConfig},
};
}
case 'get_push_service_delivery_config': {
const config = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
return {
type: 'get_push_service_delivery_config',
data: {config},
};
}
default: {
const exhaustiveCheck: never = request;
throw new Error(
@@ -864,7 +879,7 @@ export class RpcService {
if (!queueAllowed) {
return;
}
await this.workerService.addJob('reconcileUserPayments', {userId: userIdString});
await this.workerService.addJob('reconcileUserPayments', {userId: userIdString}, {skipLedger: true});
})
.catch((error) => {
Logger.warn(
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestUserWithPremium} from '@app/api/stripe/tests/StripeWebhookTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
describe('RpcService session payment reconciliation', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('queues payment reconciliation without a job record', async () => {
const account = await createTestUserWithPremium(harness, UserPremiumTypes.SUBSCRIPTION, {
stripeCustomerId: 'cus_rpc_session_reconcile',
});
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
try {
await createBuilder(harness, '')
.post('/test/rpc-session-init')
.body({type: 'session', token: account.token, version: 1, ip: '127.0.0.1'})
.expect(HTTP_STATUS.OK)
.execute();
await vi.waitFor(() =>
expect(addJob).toHaveBeenCalledWith('reconcileUserPayments', {userId: account.userId}, {skipLedger: true}),
);
} finally {
addJob.mockRestore();
}
});
});
@@ -352,7 +352,16 @@ export class InMemoryCassandraQueryExecutor implements CassandraQueryExecutorFor
let rows = [...this.table(meta).values()].filter((row) => matchesWhere(row, meta.where, params));
if (meta.orderBy) {
const direction = meta.orderBy.direction === 'DESC' ? -1 : 1;
rows = rows.sort((a, b) => compareValues(a[meta.orderBy!.col], b[meta.orderBy!.col]) * direction);
const column = meta.orderBy.col as string;
const primaryKey = meta.table.primaryKey as ReadonlyArray<string>;
const columns = [column, ...primaryKey.slice(primaryKey.indexOf(column) + 1)];
rows = rows.sort((a, b) => {
for (const c of columns) {
const cmp = compareValues(a[c], b[c]);
if (cmp !== 0) return cmp * direction;
}
return 0;
});
}
if (typeof meta.limit === 'number') {
rows = rows.slice(0, meta.limit);
@@ -957,7 +957,7 @@ export function UserAccountController(app: HonoApp) {
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Registers a mobile push device token for APNs, Firebase Cloud Messaging, or UnifiedPush. UnifiedPush registrations include the endpoint URL plus Web Push encryption keys.',
'Registers a mobile push device for APNs, Firebase Cloud Messaging, or UnifiedPush. A Web Push registration sends the endpoint URL with encryption_key and auth_secret. A raw registration sends the platform push token with no keys.',
}),
async (ctx) => {
const authSession = ctx.get('authSession');
@@ -3,6 +3,7 @@
import type {UserID} from '@app/api/BrandedTypes';
import {Db, type DbOp} from '@app/api/database/CassandraTypes';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {Logger} from '@app/api/Logger';
import {User} from '@app/api/models/User';
import {
UserDataRepository,
@@ -96,7 +97,12 @@ export class UserAccountRepository {
return updatedUser;
} catch (error) {
if (!dataCommitted && emailClaim) {
await this.emailOwnershipRepo.abortEmailClaim(emailClaim);
await this.emailOwnershipRepo.abortEmailClaim(emailClaim).catch((abortError: unknown) => {
Logger.warn(
{userId: userId.toString(), abortError},
'Failed to abort the email claim of a user write that did not commit',
);
});
}
throw error;
}
@@ -104,6 +104,27 @@ function assertPublicPushEndpoint(endpoint: string, fieldName: string): void {
}
}
function isPushEndpointUrl(token: string): boolean {
const normalized = token.trim().toLowerCase();
return normalized.startsWith('https://') || normalized.startsWith('http://');
}
function resolveMobileWebPushKeys(device: RegisterMobileDeviceRequest): {p256dh: string; auth: string} | null {
const p256dh = device.encryption_key;
const auth = device.auth_secret;
if (p256dh && auth) return {p256dh, auth};
if (p256dh || auth) {
throw InputValidationError.create(
p256dh ? 'auth_secret' : 'encryption_key',
'Web Push registrations require encryption_key and auth_secret',
);
}
if (isPushEndpointUrl(device.token)) {
throw InputValidationError.create('token', 'Endpoint URL registrations require encryption_key and auth_secret');
}
return null;
}
function normalizeMobileAppId(appId: string | undefined): string {
const normalized = appId?.trim();
return normalized && normalized.length > 0 ? normalized : DEFAULT_MOBILE_APP_ID;
@@ -400,7 +421,8 @@ export class UserContentService {
async registerMobileDevice(params: RegisterMobileDeviceParams): Promise<PushSubscription> {
const {userId, authSessionIdHash, device} = params;
if (device.platform === 'android_unified_push') {
const webPushKeys = resolveMobileWebPushKeys(device);
if (webPushKeys) {
assertPublicPushEndpoint(device.token, 'token');
}
const appId = normalizeMobileAppId(device.app_id);
@@ -411,8 +433,8 @@ export class UserContentService {
subscription_id: subscriptionId,
auth_session_id_hash: authSessionIdHash ?? null,
endpoint: device.token,
p256dh_key: device.platform === 'android_unified_push' ? (device.encryption_key ?? null) : null,
auth_key: device.platform === 'android_unified_push' ? (device.auth_secret ?? null) : null,
p256dh_key: webPushKeys?.p256dh ?? null,
auth_key: webPushKeys?.auth ?? null,
user_agent: device.user_agent ?? null,
platform: device.platform,
app_id: appId,
@@ -43,7 +43,10 @@ describe('HarvestDownloadToken', () => {
test('rejects a tampered signature', () => {
const token = signHarvestDownloadToken(payload(), SECRET);
expect(verifyHarvestDownloadToken(`${token.slice(0, -2)}xy`, SECRET)).toBeNull();
const [encoded, signature] = token.split('.');
const tampered = Buffer.from(signature as string, 'base64url');
tampered[0] = (tampered[0] as number) ^ 0x01;
expect(verifyHarvestDownloadToken(`${encoded}.${tampered.toString('base64url')}`, SECRET)).toBeNull();
});
test('rejects malformed input', () => {
@@ -6,9 +6,12 @@ import {
loginAccount,
logoutSpecificSessions,
} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import type {PushSubscription} from '@app/api/models/PushSubscription';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {PushSubscriptionRepository} from '@app/api/user/repositories/PushSubscriptionRepository';
import {
deleteMobileDevice,
deletePushSubscription,
@@ -21,6 +24,15 @@ import {
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {beforeEach, describe, expect, test} from 'vitest';
async function findStoredSubscription(userId: string, subscriptionId: string): Promise<PushSubscription> {
const subscriptions = await new PushSubscriptionRepository().listPushSubscriptions(createUserID(BigInt(userId)));
const subscription = subscriptions.find((entry) => entry.subscriptionId === subscriptionId);
if (!subscription) {
throw new Error(`Stored push subscription ${subscriptionId} not found`);
}
return subscription;
}
describe('Push Subscription Lifecycle', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
@@ -144,6 +156,155 @@ describe('Push Subscription Lifecycle', () => {
expect(mobileDevices.devices[0].device_id).toBe(registered.device_id);
expect(mobileDevices.devices[0].platform).toBe('android_unified_push');
});
test('APNs Web Push registration stores the endpoint and encryption keys', async () => {
const account = await createTestAccount(harness);
const endpoint = 'https://relay.example.com/apns/device-1';
const registered = await registerMobileDevice(harness, account.token, {
platform: 'ios_apns',
token: endpoint,
encryption_key: 'relay-p256dh-key',
auth_secret: 'relay-auth-secret',
app_id: 'stable',
});
const subscription = await findStoredSubscription(account.userId, registered.device_id);
expect(subscription.platform).toBe('ios_apns');
expect(subscription.endpoint).toBe(endpoint);
expect(subscription.p256dhKey).toBe('relay-p256dh-key');
expect(subscription.authKey).toBe('relay-auth-secret');
});
test('FCM Web Push registration stores the endpoint and encryption keys', async () => {
const account = await createTestAccount(harness);
const endpoint = 'https://relay.example.com/fcm/device-1';
const registered = await registerMobileDevice(harness, account.token, {
platform: 'android_fcm',
token: endpoint,
encryption_key: 'fcm-relay-p256dh-key',
auth_secret: 'fcm-relay-auth-secret',
});
const subscription = await findStoredSubscription(account.userId, registered.device_id);
expect(subscription.platform).toBe('android_fcm');
expect(subscription.endpoint).toBe(endpoint);
expect(subscription.p256dhKey).toBe('fcm-relay-p256dh-key');
expect(subscription.authKey).toBe('fcm-relay-auth-secret');
});
test('raw token registration stores the token without encryption keys', async () => {
const account = await createTestAccount(harness);
const registered = await registerMobileDevice(harness, account.token, {
platform: 'ios_apns',
token: '0123456789abcdef',
provider_environment: 'production',
});
const subscription = await findStoredSubscription(account.userId, registered.device_id);
expect(subscription.platform).toBe('ios_apns');
expect(subscription.endpoint).toBe('0123456789abcdef');
expect(subscription.p256dhKey).toBeNull();
expect(subscription.authKey).toBeNull();
});
test('Web Push and raw token registrations coexist for one platform', async () => {
const account = await createTestAccount(harness);
const rawDevice = await registerMobileDevice(harness, account.token, {
platform: 'android_fcm',
token: 'fcm-legacy-token',
});
const webPushDevice = await registerMobileDevice(harness, account.token, {
platform: 'android_fcm',
token: 'https://relay.example.com/fcm/device-2',
encryption_key: 'coexist-p256dh-key',
auth_secret: 'coexist-auth-secret',
});
expect(rawDevice.device_id).not.toBe(webPushDevice.device_id);
const rawSubscription = await findStoredSubscription(account.userId, rawDevice.device_id);
const webPushSubscription = await findStoredSubscription(account.userId, webPushDevice.device_id);
expect(rawSubscription.p256dhKey).toBeNull();
expect(rawSubscription.authKey).toBeNull();
expect(webPushSubscription.p256dhKey).toBe('coexist-p256dh-key');
expect(webPushSubscription.authKey).toBe('coexist-auth-secret');
const mobileDevices = await listMobileDevices(harness, account.token);
expect(mobileDevices.devices).toHaveLength(2);
});
test('endpoint registration without encryption keys is rejected', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/mobile-devices')
.body({
platform: 'ios_apns',
token: 'https://relay.example.com/apns/no-keys',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('endpoint registration with only one encryption key is rejected', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/mobile-devices')
.body({
platform: 'android_fcm',
token: 'https://relay.example.com/fcm/half-keys',
encryption_key: 'half-p256dh-key',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('raw token registration with encryption keys is rejected', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/mobile-devices')
.body({
platform: 'ios_apns',
token: '0123456789abcdef',
encryption_key: 'raw-p256dh-key',
auth_secret: 'raw-auth-secret',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('Web Push registration rejects an endpoint that is not publicly routable', async () => {
const account = await createTestAccount(harness);
const response = await createBuilder(harness, account.token)
.post('/users/@me/mobile-devices')
.body({
platform: 'ios_apns',
token: 'https://127.0.0.1/apns/device',
encryption_key: 'local-p256dh-key',
auth_secret: 'local-auth-secret',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(JSON.stringify(response)).toContain('URL_NOT_PUBLICLY_ROUTABLE');
});
test('unregister removes a Web Push mobile registration', async () => {
const account = await createTestAccount(harness);
const endpoint = 'https://relay.example.com/apns/unregister';
await registerMobileDevice(harness, account.token, {
platform: 'ios_apns',
token: endpoint,
encryption_key: 'unregister-p256dh-key',
auth_secret: 'unregister-auth-secret',
app_id: 'stable',
provider_environment: 'production',
});
await unregisterMobileDevice(harness, account.token, {
platform: 'ios_apns',
token: endpoint,
app_id: 'stable',
provider_environment: 'production',
});
const mobileDevices = await listMobileDevices(harness, account.token);
expect(mobileDevices.devices).toHaveLength(0);
});
test('mobile Web Push registrations stay out of the web push subscription list', async () => {
const account = await createTestAccount(harness);
await registerMobileDevice(harness, account.token, {
platform: 'android_fcm',
token: 'https://relay.example.com/fcm/separate',
encryption_key: 'separate-p256dh-key',
auth_secret: 'separate-auth-secret',
});
const webSubscriptions = await listPushSubscriptions(harness, account.token);
const mobileDevices = await listMobileDevices(harness, account.token);
expect(webSubscriptions.subscriptions).toHaveLength(0);
expect(mobileDevices.devices).toHaveLength(1);
});
test('list subscriptions returns multiple subscriptions', async () => {
const account = await createTestAccount(harness);
const first = await subscribePush(harness, account.token, 'https://push.example.com/multi-1');
+6 -1
View File
@@ -5,13 +5,18 @@ import * as RegexUtils from '@app/api/utils/RegexUtils';
let _invitePattern: RegExp | null = null;
function getInviteEndpointBase(): string {
const url = new URL(Config.endpoints.invite);
return `${url.hostname}${url.pathname.replace(/\/+$/, '')}`;
}
function getInvitePattern(): RegExp {
if (!_invitePattern) {
_invitePattern = new RegExp(
[
'(?:https?:\\/\\/)?',
'(?:',
`${RegexUtils.escapeRegex(Config.hosts.invite)}(?:\\/#)?\\/(?!invite\\/)([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
`${RegexUtils.escapeRegex(getInviteEndpointBase())}(?:\\/#)?\\/(?!invite\\/)([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
'|',
`${RegexUtils.escapeRegex(new URL(Config.endpoints.webApp).hostname)}(?:\\/#)?\\/invite\\/([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
')',
+34 -31
View File
@@ -8,24 +8,17 @@ import * as InviteUtils from '@app/api/utils/InviteUtils';
import {URL_REGEX} from '@fluxer/constants/src/Core';
import * as idna from 'idna-uts46-hx';
const MARKETING_PATH_PREFIXES = ['/channels/', '/theme/'];
const CLIENT_ROUTE_PATH_PREFIXES = ['/channels/', '/theme/'];
interface ExcludedLinkBase {
hostname: string;
pathPrefix: string;
}
function normalizeHostname(hostname: string | undefined) {
return hostname?.trim().toLowerCase() || '';
}
let _marketingHostname: string | null = null;
function getMarketingHostname() {
if (!_marketingHostname) {
_marketingHostname = normalizeHostname(Config.hosts.marketing);
}
return _marketingHostname;
}
const isMarketingPath = (hostname: string, pathname: string) =>
hostname === getMarketingHostname() && MARKETING_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix));
function getWebAppHostname() {
try {
return new URL(Config.endpoints.webApp).hostname;
@@ -34,23 +27,32 @@ function getWebAppHostname() {
}
}
let _excludedHostnames: Set<string> | null = null;
function getExcludedHostnames(): Set<string> {
if (!_excludedHostnames) {
_excludedHostnames = new Set<string>();
const addHostname = (hostname: string | undefined) => {
const normalized = normalizeHostname(hostname);
if (normalized) {
_excludedHostnames!.add(normalized);
}
};
addHostname(Config.hosts.invite);
addHostname(Config.hosts.gift);
Config.hosts.unfurlIgnored.forEach(addHostname);
addHostname(getWebAppHostname());
function endpointLinkBase(endpoint: string): ExcludedLinkBase | null {
try {
const url = new URL(endpoint);
return {hostname: normalizeHostname(url.hostname), pathPrefix: `${url.pathname.replace(/\/+$/, '')}/`};
} catch {
return null;
}
return _excludedHostnames;
}
let _excludedLinkBases: Array<ExcludedLinkBase> | null = null;
function getExcludedLinkBases(): Array<ExcludedLinkBase> {
if (!_excludedLinkBases) {
const bases: Array<ExcludedLinkBase | null> = [
...Config.hosts.unfurlIgnored.map((hostname) => ({hostname: normalizeHostname(hostname), pathPrefix: '/'})),
endpointLinkBase(Config.endpoints.invite),
endpointLinkBase(Config.endpoints.gift),
];
for (const hostname of [getWebAppHostname(), Config.hosts.marketing]) {
for (const pathPrefix of CLIENT_ROUTE_PATH_PREFIXES) {
bases.push({hostname: normalizeHostname(hostname), pathPrefix});
}
}
_excludedLinkBases = bases.filter((base): base is ExcludedLinkBase => base !== null && base.hostname !== '');
}
return _excludedLinkBases;
}
function idnaEncodeURL(url: string) {
@@ -80,8 +82,9 @@ function isFluxerAppExcludedURL(url: string) {
try {
const parsedUrl = new URL(url);
const hostname = normalizeHostname(parsedUrl.hostname);
const isMarketingPathMatch = isMarketingPath(hostname, parsedUrl.pathname);
return isMarketingPathMatch || getExcludedHostnames().has(hostname);
return getExcludedLinkBases().some(
(base) => base.hostname === hostname && parsedUrl.pathname.startsWith(base.pathPrefix),
);
} catch {
return false;
}
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Logger} from '@app/api/Logger';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import {WorkerQueueOverflowError} from '@app/api/worker/WorkerQueueOverflowError';
import type {WorkerService} from '@app/api/worker/WorkerService';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {ms} from 'itty-time';
const INITIAL_SYNC_KEY = 'sync:email_domains:initialized';
const PURGE_KEY = 'sync:blocklist_feeds:purged';
const CLAIM_TTL_SECONDS = ms('6 hours') / 1000;
const FEED_TASKS = [
'syncDisposableEmailDomains',
'syncUrlBlocklists',
'syncFileShaBlocklists',
] as const satisfies ReadonlyArray<WorkerTaskName>;
export async function queueBlocklistFeedStartupJobs(
kvClient: Pick<IKVProvider, 'setnx' | 'del'>,
workerService: Pick<WorkerService, 'addJob'>,
enabled: boolean,
): Promise<void> {
if (enabled) {
if (await kvClient.setnx(INITIAL_SYNC_KEY, '1', CLAIM_TTL_SECONDS)) {
Logger.info('Triggering initial disposable email domain sync');
await queueJobs(workerService, ['syncDisposableEmailDomains']);
}
return;
}
const wasEnabled = (await kvClient.del(INITIAL_SYNC_KEY)) > 0;
const claimed = await kvClient.setnx(PURGE_KEY, '1', CLAIM_TTL_SECONDS);
if (!wasEnabled && !claimed) return;
Logger.info('Removing blocklist feed data, blocklist feeds are disabled');
await queueJobs(workerService, FEED_TASKS);
}
async function queueJobs(
workerService: Pick<WorkerService, 'addJob'>,
tasks: ReadonlyArray<WorkerTaskName>,
): Promise<void> {
for (const task of tasks) {
try {
await workerService.addJob(task, {});
} catch (error) {
if (!(error instanceof WorkerQueueOverflowError)) {
throw error;
}
Logger.warn({task}, 'Dropped blocklist feed job, jobs stream is at its limit');
}
}
}
@@ -17,13 +17,13 @@ import {
StorageType,
type StreamConfig,
} from '@nats-io/jetstream';
import {nanos} from '@nats-io/transport-node';
import {millis, nanos} from '@nats-io/transport-node';
import type {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectionManager';
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
const STREAM_NAME = 'JOBS';
const SUBJECT_PREFIX = 'jobs.';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
export const JOBS_STREAM_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const LEGACY_CONSUMER_NAME = 'workers';
const DLQ_STREAM_NAME = 'JOBS_DLQ';
const DLQ_SUBJECT_PREFIX = 'dlq.';
@@ -59,7 +59,7 @@ const JOBS_STREAM: WorkerStreamDefinition = {
name: STREAM_NAME,
subject: `${SUBJECT_PREFIX}>`,
retention: RetentionPolicy.Workqueue,
maxAgeMs: MAX_AGE_MS,
maxAgeMs: JOBS_STREAM_MAX_AGE_MS,
minBytes: STREAM_MIN_BYTES,
maxMessages: STREAM_MAX_MSGS,
maxMessagesPerSubject: STREAM_MAX_MSGS_PER_SUBJECT,
@@ -106,6 +106,7 @@ export class JetStreamWorkerQueue {
private consumersReady = false;
private streamSetup: Promise<void> | null = null;
private dlqStreamSetup: Promise<void> | null = null;
private jobsStreamMaxAgeMs = JOBS_STREAM_MAX_AGE_MS;
constructor(connectionManager: JetStreamConnectionManager) {
this.connectionManager = connectionManager;
@@ -125,11 +126,16 @@ export class JetStreamWorkerQueue {
if (existingConfig === null) {
await this.addStream(jsm);
} else {
this.jobsStreamMaxAgeMs = millis(existingConfig.max_age);
await this.applyStreamLimits(jsm, existingConfig);
}
this.streamReady = true;
}
getJobsStreamMaxAgeMs(): number {
return this.jobsStreamMaxAgeMs;
}
private async oversizedSubjects(jsm: JetStreamManager): Promise<Array<[string, number]> | null> {
try {
const info = await jsm.streams.info(STREAM_NAME, {subjects_filter: JOBS_STREAM.subject});
@@ -462,7 +468,7 @@ export class JetStreamWorkerQueue {
priority?: number;
jobKey?: string;
},
): Promise<string> {
): Promise<{seq: string; duplicate: boolean}> {
const js = this.connectionManager.getJetStreamClient();
const subject = `${SUBJECT_PREFIX}${taskType}`;
const body = JSON.stringify({
@@ -477,8 +483,7 @@ export class JetStreamWorkerQueue {
const ack = await js.publish(subject, body, {
msgID,
});
const jobId = `${ack.seq}`;
return jobId;
return {seq: `${ack.seq}`, duplicate: ack.duplicate === true};
} catch (error) {
const rejection = describeStreamRejection(error);
if (rejection === null) {
@@ -67,6 +67,7 @@ const LANE_CONFIG = {
consumerName: 'workers_batch',
tasks: [
'expireAttachments',
'expireStaleJobs',
'indexChannelMessages',
'indexGuildMembers',
'processAssetDeletionQueue',
+13 -23
View File
@@ -23,8 +23,9 @@ import {
} from '@app/api/middleware/ServiceSingletons';
import {initializeSearch, shutdownSearch} from '@app/api/SearchFactory';
import {awaitAll} from '@app/api/utils/ConcurrencyUtils';
import {queueBlocklistFeedStartupJobs} from '@app/api/worker/BlocklistFeedStartup';
import {CronScheduler} from '@app/api/worker/CronScheduler';
import {JetStreamWorkerQueue} from '@app/api/worker/JetStreamWorkerQueue';
import {JetStreamWorkerQueue, JOBS_STREAM_MAX_AGE_MS} from '@app/api/worker/JetStreamWorkerQueue';
import {clearWorkerDependencies, setWorkerDependencies} from '@app/api/worker/WorkerContext';
import {initializeWorkerDependencies, type WorkerDependencies} from '@app/api/worker/WorkerDependencies';
import {WorkerHeartbeat} from '@app/api/worker/WorkerHeartbeat';
@@ -34,7 +35,6 @@ import {
validateLaneCompleteness,
} from '@app/api/worker/WorkerLaneConfig';
import {createWorkerProcessErrorHandler} from '@app/api/worker/WorkerProcessErrorHandler';
import {WorkerQueueOverflowError} from '@app/api/worker/WorkerQueueOverflowError';
import {WorkerRunner} from '@app/api/worker/WorkerRunner';
import {WorkerService} from '@app/api/worker/WorkerService';
import {workerTasks} from '@app/api/worker/WorkerTaskRegistry';
@@ -43,9 +43,8 @@ import {BACKGROUND_READ_TIMEOUT_MS, initCassandra, shutdownCassandra} from '@pkg
import {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectionManager';
import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/postgres/src/Client';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import {ms} from 'itty-time';
function registerCronJobs(cron: CronScheduler): void {
function registerCronJobs(cron: CronScheduler, jobsStreamMaxAgeMs: number): void {
cron.upsert('processAssetDeletionQueue', 'processAssetDeletionQueue', {}, '0 */5 * * * *', {ledger: false});
if (Config.cachePurge.adapter !== 'none') {
cron.upsert('processCachePurgeQueue', 'processCachePurgeQueue', {}, '*/10 * * * * *', {ledger: false});
@@ -62,6 +61,14 @@ function registerCronJobs(cron: CronScheduler): void {
}
cron.upsert('processInactivityDeletions', 'processInactivityDeletions', {}, '0 0 */6 * * *', {ledger: false});
cron.upsert('expireAttachments', 'expireAttachments', {}, '0 0 */12 * * *', {ledger: false});
if (jobsStreamMaxAgeMs > 0 && jobsStreamMaxAgeMs <= JOBS_STREAM_MAX_AGE_MS) {
cron.upsert('expireStaleJobs', 'expireStaleJobs', {}, '0 45 3 * * *', {ledger: false});
} else {
Logger.warn(
{jobsStreamMaxAgeMs},
'Jobs stream keeps jobs past 7 days, stale jobs stay active until their ledger rows expire',
);
}
cron.upsert('prunePostgresKvTtl', 'prunePostgresKvTtl', {}, '0 */5 * * * *', {ledger: false});
cron.upsert('syncDiscoveryIndex', 'syncDiscoveryIndex', {}, '0 */15 * * * *', {ledger: false});
if (Config.blocklistFeeds.enabled) {
@@ -240,26 +247,9 @@ export async function startWorkerMain(): Promise<void> {
}
dependencies = await initializeWorkerDependencies(snowflakeService);
setWorkerDependencies(dependencies);
if (Config.blocklistFeeds.enabled) {
const didClaimEmailSync = await dependencies.kvClient.setnx(
'sync:email_domains:initialized',
'1',
ms('6 hours') / 1000,
);
if (didClaimEmailSync) {
Logger.info('Triggering initial disposable email domain sync');
try {
await workerService.addJob('syncDisposableEmailDomains', {});
} catch (error) {
if (!(error instanceof WorkerQueueOverflowError)) {
throw error;
}
Logger.warn('Dropped initial disposable email domain sync, jobs stream is at its limit');
}
}
}
await queueBlocklistFeedStartupJobs(dependencies.kvClient, workerService, Config.blocklistFeeds.enabled);
cron = new CronScheduler(workerService, Logger, dependencies.kvClient, heartbeat);
registerCronJobs(cron);
registerCronJobs(cron, queue.getJobsStreamMaxAgeMs());
for (const lane of activeWorkerLanes) {
const laneTasks: Record<string, WorkerTaskHandler> = {};
for (const taskType of lane.taskTypes) {
+11 -8
View File
@@ -29,10 +29,10 @@ export class WorkerService implements IWorkerService<WorkerTaskName> {
const skipLedger = options?.skipLedger === true;
const requireLedger = options?.requireLedger === true;
const payloadRecord = payload as Record<string, unknown>;
let ledgerWritten = false;
let ledgerCreatedAt: Date | null = null;
if (!skipLedger) {
try {
await this.ledger.createJob({
ledgerCreatedAt = await this.ledger.createJob({
jobId,
taskType,
payload: payloadRecord,
@@ -43,29 +43,32 @@ export class WorkerService implements IWorkerService<WorkerTaskName> {
jetStreamLane: findLaneForTask(taskType),
jetStreamSeq: null,
});
ledgerWritten = true;
} catch (ledgerErr) {
Logger.error({err: ledgerErr, jobId: jobId.toString(), taskType}, 'Failed to write ledger row for job');
if (requireLedger) throw ledgerErr;
}
}
const enrichedPayload = ledgerWritten ? {...payloadRecord, __jobId: jobId.toString()} : payloadRecord;
const enrichedPayload = ledgerCreatedAt !== null ? {...payloadRecord, __jobId: jobId.toString()} : payloadRecord;
try {
const seq = await this.queue.enqueue(taskType, enrichedPayload, {
const {seq, duplicate} = await this.queue.enqueue(taskType, enrichedPayload, {
...(options?.runAt !== undefined && {runAt: options.runAt}),
...(options?.maxAttempts !== undefined && {maxAttempts: options.maxAttempts}),
...(options?.priority !== undefined && {priority: options.priority}),
...(options?.jobKey !== undefined && {jobKey: options.jobKey}),
});
if (ledgerWritten) {
if (ledgerCreatedAt !== null && duplicate) {
await this.ledger
.discardJob(jobId, ledgerCreatedAt)
.catch((err) => Logger.warn({err, jobId: jobId.toString()}, 'Ledger discardJob failed'));
} else if (ledgerCreatedAt !== null) {
await this.ledger
.setJetStreamSeq(jobId, seq)
.catch((err) => Logger.warn({err, jobId: jobId.toString()}, 'Ledger setJetStreamSeq failed'));
}
Logger.debug({taskType, jobId: jobId.toString(), seq}, 'Job queued successfully');
Logger.debug({taskType, jobId: jobId.toString(), seq, duplicate}, 'Job queued successfully');
return jobId;
} catch (error) {
if (ledgerWritten) {
if (ledgerCreatedAt !== null) {
await this.ledger
.markDeadletter(jobId, error instanceof Error ? error.message : String(error))
.catch((err) => Logger.warn({err, jobId: jobId.toString()}, 'Ledger markDeadletter failed'));
@@ -14,6 +14,7 @@ import bulkDeleteUserMessages from '@app/api/worker/tasks/BulkDeleteUserMessages
import bulkDeleteUserMessagesScoped from '@app/api/worker/tasks/BulkDeleteUserMessagesScoped';
import deleteUserMessagesInGuildByTime from '@app/api/worker/tasks/DeleteUserMessagesInGuildByTime';
import expireAttachments from '@app/api/worker/tasks/ExpireAttachments';
import expireStaleJobs from '@app/api/worker/tasks/ExpireStaleJobs';
import extractEmbeds from '@app/api/worker/tasks/ExtractEmbeds';
import finalizeNcmecAttachmentReport from '@app/api/worker/tasks/FinalizeNcmecAttachmentReport';
import flushUserActivityBuffer from '@app/api/worker/tasks/FlushUserActivityBuffer';
@@ -59,6 +60,7 @@ export const workerTasks: Record<WorkerTaskName, WorkerTaskHandler> = {
bulkUpdateUserFlags: bulkUpdateUserFlags,
deleteUserMessagesInGuildByTime,
expireAttachments,
expireStaleJobs,
extractEmbeds,
finalizeNcmecAttachmentReport,
handleMentions,
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {JOB_STALE_AFTER_MS, JobLedgerRepository} from '@app/api/jobs/JobLedgerRepository';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
const PAGE_SIZE = 500;
const MAX_CLEARED_PER_RUN = 1000;
const expireStaleJobs: WorkerTaskHandler = async (_payload, helpers) => {
const result = await new JobLedgerRepository().expireStaleActiveJobs({
staleBeforeMs: Date.now() - JOB_STALE_AFTER_MS,
pageSize: PAGE_SIZE,
maxCleared: MAX_CLEARED_PER_RUN,
});
if (!result.complete) {
helpers.logger.warn({...result}, 'Stale job sweep reached its per-run cap');
} else if (result.cleared > 0) {
helpers.logger.info({...result}, 'Expired stale jobs');
}
};
export default expireStaleJobs;
@@ -1,18 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {expireLegacyDefaultTtlRows} from '@app/api/database/PostgresKvDefaultTtlExpiry';
import {pruneExpiredPostgresKvRows} from '@app/api/database/PostgresKvQueryExecutor';
import {expireLegacyJobLedgerRows} from '@app/api/jobs/PostgresJobLedgerExpiry';
import {getDefaultPostgresClient} from '@pkgs/postgres/src/Client';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import {ms} from 'itty-time';
const PRUNE_BATCH_SIZE = 5000;
const MAX_PRUNE_BATCHES_PER_RUN = 20;
const LEGACY_EXPIRY_BUDGET_MS = ms('2 minutes');
const prunePostgresKvTtl: WorkerTaskHandler = async (_payload, helpers) => {
if (Config.database.backend !== 'postgres') {
return;
}
const client = getDefaultPostgresClient();
const deadlineMs = Date.now() + LEGACY_EXPIRY_BUDGET_MS;
const legacyJobs = await expireLegacyJobLedgerRows(client, deadlineMs);
if (legacyJobs !== null && (legacyJobs.deleted > 0 || legacyJobs.expiring > 0 || !legacyJobs.complete)) {
helpers.logger.info({...legacyJobs}, 'Expired legacy job ledger rows');
}
const legacyDefaults = await expireLegacyDefaultTtlRows(client, deadlineMs);
if (
legacyDefaults !== null &&
(legacyDefaults.deleted > 0 || legacyDefaults.expiring > 0 || !legacyDefaults.complete)
) {
helpers.logger.info({...legacyDefaults}, 'Expired rows written without their table default TTL');
}
let deleted = 0;
for (let batch = 0; batch < MAX_PRUNE_BATCHES_PER_RUN; batch += 1) {
const batchDeleted = await pruneExpiredPostgresKvRows(client, PRUNE_BATCH_SIZE);
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {domainToASCII} from 'node:url';
import {Config} from '@app/api/Config';
import {isAccountPolicyContactDomainReputationExempt} from '@app/api/risk/AccountPolicyService';
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
import * as FetchUtils from '@app/api/utils/FetchUtils';
@@ -131,10 +132,7 @@ async function throwIfCancelled(helpers: WorkerTaskHelpers): Promise<void> {
}
}
const syncDisposableEmailDomains: WorkerTaskHandler = async (_payload, helpers) => {
helpers.logger.info('Starting disposable email domain sync');
await helpers.setContextLink('/suspicious-email-domains');
const {adminRepository} = getWorkerDependencies();
async function fetchFeedDomains(helpers: WorkerTaskHelpers): Promise<Set<string>> {
const freshSet = new Set<string>();
const perSourceCounts: Record<string, number> = {};
const perSourceRawCounts: Record<string, number> = {};
@@ -164,6 +162,14 @@ const syncDisposableEmailDomains: WorkerTaskHandler = async (_payload, helpers)
},
'Fetched disposable email domains from all sources',
);
return freshSet;
}
const syncDisposableEmailDomains: WorkerTaskHandler = async (_payload, helpers) => {
helpers.logger.info('Starting disposable email domain sync');
await helpers.setContextLink('/suspicious-email-domains');
const {adminRepository} = getWorkerDependencies();
const freshSet = Config.blocklistFeeds.enabled ? await fetchFeedDomains(helpers) : new Set<string>();
const currentSet = await loadCurrentDisposableEmailDomains();
let addCount = 0;
for (const domain of freshSet) {
@@ -1,16 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {BANNED_FILE_SHAS_REFRESH_CHANNEL} from '@app/api/constants/ContentModeration';
import {Config} from '@app/api/Config';
import {
BANNED_FILE_SHAS_REFRESH_CHANNEL,
ContentBlocklistCategory,
isBlocklistFeedFileSha,
} from '@app/api/constants/ContentModeration';
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
import * as FetchUtils from '@app/api/utils/FetchUtils';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import type {WorkerTaskHandler, WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
const MALWARE_BAZAAR_SHA256_URL = 'https://bazaar.abuse.ch/export/txt/sha256/recent/';
const SHA256_RE = /^[0-9a-fA-F]{64}$/;
async function removeFeedFileShas(helpers: WorkerTaskHelpers): Promise<void> {
const {adminRepository, kvClient} = getWorkerDependencies();
let removed = 0;
for (const row of await adminRepository.loadAllBannedFileShas()) {
if (!isBlocklistFeedFileSha(row)) continue;
if (await adminRepository.unbanFeedFileSha(row.sha256_hex)) removed++;
}
if (removed > 0) {
await kvClient.publish(BANNED_FILE_SHAS_REFRESH_CHANNEL, 'refresh');
}
helpers.logger.info({removed}, 'Removed file-SHA blocklist feed rows');
}
const syncFileShaBlocklists: WorkerTaskHandler = async (_payload, helpers) => {
helpers.logger.info('Starting file-SHA blocklist sync');
await helpers.setContextLink('/file-sha-bans');
if (!Config.blocklistFeeds.enabled) {
await removeFeedFileShas(helpers);
return;
}
const {adminRepository, kvClient} = getWorkerDependencies();
let added = 0;
try {
@@ -38,7 +61,7 @@ const syncFileShaBlocklists: WorkerTaskHandler = async (_payload, helpers) => {
if (existingSet.has(sha)) continue;
await adminRepository.banFileSha({
sha256_hex: sha,
category: 'malware_bazaar',
category: ContentBlocklistCategory.MALWARE_BAZAAR,
severity: 2,
content_type: null,
source_url: MALWARE_BAZAAR_SHA256_URL,
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {BANNED_URLS_REFRESH_CHANNEL} from '@app/api/constants/ContentModeration';
import {RISK_S3_KEYS, writeLinesToS3} from '@app/api/risk/RiskBlocklistS3';
import {deleteRiskS3Object, RISK_S3_KEYS, writeLinesToS3} from '@app/api/risk/RiskBlocklistS3';
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
import * as FetchUtils from '@app/api/utils/FetchUtils';
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
@@ -62,10 +63,23 @@ async function fetchFeed(source: FeedSource): Promise<Array<string>> {
return source.parse(text);
}
const MISSING_FEED_FILE_ERRORS = new Set(['NoSuchBucket', 'NoSuchKey', 'NotFound']);
const syncUrlBlocklists: WorkerTaskHandler = async (_payload, helpers) => {
helpers.logger.info('Starting URL blocklist sync');
await helpers.setContextLink('/url-domain-bans');
const {storageService, kvClient} = getWorkerDependencies();
if (!Config.blocklistFeeds.enabled) {
try {
await deleteRiskS3Object(storageService, RISK_S3_KEYS.feedUrls);
} catch (error) {
if (!(error instanceof Error && MISSING_FEED_FILE_ERRORS.has(error.name))) {
helpers.logger.warn({error}, 'Failed to delete the URL blocklist feed file');
}
}
await kvClient.publish(BANNED_URLS_REFRESH_CHANNEL, 'refresh');
return;
}
const results = await Promise.allSettled(
FEED_SOURCES.map(async (source) => ({source, rawUrls: await fetchFeed(source)})),
);
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {queueBlocklistFeedStartupJobs} from '@app/api/worker/BlocklistFeedStartup';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import {WorkerQueueOverflowError} from '@app/api/worker/WorkerQueueOverflowError';
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
import {describe, expect, it, vi} from 'vitest';
const INITIAL_SYNC_KEY = 'sync:email_domains:initialized';
const FEED_TASKS = ['syncDisposableEmailDomains', 'syncUrlBlocklists', 'syncFileShaBlocklists'];
function createWorkerService() {
return {addJob: vi.fn(async (_task: WorkerTaskName, _payload: WorkerJobPayload) => 1n)};
}
function queuedTasks(workerService: ReturnType<typeof createWorkerService>): Array<string> {
return workerService.addJob.mock.calls.map(([task]) => task);
}
describe('queueBlocklistFeedStartupJobs', () => {
it('with feeds on, a fresh start queues only the disposable sync and claims it for six hours', async () => {
const kv = new MockKVProvider();
const workerService = createWorkerService();
await queueBlocklistFeedStartupJobs(kv, workerService, true);
expect(workerService.addJob.mock.calls).toEqual([['syncDisposableEmailDomains', {}]]);
expect(kv.setnxSpy.mock.calls).toEqual([[INITIAL_SYNC_KEY, '1', 21600]]);
expect(kv.delSpy).not.toHaveBeenCalled();
});
it('with feeds on, a start inside the claim queues nothing', async () => {
const kv = new MockKVProvider();
const workerService = createWorkerService();
await queueBlocklistFeedStartupJobs(kv, workerService, true);
await queueBlocklistFeedStartupJobs(kv, workerService, true);
expect(queuedTasks(workerService)).toEqual(['syncDisposableEmailDomains']);
});
it('with feeds off, the first start queues all three feed tasks and a second start queues none', async () => {
const kv = new MockKVProvider();
const workerService = createWorkerService();
await queueBlocklistFeedStartupJobs(kv, workerService, false);
expect(workerService.addJob.mock.calls).toEqual(FEED_TASKS.map((task) => [task, {}]));
await queueBlocklistFeedStartupJobs(kv, workerService, false);
expect(queuedTasks(workerService)).toEqual(FEED_TASKS);
});
it('with feeds off, a start after a feeds-on start cleans up again inside the claim', async () => {
const kv = new MockKVProvider();
const workerService = createWorkerService();
await queueBlocklistFeedStartupJobs(kv, workerService, false);
await queueBlocklistFeedStartupJobs(kv, workerService, true);
await queueBlocklistFeedStartupJobs(kv, workerService, false);
expect(queuedTasks(workerService)).toEqual([...FEED_TASKS, 'syncDisposableEmailDomains', ...FEED_TASKS]);
expect(await kv.exists(INITIAL_SYNC_KEY)).toBe(0);
});
it('a legacy initial sync key without expiry is cleared, so re-enabling runs the initial sync', async () => {
const kv = new MockKVProvider();
await kv.set(INITIAL_SYNC_KEY, '1');
expect(await kv.ttl(INITIAL_SYNC_KEY)).toBe(-1);
await queueBlocklistFeedStartupJobs(kv, createWorkerService(), false);
expect(await kv.exists(INITIAL_SYNC_KEY)).toBe(0);
const workerService = createWorkerService();
await queueBlocklistFeedStartupJobs(kv, workerService, true);
expect(queuedTasks(workerService)).toEqual(['syncDisposableEmailDomains']);
});
it('a full jobs stream drops the job without failing startup', async () => {
const overflowing = createWorkerService();
overflowing.addJob.mockImplementation(async (task) => {
throw new WorkerQueueOverflowError(task, 'maximum messages exceeded');
});
await expect(queueBlocklistFeedStartupJobs(new MockKVProvider(), overflowing, false)).resolves.toBeUndefined();
expect(queuedTasks(overflowing)).toEqual(FEED_TASKS);
const failing = createWorkerService();
failing.addJob.mockRejectedValue(new Error('jetstream unavailable'));
await expect(queueBlocklistFeedStartupJobs(new MockKVProvider(), failing, false)).rejects.toThrow(
'jetstream unavailable',
);
});
});
@@ -0,0 +1,228 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {
BANNED_FILE_SHAS_REFRESH_CHANNEL,
BANNED_URLS_REFRESH_CHANNEL,
ContentBlocklistCategory,
} from '@app/api/constants/ContentModeration';
import type {BannedFileShaRow} from '@app/api/database/types/AdminArchiveTypes';
import {fileShaCache} from '@app/api/middleware/FileShaCache';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import type {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
import syncDisposableEmailDomains from '@app/api/worker/tasks/SyncDisposableEmailDomains';
import syncFileShaBlocklists from '@app/api/worker/tasks/SyncFileShaBlocklists';
import syncUrlBlocklists from '@app/api/worker/tasks/SyncUrlBlocklists';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest';
const FEED_BUCKET = 'fluxer-geoip';
const FEED_KEY = 'blocklists/feed-urls.txt';
const FEED_SHA = 'a1'.repeat(32);
const ADMIN_BAZAAR_SHA = 'b2'.repeat(32);
const ADMIN_MANUAL_SHA = 'c3'.repeat(32);
const UNOWNED_NCMEC_SHA = 'd4'.repeat(32);
const ADMIN_USER_ID = 42n;
function createHelpers(overrides: Partial<WorkerTaskHelpers> = {}): WorkerTaskHelpers {
return {
logger: new NoopLogger(),
jobId: 4242n,
addJob: async () => 0n,
reportProgress: async () => {},
shouldCancel: async () => false,
setContextLink: async () => {},
...overrides,
};
}
function fileShaRow(sha256Hex: string, category: string, addedBy: bigint | null): BannedFileShaRow {
return {
sha256_hex: sha256Hex,
category,
severity: 2,
content_type: null,
source_url: null,
added_at: new Date(),
added_by: addedBy,
notes: null,
};
}
async function seedFileShas(): Promise<void> {
const adminRepository = getAdminRepository();
await adminRepository.banFileSha(fileShaRow(FEED_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, null));
await adminRepository.banFileSha(
fileShaRow(ADMIN_BAZAAR_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, ADMIN_USER_ID),
);
await adminRepository.banFileSha(fileShaRow(ADMIN_MANUAL_SHA, ContentBlocklistCategory.MANUAL, ADMIN_USER_ID));
await adminRepository.banFileSha(fileShaRow(UNOWNED_NCMEC_SHA, ContentBlocklistCategory.NCMEC, null));
}
describe('blocklist feeds turned off', () => {
let harness: ApiTestHarness;
let previousFeedsEnabled: boolean;
let fetchSpy: MockInstance<typeof fetch>;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
previousFeedsEnabled = Config.blocklistFeeds.enabled;
await harness.reset();
harness.storageService.reset();
setWorkerDependenciesForTest({
adminRepository: getAdminRepository(),
kvClient: harness.kvProvider,
storageService: harness.storageService,
});
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
Config.blocklistFeeds.enabled = previousFeedsEnabled;
fetchSpy.mockRestore();
});
afterAll(async () => {
clearWorkerDependencies();
await harness?.shutdown();
});
function publishCalls(): Array<Array<string>> {
return (harness.kvProvider as MockKVProvider).publishSpy.mock.calls;
}
it('removes every stored disposable domain without fetching a feed', async () => {
const adminRepository = getAdminRepository();
for (const domain of ['mailinator.com', 'guerrillamail.com', 'tempmail.dev']) {
await adminRepository.addDisposableEmailDomain(domain);
}
Config.blocklistFeeds.enabled = false;
const reportProgress = vi.fn(async () => {});
await syncDisposableEmailDomains({}, createHelpers({reportProgress}));
expect(await adminRepository.listDisposableEmailDomains()).toEqual([]);
expect(fetchSpy).not.toHaveBeenCalled();
expect(reportProgress).toHaveBeenLastCalledWith(3, 3, '+0 added, -3 removed');
});
it('reports a stored domain as disposable only while feeds are on', async () => {
const adminRepository = getAdminRepository();
await adminRepository.addDisposableEmailDomain('mailinator.com');
Config.blocklistFeeds.enabled = true;
expect(await adminRepository.isEmailDomainDisposable('mailinator.com')).toBe(true);
Config.blocklistFeeds.enabled = false;
expect(await adminRepository.isEmailDomainDisposable('mailinator.com')).toBe(false);
});
it('removes MalwareBazaar feed rows and keeps every other file-SHA ban', async () => {
await seedFileShas();
Config.blocklistFeeds.enabled = false;
await syncFileShaBlocklists({}, createHelpers());
const remaining = (await getAdminRepository().loadAllBannedFileShas()).map((row) => row.sha256_hex).sort();
expect(remaining).toEqual([ADMIN_BAZAAR_SHA, ADMIN_MANUAL_SHA, UNOWNED_NCMEC_SHA]);
expect(publishCalls()).toEqual([[BANNED_FILE_SHAS_REFRESH_CHANNEL, 'refresh']]);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('keeps a file-SHA ban an Admin took over after the purge read the table', async () => {
const adminRepository = getAdminRepository();
await adminRepository.banFileSha(fileShaRow(FEED_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, null));
const staleRows = await adminRepository.loadAllBannedFileShas();
await adminRepository.banFileSha(fileShaRow(FEED_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, ADMIN_USER_ID));
const staleRead = vi.spyOn(adminRepository, 'loadAllBannedFileShas').mockResolvedValueOnce(staleRows);
Config.blocklistFeeds.enabled = false;
await syncFileShaBlocklists({}, createHelpers());
staleRead.mockRestore();
expect(await adminRepository.isFileShaBanned(FEED_SHA)).toBe(true);
});
it('file-SHA cache skips feed rows only while feeds are off', async () => {
await seedFileShas();
Config.blocklistFeeds.enabled = false;
await fileShaCache.refresh();
expect(fileShaCache.isBanned(FEED_SHA)).toBe(false);
expect(fileShaCache.isBanned(ADMIN_BAZAAR_SHA)).toBe(true);
expect(fileShaCache.isBanned(ADMIN_MANUAL_SHA)).toBe(true);
expect(fileShaCache.isBanned(UNOWNED_NCMEC_SHA)).toBe(true);
Config.blocklistFeeds.enabled = true;
await fileShaCache.refresh();
expect(fileShaCache.isBanned(FEED_SHA)).toBe(true);
});
it('deletes the URL feed file and never fetches while feeds are off', async () => {
await harness.storageService.uploadObject({
bucket: FEED_BUCKET,
key: FEED_KEY,
body: Buffer.from('https://feed.example/x\n'),
});
Config.blocklistFeeds.enabled = false;
await syncUrlBlocklists({}, createHelpers());
expect(harness.storageService.deleteObjectSpy).toHaveBeenCalledWith(FEED_BUCKET, FEED_KEY);
expect(harness.storageService.hasObject(FEED_BUCKET, FEED_KEY)).toBe(false);
expect(publishCalls()).toEqual([[BANNED_URLS_REFRESH_CHANNEL, 'refresh']]);
expect(fetchSpy).not.toHaveBeenCalled();
const quietLogger = new NoopLogger();
const quietWarn = vi.spyOn(quietLogger, 'warn');
vi.spyOn(harness.storageService, 'deleteObject').mockRejectedValueOnce(
Object.assign(new Error('The specified bucket does not exist'), {name: 'NoSuchBucket'}),
);
await expect(syncUrlBlocklists({}, createHelpers({logger: quietLogger}))).resolves.toBeUndefined();
expect(quietWarn).not.toHaveBeenCalled();
const loudLogger = new NoopLogger();
const loudWarn = vi.spyOn(loudLogger, 'warn');
harness.storageService.configure({shouldFailDelete: true});
await expect(syncUrlBlocklists({}, createHelpers({logger: loudLogger}))).resolves.toBeUndefined();
expect(loudWarn).toHaveBeenCalledTimes(1);
});
it('URL cache ignores the feed file only while feeds are off', async () => {
const feedUrl = canonicalizeUrl('https://feed.example/x');
const adminUrl = canonicalizeUrl('https://admin.example/y');
expect(feedUrl).not.toBeNull();
expect(adminUrl).not.toBeNull();
await harness.storageService.uploadObject({
bucket: FEED_BUCKET,
key: FEED_KEY,
body: Buffer.from(`${feedUrl}\n`),
});
await getAdminRepository().banUrl({
url_canonical: adminUrl!,
category: ContentBlocklistCategory.MANUAL,
severity: 2,
source_url: null,
added_at: new Date(),
added_by: ADMIN_USER_ID,
notes: null,
});
urlBlocklistCache.setStorageService(harness.storageService);
Config.blocklistFeeds.enabled = false;
await urlBlocklistCache.refresh();
expect(urlBlocklistCache.isUrlBanned('https://feed.example/x')).toBe(false);
expect(urlBlocklistCache.isUrlBanned('https://admin.example/y')).toBe(true);
Config.blocklistFeeds.enabled = true;
await urlBlocklistCache.refresh();
expect(urlBlocklistCache.isUrlBanned('https://feed.example/x')).toBe(true);
});
});
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {spawnSync} from 'node:child_process';
import {createServer} from 'node:net';
import {AdminRepository} from '@app/api/admin/AdminRepository';
import {Config} from '@app/api/Config';
import {ContentBlocklistCategory} from '@app/api/constants/ContentModeration';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
import type {BannedFileShaRow} from '@app/api/database/types/AdminArchiveTypes';
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {MockStorageService} from '@app/api/test/mocks/MockStorageService';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import syncDisposableEmailDomains from '@app/api/worker/tasks/SyncDisposableEmailDomains';
import syncFileShaBlocklists from '@app/api/worker/tasks/SyncFileShaBlocklists';
import {
clearWorkerDependencies,
getWorkerDependencies,
setWorkerDependenciesForTest,
} from '@app/api/worker/WorkerContext';
import {
getDefaultPostgresClient,
type IPostgresClient,
initPostgres,
shutdownPostgres,
} from '@pkgs/postgres/src/Client';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const KV_TABLE = 'kv_blocklist_feeds_disabled';
const CONTAINER = `fluxer-feeds-${process.pid.toString(36)}-${Date.now().toString(36)}`;
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
const FEED_SHA = 'a1'.repeat(32);
const ADMIN_BAZAAR_SHA = 'b2'.repeat(32);
const ADMIN_MANUAL_SHA = 'c3'.repeat(32);
const ADMIN_USER_ID = 1_234_567_890_123n;
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (typeof address === 'string' || address === null) {
reject(new Error('no port'));
return;
}
const port = address.port;
server.close(() => resolve(port));
});
});
}
function createHelpers(): WorkerTaskHelpers {
return {
logger: new NoopLogger(),
jobId: 4242n,
addJob: async () => 0n,
reportProgress: async () => {},
shouldCancel: async () => false,
setContextLink: async () => {},
};
}
function fileShaRow(sha256Hex: string, category: string, addedBy: bigint | null): BannedFileShaRow {
return {
sha256_hex: sha256Hex,
category,
severity: 2,
content_type: null,
source_url: null,
added_at: new Date(),
added_by: addedBy,
notes: null,
};
}
describe.skipIf(!dockerAvailable)('blocklist feeds turned off against postgres', () => {
let raw: IPostgresClient;
let executor: PostgresKvQueryExecutor;
let previousFeedsEnabled: boolean;
beforeAll(async () => {
const port = await freePort();
startDockerContainer([
'run',
'-d',
'--name',
CONTAINER,
'-e',
'POSTGRES_USER=fluxer',
'-e',
'POSTGRES_PASSWORD=fluxer',
'-e',
'POSTGRES_DB=fluxer',
'-p',
`127.0.0.1:${port}:5432`,
'postgres:16-alpine',
'-c',
'fsync=off',
]);
let ready = false;
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
await sleep(500);
const probe = spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
stdio: 'ignore',
});
if (probe.status !== 0) continue;
try {
await initPostgres({
url: `postgres://fluxer:[email protected]:${port}/fluxer`,
maxConnections: 4,
kvTable: KV_TABLE,
});
await getDefaultPostgresClient().query('SELECT 1');
ready = true;
} catch {
await shutdownPostgres().catch(() => {});
}
}
if (!ready) throw new Error('postgres never came up');
raw = getDefaultPostgresClient();
await ensurePostgresKvSchema(raw);
executor = new PostgresKvQueryExecutor(raw);
}, 900_000);
beforeEach(async () => {
previousFeedsEnabled = Config.blocklistFeeds.enabled;
await raw.query(`DELETE FROM ${KV_TABLE}`);
setCassandraQueryExecutorForTesting(executor);
setWorkerDependenciesForTest({
adminRepository: new AdminRepository(),
kvClient: new MockKVProvider(),
storageService: new MockStorageService(),
});
Config.blocklistFeeds.enabled = false;
});
afterEach(() => {
Config.blocklistFeeds.enabled = previousFeedsEnabled;
});
afterAll(async () => {
clearWorkerDependencies();
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
await shutdownPostgres().catch(() => {});
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
});
it('removes every disposable_email_domains row from the KV table', async () => {
const repository = new AdminRepository();
for (let i = 0; i < 1200; i++) {
await repository.addDisposableEmailDomain(`disposable-${i}.example`);
}
const seeded = await raw.query<{count: number}>(
`SELECT count(*)::int AS count FROM ${KV_TABLE} WHERE table_name = 'disposable_email_domains'`,
);
expect(seeded.rows[0]?.count).toBe(1200);
await syncDisposableEmailDomains({}, createHelpers());
const remaining = await raw.query<{count: number}>(
`SELECT count(*)::int AS count FROM ${KV_TABLE} WHERE table_name = 'disposable_email_domains'`,
);
expect(remaining.rows[0]?.count).toBe(0);
});
it('keeps file-SHA rows with added_by set and removes feed rows stored with a JSON null added_by', async () => {
const repository = new AdminRepository();
await repository.banFileSha(fileShaRow(FEED_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, null));
await repository.banFileSha(fileShaRow(ADMIN_BAZAAR_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, ADMIN_USER_ID));
await repository.banFileSha(fileShaRow(ADMIN_MANUAL_SHA, ContentBlocklistCategory.MANUAL, ADMIN_USER_ID));
const feedRow = await raw.query<{added_by_type: string}>(
`SELECT jsonb_typeof(row_data->'added_by') AS added_by_type FROM ${KV_TABLE}
WHERE table_name = 'banned_file_shas' AND row_data->>'sha256_hex' = $1`,
[FEED_SHA],
);
expect(feedRow.rows).toEqual([{added_by_type: 'null'}]);
await syncFileShaBlocklists({}, createHelpers());
const remaining = await raw.query<{sha: string}>(
`SELECT row_data->>'sha256_hex' AS sha FROM ${KV_TABLE} WHERE table_name = 'banned_file_shas' ORDER BY 1`,
);
expect(remaining.rows.map((row) => row.sha)).toEqual([ADMIN_BAZAAR_SHA, ADMIN_MANUAL_SHA]);
});
it('keeps a file-SHA ban an Admin took over after the purge read the table', async () => {
const {adminRepository} = getWorkerDependencies();
await adminRepository.banFileSha(fileShaRow(FEED_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, null));
const staleRows = await adminRepository.loadAllBannedFileShas();
await adminRepository.banFileSha(fileShaRow(FEED_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, ADMIN_USER_ID));
const staleRead = vi.spyOn(adminRepository, 'loadAllBannedFileShas').mockResolvedValueOnce(staleRows);
await syncFileShaBlocklists({}, createHelpers());
staleRead.mockRestore();
expect(await adminRepository.isFileShaBanned(FEED_SHA)).toBe(true);
});
});
@@ -94,7 +94,7 @@ function createQueue(params: {
dlqExists?: boolean;
reject?: (config: Partial<StreamConfig>) => Error | null;
updateError?: Error;
publish?: (subject: string) => {seq: number};
publish?: (subject: string, body: string, options: {msgID: string}) => {seq: number; duplicate?: boolean};
subjectCounts?: Record<string, number>;
subjectCountsError?: Error;
}): {
@@ -171,9 +171,9 @@ function createQueue(params: {
},
}),
getJetStreamClient: () => ({
publish: (subject: string) => {
publish: (subject: string, body: string, options: {msgID: string}) => {
const publish = params.publish ?? (() => ({seq: 1}));
return Promise.resolve(publish(subject));
return Promise.resolve(publish(subject, body, options));
},
}),
} as unknown as JetStreamConnectionManager;
@@ -281,6 +281,27 @@ describe('jobs stream limits', () => {
});
});
describe('jobs stream max age', () => {
it('reports the max age of a jobs stream it creates', async () => {
const {queue, added} = createQueue({existing: null});
await queue.ensureStream();
expect(queue.getJobsStreamMaxAgeMs()).toBe(7 * 24 * 60 * 60 * 1000);
expect(added[0]?.max_age).toBe(queue.getJobsStreamMaxAgeMs() * 1_000_000);
});
it('reports the max age an existing jobs stream really has and never changes it', async () => {
for (const [maxAgeNanos, expectedMs] of [
[30 * 24 * 60 * 60 * 1_000_000_000, 30 * 24 * 60 * 60 * 1000],
[0, 0],
] as const) {
const {queue, updated} = createQueue({existing: {...LEGACY_CONFIG, max_age: maxAgeNanos} as StreamConfig});
await queue.ensureStream();
expect(queue.getJobsStreamMaxAgeMs()).toBe(expectedMs);
expect(updated.some((config) => 'max_age' in config)).toBe(false);
}
});
});
describe('dead-letter stream', () => {
it('keeps startup alive when the dead-letter stream does not fit', async () => {
const {queue, dlqAdded} = createQueue({dlqExists: false, reject: () => noStorageError()});
@@ -298,16 +319,16 @@ describe('dead-letter stream', () => {
describe('jobs stream enqueue shedding', () => {
it('rejects enqueues once the stream is at its cap', async () => {
const {queue} = createQueue({existing: LEGACY_CONFIG, publish: boundedPublisher(2)});
await expect(queue.enqueue('extractEmbeds', {})).resolves.toBe('1');
await expect(queue.enqueue('extractEmbeds', {})).resolves.toBe('2');
await expect(queue.enqueue('extractEmbeds', {})).resolves.toEqual({seq: '1', duplicate: false});
await expect(queue.enqueue('extractEmbeds', {})).resolves.toEqual({seq: '2', duplicate: false});
await expect(queue.enqueue('extractEmbeds', {})).rejects.toBeInstanceOf(WorkerQueueOverflowError);
});
it('caps each task type independently', async () => {
const {queue} = createQueue({existing: LEGACY_CONFIG, publish: boundedPublisher(1)});
await expect(queue.enqueue('extractEmbeds', {})).resolves.toBe('1');
await expect(queue.enqueue('extractEmbeds', {})).resolves.toEqual({seq: '1', duplicate: false});
await expect(queue.enqueue('extractEmbeds', {})).rejects.toBeInstanceOf(WorkerQueueOverflowError);
await expect(queue.enqueue('handleMentions', {})).resolves.toBe('2');
await expect(queue.enqueue('handleMentions', {})).resolves.toEqual({seq: '2', duplicate: false});
});
it('sheds enqueues the server refuses for lack of resources', async () => {
@@ -320,6 +341,29 @@ describe('jobs stream enqueue shedding', () => {
await expect(queue.enqueue('handleMentions', {})).rejects.toBeInstanceOf(WorkerQueueOverflowError);
});
it('reports a publish the stream deduplicated under the same job key', async () => {
const seen = new Map<string, number>();
const {queue} = createQueue({
existing: LEGACY_CONFIG,
publish: (_subject, _body, options) => {
const existing = seen.get(options.msgID);
if (existing !== undefined) return {seq: existing, duplicate: true};
seen.set(options.msgID, seen.size + 1);
return {seq: seen.size, duplicate: false};
},
});
const options = {jobKey: 'batch-audit-log-message-deletes:1'};
await expect(queue.enqueue('batchGuildAuditLogMessageDeletes', {}, options)).resolves.toEqual({
seq: '1',
duplicate: false,
});
await expect(queue.enqueue('batchGuildAuditLogMessageDeletes', {}, options)).resolves.toEqual({
seq: '1',
duplicate: true,
});
await expect(queue.enqueue('batchGuildAuditLogMessageDeletes', {})).resolves.toEqual({seq: '2', duplicate: false});
});
it('rethrows publish failures that are not stream limits', async () => {
const failure = new Error('no responders');
const {queue} = createQueue({
@@ -7,6 +7,7 @@ import {WorkerService} from '@app/api/worker/WorkerService';
import {describe, expect, test} from 'vitest';
const JOB_ID = 4242n;
const CREATED_AT = new Date('2026-09-21T12:00:00.000Z');
function createSnowflake(): ISnowflakeService {
return {
@@ -14,17 +15,24 @@ function createSnowflake(): ISnowflakeService {
} as unknown as ISnowflakeService;
}
function createHarness(options?: {createJobError?: Error; enqueueError?: Error}) {
function createHarness(options?: {
createJobError?: Error;
enqueueError?: Error;
duplicate?: boolean;
discardError?: Error;
}) {
const calls: Array<string> = [];
const createdJobs: Array<CreateJobInput> = [];
const enqueued: Array<{taskType: string; payload: Record<string, unknown>}> = [];
const seqUpdates: Array<{jobId: bigint; seq: string}> = [];
const deadletters: Array<{jobId: bigint; errorMessage: string}> = [];
const discarded: Array<{jobId: bigint; createdAt: Date}> = [];
const ledger = {
createJob: async (input: CreateJobInput) => {
calls.push('createJob');
if (options?.createJobError) throw options.createJobError;
createdJobs.push(input);
return CREATED_AT;
},
setJetStreamSeq: async (jobId: bigint, seq: string) => {
calls.push('setJetStreamSeq');
@@ -34,17 +42,22 @@ function createHarness(options?: {createJobError?: Error; enqueueError?: Error})
calls.push('markDeadletter');
deadletters.push({jobId, errorMessage});
},
discardJob: async (jobId: bigint, createdAt: Date) => {
calls.push('discardJob');
if (options?.discardError) throw options.discardError;
discarded.push({jobId, createdAt});
},
} as unknown as IJobLedgerRepository;
const queue = {
enqueue: async (taskType: string, payload: Record<string, unknown>) => {
calls.push('enqueue');
if (options?.enqueueError) throw options.enqueueError;
enqueued.push({taskType, payload});
return 'seq-9';
return {seq: 'seq-9', duplicate: options?.duplicate === true};
},
} as unknown as JetStreamWorkerQueue;
const service = new WorkerService(queue, createSnowflake(), ledger);
return {service, calls, createdJobs, enqueued, seqUpdates, deadletters};
return {service, calls, createdJobs, enqueued, seqUpdates, deadletters, discarded};
}
describe('WorkerService ledger ordering', () => {
@@ -88,6 +101,42 @@ describe('WorkerService ledger ordering', () => {
expect(harness.deadletters).toEqual([{jobId: JOB_ID, errorMessage: 'stream unreachable'}]);
});
test('discards the ledger row when the stream already holds a job under the same key', async () => {
const harness = createHarness({duplicate: true});
const jobId = await harness.service.addJob(
'batchGuildAuditLogMessageDeletes',
{guildId: '1'},
{jobKey: 'batch-audit-log-message-deletes:1'},
);
expect(jobId).toBe(JOB_ID);
expect(harness.calls).toEqual(['createJob', 'enqueue', 'discardJob']);
expect(harness.discarded).toEqual([{jobId: JOB_ID, createdAt: CREATED_AT}]);
expect(harness.seqUpdates).toEqual([]);
});
test('still returns the job id when discarding the duplicate ledger row fails', async () => {
const harness = createHarness({duplicate: true, discardError: new Error('write timeout')});
const jobId = await harness.service.addJob(
'batchGuildAuditLogMessageDeletes',
{guildId: '1'},
{jobKey: 'batch-audit-log-message-deletes:1'},
);
expect(jobId).toBe(JOB_ID);
expect(harness.calls).toEqual(['createJob', 'enqueue', 'discardJob']);
});
test('does not touch the ledger for a duplicate the caller never ledgered', async () => {
const harness = createHarness({duplicate: true});
await harness.service.addJob('handleMentions', {}, {skipLedger: true, jobKey: 'mentions:1'});
expect(harness.calls).toEqual(['enqueue']);
});
test('never touches the ledger when the caller skips it', async () => {
const harness = createHarness();
+10 -66
View File
@@ -52,69 +52,13 @@ async function renderSVGToBuffer(svgContent: string, size: number): Promise<Buff
return sharp(Buffer.from(fixed)).resize(size, size).png().toBuffer();
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
h = ((h % 360) + 360) % 360;
h /= 360;
let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const hueToRgb = (p: number, q: number, t: number): number => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
r = hueToRgb(p, q, h + 1 / 3);
g = hueToRgb(p, q, h);
b = hueToRgb(p, q, h - 1 / 3);
}
return [
Math.round(Math.min(1, Math.max(0, r)) * 255),
Math.round(Math.min(1, Math.max(0, g)) * 255),
Math.round(Math.min(1, Math.max(0, b)) * 255),
];
}
async function createPlaceholder(size: number): Promise<Buffer> {
const h = Math.random() * 360;
const [r, g, b] = hslToRgb(h, 0.7, 0.6);
const radius = Math.floor(size * 0.4);
const cx = Math.floor(size / 2);
const cy = Math.floor(size / 2);
const svg = `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<circle cx="${cx}" cy="${cy}" r="${radius}" fill="rgb(${r},${g},${b})"/>
</svg>`;
return sharp(Buffer.from(svg)).png().toBuffer();
}
async function loadEmojiImage(surrogate: string, size: number): Promise<Buffer> {
const codepoint = convertToCodePoints(surrogate);
const svg = loadLocalTwemojiSVG(codepoint);
if (svg) {
try {
return await renderSVGToBuffer(svg, size);
} catch (error) {
console.error(`Failed to render SVG for ${codepoint}:`, error);
}
if (svg == null) {
throw new Error(`Missing SVG for ${codepoint} (${surrogate})`);
}
if (codepoint.includes('-200d-')) {
const basePart = codepoint.split('-200d-')[0];
const baseSvg = loadLocalTwemojiSVG(basePart);
if (baseSvg) {
try {
return await renderSVGToBuffer(baseSvg, size);
} catch (error) {
console.error(`Failed to render base SVG for ${basePart}:`, error);
}
}
}
console.error(`Missing SVG for ${codepoint} (${surrogate}), using placeholder`);
return createPlaceholder(size);
return renderSVGToBuffer(svg, size);
}
async function renderSpriteSheet(
@@ -164,11 +108,11 @@ async function renderSpriteSheet(
}
async function generateMainSpriteSheet(
emojiData: Record<string, Array<EmojiObject>>,
categories: Record<string, Array<EmojiObject>>,
outputDir: string,
): Promise<void> {
const base: Array<EmojiEntry> = [];
for (const objs of Object.values(emojiData)) {
for (const objs of Object.values(categories)) {
for (const obj of objs) {
base.push({surrogates: obj.surrogates});
}
@@ -177,7 +121,7 @@ async function generateMainSpriteSheet(
}
async function generateSkinToneSpriteSheets(
emojiData: Record<string, Array<EmojiObject>>,
categories: Record<string, Array<EmojiObject>>,
outputDir: string,
): Promise<void> {
const skinTones = ['\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
@@ -185,7 +129,7 @@ async function generateSkinToneSpriteSheets(
const skinTone = skinTones[skinIndex];
const skinCodepoint = convertToCodePoints(skinTone);
const skinEntries: Array<EmojiEntry> = [];
for (const objs of Object.values(emojiData)) {
for (const objs of Object.values(categories)) {
for (const obj of objs) {
if (obj.skins && obj.skins.length > skinIndex && obj.skins[skinIndex].surrogates) {
skinEntries.push({surrogates: obj.skins[skinIndex].surrogates});
@@ -242,11 +186,11 @@ async function main(): Promise<void> {
const outputDir = join(appDir, 'src', 'media', 'images', 'emoji-sprites');
mkdirSync(outputDir, {recursive: true});
const emojiDataPath = join(appDir, 'src', 'media', 'data', 'emojis.json');
const emojiData: Record<string, Array<EmojiObject>> = JSON.parse(readFileSync(emojiDataPath, 'utf-8'));
const emojiData: {categories: Record<string, Array<EmojiObject>>} = JSON.parse(readFileSync(emojiDataPath, 'utf-8'));
console.log('Generating main sprite sheet...');
await generateMainSpriteSheet(emojiData, outputDir);
await generateMainSpriteSheet(emojiData.categories, outputDir);
console.log('Generating skin tone sprite sheets...');
await generateSkinToneSpriteSheets(emojiData, outputDir);
await generateSkinToneSpriteSheets(emojiData.categories, outputDir);
console.log('Generating picker sprite sheet...');
await generatePickerSpriteSheet(outputDir);
console.log('Emoji sprites generated successfully.');
@@ -4,7 +4,6 @@ import * as ChannelStickerCommands from '@app/features/channel/commands/ChannelS
import styles from '@app/features/channel/components/ChannelStickersArea.module.css';
import ChannelSticker from '@app/features/channel/state/ChannelSticker';
import {useStickerAnimation} from '@app/features/emoji/hooks/useStickerAnimation';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import {StickerContextMenuItems} from '@app/features/ui/action_menu/items/StickerContextMenuItems';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
@@ -16,7 +15,6 @@ import {TrashIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useEffect, useState} from 'react';
const REMOVE_STICKER_DESCRIPTOR = msg({
message: 'Remove sticker',
@@ -33,15 +31,6 @@ export const ChannelStickersArea: React.FC<ChannelStickersAreaProps> = observer(
const {i18n} = useLingui();
const sticker = ChannelSticker.getPendingSticker(channelId);
const {shouldAnimate, interactionHandlers} = useStickerAnimation({isAnimated: sticker?.animated ?? false});
const [previousSticker, setPreviousSticker] = useState(sticker);
useEffect(() => {
if (previousSticker && !sticker) {
ComponentBus.dispatch('FORCE_JUMP_TO_PRESENT');
} else if (!previousSticker && sticker) {
ComponentBus.dispatch('FORCE_JUMP_TO_PRESENT');
}
setPreviousSticker(sticker);
}, [sticker, previousSticker]);
if (!sticker) {
return null;
}
@@ -707,7 +707,8 @@ export const LexicalChannelTextareaContent = observer(
const stickerBoundaryChanged = isSameChannel && previous.hasPendingSticker !== hasPendingSticker;
if (
wasAtBottomBeforeComposerBoundaryChange.current &&
(stickerBoundaryChanged || (attachmentBoundaryChanged && Messages.getMessages(channel.id).hasMoreAfter))
(stickerBoundaryChanged || attachmentBoundaryChanged) &&
Messages.getMessages(channel.id).hasMoreAfter
) {
ComponentBus.dispatch('FORCE_JUMP_TO_PRESENT', {channelId: channel.id});
}
@@ -3,7 +3,6 @@
import {
canReportMessage,
createMessageActionHandlers,
getCopyableMessageText,
getEffectiveContent,
isClientSystemMessage,
isEmbedsSuppressed,
@@ -180,7 +179,6 @@ export const useMessageActionMenuData = (
);
const developerMode = UserSettings.developerMode;
const effectiveContent = useMemo(() => getEffectiveContent(message), [message]);
const copyableMessageText = useMemo(() => getCopyableMessageText(message, i18n), [message, i18n.locale]);
const canManageMessages = useMemo(
() =>
permissions != null &&
@@ -366,7 +364,7 @@ export const useMessageActionMenuData = (
),
});
}
if (copyableMessageText) {
if (effectiveContent) {
utilityActions.push({
id: messageActionMenuItemIds.copyMessage,
icon: <CopyMessageTextIcon size={20} data-flx="channel.message-action-menu.groups.copy-message-text-icon" />,
@@ -460,7 +458,6 @@ export const useMessageActionMenuData = (
isSpeaking,
voiceReady,
effectiveContent,
copyableMessageText,
handleSpeakMessage,
handleReportMessage,
handleDebugMessage,
@@ -18,7 +18,6 @@ import * as SavedMessageCommands from '@app/features/messaging/commands/SavedMes
import {ForwardModal, type ForwardModalSuccess} from '@app/features/messaging/components/modals/ForwardModal';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import SavedMessages from '@app/features/messaging/state/SavedMessages';
import {buildRawMessageContentCopyText} from '@app/features/messaging/utils/MessageCopyTextUtils';
import {buildMessageJumpLink} from '@app/features/messaging/utils/MessageLinkUtils';
import {retryFailedMessage} from '@app/features/messaging/utils/MessageRetryUtils';
import {type ReactionEmoji, toReactionEmoji} from '@app/features/messaging/utils/ReactionUtils';
@@ -94,10 +93,6 @@ export function getEffectiveContent(message: Message): string {
return '';
}
export function getCopyableMessageText(message: Message, _i18n: I18n): string {
return buildRawMessageContentCopyText(message);
}
export function isEmbedsSuppressed(message: Message): boolean {
return (message.flags & MessageFlags.SUPPRESS_EMBEDS) !== 0;
}
@@ -324,7 +319,7 @@ export function createMessageActionHandlers(
onClose?.();
};
const handleCopyMessage = () => {
const content = getCopyableMessageText(message, i18n);
const content = getEffectiveContent(message);
if (content) {
TextCopyCommands.copy(i18n, content);
onClose?.();
@@ -544,7 +539,7 @@ export function requestMessageForward(
}
export function requestCopyMessageText(message: Message, i18n: I18n): void {
const content = getCopyableMessageText(message, i18n);
const content = getEffectiveContent(message);
if (!content) return;
void TextCopyCommands.copy(i18n, content);
}
@@ -4,15 +4,12 @@ import type {MessageAttachment} from '@fluxer/schema/src/domains/message/Message
export const ATTACHMENT_CARD_WIDTH = 400;
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', 'image/avif'];
const VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
function isImageType(contentType?: string): boolean {
return contentType ? IMAGE_TYPES.includes(contentType) : false;
return contentType?.startsWith('image/') ?? false;
}
function isVideoType(contentType?: string): boolean {
return contentType ? VIDEO_TYPES.includes(contentType) : false;
return contentType?.startsWith('video/') ?? false;
}
const hasRenderableDimensions = (attachment: MessageAttachment): boolean =>
@@ -1,8 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const EYE_IN_SPEECH_BUBBLE = '\u{1F441}\u200D\u{1F5E8}';
export function convertToCodePoints(emoji: string): string {
const containsZWJ = emoji.includes('\u200D');
const processedEmoji = containsZWJ ? emoji : emoji.replace(/\uFE0F/g, '');
const emojiWithoutFE0F = emoji.replace(/\uFE0F/g, '');
const keepsFE0F = emoji.includes('\u200D') && emojiWithoutFE0F !== EYE_IN_SPEECH_BUBBLE;
const processedEmoji = keepsFE0F ? emoji : emojiWithoutFE0F;
return Array.from(processedEmoji)
.map((char) => char.codePointAt(0)?.toString(16).replace(/^0+/, '') || '')
.join('-');
@@ -2,6 +2,7 @@
import type {UnicodeEmoji} from '@app/features/emoji/types/EmojiTypes';
import * as EmojiUtils from '@app/features/expressions/utils/EmojiUtils';
import type {EmojiSurrogateMatch} from '@app/features/messaging/utils/markdown/parser/EmojiParsers';
import * as RegexUtils from '@app/features/messaging/utils/RegexUtils';
import emojiData from '@app/media/data/emojis.json';
import {SKIN_TONE_SURROGATES} from '@fluxer/constants/src/EmojiConstants';
@@ -60,6 +61,15 @@ const categories = Object.freeze(Object.keys(emojiData.categories));
const toCanonicalSurrogate = (surrogate: string): string => surrogate.replace(/️/g, '');
const EYE_IN_SPEECH_BUBBLE_FULLY_QUALIFIED = '\u{1F441}\uFE0F\u200D\u{1F5E8}\uFE0F';
const HAIR_COMPONENT_NAMES: Record<string, string> = {
'\u{1F9B0}': 'red_hair',
'\u{1F9B1}': 'curly_hair',
'\u{1F9B3}': 'white_hair',
'\u{1F9B2}': 'bald',
};
const REGIONAL_INDICATOR_PAIR_PATTERN = '\\uD83C[\\uDDE6-\\uDDFF]\\uD83C[\\uDDE6-\\uDDFF]';
let defaultSkinTone: string = '';
class UnicodeEmojiClass {
@@ -216,6 +226,11 @@ function buildEmojiIndex(): EmojiIndex {
surrogateToName[skinToneEntry.surrogatePair] = skinToneEntry.name;
canonicalSurrogateToName[toCanonicalSurrogate(skinToneEntry.surrogatePair)] = skinToneEntry.name;
});
const skins = (emojiObject as {skins?: ReadonlyArray<{names: ReadonlyArray<string>; surrogates: string}>}).skins;
skins?.slice(SKIN_TONE_SURROGATES.length).forEach((skin) => {
surrogateToName[skin.surrogates] = skin.names[0];
canonicalSurrogateToName[toCanonicalSurrogate(skin.surrogates)] = skin.names[0];
});
categoryByEmojiName[emoji.uniqueName] = category;
emojis.push(emojiJson);
return emojiJson;
@@ -228,6 +243,20 @@ function buildEmojiIndex(): EmojiIndex {
canonicalSurrogateToName[toCanonicalSurrogate(surrogatePair)] = `skin-tone-${index + 1}`;
});
Object.entries(HAIR_COMPONENT_NAMES).forEach(([surrogate, name]) => {
surrogateToName[surrogate] = name;
canonicalSurrogateToName[surrogate] = name;
});
Object.keys(surrogateToName)
.filter((surrogate) => surrogate.includes('\u20E3'))
.map(toCanonicalSurrogate)
.concat(EYE_IN_SPEECH_BUBBLE_FULLY_QUALIFIED)
.forEach((alias) => {
const name = canonicalSurrogateToName[toCanonicalSurrogate(alias)];
if (name) surrogateToName[alias] = name;
});
const keywordOwner: Record<string, string> = {};
const keywordCount: Record<string, number> = {};
@@ -275,7 +304,7 @@ function buildEmojiIndex(): EmojiIndex {
emojis,
skinToneSpriteCount,
baseSpriteCount,
emojiSurrogateRegex: new RegExp(`(${surrogateAlternation})`, 'g'),
emojiSurrogateRegex: new RegExp(`(${REGIONAL_INDICATOR_PAIR_PATTERN}|${surrogateAlternation})`, 'g'),
emojiShortcutRegex: new RegExp(`^(${shortcutAlternation})`),
};
}
@@ -288,6 +317,50 @@ function getEmojiIndex(): EmojiIndex {
const lookupSurrogateName = (surrogate: string): string | null =>
getEmojiIndex().canonicalSurrogateToName[toCanonicalSurrogate(surrogate)] ?? null;
const isRegionalIndicatorAt = (text: string, index: number): boolean => {
const lowSurrogate = text.charCodeAt(index + 1);
return text.charCodeAt(index) === 0xd83c && lowSurrogate >= 0xdde6 && lowSurrogate <= 0xddff;
};
const isInsideRegionalIndicatorPair = (text: string, index: number): boolean => {
if (!isRegionalIndicatorAt(text, index)) return false;
let runStart = index;
while (isRegionalIndicatorAt(text, runStart - 2)) runStart -= 2;
return (index - runStart) % 4 === 2;
};
const toEmojiSurrogateMatch = (text: string, start: number, end: number): EmojiSurrogateMatch => ({
start,
end,
name: lookupSurrogateName(text.slice(start, end)),
});
function* matchEmojiSurrogates(text: string, startIndex = 0): Generator<EmojiSurrogateMatch, void> {
const regex = getEmojiIndex().emojiSurrogateRegex;
let index = startIndex;
if (isInsideRegionalIndicatorPair(text, index)) {
yield toEmojiSurrogateMatch(text, index, index + 2);
index += 2;
}
while (true) {
regex.lastIndex = index;
const match = regex.exec(text);
if (match == null) return;
index = match.index + match[0].length;
const emojiMatch = toEmojiSurrogateMatch(text, match.index, index);
const isUnknownRegionalIndicatorPair =
emojiMatch.name == null &&
isRegionalIndicatorAt(text, match.index) &&
isRegionalIndicatorAt(text, match.index + 2);
if (isUnknownRegionalIndicatorPair) {
yield toEmojiSurrogateMatch(text, match.index, match.index + 2);
yield toEmojiSurrogateMatch(text, match.index + 2, index);
} else {
yield emojiMatch;
}
}
}
const EMOJI_SHORTCODE_RE = /^:([^\s:]+(?:::skin-tone-[0-9])?):/;
const categoryIcons = {
people: SmileyIcon,
@@ -377,6 +450,8 @@ export default {
getSurrogateName: (surrogate: string): string | null => {
return lookupSurrogateName(surrogate);
},
matchEmojiSurrogates,
isInsideRegionalIndicatorPair,
findEmojiByName: (emojiName: string): UnicodeEmoji | null => {
return getEmojiIndex().nameToEmoji[emojiName] || null;
},
@@ -400,8 +475,5 @@ export default {
get EMOTICON_PREFIX_RE(): RegExp {
return getEmojiIndex().emojiShortcutRegex;
},
get EMOJI_SURROGATE_RE(): RegExp {
return getEmojiIndex().emojiSurrogateRegex;
},
EMOJI_SPRITES,
};
@@ -29,17 +29,19 @@ export function registerComposerEmojiShortcode(editor: LexicalEditor, resolve: C
}
function findUnicodeEmoji(text: string, startIndex: number): TypedEmojiMatch | null {
const pattern = new RegExp(UnicodeEmojis.EMOJI_SURROGATE_RE.source, 'g');
pattern.lastIndex = startIndex;
const match = pattern.exec(text);
if (match == null) {
const matches = UnicodeEmojis.matchEmojiSurrogates(text, startIndex);
let match = matches.next().value;
if (match && UnicodeEmojis.isInsideRegionalIndicatorPair(text, match.end)) {
match = matches.next().value;
}
if (!match) {
return null;
}
const name = UnicodeEmojis.nameForSurrogate(match[0], false);
const name = match.name;
if (!name) {
return null;
}
return {start: match.index, end: match.index + match[0].length, name};
return {start: match.start, end: match.end, name};
}
interface EmojiToken extends TypedEmojiMatch {
@@ -248,10 +248,6 @@ export function buildMessageSnapshotCopyText(snapshot: MessageSnapshot, options:
return joinCopyBlocks(blocks);
}
export function buildRawMessageContentCopyText(message: Pick<Message, 'content'>): string {
return message.content;
}
export function buildUserMessageCopyText(message: Message, options: MessageCopyTextOptions): string {
const blocks: Array<string> = [];
const messageOptions: MessageCopyTextOptions = {
@@ -5,7 +5,7 @@ import {type EmojiProvider, setEmojiParserConfig} from '@app/features/messaging/
import {SKIN_TONE_SURROGATES} from '@fluxer/constants/src/EmojiConstants';
const emojiProvider: EmojiProvider = {
getSurrogateName: UnicodeEmojis.getSurrogateName,
matchEmojiSurrogates: UnicodeEmojis.matchEmojiSurrogates,
findEmojiByName: UnicodeEmojis.findEmojiByShortcodeName,
findEmojiWithSkinTone: UnicodeEmojis.findEmojiWithSkinTone,
};
@@ -13,9 +13,6 @@ const emojiProvider: EmojiProvider = {
export function initializeEmojiParser(): void {
setEmojiParserConfig({
emojiProvider,
get emojiRegex() {
return UnicodeEmojis.EMOJI_SURROGATE_RE;
},
skinToneSurrogates: SKIN_TONE_SURROGATES,
});
}
@@ -4,17 +4,21 @@ export interface UnicodeEmoji {
surrogates: string;
}
export interface EmojiSurrogateMatch {
start: number;
end: number;
name: string | null;
}
export interface EmojiProvider {
getSurrogateName(surrogate: string): string | null;
matchEmojiSurrogates(text: string): Iterable<EmojiSurrogateMatch>;
findEmojiByName(name: string): UnicodeEmoji | null;
findEmojiWithSkinTone(baseName: string, skinToneSurrogate: string): UnicodeEmoji | null;
}
export interface EmojiParserConfig {
emojiProvider?: EmojiProvider;
emojiRegex?: RegExp;
skinToneSurrogates?: ReadonlyArray<string>;
convertToCodePoints?: (emoji: string) => string;
}
let globalEmojiConfig: EmojiParserConfig | null = null;
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {convertToCodePoints} from '@app/features/expressions/utils/EmojiCodepointUtils';
import {flattenAST} from '@app/features/messaging/utils/markdown/parser/AstUtils';
import {getEmojiParserConfig} from '@app/features/messaging/utils/markdown/parser/EmojiParsers';
import {MARKDOWN_PARSER_WASM_BASE64} from '@app/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes';
@@ -46,11 +47,19 @@ const lenientTextDecoder = new TextDecoder('utf-8', {ignoreBOM: true, fatal: fal
let textDecoder = new TextDecoder('utf-8', {ignoreBOM: true, fatal: true});
let wasm: WasmExports | null = null;
let wasmModule: unknown = null;
let cachedMemory: Uint8Array | null = null;
let replacingOversizedInstance = false;
interface WasmInstance {
exports: Record<string, unknown>;
}
declare const WebAssembly: {
Module: new (bytes: Uint8Array) => unknown;
Instance: new (module: unknown, imports?: Record<string, unknown>) => {exports: Record<string, unknown>};
Instance: new (module: unknown, imports?: Record<string, unknown>) => WasmInstance;
compile(bytes: Uint8Array): Promise<unknown>;
instantiate(module: unknown, imports?: Record<string, unknown>): Promise<WasmInstance>;
};
function getWasmMemory(exports: Record<string, unknown>): WasmExports['memory'] {
@@ -93,22 +102,39 @@ function decodeBase64(value: string): Uint8Array {
return bytes;
}
function getWasm(): WasmExports {
if (!wasm) {
const module = new WebAssembly.Module(decodeBase64(MARKDOWN_PARSER_WASM_BASE64));
const instance = new WebAssembly.Instance(module, {});
wasm = createWasmExports(instance.exports);
cachedMemory = null;
}
function setWasmInstance(instance: WasmInstance): WasmExports {
wasm = createWasmExports(instance.exports);
cachedMemory = null;
return wasm;
}
export async function preloadMarkdownParserWasm(): Promise<void> {
if (wasm) return;
wasmModule ??= await WebAssembly.compile(decodeBase64(MARKDOWN_PARSER_WASM_BASE64));
const instance = await WebAssembly.instantiate(wasmModule, {});
if (!wasm) setWasmInstance(instance);
}
function getWasm(): WasmExports {
if (wasm) return wasm;
wasmModule ??= new WebAssembly.Module(decodeBase64(MARKDOWN_PARSER_WASM_BASE64));
return setWasmInstance(new WebAssembly.Instance(wasmModule, {}));
}
function releaseOversizedWasmMemory(): void {
if ((wasm?.memory.buffer.byteLength ?? 0) <= MAX_RETAINED_WASM_MEMORY_BYTES) {
if (replacingOversizedInstance || (wasm?.memory.buffer.byteLength ?? 0) <= MAX_RETAINED_WASM_MEMORY_BYTES) {
return;
}
wasm = null;
cachedMemory = null;
const oversized = wasm;
replacingOversizedInstance = true;
WebAssembly.instantiate(wasmModule, {})
.then((instance) => {
if (wasm === oversized) setWasmInstance(instance);
})
.catch(() => {})
.finally(() => {
replacingOversizedInstance = false;
});
}
function memoryU8(): Uint8Array {
@@ -202,14 +228,6 @@ class Utf8OffsetTracker {
}
}
function defaultCodepoints(emoji: string): string {
const containsZwJ = emoji.includes('‍');
const processed = containsZwJ ? emoji : emoji.replace(/️/g, '');
return Array.from(processed)
.map((char) => char.codePointAt(0)?.toString(16).replace(/^0+/, '') || '')
.join('-');
}
const PLAINTEXT_SYMBOLS = new Set(['™', '™️', '©', '©️', '®', '®️']);
const SPECIAL_SHORTCODES: Record<string, string> = {
tm: '™',
@@ -226,29 +244,23 @@ function buildEmojiContext(input: string): string {
const provider = config?.emojiProvider;
let context = '';
if (!provider) return context;
const convertToCodePoints = config.convertToCodePoints || defaultCodepoints;
const emojiRegex = config.emojiRegex;
if (emojiRegex) {
const offsetTracker = new Utf8OffsetTracker();
emojiRegex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = emojiRegex.exec(input)) !== null) {
const candidate = match[0];
if (!candidate || PLAINTEXT_SYMBOLS.has(candidate)) continue;
const name = provider.getSurrogateName(candidate);
if (!name) continue;
const candidateBytes = textEncoder.encode(candidate).byteLength;
const byteOffset = offsetTracker.offsetFor(input, match.index);
context += appendContextLine([
'S',
String(byteOffset),
String(candidateBytes),
candidate,
name,
convertToCodePoints(candidate),
]);
offsetTracker.advance(candidate.length, candidateBytes);
}
const offsetTracker = new Utf8OffsetTracker();
for (const match of provider.matchEmojiSurrogates(input)) {
const candidate = input.slice(match.start, match.end);
if (PLAINTEXT_SYMBOLS.has(candidate)) continue;
const name = match.name;
if (!name) continue;
const candidateBytes = textEncoder.encode(candidate).byteLength;
const byteOffset = offsetTracker.offsetFor(input, match.start);
context += appendContextLine([
'S',
String(byteOffset),
String(candidateBytes),
candidate,
name,
convertToCodePoints(candidate),
]);
offsetTracker.advance(candidate.length, candidateBytes);
}
const shortcodeRegex = /:([\p{L}\p{N}_-]+):/gu;
const seen = new Set<string>();
@@ -22,7 +22,9 @@ import {
matchesPushChannelNotification,
normalizePushPayload,
resolvePushChannelId,
resolvePushMessageId,
resolvePushNotificationTag,
shouldRenotifyPushNotification,
shouldSilenceNonMobilePushNotification,
} from '@app/features/platform/service_worker/WorkerPushPayload';
@@ -214,6 +216,17 @@ const closePushNotifications = async (tag: string | undefined): Promise<number>
return 0;
}
};
const getShownPushNotifications = async (tag: string): Promise<ReadonlyArray<Notification>> => {
if (typeof self.registration.getNotifications !== 'function') {
return [];
}
try {
return await self.registration.getNotifications({tag});
} catch (error) {
await log('error', 'push: failed to read shown notifications', {tag, error: describeError(error)});
return [];
}
};
const closePushNotificationsForChannel = async (channelId: string): Promise<number> => {
if (typeof self.registration.getNotifications !== 'function') {
return 0;
@@ -347,6 +360,9 @@ self.addEventListener('push', (event: PushEvent) => {
})) as ReadonlyArray<WindowClient>;
clientState = getPushNotificationClientState(clientList);
} catch {}
const renotify =
tag !== undefined &&
shouldRenotifyPushNotification(resolvePushMessageId(payload), await getShownPushNotifications(tag));
const options: NotificationOptions & {
renotify?: boolean;
} = {
@@ -355,7 +371,7 @@ self.addEventListener('push', (event: PushEvent) => {
badge: payload.badge ?? undefined,
data: payload.data ?? undefined,
tag,
renotify: tag !== undefined,
renotify,
...getNotificationAlertOptions({
mobileOrTablet: isMobileOrTabletUserAgent(workerNavigator.userAgent, workerNavigator.maxTouchPoints ?? 0),
silentOnNonMobile: shouldSilenceNonMobilePushNotification(clientState),
@@ -365,6 +381,7 @@ self.addEventListener('push', (event: PushEvent) => {
title,
hasBody: Boolean(payload.body),
tag,
renotify,
hasData: payload.data !== undefined,
badgeCount,
hasWindowClient: clientState.hasWindowClient,

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