mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 19:52:13 +09:00
Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d96a4574a | ||
|
|
61b4511ae4 | ||
|
|
137edc7cfb | ||
|
|
14e772a751 | ||
|
|
32afbf12d6 | ||
|
|
ffaf5119d8 | ||
|
|
10bc8c1efa | ||
|
|
85a03a9e39 | ||
|
|
51ee6567b4 | ||
|
|
090220a29d | ||
|
|
e7973b8be0 | ||
|
|
b5324c9223 | ||
|
|
edb8d80077 | ||
|
|
ddee116339 | ||
|
|
bdacaea4a8 | ||
|
|
9e28e02b5d | ||
|
|
3527dc95a2 | ||
|
|
27c7b2722d | ||
|
|
ba96f52ed6 | ||
|
|
2c8b3ff45c | ||
|
|
631bc2307a | ||
|
|
8f4f9a8601 | ||
|
|
1c920f966e | ||
|
|
2b1de38949 | ||
|
|
d5daf61dbd | ||
|
|
06c42ce02f | ||
|
|
4f21430880 | ||
|
|
9731bac40f | ||
|
|
b144e650f2 | ||
|
|
ef6536644c | ||
|
|
17eab43245 | ||
|
|
fbd7f1e3b8 | ||
|
|
25af7516a4 | ||
|
|
c020eded31 | ||
|
|
5331c0216a | ||
|
|
528777926c | ||
|
|
47b5c3d4f0 | ||
|
|
d9bfca66d6 | ||
|
|
ded51b65d3 | ||
|
|
ddc8837d4f | ||
|
|
df16957c95 | ||
|
|
f38b19d4bd | ||
|
|
f7cd4f2c74 | ||
|
|
a078392888 | ||
|
|
3060f23f8c | ||
|
|
6a5f0d4d29 | ||
|
|
91d989dc7c | ||
|
|
7c82804df6 | ||
|
|
b7de83f7fc | ||
|
|
97bd022bee | ||
|
|
62324df039 | ||
|
|
9f78b3d9a6 | ||
|
|
362a89f2b2 | ||
|
|
ce41960fcd | ||
|
|
2083eaddd6 | ||
|
|
d398ebc44b | ||
|
|
59887ad404 | ||
|
|
5aa283e8e0 | ||
|
|
d9ed256a4b | ||
|
|
a297f89b83 | ||
|
|
4a16921242 | ||
|
|
a6f83c4fb1 | ||
|
|
7b5c82c6cf | ||
|
|
30a61ce90f | ||
|
|
22bc2cab74 | ||
|
|
53df9a0d6d | ||
|
|
f9b7ec4d0b | ||
|
|
569abf57b7 | ||
|
|
ae8e32d809 | ||
|
|
a81b1abec7 | ||
|
|
8836565c32 | ||
|
|
a1b595d52f | ||
|
|
abb71ed558 | ||
|
|
c006d413ac | ||
|
|
fa11acae15 | ||
|
|
300f467ad0 | ||
|
|
698469fa96 | ||
|
|
591e9fe2ba | ||
|
|
d148b4e5b7 | ||
|
|
324f333bb5 | ||
|
|
9b0b703c9d | ||
|
|
5660972c71 | ||
|
|
b4a5eb77a8 | ||
|
|
4bbfee4cec | ||
|
|
9e89539f0a | ||
|
|
fa71eb8682 | ||
|
|
49b7127bc7 | ||
|
|
9cb8812be0 | ||
|
|
7d82d188a0 | ||
|
|
5ce5669f17 | ||
|
|
9ea750152e | ||
|
|
e87e2fe7bf | ||
|
|
871b5116dc | ||
|
|
d7b2e67c35 |
@@ -25,7 +25,7 @@ Closes #456
|
||||
|
||||
You must understand every line you submit and be able to explain why the change is correct.
|
||||
|
||||
The [LLM usage policy](LLM_USAGE_POLICY.md) defines the authorship requirements for contributors who do not have write access.
|
||||
The [LLM usage policy](https://github.com/fluxerapp/fluxer/blob/main/.github/LLM_USAGE_POLICY.md) defines the authorship requirements for contributors who do not have write access.
|
||||
|
||||
Each contribution must contain one coherent change. Do not include unrelated fixes, refactoring or formatting changes.
|
||||
|
||||
@@ -80,15 +80,15 @@ Complete every section of the pull request template. Clearly describe:
|
||||
|
||||
Use the [bug report form](https://github.com/fluxerapp/fluxer/issues/new?template=bug-report.yaml) to report reproducible defects.
|
||||
|
||||
Report security vulnerabilities privately through the channels specified in the [security policy](SECURITY.md). Do not report vulnerabilities in public issues or discussions.
|
||||
Report security vulnerabilities privately through the channels specified in the [security policy](https://github.com/fluxerapp/fluxer/blob/main/.github/SECURITY.md). Do not report vulnerabilities in public issues or discussions.
|
||||
|
||||
Use [discussions](https://github.com/orgs/fluxerapp/discussions) for feature proposals and self-hosting questions.
|
||||
|
||||
Submit translations through [Weblate](https://weblate.fluxer.tools), not through pull requests.
|
||||
|
||||
All repository activity is governed by the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
All repository activity is governed by the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
|
||||
|
||||
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
|
||||
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
|
||||
|
||||
## Private marketing project
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ External contributions do not grant voting rights, commit access, employment or
|
||||
|
||||
## Licence and contributor rights
|
||||
|
||||
Source code owned by Fluxer Platform AB in this repository is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). The licence permits its use, modification and redistribution subject to its terms.
|
||||
Source code owned by Fluxer Platform AB in this repository is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). The licence permits its use, modification and redistribution subject to its terms.
|
||||
|
||||
Fluxer Platform AB does not require contributors to sign a contributor licence agreement or assign their copyright. Contributors retain the copyright in their work.
|
||||
|
||||
Every commit made by a contributor must include the [Developer Certificate of Origin](https://developercertificate.org) sign-off required by the [contributing guidelines](CONTRIBUTING.md). Pull requests opened by Fluxer repository automation are exempt from this requirement.
|
||||
Every commit made by a contributor must include the [Developer Certificate of Origin](https://developercertificate.org) sign-off required by the [contributing guidelines](https://github.com/fluxerapp/fluxer/blob/main/.github/CONTRIBUTING.md). Pull requests opened by Fluxer repository automation are exempt from this requirement.
|
||||
|
||||
## Name and marks
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ Deliberately submitting a fabricated security report MAY result in an immediate
|
||||
|
||||
Maintainers are not required to investigate possible LLM use proactively. Writing style alone is not evidence of a violation.
|
||||
|
||||
A person MUST NOT publicly accuse or harass a contributor because of suspected LLM use. All discussion, review and enforcement under this policy MUST comply with the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
A person MUST NOT publicly accuse or harass a contributor because of suspected LLM use. All discussion, review and enforcement under this policy MUST comply with the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
|
||||
|
||||
## 11. Normative References
|
||||
|
||||
|
||||
@@ -106,10 +106,10 @@ jobs:
|
||||
|
||||
- name: upload assets to S3 static bucket
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_ENDPOINT: https://ewr1.vultrobjects.com
|
||||
STATIC_BUCKET: fluxer-static
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.STATIC_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.STATIC_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_ENDPOINT: ${{ vars.STATIC_S3_ENDPOINT }}
|
||||
STATIC_BUCKET: ${{ vars.STATIC_S3_BUCKET }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-app-proxy
|
||||
--step upload_assets
|
||||
|
||||
@@ -139,10 +139,10 @@ jobs:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: https://ewr1.vultrobjects.com
|
||||
S3_BUCKET: fluxer-downloads
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
DESKTOP_PLATFORM: ${{ matrix.platform }}
|
||||
DESKTOP_ARCH: ${{ matrix.arch }}
|
||||
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
|
||||
@@ -524,11 +524,11 @@ jobs:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: https://ewr1.vultrobjects.com
|
||||
S3_BUCKET: fluxer-downloads
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: "1.93.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Install pnpm
|
||||
@@ -286,7 +286,7 @@ jobs:
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: "1.93.0"
|
||||
components: rustfmt
|
||||
|
||||
- name: Sync ci helper dependencies
|
||||
|
||||
Generated
+9
@@ -1777,6 +1777,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
"clap",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper-util",
|
||||
@@ -1800,6 +1801,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"moka",
|
||||
"reqwest",
|
||||
@@ -1836,6 +1838,7 @@ dependencies = [
|
||||
"cc",
|
||||
"clap",
|
||||
"criterion",
|
||||
"fluxer_common",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"http 1.4.2",
|
||||
@@ -1875,6 +1878,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"criterion",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"fluxer_markdown_parser",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
@@ -1943,6 +1947,7 @@ dependencies = [
|
||||
"encoding_rs",
|
||||
"entities",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"infer",
|
||||
"moka",
|
||||
@@ -2038,10 +2043,14 @@ dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
"axum",
|
||||
"base64",
|
||||
"hmac 0.13.0",
|
||||
"maxminddb",
|
||||
"moka",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
"urlencoding",
|
||||
|
||||
@@ -31,5 +31,5 @@
|
||||
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
|
||||
|
||||
<p align="center">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-1920w.png" alt="Fluxer app showcase" width="900">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
|
||||
</p>
|
||||
|
||||
@@ -12573,6 +12573,15 @@
|
||||
"bluesky": {"type": "boolean"}
|
||||
},
|
||||
"required": ["gif", "youtube", "bluesky"]
|
||||
},
|
||||
"deferred_phone_gate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"window_hours": {"type": "number"},
|
||||
"member_threshold": {"type": "number"}
|
||||
},
|
||||
"required": ["enabled", "window_hours", "member_threshold"]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -12583,7 +12592,8 @@
|
||||
"premium_mode",
|
||||
"services",
|
||||
"services_resolved",
|
||||
"services_available"
|
||||
"services_available",
|
||||
"deferred_phone_gate"
|
||||
]
|
||||
},
|
||||
"integrations": {
|
||||
@@ -13110,6 +13120,21 @@
|
||||
"youtube_enabled": {"nullable": true, "type": "boolean"},
|
||||
"bluesky_enabled": {"nullable": true, "type": "boolean"}
|
||||
}
|
||||
},
|
||||
"deferred_phone_gate": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"window_hours": {"type": "number", "maximum": 8760, "minimum": 0, "exclusiveMinimum": true},
|
||||
"member_threshold": {
|
||||
"type": "integer",
|
||||
"maximum": 1000000,
|
||||
"format": "int32",
|
||||
"minimum": 0,
|
||||
"exclusiveMinimum": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14879,12 +14904,16 @@
|
||||
"premium_grace_ends_at": {"nullable": true, "type": "string"},
|
||||
"premium_lifetime_sequence": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
|
||||
"suspicious_activity_flags": {"$ref": "#/components/schemas/SuspiciousActivityFlags"},
|
||||
"phone_verification_deferred": {
|
||||
"type": "boolean",
|
||||
"description": "Whether a stored phone requirement is deferred until the user joins a discoverable or large community"
|
||||
},
|
||||
"temp_banned_until": {"nullable": true, "type": "string"},
|
||||
"pending_deletion_at": {"nullable": true, "type": "string"},
|
||||
"pending_bulk_message_deletion_at": {"nullable": true, "type": "string"},
|
||||
"deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
|
||||
"deletion_public_reason": {"nullable": true, "type": "string"},
|
||||
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
|
||||
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 115},
|
||||
"traits": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
|
||||
"has_totp": {"type": "boolean"},
|
||||
"authenticator_types": {"type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}, "maxItems": 10},
|
||||
@@ -14919,6 +14948,7 @@
|
||||
"premium_grace_ends_at",
|
||||
"premium_lifetime_sequence",
|
||||
"suspicious_activity_flags",
|
||||
"phone_verification_deferred",
|
||||
"temp_banned_until",
|
||||
"pending_deletion_at",
|
||||
"pending_bulk_message_deletion_at",
|
||||
@@ -15451,7 +15481,7 @@
|
||||
"acls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 100,
|
||||
"maxItems": 115,
|
||||
"description": "List of access control permissions to assign"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -69,6 +69,7 @@ mod tests {
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
|
||||
@@ -102,6 +102,8 @@ pub struct AdminUser {
|
||||
#[serde(default)]
|
||||
pub suspicious_activity_flags: i32,
|
||||
#[serde(default)]
|
||||
pub phone_verification_deferred: bool,
|
||||
#[serde(default)]
|
||||
pub has_totp: bool,
|
||||
#[serde(default)]
|
||||
pub authenticator_types: Vec<i32>,
|
||||
|
||||
@@ -37,6 +37,28 @@ pub struct InstancePolicyResponse {
|
||||
pub services_resolved: InstanceServicesResolved,
|
||||
#[serde(default)]
|
||||
pub services_available: InstanceServicesAvailable,
|
||||
#[serde(default)]
|
||||
pub deferred_phone_gate: DeferredPhoneGateResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct DeferredPhoneGateResponse {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub window_hours: f64,
|
||||
#[serde(default)]
|
||||
pub member_threshold: i64,
|
||||
}
|
||||
|
||||
impl Default for DeferredPhoneGateResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
window_hours: 6.0,
|
||||
member_threshold: 50,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InstancePolicyResponse {
|
||||
@@ -50,6 +72,7 @@ impl Default for InstancePolicyResponse {
|
||||
services: InstanceServicesOverrides::default(),
|
||||
services_resolved: InstanceServicesResolved::default(),
|
||||
services_available: InstanceServicesAvailable::default(),
|
||||
deferred_phone_gate: DeferredPhoneGateResponse::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,6 +539,18 @@ pub struct InstancePolicyUpdateRequest {
|
||||
pub premium_mode: Option<PremiumMode>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub services: Option<InstanceServicesUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deferred_phone_gate: Option<DeferredPhoneGateUpdateRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct DeferredPhoneGateUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub window_hours: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub member_threshold: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
|
||||
@@ -112,6 +112,7 @@ fn is_urlencoded_form(request: &Request) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn extract_csrf_from_form_body(
|
||||
request: Request,
|
||||
) -> Result<(Request, Option<String>), Response> {
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
DeferredPhoneGateUpdateRequest, GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
@@ -547,6 +547,7 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
_ => None,
|
||||
};
|
||||
let services = build_services_update(form);
|
||||
let deferred_phone_gate = build_deferred_phone_gate_update(form);
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
@@ -558,12 +559,37 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
direct_messages_disabled,
|
||||
premium_mode,
|
||||
services,
|
||||
deferred_phone_gate,
|
||||
}),
|
||||
integrations: None,
|
||||
media: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_deferred_phone_gate_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Option<DeferredPhoneGateUpdateRequest> {
|
||||
let enabled = form
|
||||
.first("policy_deferred_phone_gate_enabled")
|
||||
.map(|value| value == "true");
|
||||
let window_hours = form
|
||||
.first("policy_deferred_phone_gate_window_hours")
|
||||
.and_then(|value| value.parse::<f64>().ok())
|
||||
.filter(|value| *value > 0.0);
|
||||
let member_threshold = form
|
||||
.first("policy_deferred_phone_gate_member_threshold")
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.filter(|value| *value > 0);
|
||||
if enabled.is_none() && window_hours.is_none() && member_threshold.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(DeferredPhoneGateUpdateRequest {
|
||||
enabled,
|
||||
window_hours,
|
||||
member_threshold,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_services_update(form: &MultiValueForm) -> Option<InstanceServicesUpdateRequest> {
|
||||
let parse_tristate = |key: &str| match form.first(key) {
|
||||
Some("inherit") => Some(None),
|
||||
@@ -712,6 +738,7 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
|
||||
direct_messages_disabled: None,
|
||||
premium_mode: None,
|
||||
services: None,
|
||||
deferred_phone_gate: None,
|
||||
}),
|
||||
integrations: None,
|
||||
media: None,
|
||||
|
||||
@@ -103,6 +103,7 @@ pub fn instance_config_page(
|
||||
instance_config.self_hosted,
|
||||
))
|
||||
(sso_config_section(base, csrf_token, &instance_config.sso))
|
||||
(deferred_phone_gate_form(base, csrf_token, &instance_config.policy))
|
||||
},
|
||||
))
|
||||
@if instance_config.self_hosted {
|
||||
@@ -283,6 +284,57 @@ fn direct_messages_form(base: &str, csrf_token: &str, policy: &InstancePolicyRes
|
||||
}
|
||||
}
|
||||
|
||||
fn deferred_phone_gate_form(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
) -> Markup {
|
||||
let gate = &policy.deferred_phone_gate;
|
||||
let status = if gate.enabled {
|
||||
("Enabled", BadgeVariant::Success)
|
||||
} else {
|
||||
("Disabled", BadgeVariant::Default)
|
||||
};
|
||||
html! {
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Deferred phone verification" }
|
||||
(badge(status.0, status.1))
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"When enabled, a phone requirement raised at registration is held back and only \
|
||||
applied if the account joins a discoverable community, or one above the member \
|
||||
threshold, within the window. Accounts that wait out the window are not challenged. \
|
||||
Inbound-SMS requirements are never deferred."
|
||||
}
|
||||
form method="post" action={(base) "/instance-config?action=update_policy"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(select_input("policy_deferred_phone_gate_enabled", "Deferred phone verification", &[
|
||||
("true", "Enabled"),
|
||||
("false", "Disabled"),
|
||||
], if gate.enabled { "true" } else { "false" }))
|
||||
(text_input(
|
||||
"policy_deferred_phone_gate_window_hours",
|
||||
"Window (hours)",
|
||||
&gate.window_hours.to_string(),
|
||||
"6",
|
||||
))
|
||||
(text_input(
|
||||
"policy_deferred_phone_gate_member_threshold",
|
||||
"Member threshold",
|
||||
&gate.member_threshold.to_string(),
|
||||
"50",
|
||||
))
|
||||
(form_actions(html! {
|
||||
(submit_button("Save deferred phone verification"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
|
||||
html! {
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
|
||||
@@ -291,6 +291,11 @@ fn flags_card(
|
||||
can_update_suspicious,
|
||||
Some(acl::USER_UPDATE_SUSPICIOUS_ACTIVITY),
|
||||
))
|
||||
@if user.phone_verification_deferred {
|
||||
p class="text-sm text-amber-700 dark:text-amber-400" {
|
||||
"Phone verification is deferred: the requirement above is stored but not enforced until this user joins a discoverable or large community within the deferral window."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,41 +434,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_instance_config_hides_self_host_setup_controls() {
|
||||
let app = setup().await;
|
||||
let body = get(&app, "/instance-config", &[]).await;
|
||||
|
||||
assert_full_layout(&body);
|
||||
assert!(body.contains("Registration Controls"), "{body}");
|
||||
assert!(body.contains("Runtime Integrations"), "{body}");
|
||||
assert!(body.contains("Gateway Rollout Configuration"), "{body}");
|
||||
assert!(!body.contains("Public App Identity"), "{body}");
|
||||
assert!(!body.contains("Setup complete"), "{body}");
|
||||
assert!(!body.contains("Community & Policy"), "{body}");
|
||||
assert!(!body.contains("Single community"), "{body}");
|
||||
assert!(!body.contains("Direct messages & friends"), "{body}");
|
||||
assert!(!body.contains("Premium model"), "{body}");
|
||||
assert!(!body.contains("Optional services"), "{body}");
|
||||
assert!(!body.contains("Registration Fields"), "{body}");
|
||||
assert!(
|
||||
!body.contains("Collect date of birth during registration"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_app_public"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_app_registration"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_policy"),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() {
|
||||
let app = setup().await;
|
||||
@@ -898,6 +863,7 @@ fn user(id: &str, username: &str) -> Value {
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"has_verified_phone": false,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
|
||||
@@ -29,12 +29,16 @@
|
||||
"main": "./src/MediaProxyUtils.ts",
|
||||
"types": "./src/MediaProxyUtils.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
buildExternalMediaProxyPath,
|
||||
buildV2ExternalMediaProxyPath,
|
||||
reconstructOriginalUrl,
|
||||
} from '@pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const ROUND_TRIP_URLS = [
|
||||
'https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
|
||||
'https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here',
|
||||
'https://example.com:8443/a.png',
|
||||
'https://avatars.githubusercontent.com/u/241303489?v=4',
|
||||
'http://example.com/plain.gif',
|
||||
'https://example.com/deep/nested/path/to/file.jpeg',
|
||||
'https://example.com/file.png?a=1&b=2&c=3',
|
||||
'https://example.com/spaced%20name.png',
|
||||
'https://example.com/unicode/%C3%A5%C3%A4%C3%B6.png',
|
||||
'https://example.com/file.png?redirect=https%3A%2F%2Fother.example%2Fx.png',
|
||||
'https://sub.domain.example.co.uk/a/b.webp',
|
||||
];
|
||||
|
||||
describe('buildExternalMediaProxyPath', () => {
|
||||
it('emits the plain path shape with the extension last', () => {
|
||||
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp')).toBe(
|
||||
'https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
|
||||
);
|
||||
});
|
||||
|
||||
it('puts an encoded query, leading question mark included, ahead of the protocol', () => {
|
||||
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here')).toBe(
|
||||
'%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/HkAKKCzZ.webp',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a non default port on the host segment', () => {
|
||||
expect(buildExternalMediaProxyPath('https://example.com:8443/a.png')).toBe('https/example.com:8443/a.png');
|
||||
});
|
||||
|
||||
it('preserves the http scheme', () => {
|
||||
expect(buildExternalMediaProxyPath('http://example.com/a.gif')).toBe('http/example.com/a.gif');
|
||||
});
|
||||
|
||||
it('handles a root url with no path', () => {
|
||||
expect(buildExternalMediaProxyPath('https://example.com/')).toBe('https/example.com');
|
||||
});
|
||||
|
||||
it('never emits the v2 prefix any more', () => {
|
||||
for (const url of ROUND_TRIP_URLS) {
|
||||
expect(buildExternalMediaProxyPath(url).startsWith('v2/')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('ends in the source file extension so extension based cdn caching applies', () => {
|
||||
for (const [url, ext] of [
|
||||
['https://example.com/a.webp', '.webp'],
|
||||
['https://example.com/a.png?x=1', '.png'],
|
||||
['https://example.com/a/b/c.jpeg', '.jpeg'],
|
||||
] as const) {
|
||||
expect(buildExternalMediaProxyPath(url).endsWith(ext)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a url it cannot parse', () => {
|
||||
expect(() => buildExternalMediaProxyPath('not a url')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconstructOriginalUrl', () => {
|
||||
it('round trips every supported shape', () => {
|
||||
for (const url of ROUND_TRIP_URLS) {
|
||||
expect(reconstructOriginalUrl(buildExternalMediaProxyPath(url))).toBe(url);
|
||||
}
|
||||
});
|
||||
|
||||
it('decodes an externally produced path verbatim', () => {
|
||||
expect(
|
||||
reconstructOriginalUrl('%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp'),
|
||||
).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here');
|
||||
});
|
||||
|
||||
it('does not double the question mark when the query segment carries one', () => {
|
||||
const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png');
|
||||
expect(decoded).toBe('https://example.com/x.png?a=1');
|
||||
expect(decoded).not.toContain('??');
|
||||
});
|
||||
|
||||
it('still accepts a query segment without a leading question mark', () => {
|
||||
expect(reconstructOriginalUrl('a%3D1/https/example.com/x.png')).toBe('https://example.com/x.png?a=1');
|
||||
});
|
||||
|
||||
it('still decodes v2 paths so links already sent keep working', () => {
|
||||
expect(reconstructOriginalUrl(buildV2ExternalMediaProxyPath('https://example.com/a.png?x=1'))).toBe(
|
||||
'https://example.com/a.png?x=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a path that has no host after the protocol', () => {
|
||||
expect(() => reconstructOriginalUrl('https')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects an empty v2 payload', () => {
|
||||
expect(() => reconstructOriginalUrl('v2/')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,8 @@ function reconstructLegacyOriginalUrl(proxyUrlPath: string): string {
|
||||
const query = encodedQuery ? decodeLegacyComponent(encodedQuery) : '';
|
||||
const path = decodeLegacyComponent(encodedPath);
|
||||
const {hostname, port} = decodeLegacyHostAndPort(hostPart);
|
||||
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${query ? `?${query}` : ''}`;
|
||||
const normalizedQuery = query.startsWith('?') ? query.slice(1) : query;
|
||||
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${normalizedQuery ? `?${normalizedQuery}` : ''}`;
|
||||
}
|
||||
|
||||
function reconstructV2OriginalUrl(proxyUrlPath: string): string {
|
||||
@@ -85,11 +86,28 @@ function reconstructV2OriginalUrl(proxyUrlPath: string): string {
|
||||
return decodeV2PathComponent(encodedOriginalUrl);
|
||||
}
|
||||
|
||||
export function buildExternalMediaProxyPath(inputUrl: string): string {
|
||||
export function buildV2ExternalMediaProxyPath(inputUrl: string): string {
|
||||
const parsedUrl = new URL(inputUrl);
|
||||
return `${V2_PATH_PREFIX}${encodeV2PathComponent(parsedUrl.toString())}`;
|
||||
}
|
||||
|
||||
export function buildExternalMediaProxyPath(inputUrl: string): string {
|
||||
const parsedUrl = new URL(inputUrl);
|
||||
const protocol = parsedUrl.protocol.replace(/:$/u, '');
|
||||
const host = parsedUrl.port ? `${parsedUrl.hostname}:${parsedUrl.port}` : parsedUrl.hostname;
|
||||
const path = parsedUrl.pathname
|
||||
.replace(/^\//u, '')
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
const segments = parsedUrl.search ? [encodeURIComponent(parsedUrl.search)] : [];
|
||||
segments.push(protocol, host);
|
||||
if (path) {
|
||||
segments.push(path);
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
export function reconstructOriginalUrl(proxyUrlPath: string): string {
|
||||
const reconstructedUrl = proxyUrlPath.startsWith(V2_PATH_PREFIX)
|
||||
? reconstructV2OriginalUrl(proxyUrlPath)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfigs/package.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
|
||||
"@pkgs/*": ["../*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
|
||||
import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig';
|
||||
@@ -237,6 +238,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
|
||||
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
|
||||
},
|
||||
s3Downloads: resolveDownloadsProvider(master),
|
||||
s3: {
|
||||
endpoint: s3Config.endpoint,
|
||||
presignedUrlBase: s3Config.presigned_url_base,
|
||||
@@ -461,6 +463,14 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
|
||||
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
|
||||
enableVoiceReconciliation: apiWorkerConfig?.enable_voice_reconciliation ?? true,
|
||||
voiceReconciliation: {
|
||||
intervalMs: apiWorkerConfig?.voice_reconciliation?.interval_ms,
|
||||
staggerDelayMs: apiWorkerConfig?.voice_reconciliation?.stagger_delay_ms,
|
||||
lockTtlSeconds: apiWorkerConfig?.voice_reconciliation?.lock_ttl_seconds,
|
||||
cadenceTtlSeconds: apiWorkerConfig?.voice_reconciliation?.cadence_ttl_seconds,
|
||||
gatewayOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.gateway_only_grace_ms,
|
||||
liveKitOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.livekit_only_grace_ms,
|
||||
},
|
||||
laneConcurrencyOverrides: {
|
||||
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
|
||||
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
|
||||
|
||||
@@ -100,6 +100,11 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
youtube_enabled: policy.youtube_enabled,
|
||||
bluesky_enabled: policy.bluesky_enabled,
|
||||
},
|
||||
deferred_phone_gate: {
|
||||
enabled: policy.deferred_phone_gate_enabled,
|
||||
window_hours: policy.deferred_phone_gate_window_hours,
|
||||
member_threshold: policy.deferred_phone_gate_member_threshold,
|
||||
},
|
||||
services_resolved: resolvedServices,
|
||||
services_available: {
|
||||
gif: integrations.gif.effective_available,
|
||||
@@ -591,6 +596,17 @@ async function applyInstancePolicyUpdate(
|
||||
patch.bluesky_enabled = policy.services.bluesky_enabled ?? null;
|
||||
}
|
||||
}
|
||||
if (policy.deferred_phone_gate) {
|
||||
if (policy.deferred_phone_gate.enabled !== undefined) {
|
||||
patch.deferred_phone_gate_enabled = policy.deferred_phone_gate.enabled;
|
||||
}
|
||||
if (policy.deferred_phone_gate.window_hours !== undefined) {
|
||||
patch.deferred_phone_gate_window_hours = policy.deferred_phone_gate.window_hours;
|
||||
}
|
||||
if (policy.deferred_phone_gate.member_threshold !== undefined) {
|
||||
patch.deferred_phone_gate_member_threshold = policy.deferred_phone_gate.member_threshold;
|
||||
}
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.setInstancePolicyConfig(patch);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import dns from 'node:dns';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN} from '@fluxer/constants/src/UserConstants';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {formatGeoipLocation} from '@pkgs/geoip/src/GeoipLookup';
|
||||
@@ -82,7 +83,9 @@ export async function mapUserToAdminResponse(
|
||||
premium_grace_ends_at: user.premiumGraceEndsAt?.toISOString() ?? null,
|
||||
premium_lifetime_sequence: user.premiumLifetimeSequence ?? null,
|
||||
suspicious_activity_flags: user.suspiciousActivityFlags,
|
||||
temp_banned_until: user.tempBannedUntil?.toISOString() ?? null,
|
||||
phone_verification_deferred: ((user.suspiciousActivityFlags ?? 0) & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0,
|
||||
temp_banned_until:
|
||||
user.tempBannedUntil && user.tempBannedUntil.getTime() > Date.now() ? user.tempBannedUntil.toISOString() : null,
|
||||
pending_deletion_at: user.pendingDeletionAt?.toISOString() ?? null,
|
||||
pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null,
|
||||
deletion_reason_code: user.deletionReasonCode,
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS,
|
||||
ALL_SUSPICIOUS_ACTIVITY_FLAGS,
|
||||
DEFERRABLE_PHONE_FLAGS,
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
imposePhoneRequirements,
|
||||
SuspiciousActivityFlags,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
@@ -31,11 +39,13 @@ import * as AuthMfa from '../../auth/AuthMfa';
|
||||
import * as AuthSession from '../../auth/AuthSession';
|
||||
import * as AuthUtility from '../../auth/AuthUtility';
|
||||
import {createPasswordResetToken, createUserID, type UserID} from '../../BrandedTypes';
|
||||
import type {UserRow} from '../../database/types/UserTypes';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository';
|
||||
import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes';
|
||||
import {getIpAddressReverse, getLocationLabelFromIp} from '../../utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '../../utils/UserAgentUtils';
|
||||
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
|
||||
import {mapUserToAdminResponse} from '../models/UserTypes';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
@@ -406,11 +416,14 @@ export class AdminUserSecurityService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{has_verified_phone: data.has_verified_phone},
|
||||
user.toRow(),
|
||||
);
|
||||
const phonePatch: Partial<UserRow> = {has_verified_phone: data.has_verified_phone};
|
||||
if (data.has_verified_phone) {
|
||||
const clearedFlags = (user.suspiciousActivityFlags ?? 0) & ~ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS;
|
||||
if (clearedFlags !== (user.suspiciousActivityFlags ?? 0)) {
|
||||
phonePatch.suspicious_activity_flags = clearedFlags;
|
||||
}
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(userId, phonePatch, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
@@ -418,7 +431,15 @@ export class AdminUserSecurityService {
|
||||
targetId: BigInt(userId),
|
||||
action: 'update_has_verified_phone',
|
||||
auditLogReason,
|
||||
metadata: new Map([['has_verified_phone', String(data.has_verified_phone)]]),
|
||||
metadata: new Map(
|
||||
phonePatch.suspicious_activity_flags === undefined
|
||||
? [['has_verified_phone', String(data.has_verified_phone)]]
|
||||
: [
|
||||
['has_verified_phone', String(data.has_verified_phone)],
|
||||
['suspicious_activity_flags_before', String(user.suspiciousActivityFlags ?? 0)],
|
||||
['suspicious_activity_flags_after', String(phonePatch.suspicious_activity_flags)],
|
||||
],
|
||||
),
|
||||
});
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
@@ -438,15 +459,24 @@ export class AdminUserSecurityService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const currentFlags = user.suspiciousActivityFlags ?? 0;
|
||||
const keepsDeferral =
|
||||
(currentFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0 &&
|
||||
(data.flags & DEFERRABLE_PHONE_FLAGS) !== 0 &&
|
||||
(data.flags & DEFERRABLE_PHONE_FLAGS) === (currentFlags & DEFERRABLE_PHONE_FLAGS);
|
||||
const newFlags = keepsDeferral ? data.flags | DEFERRED_PHONE_ON_COMMUNITY_JOIN : data.flags;
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
suspicious_activity_flags: data.flags,
|
||||
suspicious_activity_flags: newFlags,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
if ((user.suspiciousActivityFlags ?? 0) !== data.flags && data.flags !== 0) {
|
||||
if (
|
||||
(currentFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== (newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) &&
|
||||
(newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== 0
|
||||
) {
|
||||
await this.recordRiskOutcomes(userId, ['challenged'], 'admin_update_suspicious_activity_flags');
|
||||
}
|
||||
await auditService.createAuditLog({
|
||||
@@ -600,7 +630,7 @@ export class AdminUserSecurityService {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const currentFlags = user.suspiciousActivityFlags ?? 0;
|
||||
const newFlags = (currentFlags | addMask) & ~removeMask;
|
||||
const newFlags = imposePhoneRequirements(currentFlags, addMask) & ~removeMask;
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{suspicious_activity_flags: newFlags},
|
||||
@@ -720,7 +750,7 @@ export class AdminUserSecurityService {
|
||||
approximateLastUsedAt: Date;
|
||||
clientIp: string;
|
||||
clientUserAgent: string | null;
|
||||
clientIsDesktop: boolean | null;
|
||||
clientOs: string | null;
|
||||
deletedAt: Date | null;
|
||||
}> = [
|
||||
...activeSessions.map((s) => ({
|
||||
@@ -729,7 +759,7 @@ export class AdminUserSecurityService {
|
||||
approximateLastUsedAt: s.approximateLastUsedAt,
|
||||
clientIp: s.clientIp,
|
||||
clientUserAgent: s.clientUserAgent,
|
||||
clientIsDesktop: s.clientIsDesktop,
|
||||
clientOs: s.clientOs ?? null,
|
||||
deletedAt: null as Date | null,
|
||||
})),
|
||||
...tombstones.map((t) => ({
|
||||
@@ -738,7 +768,7 @@ export class AdminUserSecurityService {
|
||||
approximateLastUsedAt: t.approximateLastUsedAt,
|
||||
clientIp: t.clientIp,
|
||||
clientUserAgent: t.clientUserAgent,
|
||||
clientIsDesktop: t.clientIsDesktop,
|
||||
clientOs: t.clientOs ?? null,
|
||||
deletedAt: t.deletedAt,
|
||||
})),
|
||||
];
|
||||
@@ -747,6 +777,8 @@ export class AdminUserSecurityService {
|
||||
if (a.deletedAt !== null && b.deletedAt === null) return 1;
|
||||
return b.createdAt.getTime() - a.createdAt.getTime();
|
||||
});
|
||||
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
|
||||
const productName = branding.product_name;
|
||||
const canViewIp = acls.has(AdminACLs.USER_VIEW_IP) || acls.has(AdminACLs.WILDCARD);
|
||||
if (!canViewIp) {
|
||||
await auditService.createAuditLog({
|
||||
@@ -759,9 +791,10 @@ export class AdminUserSecurityService {
|
||||
});
|
||||
return {
|
||||
sessions: entries.map((entry) => {
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
const clientInfo = resolveSessionClientInfo({
|
||||
userAgent: entry.clientUserAgent,
|
||||
isDesktopClient: entry.clientIsDesktop,
|
||||
reportedOs: entry.clientOs,
|
||||
productName,
|
||||
});
|
||||
return {
|
||||
session_id_hash: entry.sessionIdHash.toString('base64url'),
|
||||
@@ -769,8 +802,8 @@ export class AdminUserSecurityService {
|
||||
approx_last_used_at: entry.approximateLastUsedAt.toISOString(),
|
||||
client_ip: '[redacted]',
|
||||
client_ip_reverse: null,
|
||||
client_os: clientOs,
|
||||
client_platform: clientPlatform,
|
||||
client_os: clientInfo.os,
|
||||
client_platform: clientInfo.platform,
|
||||
client_location: null,
|
||||
deleted_at: entry.deletedAt?.toISOString() ?? null,
|
||||
};
|
||||
@@ -807,9 +840,10 @@ export class AdminUserSecurityService {
|
||||
const clientLocation = locationResult.status === 'fulfilled' ? locationResult.value : null;
|
||||
const reverseDnsResult = reverseDnsResults[index];
|
||||
const clientIpReverse = reverseDnsResult?.status === 'fulfilled' ? reverseDnsResult.value : null;
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
const clientInfo = resolveSessionClientInfo({
|
||||
userAgent: entry.clientUserAgent,
|
||||
isDesktopClient: entry.clientIsDesktop,
|
||||
reportedOs: entry.clientOs,
|
||||
productName,
|
||||
});
|
||||
return {
|
||||
session_id_hash: entry.sessionIdHash.toString('base64url'),
|
||||
@@ -817,8 +851,8 @@ export class AdminUserSecurityService {
|
||||
approx_last_used_at: entry.approximateLastUsedAt.toISOString(),
|
||||
client_ip: entry.clientIp,
|
||||
client_ip_reverse: clientIpReverse,
|
||||
client_os: clientOs,
|
||||
client_platform: clientPlatform,
|
||||
client_os: clientInfo.os,
|
||||
client_platform: clientInfo.platform,
|
||||
client_location: clientLocation,
|
||||
deleted_at: entry.deletedAt?.toISOString() ?? null,
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ export class AdminGuildMembershipService {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
await guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: true,
|
||||
@@ -83,6 +84,7 @@ export class AdminGuildMembershipService {
|
||||
try {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
await guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: false,
|
||||
|
||||
@@ -551,18 +551,7 @@ export function AuthController(app: HonoApp) {
|
||||
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const clientIp = requireClientIp(ctx.req.raw, {
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
||||
});
|
||||
const clientPlatform = ctx.req.header('x-fluxer-platform')?.trim().toLowerCase() ?? undefined;
|
||||
return ctx.json(
|
||||
await ctx.get('authRequestService').initiateHandoff({
|
||||
userAgent: ctx.req.header('User-Agent'),
|
||||
clientIp,
|
||||
clientPlatform,
|
||||
}),
|
||||
);
|
||||
return ctx.json(await ctx.get('authRequestService').initiateHandoff({request: ctx.req.raw}));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -611,7 +600,6 @@ export function AuthController(app: HonoApp) {
|
||||
});
|
||||
await ctx.get('authRequestService').completeHandoff({
|
||||
data: ctx.req.valid('json'),
|
||||
request: ctx.req.raw,
|
||||
clientIp,
|
||||
authToken: ctx.get('authToken') ?? undefined,
|
||||
});
|
||||
|
||||
@@ -100,7 +100,10 @@ export async function revertEmailChange(
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser),
|
||||
});
|
||||
const [authToken] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request});
|
||||
const [authToken] = await AuthSession.createAuthSession(ctx, {
|
||||
user: updatedUser,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
await contactChangeLog.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser,
|
||||
|
||||
@@ -105,9 +105,7 @@ export interface IpAuthorizationTicketCache {
|
||||
userId: string;
|
||||
email: string;
|
||||
username: string;
|
||||
clientIp: string;
|
||||
userAgent: string;
|
||||
platform: string | null;
|
||||
origin: AuthSession.SessionOrigin;
|
||||
authToken: string;
|
||||
clientLocation: string;
|
||||
inviteCode?: string | null;
|
||||
@@ -115,8 +113,8 @@ export interface IpAuthorizationTicketCache {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function getTicketCacheKey(ticket: string): string {
|
||||
return `ip-auth-ticket:${ticket}`;
|
||||
export function getTicketCacheKey(ticket: string): string {
|
||||
return `ip-auth-ticket-v2:${ticket}`;
|
||||
}
|
||||
|
||||
function getTokenCacheKey(token: string): string {
|
||||
@@ -148,7 +146,7 @@ export async function resendIpAuthorization(
|
||||
payload.email,
|
||||
payload.username,
|
||||
payload.authToken,
|
||||
payload.clientIp,
|
||||
payload.origin.ip,
|
||||
payload.clientLocation,
|
||||
null,
|
||||
);
|
||||
@@ -172,7 +170,7 @@ export async function completeIpAuthorization(
|
||||
user_id: string;
|
||||
ticket: string;
|
||||
}> {
|
||||
const {users, cache, config} = ctx.services;
|
||||
const {users, cache} = ctx.services;
|
||||
const tokenMapping = await cache.get<{
|
||||
ticket: string;
|
||||
}>(getTokenCacheKey(token));
|
||||
@@ -193,19 +191,8 @@ export async function completeIpAuthorization(
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
await users.createAuthorizedIp(user.id, payload.clientIp);
|
||||
const headers: Record<string, string> = {
|
||||
[config.proxy.client_ip_header]: payload.clientIp,
|
||||
'user-agent': payload.userAgent,
|
||||
};
|
||||
if (payload.platform) {
|
||||
headers['x-fluxer-platform'] = payload.platform;
|
||||
}
|
||||
const syntheticRequest = new Request('https://api.fluxer.app/auth/ip-authorization', {
|
||||
headers,
|
||||
method: 'POST',
|
||||
});
|
||||
const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, request: syntheticRequest});
|
||||
await users.createAuthorizedIp(user.id, payload.origin.ip);
|
||||
const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, origin: payload.origin});
|
||||
await cache.delete(cacheKey);
|
||||
await cache.delete(getTokenCacheKey(token));
|
||||
return {token: sessionToken, user_id: user.id.toString(), ticket: tokenMapping.ticket};
|
||||
@@ -313,15 +300,11 @@ export async function login(
|
||||
const authToken = createIpAuthorizationToken(await AuthUtility.generateSecureToken(ctx));
|
||||
const geoipResult = await lookupGeoip(clientIp);
|
||||
const clientLocation = formatGeoipLocation(geoipResult) ?? UNKNOWN_LOCATION;
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const platform = request.headers.get('x-fluxer-platform');
|
||||
const cachePayload: IpAuthorizationTicketCache = {
|
||||
userId: currentUser.id.toString(),
|
||||
email: currentUser.email!,
|
||||
username: currentUser.username,
|
||||
clientIp,
|
||||
userAgent,
|
||||
platform: platform ?? null,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
authToken,
|
||||
clientLocation,
|
||||
inviteCode: data.invite_code ?? null,
|
||||
@@ -329,7 +312,7 @@ export async function login(
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const ttlSeconds = seconds('15 minutes');
|
||||
await cache.set<IpAuthorizationTicketCache>(`ip-auth-ticket:${ticket}`, cachePayload, ttlSeconds);
|
||||
await cache.set<IpAuthorizationTicketCache>(getTicketCacheKey(ticket), cachePayload, ttlSeconds);
|
||||
await cache.set<{
|
||||
ticket: string;
|
||||
}>(`ip-auth-token:${authToken}`, {ticket}, ttlSeconds);
|
||||
@@ -364,7 +347,10 @@ export async function login(
|
||||
Logger.warn({inviteCode: data.invite_code, error}, 'Failed to auto-join invite on login');
|
||||
}
|
||||
}
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user: currentUser, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user: currentUser,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {
|
||||
user_id: currentUser.id.toString(),
|
||||
token,
|
||||
@@ -418,7 +404,10 @@ export async function loginMfaTotp(
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await cache.delete(attemptsKey);
|
||||
await cache.delete(userAttemptsKey);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
@@ -438,7 +427,10 @@ export async function loginMfaWebAuthn(
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSche
|
||||
import {uint8ArrayToBase64} from 'uint8array-extras';
|
||||
import {Config} from '../Config';
|
||||
import {Logger} from '../Logger';
|
||||
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
|
||||
import type {AuthSession} from '../models/AuthSession';
|
||||
import {getLocationLabelFromIp} from '../utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '../utils/UserAgentUtils';
|
||||
import {resolveSessionClientInfo} from '../utils/SessionClientIdentity';
|
||||
|
||||
const DEV_FALLBACK_AUTH_SESSION_LOCATION = 'Stockholm, Stockholm County, Sweden';
|
||||
|
||||
@@ -40,30 +41,24 @@ export async function mapAuthSessionsToResponse({
|
||||
const locationResults = await Promise.allSettled(
|
||||
sortedSessions.map((session) => resolveAuthSessionLocation(session)),
|
||||
);
|
||||
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
|
||||
return sortedSessions.map((authSession, index): AuthSessionResponse => {
|
||||
const locationResult = locationResults[index];
|
||||
const clientLocation = locationResult?.status === 'fulfilled' ? locationResult.value : null;
|
||||
let clientOs: string;
|
||||
let clientPlatform: string;
|
||||
if (authSession.clientUserAgent) {
|
||||
const parsed = resolveSessionClientInfo({
|
||||
userAgent: authSession.clientUserAgent,
|
||||
isDesktopClient: authSession.clientIsDesktop,
|
||||
});
|
||||
clientOs = parsed.clientOs;
|
||||
clientPlatform = parsed.clientPlatform;
|
||||
} else {
|
||||
clientOs = authSession.clientOs || 'Unknown';
|
||||
clientPlatform = authSession.clientPlatform || 'Unknown';
|
||||
}
|
||||
const clientInfo = resolveSessionClientInfo({
|
||||
userAgent: authSession.clientUserAgent,
|
||||
reportedOs: authSession.clientOs ?? null,
|
||||
productName: branding.product_name,
|
||||
});
|
||||
const idHash = uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true});
|
||||
const isCurrent = currentSessionId ? Buffer.compare(authSession.sessionIdHash, currentSessionId) === 0 : false;
|
||||
return {
|
||||
id_hash: idHash,
|
||||
client_info: {
|
||||
platform: clientPlatform,
|
||||
os: clientOs,
|
||||
browser: undefined,
|
||||
platform: clientInfo.platform,
|
||||
os: clientInfo.os,
|
||||
browser: clientInfo.browser,
|
||||
device: clientInfo.device,
|
||||
location: clientLocation
|
||||
? {
|
||||
city: clientLocation.split(',').at(0)?.trim() || null,
|
||||
|
||||
@@ -275,7 +275,10 @@ export async function resetPassword(
|
||||
if (hasMfa) {
|
||||
return await createMfaTicketResponse(ctx, updatedUser);
|
||||
}
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user: updatedUser,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {user_id: updatedUser.id.toString(), token};
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
normalizePolicyContactDomain,
|
||||
} from '../risk/AccountPolicyEvaluator';
|
||||
import type {IRegistrationEventsRepository} from '../risk/adapters/VelocityAdapter';
|
||||
import {deferPhoneFlagsUntilCommunityJoin} from '../risk/DeferredPhoneGate';
|
||||
import type {IRiskHistoryRepository} from '../risk/HistoricalOutcomeRepository';
|
||||
import type {IRiskAssessmentRepository} from '../risk/RiskAssessmentRepository';
|
||||
import {deriveLatestRiskContext} from '../risk/RiskHistoryContext';
|
||||
@@ -334,7 +335,7 @@ export async function register(
|
||||
action: riskResult.recommendedAction,
|
||||
},
|
||||
});
|
||||
const combinedFlags = policyDecision.flagBits;
|
||||
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(policyDecision.flagBits);
|
||||
const createdAt = new Date();
|
||||
const riskContext = deriveLatestRiskContext({
|
||||
userId: userId.toString(),
|
||||
@@ -443,7 +444,10 @@ export async function register(
|
||||
);
|
||||
}
|
||||
await singleCommunityService.joinStockCommunity(userId, requestCache);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
if (grantBootstrapAdmin) {
|
||||
await instanceConfigRepository.markAdminBootstrapped();
|
||||
}
|
||||
|
||||
@@ -33,11 +33,12 @@ import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResp
|
||||
import type {ApiContext} from '../ApiContext';
|
||||
import {createUserID, type UserID} from '../BrandedTypes';
|
||||
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
|
||||
import type {User} from '../models/User';
|
||||
import {mapUserToPartialResponse} from '../user/UserMappers';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
import {resolveSessionClientInfo} from '../utils/UserAgentUtils';
|
||||
import {resolveSessionClientInfo} from '../utils/SessionClientIdentity';
|
||||
import {generateUsernameSuggestions} from '../utils/UsernameSuggestionUtils';
|
||||
import * as AuthEmail from './AuthEmail';
|
||||
import * as AuthEmailRevert from './AuthEmailRevert';
|
||||
@@ -89,7 +90,6 @@ interface AuthLogoutRequest {
|
||||
|
||||
interface AuthHandoffCompleteRequest {
|
||||
data: HandoffCompleteRequest;
|
||||
request: Request;
|
||||
clientIp: string;
|
||||
authToken?: string;
|
||||
}
|
||||
@@ -122,9 +122,7 @@ interface AuthLogoutAuthSessionsRequest {
|
||||
}
|
||||
|
||||
interface AuthHandoffInitiateRequest {
|
||||
userAgent?: string;
|
||||
clientIp: string;
|
||||
clientPlatform?: string;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface AuthHandoffInfoRequest {
|
||||
@@ -263,7 +261,7 @@ export class AuthRequestService {
|
||||
user: await this.getUserPartial(parsed.user_id),
|
||||
};
|
||||
}
|
||||
const ticketPayload = await cache.get(`ip-auth-ticket:${ticket}`);
|
||||
const ticketPayload = await cache.get(AuthLogin.getTicketCacheKey(ticket));
|
||||
if (!ticketPayload) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_AUTHORIZATION_TICKET);
|
||||
}
|
||||
@@ -276,7 +274,10 @@ export class AuthRequestService {
|
||||
|
||||
async authenticateWebAuthnDiscoverable({data, request}: AuthWebAuthnAuthenticateRequest) {
|
||||
const user = await AuthMfa.verifyWebAuthnAuthenticationDiscoverable(this.apiContext, data.response, data.challenge);
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
|
||||
});
|
||||
return {token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
|
||||
}
|
||||
|
||||
@@ -298,12 +299,9 @@ export class AuthRequestService {
|
||||
return {suggestions: generateUsernameSuggestions(globalName)};
|
||||
}
|
||||
|
||||
async initiateHandoff({
|
||||
userAgent,
|
||||
clientIp,
|
||||
clientPlatform,
|
||||
}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
|
||||
const result = await this.desktopHandoffService.initiateHandoff({userAgent, clientIp, clientPlatform});
|
||||
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
|
||||
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
|
||||
const result = await this.desktopHandoffService.initiateHandoff({origin});
|
||||
return {
|
||||
code: result.code,
|
||||
expires_at: result.expiresAt.toISOString(),
|
||||
@@ -312,19 +310,22 @@ export class AuthRequestService {
|
||||
|
||||
async getHandoffInfo({code, clientIp}: AuthHandoffInfoRequest): Promise<HandoffInfoResponse> {
|
||||
const info = await this.desktopHandoffService.getHandoffInfo(code, clientIp);
|
||||
if (info.status === 'expired' || !info.clientIp) {
|
||||
if (info.status === 'expired' || !info.origin) {
|
||||
return {status: info.status, client_info: null};
|
||||
}
|
||||
const geo = await lookupGeoip(info.clientIp);
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
userAgent: info.userAgent ?? null,
|
||||
isDesktopClient: info.clientPlatform === 'desktop',
|
||||
const geo = await lookupGeoip(info.origin.ip);
|
||||
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
|
||||
const resolved = resolveSessionClientInfo({
|
||||
userAgent: info.origin.userAgent,
|
||||
reportedOs: info.origin.clientOs,
|
||||
productName: branding.product_name,
|
||||
});
|
||||
return {
|
||||
status: 'pending',
|
||||
client_info: {
|
||||
platform: clientPlatform,
|
||||
os: clientOs,
|
||||
platform: resolved.platform,
|
||||
os: resolved.os,
|
||||
device: resolved.device,
|
||||
location: {
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
@@ -334,18 +335,18 @@ export class AuthRequestService {
|
||||
};
|
||||
}
|
||||
|
||||
async completeHandoff({data, request, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
async completeHandoff({data, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
const sessionToken = data.token ?? authToken;
|
||||
if (!sessionToken) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
await this.desktopHandoffService.completeHandoff(
|
||||
data.code,
|
||||
() =>
|
||||
(origin) =>
|
||||
AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
|
||||
token: sessionToken,
|
||||
expectedUserId: data.user_id,
|
||||
request,
|
||||
origin,
|
||||
}),
|
||||
clientIp,
|
||||
);
|
||||
|
||||
@@ -15,12 +15,19 @@ import {Logger} from '../Logger';
|
||||
import type {AuthSession} from '../models/AuthSession';
|
||||
import type {User} from '../models/User';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {isFluxerNativeUserAgent, parseReportedClientOs} from '../utils/SessionClientIdentity';
|
||||
import {mapAuthSessionsToResponse} from './AuthModel';
|
||||
import * as AuthUtility from './AuthUtility';
|
||||
|
||||
export interface SessionOrigin {
|
||||
ip: string;
|
||||
userAgent: string | null;
|
||||
clientOs: string | null;
|
||||
}
|
||||
|
||||
interface CreateAuthSessionParams {
|
||||
user: User;
|
||||
request: Request;
|
||||
origin: SessionOrigin;
|
||||
}
|
||||
|
||||
interface LogoutAuthSessionsParams {
|
||||
@@ -60,29 +67,35 @@ interface ReplaceCurrentAuthSessionResult {
|
||||
interface CreateAdditionalAuthSessionFromTokenParams {
|
||||
token: string;
|
||||
expectedUserId?: string;
|
||||
request: Request;
|
||||
origin: SessionOrigin;
|
||||
}
|
||||
|
||||
export function resolveSessionOrigin(ctx: ApiContext, request: Request): SessionOrigin {
|
||||
const {config} = ctx.services;
|
||||
const ip = requireClientIp(request, {
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
});
|
||||
const userAgent = request.headers.get('user-agent')?.trim() || null;
|
||||
const clientOs = isFluxerNativeUserAgent(userAgent)
|
||||
? parseReportedClientOs(request.headers.get('x-fluxer-client-properties'))
|
||||
: null;
|
||||
return {ip, userAgent, clientOs};
|
||||
}
|
||||
|
||||
export async function createAuthSession(
|
||||
ctx: ApiContext,
|
||||
{user, request}: CreateAuthSessionParams,
|
||||
{user, origin}: CreateAuthSessionParams,
|
||||
): Promise<[token: string, AuthSession]> {
|
||||
const {users, config} = ctx.services;
|
||||
const {users} = ctx.services;
|
||||
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
|
||||
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
|
||||
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
|
||||
const now = new Date();
|
||||
const token = await AuthUtility.generateAuthToken(ctx);
|
||||
const ip = requireClientIp(request, {
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
});
|
||||
const platformHeader = request.headers.get('x-fluxer-platform')?.trim().toLowerCase() ?? null;
|
||||
const uaRaw = request.headers.get('user-agent') ?? '';
|
||||
const isDesktopClient = platformHeader === 'desktop';
|
||||
let clientCountry: string | null = null;
|
||||
try {
|
||||
const geoip = await lookupGeoip(ip);
|
||||
const geoip = await lookupGeoip(origin.ip);
|
||||
clientCountry = geoip.countryCode ? geoip.countryCode.toUpperCase() : null;
|
||||
} catch (error) {
|
||||
Logger.warn({userId: user.id.toString(), error}, 'GeoIP lookup failed at session creation');
|
||||
@@ -92,11 +105,9 @@ export async function createAuthSession(
|
||||
session_id_hash: Buffer.from(AuthUtility.getTokenIdHash(ctx, token)),
|
||||
created_at: now,
|
||||
approx_last_used_at: now,
|
||||
client_ip: ip,
|
||||
client_user_agent: uaRaw || null,
|
||||
client_is_desktop: isDesktopClient,
|
||||
client_os: null,
|
||||
client_platform: null,
|
||||
client_ip: origin.ip,
|
||||
client_user_agent: origin.userAgent,
|
||||
client_os: origin.clientOs,
|
||||
client_country: clientCountry,
|
||||
version: 1,
|
||||
});
|
||||
@@ -105,7 +116,7 @@ export async function createAuthSession(
|
||||
|
||||
export async function createAdditionalAuthSessionFromToken(
|
||||
ctx: ApiContext,
|
||||
{token, expectedUserId, request}: CreateAdditionalAuthSessionFromTokenParams,
|
||||
{token, expectedUserId, origin}: CreateAdditionalAuthSessionFromTokenParams,
|
||||
): Promise<{
|
||||
token: string;
|
||||
userId: string;
|
||||
@@ -122,7 +133,7 @@ export async function createAdditionalAuthSessionFromToken(
|
||||
if (expectedUserId && user.id.toString() !== expectedUserId) {
|
||||
throw new SessionTokenMismatchError();
|
||||
}
|
||||
const [newToken] = await createAuthSession(ctx, {user, request});
|
||||
const [newToken] = await createAuthSession(ctx, {user, origin});
|
||||
return {token: newToken, userId: user.id.toString()};
|
||||
}
|
||||
|
||||
@@ -205,7 +216,7 @@ export async function replaceCurrentAuthSession(
|
||||
(authSession) => !authSession.sessionIdHash.equals(currentAuthSession.sessionIdHash),
|
||||
);
|
||||
await deleteAndTerminateAuthSessions(ctx, user.id, otherAuthSessions);
|
||||
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, request});
|
||||
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, origin: resolveSessionOrigin(ctx, request)});
|
||||
const newAuthSessionIdHash = encodeSessionIdHash(newAuthSession.sessionIdHash);
|
||||
await dispatchAuthSessionChange(ctx, {
|
||||
userId: user.id,
|
||||
|
||||
@@ -5,8 +5,9 @@ import {HandoffCodeExpiredError} from '@fluxer/errors/src/domains/auth/HandoffCo
|
||||
import {InvalidHandoffCodeError} from '@fluxer/errors/src/domains/auth/InvalidHandoffCodeError';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import type {SessionOrigin} from '../AuthSession';
|
||||
|
||||
const HANDOFF_CODE_PREFIX = 'desktop-handoff:';
|
||||
const HANDOFF_CODE_PREFIX = 'desktop-handoff-v2:';
|
||||
const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:';
|
||||
const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
const CODE_LENGTH = 12;
|
||||
@@ -19,9 +20,7 @@ const MAX_INFO_LOOKUPS = 3;
|
||||
|
||||
interface HandoffData {
|
||||
createdAt: number;
|
||||
userAgent?: string;
|
||||
clientIp: string;
|
||||
clientPlatform?: string;
|
||||
origin: SessionOrigin;
|
||||
infoLookupCount: number;
|
||||
}
|
||||
|
||||
@@ -61,7 +60,7 @@ function assertValidHandoffCode(code: string): void {
|
||||
export class DesktopHandoffService {
|
||||
constructor(private readonly apiContext: ApiContext) {}
|
||||
|
||||
async initiateHandoff(args: {userAgent?: string; clientIp: string; clientPlatform?: string}): Promise<{
|
||||
async initiateHandoff(args: {origin: SessionOrigin}): Promise<{
|
||||
code: string;
|
||||
expiresAt: Date;
|
||||
}> {
|
||||
@@ -70,9 +69,7 @@ export class DesktopHandoffService {
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
const handoffData: HandoffData = {
|
||||
createdAt: Date.now(),
|
||||
userAgent: args.userAgent,
|
||||
clientIp: args.clientIp,
|
||||
clientPlatform: args.clientPlatform,
|
||||
origin: args.origin,
|
||||
infoLookupCount: 0,
|
||||
};
|
||||
const expirySeconds = seconds('5 minutes');
|
||||
@@ -83,7 +80,7 @@ export class DesktopHandoffService {
|
||||
|
||||
async completeHandoff(
|
||||
code: string,
|
||||
createTokenData: () => Promise<{token: string; userId: string}>,
|
||||
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
|
||||
approverIp: string,
|
||||
): Promise<void> {
|
||||
const {cache} = this.apiContext.services;
|
||||
@@ -107,7 +104,7 @@ export class DesktopHandoffService {
|
||||
if (remainingSeconds <= 0) {
|
||||
throw new HandoffCodeExpiredError();
|
||||
}
|
||||
const {token, userId} = await createTokenData();
|
||||
const {token, userId} = await createTokenData(handoffData.origin);
|
||||
const tokenData: HandoffTokenData = {
|
||||
token,
|
||||
userId,
|
||||
@@ -122,9 +119,7 @@ export class DesktopHandoffService {
|
||||
approverIp: string,
|
||||
): Promise<{
|
||||
status: 'pending' | 'expired';
|
||||
userAgent?: string;
|
||||
clientIp?: string;
|
||||
clientPlatform?: string;
|
||||
origin?: SessionOrigin;
|
||||
}> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
@@ -149,12 +144,7 @@ export class DesktopHandoffService {
|
||||
{approvedAt: Date.now()},
|
||||
remainingTtl > 0 ? remainingTtl : seconds('5 minutes'),
|
||||
);
|
||||
return {
|
||||
status: 'pending',
|
||||
userAgent: handoffData.userAgent,
|
||||
clientIp: handoffData.clientIp,
|
||||
clientPlatform: handoffData.clientPlatform,
|
||||
};
|
||||
return {status: 'pending', origin: handoffData.origin};
|
||||
}
|
||||
|
||||
async getHandoffStatus(
|
||||
|
||||
@@ -332,7 +332,10 @@ export class SsoService {
|
||||
});
|
||||
const claims = await this.resolveClaims(tokenResponse, config, statePayload.nonce);
|
||||
const user = await this.resolveUserFromClaims(claims, config);
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
|
||||
});
|
||||
return {token, user_id: user.id.toString(), redirect_to: statePayload.redirectTo ?? ''};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {setInjectedRegistrationRiskEvaluator} from '../../middleware/ServiceMiddleware';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import {
|
||||
RecommendedAction,
|
||||
RiskConfidence,
|
||||
RiskDecisionMethod,
|
||||
RiskLevel,
|
||||
type RiskLevel as RiskLevelType,
|
||||
} from '../../risk/RiskTypes';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
import type {IRegistrationRiskEvaluator} from '../services/IRegistrationRiskEvaluator';
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
loginAccount,
|
||||
registerUser,
|
||||
} from './AuthTestUtils';
|
||||
|
||||
function phoneRiskEvaluator(level: RiskLevelType, riskScore: number): IRegistrationRiskEvaluator {
|
||||
return {
|
||||
async evaluate() {
|
||||
return {
|
||||
level,
|
||||
recommendedAction: RecommendedAction.RequireOutboundPhone,
|
||||
assessment: {
|
||||
suspicious: true,
|
||||
level,
|
||||
confidence: RiskConfidence.High,
|
||||
riskScore,
|
||||
reasoning: 'deferred phone gate test',
|
||||
recommendedAction: RecommendedAction.RequireOutboundPhone,
|
||||
method: RiskDecisionMethod.Noop,
|
||||
modelUsed: 'test',
|
||||
rounds: 0,
|
||||
elapsedMs: 0,
|
||||
signals: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createGuildWithInvite(harness: ApiTestHarness): Promise<{guildId: string; inviteCode: string}> {
|
||||
let owner = await createTestAccount(harness);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${owner.userId}/acls`)
|
||||
.body({acls: ['*']})
|
||||
.expect(200)
|
||||
.execute();
|
||||
owner = await loginAccount(harness, owner);
|
||||
const guild = await createBuilder<GuildResponse>(harness, owner.token)
|
||||
.post('/guilds')
|
||||
.body({name: `PhoneGate-${Date.now()}`})
|
||||
.execute();
|
||||
const invite = await createBuilder<{code: string}>(harness, owner.token)
|
||||
.post(`/channels/${guild.system_channel_id}`.concat('/invites'))
|
||||
.body({max_uses: 0, max_age: 0, unique: false, temporary: false})
|
||||
.execute();
|
||||
return {guildId: guild.id, inviteCode: invite.code};
|
||||
}
|
||||
|
||||
async function readFlags(userId: string): Promise<number> {
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const {createUserID} = await import('../../BrandedTypes');
|
||||
const user = await new UserRepository().findUnique(createUserID(BigInt(userId)));
|
||||
return user?.suspiciousActivityFlags ?? 0;
|
||||
}
|
||||
|
||||
describe('Deferred phone verification gate', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('applies the phone requirement immediately while the gate is off', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: false});
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-off'),
|
||||
username: createUniqueUsername('gate_off'),
|
||||
global_name: 'Gate Off',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
});
|
||||
|
||||
it('defers the phone requirement at registration while the gate is on', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-on'),
|
||||
username: createUniqueUsername('gate_on'),
|
||||
global_name: 'Gate On',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
const me = await createBuilder<{required_actions: Array<string>}>(harness, registration.token)
|
||||
.get('/users/@me')
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(me.required_actions ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('lets a deferred account join a small guild without being challenged', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
const {guildId, inviteCode} = await createGuildWithInvite(harness);
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-small'),
|
||||
username: createUniqueUsername('gate_small'),
|
||||
global_name: 'Gate Small',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(200).execute();
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(guildId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not defer the inbound-SMS tier, which stays enforced from registration', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
setInjectedRegistrationRiskEvaluator({
|
||||
async evaluate() {
|
||||
return {
|
||||
level: RiskLevel.VeryHigh,
|
||||
recommendedAction: RecommendedAction.RequireInboundPhone,
|
||||
assessment: {
|
||||
suspicious: true,
|
||||
level: RiskLevel.VeryHigh,
|
||||
confidence: RiskConfidence.High,
|
||||
riskScore: 90,
|
||||
reasoning: 'inbound tier',
|
||||
recommendedAction: RecommendedAction.RequireInboundPhone,
|
||||
method: RiskDecisionMethod.Noop,
|
||||
modelUsed: 'test',
|
||||
rounds: 0,
|
||||
elapsedMs: 0,
|
||||
signals: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-inbound'),
|
||||
username: createUniqueUsername('gate_inbound'),
|
||||
global_name: 'Gate Inbound',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION).not.toBe(0);
|
||||
});
|
||||
|
||||
it('promotes the requirement and refuses the join on a qualifying guild inside the window', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({
|
||||
deferred_phone_gate_enabled: true,
|
||||
deferred_phone_gate_member_threshold: 1,
|
||||
deferred_phone_gate_window_hours: 24,
|
||||
});
|
||||
const {inviteCode} = await createGuildWithInvite(harness);
|
||||
const filler = await createTestAccount(harness);
|
||||
await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute();
|
||||
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-qualifying'),
|
||||
username: createUniqueUsername('gate_qualifying'),
|
||||
global_name: 'Gate Qualifying',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
expect((await readFlags(registration.user_id)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
|
||||
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(403).execute();
|
||||
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {maskIpForDisplay} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
|
||||
import {createAuthHarness, createTestAccount, fetchMe, loginAccount} from './AuthTestUtils';
|
||||
|
||||
interface HandoffInitiateResponse {
|
||||
@@ -17,6 +19,7 @@ interface HandoffInfoResponse {
|
||||
client_info?: {
|
||||
platform?: string | null;
|
||||
os?: string | null;
|
||||
device?: 'mobile' | 'desktop';
|
||||
location?: {
|
||||
city?: string | null;
|
||||
region?: string | null;
|
||||
@@ -25,6 +28,16 @@ interface HandoffInfoResponse {
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface AuthSessionsResponseItem {
|
||||
masked_ip?: string | null;
|
||||
client_info?: {
|
||||
platform?: string | null;
|
||||
os?: string | null;
|
||||
browser?: string | null;
|
||||
device?: 'mobile' | 'desktop';
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface HandoffStatusResponse {
|
||||
status: 'pending' | 'completed' | 'expired';
|
||||
token?: string;
|
||||
@@ -53,6 +66,46 @@ describe('Auth desktop handoff flow', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('attributes the handed-off session to the initiating desktop, not the approving browser', async () => {
|
||||
const DESKTOP_IP = '203.0.113.77';
|
||||
const desktopUserAgent =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) FluxerStable/1.4.0 Chrome/128.0.0.0 Electron/32.0.0 Safari/537.36';
|
||||
const browserUserAgent =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
|
||||
const account = await createTestAccount(harness);
|
||||
const login = await loginAccount(harness, account);
|
||||
const initResp = await createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
|
||||
.post('/auth/handoff/initiate')
|
||||
.header('User-Agent', desktopUserAgent)
|
||||
.header('x-forwarded-for', DESKTOP_IP)
|
||||
.body(null)
|
||||
.execute();
|
||||
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
|
||||
.get(`/auth/handoff/${initResp.code}/info`)
|
||||
.header('User-Agent', browserUserAgent)
|
||||
.execute();
|
||||
expect(info.client_info?.platform).toBe('Fluxer macOS');
|
||||
expect(info.client_info?.device).toBe('desktop');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/handoff/complete')
|
||||
.header('User-Agent', browserUserAgent)
|
||||
.body({code: initResp.code, token: login.token, user_id: login.userId})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
|
||||
.get(`/auth/handoff/${initResp.code}/status`)
|
||||
.execute();
|
||||
const sessions = await createBuilder<Array<AuthSessionsResponseItem>>(harness, completed.token!)
|
||||
.get('/auth/sessions')
|
||||
.execute();
|
||||
const handedOff = sessions.filter((session) => session.client_info?.platform === 'Fluxer macOS');
|
||||
expect(handedOff).toHaveLength(1);
|
||||
expect(handedOff[0]?.client_info?.os).toBe('macOS');
|
||||
expect(handedOff[0]?.client_info?.browser).toBeNull();
|
||||
expect(handedOff[0]?.client_info?.device).toBe('desktop');
|
||||
expect(sessions.some((session) => session.client_info?.browser === 'Chrome')).toBe(false);
|
||||
expect(handedOff[0]?.masked_ip).toBe(maskIpForDisplay(DESKTOP_IP));
|
||||
});
|
||||
it('completes full handoff flow: initiate → info → complete → status', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const login = await loginAccount(harness, account);
|
||||
|
||||
@@ -93,19 +93,7 @@ export class MessageEditService {
|
||||
assertGuildMemberCanCommunicate(member);
|
||||
}
|
||||
if (data.message_snapshots !== undefined) {
|
||||
const isAuthor = message.authorId === userId;
|
||||
const canManage = isAuthor ? true : await hasPermission(Permissions.MANAGE_MESSAGES);
|
||||
if (!isAuthor && !canManage) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
const updatedMessage = await this.withMessageLock(channelId, messageId, () =>
|
||||
this.deps.persistenceService.updateSnapshotAttachments({
|
||||
message,
|
||||
snapshotEdits: data.message_snapshots ?? [],
|
||||
}),
|
||||
);
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
return updatedMessage;
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
const user = await this.deps.userRepository.findUnique(userId);
|
||||
this.deps.validationService.validateMessageEditable(message);
|
||||
|
||||
@@ -929,9 +929,8 @@ export class MessageSendService {
|
||||
algorithm: 'leaky_bucket',
|
||||
});
|
||||
if (!slowmodeResult.allowed) {
|
||||
const retryAfter = Math.max(0, slowmodeResult.resetTime.getTime() - Date.now());
|
||||
throw new SlowmodeRateLimitError({
|
||||
retryAfter,
|
||||
retryAfter: slowmodeResult.retryAfter,
|
||||
retryAfterDecimal: slowmodeResult.retryAfterDecimal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,4 +52,27 @@ describe('Slowmode Enforcement', () => {
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.id).toBe(firstMessage.id);
|
||||
});
|
||||
it('reports the slowmode retry window in seconds on the Retry-After header', async () => {
|
||||
const rateLimitPerUser = 5;
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0]!;
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
const channel = await createChannel(harness, owner.token, guild.id, 'slowmode-channel');
|
||||
await updateChannel(harness, owner.token, channel.id, {
|
||||
rate_limit_per_user: rateLimitPerUser,
|
||||
});
|
||||
await sendChannelMessage(harness, member.token, channel.id, 'first message');
|
||||
const {response, json} = await createBuilder<{code: string; retry_after: number}>(harness, member.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: 'second message'})
|
||||
.expect(400, APIErrorCodes.SLOWMODE_RATE_LIMITED)
|
||||
.executeWithResponse();
|
||||
const headerRetryAfter = Number(response.headers.get('Retry-After'));
|
||||
expect(Number.isInteger(headerRetryAfter)).toBe(true);
|
||||
expect(headerRetryAfter).toBeGreaterThan(0);
|
||||
expect(headerRetryAfter).toBeLessThanOrEqual(rateLimitPerUser);
|
||||
expect(json.retry_after).toBeGreaterThan(0);
|
||||
expect(json.retry_after).toBeLessThanOrEqual(rateLimitPerUser);
|
||||
expect(headerRetryAfter - json.retry_after).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ResolvedDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
|
||||
|
||||
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
|
||||
@@ -150,6 +151,7 @@ export interface APIConfig {
|
||||
static: string;
|
||||
};
|
||||
};
|
||||
s3Downloads: ResolvedDownloadsProvider;
|
||||
email: {
|
||||
enabled: boolean;
|
||||
provider: 'smtp' | 'none';
|
||||
@@ -359,6 +361,14 @@ export interface APIConfig {
|
||||
taskName?: WorkerTaskName;
|
||||
enableCronScheduler?: boolean;
|
||||
enableVoiceReconciliation: boolean;
|
||||
voiceReconciliation: {
|
||||
intervalMs: number | undefined;
|
||||
staggerDelayMs: number | undefined;
|
||||
lockTtlSeconds: number | undefined;
|
||||
cadenceTtlSeconds: number | undefined;
|
||||
gatewayOnlyGraceMs: number | undefined;
|
||||
liveKitOnlyGraceMs: number | undefined;
|
||||
};
|
||||
laneConcurrencyOverrides: {
|
||||
realtime?: number;
|
||||
unfurl?: number;
|
||||
|
||||
@@ -18,9 +18,7 @@ export interface AuthSessionRow {
|
||||
approx_last_used_at: Date;
|
||||
client_ip: string;
|
||||
client_user_agent: Nullish<string>;
|
||||
client_is_desktop: Nullish<boolean>;
|
||||
client_os?: Nullish<string>;
|
||||
client_platform?: Nullish<string>;
|
||||
client_os: Nullish<string>;
|
||||
client_country: Nullish<string>;
|
||||
version: number;
|
||||
}
|
||||
@@ -32,9 +30,7 @@ export interface AuthSessionTombstoneRow {
|
||||
approx_last_used_at: Date;
|
||||
client_ip: string;
|
||||
client_user_agent: Nullish<string>;
|
||||
client_is_desktop: Nullish<boolean>;
|
||||
client_os: Nullish<string>;
|
||||
client_platform: Nullish<string>;
|
||||
client_country: Nullish<string>;
|
||||
deleted_at: Date;
|
||||
version: number;
|
||||
@@ -140,9 +136,7 @@ export const AUTH_SESSION_COLUMNS = [
|
||||
'approx_last_used_at',
|
||||
'client_ip',
|
||||
'client_user_agent',
|
||||
'client_is_desktop',
|
||||
'client_os',
|
||||
'client_platform',
|
||||
'client_country',
|
||||
'version',
|
||||
] as const satisfies ReadonlyArray<keyof AuthSessionRow>;
|
||||
@@ -153,9 +147,7 @@ export const AUTH_SESSION_TOMBSTONE_COLUMNS = [
|
||||
'approx_last_used_at',
|
||||
'client_ip',
|
||||
'client_user_agent',
|
||||
'client_is_desktop',
|
||||
'client_os',
|
||||
'client_platform',
|
||||
'client_country',
|
||||
'deleted_at',
|
||||
'version',
|
||||
|
||||
@@ -104,6 +104,7 @@ export function GuildDiscoveryController(app: HonoApp) {
|
||||
app.post(
|
||||
'/discovery/guilds/:guild_id/join',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_JOIN),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
|
||||
@@ -319,6 +319,7 @@ export class GuildMemberService {
|
||||
sendJoinMessage?: boolean;
|
||||
skipGuildLimitCheck?: boolean;
|
||||
skipBanCheck?: boolean;
|
||||
skipRiskGate?: boolean;
|
||||
isTemporary?: boolean;
|
||||
joinSourceType?: JoinSourceType;
|
||||
sourceInviteCode?: InviteCode;
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {type JoinSourceType, JoinSourceTypes, SystemChannelFlags} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
GuildFeatures,
|
||||
type JoinSourceType,
|
||||
JoinSourceTypes,
|
||||
SystemChannelFlags,
|
||||
} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
DEFAULT_GUILD_FOLDER_ICON,
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
type MentionReplyPreference,
|
||||
PHONE_REQUIREMENT_FLAGS,
|
||||
UserNotificationSettings,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -17,10 +24,12 @@ import {MaxGuildsError} from '@fluxer/errors/src/domains/guild/MaxGuildsError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
|
||||
import {CommunicationDisabledError} from '@fluxer/errors/src/domains/moderation/CommunicationDisabledError';
|
||||
import {AccountSuspiciousActivityError} from '@fluxer/errors/src/domains/user/AccountSuspiciousActivityError';
|
||||
import {UserNotInVoiceError} from '@fluxer/errors/src/domains/user/UserNotInVoiceError';
|
||||
import {DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildMemberUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
import {ms} from 'itty-time';
|
||||
import {requireEmailVerified} from '../../../auth/EmailVerificationUtils';
|
||||
@@ -38,13 +47,24 @@ import {resolveLimitSafe} from '../../../limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder';
|
||||
import {profileSubstringBlocklistCache} from '../../../middleware/ProfileSubstringBlocklistCache';
|
||||
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
||||
import type {Guild} from '../../../models/Guild';
|
||||
import type {GuildMember} from '../../../models/GuildMember';
|
||||
import type {User} from '../../../models/User';
|
||||
import type {UserGuildSettings} from '../../../models/UserGuildSettings';
|
||||
import type {UserSettings} from '../../../models/UserSettings';
|
||||
import {
|
||||
DEFAULT_PHONE_GATE_MEMBER_THRESHOLD,
|
||||
evaluateDeferredPhoneGate,
|
||||
getDeferredPhoneGateConfig,
|
||||
guildTriggersPhoneGate,
|
||||
} from '../../../risk/DeferredPhoneGate';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import {isProfileSubstringExempt} from '../../../user/UserHelpers';
|
||||
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '../../../user/UserMappers';
|
||||
import {getEffectiveSuspiciousFlags, isProfileSubstringExempt} from '../../../user/UserHelpers';
|
||||
import {
|
||||
mapUserGuildSettingsToResponse,
|
||||
mapUserSettingsToResponse,
|
||||
mapUserToPrivateResponse,
|
||||
} from '../../../user/UserMappers';
|
||||
import {addGuildToUncategorizedFolder, removeGuildFromUserFolders} from '../../../user/utils/GuildFolderUtils';
|
||||
import type {GuildAuditLogService} from '../../GuildAuditLogService';
|
||||
import type {GuildAuditLogChange} from '../../GuildAuditLogTypes';
|
||||
@@ -374,6 +394,68 @@ export class GuildMemberOperationsService {
|
||||
await this.gatewayService.leaveGuild({userId: targetId, guildId});
|
||||
}
|
||||
|
||||
private async applyDeferredPhoneGate(user: User, guild: Guild): Promise<void> {
|
||||
if (user.hasVerifiedPhone) {
|
||||
return;
|
||||
}
|
||||
const rawFlags = user.suspiciousActivityFlags ?? 0;
|
||||
if ((rawFlags & (DEFERRED_PHONE_ON_COMMUNITY_JOIN | PHONE_REQUIREMENT_FLAGS)) === 0) {
|
||||
return;
|
||||
}
|
||||
const {status, config} = await getDeferredPhoneGateConfig();
|
||||
const logContext = {
|
||||
userId: user.id.toString(),
|
||||
guildId: guild.id.toString(),
|
||||
discoverable: guild.features.has(GuildFeatures.DISCOVERABLE),
|
||||
memberCount: guild.memberCount,
|
||||
accountAgeMs: Date.now() - snowflakeToDate(BigInt(user.id)).getTime(),
|
||||
};
|
||||
if (status !== 'ok') {
|
||||
const undeferredFlags = getEffectiveSuspiciousFlags({
|
||||
...user,
|
||||
suspiciousActivityFlags: rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
} as User);
|
||||
if (
|
||||
(undeferredFlags & PHONE_REQUIREMENT_FLAGS) === 0 ||
|
||||
!guildTriggersPhoneGate(guild, DEFAULT_PHONE_GATE_MEMBER_THRESHOLD)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
Logger.info(logContext, `deferred_phone_gate.enforced_while_${status}`);
|
||||
throw new AccountSuspiciousActivityError(undeferredFlags);
|
||||
}
|
||||
const liveFlags = getEffectiveSuspiciousFlags(user);
|
||||
if ((liveFlags & PHONE_REQUIREMENT_FLAGS) !== 0) {
|
||||
if (!guildTriggersPhoneGate(guild, config.memberThreshold)) {
|
||||
return;
|
||||
}
|
||||
Logger.info(logContext, 'deferred_phone_gate.blocked_unsatisfied_phone_requirement');
|
||||
throw new AccountSuspiciousActivityError(liveFlags);
|
||||
}
|
||||
const outcome = evaluateDeferredPhoneGate(user, guild, config, Date.now());
|
||||
if (!outcome.applies) {
|
||||
Logger.info(logContext, `deferred_phone_gate.skipped_${outcome.reason}`);
|
||||
return;
|
||||
}
|
||||
const promotedFlags = getEffectiveSuspiciousFlags({...user, suspiciousActivityFlags: outcome.flags} as User);
|
||||
if (promotedFlags === 0) {
|
||||
Logger.info(logContext, 'deferred_phone_gate.skipped_unenforceable');
|
||||
return;
|
||||
}
|
||||
const updatedUser = await this.userRepository.patchUpsert(
|
||||
user.id,
|
||||
{suspicious_activity_flags: outcome.flags},
|
||||
user.toRow(),
|
||||
);
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser),
|
||||
});
|
||||
Logger.info(logContext, 'deferred_phone_gate.applied');
|
||||
throw new AccountSuspiciousActivityError(promotedFlags);
|
||||
}
|
||||
|
||||
async addUserToGuild(
|
||||
params: {
|
||||
userId: UserID;
|
||||
@@ -381,6 +463,7 @@ export class GuildMemberOperationsService {
|
||||
sendJoinMessage?: boolean;
|
||||
skipGuildLimitCheck?: boolean;
|
||||
skipBanCheck?: boolean;
|
||||
skipRiskGate?: boolean;
|
||||
isTemporary?: boolean;
|
||||
joinSourceType?: JoinSourceType;
|
||||
sourceInviteCode?: InviteCode;
|
||||
@@ -398,6 +481,7 @@ export class GuildMemberOperationsService {
|
||||
sendJoinMessage = true,
|
||||
skipGuildLimitCheck = false,
|
||||
skipBanCheck = false,
|
||||
skipRiskGate = false,
|
||||
isTemporary = false,
|
||||
joinSourceType = JoinSourceTypes.INSTANT_INVITE,
|
||||
sourceInviteCode = null,
|
||||
@@ -418,6 +502,9 @@ export class GuildMemberOperationsService {
|
||||
if (!skipGuildLimitCheck) {
|
||||
await this.enforceGuildLimit(user, userGuildsCount);
|
||||
}
|
||||
if (!skipRiskGate && !user.isBot) {
|
||||
await this.applyDeferredPhoneGate(user, guild);
|
||||
}
|
||||
const maxGuildMembers = resolveMaxGuildMembersLimit({
|
||||
guildFeatures: guild.features,
|
||||
snapshot: this.limitConfigService.getConfigSnapshot(),
|
||||
|
||||
@@ -187,3 +187,26 @@ describe('StorageService.copyObjectWithMetadataStripping', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider selection', () => {
|
||||
interface ClientProbe {
|
||||
client: {config: {region: () => Promise<string>; endpoint?: () => Promise<{hostname: string}>}};
|
||||
}
|
||||
|
||||
it('defaults to the shared S3 configuration', async () => {
|
||||
const service = new StorageService() as unknown as ClientProbe;
|
||||
expect(await service.client.config.region()).toBe(Config.s3.region);
|
||||
});
|
||||
|
||||
it('uses an explicitly supplied provider instead of the shared one', async () => {
|
||||
const service = new StorageService({
|
||||
endpoint: 'https://downloads.example.net',
|
||||
forcePathStyle: false,
|
||||
region: 'eu-central-9',
|
||||
accessKeyId: 'DL_KEY',
|
||||
secretAccessKey: 'DL_SECRET',
|
||||
}) as unknown as ClientProbe;
|
||||
expect(await service.client.config.region()).toBe('eu-central-9');
|
||||
expect(await service.client.config.region()).not.toBe(Config.s3.region);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {S3ProviderSettings} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createHash} from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
@@ -105,27 +106,36 @@ function extractStreamFromGet(out: GetObjectCommandOutput): Readable {
|
||||
export class StorageService implements IStorageService {
|
||||
private readonly client: S3Client;
|
||||
private readonly presignClient: S3Client;
|
||||
private readonly provider: S3ProviderSettings;
|
||||
|
||||
constructor() {
|
||||
this.client = buildPooledS3Client({
|
||||
constructor(provider?: S3ProviderSettings) {
|
||||
this.provider = provider ?? {
|
||||
endpoint: Config.s3.endpoint,
|
||||
presignedUrlBase: Config.s3.presignedUrlBase,
|
||||
forcePathStyle: Config.s3.forcePathStyle,
|
||||
region: Config.s3.region,
|
||||
accessKeyId: Config.s3.accessKeyId,
|
||||
secretAccessKey: Config.s3.secretAccessKey,
|
||||
};
|
||||
this.client = buildPooledS3Client({
|
||||
endpoint: this.provider.endpoint,
|
||||
region: this.provider.region,
|
||||
accessKeyId: this.provider.accessKeyId,
|
||||
secretAccessKey: this.provider.secretAccessKey,
|
||||
forcePathStyle: true,
|
||||
});
|
||||
this.presignClient = buildPooledS3Client({
|
||||
endpoint: this.resolvePresignEndpoint(),
|
||||
region: Config.s3.region,
|
||||
accessKeyId: Config.s3.accessKeyId,
|
||||
secretAccessKey: Config.s3.secretAccessKey,
|
||||
forcePathStyle: Config.s3.forcePathStyle,
|
||||
region: this.provider.region,
|
||||
accessKeyId: this.provider.accessKeyId,
|
||||
secretAccessKey: this.provider.secretAccessKey,
|
||||
forcePathStyle: this.provider.forcePathStyle,
|
||||
});
|
||||
}
|
||||
|
||||
private resolvePresignEndpoint(): string {
|
||||
const fallbackEndpoint = Config.s3.endpoint;
|
||||
const configuredEndpoint = Config.s3.presignedUrlBase;
|
||||
const fallbackEndpoint = this.provider.endpoint;
|
||||
const configuredEndpoint = this.provider.presignedUrlBase;
|
||||
if (!configuredEndpoint) {
|
||||
return fallbackEndpoint;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {Config} from '../Config';
|
||||
import {createDownloadsStorageService} from './StorageServiceFactory';
|
||||
|
||||
describe('createDownloadsStorageService', () => {
|
||||
it('returns null when no downloads override is configured', () => {
|
||||
expect(Config.s3Downloads.isOverridden).toBe(false);
|
||||
expect(createDownloadsStorageService()).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the downloads provider to the shared provider by default', () => {
|
||||
expect(Config.s3Downloads.settings.endpoint).toBe(Config.s3.endpoint);
|
||||
expect(Config.s3Downloads.settings.region).toBe(Config.s3.region);
|
||||
expect(Config.s3Downloads.settings.accessKeyId).toBe(Config.s3.accessKeyId);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '../Config';
|
||||
import type {IStorageService} from './IStorageService';
|
||||
import {StorageService} from './StorageService';
|
||||
|
||||
export function createStorageService(): IStorageService {
|
||||
return new StorageService();
|
||||
}
|
||||
|
||||
export function createDownloadsStorageService(): IStorageService | null {
|
||||
if (!Config.s3Downloads.isOverridden) {
|
||||
return null;
|
||||
}
|
||||
return new StorageService(Config.s3Downloads.settings);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {sanitizeLimitConfigForInstance} from '../constants/LimitConfig';
|
||||
import {fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
|
||||
import type {InstanceConfigurationRow} from '../database/types/InstanceConfigTypes';
|
||||
import {Logger} from '../Logger';
|
||||
import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from '../risk/DeferredPhoneGateCache';
|
||||
import {InstanceConfiguration} from '../Tables';
|
||||
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '../utils/AttachmentDecay';
|
||||
import {isJsonRecord, parseJsonArray, parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
@@ -84,6 +85,9 @@ export interface InstancePolicyConfig {
|
||||
gif_enabled: boolean | null;
|
||||
youtube_enabled: boolean | null;
|
||||
bluesky_enabled: boolean | null;
|
||||
deferred_phone_gate_enabled: boolean;
|
||||
deferred_phone_gate_window_hours: number;
|
||||
deferred_phone_gate_member_threshold: number;
|
||||
}
|
||||
|
||||
interface InstanceCommunityPublicConfig {
|
||||
@@ -402,6 +406,9 @@ const DEFAULT_INSTANCE_POLICY_CONFIG: InstancePolicyConfig = {
|
||||
gif_enabled: null,
|
||||
youtube_enabled: null,
|
||||
bluesky_enabled: null,
|
||||
deferred_phone_gate_enabled: false,
|
||||
deferred_phone_gate_window_hours: 6,
|
||||
deferred_phone_gate_member_threshold: 50,
|
||||
};
|
||||
|
||||
function isPremiumMode(value: unknown): value is InstancePremiumMode {
|
||||
@@ -412,6 +419,13 @@ function normalizeNullableBoolean(value: unknown): boolean | null {
|
||||
return typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeInstancePolicyConfig(value: unknown): InstancePolicyConfig {
|
||||
if (!isJsonRecord(value)) {
|
||||
return {...DEFAULT_INSTANCE_POLICY_CONFIG};
|
||||
@@ -425,6 +439,15 @@ function normalizeInstancePolicyConfig(value: unknown): InstancePolicyConfig {
|
||||
gif_enabled: normalizeNullableBoolean(value.gif_enabled),
|
||||
youtube_enabled: normalizeNullableBoolean(value.youtube_enabled),
|
||||
bluesky_enabled: normalizeNullableBoolean(value.bluesky_enabled),
|
||||
deferred_phone_gate_enabled: value.deferred_phone_gate_enabled === true,
|
||||
deferred_phone_gate_window_hours: normalizePositiveNumber(
|
||||
value.deferred_phone_gate_window_hours,
|
||||
DEFAULT_INSTANCE_POLICY_CONFIG.deferred_phone_gate_window_hours,
|
||||
),
|
||||
deferred_phone_gate_member_threshold: normalizePositiveNumber(
|
||||
value.deferred_phone_gate_member_threshold,
|
||||
DEFAULT_INSTANCE_POLICY_CONFIG.deferred_phone_gate_member_threshold,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -913,12 +936,18 @@ export class InstanceConfigRepository {
|
||||
this.refreshRequested = false;
|
||||
this.configCache = await this.fetchAllConfigsFromDatabase();
|
||||
} while (this.refreshRequested);
|
||||
this.syncDeferredPhoneGateCache(this.configCache.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
})().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
await this.refreshPromise;
|
||||
}
|
||||
|
||||
private syncDeferredPhoneGateCache(raw: string | null): void {
|
||||
const policy = raw ? normalizeInstancePolicyConfig(parseJsonRecord(raw)) : {...DEFAULT_INSTANCE_POLICY_CONFIG};
|
||||
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
|
||||
}
|
||||
|
||||
private updateCachedConfigs(entries: Array<[string, string]>): void {
|
||||
if (!this.configCache) {
|
||||
return;
|
||||
@@ -1079,16 +1108,16 @@ export class InstanceConfigRepository {
|
||||
|
||||
async getInstancePolicyConfig(): Promise<InstancePolicyConfig> {
|
||||
const raw = await this.getConfig(INSTANCE_POLICY_CONFIG_KEY);
|
||||
if (!raw) {
|
||||
return {...DEFAULT_INSTANCE_POLICY_CONFIG};
|
||||
}
|
||||
return normalizeInstancePolicyConfig(parseJsonRecord(raw));
|
||||
const policy = raw ? normalizeInstancePolicyConfig(parseJsonRecord(raw)) : {...DEFAULT_INSTANCE_POLICY_CONFIG};
|
||||
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
|
||||
return policy;
|
||||
}
|
||||
|
||||
async setInstancePolicyConfig(config: Partial<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
|
||||
const current = await this.getInstancePolicyConfig();
|
||||
const next = normalizeInstancePolicyConfig({...current, ...config});
|
||||
await this.setConfig(INSTANCE_POLICY_CONFIG_KEY, JSON.stringify(next));
|
||||
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export class SingleCommunityService {
|
||||
}
|
||||
try {
|
||||
await this.guildMemberService.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
skipGuildLimitCheck: true,
|
||||
|
||||
@@ -69,7 +69,8 @@ export class LimitConfigService {
|
||||
}
|
||||
|
||||
async refreshCache(): Promise<void> {
|
||||
this.premiumMode = (await this.repository.getInstancePolicyConfig()).premium_mode;
|
||||
const policyConfig = await this.repository.getInstancePolicyConfig();
|
||||
this.premiumMode = policyConfig.premium_mode;
|
||||
setCachedInstancePremiumMode(this.premiumMode);
|
||||
const currentHash = computeDefaultsHash();
|
||||
const lockToken = await this.cacheService.acquireLock(LIMIT_CONFIG_REFRESH_LOCK_KEY, 10);
|
||||
|
||||
@@ -60,7 +60,7 @@ import {KVActivityTracker} from '../infrastructure/KVActivityTracker';
|
||||
import {KVBulkMessageDeletionQueueService} from '../infrastructure/KVBulkMessageDeletionQueueService';
|
||||
import {NatsUnfurlerService} from '../infrastructure/NatsUnfurlerService';
|
||||
import {PremiumStateReconciliationQueueService} from '../infrastructure/PremiumStateReconciliationQueueService';
|
||||
import {createStorageService} from '../infrastructure/StorageServiceFactory';
|
||||
import {createDownloadsStorageService, createStorageService} from '../infrastructure/StorageServiceFactory';
|
||||
import {UserCacheService} from '../infrastructure/UserCacheService';
|
||||
import {createUsersServiceClient} from '../infrastructure/UsersServiceClient';
|
||||
import {VirusScanService} from '../infrastructure/VirusScanService';
|
||||
@@ -193,6 +193,10 @@ export const getStorageService: () => IStorageService = (() => {
|
||||
const fallback = singleton(() => createStorageService());
|
||||
return () => _injectedStorageService ?? fallback();
|
||||
})();
|
||||
const getDownloadsStorageService: () => IStorageService = (() => {
|
||||
const override = singleton(() => createDownloadsStorageService());
|
||||
return () => override() ?? getStorageService();
|
||||
})();
|
||||
export const getErrorI18nService = singleton(() => new ErrorI18nService());
|
||||
export const getLimitConfigService = singleton(
|
||||
() => new LimitConfigService(getInstanceConfigRepository(), getCacheService(), getKVClient()),
|
||||
@@ -262,7 +266,7 @@ export function getKVAccountDeletionQueue(): KVAccountDeletionQueueService {
|
||||
return accountDeletionQueue;
|
||||
}
|
||||
|
||||
export const getDownloadService = singleton(() => new DownloadService(getStorageService()));
|
||||
export const getDownloadService = singleton(() => new DownloadService(getDownloadsStorageService()));
|
||||
export const getThemeService = singleton(() => new ThemeService(getStorageService()));
|
||||
const getNcmecReporter = singleton(() => new NcmecReporter({config: createNcmecApiConfig(), fetch}));
|
||||
const getNcmecRepository = singleton(() => new NcmecRepository());
|
||||
|
||||
@@ -10,9 +10,7 @@ export class AuthSession {
|
||||
readonly approximateLastUsedAt: Date;
|
||||
readonly clientIp: string;
|
||||
readonly clientUserAgent: string | null;
|
||||
readonly clientIsDesktop: boolean | null;
|
||||
readonly clientOs?: string | null;
|
||||
readonly clientPlatform?: string | null;
|
||||
readonly clientOs: string | null;
|
||||
readonly clientCountry: string | null;
|
||||
readonly version: number;
|
||||
|
||||
@@ -23,9 +21,7 @@ export class AuthSession {
|
||||
this.approximateLastUsedAt = row.approx_last_used_at;
|
||||
this.clientIp = row.client_ip;
|
||||
this.clientUserAgent = row.client_user_agent ?? null;
|
||||
this.clientIsDesktop = row.client_is_desktop ?? null;
|
||||
this.clientOs = row.client_os ?? null;
|
||||
this.clientPlatform = row.client_platform ?? null;
|
||||
this.clientCountry = row.client_country ?? null;
|
||||
this.version = row.version;
|
||||
}
|
||||
@@ -38,9 +34,7 @@ export class AuthSession {
|
||||
approx_last_used_at: this.approximateLastUsedAt,
|
||||
client_ip: this.clientIp,
|
||||
client_user_agent: this.clientUserAgent,
|
||||
client_is_desktop: this.clientIsDesktop,
|
||||
client_os: this.clientOs,
|
||||
client_platform: this.clientPlatform,
|
||||
client_country: this.clientCountry,
|
||||
version: this.version,
|
||||
};
|
||||
@@ -54,9 +48,7 @@ export class AuthSessionTombstone {
|
||||
readonly approximateLastUsedAt: Date;
|
||||
readonly clientIp: string;
|
||||
readonly clientUserAgent: string | null;
|
||||
readonly clientIsDesktop: boolean | null;
|
||||
readonly clientOs?: string | null;
|
||||
readonly clientPlatform?: string | null;
|
||||
readonly clientOs: string | null;
|
||||
readonly clientCountry: string | null;
|
||||
readonly deletedAt: Date;
|
||||
readonly version: number;
|
||||
@@ -68,9 +60,7 @@ export class AuthSessionTombstone {
|
||||
this.approximateLastUsedAt = row.approx_last_used_at;
|
||||
this.clientIp = row.client_ip;
|
||||
this.clientUserAgent = row.client_user_agent ?? null;
|
||||
this.clientIsDesktop = row.client_is_desktop ?? null;
|
||||
this.clientOs = row.client_os ?? null;
|
||||
this.clientPlatform = row.client_platform ?? null;
|
||||
this.clientCountry = row.client_country ?? null;
|
||||
this.deletedAt = row.deleted_at;
|
||||
this.version = row.version;
|
||||
|
||||
@@ -273,6 +273,7 @@ export class OAuth2RequestService {
|
||||
}
|
||||
}
|
||||
await this.guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId: botUserId,
|
||||
guildId,
|
||||
skipGuildLimitCheck: true,
|
||||
|
||||
@@ -26040,11 +26040,18 @@
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "The operating system of the requesting device"
|
||||
},
|
||||
"device": {
|
||||
"enum": ["mobile", "desktop"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["mobile", "desktop"],
|
||||
"description": "Device class of the requesting device, decided by the server"
|
||||
},
|
||||
"location": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}],
|
||||
"description": "The approximate location of the requesting device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["device"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
@@ -26288,11 +26295,18 @@
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "The browser reported by the client"
|
||||
},
|
||||
"device": {
|
||||
"enum": ["mobile", "desktop"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["mobile", "desktop"],
|
||||
"description": "Device class of the session, decided by the server"
|
||||
},
|
||||
"location": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}],
|
||||
"description": "The geolocation data sent by the client"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["device"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
@@ -27388,9 +27402,9 @@
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 25,
|
||||
"maximum": 50,
|
||||
"format": "int32",
|
||||
"description": "Number of messages to return for this channel (1-25)"
|
||||
"description": "Number of messages to return for this channel (1-50)"
|
||||
},
|
||||
"before": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
"after": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
@@ -33239,12 +33253,20 @@
|
||||
"payment_intent_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"charge_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"refund_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"refunded_amount_cents": {"type": "integer", "format": "int53"},
|
||||
"refunded_amount_cents": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount actually refunded so far, in the currency minor unit; 0 until the provider confirms success"
|
||||
},
|
||||
"invoice_amount_paid_cents": {"type": "integer", "format": "int53"},
|
||||
"currency": {"type": "string"},
|
||||
"subscription_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Subscription that was cancelled along with the refund, when applicable"
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Provider status of the refund (e.g. pending, succeeded, failed); money only moved once succeeded"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -33255,7 +33277,8 @@
|
||||
"refunded_amount_cents",
|
||||
"invoice_amount_paid_cents",
|
||||
"currency",
|
||||
"subscription_id"
|
||||
"subscription_id",
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"ReadStateAckResponse": {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {ms} from 'itty-time';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {Guild} from '../models/Guild';
|
||||
import type {User} from '../models/User';
|
||||
import {type DeferredPhoneGateConfig, evaluateDeferredPhoneGate, guildTriggersPhoneGate} from './DeferredPhoneGate';
|
||||
import {resolveDeferredPhoneGateEnabled} from './DeferredPhoneGateCache';
|
||||
|
||||
const CONFIG: DeferredPhoneGateConfig = {
|
||||
enabled: true,
|
||||
windowMs: 6 * ms('1 hour'),
|
||||
memberThreshold: 50,
|
||||
};
|
||||
|
||||
const USER_SNOWFLAKE = 1485046297690587136n;
|
||||
const REGISTERED_AT = snowflakeToDate(USER_SNOWFLAKE).getTime();
|
||||
|
||||
function createUser(overrides: Partial<Pick<User, 'hasVerifiedPhone' | 'suspiciousActivityFlags'>> = {}): User {
|
||||
return {
|
||||
id: USER_SNOWFLAKE,
|
||||
hasVerifiedPhone: false,
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
...overrides,
|
||||
} as unknown as User;
|
||||
}
|
||||
|
||||
function createGuild(overrides: {discoverable?: boolean; memberCount?: number} = {}): Guild {
|
||||
return {
|
||||
id: 1n,
|
||||
features: new Set(overrides.discoverable ? [GuildFeatures.DISCOVERABLE] : []),
|
||||
memberCount: overrides.memberCount ?? 10,
|
||||
} as unknown as Guild;
|
||||
}
|
||||
|
||||
describe('evaluateDeferredPhoneGate', () => {
|
||||
it('applies to a discoverable guild inside the window, promoting the real phone flags', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser(),
|
||||
createGuild({discoverable: true, memberCount: 3}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + ms('1 hour'),
|
||||
);
|
||||
expect(outcome.applies).toBe(true);
|
||||
if (!outcome.applies) return;
|
||||
expect(outcome.flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
expect(outcome.flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
});
|
||||
|
||||
it('applies to a large non-discoverable guild inside the window', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser(),
|
||||
createGuild({memberCount: 51}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + ms('1 hour'),
|
||||
);
|
||||
expect(outcome.applies).toBe(true);
|
||||
});
|
||||
|
||||
it('does not apply to a small non-discoverable guild', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser(),
|
||||
createGuild({memberCount: 50}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + ms('1 hour'),
|
||||
);
|
||||
expect(outcome).toEqual({applies: false, reason: 'guild_below_threshold'});
|
||||
});
|
||||
|
||||
it('applies on the last millisecond inside the window', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser(),
|
||||
createGuild({discoverable: true}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + CONFIG.windowMs - 1,
|
||||
);
|
||||
expect(outcome.applies).toBe(true);
|
||||
});
|
||||
|
||||
it('does not gate once the window has elapsed, and mutates nothing', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser(),
|
||||
createGuild({discoverable: true}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + CONFIG.windowMs,
|
||||
);
|
||||
expect(outcome).toEqual({applies: false, reason: 'outside_window'});
|
||||
});
|
||||
it('is inert while the gate is disabled, even on a qualifying guild', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser(),
|
||||
createGuild({discoverable: true}),
|
||||
{...CONFIG, enabled: false},
|
||||
REGISTERED_AT + ms('1 hour'),
|
||||
);
|
||||
expect(outcome).toEqual({applies: false, reason: 'gate_disabled'});
|
||||
});
|
||||
|
||||
it('does not apply to a user who already has a verified phone', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser({hasVerifiedPhone: true}),
|
||||
createGuild({discoverable: true}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + ms('1 hour'),
|
||||
);
|
||||
expect(outcome).toEqual({applies: false, reason: 'already_verified'});
|
||||
});
|
||||
|
||||
it('preserves non-phone requirements when promoting', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser({
|
||||
suspiciousActivityFlags:
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
|
||||
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION |
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
}),
|
||||
createGuild({discoverable: true}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + ms('1 hour'),
|
||||
);
|
||||
expect(outcome.applies).toBe(true);
|
||||
if (!outcome.applies) return;
|
||||
expect(outcome.flags).toBe(
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDeferredPhoneGateEnabled', () => {
|
||||
it('is on only when the tunable is set and the instance is not single-community', () => {
|
||||
expect(resolveDeferredPhoneGateEnabled({deferred_phone_gate_enabled: true, single_community_enabled: false})).toBe(
|
||||
true,
|
||||
);
|
||||
expect(resolveDeferredPhoneGateEnabled({deferred_phone_gate_enabled: true, single_community_enabled: true})).toBe(
|
||||
false,
|
||||
);
|
||||
expect(resolveDeferredPhoneGateEnabled({deferred_phone_gate_enabled: false, single_community_enabled: false})).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guildTriggersPhoneGate', () => {
|
||||
it('qualifies a discoverable guild regardless of size', () => {
|
||||
expect(guildTriggersPhoneGate(createGuild({discoverable: true, memberCount: 1}), 50)).toBe(true);
|
||||
});
|
||||
it('qualifies a guild strictly above the member threshold', () => {
|
||||
expect(guildTriggersPhoneGate(createGuild({memberCount: 51}), 50)).toBe(true);
|
||||
expect(guildTriggersPhoneGate(createGuild({memberCount: 50}), 50)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
DEFERRABLE_PHONE_FLAGS,
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
NEVER_DEFERRABLE_PHONE_FLAGS,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {ms} from 'itty-time';
|
||||
import {Logger} from '../Logger';
|
||||
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
|
||||
import type {Guild} from '../models/Guild';
|
||||
import type {User} from '../models/User';
|
||||
import {resolveDeferredPhoneGateEnabled} from './DeferredPhoneGateCache';
|
||||
|
||||
export interface DeferredPhoneGateConfig {
|
||||
enabled: boolean;
|
||||
windowMs: number;
|
||||
memberThreshold: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PHONE_GATE_MEMBER_THRESHOLD = 50;
|
||||
|
||||
const DISABLED_CONFIG: DeferredPhoneGateConfig = {
|
||||
enabled: false,
|
||||
windowMs: Number.POSITIVE_INFINITY,
|
||||
memberThreshold: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
|
||||
type DeferredPhoneGateConfigResult =
|
||||
| {status: 'ok'; config: DeferredPhoneGateConfig}
|
||||
| {status: 'disabled'; config: DeferredPhoneGateConfig}
|
||||
| {status: 'unreadable'; config: DeferredPhoneGateConfig};
|
||||
|
||||
export async function getDeferredPhoneGateConfig(): Promise<DeferredPhoneGateConfigResult> {
|
||||
try {
|
||||
const policy = await getInstanceConfigRepository().getInstancePolicyConfig();
|
||||
if (!resolveDeferredPhoneGateEnabled(policy)) {
|
||||
return {status: 'disabled', config: DISABLED_CONFIG};
|
||||
}
|
||||
return {
|
||||
status: 'ok',
|
||||
config: {
|
||||
enabled: true,
|
||||
windowMs: policy.deferred_phone_gate_window_hours * ms('1 hour'),
|
||||
memberThreshold: policy.deferred_phone_gate_member_threshold,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to read deferred phone gate config');
|
||||
return {status: 'unreadable', config: DISABLED_CONFIG};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deferPhoneFlagsUntilCommunityJoin(flagBits: number): Promise<number> {
|
||||
if ((flagBits & DEFERRABLE_PHONE_FLAGS) === 0 || (flagBits & NEVER_DEFERRABLE_PHONE_FLAGS) !== 0) {
|
||||
return flagBits;
|
||||
}
|
||||
const {status} = await getDeferredPhoneGateConfig();
|
||||
if (status !== 'ok') {
|
||||
return flagBits;
|
||||
}
|
||||
return flagBits | DEFERRED_PHONE_ON_COMMUNITY_JOIN;
|
||||
}
|
||||
|
||||
export function guildTriggersPhoneGate(guild: Guild, memberThreshold: number): boolean {
|
||||
return guild.features.has(GuildFeatures.DISCOVERABLE) || guild.memberCount > memberThreshold;
|
||||
}
|
||||
|
||||
type DeferredPhoneGateOutcome =
|
||||
| {applies: false; reason: 'gate_disabled' | 'already_verified' | 'guild_below_threshold' | 'outside_window'}
|
||||
| {applies: true; flags: number};
|
||||
|
||||
export function evaluateDeferredPhoneGate(
|
||||
user: User,
|
||||
guild: Guild,
|
||||
config: DeferredPhoneGateConfig,
|
||||
now: number,
|
||||
): DeferredPhoneGateOutcome {
|
||||
if (!config.enabled) {
|
||||
return {applies: false, reason: 'gate_disabled'};
|
||||
}
|
||||
if (user.hasVerifiedPhone) {
|
||||
return {applies: false, reason: 'already_verified'};
|
||||
}
|
||||
if (!guildTriggersPhoneGate(guild, config.memberThreshold)) {
|
||||
return {applies: false, reason: 'guild_below_threshold'};
|
||||
}
|
||||
if (now - snowflakeToDate(BigInt(user.id)).getTime() >= config.windowMs) {
|
||||
return {applies: false, reason: 'outside_window'};
|
||||
}
|
||||
return {applies: true, flags: (user.suspiciousActivityFlags ?? 0) & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
let cachedEnabled = false;
|
||||
|
||||
export function resolveDeferredPhoneGateEnabled(policy: {
|
||||
deferred_phone_gate_enabled: boolean;
|
||||
single_community_enabled: boolean;
|
||||
}): boolean {
|
||||
return policy.deferred_phone_gate_enabled && !policy.single_community_enabled;
|
||||
}
|
||||
|
||||
export function getCachedDeferredPhoneGateEnabled(): boolean {
|
||||
return cachedEnabled;
|
||||
}
|
||||
|
||||
export function setCachedDeferredPhoneGateEnabled(enabled: boolean): void {
|
||||
cachedEnabled = enabled;
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PremiumFlags, SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
imposePhoneRequirements,
|
||||
PremiumFlags,
|
||||
SuspiciousActivityFlags,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import type {RpcSessionTimings} from '@fluxer/schema/src/domains/rpc/RpcSchemas';
|
||||
import {Config} from '../Config';
|
||||
import type {UserRow} from '../database/types/UserTypes';
|
||||
@@ -370,12 +376,14 @@ export class RpcSessionStartService {
|
||||
timeRpcStepSync(
|
||||
timingSteps,
|
||||
'check_required_inbound_phone_flags_already_set',
|
||||
() => (user.suspiciousActivityFlags & requiredFlags) === requiredFlags,
|
||||
() =>
|
||||
(user.suspiciousActivityFlags & requiredFlags) === requiredFlags &&
|
||||
(user.suspiciousActivityFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const newFlags = user.suspiciousActivityFlags | requiredFlags;
|
||||
const newFlags = imposePhoneRequirements(user.suspiciousActivityFlags, requiredFlags);
|
||||
try {
|
||||
const updatedUser = await timeRpcStep(timingSteps, 'persist_inbound_phone_requirement', async () =>
|
||||
this.deps.userRepository.patchUpsert(user.id, {suspicious_activity_flags: newFlags}, user.toRow()),
|
||||
|
||||
@@ -296,6 +296,7 @@ export class StripePremiumService {
|
||||
const existingMember = await this.guildRepository.getMember(visionariesGuildId, userId);
|
||||
if (!existingMember) {
|
||||
await this.guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId: visionariesGuildId,
|
||||
sendJoinMessage: true,
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
SelfServeRefundResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type Stripe from 'stripe';
|
||||
import type {UserID} from '../../BrandedTypes';
|
||||
import {createUserID, type UserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
@@ -206,6 +206,51 @@ export class StripeRefundService {
|
||||
};
|
||||
}
|
||||
|
||||
private async countPriorTerminalFailures(invoiceId: string): Promise<number> {
|
||||
const priorRefunds = await getBillingRepository().refunds.listByInvoice(invoiceId);
|
||||
return priorRefunds.filter((r) => r.status === 'failed' || r.status === 'canceled').length;
|
||||
}
|
||||
|
||||
private async finalizeIfSucceeded(refund: Stripe.Refund): Promise<void> {
|
||||
if (refund.status !== 'succeeded' || refund.metadata?.refund_kind !== 'self_serve') {
|
||||
return;
|
||||
}
|
||||
const userIdRaw = refund.metadata.user_id;
|
||||
if (!userIdRaw) {
|
||||
return;
|
||||
}
|
||||
let userId: UserID;
|
||||
try {
|
||||
userId = createUserID(BigInt(userIdRaw));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user || user.firstRefundAt) {
|
||||
return;
|
||||
}
|
||||
const subscriptionId = refund.metadata.subscription_id;
|
||||
if (subscriptionId) {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString(), subscriptionId},
|
||||
'Self-serve refund confirmed but subscription cancellation failed; will reconcile via webhook',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
|
||||
Logger.info(
|
||||
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null},
|
||||
'Self-serve refund confirmed succeeded; cooldown and cancellation finalized',
|
||||
);
|
||||
}
|
||||
|
||||
async handleRefundWebhookEvent(refund: Stripe.Refund): Promise<void> {
|
||||
await this.finalizeIfSucceeded(refund);
|
||||
}
|
||||
|
||||
async refundLatestPurchase(userId: UserID): Promise<SelfServeRefundResponse> {
|
||||
const stripe = this.ensureStripe();
|
||||
const user = await this.getRequiredUser(userId);
|
||||
@@ -220,6 +265,14 @@ export class StripeRefundService {
|
||||
if (this.cooldownExpiresAt(user)) {
|
||||
throw new StripeRefundCooldownActiveError();
|
||||
}
|
||||
const priorFailures = await this.countPriorTerminalFailures(target.invoiceId);
|
||||
const idempotencyKey = [
|
||||
'self-serve-refund',
|
||||
user.id.toString(),
|
||||
target.invoiceId,
|
||||
target.paymentIntentId ?? target.chargeId,
|
||||
...(priorFailures > 0 ? [`retry-${priorFailures}`] : []),
|
||||
].join(':');
|
||||
let refund: Stripe.Response<Stripe.Refund>;
|
||||
try {
|
||||
refund = await stripe.refunds.create(
|
||||
@@ -232,11 +285,10 @@ export class StripeRefundService {
|
||||
invoice_id: target.invoiceId,
|
||||
refund_kind: 'self_serve',
|
||||
refund_window_days: String(SELF_SERVE_REFUND_WINDOW_DAYS),
|
||||
...(target.subscriptionId ? {subscription_id: target.subscriptionId} : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
idempotencyKey: `self-serve-refund:${user.id}:${target.invoiceId}:${target.paymentIntentId ?? target.chargeId}`,
|
||||
},
|
||||
{idempotencyKey},
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
@@ -254,36 +306,29 @@ export class StripeRefundService {
|
||||
} catch (mirrorErr) {
|
||||
Logger.error({mirrorErr, refundId: refund.id}, 'Mirror upsert failed after Stripe write; reconciler will heal');
|
||||
}
|
||||
if (target.subscriptionId) {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString(), subscriptionId: target.subscriptionId},
|
||||
'Self-serve refund issued but subscription cancellation failed; will reconcile via webhook',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
|
||||
await this.finalizeIfSucceeded(refund);
|
||||
const succeeded = refund.status === 'succeeded';
|
||||
Logger.info(
|
||||
{
|
||||
userId: user.id.toString(),
|
||||
invoiceId: target.invoiceId,
|
||||
refundId: refund.id,
|
||||
status: refund.status,
|
||||
amountCents: refund.amount,
|
||||
subscriptionId: target.subscriptionId,
|
||||
},
|
||||
'Self-serve refund issued',
|
||||
succeeded ? 'Self-serve refund issued' : 'Self-serve refund created; awaiting confirmation from provider',
|
||||
);
|
||||
return {
|
||||
invoice_id: target.invoiceId,
|
||||
payment_intent_id: target.paymentIntentId,
|
||||
charge_id: target.chargeId,
|
||||
refund_id: refund.id,
|
||||
refunded_amount_cents: refund.amount,
|
||||
refunded_amount_cents: succeeded ? refund.amount : 0,
|
||||
invoice_amount_paid_cents: target.amountPaidCents,
|
||||
currency: target.currency,
|
||||
subscription_id: target.subscriptionId,
|
||||
subscription_id: succeeded ? target.subscriptionId : null,
|
||||
status: refund.status ?? 'pending',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {StripeGiftReversalHandler} from './StripeGiftReversalHandler';
|
||||
import type {StripeGiftService} from './StripeGiftService';
|
||||
import {StripePaymentFraudService} from './StripePaymentFraudService';
|
||||
import type {StripePremiumService} from './StripePremiumService';
|
||||
import type {StripeRefundService} from './StripeRefundService';
|
||||
import {StripeSubscriptionReconciler} from './StripeSubscriptionReconciler';
|
||||
import {StripeSubscriptionWebhookHandler} from './StripeSubscriptionWebhookHandler';
|
||||
|
||||
@@ -61,6 +62,7 @@ export class StripeWebhookService {
|
||||
adminRepository: AdminRepository,
|
||||
snowflakeService: ISnowflakeService,
|
||||
private billingRepository: BillingRepository,
|
||||
private refundService: StripeRefundService,
|
||||
) {
|
||||
this.checkoutHandler = new StripeCheckoutWebhookHandler(
|
||||
stripe,
|
||||
@@ -336,6 +338,7 @@ export class StripeWebhookService {
|
||||
livemode: event.livemode,
|
||||
}),
|
||||
);
|
||||
await this.refundService.handleRefundWebhookEvent(r);
|
||||
break;
|
||||
}
|
||||
case 'invoice.created':
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
@@ -105,18 +106,30 @@ function invoiceListHandler(invoices: ReadonlyArray<MockStripeInvoice>) {
|
||||
});
|
||||
}
|
||||
|
||||
function refundCreateHandler() {
|
||||
function refundCreateHandler(opts?: {
|
||||
status?: 'succeeded' | 'pending' | 'failed';
|
||||
failureReason?: string;
|
||||
onRequest?: (idempotencyKey: string | null) => void;
|
||||
}) {
|
||||
return http.post(`${STRIPE_API_BASE}/v1/refunds`, async ({request}) => {
|
||||
opts?.onRequest?.(request.headers.get('idempotency-key'));
|
||||
const formData = await request.formData();
|
||||
const params = Object.fromEntries(formData.entries());
|
||||
const metadata: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const match = key.match(/^metadata\[(.+)\]$/);
|
||||
if (match) metadata[match[1]] = value as string;
|
||||
}
|
||||
return HttpResponse.json({
|
||||
id: 're_test_self_serve',
|
||||
object: 'refund',
|
||||
amount: Number.parseInt((params.amount as string) ?? '0', 10),
|
||||
currency: 'usd',
|
||||
status: 'succeeded',
|
||||
status: opts?.status ?? 'succeeded',
|
||||
failure_reason: opts?.failureReason ?? null,
|
||||
payment_intent: params.payment_intent ?? null,
|
||||
charge: params.charge ?? null,
|
||||
metadata,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -274,5 +287,62 @@ describe('StripeRefundService self-serve refund', () => {
|
||||
.expect(400, APIErrorCodes.STRIPE_NO_PURCHASE_HISTORY)
|
||||
.execute();
|
||||
});
|
||||
test('does not finalize cooldown or cancel the subscription while the refund is still pending at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(
|
||||
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]),
|
||||
refundCreateHandler({status: 'pending'}),
|
||||
);
|
||||
const account = await createTestAccount(harness);
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
|
||||
});
|
||||
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
|
||||
.post('/premium/refund-latest')
|
||||
.execute();
|
||||
expect(response.status).toBe('pending');
|
||||
expect(response.refunded_amount_cents).toBe(0);
|
||||
expect(response.subscription_id).toBeNull();
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
test('does not finalize cooldown or cancel the subscription when the refund fails at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(
|
||||
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]),
|
||||
refundCreateHandler({status: 'failed', failureReason: 'unknown'}),
|
||||
);
|
||||
const account = await createTestAccount(harness);
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
|
||||
});
|
||||
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
|
||||
.post('/premium/refund-latest')
|
||||
.execute();
|
||||
expect(response.status).toBe('failed');
|
||||
expect(response.refunded_amount_cents).toBe(0);
|
||||
expect(response.subscription_id).toBeNull();
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
test('retries with a fresh idempotency key once a prior attempt has failed at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]));
|
||||
const account = await createTestAccount(harness);
|
||||
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
|
||||
const idempotencyKeys: Array<string | null> = [];
|
||||
server.use(refundCreateHandler({status: 'failed', onRequest: (key) => idempotencyKeys.push(key)}));
|
||||
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
|
||||
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
|
||||
expect(idempotencyKeys).toHaveLength(2);
|
||||
expect(idempotencyKeys[0]).not.toBeNull();
|
||||
expect(idempotencyKeys[1]).not.toBeNull();
|
||||
expect(idempotencyKeys[1]).not.toBe(idempotencyKeys[0]);
|
||||
expect(idempotencyKeys[1]).toContain('retry-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createMockWebhookPayload, type StripeWebhookEventData} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
import {UserRepository} from '../../user/repositories/UserRepository';
|
||||
import {setupSyncStripeWebhookWorker} from './StripeWebhookTestUtils';
|
||||
@@ -341,4 +343,90 @@ describe('Stripe Webhook Refund', () => {
|
||||
expect(updatedRedeemer.premium_type).toBe(UserPremiumTypes.LIFETIME);
|
||||
});
|
||||
});
|
||||
describe('refund.updated', () => {
|
||||
test('finalizes self-serve cooldown and cancels the subscription once the refund is confirmed succeeded', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const userRepository = new UserRepository();
|
||||
const subscriptionId = 'sub_test_webhook_finalize';
|
||||
await userRepository.patchUpsert(
|
||||
userId,
|
||||
{stripe_subscription_id: subscriptionId},
|
||||
(await userRepository.findUnique(userId))!.toRow(),
|
||||
);
|
||||
server.use(
|
||||
http.delete('https://api.stripe.com/v1/subscriptions/:id', ({params}) =>
|
||||
HttpResponse.json({id: params.id, object: 'subscription', status: 'canceled'}),
|
||||
),
|
||||
);
|
||||
await sendWebhook({
|
||||
type: 'refund.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: 'pyr_test_webhook_finalize',
|
||||
status: 'succeeded',
|
||||
amount: 2499,
|
||||
currency: 'brl',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId.toString(),
|
||||
invoice_id: 'in_test_webhook_finalize',
|
||||
subscription_id: subscriptionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).not.toBeNull();
|
||||
});
|
||||
test('does not finalize cooldown while the refund is still pending', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const userRepository = new UserRepository();
|
||||
await sendWebhook({
|
||||
type: 'refund.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: 'pyr_test_webhook_pending',
|
||||
status: 'pending',
|
||||
amount: 2499,
|
||||
currency: 'brl',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId.toString(),
|
||||
invoice_id: 'in_test_webhook_pending',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
});
|
||||
describe('refund.failed', () => {
|
||||
test('does not finalize cooldown when the refund ultimately fails', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const userRepository = new UserRepository();
|
||||
await sendWebhook({
|
||||
type: 'refund.failed',
|
||||
data: {
|
||||
object: {
|
||||
id: 'pyr_test_webhook_failed',
|
||||
status: 'failed',
|
||||
failure_reason: 'unknown',
|
||||
amount: 2499,
|
||||
currency: 'brl',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId.toString(),
|
||||
invoice_id: 'in_test_webhook_failed',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {Context} from 'hono';
|
||||
import {seconds} from 'itty-time';
|
||||
import {AttachmentDecayRepository} from '../attachment/AttachmentDecayRepository';
|
||||
import type {IpAuthorizationTicketCache} from '../auth/AuthLogin';
|
||||
import {getTicketCacheKey} from '../auth/AuthLogin';
|
||||
import {
|
||||
type ChannelID,
|
||||
createApplicationID,
|
||||
@@ -86,6 +87,7 @@ import {UserRepository} from '../user/repositories/UserRepository';
|
||||
import {processUserDeletion} from '../user/services/UserDeletionService';
|
||||
import {UserHarvestRepository} from '../user/UserHarvestRepository';
|
||||
import {getExpiryBucket} from '../utils/AttachmentDecay';
|
||||
import {parseReportedClientOs} from '../utils/SessionClientIdentity';
|
||||
import {ScheduledMessageExecutor} from '../worker/executors/ScheduledMessageExecutor';
|
||||
import {processExpiredAttachments} from '../worker/tasks/ExpireAttachments';
|
||||
import {processInactivityDeletionsCore} from '../worker/tasks/ProcessInactivityDeletions';
|
||||
@@ -627,7 +629,7 @@ export function TestHarnessController(app: HonoApp) {
|
||||
client_ip: clientIp,
|
||||
user_agent: userAgent,
|
||||
client_location: clientLocation,
|
||||
platform,
|
||||
client_properties: clientProperties,
|
||||
resend_used: resendUsed,
|
||||
invite_code: inviteCode,
|
||||
created_at: createdAtInput,
|
||||
@@ -649,9 +651,11 @@ export function TestHarnessController(app: HonoApp) {
|
||||
userId: String(userId),
|
||||
email: String(email),
|
||||
username: String(username),
|
||||
clientIp: String(clientIp),
|
||||
userAgent: String(userAgent),
|
||||
platform: platform ? String(platform) : null,
|
||||
origin: {
|
||||
ip: String(clientIp),
|
||||
userAgent: userAgent ? String(userAgent) : null,
|
||||
clientOs: parseReportedClientOs(clientProperties ? String(clientProperties) : null),
|
||||
},
|
||||
authToken: String(token),
|
||||
clientLocation: String(clientLocation),
|
||||
inviteCode: inviteCode ? String(inviteCode) : null,
|
||||
@@ -659,7 +663,7 @@ export function TestHarnessController(app: HonoApp) {
|
||||
createdAt,
|
||||
};
|
||||
const ttl = typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : seconds('15 minutes');
|
||||
await cacheService.set(`ip-auth-ticket:${ticket}`, payload, ttl);
|
||||
await cacheService.set(getTicketCacheKey(String(ticket)), payload, ttl);
|
||||
await cacheService.set(`ip-auth-token:${token}`, {ticket: String(ticket)}, ttl);
|
||||
return ctx.json(
|
||||
{
|
||||
@@ -721,7 +725,7 @@ export function TestHarnessController(app: HonoApp) {
|
||||
return ctx.json({error: 'ticket or token is required'}, 400);
|
||||
}
|
||||
if (ticket) {
|
||||
await cacheService.delete(`ip-auth-ticket:${ticket}`);
|
||||
await cacheService.delete(getTicketCacheKey(String(ticket)));
|
||||
await cacheService.delete(`ip-auth:${ticket}`);
|
||||
}
|
||||
if (token) {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PremiumFlags, SuspiciousActivityFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
imposePhoneRequirements,
|
||||
PremiumFlags,
|
||||
SuspiciousActivityFlags,
|
||||
UserPremiumTypes,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import type {User} from '../models/User';
|
||||
import {setInjectedAccountPolicyEvaluator} from '../risk/AccountPolicyService';
|
||||
import {setCachedDeferredPhoneGateEnabled} from '../risk/DeferredPhoneGateCache';
|
||||
import {
|
||||
createCurrentBehaviorTestAccountPolicyEvaluator,
|
||||
TEST_POLICY_CONTACT_DOMAIN,
|
||||
@@ -23,6 +30,88 @@ function createUser(
|
||||
} as User;
|
||||
}
|
||||
|
||||
describe('deferred phone gate marker', () => {
|
||||
beforeEach(() => {
|
||||
setInjectedAccountPolicyEvaluator(createCurrentBehaviorTestAccountPolicyEvaluator());
|
||||
setCachedDeferredPhoneGateEnabled(true);
|
||||
});
|
||||
afterEach(() => {
|
||||
setInjectedAccountPolicyEvaluator(undefined);
|
||||
setCachedDeferredPhoneGateEnabled(false);
|
||||
});
|
||||
it('does not suppress anything until a policy read has proven the gate is on', () => {
|
||||
setCachedDeferredPhoneGateEnabled(false);
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']);
|
||||
});
|
||||
it('suppresses a deferred phone requirement so the account is not locked out', () => {
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual([]);
|
||||
expect(getEffectiveSuspiciousFlags(user)).toBe(0);
|
||||
});
|
||||
it('never lets an inbound-SMS requirement be suppressed, since that tier is never deferred', () => {
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags:
|
||||
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE', 'REQUIRE_INBOUND_PHONE_VERIFICATION']);
|
||||
});
|
||||
it('keeps non-phone requirements active while a phone requirement is deferred', () => {
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags:
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE |
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_EMAIL']);
|
||||
expect(getEffectiveSuspiciousFlags(user)).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
|
||||
});
|
||||
it('applies the phone requirement in full once the marker is cleared', () => {
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE,
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']);
|
||||
expect(getEffectiveSuspiciousFlags(user)).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE);
|
||||
});
|
||||
it('leaves an account carrying only the marker completely unrestricted', () => {
|
||||
const user = createUser({suspiciousActivityFlags: DEFERRED_PHONE_ON_COMMUNITY_JOIN});
|
||||
expect(getRequiredActions(user)).toEqual([]);
|
||||
expect(getEffectiveSuspiciousFlags(user)).toBe(0);
|
||||
});
|
||||
it('re-arms stored phone requirements as soon as the gate is switched off', () => {
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual([]);
|
||||
setCachedDeferredPhoneGateEnabled(false);
|
||||
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']);
|
||||
});
|
||||
it('stops suppressing once another subsystem imposes the phone requirement directly', () => {
|
||||
const deferred = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN;
|
||||
const imposed = imposePhoneRequirements(deferred, SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE);
|
||||
expect(imposed & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
const user = createUser({suspiciousActivityFlags: imposed});
|
||||
expect(getRequiredActions(user)).toEqual(['REQUIRE_REVERIFIED_PHONE']);
|
||||
});
|
||||
it('keeps the marker when a non-phone requirement is imposed', () => {
|
||||
const deferred = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN;
|
||||
const imposed = imposePhoneRequirements(deferred, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
|
||||
expect(imposed & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(getRequiredActions(createUser({suspiciousActivityFlags: imposed}))).toEqual(['REQUIRE_VERIFIED_EMAIL']);
|
||||
});
|
||||
it('yields no enforceable requirement for an account without an email, so the gate must not promote it', () => {
|
||||
const user = createUser({
|
||||
email: null,
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE,
|
||||
});
|
||||
expect(getEffectiveSuspiciousFlags(user)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRequiredActions', () => {
|
||||
beforeEach(() => {
|
||||
setInjectedAccountPolicyEvaluator(createCurrentBehaviorTestAccountPolicyEvaluator());
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PremiumFlags, SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
DEFERRABLE_PHONE_FLAGS,
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
PremiumFlags,
|
||||
SuspiciousActivityFlags,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import type {RequiredAction} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
import {Config} from '../Config';
|
||||
@@ -8,6 +14,7 @@ import type {UserRow} from '../database/types/UserTypes';
|
||||
import {getCachedInstancePremiumMode} from '../limits/InstancePremiumModeCache';
|
||||
import type {User} from '../models/User';
|
||||
import {accountPolicyContactHasCapability} from '../risk/AccountPolicyService';
|
||||
import {getCachedDeferredPhoneGateEnabled} from '../risk/DeferredPhoneGateCache';
|
||||
|
||||
type ClauseAction = Exclude<RequiredAction, 'REQUIRE_INBOUND_PHONE_VERIFICATION'>;
|
||||
type VerificationChannel = 'email' | 'phone';
|
||||
@@ -123,8 +130,18 @@ function getRequiredActionSortIndex(action: RequiredAction): number {
|
||||
return index === -1 ? REQUIRED_ACTION_ORDER.length : index;
|
||||
}
|
||||
|
||||
function suppressDeferredPhoneFlags(rawFlags: number): number {
|
||||
if ((rawFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0) {
|
||||
return rawFlags;
|
||||
}
|
||||
if (!getCachedDeferredPhoneGateEnabled()) {
|
||||
return rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN;
|
||||
}
|
||||
return rawFlags & ~DEFERRABLE_PHONE_FLAGS;
|
||||
}
|
||||
|
||||
export function getRequiredActions(user: User): ReadonlyArray<RequiredAction> {
|
||||
const flags = user.suspiciousActivityFlags ?? 0;
|
||||
const flags = suppressDeferredPhoneFlags(user.suspiciousActivityFlags ?? 0);
|
||||
if (flags === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -211,9 +211,7 @@ function toTombstoneRow(row: AuthSessionRow, deletedAt: Date): AuthSessionTombst
|
||||
approx_last_used_at: row.approx_last_used_at,
|
||||
client_ip: row.client_ip,
|
||||
client_user_agent: row.client_user_agent,
|
||||
client_is_desktop: row.client_is_desktop,
|
||||
client_os: row.client_os ?? null,
|
||||
client_platform: row.client_platform ?? null,
|
||||
client_country: row.client_country ?? null,
|
||||
deleted_at: deletedAt,
|
||||
version: row.version,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, imposePhoneRequirements} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
@@ -299,7 +300,7 @@ export class UserAccountRequestService {
|
||||
action: emailSetRecommendedAction,
|
||||
},
|
||||
});
|
||||
nextSuspiciousFlags |= policyDecision.flagBits;
|
||||
nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyDecision.flagBits);
|
||||
if (nextSuspiciousFlags !== currentSuspiciousFlags) {
|
||||
user = await this.userRepository.patchUpsert(
|
||||
user.id,
|
||||
@@ -550,7 +551,7 @@ export class UserAccountRequestService {
|
||||
}
|
||||
|
||||
private shouldSkipFollowupRiskChecks(user: User): boolean {
|
||||
return user.hasEverPurchased || user.suspiciousActivityFlags === 0;
|
||||
return user.hasEverPurchased || ((user.suspiciousActivityFlags ?? 0) & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0;
|
||||
}
|
||||
|
||||
private enforceUserAccess(user: User): void {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {UserContentServiceTestHooks} from './UserContentService';
|
||||
|
||||
const {isUnreachableEntityError} = UserContentServiceTestHooks;
|
||||
|
||||
describe('isUnreachableEntityError', () => {
|
||||
it('treats a deleted or left community as unreachable rather than fatal', () => {
|
||||
expect(isUnreachableEntityError(new UnknownGuildError())).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a gone channel and a lost permission as unreachable', () => {
|
||||
expect(isUnreachableEntityError(new UnknownChannelError())).toBe(true);
|
||||
expect(isUnreachableEntityError(new MissingPermissionsError())).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a deleted message to the delete path instead of marking it unavailable', () => {
|
||||
expect(isUnreachableEntityError(new UnknownMessageError())).toBe(false);
|
||||
});
|
||||
|
||||
it('still lets unexpected failures surface', () => {
|
||||
expect(isUnreachableEntityError(new Error('database is on fire'))).toBe(false);
|
||||
expect(isUnreachableEntityError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownCha
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {MaxBookmarksError} from '@fluxer/errors/src/domains/core/MaxBookmarksError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {HarvestExpiredError} from '@fluxer/errors/src/domains/moderation/HarvestExpiredError';
|
||||
import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestFailedError';
|
||||
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
|
||||
@@ -94,6 +95,13 @@ function normalizeProviderEnvironment(
|
||||
return platform === 'ios_apns' ? DEFAULT_APNS_PROVIDER_ENVIRONMENT : null;
|
||||
}
|
||||
|
||||
const isUnreachableEntityError = (error: unknown): boolean =>
|
||||
error instanceof MissingPermissionsError ||
|
||||
error instanceof UnknownChannelError ||
|
||||
error instanceof UnknownGuildError;
|
||||
|
||||
export const UserContentServiceTestHooks = {isUnreachableEntityError};
|
||||
|
||||
export class UserContentService {
|
||||
private readonly updatePropagator: BaseUserUpdatePropagator;
|
||||
private readonly userRepository: UserContentRepository;
|
||||
@@ -138,11 +146,7 @@ export class UserContentService {
|
||||
messageId: mention.messageId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof UnknownMessageError ||
|
||||
error instanceof MissingPermissionsError ||
|
||||
error instanceof UnknownChannelError
|
||||
) {
|
||||
if (error instanceof UnknownMessageError || isUnreachableEntityError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
@@ -188,7 +192,7 @@ export class UserContentService {
|
||||
await this.userRepository.deleteSavedMessage(userId, savedMessage.messageId);
|
||||
return null;
|
||||
}
|
||||
if (error instanceof MissingPermissionsError || error instanceof UnknownChannelError) {
|
||||
if (isUnreachableEntityError(error)) {
|
||||
status = 'missing_permissions';
|
||||
} else {
|
||||
throw error;
|
||||
|
||||
@@ -808,6 +808,9 @@ export class UserRelationshipService {
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
if (user.pendingDeletionAt !== null) {
|
||||
return false;
|
||||
}
|
||||
return (user.flags & UserFlags.DELETED) === UserFlags.DELETED;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,16 @@ import {
|
||||
} from './RelationshipTestUtils';
|
||||
import {fetchUserMe} from './UserTestUtils';
|
||||
|
||||
async function markUserScheduledForDeletion(harness: ApiTestHarness, userId: string): Promise<void> {
|
||||
const pendingDeletionAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString();
|
||||
await createBuilder(harness, '')
|
||||
.post(`/test/users/${userId}/set-pending-deletion`)
|
||||
.body({pending_deletion_at: pendingDeletionAt, set_self_deleted_flag: false})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await markUserDeleted(harness, userId);
|
||||
}
|
||||
|
||||
async function markUserDeleted(harness: ApiTestHarness, userId: string): Promise<void> {
|
||||
await createBuilder(harness, '')
|
||||
.patch(`/test/users/${userId}/flags`)
|
||||
@@ -221,6 +231,16 @@ describe('UserRelationshipStateTransitions', () => {
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'FRIEND_REQUEST_BLOCKED')
|
||||
.execute();
|
||||
});
|
||||
test('can accept a friend request from a user scheduled for deletion', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
await sendFriendRequest(harness, bob.token, alice.userId);
|
||||
await markUserScheduledForDeletion(harness, bob.userId);
|
||||
const {json: friendship} = await acceptFriendRequest(harness, alice.token, bob.userId);
|
||||
assertRelationshipType(friendship, RelationshipTypes.FRIEND);
|
||||
const {json: aliceAfter} = await listRelationships(harness, alice.token);
|
||||
assertRelationshipType(findRelationship(aliceAfter, bob.userId)!, RelationshipTypes.FRIEND);
|
||||
});
|
||||
test('cannot send friend request to user who blocked you', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Bowser from 'bowser';
|
||||
import {Logger} from '../Logger';
|
||||
import {parseJsonRecord} from './JsonBoundaryUtils';
|
||||
|
||||
export type SessionDeviceClass = 'mobile' | 'desktop';
|
||||
|
||||
interface SessionClientSignals {
|
||||
userAgent: string | null;
|
||||
reportedOs: string | null;
|
||||
productName: string;
|
||||
}
|
||||
|
||||
export interface SessionClientInfo {
|
||||
platform: string | null;
|
||||
os: string | null;
|
||||
browser: string | null;
|
||||
device: SessionDeviceClass;
|
||||
}
|
||||
|
||||
type OsToken = 'android' | 'ios' | 'macos' | 'windows' | 'linux';
|
||||
|
||||
const OS_DISPLAY: Record<OsToken, string> = {
|
||||
android: 'Android',
|
||||
ios: 'iOS',
|
||||
macos: 'macOS',
|
||||
windows: 'Windows',
|
||||
linux: 'Linux',
|
||||
};
|
||||
|
||||
const BOWSER_OS_TO_TOKEN: Record<string, OsToken> = {
|
||||
macOS: 'macos',
|
||||
Windows: 'windows',
|
||||
Linux: 'linux',
|
||||
iOS: 'ios',
|
||||
Android: 'android',
|
||||
};
|
||||
|
||||
const NATIVE_UA_REGEX = /^Fluxer (Android|iOS|Linux|Desktop|Client)(?=[/ ]|$)/;
|
||||
const ELECTRON_UA_REGEX = /\bElectron\/\d+(?:\.\d+)*/;
|
||||
const PRODUCT_TOKEN_OS: Record<string, OsToken | null> = {
|
||||
Android: 'android',
|
||||
iOS: 'ios',
|
||||
Linux: 'linux',
|
||||
Desktop: null,
|
||||
Client: null,
|
||||
};
|
||||
const CLIENT_PROPERTIES_HEADER_MAX_LENGTH = 4096;
|
||||
const MOBILE_PLATFORM_TYPES = new Set(['mobile', 'tablet']);
|
||||
|
||||
function narrowOsToken(value: string | null): OsToken | null {
|
||||
if (value === null) return null;
|
||||
return Object.hasOwn(OS_DISPLAY, value) ? (value as OsToken) : null;
|
||||
}
|
||||
|
||||
function parseUserAgent(userAgent: string): Bowser.Parser.Parser | null {
|
||||
try {
|
||||
return Bowser.getParser(userAgent);
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to parse user agent');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function bowserOsToken(userAgent: string): OsToken | null {
|
||||
if (!userAgent) return null;
|
||||
return narrowOsToken(BOWSER_OS_TO_TOKEN[parseUserAgent(userAgent)?.getOSName() ?? ''] ?? null);
|
||||
}
|
||||
|
||||
export function isFluxerNativeUserAgent(userAgent: string | null): boolean {
|
||||
return NATIVE_UA_REGEX.test(userAgent?.trim() ?? '');
|
||||
}
|
||||
|
||||
export function parseReportedClientOs(headerValue: string | null): OsToken | null {
|
||||
if (!headerValue) return null;
|
||||
const trimmed = headerValue.trim();
|
||||
if (!trimmed || trimmed.length > CLIENT_PROPERTIES_HEADER_MAX_LENGTH) return null;
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(trimmed, 'base64').toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const record = parseJsonRecord(decoded);
|
||||
if (!record) return null;
|
||||
const os = record.os;
|
||||
return typeof os === 'string' ? narrowOsToken(os) : null;
|
||||
}
|
||||
|
||||
export function resolveSessionClientInfo({
|
||||
userAgent,
|
||||
reportedOs,
|
||||
productName,
|
||||
}: SessionClientSignals): SessionClientInfo {
|
||||
const ua = userAgent?.trim() ?? '';
|
||||
const nativeMatch = NATIVE_UA_REGEX.exec(ua);
|
||||
|
||||
if (nativeMatch) {
|
||||
const productToken = nativeMatch[1] as keyof typeof PRODUCT_TOKEN_OS;
|
||||
const osToken = narrowOsToken(reportedOs) ?? PRODUCT_TOKEN_OS[productToken] ?? bowserOsToken(ua);
|
||||
const osDisplay = osToken ? OS_DISPLAY[osToken] : null;
|
||||
const mobile = osToken === 'ios' || osToken === 'android';
|
||||
const platform = osDisplay
|
||||
? mobile
|
||||
? `${productName} ${osDisplay}`
|
||||
: `${productName} Lite ${osDisplay}`
|
||||
: `${productName} Lite`;
|
||||
return {platform, os: osDisplay, browser: null, device: mobile ? 'mobile' : 'desktop'};
|
||||
}
|
||||
|
||||
if (ELECTRON_UA_REGEX.test(ua)) {
|
||||
const osToken = bowserOsToken(ua);
|
||||
const osDisplay = osToken ? OS_DISPLAY[osToken] : null;
|
||||
const mobile = osToken === 'ios' || osToken === 'android';
|
||||
return {
|
||||
platform: osDisplay ? `${productName} ${osDisplay}` : productName,
|
||||
os: osDisplay,
|
||||
browser: null,
|
||||
device: mobile ? 'mobile' : 'desktop',
|
||||
};
|
||||
}
|
||||
|
||||
const parser = ua ? parseUserAgent(ua) : null;
|
||||
const browser = parser?.getBrowserName() || null;
|
||||
const os = parser?.getOSName() || null;
|
||||
const platformType = parser?.getPlatformType(true) ?? '';
|
||||
return {
|
||||
platform: browser ?? os,
|
||||
os,
|
||||
browser,
|
||||
device: MOBILE_PLATFORM_TYPES.has(platformType) ? 'mobile' : 'desktop',
|
||||
};
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Bowser from 'bowser';
|
||||
import {Logger} from '../Logger';
|
||||
|
||||
interface UserAgentInfo {
|
||||
clientOs: string;
|
||||
detectedPlatform: string;
|
||||
}
|
||||
|
||||
const UNKNOWN_LABEL = 'Unknown';
|
||||
|
||||
function formatName(name?: string | null): string {
|
||||
const normalized = name?.trim();
|
||||
return normalized || UNKNOWN_LABEL;
|
||||
}
|
||||
|
||||
function parseUserAgentSafe(userAgentRaw: string): UserAgentInfo {
|
||||
const ua = userAgentRaw.trim();
|
||||
if (!ua) return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
|
||||
try {
|
||||
const parser = Bowser.getParser(ua);
|
||||
return {
|
||||
clientOs: formatName(parser.getOSName()),
|
||||
detectedPlatform: formatName(parser.getBrowserName()),
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to parse user agent');
|
||||
return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSessionClientInfo(args: {userAgent: string | null; isDesktopClient: boolean | null}): {
|
||||
clientOs: string;
|
||||
clientPlatform: string;
|
||||
} {
|
||||
const parsed = parseUserAgentSafe(args.userAgent ?? '');
|
||||
const clientPlatform = args.isDesktopClient ? 'Fluxer Desktop' : parsed.detectedPlatform;
|
||||
return {
|
||||
clientOs: parsed.clientOs,
|
||||
clientPlatform,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
isFluxerNativeUserAgent,
|
||||
parseReportedClientOs,
|
||||
resolveSessionClientInfo,
|
||||
type SessionClientInfo,
|
||||
} from '../SessionClientIdentity';
|
||||
|
||||
const ELECTRON_MAC_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) FluxerStable/2026.614.83512 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36';
|
||||
const ELECTRON_WINDOWS_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) FluxerCanary/2026.614.83512 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36';
|
||||
const CHROME_MAC_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
||||
const SAFARI_IPHONE_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
|
||||
const resolve = (userAgent: string | null, reportedOs: string | null, productName = 'Fluxer'): SessionClientInfo =>
|
||||
resolveSessionClientInfo({userAgent, reportedOs, productName});
|
||||
|
||||
describe('resolveSessionClientInfo', () => {
|
||||
it('labels the Flutter mobile clients by their operating system', () => {
|
||||
expect(resolve('Fluxer iOS/1.4.2 (stable)', 'ios')).toEqual({
|
||||
platform: 'Fluxer iOS',
|
||||
os: 'iOS',
|
||||
browser: null,
|
||||
device: 'mobile',
|
||||
});
|
||||
expect(resolve('Fluxer Android/1.4.2 (stable)', 'android')).toEqual({
|
||||
platform: 'Fluxer Android',
|
||||
os: 'Android',
|
||||
browser: null,
|
||||
device: 'mobile',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts the versionless user agent emitted before package info loads', () => {
|
||||
expect(resolve('Fluxer iOS (stable)', 'ios')).toEqual({
|
||||
platform: 'Fluxer iOS',
|
||||
os: 'iOS',
|
||||
browser: null,
|
||||
device: 'mobile',
|
||||
});
|
||||
});
|
||||
|
||||
it('labels Flutter desktop builds as Lite and distinguishes them by reported operating system', () => {
|
||||
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'macos')).toEqual({
|
||||
platform: 'Fluxer Lite macOS',
|
||||
os: 'macOS',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'windows')).toEqual({
|
||||
platform: 'Fluxer Lite Windows',
|
||||
os: 'Windows',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'linux')).toEqual({
|
||||
platform: 'Fluxer Lite Linux',
|
||||
os: 'Linux',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a narrow Linux window as a desktop because the product token cannot carry form factor', () => {
|
||||
expect(resolve('Fluxer Linux/1.4.2 (stable)', 'linux')).toEqual({
|
||||
platform: 'Fluxer Lite Linux',
|
||||
os: 'Linux',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves legacy rows that predate the reported operating system', () => {
|
||||
expect(resolve('Fluxer iOS/1.4.2 (stable)', null)).toEqual({
|
||||
platform: 'Fluxer iOS',
|
||||
os: 'iOS',
|
||||
browser: null,
|
||||
device: 'mobile',
|
||||
});
|
||||
expect(resolve('Fluxer Desktop/1.4.2 (stable)', null)).toEqual({
|
||||
platform: 'Fluxer Lite',
|
||||
os: null,
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a corrupt reported operating system instead of rendering it', () => {
|
||||
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'Windows 11')).toEqual({
|
||||
platform: 'Fluxer Lite',
|
||||
os: null,
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
});
|
||||
|
||||
it('labels the desktop application by operating system without naming a browser', () => {
|
||||
expect(resolve(ELECTRON_MAC_UA, null)).toEqual({
|
||||
platform: 'Fluxer macOS',
|
||||
os: 'macOS',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
expect(resolve(ELECTRON_WINDOWS_UA, null)).toEqual({
|
||||
platform: 'Fluxer Windows',
|
||||
os: 'Windows',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps browser sessions reporting their browser', () => {
|
||||
expect(resolve(CHROME_MAC_UA, null)).toEqual({
|
||||
platform: 'Chrome',
|
||||
os: 'macOS',
|
||||
browser: 'Chrome',
|
||||
device: 'desktop',
|
||||
});
|
||||
expect(resolve(SAFARI_IPHONE_UA, null)).toEqual({
|
||||
platform: 'Safari',
|
||||
os: 'iOS',
|
||||
browser: 'Safari',
|
||||
device: 'mobile',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the operating system when the browser cannot be named', () => {
|
||||
expect(resolve('SomeBot (Windows NT 10.0; Win64; x64)', null)).toEqual({
|
||||
platform: 'Windows',
|
||||
os: 'Windows',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a product token that only prefixes a real one', () => {
|
||||
const resolved = resolve('Fluxer iOS-not-really/6.6.6', 'ios');
|
||||
expect(resolved.platform).not.toBe('Fluxer iOS');
|
||||
expect(resolved.browser).toBe(resolved.platform);
|
||||
});
|
||||
|
||||
it('never emits an Unknown literal when the user agent is absent', () => {
|
||||
expect(resolve(null, null)).toEqual({platform: null, os: null, browser: null, device: 'desktop'});
|
||||
expect(resolve('', null)).toEqual({platform: null, os: null, browser: null, device: 'desktop'});
|
||||
});
|
||||
|
||||
it('uses instance branding for the product word', () => {
|
||||
expect(resolve('Fluxer iOS/1.4.2 (stable)', 'ios', 'Acme').platform).toBe('Acme iOS');
|
||||
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'windows', 'Acme').platform).toBe('Acme Lite Windows');
|
||||
expect(resolve(ELECTRON_MAC_UA, null, 'Acme').platform).toBe('Acme macOS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFluxerNativeUserAgent', () => {
|
||||
it('matches only the Fluxer native product tokens', () => {
|
||||
expect(isFluxerNativeUserAgent('Fluxer iOS/1.4.2 (stable)')).toBe(true);
|
||||
expect(isFluxerNativeUserAgent('Fluxer Desktop (canary)')).toBe(true);
|
||||
expect(isFluxerNativeUserAgent(ELECTRON_MAC_UA)).toBe(false);
|
||||
expect(isFluxerNativeUserAgent(CHROME_MAC_UA)).toBe(false);
|
||||
expect(isFluxerNativeUserAgent(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseReportedClientOs', () => {
|
||||
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value), 'utf8').toString('base64');
|
||||
|
||||
it('reads the operating system from the client properties header', () => {
|
||||
expect(parseReportedClientOs(encode({os: 'macos', device: 'mobile'}))).toBe('macos');
|
||||
expect(parseReportedClientOs(encode({os: 'ios'}))).toBe('ios');
|
||||
});
|
||||
|
||||
it('rejects anything outside the known operating systems', () => {
|
||||
expect(parseReportedClientOs(null)).toBeNull();
|
||||
expect(parseReportedClientOs('')).toBeNull();
|
||||
expect(parseReportedClientOs('not base64 $$$')).toBeNull();
|
||||
expect(parseReportedClientOs(encode([1, 2]))).toBeNull();
|
||||
expect(parseReportedClientOs(encode({os: 'solaris'}))).toBeNull();
|
||||
expect(parseReportedClientOs(encode({os: 42}))).toBeNull();
|
||||
expect(parseReportedClientOs(encode({}))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an oversized header without decoding it', () => {
|
||||
expect(parseReportedClientOs('a'.repeat(4097))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {candidateTtlSecondsFor} from './VoiceReconciliationWorker';
|
||||
|
||||
const INTERVAL_MS = 15000;
|
||||
const GATEWAY_ONLY_GRACE_MS = 10000;
|
||||
|
||||
function ttlFor(observedSweepSpacingMs: number): number {
|
||||
return candidateTtlSecondsFor({
|
||||
intervalMs: INTERVAL_MS,
|
||||
observedSweepSpacingMs,
|
||||
graceMs: GATEWAY_ONLY_GRACE_MS,
|
||||
});
|
||||
}
|
||||
|
||||
describe('candidateTtlSecondsFor', () => {
|
||||
it('outlives the gap between two consecutive observations of the same key', () => {
|
||||
for (const observedSweepSpacingMs of [0, 45_000, 136_000, 300_000, 596_000, 900_000]) {
|
||||
expect(ttlFor(observedSweepSpacingMs) * 1000).toBeGreaterThan(observedSweepSpacingMs);
|
||||
}
|
||||
});
|
||||
|
||||
it('outlives a sweep gap far longer than the tick interval', () => {
|
||||
expect(ttlFor(596_000) * 1000).toBeGreaterThan(596_000);
|
||||
});
|
||||
|
||||
it('grows with the observed sweep spacing rather than the tick interval', () => {
|
||||
expect(ttlFor(596_000)).toBeGreaterThan(ttlFor(136_000));
|
||||
expect(ttlFor(136_000)).toBeGreaterThan(ttlFor(0));
|
||||
});
|
||||
|
||||
it('keeps a floor that survives a single long sweep before any spacing is observed', () => {
|
||||
expect(ttlFor(0)).toBeGreaterThanOrEqual(300);
|
||||
});
|
||||
|
||||
it('stays bounded so a stale candidate cannot outlive its connection indefinitely', () => {
|
||||
expect(ttlFor(Number.MAX_SAFE_INTEGER)).toBeLessThanOrEqual(3600);
|
||||
});
|
||||
});
|
||||
@@ -96,9 +96,24 @@ const DEFAULT_STAGGER_DELAY_MS = 25;
|
||||
const DEFAULT_LOCK_TTL_SECONDS = 180;
|
||||
const DEFAULT_GATEWAY_ONLY_GRACE_MS = 10000;
|
||||
const DEFAULT_LIVEKIT_ONLY_GRACE_MS = 60000;
|
||||
const MIN_CANDIDATE_TTL_SECONDS = 300;
|
||||
const MAX_CANDIDATE_TTL_SECONDS = 3600;
|
||||
const CANDIDATE_TTL_SWEEP_MULTIPLIER = 3;
|
||||
const LAST_SWEEP_KEY_TTL_SECONDS = 86400;
|
||||
const ROOM_KEY_PREFIX = 'voice:room:server:';
|
||||
export function candidateTtlSecondsFor(input: {
|
||||
intervalMs: number;
|
||||
observedSweepSpacingMs: number;
|
||||
graceMs: number;
|
||||
}): number {
|
||||
const spacingMs = Math.max(input.intervalMs, input.observedSweepSpacingMs);
|
||||
const ttlSeconds = Math.ceil((spacingMs * CANDIDATE_TTL_SWEEP_MULTIPLIER + input.graceMs * 2) / 1000);
|
||||
return Math.min(MAX_CANDIDATE_TTL_SECONDS, Math.max(MIN_CANDIDATE_TTL_SECONDS, ttlSeconds));
|
||||
}
|
||||
|
||||
const VOICE_RECONCILIATION_LOCK_KEY = 'voice:reconcile:lock';
|
||||
const VOICE_RECONCILIATION_CADENCE_KEY = 'voice:reconcile:cadence';
|
||||
const VOICE_RECONCILIATION_LAST_SWEEP_KEY = 'voice:reconcile:last-sweep-at';
|
||||
const GATEWAY_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:gateway-only:';
|
||||
const LIVEKIT_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:livekit-only:';
|
||||
|
||||
@@ -115,8 +130,7 @@ export class VoiceReconciliationWorker {
|
||||
private readonly cadenceTtlSeconds: number;
|
||||
private readonly gatewayOnlyGraceMs: number;
|
||||
private readonly liveKitOnlyGraceMs: number;
|
||||
private readonly gatewayOnlyCandidateTtlSeconds: number;
|
||||
private readonly liveKitOnlyCandidateTtlSeconds: number;
|
||||
private observedSweepSpacingMs = 0;
|
||||
private intervalHandle: NodeJS.Timeout | null = null;
|
||||
private reconciling = false;
|
||||
private reconciliationLockLost = false;
|
||||
@@ -136,14 +150,6 @@ export class VoiceReconciliationWorker {
|
||||
this.cadenceTtlSeconds = options.cadenceTtlSeconds ?? Math.max(1, Math.ceil((this.intervalMs * 3) / 1000));
|
||||
this.gatewayOnlyGraceMs = options.gatewayOnlyGraceMs ?? DEFAULT_GATEWAY_ONLY_GRACE_MS;
|
||||
this.liveKitOnlyGraceMs = options.liveKitOnlyGraceMs ?? DEFAULT_LIVEKIT_ONLY_GRACE_MS;
|
||||
this.gatewayOnlyCandidateTtlSeconds = Math.max(
|
||||
60,
|
||||
Math.ceil((this.intervalMs * 4 + this.gatewayOnlyGraceMs * 4) / 1000),
|
||||
);
|
||||
this.liveKitOnlyCandidateTtlSeconds = Math.max(
|
||||
60,
|
||||
Math.ceil((this.intervalMs * 4 + this.liveKitOnlyGraceMs * 4) / 1000),
|
||||
);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -156,6 +162,7 @@ export class VoiceReconciliationWorker {
|
||||
intervalMs: this.intervalMs,
|
||||
gatewayOnlyGraceMs: this.gatewayOnlyGraceMs,
|
||||
liveKitOnlyGraceMs: this.liveKitOnlyGraceMs,
|
||||
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
|
||||
},
|
||||
'Starting VoiceReconciliationWorker',
|
||||
);
|
||||
@@ -175,6 +182,7 @@ export class VoiceReconciliationWorker {
|
||||
|
||||
async reconcile(): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
await this.recordSweepSpacing(startTime);
|
||||
const discovery = await this.discoverActiveRooms();
|
||||
this.logger.info(
|
||||
{
|
||||
@@ -233,6 +241,8 @@ export class VoiceReconciliationWorker {
|
||||
const durationMs = Date.now() - startTime;
|
||||
this.logger.info(
|
||||
{
|
||||
observedSweepSpacingMs: this.observedSweepSpacingMs,
|
||||
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
|
||||
roomsChecked,
|
||||
totalConfirmed,
|
||||
totalRepaired,
|
||||
@@ -300,6 +310,37 @@ export class VoiceReconciliationWorker {
|
||||
}
|
||||
}
|
||||
|
||||
private candidateTtlSeconds(graceMs: number): number {
|
||||
return candidateTtlSecondsFor({
|
||||
intervalMs: this.intervalMs,
|
||||
observedSweepSpacingMs: this.observedSweepSpacingMs,
|
||||
graceMs,
|
||||
});
|
||||
}
|
||||
|
||||
private async recordSweepSpacing(startedAt: number): Promise<void> {
|
||||
try {
|
||||
const previous = await this.kvClient.get(VOICE_RECONCILIATION_LAST_SWEEP_KEY);
|
||||
const previousAt = previous === null ? Number.NaN : Number(previous);
|
||||
if (Number.isFinite(previousAt) && startedAt > previousAt) {
|
||||
this.observedSweepSpacingMs = Math.max(this.observedSweepSpacingMs, startedAt - previousAt);
|
||||
const gatewayOnlyCandidateTtlSeconds = this.candidateTtlSeconds(this.gatewayOnlyGraceMs);
|
||||
if (this.observedSweepSpacingMs >= gatewayOnlyCandidateTtlSeconds * 1000) {
|
||||
this.logger.warn(
|
||||
{
|
||||
observedSweepSpacingMs: this.observedSweepSpacingMs,
|
||||
gatewayOnlyCandidateTtlSeconds,
|
||||
},
|
||||
'Reconciliation sweeps are further apart than the candidate TTL; divergent voice states will be deferred forever',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.kvClient.setex(VOICE_RECONCILIATION_LAST_SWEEP_KEY, LAST_SWEEP_KEY_TTL_SECONDS, String(startedAt));
|
||||
} catch (error) {
|
||||
this.logger.warn({error}, 'Failed to record reconciliation sweep spacing');
|
||||
}
|
||||
}
|
||||
|
||||
private async acquireCadenceLease(): Promise<boolean> {
|
||||
try {
|
||||
return await this.kvClient.setnx(VOICE_RECONCILIATION_CADENCE_KEY, '1', this.cadenceTtlSeconds);
|
||||
@@ -993,7 +1034,7 @@ export class VoiceReconciliationWorker {
|
||||
}
|
||||
await this.kvClient.setex(
|
||||
key,
|
||||
this.gatewayOnlyCandidateTtlSeconds,
|
||||
this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
|
||||
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
|
||||
);
|
||||
return false;
|
||||
@@ -1024,7 +1065,7 @@ export class VoiceReconciliationWorker {
|
||||
}
|
||||
await this.kvClient.setex(
|
||||
key,
|
||||
this.liveKitOnlyCandidateTtlSeconds,
|
||||
this.candidateTtlSeconds(this.liveKitOnlyGraceMs),
|
||||
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
|
||||
);
|
||||
return false;
|
||||
|
||||
@@ -243,6 +243,12 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
|
||||
voiceRoomStore,
|
||||
kvClient,
|
||||
logger: Logger,
|
||||
intervalMs: Config.worker.voiceReconciliation.intervalMs,
|
||||
staggerDelayMs: Config.worker.voiceReconciliation.staggerDelayMs,
|
||||
lockTtlSeconds: Config.worker.voiceReconciliation.lockTtlSeconds,
|
||||
cadenceTtlSeconds: Config.worker.voiceReconciliation.cadenceTtlSeconds,
|
||||
gatewayOnlyGraceMs: Config.worker.voiceReconciliation.gatewayOnlyGraceMs,
|
||||
liveKitOnlyGraceMs: Config.worker.voiceReconciliation.liveKitOnlyGraceMs,
|
||||
})
|
||||
: null;
|
||||
if (Config.voice.enabled && voiceTopology !== null) {
|
||||
|
||||
@@ -50,7 +50,7 @@ import type {User} from '../../models/User';
|
||||
import type {UserGuildSettings} from '../../models/UserGuildSettings';
|
||||
import type {UserSettings} from '../../models/UserSettings';
|
||||
import type {WebAuthnCredential} from '../../models/WebAuthnCredential';
|
||||
import {resolveSessionClientInfo} from '../../utils/UserAgentUtils';
|
||||
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
|
||||
import {createArchiveJsonBuffer} from '../utils/ArchiveJson';
|
||||
import {appendAssetToArchive, buildHashedAssetKey, getAnimatedAssetExtension} from '../utils/AssetArchiveHelpers';
|
||||
import {ContentAddressedAttachmentCollector} from '../utils/ContentAddressedAttachmentCollector';
|
||||
@@ -114,6 +114,7 @@ interface HarvestMessageResult {
|
||||
interface UserDataJsonParams {
|
||||
user: User;
|
||||
userId: UserID;
|
||||
productName: string;
|
||||
authSessions: Array<AuthSession>;
|
||||
relationships: Array<Relationship>;
|
||||
userNotes: Map<UserID, string>;
|
||||
@@ -354,6 +355,7 @@ function buildUserDataJson(params: UserDataJsonParams) {
|
||||
const {
|
||||
user,
|
||||
userId,
|
||||
productName,
|
||||
authSessions,
|
||||
relationships,
|
||||
userNotes,
|
||||
@@ -412,17 +414,18 @@ function buildUserDataJson(params: UserDataJsonParams) {
|
||||
authenticator_types: Array.from(user.authenticatorTypes),
|
||||
},
|
||||
auth_sessions: authSessions.map((session) => {
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
const clientInfo = resolveSessionClientInfo({
|
||||
userAgent: session.clientUserAgent,
|
||||
isDesktopClient: session.clientIsDesktop,
|
||||
reportedOs: session.clientOs ?? null,
|
||||
productName,
|
||||
});
|
||||
return {
|
||||
created_at: session.createdAt.toISOString(),
|
||||
approx_last_used_at: session.approximateLastUsedAt?.toISOString() ?? null,
|
||||
client_ip: session.clientIp,
|
||||
client_os: clientOs,
|
||||
client_os: clientInfo.os,
|
||||
client_user_agent: session.clientUserAgent,
|
||||
client_platform: clientPlatform,
|
||||
client_platform: clientInfo.platform,
|
||||
};
|
||||
}),
|
||||
relationships: relationships.map((rel) => ({
|
||||
@@ -872,9 +875,11 @@ const harvestUserData: WorkerTaskHandler = async (payload, helpers) => {
|
||||
const guildSettings = await Promise.all(
|
||||
guildIds.map((guildId: GuildID) => userRepository.findGuildSettings(userId, guildId)),
|
||||
);
|
||||
const {branding} = await instanceConfigRepository.getAppPublicConfig();
|
||||
const userData = buildUserDataJson({
|
||||
user,
|
||||
userId,
|
||||
productName: branding.product_name,
|
||||
authSessions,
|
||||
relationships,
|
||||
userNotes,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {AgeVerificationService} from '../../stripe/services/AgeVerificationServi
|
||||
import {StripeCheckoutService} from '../../stripe/services/StripeCheckoutService';
|
||||
import {StripeGiftService} from '../../stripe/services/StripeGiftService';
|
||||
import {StripePremiumService} from '../../stripe/services/StripePremiumService';
|
||||
import {StripeRefundService} from '../../stripe/services/StripeRefundService';
|
||||
import {StripeSubscriptionService} from '../../stripe/services/StripeSubscriptionService';
|
||||
import {StripeWebhookService} from '../../stripe/services/StripeWebhookService';
|
||||
import type {IUserRepositoryAggregate} from '../../user/repositories/IUserRepositoryAggregate';
|
||||
@@ -80,6 +81,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
|
||||
const ageVerificationService = deps.stripe
|
||||
? new AgeVerificationService(deps.stripe, deps.userRepository, deps.gatewayService, deps.cacheService)
|
||||
: null;
|
||||
const refundService = new StripeRefundService(deps.stripe, deps.userRepository, subscriptionService);
|
||||
const webhookService = new StripeWebhookService(
|
||||
deps.stripe,
|
||||
checkoutService,
|
||||
@@ -99,6 +101,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
|
||||
deps.adminRepository,
|
||||
deps.snowflakeService,
|
||||
deps.billingRepository,
|
||||
refundService,
|
||||
);
|
||||
await webhookService.handleWebhook({body, signature});
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
try {
|
||||
await deps.guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {imposePhoneRequirements, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {AdminAuditService} from '../../../admin/services/AdminAuditService';
|
||||
@@ -58,7 +58,7 @@ const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
const user = await deps.userRepository.findUnique(userId);
|
||||
if (!user) throw new Error('user_not_found');
|
||||
const currentFlags = user.suspiciousActivityFlags ?? 0;
|
||||
const newFlags = (currentFlags | addMask) & ~removeMask;
|
||||
const newFlags = imposePhoneRequirements(currentFlags, addMask) & ~removeMask;
|
||||
const updatedUser = await deps.userRepository.patchUpsert(
|
||||
userId,
|
||||
{suspicious_activity_flags: newFlags},
|
||||
|
||||
@@ -52,6 +52,15 @@ source edits in this package.
|
||||
Forces Opus RED/FEC, stereo signaling, 10 ms packet time, no DTX, and a
|
||||
510 kbps maximum average bitrate in local offers and remote answers.
|
||||
|
||||
10. **Remote audio volume restore at exactly zero** (`src/room/track/RemoteAudioTrack.ts`)
|
||||
`attach()`, `connectWebAudio()` and `getVolume()` guarded the remembered
|
||||
`elementVolume` with a truthiness check, so a track deliberately held at `0`
|
||||
came back at full volume whenever it was re-attached or its Web Audio graph
|
||||
was rebuilt. All three guards now test `!== undefined`. Note that remote
|
||||
gains above `1.0` are only legal because `setVolume()` takes the Web Audio
|
||||
`gainNode` branch; the `el.volume` branch would throw `IndexSizeError`.
|
||||
`webAudioMix` must stay unconditional.
|
||||
|
||||
## Updating from upstream
|
||||
|
||||
1. Check the upstream changelog for the target version.
|
||||
|
||||
@@ -64,7 +64,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
|
||||
}
|
||||
|
||||
getVolume(): number {
|
||||
if (this.elementVolume) {
|
||||
if (this.elementVolume !== undefined) {
|
||||
return this.elementVolume;
|
||||
}
|
||||
if (isReactNative()) {
|
||||
@@ -113,7 +113,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
|
||||
element.muted = true;
|
||||
}
|
||||
|
||||
if (this.elementVolume) {
|
||||
if (this.elementVolume !== undefined) {
|
||||
this.setVolume(this.elementVolume);
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
|
||||
lastNode.connect(this.gainNode);
|
||||
this.gainNode.connect(context.destination);
|
||||
|
||||
if (this.elementVolume) {
|
||||
if (this.elementVolume !== undefined) {
|
||||
this.gainNode.gain.setTargetAtTime(this.elementVolume, 0, 0.1);
|
||||
}
|
||||
|
||||
|
||||
Generated
+3
-50
@@ -53,12 +53,6 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -111,7 +105,7 @@ version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.4",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
@@ -123,7 +117,7 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.4",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"wasip2",
|
||||
@@ -203,7 +197,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"wasm-bindgen",
|
||||
"wee_alloc",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
@@ -225,12 +218,6 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memory_units"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -530,7 +517,7 @@ version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.4",
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
@@ -603,40 +590,6 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wee_alloc"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e"
|
||||
dependencies = [
|
||||
"cfg-if 0.1.10",
|
||||
"libc",
|
||||
"memory_units",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
|
||||
@@ -10,7 +10,6 @@ crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = "=0.2.123"
|
||||
wee_alloc = {version = "0.4.5", default-features = false}
|
||||
zstd = {version = "0.13.3", default-features = false, features = ["no_asm", "thin", "wasm"]}
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -9,10 +9,6 @@ use formats::is_animated_image_bytes;
|
||||
use rgba::{TransformRequest, crop_rotate_rgba_alloc};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[global_allocator]
|
||||
static WASM_ALLOCATOR: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
|
||||
|
||||
#[wasm_bindgen]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn crop_rotate_rgba_raw(
|
||||
|
||||
@@ -16,11 +16,6 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(html.platform-native.window-focus-activation-guard:not(.first-click-passthrough-when-unfocused))
|
||||
:local(.appContainer) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(html.is-standalone:not(.auth-page)) :local(.appContainer) {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
@@ -86,12 +81,6 @@
|
||||
--skip-link-titlebar-offset: var(--native-titlebar-height);
|
||||
--skip-link-traffic-lights-offset: 4.5rem;
|
||||
}
|
||||
:global(html.platform-native.window-focus-activation-guard:not(.first-click-passthrough-when-unfocused))
|
||||
:local(.overlayScope)
|
||||
> :not([data-overlay-pass-through]) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.quickSwitcherPortal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -45,15 +45,9 @@ import {getActivePortalHost, setActivePortalHost} from '@app/features/ui/overlay
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import Modal from '@app/features/ui/state/Modal';
|
||||
import Popout from '@app/features/ui/state/Popout';
|
||||
import {
|
||||
getDesktopWindowBehaviorSettings,
|
||||
setDesktopWindowBehaviorSettings,
|
||||
} from '@app/features/ui/utils/DesktopWindowBehaviorUtils';
|
||||
import {getDesktopWindowBehaviorSettings} from '@app/features/ui/utils/DesktopWindowBehaviorUtils';
|
||||
import {attachExternalLinkInterceptor, isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import {
|
||||
FIRST_CLICK_PASSTHROUGH_WHEN_UNFOCUSED_CLASS,
|
||||
UNFOCUSED_FULLY_INTERACTIVE_CLASS,
|
||||
} from '@app/features/ui/utils/WindowFocusInteractionGuard';
|
||||
import {UNFOCUSED_FULLY_INTERACTIVE_CLASS} from '@app/features/ui/utils/WindowFocusInteractionGuard';
|
||||
import UserSettings from '@app/features/user/state/UserSettings';
|
||||
import {IncomingCallManager} from '@app/features/voice/components/IncomingCallManager';
|
||||
import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot';
|
||||
@@ -86,7 +80,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const reducedMotion = Accessibility.useReducedMotion;
|
||||
const stayInteractiveWhenUnfocused = Accessibility.stayInteractiveWhenUnfocused;
|
||||
const firstClickPassThroughWhenUnfocused = Accessibility.firstClickPassThroughWhenUnfocused;
|
||||
const {platform, isNative} = useNativePlatform();
|
||||
const useSystemTitleBar = useNativeTitleBar();
|
||||
const messageDisplayCompact = UserSettings.getMessageDisplayCompact();
|
||||
@@ -163,11 +156,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
useDocumentClassToggle('reduced-motion', reducedMotion);
|
||||
useDocumentClassToggle('mobile-layout', MobileLayout.platformMobileDetected || MobileLayout.enabled);
|
||||
useDocumentClassToggle(UNFOCUSED_FULLY_INTERACTIVE_CLASS, stayInteractiveWhenUnfocused);
|
||||
useDocumentClassToggle(FIRST_CLICK_PASSTHROUGH_WHEN_UNFOCUSED_CLASS, firstClickPassThroughWhenUnfocused);
|
||||
useEffect(() => {
|
||||
if (!isNative) return;
|
||||
void setDesktopWindowBehaviorSettings({firstClickPassThroughWhenUnfocused});
|
||||
}, [isNative, firstClickPassThroughWhenUnfocused]);
|
||||
useDesktopAllowTransparency(isNative);
|
||||
useWindowEventListeners({preventDocumentScroll: !isNative});
|
||||
useRemScaleTracking();
|
||||
|
||||
@@ -600,7 +600,6 @@ export interface AccessibilitySettings {
|
||||
hdrDisplayMode: HdrDisplayMode;
|
||||
preserveEditDraft: boolean;
|
||||
stayInteractiveWhenUnfocused: boolean;
|
||||
firstClickPassThroughWhenUnfocused: boolean;
|
||||
scrollToBottomOnMessageSend: boolean;
|
||||
sequentialFileSend: boolean;
|
||||
showNeko: boolean;
|
||||
@@ -718,7 +717,6 @@ class Accessibility {
|
||||
hdrDisplayMode = HdrDisplayMode.FULL;
|
||||
preserveEditDraft = false;
|
||||
stayInteractiveWhenUnfocused = false;
|
||||
firstClickPassThroughWhenUnfocused = false;
|
||||
scrollToBottomOnMessageSend = true;
|
||||
sequentialFileSend = false;
|
||||
showNeko = false;
|
||||
@@ -829,7 +827,6 @@ class Accessibility {
|
||||
'hdrDisplayMode',
|
||||
'preserveEditDraft',
|
||||
'stayInteractiveWhenUnfocused',
|
||||
'firstClickPassThroughWhenUnfocused',
|
||||
'scrollToBottomOnMessageSend',
|
||||
'sequentialFileSend',
|
||||
],
|
||||
@@ -890,7 +887,6 @@ class Accessibility {
|
||||
hdrDisplayMode: HDR_TO_PROTO[s.hdrDisplayMode],
|
||||
preserveEditDraft: s.preserveEditDraft,
|
||||
stayInteractiveWhenUnfocused: s.stayInteractiveWhenUnfocused,
|
||||
firstClickPassThroughWhenUnfocused: s.firstClickPassThroughWhenUnfocused,
|
||||
scrollToBottomOnMessageSend: s.scrollToBottomOnMessageSend,
|
||||
sequentialFileSend: s.sequentialFileSend,
|
||||
}),
|
||||
@@ -980,8 +976,6 @@ class Accessibility {
|
||||
if (m.preserveEditDraft !== undefined) s.preserveEditDraft = m.preserveEditDraft;
|
||||
if (m.stayInteractiveWhenUnfocused !== undefined)
|
||||
s.stayInteractiveWhenUnfocused = m.stayInteractiveWhenUnfocused;
|
||||
if (m.firstClickPassThroughWhenUnfocused !== undefined)
|
||||
s.firstClickPassThroughWhenUnfocused = m.firstClickPassThroughWhenUnfocused;
|
||||
if (m.scrollToBottomOnMessageSend !== undefined) s.scrollToBottomOnMessageSend = m.scrollToBottomOnMessageSend;
|
||||
if (m.sequentialFileSend !== undefined) s.sequentialFileSend = m.sequentialFileSend;
|
||||
},
|
||||
@@ -1316,8 +1310,6 @@ class Accessibility {
|
||||
if (validated.preserveEditDraft !== undefined) this.preserveEditDraft = validated.preserveEditDraft;
|
||||
if (validated.stayInteractiveWhenUnfocused !== undefined)
|
||||
this.stayInteractiveWhenUnfocused = validated.stayInteractiveWhenUnfocused;
|
||||
if (validated.firstClickPassThroughWhenUnfocused !== undefined)
|
||||
this.firstClickPassThroughWhenUnfocused = validated.firstClickPassThroughWhenUnfocused;
|
||||
if (validated.scrollToBottomOnMessageSend !== undefined)
|
||||
this.scrollToBottomOnMessageSend = validated.scrollToBottomOnMessageSend;
|
||||
if (validated.sequentialFileSend !== undefined) this.sequentialFileSend = validated.sequentialFileSend;
|
||||
@@ -1418,8 +1410,6 @@ class Accessibility {
|
||||
hdrDisplayMode: data.hdrDisplayMode ?? this.hdrDisplayMode,
|
||||
preserveEditDraft: data.preserveEditDraft ?? this.preserveEditDraft,
|
||||
stayInteractiveWhenUnfocused: data.stayInteractiveWhenUnfocused ?? this.stayInteractiveWhenUnfocused,
|
||||
firstClickPassThroughWhenUnfocused:
|
||||
data.firstClickPassThroughWhenUnfocused ?? this.firstClickPassThroughWhenUnfocused,
|
||||
scrollToBottomOnMessageSend: data.scrollToBottomOnMessageSend ?? this.scrollToBottomOnMessageSend,
|
||||
sequentialFileSend: data.sequentialFileSend ?? this.sequentialFileSend,
|
||||
showNeko: data.showNeko ?? this.showNeko,
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {mergeFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder';
|
||||
import styles from '@app/features/app/components/floating/UnreadChannelsContent.module.css';
|
||||
import {
|
||||
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
|
||||
UNREAD_PREVIEW_MESSAGE_LIMIT,
|
||||
} from '@app/features/app/components/floating/UnreadPreviewBudget';
|
||||
import previewStyles from '@app/features/app/components/shared/MessagePreview.module.css';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import {renderChannelStream} from '@app/features/channel/components/ChannelMessageStream';
|
||||
@@ -131,8 +135,6 @@ interface ChannelPreviewData {
|
||||
|
||||
const INITIAL_VISIBLE_CHANNELS = 10;
|
||||
const LOAD_MORE_CHUNK = 10;
|
||||
const UNREAD_PREVIEW_MESSAGE_LIMIT = 5;
|
||||
const BULK_PREVIEW_CHANNEL_BATCH_SIZE = 10;
|
||||
|
||||
interface CacheEntry {
|
||||
cacheKey: string;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
|
||||
UNREAD_PREVIEW_MESSAGE_LIMIT,
|
||||
} from '@app/features/app/components/floating/UnreadPreviewBudget';
|
||||
import {BulkMessageFetchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const buildBatch = (limit: number) => ({
|
||||
requests: Array.from({length: BULK_PREVIEW_CHANNEL_BATCH_SIZE}, (_, index) => ({
|
||||
channel_id: String(1000000000000000000n + BigInt(index)),
|
||||
limit,
|
||||
})),
|
||||
});
|
||||
|
||||
describe('unread preview fetch budget', () => {
|
||||
it('keeps the anchored window inside the bulk fetch schema', () => {
|
||||
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2))).not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps the unanchored window inside the bulk fetch schema', () => {
|
||||
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT))).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a batch one channel wider than the client sends', () => {
|
||||
const oversized = buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2);
|
||||
oversized.requests.push({channel_id: '1000000000000000099', limit: UNREAD_PREVIEW_MESSAGE_LIMIT * 2});
|
||||
expect(() => BulkMessageFetchRequest.parse(oversized)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const UNREAD_PREVIEW_MESSAGE_LIMIT = 25;
|
||||
export const BULK_PREVIEW_CHANNEL_BATCH_SIZE = 5;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user