Compare commits

...
Author SHA1 Message Date
HampusandGitHub c7bd1be3e4 fix(auth): offer every transport for passkeys stored without any (#3077) 2026-09-30 23:01:37 +02:00
HampusandGitHub 2161d84701 fix(self-hosting): grow seaweedfs one volume at a time (#3076) 2026-09-30 22:55:12 +02:00
HampusandGitHub eaeeb3b502 fix(api): report final system DM progress (#3074) 2026-09-30 22:11:34 +02:00
HampusandGitHub dc32a7c70e feat(admin): allow system DMs to all users (#3073) 2026-09-30 21:29:05 +02:00
HampusandGitHub ab0b483fbe perf(gateway): speed up presence and harden guild queries (#3072) 2026-09-30 21:12:13 +02:00
HampusandGitHub 6e2f90b03c fix(premium): drop the grace period after a voluntary cancel (#3071) 2026-09-30 21:03:25 +02:00
HampusandGitHub 5e0806f479 fix(voice): preserve microphone channels during screen sharing (#3070) 2026-09-30 20:47:30 +02:00
HampusandGitHub dfdfffe5de feat(premium): give failed renewals a billing-cycle grace period (#3066) 2026-09-30 18:48:01 +02:00
HampusandGitHub f5e32aed31 fix(ci): correct TTL fixtures and unused dependencies (#3065) 2026-09-30 17:45:07 +02:00
HampusandGitHub 710c1aeaa8 fix(deps): bump yanked yoke-derive to 0.8.4 (#3063) 2026-09-30 16:59:59 +02:00
HampusandGitHub af49cd6cc4 refactor(ban): drop ipinfo cgnat blast-radius guard (#3062) 2026-09-30 16:54:43 +02:00
omsterandGitHub ca719e7b5e feat(admin,api): restrict community creation on self-hosted (#3055) 2026-09-30 16:32:45 +02:00
HampusandGitHub ab4069ed0e fix(app): let hidden sidebar buttons be shown again (#3061) 2026-09-30 15:03:44 +02:00
HampusandGitHub 12bfaa83ba fix(sso): route mobile sign-in through the web callback (#3060) 2026-09-30 14:48:24 +02:00
HampusandGitHub 1076728241 perf(gateway): keep large guilds responsive under floods (#3058) 2026-09-30 12:26:21 +02:00
HampusandGitHub 360b984adc fix(ci): repair admin test config and a ttl race in api tests (#3054) 2026-09-30 02:39:46 +02:00
HampusandGitHub dcdf7e1d93 fix(api): let new channels inherit the adult-only setting (#3053) 2026-09-30 02:31:47 +02:00
HampusandGitHub e8cb167dbf feat(premium): add App Store and Google Play purchases (#3052) 2026-09-30 01:55:19 +02:00
HampusandGitHub 0b3418dcbe fix(app): make unchecked checkbox border visible (#3050) 2026-09-30 01:23:43 +02:00
HampusandGitHub ec7649193c docs(admin): document notify_reporter on report resolve (#3049) 2026-09-30 01:01:22 +02:00
417 changed files with 29514 additions and 7467 deletions
Generated
+2 -2
View File
@@ -5830,9 +5830,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
+5 -1
View File
@@ -160,7 +160,6 @@ MEILI_MASTER_KEY=CHANGE_ME
# api.pwnedpasswords.com.
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=false
#FLUXER_BLOCKLIST_FEEDS_ENABLED=false
#FLUXER_IPINFO_API_KEY=
# A local path, or an s3:// URL read with the S3 credentials of this file.
#FLUXER_GEOIP_DB_PATH=
@@ -449,6 +448,11 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
#FLUXER_SEAWEEDFS_TELEMETRY=false
# Volumes SeaweedFS creates at once when a bucket needs space. Each reserves 1 GB
# of free disk from the start, and SeaweedFS's own default of 7 fills a small
# disk before every bucket has one, so uploads fail with no free volumes left.
#FLUXER_SEAWEEDFS_VOLUME_GROWTH=1
# Node sizes its heap from the container limit by default. Leave these unset
# unless you need to pin it. A heap ceiling above the container limit gets the
# container OOM-killed instead of reporting a heap error. The values below are
+1 -1
View File
@@ -33,7 +33,6 @@ x-fluxer-env: &fluxer-env
FLUXER_APP_ORIGIN_ALIASES: ${FLUXER_APP_ORIGIN_ALIASES:-}
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: ${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-}
FLUXER_BLOCKLIST_FEEDS_ENABLED: ${FLUXER_BLOCKLIST_FEEDS_ENABLED:-}
FLUXER_IPINFO_API_KEY: ${FLUXER_IPINFO_API_KEY:-}
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
FLUXER_API_ENDPOINT: ${FLUXER_API_ENDPOINT:-}
@@ -354,6 +353,7 @@ services:
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-2gb}
environment:
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
WEED_MASTER_VOLUME_GROWTH_COPY_1: ${FLUXER_SEAWEEDFS_VOLUME_GROWTH:-1}
command: ["server", "-s3", "-dir=/data", "-master.telemetry=${FLUXER_SEAWEEDFS_TELEMETRY:-false}"]
volumes:
- seaweedfs-data:/data
+321 -6
View File
@@ -1251,7 +1251,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list, or that IPInfo reports as a high blast-radius carrier NAT, is refused with 400 IP_BAN_DECLINED and recorded in the audit log.",
"description": "Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list is refused with 400 IP_BAN_DECLINED and recorded in the audit log.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -5435,7 +5435,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Queue a worker job that delivers the same content to every listed user as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.",
"description": "Queue a worker job that delivers the same content to every listed user, or to every user when all_users is set, as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.",
"security": [{"adminApiKey": []}],
"requestBody": {
"required": true,
@@ -7727,6 +7727,130 @@
]
}
},
"/admin/users/{user_id}/store-purchases": {
"get": {
"operationId": "list_admin_user_store_purchases",
"summary": "List user store purchases",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminStorePurchaseListResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Lists the App Store and Google Play purchases bound to a user, newest first, with their store state. Only available on hosted instances. Requires USER_LOOKUP permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"description": "The ID of the user"
}
]
}
},
"/admin/users/{user_id}/store-purchases/refresh": {
"post": {
"operationId": "refresh_admin_user_store_purchases",
"summary": "Refresh user store purchases",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminStorePurchaseListResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Reads every store purchase bound to a user again from the App Store or Google Play, applies the result to the account and returns the updated purchases. Creates audit log entry. Only available on hosted instances. Requires USER_UPDATE_FLAGS permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"description": "The ID of the user"
}
]
}
},
"/admin/users/{user_id}/suspicious-activity-disablement": {
"put": {
"operationId": "disable_admin_user_suspicious",
@@ -9407,6 +9531,18 @@
},
"required": ["flags"]
},
"AdminStorePurchaseListResponse": {
"type": "object",
"properties": {
"purchases": {
"type": "array",
"items": {"$ref": "#/components/schemas/AdminStorePurchaseResponse"},
"description": "Store purchases bound to the user"
}
},
"required": ["purchases"],
"additionalProperties": false
},
"TerminateSessionsResponse": {
"type": "object",
"properties": {"terminated_count": {"$ref": "#/components/schemas/Int32Type"}},
@@ -10014,20 +10150,25 @@
"description": "Message content to send to each recipient"
},
"user_ids": {
"description": "Recipient user IDs. Each receives the same content as a system DM.",
"minItems": 1,
"maxItems": 10000,
"type": "array",
"items": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "Recipient user IDs. Each receives the same content as a system DM."
"items": {"$ref": "#/components/schemas/SnowflakeType"}
},
"all_users": {
"description": "Send to every user account, skipping bots, system accounts, and deleted or disabled accounts",
"type": "boolean"
}
},
"required": ["content", "user_ids"]
"required": ["content"]
},
"SendSystemDmResponse": {
"type": "object",
"properties": {
"recipient_count": {
"description": "Number of recipients the worker job was queued to deliver to",
"nullable": true,
"description": "Number of recipients the worker job was queued to deliver to, or null when sending to all users",
"allOf": [{"$ref": "#/components/schemas/Int32Type"}]
}
},
@@ -10523,6 +10664,7 @@
"feature_custom_notification_sounds",
"feature_early_access",
"feature_global_expressions",
"feature_guild_create",
"feature_higher_video_quality",
"feature_per_guild_profiles",
"feature_voice_entrance_sounds",
@@ -10830,6 +10972,7 @@
"single_community_guild_id": {"nullable": true, "type": "string"},
"direct_messages_disabled": {"type": "boolean"},
"direct_messages_locked": {"type": "boolean"},
"guild_create_access": {"type": "boolean"},
"premium_mode": {"type": "string", "enum": ["mirror", "everyone"]},
"services": {
"type": "object",
@@ -10867,6 +11010,7 @@
"single_community_guild_id",
"direct_messages_disabled",
"direct_messages_locked",
"guild_create_access",
"premium_mode",
"services",
"services_resolved",
@@ -11387,6 +11531,7 @@
"direct_messages_disabled": {"type": "boolean"},
"direct_messages_locked": {"type": "boolean", "enum": [false]},
"premium_mode": {"type": "string", "enum": ["mirror", "everyone"]},
"guild_create_access": {"type": "boolean"},
"services": {
"nullable": true,
"type": "object",
@@ -15790,6 +15935,176 @@
"required": ["target_user_id", "category", "nickname", "since", "target"],
"additionalProperties": false
},
"AdminStorePurchaseResponse": {
"type": "object",
"properties": {
"id": {
"description": "The unique identifier (snowflake) for this store purchase",
"allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}]
},
"user_id": {
"nullable": true,
"description": "ID of the user the purchase is bound to, null when unbound",
"allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}]
},
"provider": {
"type": "string",
"enum": ["app_store", "google_play"],
"description": "Store the purchase was made in"
},
"kind": {
"type": "string",
"enum": ["subscription", "gift"],
"description": "Whether the purchase is a subscription or a gift"
},
"slot": {
"type": "string",
"enum": ["monthly", "yearly", "gift_1_month", "gift_1_year"],
"description": "Fluxer product the purchase is for"
},
"environment": {
"type": "string",
"enum": ["production", "sandbox"],
"description": "Whether the purchase was real or a test purchase"
},
"app_id": {
"type": "string",
"description": "Bundle identifier or package name of the app that made the purchase"
},
"product_id": {"type": "string", "description": "Store product identifier"},
"base_plan_id": {
"nullable": true,
"description": "Google Play base plan identifier, null otherwise",
"type": "string"
},
"latest_transaction_id": {
"nullable": true,
"description": "Latest App Store transaction ID or Google Play order ID for the purchase",
"type": "string"
},
"ownership_type": {
"nullable": true,
"description": "Store ownership type, such as PURCHASED or FAMILY_SHARED",
"type": "string"
},
"state": {"type": "string", "description": "Normalized state of the purchase"},
"store_state": {"nullable": true, "description": "Raw state reported by the store", "type": "string"},
"entitled": {"type": "boolean", "description": "Whether the purchase currently grants Plutonium"},
"expires_at": {
"nullable": true,
"description": "When the paid period ends",
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"grace_ends_at": {
"nullable": true,
"description": "When the billing grace period ends",
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"auto_renew": {
"nullable": true,
"description": "Whether the subscription renews automatically",
"type": "boolean"
},
"started_at": {
"nullable": true,
"description": "When the subscription or purchase started",
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"purchased_at": {
"nullable": true,
"description": "When the latest payment was made",
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"revoked_at": {
"nullable": true,
"description": "When the store revoked or refunded the purchase",
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"revocation_reason": {
"nullable": true,
"description": "Reason the store gave for the revocation",
"type": "string"
},
"superseded": {"type": "boolean", "description": "Whether a newer purchase replaced this one"},
"acknowledged": {"type": "boolean", "description": "Whether the purchase was acknowledged with the store"},
"gift_code": {"nullable": true, "description": "Gift code minted by a gift purchase", "type": "string"},
"bound_at": {
"nullable": true,
"description": "When the purchase was bound to its user",
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"last_event_at": {
"nullable": true,
"description": "Time of the latest store event applied to the purchase",
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"synced_at": {
"nullable": true,
"description": "When the purchase was last synced with the store",
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"created_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$",
"description": "When Fluxer first saw the purchase"
},
"updated_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$",
"description": "When the purchase record last changed"
}
},
"required": [
"id",
"user_id",
"provider",
"kind",
"slot",
"environment",
"app_id",
"product_id",
"base_plan_id",
"latest_transaction_id",
"ownership_type",
"state",
"store_state",
"entitled",
"expires_at",
"grace_ends_at",
"auto_renew",
"started_at",
"purchased_at",
"revoked_at",
"revocation_reason",
"superseded",
"acknowledged",
"gift_code",
"bound_at",
"last_event_at",
"synced_at",
"created_at",
"updated_at"
],
"additionalProperties": false
},
"DiscriminatorType": {"anyOf": [{"type": "string"}, {"type": "number"}]},
"UsernameType": {"type": "string"},
"WebAuthnCredentialResponse": {
+17
View File
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::tooltip::{Hint, HintLink};
pub fn limit_key_hint(key: &str) -> Option<Hint<'static>> {
match key {
"feature_guild_create" => Some(Hint {
name: Some("Community Creation Access"),
body: "Admins with the wildcard ACL can always create communities.",
link: Some(HintLink::new(
"/instance-config#community-creation",
"Community creation policy",
)),
}),
_ => None,
}
}
+7 -2
View File
@@ -8,13 +8,18 @@ use super::types::SendSystemDmResponse;
impl AdminApiClient {
pub async fn send_system_dm(
&self,
user_ids: &[String],
user_ids: Option<&[String]>,
content: &str,
) -> ApiResult<SendSystemDmResponse> {
let body = generated_types::SendSystemDmRequest {
content: generated_types::SendSystemDmRequestContent::try_from(content)
.map_err(|e| ApiError::Parse(e.to_string()))?,
user_ids: user_ids.iter().map(|id| snowflake(id)).collect(),
user_ids: user_ids
.unwrap_or_default()
.iter()
.map(|id| snowflake(id))
.collect(),
all_users: user_ids.is_none().then_some(true),
};
let response = self
.generated()
@@ -43,6 +43,8 @@ pub struct InstancePolicyResponse {
pub direct_messages_locked: bool,
#[serde(default)]
pub premium_mode: PremiumMode,
#[serde(default = "default_guild_create_access")]
pub guild_create_access: bool,
#[serde(default)]
pub services: InstanceServicesOverrides,
#[serde(default)]
@@ -51,6 +53,10 @@ pub struct InstancePolicyResponse {
pub services_available: InstanceServicesAvailable,
}
fn default_guild_create_access() -> bool {
true
}
impl Default for InstancePolicyResponse {
fn default() -> Self {
Self {
@@ -59,6 +65,7 @@ impl Default for InstancePolicyResponse {
direct_messages_disabled: false,
direct_messages_locked: false,
premium_mode: PremiumMode::Everyone,
guild_create_access: default_guild_create_access(),
services: InstanceServicesOverrides::default(),
services_resolved: InstanceServicesResolved::default(),
services_available: InstanceServicesAvailable::default(),
@@ -656,6 +663,8 @@ pub struct InstancePolicyUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub direct_messages_disabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_create_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_mode: Option<PremiumMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub services: Option<InstanceServicesUpdateRequest>,
+1 -1
View File
@@ -4,5 +4,5 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SendSystemDmResponse {
pub recipient_count: i64,
pub recipient_count: Option<i64>,
}
+1
View File
@@ -2,6 +2,7 @@
pub mod acl;
pub mod admin_flags;
pub mod admin_hints;
pub mod api;
pub mod config;
pub mod fonts;
+2 -1
View File
@@ -171,7 +171,8 @@ pub(crate) async fn system_dms_post(
let flash = if let Some(content) = content.as_deref()
&& !user_ids.is_empty()
{
match client.send_system_dm(&user_ids, content).await {
let recipients = (user_ids != ["*"]).then_some(user_ids.as_slice());
match client.send_system_dm(recipients, content).await {
Ok(_) => FlashData::success("System DM sent"),
Err(error) => {
tracing::warn!(%error, "admin API request failed: send system DM");
+5 -4
View File
@@ -734,6 +734,9 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let direct_messages_disabled = form
.first("policy_direct_messages_disabled")
.map(|value| value == "true");
let guild_create_access = form
.first("policy_guild_create_access")
.map(|value| value == "true");
let premium_mode = match form.first("policy_premium_mode") {
Some("mirror") => Some(PremiumMode::Mirror),
Some("everyone") => Some(PremiumMode::Everyone),
@@ -745,6 +748,7 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
single_community_enabled: None,
single_community_name: None,
direct_messages_disabled,
guild_create_access,
premium_mode,
services,
}),
@@ -875,10 +879,7 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
policy: Some(InstancePolicyUpdateRequest {
single_community_enabled: Some(enabled),
single_community_name: None,
direct_messages_disabled: None,
premium_mode: None,
services: None,
..Default::default()
}),
..Default::default()
}
+25
View File
@@ -238,3 +238,28 @@ input:disabled + .checkbox-custom {
border: 2px solid transparent;
background-clip: content-box;
}
:target {
padding: 0.5rem;
border-radius: 0.25rem;
scroll-margin-top: 6rem;
animation: target-pulse 700ms ease-in-out 3;
}
@keyframes target-pulse {
0%,
100% {
background-color: transparent;
}
50% {
background-color: hsl(242 70% 55% / 0.18);
}
}
@media (prefers-reduced-motion: reduce) {
:target {
background-color: hsl(242 70% 55% / 0.12);
animation: none;
}
}
@@ -21,6 +21,7 @@ pub mod resource_link;
pub mod section_card;
pub mod stack;
pub mod table;
pub mod tooltip;
pub mod typography;
pub mod user_display;
pub mod user_profile_badges;
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::icons::paperclip_icon;
use maud::{Markup, html};
use std::sync::atomic::{AtomicUsize, Ordering};
static HINT_TOGGLE_ID: AtomicUsize = AtomicUsize::new(0);
pub struct HintLink<'a> {
href: &'a str,
label: &'a str,
}
impl<'a> HintLink<'a> {
pub fn new(href: &'a str, label: &'a str) -> Self {
debug_assert!(
href.starts_with('/'),
"hint link href must be admin-absolute: {href:?}"
);
debug_assert!(
href.contains('#'),
"hint link href should point at an anchor: {href:?}"
);
debug_assert!(!label.trim().is_empty(), "hint link needs a label");
Self { href, label }
}
}
pub struct Hint<'a> {
pub name: Option<&'a str>,
pub body: &'a str,
pub link: Option<HintLink<'a>>,
}
pub fn info(base: &str, hint: &Hint<'_>) -> Markup {
let aria_label = match hint.name {
Some(name) => format!("About {name}"),
None => "More information".to_owned(),
};
let toggle_id = format!(
"hint-toggle-{}",
HINT_TOGGLE_ID.fetch_add(1, Ordering::Relaxed)
);
html! {
span class="group relative inline-flex items-center" {
input type="checkbox" id=(toggle_id) class="peer sr-only";
label for=(toggle_id) tabindex="0" aria-label=(aria_label)
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full \
font-semibold text-brand-primary leading-none active:scale-97 \
hover:text-brand-primary-dark" {
"?"
}
label for=(toggle_id) aria-hidden="true"
class="invisible fixed inset-0 z-20 cursor-default peer-checked:visible" {}
div class="invisible absolute bottom-full left-2 z-30 w-64 pb-3 pl-2 opacity-0 \
transition-[opacity,visibility] duration-200 ease-out motion-reduce:transition-none \
group-hover:visible group-hover:opacity-100 \
group-focus-within:visible group-focus-within:opacity-100 \
peer-checked:visible peer-checked:opacity-100" {
div class="rounded-lg border border-neutral-200 bg-white p-3 text-neutral-600 \
text-xs shadow-lg" {
@if let Some(name) = hint.name {
p class="font-semibold text-neutral-900" { (name) }
}
p class=[hint.name.is_some().then_some("mt-1")] { (hint.body) }
@if let Some(link) = &hint.link {
a href={(base) (link.href)} hx-boost="false"
class="mt-2 inline-flex items-center gap-1 text-blue-600 hover:underline" {
(paperclip_icon(""))(link.label)
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::HintLink;
#[test]
#[should_panic(expected = "anchor")]
fn rejects_a_link_that_points_at_no_anchor() {
let _ = HintLink::new("/instance-config", "Instance policy");
}
#[test]
#[should_panic(expected = "label")]
fn rejects_a_link_with_no_label() {
let _ = HintLink::new("/instance-config#community-creation", " ");
}
}
@@ -245,6 +245,7 @@ fn policy_config_section(
(single_community_form(base, csrf_token, policy))
(direct_messages_form(base, csrf_token, policy))
(premium_mode_form(base, csrf_token, policy, premium_name))
(community_creation_form(base, csrf_token, policy))
(services_form(base, csrf_token, policy))
}
},
@@ -364,6 +365,37 @@ fn premium_mode_form(
}
}
fn community_creation_form(
base: &str,
csrf_token: &str,
policy: &InstancePolicyResponse,
) -> Markup {
html! {
div id="community-creation" class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Community creation" }
form method="post" action={(base) "/instance-config?action=update_policy"} {
(csrf_input(csrf_token))
div class="space-y-4" {
(select_input("policy_guild_create_access", "Who can create communities", &[
("true", "Everyone"),
("false", "Restricted"),
], if policy.guild_create_access { "true" } else { "false" }))
p class="text-xs text-neutral-500" {
"When restricted, only admins with the wildcard ACL and users matched by a "
a href={(base) "/limit-config"} class="text-blue-600 hover:underline" {
"limit rule"
}
" that grants Community Creation Access can create communities."
}
(form_actions(html! {
(submit_button("Save community creation policy"))
}))
}
}
}
}
}
fn service_select(name: &str, label: &str, override_value: Option<bool>, resolved: bool) -> Markup {
let selected = match override_value {
None => "inherit",
@@ -2,6 +2,7 @@
use crate::{
acl::{self, INSTANCE_LIMIT_CONFIG_UPDATE},
admin_hints,
api::types::{LimitConfigResponse, LimitKeyMetadata, LimitRule},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -9,6 +10,7 @@ use crate::{
components::{
form::{FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, submit_button},
page_container::{card_with_header, page_header},
tooltip,
},
layout::admin_layout,
},
@@ -208,7 +210,7 @@ fn rule_editor(
@for category in CATEGORY_ORDER {
@let keys = keys_for_category(response, category);
@if !keys.is_empty() {
(category_section(response, rule, category, &keys, can_update))
(category_section(&config.base_path, response, rule, category, &keys, can_update))
}
}
@if can_update {
@@ -274,6 +276,7 @@ fn keys_for_category(response: &LimitConfigResponse, category: &str) -> Vec<Stri
}
fn category_section(
base: &str,
response: &LimitConfigResponse,
rule: &LimitRule,
category: &str,
@@ -292,7 +295,7 @@ fn category_section(
div class="space-y-4" {
@for key in keys {
@if let Some(metadata) = response.metadata.get(key) {
(limit_field(response, rule, key, metadata, can_update))
(limit_field(base, response, rule, key, metadata, can_update))
}
}
}
@@ -301,6 +304,7 @@ fn category_section(
}
fn limit_field(
base: &str,
response: &LimitConfigResponse,
rule: &LimitRule,
key: &str,
@@ -319,9 +323,10 @@ fn limit_field(
.as_ref()
.is_some_and(|fields| fields.iter().any(|field| field == key));
if metadata.is_toggle {
toggle_field(key, metadata, current_value, modified, can_update)
toggle_field(base, key, metadata, current_value, modified, can_update)
} else {
numeric_field(
base,
key,
metadata,
current_value,
@@ -333,6 +338,7 @@ fn limit_field(
}
fn toggle_field(
base: &str,
key: &str,
metadata: &LimitKeyMetadata,
current_value: Option<u64>,
@@ -343,7 +349,7 @@ fn toggle_field(
html! {
div class={(field_class(modified, false))} {
div class="flex-1 space-y-1" {
(field_label_row(key, metadata, modified))
(field_label_row(base, key, metadata, modified))
p class="text-xs text-neutral-500" { (metadata.description) }
}
div class="shrink-0" {
@@ -369,6 +375,7 @@ fn toggle_field(
}
fn numeric_field(
base: &str,
key: &str,
metadata: &LimitKeyMetadata,
current_value: Option<u64>,
@@ -385,7 +392,7 @@ fn numeric_field(
html! {
div class={(field_class(modified, true))} {
div class="flex flex-wrap items-center justify-between gap-2" {
(field_label_row(key, metadata, modified))
(field_label_row(base, key, metadata, modified))
}
p class="text-xs text-neutral-500" {
(metadata.description)
@@ -417,10 +424,13 @@ fn numeric_field(
}
}
fn field_label_row(key: &str, metadata: &LimitKeyMetadata, modified: bool) -> Markup {
fn field_label_row(base: &str, key: &str, metadata: &LimitKeyMetadata, modified: bool) -> Markup {
html! {
div class="flex flex-wrap items-center gap-2" {
label for=(key) class="font-medium text-neutral-900 text-sm" { (metadata.label) }
@if let Some(hint) = admin_hints::limit_key_hint(key) {
(tooltip::info(base, &hint))
}
span class=(scope_class(&metadata.scope)) { (scope_label(&metadata.scope)) }
@if modified {
span class="rounded bg-neutral-100 px-1.5 py-0.5 text-neutral-700 text-xs" { "Modified" }
@@ -58,7 +58,7 @@ pub fn system_dm_page(
(form_field_group(
"Recipient user IDs", "system-dm-user-ids",
true, None,
Some("One per line. Snowflake IDs only."),
Some("One per line. Snowflake IDs only, or a single * to send to every user."),
html! {
textarea id="system-dm-user-ids" name="user_ids"
required rows="10"
@@ -461,6 +461,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"single_community_guild_id": null,
"direct_messages_disabled": false,
"direct_messages_locked": false,
"guild_create_access": false,
"premium_mode": "mirror",
"services": {
"gif_enabled": true,
@@ -354,12 +354,10 @@ fn test_config(api_endpoint: String) -> AdminConfig {
static_cdn_endpoint: "https://static.example.test".to_owned(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
kv_url: String::new(),
oauth_client_id: "admin-client".to_owned(),
oauth_client_secret: "admin-secret".to_owned(),
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
build_version: "test".to_owned(),
release_channel: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
+1 -4
View File
@@ -11,13 +11,10 @@
},
"dependencies": {
"@aws-sdk/client-s3": "catalog:",
"@pkgs/cassandra": "workspace:*",
"@fluxer/geo_utils": "workspace:*",
"@fluxer/instance_bootstrap": "workspace:*",
"@fluxer/ip_utils": "workspace:*",
"@pkgs/postgres": "workspace:*",
"maxmind": "catalog:",
"zod": "catalog:"
"maxmind": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
@@ -1,60 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
const TABLE = 'ipinfo_cache';
const SELECT_CQL = `SELECT payload FROM ${TABLE} WHERE cache_key = :cache_key LIMIT 1;`;
const INSERT_WITH_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload) USING TTL :ttl;`;
const INSERT_DEFAULT_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload);`;
interface CassandraIpInfoCacheOptions {
client?: ICassandraClient;
getClient?: () => ICassandraClient;
}
export function createCassandraIpInfoCache(options: CassandraIpInfoCacheOptions): IpInfoCache {
return {
async get<T>(key: string): Promise<T | null> {
try {
const client = options.client ?? options.getClient?.();
if (!client) {
return null;
}
const result = await client.execute({cql: SELECT_CQL, params: {cache_key: key}});
const row = result.first();
if (!row) return null;
const payload = row.get('payload');
if (typeof payload !== 'string') return null;
return JSON.parse(payload) as T;
} catch {
return null;
}
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
let payload: string;
try {
payload = JSON.stringify(value);
} catch {
return;
}
try {
const client = options.client ?? options.getClient?.();
if (!client) {
return;
}
if (ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0) {
await client.execute({
cql: INSERT_WITH_TTL_CQL,
params: {cache_key: key, payload, ttl: ttlSeconds},
});
} else {
await client.execute({
cql: INSERT_DEFAULT_TTL_CQL,
params: {cache_key: key, payload},
});
}
} catch {}
},
};
}
@@ -1,119 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
import type {IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
const TABLE = 'ipinfo_requests_by_hour';
const INSERT_CQL = `INSERT INTO ${TABLE} (
bucket_date,
bucket_hour,
requested_at,
event_id,
source,
reason,
ip,
cache_key,
request_url,
http_status,
outcome,
available,
risk_note,
latency_ms,
response_ip,
country_code,
asn,
is_anonymous,
is_tor,
is_vpn,
is_proxy,
is_residential_proxy,
metadata_json
) VALUES (
:bucket_date,
:bucket_hour,
:requested_at,
:event_id,
:source,
:reason,
:ip,
:cache_key,
:request_url,
:http_status,
:outcome,
:available,
:risk_note,
:latency_ms,
:response_ip,
:country_code,
:asn,
:is_anonymous,
:is_tor,
:is_vpn,
:is_proxy,
:is_residential_proxy,
:metadata_json
);`;
interface CassandraIpInfoRequestAuditOptions {
client?: ICassandraClient;
getClient?: () => ICassandraClient;
}
export function createCassandraIpInfoRequestAuditLogger(
options: CassandraIpInfoRequestAuditOptions,
): IpInfoRequestAuditLogger {
return {
async record(event: IpInfoRequestAuditEvent): Promise<void> {
try {
const client = options.client ?? options.getClient?.();
if (!client) {
return;
}
await client.execute({
cql: INSERT_CQL,
params: {
bucket_date: formatUtcDate(event.requestedAt),
bucket_hour: event.requestedAt.getUTCHours(),
requested_at: event.requestedAt,
event_id: randomUUID(),
source: event.source,
reason: event.reason,
ip: event.ip,
cache_key: event.cacheKey,
request_url: event.requestUrl,
http_status: event.httpStatus,
outcome: event.outcome,
available: event.available,
risk_note: event.note,
latency_ms: event.latencyMs,
response_ip: event.responseIp,
country_code: event.countryCode,
asn: event.asnNumber,
is_anonymous: event.isAnonymous,
is_tor: event.isTor,
is_vpn: event.isVpn,
is_proxy: event.isProxy,
is_residential_proxy: event.isResidentialProxy,
metadata_json: serializeMetadata(event.metadata),
},
});
} catch {}
},
};
}
function formatUtcDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
if (!metadata || Object.keys(metadata).length === 0) {
return null;
}
try {
return JSON.stringify(metadata);
} catch {
return null;
}
}
-506
View File
@@ -1,506 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import {z} from 'zod';
const IPINFO_BASE_URL = 'https://api.ipinfo.io/lookup';
const FETCH_TIMEOUT_MS = 3000;
const CACHE_KEY_PREFIX = 'ipinfo:max:';
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/u;
const POSITIVE_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60;
const NEGATIVE_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
const FAILURE_TTL_REQUEST_FAILED_SECONDS = 60;
const FAILURE_TTL_HTTP_ERROR_SECONDS = 300;
const FAILURE_TTL_QUOTA_SECONDS = 900;
const FAILURE_TTL_SCHEMA_MISMATCH_SECONDS = 600;
export interface IpInfoGeoBlock {
countryCode: string | null;
countryName: string | null;
continent: string | null;
continentCode: string | null;
region: string | null;
regionCode: string | null;
city: string | null;
postalCode: string | null;
timezone: string | null;
latitude: number | null;
longitude: number | null;
accuracyRadiusKm: number | null;
}
export interface IpInfoAsnBlock {
asn: string | null;
number: number | null;
name: string | null;
domain: string | null;
type: string | null;
}
export interface IpInfoMobileBlock {
name: string | null;
mcc: string | null;
mnc: string | null;
}
export interface IpInfoAnonymousBlock {
isAnonymous: boolean;
providerName: string | null;
isVpn: boolean;
isProxy: boolean;
isResidentialProxy: boolean;
isTor: boolean;
isRelay: boolean;
percentDaysSeen: number | null;
}
export interface IpInfoFlags {
isAnycast: boolean;
isHosting: boolean;
isMobile: boolean;
isSatellite: boolean;
}
export interface IpInfoLookupResult {
ip: string;
available: boolean;
note: string;
geo: IpInfoGeoBlock;
asn: IpInfoAsnBlock;
mobile: IpInfoMobileBlock;
anonymous: IpInfoAnonymousBlock;
flags: IpInfoFlags;
}
export interface IpInfoCache {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
}
export interface CachedIpInfoFailure extends IpInfoLookupResult {
cachedFailure: true;
failureOutcome: 'http_error' | 'request_failed' | 'schema_mismatch';
failureHttpStatus: number | null;
cachedAtMs: number;
}
export function isCachedIpInfoFailure(value: unknown): value is CachedIpInfoFailure {
return typeof value === 'object' && value !== null && (value as {available?: unknown}).available === false;
}
function failureCacheTtlSeconds(outcome: CachedIpInfoFailure['failureOutcome'], httpStatus: number | null): number {
if (outcome === 'request_failed') return FAILURE_TTL_REQUEST_FAILED_SECONDS;
if (outcome === 'schema_mismatch') return FAILURE_TTL_SCHEMA_MISMATCH_SECONDS;
if (httpStatus === 402 || httpStatus === 403 || httpStatus === 429) return FAILURE_TTL_QUOTA_SECONDS;
return FAILURE_TTL_HTTP_ERROR_SECONDS;
}
export interface IpInfoLookupContext {
source?: string;
reason?: string;
metadata?: Record<string, string | number | boolean | null>;
}
export interface IpInfoRequestAuditEvent {
requestedAt: Date;
ip: string;
cacheKey: string;
source: string;
reason: string | null;
metadata?: Record<string, string | number | boolean | null>;
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch';
httpStatus: number | null;
available: boolean;
note: string;
latencyMs: number;
requestUrl: string;
responseIp: string | null;
countryCode: string | null;
asnNumber: number | null;
isAnonymous: boolean;
isTor: boolean;
isVpn: boolean;
isProxy: boolean;
isResidentialProxy: boolean;
}
export interface IpInfoRequestAuditLogger {
record(event: IpInfoRequestAuditEvent): Promise<void>;
}
interface IpInfoServiceContext {
apiKey: string;
cache: IpInfoCache;
auditLogger?: IpInfoRequestAuditLogger;
}
export interface IpInfoService {
lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult>;
}
const IpInfoDateSchema = z.string().regex(ISO_DATE_REGEX);
const RawIpInfoGeoSchema = z.object({
city: z.string().optional(),
region: z.string().optional(),
region_code: z.string().optional(),
country: z.string().optional(),
country_code: z.string().optional(),
continent: z.string().optional(),
continent_code: z.string().optional(),
latitude: z.number().optional(),
longitude: z.number().optional(),
timezone: z.string().optional(),
postal_code: z.string().optional(),
dma_code: z.string().optional(),
geoname_id: z.string().optional(),
radius: z.number().int().optional(),
last_changed: IpInfoDateSchema.optional(),
});
const RawIpInfoAsSchema = z.object({
asn: z.string().optional(),
name: z.string().optional(),
domain: z.string().optional(),
type: z.string().optional(),
last_changed: IpInfoDateSchema.optional(),
});
const RawIpInfoMobileSchema = z.object({
name: z.string().optional(),
mcc: z.string().optional(),
mnc: z.string().optional(),
});
const RawIpInfoAnonymousSchema = z.object({
name: z.string().optional(),
last_seen: IpInfoDateSchema.optional(),
percent_days_seen: z.number().int().optional(),
is_proxy: z.boolean().optional(),
is_relay: z.boolean().optional(),
is_tor: z.boolean().optional(),
is_vpn: z.boolean().optional(),
is_res_proxy: z.boolean().optional(),
});
const RawIpInfoResponseSchema = z.object({
ip: z.string(),
hostname: z.string().optional(),
geo: RawIpInfoGeoSchema,
as: RawIpInfoAsSchema,
mobile: RawIpInfoMobileSchema.optional(),
anonymous: RawIpInfoAnonymousSchema,
is_anonymous: z.boolean().optional(),
is_anycast: z.boolean().optional(),
is_hosting: z.boolean().optional(),
is_mobile: z.boolean().optional(),
is_satellite: z.boolean().optional(),
});
type RawIpInfoResponse = z.infer<typeof RawIpInfoResponseSchema>;
export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
const inflight: Map<string, Promise<IpInfoLookupResult>> = new Map();
return {
async lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult> {
const cacheKey = `${CACHE_KEY_PREFIX}${getSameIpDecisionKey(ip) ?? ip}`;
const cached = await ctx.cache.get<IpInfoLookupResult>(cacheKey);
if (cached !== null) {
if (isCachedIpInfoFailure(cached)) {
return unavailable(ip, cached.note);
}
return {...cached, ip};
}
const existing = inflight.get(cacheKey);
if (existing) {
const result = await existing;
return {...result, ip};
}
const requestedAt = new Date();
const startedAt = Date.now();
const requestUrl = `${IPINFO_BASE_URL}/${encodeURIComponent(ip)}`;
const fetchUrl = `${requestUrl}?token=${encodeURIComponent(ctx.apiKey)}`;
const finalize = async (params: {
result: IpInfoLookupResult;
outcome: IpInfoRequestAuditEvent['outcome'];
httpStatus: number | null;
}): Promise<IpInfoLookupResult> => {
await ctx.auditLogger
?.record({
requestedAt,
ip,
cacheKey,
source: context?.source ?? 'unknown',
reason: context?.reason ?? null,
metadata: context?.metadata,
outcome: params.outcome,
httpStatus: params.httpStatus,
available: params.result.available,
note: params.result.note,
latencyMs: Date.now() - startedAt,
requestUrl,
responseIp: params.result.available ? params.result.ip : null,
countryCode: params.result.geo.countryCode,
asnNumber: params.result.asn.number,
isAnonymous: params.result.anonymous.isAnonymous,
isTor: params.result.anonymous.isTor,
isVpn: params.result.anonymous.isVpn,
isProxy: params.result.anonymous.isProxy,
isResidentialProxy: params.result.anonymous.isResidentialProxy,
})
.catch(() => {});
return params.result;
};
const performLookup = async (): Promise<IpInfoLookupResult> => {
const finalizeFailure = async (params: {
result: IpInfoLookupResult;
outcome: CachedIpInfoFailure['failureOutcome'];
httpStatus: number | null;
}): Promise<IpInfoLookupResult> => {
const entry: CachedIpInfoFailure = {
...params.result,
cachedFailure: true,
failureOutcome: params.outcome,
failureHttpStatus: params.httpStatus,
cachedAtMs: Date.now(),
};
await ctx.cache
.set(cacheKey, entry, failureCacheTtlSeconds(params.outcome, params.httpStatus))
.catch(() => {});
return finalize(params);
};
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'));
}, FETCH_TIMEOUT_MS);
timer.unref();
let payload: unknown;
try {
const res = await fetch(fetchUrl, {
signal: controller.signal,
headers: {Accept: 'application/json'},
});
if (!res.ok) {
return finalizeFailure({
result: unavailable(ip, `IPInfo HTTP ${res.status}`),
outcome: 'http_error',
httpStatus: res.status,
});
}
payload = await res.json();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
return finalizeFailure({
result: unavailable(ip, `IPInfo request failed: ${detail}`),
outcome: 'request_failed',
httpStatus: null,
});
} finally {
clearTimeout(timer);
controller.abort();
}
const parsedResponse = RawIpInfoResponseSchema.safeParse(payload);
if (!parsedResponse.success) {
return finalizeFailure({
result: unavailable(ip, formatSchemaMismatch(parsedResponse.error)),
outcome: 'schema_mismatch',
httpStatus: 200,
});
}
const result = parseIpInfoResponse(parsedResponse.data);
const ttl = result.anonymous.isAnonymous ? POSITIVE_CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS;
await ctx.cache.set(cacheKey, result, ttl).catch(() => {});
return finalize({
result,
outcome: 'http_success',
httpStatus: 200,
});
};
const promise: Promise<IpInfoLookupResult> = performLookup().finally(() => {
if (inflight.get(cacheKey) === promise) {
inflight.delete(cacheKey);
}
});
inflight.set(cacheKey, promise);
return promise;
},
};
}
export function createUnavailableIpInfoService(reason = 'IPInfo not configured'): IpInfoService {
return {
async lookup(ip: string): Promise<IpInfoLookupResult> {
return unavailable(ip, reason);
},
};
}
function unavailable(ip: string, reason: string): IpInfoLookupResult {
return {
ip,
available: false,
note: reason,
geo: emptyGeo(),
asn: emptyAsn(),
mobile: emptyMobile(),
anonymous: emptyAnonymous(),
flags: emptyFlags(),
};
}
function emptyGeo(): IpInfoGeoBlock {
return {
countryCode: null,
countryName: null,
continent: null,
continentCode: null,
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
};
}
function emptyAsn(): IpInfoAsnBlock {
return {asn: null, number: null, name: null, domain: null, type: null};
}
function emptyMobile(): IpInfoMobileBlock {
return {name: null, mcc: null, mnc: null};
}
function emptyAnonymous(): IpInfoAnonymousBlock {
return {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
};
}
function emptyFlags(): IpInfoFlags {
return {isAnycast: false, isHosting: false, isMobile: false, isSatellite: false};
}
function parseIpInfoResponse(raw: RawIpInfoResponse): IpInfoLookupResult {
const geo = raw.geo;
const anon = raw.anonymous;
const isAnonymous =
raw.is_anonymous === true ||
anon.is_res_proxy === true ||
anon.is_vpn === true ||
anon.is_proxy === true ||
anon.is_tor === true ||
anon.is_relay === true;
return {
ip: raw.ip,
available: true,
note: describeAnonymity(isAnonymous, anon),
geo: {
countryCode: normalizeCountryCode(geo.country_code),
countryName: geo.country ?? null,
continent: geo?.continent ?? null,
continentCode: normalizeContinentCode(geo.continent_code),
region: geo.region ?? null,
regionCode: normalizeRegionCode(geo.region_code),
city: geo.city ?? null,
postalCode: geo.postal_code ?? null,
timezone: geo.timezone ?? null,
latitude: normalizeCoordinate(geo.latitude),
longitude: normalizeCoordinate(geo.longitude),
accuracyRadiusKm: typeof geo?.radius === 'number' && Number.isFinite(geo.radius) ? geo.radius : null,
},
asn: parseAsnBlock(raw.as),
mobile: {
name: raw.mobile?.name ?? null,
mcc: raw.mobile?.mcc ?? null,
mnc: raw.mobile?.mnc ?? null,
},
anonymous: {
isAnonymous,
providerName: anon?.name ?? null,
isVpn: anon?.is_vpn === true,
isProxy: anon?.is_proxy === true,
isResidentialProxy: anon?.is_res_proxy === true,
isTor: anon?.is_tor === true,
isRelay: anon?.is_relay === true,
percentDaysSeen: typeof anon?.percent_days_seen === 'number' ? anon.percent_days_seen : null,
},
flags: {
isAnycast: raw.is_anycast === true,
isHosting: raw.is_hosting === true,
isMobile: raw.is_mobile === true,
isSatellite: raw.is_satellite === true,
},
};
}
function formatSchemaMismatch(error: z.ZodError): string {
const issue = error.issues[0];
if (!issue) {
return 'IPInfo response schema mismatch';
}
const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
return `IPInfo response schema mismatch at ${path}: ${issue.message}`;
}
function parseAsnBlock(as: RawIpInfoResponse['as']): IpInfoAsnBlock {
const raw = as?.asn ?? null;
const numeric = raw ? Number(raw.replace(/^AS/i, '')) : Number.NaN;
return {
asn: raw,
number: Number.isFinite(numeric) ? numeric : null,
name: as?.name ?? null,
domain: as?.domain ?? null,
type: as?.type ?? null,
};
}
function describeAnonymity(isAnonymous: boolean, anon: RawIpInfoResponse['anonymous']): string {
if (!isAnonymous) {
return 'IPInfo: IP is not anonymous';
}
if (!anon) {
return 'IPInfo: anonymous IP';
}
const flags: Array<string> = [];
if (anon.is_res_proxy) flags.push('residential proxy');
if (anon.is_vpn) flags.push('VPN');
if (anon.is_proxy) flags.push('proxy');
if (anon.is_tor) flags.push('Tor');
if (anon.is_relay) flags.push('relay');
const provider = anon.name ? ` (provider: ${anon.name})` : '';
const seen = anon.percent_days_seen != null ? `, seen ${anon.percent_days_seen}% of days` : '';
return `IPInfo: anonymous IP${provider} — ${flags.join(', ')}${seen}`;
}
function normalizeCountryCode(value: string | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toUpperCase();
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
}
function normalizeContinentCode(value: string | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toUpperCase();
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
}
function normalizeRegionCode(value: string | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toUpperCase();
return normalized.length > 0 ? normalized : null;
}
function normalizeCoordinate(value: number | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
@@ -1,153 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import type {IpInfoCache, IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
interface PostgresIpInfoOptions {
client?: IPostgresClient;
getClient?: () => IPostgresClient;
onError?: (error: unknown, operation: string) => void;
}
const VALUE_SEPARATOR = '\u001f';
export const IPINFO_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
export const IPINFO_REQUEST_AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60;
function getClient(options: PostgresIpInfoOptions): IPostgresClient | null {
return options.client ?? options.getClient?.() ?? null;
}
function valueKey(value: unknown): string {
return JSON.stringify(value);
}
function rowKey(values: ReadonlyArray<unknown>): string {
return values.map(valueKey).join(VALUE_SEPARATOR);
}
function table(client: IPostgresClient): string {
return quoteIdentifier(client.kvTable());
}
async function upsertKvRow(
client: IPostgresClient,
tableName: string,
partitionKey: string,
key: string,
row: Record<string, unknown>,
ttlSeconds: number,
): Promise<void> {
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
await client.query(
`INSERT INTO ${table(client)} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, now())
ON CONFLICT (table_name, row_key)
DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = EXCLUDED.row_data, expires_at = EXCLUDED.expires_at, updated_at = now()`,
[tableName, partitionKey, key, JSON.stringify(row), expiresAt],
);
}
export function createPostgresIpInfoCache(options: PostgresIpInfoOptions): IpInfoCache {
return {
async get<T>(key: string): Promise<T | null> {
try {
const client = getClient(options);
if (!client) return null;
const result = await client.query<{row_data: {payload?: string}}>(
`SELECT row_data FROM ${table(client)} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
['ipinfo_cache', rowKey([key])],
);
const payload = result.rows[0]?.row_data?.payload;
return typeof payload === 'string' ? (JSON.parse(payload) as T) : null;
} catch (error) {
options.onError?.(error, 'ipinfo_cache_get');
return null;
}
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
let payload: string;
try {
payload = JSON.stringify(value);
} catch (error) {
options.onError?.(error, 'ipinfo_cache_serialize');
return;
}
try {
const client = getClient(options);
if (!client) return;
await upsertKvRow(
client,
'ipinfo_cache',
rowKey([key]),
rowKey([key]),
{cache_key: key, payload},
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : IPINFO_CACHE_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_cache_set');
}
},
};
}
export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOptions): IpInfoRequestAuditLogger {
return {
async record(event: IpInfoRequestAuditEvent): Promise<void> {
try {
const client = getClient(options);
if (!client) return;
const bucketDate = formatUtcDate(event.requestedAt);
const bucketHour = event.requestedAt.getUTCHours();
const eventId = randomUUID();
await upsertKvRow(
client,
'ipinfo_requests_by_hour',
rowKey([bucketDate, bucketHour]),
rowKey([bucketDate, bucketHour, event.requestedAt.toISOString(), eventId]),
{
bucket_date: bucketDate,
bucket_hour: bucketHour,
requested_at: event.requestedAt.toISOString(),
event_id: eventId,
source: event.source,
reason: event.reason,
ip: event.ip,
cache_key: event.cacheKey,
request_url: event.requestUrl,
http_status: event.httpStatus,
outcome: event.outcome,
available: event.available,
risk_note: event.note,
latency_ms: event.latencyMs,
response_ip: event.responseIp,
country_code: event.countryCode,
asn: event.asnNumber,
is_anonymous: event.isAnonymous,
is_tor: event.isTor,
is_vpn: event.isVpn,
is_proxy: event.isProxy,
is_residential_proxy: event.isResidentialProxy,
metadata_json: serializeMetadata(event.metadata),
},
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_request_audit_record');
}
},
};
}
function formatUtcDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
if (!metadata || Object.keys(metadata).length === 0) return null;
try {
return JSON.stringify(metadata);
} catch {
return null;
}
}
@@ -1,35 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
const DEFAULT_HOT_TTL_SECONDS = 10 * 60;
interface TieredIpInfoCacheOptions {
hot: IpInfoCache;
cold: IpInfoCache;
hotTtlSeconds?: number;
skipColdWrite?: (value: unknown) => boolean;
}
export function createTieredIpInfoCache(opts: TieredIpInfoCacheOptions): IpInfoCache {
const hotTtl = opts.hotTtlSeconds ?? DEFAULT_HOT_TTL_SECONDS;
return {
async get<T>(key: string): Promise<T | null> {
const hit = await opts.hot.get<T>(key).catch(() => null);
if (hit !== null) return hit;
const cold = await opts.cold.get<T>(key).catch(() => null);
if (cold === null) return null;
if (opts.skipColdWrite?.(cold) === true) return cold;
void opts.hot.set(key, cold, hotTtl).catch(() => {});
return cold;
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
const effectiveHotTtl = Math.max(1, Math.min(hotTtl, ttlSeconds ?? hotTtl));
const writes: Array<Promise<void>> = [opts.hot.set(key, value, effectiveHotTtl).catch(() => {})];
if (opts.skipColdWrite?.(value) !== true) {
writes.push(opts.cold.set(key, value, ttlSeconds).catch(() => {}));
}
await Promise.all(writes);
},
};
}
+28 -3
View File
@@ -259,9 +259,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
}
: undefined,
},
ipinfo: {
apiKey: master.integrations.ipinfo.api_key || undefined,
},
blocklistFeeds: {
enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted,
},
@@ -415,6 +412,34 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
apps: mapApnsApps(master.integrations.push.apns.apps),
},
},
appStore: {
enabled: master.integrations.app_store.enabled,
issuerId: master.integrations.app_store.issuer_id,
keyId: master.integrations.app_store.key_id,
privateKey: master.integrations.app_store.private_key,
privateKeyPath: master.integrations.app_store.private_key_path,
apps: (master.integrations.app_store.apps ?? []).map((app) => ({
bundleId: app.bundle_id,
appAppleId: app.app_apple_id,
})),
products: master.integrations.app_store.products ?? {},
},
googlePlay: {
enabled: master.integrations.google_play.enabled,
packages: master.integrations.google_play.packages ?? [],
clientEmail: master.integrations.google_play.client_email,
privateKey: master.integrations.google_play.private_key,
privateKeyPath: master.integrations.google_play.private_key_path,
serviceAccountJsonPath: master.integrations.google_play.service_account_json_path,
tokenUri: master.integrations.google_play.token_uri ?? 'https://oauth2.googleapis.com/token',
products: master.integrations.google_play.products ?? {},
pushAudience: master.integrations.google_play.push_audience,
pushServiceAccountEmail: master.integrations.google_play.push_service_account_email,
},
storeBilling: {
sandboxUserIds: master.integrations.store_billing.sandbox_user_ids ?? [],
sandboxEntitlesAll: master.integrations.store_billing.sandbox_entitles_all,
},
worker: {
mode: apiWorkerConfig?.mode ?? 'all_lanes',
laneName: apiWorkerConfig?.lane,
+2
View File
@@ -12,6 +12,7 @@ import {MiscRateLimitConfigs} from '@app/api/rate_limit_configs/MiscRateLimitCon
import {OAuthRateLimitConfigs} from '@app/api/rate_limit_configs/OAuthRateLimitConfig';
import type {RateLimitSection} from '@app/api/rate_limit_configs/RateLimitHelpers';
import {mergeRateLimitSections} from '@app/api/rate_limit_configs/RateLimitHelpers';
import {StoreBillingRateLimitConfigs} from '@app/api/rate_limit_configs/StoreBillingRateLimitConfig';
import {UserRateLimitConfigs} from '@app/api/rate_limit_configs/UserRateLimitConfig';
import {WebhookRateLimitConfigs} from '@app/api/rate_limit_configs/WebhookRateLimitConfig';
@@ -26,6 +27,7 @@ const rateLimitSections = [
InviteRateLimitConfigs,
WebhookRateLimitConfigs,
IntegrationRateLimitConfigs,
StoreBillingRateLimitConfigs,
AdminRateLimitConfigs,
MiscRateLimitConfigs,
] satisfies ReadonlyArray<RateLimitSection>;
+31
View File
@@ -250,6 +250,16 @@ import {
MESSAGE_REPORT_SUBMISSION_BY_REPORTER_COLUMNS,
type MessageReportSubmissionByReporterRow,
} from '@app/api/database/types/ReportTypes';
import {
STORE_ACCOUNT_TOKEN_BY_USER_COLUMNS,
STORE_ACCOUNT_TOKEN_COLUMNS,
STORE_PURCHASE_BY_USER_COLUMNS,
STORE_PURCHASE_COLUMNS,
type StoreAccountTokenByUserRow,
type StoreAccountTokenRow,
type StorePurchaseByUserRow,
type StorePurchaseRow,
} from '@app/api/database/types/StoreBillingTypes';
import {
FAVORITE_MEME_COLUMNS,
type FavoriteMemeRow,
@@ -703,6 +713,27 @@ export const GiftCodesByRedeemer = defineTable<GiftCodeByRedeemerRow, 'redeemed_
columns: GIFT_CODE_BY_REDEEMER_COLUMNS,
primaryKey: ['redeemed_by_user_id', 'code'],
});
export const StorePurchases = defineTable<StorePurchaseRow, 'store_key'>({
name: 'store_purchases',
columns: STORE_PURCHASE_COLUMNS,
primaryKey: ['store_key'],
});
export const StorePurchasesByUser = defineTable<StorePurchaseByUserRow, 'user_id' | 'store_key', 'user_id'>({
name: 'store_purchases_by_user',
columns: STORE_PURCHASE_BY_USER_COLUMNS,
primaryKey: ['user_id', 'store_key'],
partitionKey: ['user_id'],
});
export const StoreAccountTokens = defineTable<StoreAccountTokenRow, 'token_'>({
name: 'store_account_tokens',
columns: STORE_ACCOUNT_TOKEN_COLUMNS,
primaryKey: ['token_'],
});
export const StoreAccountTokensByUser = defineTable<StoreAccountTokenByUserRow, 'user_id'>({
name: 'store_account_tokens_by_user',
columns: STORE_ACCOUNT_TOKEN_BY_USER_COLUMNS,
primaryKey: ['user_id'],
});
export const AdminArchivesBySubject = defineTable<AdminArchiveRow, 'subject_type' | 'subject_id' | 'archive_id'>({
name: 'admin_archives_by_subject',
columns: ADMIN_ARCHIVE_COLUMNS,
@@ -44,6 +44,7 @@ export const AdminAuditReadActions = {
LIST_USER_GUILDS: 'list_user_guilds',
LIST_USER_RELATIONSHIPS: 'list_user_relationships',
LIST_USER_SESSIONS: 'list_user_sessions',
LIST_USER_STORE_PURCHASES: 'list_user_store_purchases',
LIST_VOICE_REGIONS: 'list_voice_regions',
LIST_VOICE_SERVERS: 'list_voice_servers',
LIST_WEBAUTHN_CREDENTIALS: 'list_webauthn_credentials',
+10 -10
View File
@@ -37,10 +37,10 @@ import {
} from '@app/api/middleware/ServiceSingletons';
import type {IApplicationRepository} from '@app/api/oauth/repositories/IApplicationRepository';
import type {ReportService} from '@app/api/report/ReportService';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import type {UserService} from '@app/api/user/services/UserService';
import type {VoiceRepository} from '@app/api/voice/VoiceRepository';
import type {SendSystemDmResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type Stripe from 'stripe';
export class AdminService {
@@ -80,7 +80,7 @@ export class AdminService {
private readonly applicationRepository: IApplicationRepository,
private readonly stripe: Stripe | null = null,
private readonly jobLedger: IJobLedgerRepository,
private readonly ipInfoService: IpInfoService,
private readonly storeEntitlementService: StoreEntitlementService,
) {
const {users, gateway, worker, snowflake} = this.apiContext.services;
this.auditService = new AdminAuditService(this.adminRepository, snowflake, {
@@ -92,7 +92,6 @@ export class AdminService {
apiContext: this.apiContext,
adminRepository: this.adminRepository,
auditService: this.auditService,
ipInfoService: this.ipInfoService,
});
this.userService = new AdminUserService({
apiContext: this.apiContext,
@@ -107,6 +106,7 @@ export class AdminService {
bulkMessageDeletionQueue: this.bulkMessageDeletionQueue,
stripe: this.stripe,
reportService: this.reportService,
storeEntitlementService: this.storeEntitlementService,
});
this.guildServiceAggregate = new AdminGuildService({
guildRepository: this.guildRepository,
@@ -178,20 +178,20 @@ export class AdminService {
}
async sendSystemDm(
data: {content: string; userIds: Array<string>},
data: {content: string; recipients: {kind: 'all'} | {kind: 'list'; userIds: Array<string>}},
adminUserId: UserID,
auditLogReason: string | null,
): Promise<SendSystemDmResponse> {
const recipientCount = data.recipients.kind === 'all' ? null : data.recipients.userIds.length;
await this.apiContext.services.worker.addJob(
'sendSystemDm',
{
content: data.content,
user_ids: data.userIds,
},
data.recipients.kind === 'all'
? {content: data.content, all_users: true}
: {content: data.content, user_ids: data.recipients.userIds},
{requireLedger: true},
);
const metadata = new Map<string, string>([
['recipient_count', data.userIds.length.toString()],
['recipient_count', recipientCount === null ? 'all' : recipientCount.toString()],
['content_length', data.content.length.toString()],
]);
await this.auditService.createAuditLog({
@@ -202,6 +202,6 @@ export class AdminService {
auditLogReason,
metadata,
});
return {recipient_count: data.userIds.length};
return {recipient_count: recipientCount};
}
}
@@ -338,7 +338,7 @@ export function BanAdminController(app: HonoApp) {
tags: ['Admin'],
requestSchema: AdminBlocklistEntryCreateRequest,
description:
'Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list, or that IPInfo reports as a high blast-radius carrier NAT, is refused with 400 IP_BAN_DECLINED and recorded in the audit log.',
'Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list is refused with 400 IP_BAN_DECLINED and recorded in the audit log.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -124,6 +124,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
single_community_guild_id: policy.single_community_guild_id,
direct_messages_disabled: policy.direct_messages_disabled,
direct_messages_locked: policy.direct_messages_locked,
guild_create_access: policy.guild_create_access,
premium_mode: policy.premium_mode,
services: {
gif_enabled: policy.gif_enabled,
@@ -831,6 +832,9 @@ function planInstancePolicyPatch(
patch.direct_messages_locked = true;
}
}
if (policy.guild_create_access !== undefined && policy.guild_create_access !== current.guild_create_access) {
patch.guild_create_access = policy.guild_create_access;
}
if (policy.services) {
if (policy.services.gif_enabled !== undefined) {
patch.gif_enabled = policy.services.gif_enabled ?? null;
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import {HostedOnlyRoute} from '@app/api/store_billing/StoreBillingController';
import {mapStorePurchaseToAdminResponse} from '@app/api/store_billing/StoreBillingMappers';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {AdminStorePurchaseListResponse} from '@fluxer/schema/src/domains/admin/AdminStoreBillingSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {Context} from 'hono';
async function requireUser(ctx: Context<HonoEnv>, userId: UserID): Promise<void> {
if (!(await ctx.get('userRepository').findUnique(userId))) {
throw new UnknownUserError();
}
}
export function StoreBillingAdminController(app: HonoApp) {
app.get(
'/admin/users/:user_id/store-purchases',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.USER_LOOKUP),
Validator('param', UserIdParam),
OpenAPI({
operationId: 'list_admin_user_store_purchases',
summary: 'List user store purchases',
responseSchema: AdminStorePurchaseListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Lists the App Store and Google Play purchases bound to a user, newest first, with their store state. Only available on hosted instances. Requires USER_LOOKUP permission.',
}),
async (ctx) => {
const userId = createUserID(ctx.req.valid('param').user_id);
await requireUser(ctx, userId);
const rows = await ctx.get('storeEntitlementService').listStorePurchases(userId);
await recordAdminRead(ctx, {
targetType: 'user',
targetId: userId,
action: AdminAuditReadActions.LIST_USER_STORE_PURCHASES,
metadata: {result_count: rows.length},
});
return ctx.json({purchases: rows.map(mapStorePurchaseToAdminResponse)});
},
);
app.post(
'/admin/users/:user_id/store-purchases/refresh',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
Validator('param', UserIdParam),
OpenAPI({
operationId: 'refresh_admin_user_store_purchases',
summary: 'Refresh user store purchases',
responseSchema: AdminStorePurchaseListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Reads every store purchase bound to a user again from the App Store or Google Play, applies the result to the account and returns the updated purchases. Creates audit log entry. Only available on hosted instances. Requires USER_UPDATE_FLAGS permission.',
}),
async (ctx) => {
const userId = createUserID(ctx.req.valid('param').user_id);
await requireUser(ctx, userId);
const storeEntitlementService = ctx.get('storeEntitlementService');
const rows = await storeEntitlementService.listStorePurchases(userId);
for (const row of rows) {
await storeEntitlementService.refreshStorePurchase(row.store_key);
}
await storeEntitlementService.applyStoreEntitlementToUser(userId);
const refreshed = await storeEntitlementService.listStorePurchases(userId);
await recordAdminWrite(ctx, {
targetType: 'user',
targetId: userId,
action: 'refresh_store_purchases',
metadata: {purchase_count: rows.length},
});
return ctx.json({purchases: refreshed.map(mapStorePurchaseToAdminResponse)});
},
);
}
@@ -23,7 +23,7 @@ export function SystemDmAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
description:
'Queue a worker job that delivers the same content to every listed user as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.',
'Queue a worker job that delivers the same content to every listed user, or to every user when all_users is set, as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -31,7 +31,12 @@ export function SystemDmAdminController(app: HonoApp) {
const auditLogReason = ctx.get('auditLogReason');
const payload = ctx.req.valid('json');
const result = await adminService.sendSystemDm(
{content: payload.content, userIds: payload.user_ids.map((id) => id.toString())},
{
content: payload.content,
recipients: payload.all_users
? {kind: 'all'}
: {kind: 'list', userIds: (payload.user_ids ?? []).map((id) => id.toString())},
},
adminUserId,
auditLogReason,
);
@@ -17,6 +17,7 @@ import {LimitConfigAdminController} from '@app/api/admin/controllers/LimitConfig
import {MessageAdminController} from '@app/api/admin/controllers/MessageAdminController';
import {ReportAdminController} from '@app/api/admin/controllers/ReportAdminController';
import {SearchAdminController} from '@app/api/admin/controllers/SearchAdminController';
import {StoreBillingAdminController} from '@app/api/admin/controllers/StoreBillingAdminController';
import {SystemDmAdminController} from '@app/api/admin/controllers/SystemDmAdminController';
import {UserAdminController} from '@app/api/admin/controllers/UserAdminController';
import {VoiceAdminController} from '@app/api/admin/controllers/VoiceAdminController';
@@ -26,6 +27,7 @@ export function registerAdminControllers(app: HonoApp) {
AdminApiKeyAdminController(app);
ApplicationAdminController(app);
UserAdminController(app);
StoreBillingAdminController(app);
CodesAdminController(app);
GuildAdminController(app);
AssetAdminController(app);
@@ -4,7 +4,6 @@ import type {ApiContext} from '@app/api/ApiContext';
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import {
BANNED_AVATAR_HASHES_REFRESH_CHANNEL,
@@ -18,7 +17,6 @@ import {
} from '@app/api/constants/ContentModeration';
import {IP_BAN_REFRESH_CHANNEL} from '@app/api/constants/IpBan';
import type {BannedProfileSubstringScope} from '@app/api/database/types/AdminArchiveTypes';
import {Logger} from '@app/api/Logger';
import {bannedAvatarHashCache} from '@app/api/middleware/BannedAvatarHashCache';
import {fileShaCache} from '@app/api/middleware/FileShaCache';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
@@ -34,13 +32,11 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {AdminBlocklistListType} from '@fluxer/schema/src/domains/admin/AdminBlocklistSchemas';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
interface AdminBanManagementServiceDeps {
apiContext: ApiContext;
adminRepository: IAdminRepository;
auditService: AdminAuditService;
ipInfoService: IpInfoService;
}
interface AdminBlocklistEntry {
@@ -146,20 +142,6 @@ export class AdminBanManagementService {
message: 'This IP address is on the instance exemption list',
});
}
if (await this.shouldSkipIpBanForCgnat(data.ip)) {
await auditService.createAuditLog({
adminUserId,
targetType: 'ip',
targetId: BigInt(0),
action: 'ban_ip_skipped_cgnat',
auditLogReason,
metadata: new Map([['ip', data.ip]]),
});
throw new BadRequestError({
code: APIErrorCodes.IP_BAN_DECLINED,
message: 'This IP address is a high blast-radius carrier network',
});
}
await adminRepository.banIp(data.ip);
ipBanCache.ban(data.ip);
await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh');
@@ -200,25 +182,6 @@ export class AdminBanManagementService {
return {banned};
}
private async shouldSkipIpBanForCgnat(ip: string): Promise<boolean> {
if (!isSingleIpBanCandidate(ip)) {
return false;
}
try {
const {cgnat: highRisk} = await getIpBanBlastRadiusVerdict(ip, this.deps.ipInfoService, {
source: 'admin.ip_ban',
reason: 'pre_write_cgnat_guard',
});
if (highRisk) {
Logger.warn({ip}, 'Skipping IP ban because IPInfo indicates high CGNAT blast-radius risk');
}
return highRisk;
} catch (error) {
Logger.warn({error, ip}, 'IPInfo CGNAT guard failed while adding IP ban');
return false;
}
}
async banEmail(
data: {
email: string;
@@ -17,6 +17,7 @@ import type {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2Toke
import {ReportStatus} from '@app/api/report/IReportRepository';
import type {ReportService} from '@app/api/report/ReportService';
import {getReportSearchService} from '@app/api/SearchFactory';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import {clearPendingDeletion, reschedulePendingDeletion} from '@app/api/user/services/PendingDeletionCoordinator';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {DeletionReasons} from '@fluxer/constants/src/Core';
@@ -41,6 +42,7 @@ interface AdminUserDeletionServiceDeps {
stripe: Stripe | null;
billingRepository: BillingRepository;
oauth2Tokens: Pick<OAuth2TokenRepository, 'deleteAllAccessTokensForUser' | 'deleteAllRefreshTokensForUser'>;
storeEntitlementService: StoreEntitlementService;
}
const minUserRequestedDeletionDays = 14;
@@ -206,6 +208,7 @@ export class AdminUserDeletionService {
);
}
}
await this.deps.storeEntitlementService.revokeForBannedUser(userId);
const email = user.email;
const notificationTemplate = scheduledDeletionEmailTemplate(data.reason_code);
const notificationAttempted = Boolean(data.notify_user && email);
@@ -21,6 +21,7 @@ import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository';
import type {ReportService} from '@app/api/report/ReportService';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
@@ -47,6 +48,7 @@ interface AdminUserServiceDeps {
bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService;
stripe: Stripe | null;
reportService: ReportService;
storeEntitlementService: StoreEntitlementService;
}
export class AdminUserService {
@@ -109,6 +111,7 @@ export class AdminUserService {
stripe: deps.stripe,
billingRepository: getBillingRepository(),
oauth2Tokens: new OAuth2TokenRepository(),
storeEntitlementService: deps.storeEntitlementService,
});
this.contactChangeLogService = contactChangeLog;
}
@@ -1,170 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
import {createUserID} from '@app/api/BrandedTypes';
import {resetIpBanExemptionsForTesting} from '@app/api/ban/IpBanExemptions';
import {getConfig} from '@app/api/Config';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
const ADMIN_ID = createUserID(42n);
const EXEMPT_IP = '10.0.0.1';
const CARRIER_IP = '198.51.100.7';
const LOOKUP_FAILURE_IP = '203.0.113.9';
interface AuditCall {
action: string;
metadata: Map<string, string> | undefined;
}
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
return {
ip: CARRIER_IP,
available: true,
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
continent: 'North America',
continentCode: 'NA',
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
},
asn: {
asn: 'AS64500',
number: 64500,
name: 'Test Carrier',
domain: null,
type: null,
},
mobile: {
name: null,
mcc: null,
mnc: null,
},
anonymous: {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {
isAnycast: false,
isHosting: false,
isMobile: false,
isSatellite: false,
},
...overrides,
};
}
function createBanManagementService(lookup: (ip: string) => Promise<IpInfoLookupResult>) {
const bannedIps: Array<string> = [];
const auditCalls: Array<AuditCall> = [];
const adminRepository = {
banIp: async (ip: string) => {
bannedIps.push(ip);
},
};
const auditService = {
createAuditLog: async ({action, metadata}: AuditCall) => {
auditCalls.push({action, metadata});
},
};
const ipInfoService = {lookup: (ip: string) => lookup(ip)};
const apiContext = {
services: {
cache: {
publish: async () => {},
},
},
};
const service = new AdminBanManagementService({
apiContext: apiContext as unknown as ApiContext,
adminRepository: adminRepository as unknown as IAdminRepository,
auditService: auditService as unknown as AdminAuditService,
ipInfoService: ipInfoService as unknown as IpInfoService,
});
return {service, bannedIps, auditCalls};
}
describe('AdminBanManagementService banIp guards', () => {
let originalExemptIps: Array<string>;
beforeEach(() => {
const config = getConfig();
originalExemptIps = config.ipBanExemptIps;
config.ipBanExemptIps = [EXEMPT_IP];
resetIpBanExemptionsForTesting();
});
afterEach(() => {
ipBanCache.unban(LOOKUP_FAILURE_IP);
getConfig().ipBanExemptIps = originalExemptIps;
resetIpBanExemptionsForTesting();
});
it('refuses an exempt address with IP_BAN_DECLINED and writes no ban row', async () => {
const {service, bannedIps, auditCalls} = createBanManagementService(async () => ipInfoResult());
const error = await service.banIp({ip: EXEMPT_IP}, ADMIN_ID, null).then(
() => null,
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(BadRequestError);
expect((error as BadRequestError).code).toBe(APIErrorCodes.IP_BAN_DECLINED);
expect((error as BadRequestError).status).toBe(400);
expect(bannedIps).toEqual([]);
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip_skipped_exempt']);
expect(auditCalls[0].metadata?.get('ip')).toBe(EXEMPT_IP);
});
it('refuses a high blast-radius carrier address with IP_BAN_DECLINED and writes no ban row', async () => {
const {service, bannedIps, auditCalls} = createBanManagementService(async () =>
ipInfoResult({
mobile: {name: 'Example Mobile', mcc: '001', mnc: '01'},
flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false},
}),
);
const error = await service.banIp({ip: CARRIER_IP}, ADMIN_ID, null).then(
() => null,
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(BadRequestError);
expect((error as BadRequestError).code).toBe(APIErrorCodes.IP_BAN_DECLINED);
expect((error as BadRequestError).status).toBe(400);
expect(bannedIps).toEqual([]);
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip_skipped_cgnat']);
expect(auditCalls[0].metadata?.get('ip')).toBe(CARRIER_IP);
});
it('still writes the ban when the IPInfo lookup fails', async () => {
const {service, bannedIps, auditCalls} = createBanManagementService(async () => {
throw new Error('ipinfo is unreachable');
});
await expect(service.banIp({ip: LOOKUP_FAILURE_IP}, ADMIN_ID, null)).resolves.toBeUndefined();
expect(bannedIps).toEqual([LOOKUP_FAILURE_IP]);
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip']);
});
});
@@ -10,83 +10,24 @@ import {
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
function createUniqueTestIp(): string {
return `198.51.${randomInt(0, 256)}.${randomInt(1, 255)}`;
}
function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
return {
ip,
available: true,
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
continent: 'North America',
continentCode: 'NA',
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
},
asn: {
asn: 'AS64500',
number: 64500,
name: 'Test ISP',
domain: null,
type: null,
},
mobile: {
name: null,
mcc: null,
mnc: null,
},
anonymous: {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {
isAnycast: false,
isHosting: false,
isMobile: false,
isSatellite: false,
},
...overrides,
};
}
describe('Admin Deletion Queue', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
setInjectedIpInfoService({
async lookup(ip: string) {
return ipInfoResult(ip);
},
});
});
afterEach(async () => {
setInjectedIpInfoService(undefined);
await harness?.shutdown();
});
test('admin scheduling queues deletion and rescheduling replaces the old Cassandra row', async () => {
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {getConfig} from '@app/api/Config';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const COMMUNITY_CREATOR_TRAIT = 'community_creator';
interface LimitConfigReadResponse {
limit_config: LimitConfigSnapshot;
}
describe('guild creation access on a self-hosted instance', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness.shutdown();
});
const asSelfHosted = async <T>(run: () => Promise<T>): Promise<T> => {
const config = getConfig();
const originalSelfHosted = config.instance.selfHosted;
config.instance.selfHosted = true;
try {
return await run();
} finally {
config.instance.selfHosted = originalSelfHosted;
}
};
const createAdmin = async (): Promise<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
AdminACLs.USER_UPDATE_TRAITS,
]);
const createMember = async (): Promise<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), []);
const setGuildCreateAccess = async (admin: TestAccount, enabled: boolean): Promise<void> => {
const updated = await createBuilder<InstanceConfigResponse>(harness, admin.token)
.patch('/admin/instance/config')
.body({policy: {guild_create_access: enabled}})
.execute();
expect(updated.policy.guild_create_access).toBe(enabled);
};
const readInstanceConfig = async (admin: TestAccount): Promise<InstanceConfigResponse> =>
await createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config').execute();
const createGuild = (account: TestAccount, name: string) =>
createBuilder<GuildResponse>(harness, account.token).post('/guilds').body({name});
const grantGuildCreateToTrait = async (admin: TestAccount, trait: string): Promise<void> => {
const current = await createBuilder<LimitConfigReadResponse>(harness, admin.token)
.get('/admin/limit-config')
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, admin.token)
.put('/admin/limit-config')
.body({
limit_config: {
traitDefinitions: [...current.limit_config.traitDefinitions, trait],
rules: [
...current.limit_config.rules,
{id: `grant_${trait}`, filters: {traits: [trait]}, limits: {feature_guild_create: 1}},
],
},
})
.expect(HTTP_STATUS.OK)
.execute();
};
const grantTrait = async (admin: TestAccount, account: TestAccount, trait: string): Promise<void> => {
await createBuilder(harness, admin.token)
.put(`/admin/users/${account.userId}/traits`)
.body({traits: [trait]})
.expect(HTTP_STATUS.OK)
.execute();
};
it('allows guild creation while the community creation policy is at its default', async () => {
const admin = await createAdmin();
expect((await readInstanceConfig(admin)).policy.guild_create_access).toBe(true);
const member = await createMember();
await asSelfHosted(async () => {
const guild = await createGuild(member, 'Default policy community').execute();
expect(guild.id).toBeTruthy();
});
});
it('stores a disabled policy and rejects guild creation for a member without a grant', async () => {
const admin = await createAdmin();
await setGuildCreateAccess(admin, false);
expect((await readInstanceConfig(admin)).policy.guild_create_access).toBe(false);
const member = await createMember();
await asSelfHosted(async () => {
await createGuild(member, 'Denied community')
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.GUILD_CREATION_PERMISSION_REQUIRED)
.execute();
});
});
it('allows guild creation only once a member holds the trait the grant rule targets', async () => {
const admin = await createAdmin();
await setGuildCreateAccess(admin, false);
await grantGuildCreateToTrait(admin, COMMUNITY_CREATOR_TRAIT);
const member = await createMember();
await asSelfHosted(async () => {
await createGuild(member, 'Ungranted community')
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.GUILD_CREATION_PERMISSION_REQUIRED)
.execute();
});
await grantTrait(admin, member, COMMUNITY_CREATOR_TRAIT);
await asSelfHosted(async () => {
const guild = await createGuild(member, 'Granted community').execute();
expect(guild.id).toBeTruthy();
});
});
it('allows guild creation for a member holding a wildcard ACL while the policy is disabled', async () => {
const admin = await createAdmin();
await setGuildCreateAccess(admin, false);
const member = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.WILDCARD]);
await asSelfHosted(async () => {
const guild = await createGuild(member, 'Wildcard community').execute();
expect(guild.id).toBeTruthy();
});
});
});
@@ -19,6 +19,7 @@ import {LimitConfigAdminAuditCases} from '@app/api/admin/tests/audit_coverage/Li
import {MessageAdminAuditCases} from '@app/api/admin/tests/audit_coverage/MessageAdminAuditCases';
import {ReportAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ReportAdminAuditCases';
import {SearchAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SearchAdminAuditCases';
import {StoreBillingAdminAuditCases} from '@app/api/admin/tests/audit_coverage/StoreBillingAdminAuditCases';
import {SystemDmAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SystemDmAdminAuditCases';
import {UserAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserAdminAuditCases';
import {UserWriteAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserWriteAdminAuditCases';
@@ -45,6 +46,7 @@ const ALL_CASES = [
...MessageAdminAuditCases,
...ReportAdminAuditCases,
...SearchAdminAuditCases,
...StoreBillingAdminAuditCases,
...SystemDmAdminAuditCases,
...UserAdminAuditCases,
...UserWriteAdminAuditCases,
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {seedStorePurchase} from '@app/api/store_billing/tests/StoreBillingTestUtils';
export const StoreBillingAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
{
method: 'GET',
route: '/admin/users/:user_id/store-purchases',
async prepare({harness}) {
const target = await createTestAccount(harness);
await seedStorePurchase(createUserID(BigInt(target.userId)));
return {
request: {path: `/admin/users/${target.userId}/store-purchases`},
expected: {
action: 'list_user_store_purchases',
targetType: 'user',
targetId: target.userId,
metadata: {result_count: '1'},
},
};
},
},
{
method: 'POST',
route: '/admin/users/:user_id/store-purchases/refresh',
async prepare({harness}) {
const target = await createTestAccount(harness);
return {
request: {path: `/admin/users/${target.userId}/store-purchases/refresh`},
expected: {
action: 'refresh_store_purchases',
targetType: 'user',
targetId: target.userId,
metadata: {purchase_count: '0'},
},
};
},
},
];
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
import {StoreBillingAdminAuditCases} from '@app/api/admin/tests/audit_coverage/StoreBillingAdminAuditCases';
describeAdminAuditCoverage('StoreBillingAdminController', StoreBillingAdminAuditCases);
@@ -29,6 +29,7 @@ import {ReadStateController} from '@app/api/read_state/ReadStateController';
import {ReportController} from '@app/api/report/ReportController';
import {InternalRpcController} from '@app/api/rpc/InternalRpcController';
import {SearchController} from '@app/api/search/controllers/SearchController';
import {StoreBillingController} from '@app/api/store_billing/StoreBillingController';
import {StripeController} from '@app/api/stripe/StripeController';
import {TestHarnessController} from '@app/api/test/TestHarnessController';
import {ThemeController} from '@app/api/theme/ThemeController';
@@ -69,6 +70,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
}
UserController(routes);
installSmsWebhookForwarder(routes, getActivityJetStream);
StoreBillingController(routes);
WebhookController(routes);
OAuth2Controller(routes);
OAuth2ApplicationsController(routes);
+8 -3
View File
@@ -168,12 +168,17 @@ export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams
return false;
}
type CredentialTransport = 'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid';
const ALL_CREDENTIAL_TRANSPORTS: Array<CredentialTransport> = ['internal', 'hybrid', 'usb', 'nfc', 'ble'];
function toCredentialDescriptor(credential: WebAuthnCredential) {
return {
id: credential.credentialId,
transports: credential.transports
? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
: undefined,
transports:
credential.transports && credential.transports.size > 0
? (Array.from(credential.transports) as Array<CredentialTransport>)
: ALL_CREDENTIAL_TRANSPORTS,
};
}
+10 -11
View File
@@ -35,6 +35,7 @@ import * as FetchUtils from '@app/api/utils/FetchUtils';
import {isJsonRecord, parseJsonRecord, parseJsonWithGuard} from '@app/api/utils/JsonBoundaryUtils';
import {generateRandomUsername} from '@app/api/utils/UsernameGenerator';
import {deriveUsernameFromDisplayName} from '@app/api/utils/UsernameSuggestionUtils';
import {SSO_MOBILE_CALLBACK_URI, SSO_MOBILE_STATE_PREFIX} from '@fluxer/constants/src/SsoConstants';
import {ProfileFieldPrivacyFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/RegistrationClosedError';
@@ -106,7 +107,6 @@ interface JwksCacheEntry {
const CODE_VERIFIER_BYTE_LENGTH = 32;
const STATE_BYTE_LENGTH = 16;
const NONCE_BYTE_LENGTH = 16;
const MOBILE_SSO_REDIRECT_URI = 'fluxer://auth/sso/callback';
let ssoLogger: ILogger | undefined;
@@ -136,11 +136,10 @@ function buildDiscoveryCacheKey(issuer: string): string {
return `sso:oidc-discovery:${key}`;
}
function resolveSsoRedirectUri(requestedRedirectUri: string | undefined, defaultRedirectUri: string): string {
if (!requestedRedirectUri) return defaultRedirectUri;
const trimmed = requestedRedirectUri.trim();
if (!trimmed) return defaultRedirectUri;
if (trimmed === defaultRedirectUri || trimmed === MOBILE_SSO_REDIRECT_URI) return trimmed;
function isMobileSsoRedirectUri(requestedRedirectUri: string | undefined, defaultRedirectUri: string): boolean {
const trimmed = requestedRedirectUri?.trim();
if (!trimmed || trimmed === defaultRedirectUri) return false;
if (trimmed === SSO_MOBILE_CALLBACK_URI) return true;
throw InputValidationError.fromCode('redirect_uri', ValidationErrorCodes.INVALID_URL_FORMAT);
}
@@ -285,16 +284,16 @@ export class SsoService {
redirect_uri: string;
}> {
const config = await this.requireReadyConfig();
const state = randomHexToken(STATE_BYTE_LENGTH);
const isMobile = isMobileSsoRedirectUri(redirectUri, config.redirectUri);
const state = `${isMobile ? SSO_MOBILE_STATE_PREFIX : ''}${randomHexToken(STATE_BYTE_LENGTH)}`;
const codeVerifier = randomBase64UrlToken(CODE_VERIFIER_BYTE_LENGTH);
const codeChallenge = buildCodeChallenge(codeVerifier);
const nonce = randomBase64UrlToken(NONCE_BYTE_LENGTH);
const ssoRedirectUri = resolveSsoRedirectUri(redirectUri, config.redirectUri);
const statePayload: SsoStatePayload = {
codeVerifier,
nonce,
redirectTo: sanitizeSsoRedirectTo(redirectTo),
redirectUri: ssoRedirectUri,
redirectUri: config.redirectUri,
createdAt: Date.now(),
};
const {cache} = this.apiContext.services;
@@ -302,7 +301,7 @@ export class SsoService {
const searchParams = new URLSearchParams({
response_type: 'code',
client_id: config.clientId ?? '',
redirect_uri: ssoRedirectUri,
redirect_uri: config.redirectUri,
scope: config.scope,
state,
code_challenge: codeChallenge,
@@ -324,7 +323,7 @@ export class SsoService {
throw new FeatureTemporarilyDisabledError();
}
}
return {authorization_url: authorizationUrlString, state, redirect_uri: ssoRedirectUri};
return {authorization_url: authorizationUrlString, state, redirect_uri: config.redirectUri};
}
async completeLogin({code, state, request}: {code: string; state: string; request: Request}): Promise<{
@@ -131,6 +131,7 @@ describe('Auth SSO flow', () => {
.body({redirect_to: '/me'})
.execute();
expect(startData.state).toBeTruthy();
expect(startData.state.startsWith('m.')).toBe(false);
expect(startData.authorization_url).toBeTruthy();
const authUrlString = startData.authorization_url;
expect(authUrlString).toContain(`state=${startData.state}`);
@@ -179,7 +180,8 @@ describe('Auth SSO flow', () => {
expect(startData.redirect_uri).not.toContain('evil.example');
expect(startData.authorization_url).toContain(encodeURIComponent(startData.redirect_uri));
});
it('uses the requested mobile SSO redirect URI without changing the post-login redirect', async () => {
it('routes mobile SSO through the default redirect URI without changing the post-login redirect', async () => {
const status = await createBuilderWithoutAuth<{redirect_uri: string}>(harness).get('/auth/sso/status').execute();
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({
@@ -187,8 +189,10 @@ describe('Auth SSO flow', () => {
redirect_uri: 'fluxer://auth/sso/callback',
})
.execute();
expect(startData.redirect_uri).toBe('fluxer://auth/sso/callback');
expect(getAuthorizationUrlParam(startData.authorization_url, 'redirect_uri')).toBe('fluxer://auth/sso/callback');
expect(startData.redirect_uri).toBe(status.redirect_uri);
expect(getAuthorizationUrlParam(startData.authorization_url, 'redirect_uri')).toBe(status.redirect_uri);
expect(startData.state.startsWith('m.')).toBe(true);
expect(getAuthorizationUrlParam(startData.authorization_url, 'state')).toBe(startData.state);
const email = `sso-mobile-redirect-${Date.now()}@example.com`;
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
@@ -65,6 +65,7 @@ describe('WebAuthn MFA login', () => {
expect(mfaOptions.userVerification).toBe('discouraged');
expect(mfaOptions.allowCredentials).toBeTruthy();
expect(mfaOptions.allowCredentials!.length).toBeGreaterThan(0);
expect(mfaOptions.allowCredentials![0]!.transports).toEqual(['internal']);
if (mfaOptions.rpId) {
device.rpId = mfaOptions.rpId;
}
@@ -87,6 +88,48 @@ describe('WebAuthn MFA login', () => {
.execute();
expect(userInfo.id).toBe(account.userId);
});
it('offers every transport for a passkey registered without transports', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
device.transports = null;
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
await registerWebAuthnCredential(harness, account.token, device, () => ({
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
}));
await setWebAuthnTwoFactor(harness, account.token, true, {
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
});
const loginResp = (await loginUser(harness, {
email: account.email,
password: account.password,
})) as LoginMfaResponse;
const mfaOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/login/mfa/webauthn/authentication-options')
.body({ticket: loginResp.ticket})
.execute();
expect(mfaOptions.allowCredentials).toEqual([
{
id: device.credentialId.toString('base64url'),
type: 'public-key',
transports: ['internal', 'hybrid', 'usb', 'nfc', 'ble'],
},
]);
const webauthnMfaLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/webauthn')
.body({
response: createAuthenticationResponse(device, mfaOptions),
challenge: mfaOptions.challenge,
ticket: loginResp.ticket,
})
.execute();
expect(webauthnMfaLogin.token).toBeTruthy();
});
it('issues a session token instead of an MFA ticket when passkey two-factor is left off', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
@@ -21,6 +21,7 @@ export interface WebAuthnDevice {
rpId: string;
origin: string;
signCount: number;
transports?: Array<string> | null;
}
export interface WebAuthnRegistrationOptions {
@@ -47,6 +48,7 @@ export interface WebAuthnAuthenticationOptions {
allowCredentials?: Array<{
id: string;
type: string;
transports?: Array<string>;
}>;
userVerification: string;
}
@@ -75,7 +77,7 @@ export interface WebAuthnTwoFactorResult {
interface AuthenticatorAttestationResponse {
clientDataJSON: string;
attestationObject: string;
transports: Array<string>;
transports?: Array<string>;
}
interface AuthenticatorAssertionResponse {
@@ -363,7 +365,7 @@ export function createRegistrationResponse(
response: {
clientDataJSON: encodeBase64URL(clientDataJSON),
attestationObject: encodeBase64URL(attestationObject),
transports: ['internal'],
...(device.transports === null ? {} : {transports: device.transports ?? ['internal']}),
},
};
}
-80
View File
@@ -1,80 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
const VERDICT_CACHE_TTL_MS = 60 * 60 * 1000;
interface IpBanBlastRadiusVerdict {
cgnat: boolean;
sharedAccess: boolean;
}
interface CachedVerdict {
expiresAtMs: number;
verdict: IpBanBlastRadiusVerdict;
}
const verdictCache = new Map<string, CachedVerdict>();
function isAnonymousAccess(result: IpInfoLookupResult): boolean {
return (
result.anonymous.isAnonymous ||
result.anonymous.isVpn ||
result.anonymous.isProxy ||
result.anonymous.isResidentialProxy ||
result.anonymous.isTor ||
result.anonymous.isRelay
);
}
export function isHighCgnatBlastRadiusRisk(result: IpInfoLookupResult): boolean {
if (!result.available || result.flags.isHosting || isAnonymousAccess(result)) {
return false;
}
const asnType = result.asn.type?.trim().toLowerCase() ?? null;
return result.flags.isMobile || result.mobile.name !== null || asnType === 'mobile';
}
export function isHighSharedAccessBlastRadiusRisk(result: IpInfoLookupResult): boolean {
if (result.flags.isHosting || isAnonymousAccess(result)) {
return false;
}
const asnType = result.asn.type?.trim().toLowerCase() ?? null;
return result.flags.isAnycast || result.flags.isSatellite || asnType === 'education';
}
export function isSingleIpBanCandidate(value: string): boolean {
return parseIpBanEntry(value)?.type === 'single';
}
export async function getIpBanBlastRadiusVerdict(
ip: string,
ipInfoService: IpInfoService,
context: {
source: string;
reason: string;
},
): Promise<IpBanBlastRadiusVerdict> {
const now = Date.now();
const cacheKey = getSameIpDecisionKey(ip) ?? ip;
const cached = verdictCache.get(cacheKey);
if (cached && cached.expiresAtMs > now) {
return cached.verdict;
}
const result = await ipInfoService.lookup(ip, {
source: context.source,
reason: context.reason,
metadata: {policy: 'ip_ban_cgnat_guard'},
});
const verdict: IpBanBlastRadiusVerdict = {
cgnat: isHighCgnatBlastRadiusRisk(result),
sharedAccess: isHighSharedAccessBlastRadiusRisk(result),
};
verdictCache.set(cacheKey, {
verdict,
expiresAtMs: now + VERDICT_CACHE_TTL_MS,
});
return verdict;
}
@@ -1,45 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {Logger} from '@app/api/Logger';
import {getDefaultCassandraClient} from '@pkgs/cassandra/src/Client';
import {createCassandraIpInfoCache} from '@pkgs/geoip/src/CassandraIpInfoCache';
import {createCassandraIpInfoRequestAuditLogger} from '@pkgs/geoip/src/CassandraIpInfoRequestAudit';
import {type IpInfoCache, type IpInfoRequestAuditLogger, isCachedIpInfoFailure} from '@pkgs/geoip/src/IpInfoService';
import {createPostgresIpInfoCache, createPostgresIpInfoRequestAuditLogger} from '@pkgs/geoip/src/PostgresIpInfoKv';
import {createTieredIpInfoCache} from '@pkgs/geoip/src/TieredIpInfoCache';
import {getDefaultPostgresClient} from '@pkgs/postgres/src/Client';
interface BuildIpInfoCacheOptions {
hot: IpInfoCache;
}
export function buildIpInfoCache(options: BuildIpInfoCacheOptions): IpInfoCache {
if (Config.database.backend === 'postgres') {
return createTieredIpInfoCache({
hot: options.hot,
cold: createPostgresIpInfoCache({
getClient: getDefaultPostgresClient,
onError: (error, operation) => Logger.warn({error, operation}, 'Postgres IPInfo cache operation failed'),
}),
skipColdWrite: isCachedIpInfoFailure,
});
}
return createTieredIpInfoCache({
hot: options.hot,
cold: createCassandraIpInfoCache({getClient: getDefaultCassandraClient}),
skipColdWrite: isCachedIpInfoFailure,
});
}
export function buildIpInfoRequestAuditLogger(): IpInfoRequestAuditLogger {
if (Config.database.backend === 'postgres') {
return createPostgresIpInfoRequestAuditLogger({
getClient: getDefaultPostgresClient,
onError: (error, operation) => Logger.warn({error, operation}, 'Postgres IPInfo audit operation failed'),
});
}
return createCassandraIpInfoRequestAuditLogger({
getClient: getDefaultCassandraClient,
});
}
@@ -1,162 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
isHighCgnatBlastRadiusRisk,
isHighSharedAccessBlastRadiusRisk,
isSingleIpBanCandidate,
} from '@app/api/ban/IpBanCgnatGuard';
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
import {describe, expect, it} from 'vitest';
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
return {
ip: '198.51.100.1',
available: true,
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
continent: 'North America',
continentCode: 'NA',
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
},
asn: {
asn: 'AS64500',
number: 64500,
name: 'Test ISP',
domain: null,
type: null,
},
mobile: {
name: null,
mcc: null,
mnc: null,
},
anonymous: {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {
isAnycast: false,
isHosting: false,
isMobile: false,
isSatellite: false,
},
...overrides,
};
}
describe('IpBanCgnatGuard', () => {
it('only treats single IP ban entries as CGNAT guard candidates', () => {
expect(isSingleIpBanCandidate('198.51.100.10')).toBe(true);
expect(isSingleIpBanCandidate('198.51.100.0/24')).toBe(false);
});
it('flags mobile carrier IPs as high blast-radius risk', () => {
expect(
isHighCgnatBlastRadiusRisk(
ipInfoResult({
mobile: {name: 'Example Mobile', mcc: '001', mnc: '01'},
flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false},
}),
),
).toBe(true);
});
it('does not exempt hosting or anonymous infrastructure', () => {
expect(
isHighCgnatBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: false, isHosting: true, isMobile: true, isSatellite: false},
}),
),
).toBe(false);
expect(
isHighCgnatBlastRadiusRisk(
ipInfoResult({
anonymous: {
isAnonymous: true,
providerName: 'Example VPN',
isVpn: true,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false},
}),
),
).toBe(false);
});
it('flags satellite, anycast and education networks as high blast-radius risk', () => {
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
}),
),
).toBe(true);
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: true, isHosting: false, isMobile: false, isSatellite: false},
}),
),
).toBe(true);
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({asn: {asn: 'AS64500', number: 64500, name: 'Test University', domain: null, type: 'education'}}),
),
).toBe(true);
});
it('does not flag ordinary residential networks as shared-access risk', () => {
expect(isHighSharedAccessBlastRadiusRisk(ipInfoResult())).toBe(false);
});
it('does not treat shared-access networks as CGNAT risk', () => {
expect(
isHighCgnatBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
}),
),
).toBe(false);
});
it('does not exempt hosting or anonymous shared-access infrastructure', () => {
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: true, isHosting: true, isMobile: false, isSatellite: false},
}),
),
).toBe(false);
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
anonymous: {
isAnonymous: true,
providerName: 'Example VPN',
isVpn: true,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
}),
),
).toBe(false);
});
});
@@ -1,210 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {server} from '@app/api/test/msw/server';
import type {
CachedIpInfoFailure,
IpInfoCache,
IpInfoRequestAuditEvent,
IpInfoRequestAuditLogger,
} from '@pkgs/geoip/src/IpInfoService';
import {createIpInfoService} from '@pkgs/geoip/src/IpInfoService';
import {delay, HttpResponse, http} from 'msw';
import {describe, expect, it} from 'vitest';
interface RecordedSet {
key: string;
value: unknown;
ttlSeconds: number | undefined;
}
interface RecordingCache {
cache: IpInfoCache;
sets: Array<RecordedSet>;
}
function createRecordingCache(): RecordingCache {
const store = new Map<string, unknown>();
const sets: Array<RecordedSet> = [];
return {
sets,
cache: {
async get<T>(key: string): Promise<T | null> {
return (store.get(key) as T | undefined) ?? null;
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
store.set(key, value);
sets.push({key, value, ttlSeconds});
},
},
};
}
function createRecordingAuditLogger(): {logger: IpInfoRequestAuditLogger; events: Array<IpInfoRequestAuditEvent>} {
const events: Array<IpInfoRequestAuditEvent> = [];
return {
events,
logger: {
async record(event: IpInfoRequestAuditEvent): Promise<void> {
events.push(event);
},
},
};
}
function useLookupHandler(handler: () => Response | Promise<Response>): {count: () => number} {
let calls = 0;
server.use(
http.get('https://api.ipinfo.io/lookup/:ip', async () => {
calls += 1;
return await handler();
}),
);
return {count: () => calls};
}
function successPayload(ip: string, anonymous: Record<string, boolean> = {}): Response {
return HttpResponse.json({
ip,
geo: {country_code: 'US', country: 'United States'},
as: {asn: 'AS64500', name: 'Test ISP'},
anonymous,
});
}
describe('IpInfoService caching', () => {
it('negative-caches an HTTP error and serves the second lookup without a request', async () => {
const requests = useLookupHandler(() => new HttpResponse(null, {status: 500}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
const first = await service.lookup('203.0.113.1');
const second = await service.lookup('203.0.113.1');
expect(first.available).toBe(false);
expect(second.available).toBe(false);
expect(requests.count()).toBe(1);
expect(sets).toHaveLength(1);
expect(sets[0]?.ttlSeconds).toBe(300);
});
it('negative-caches a request failure for a short window', async () => {
useLookupHandler(async () => {
await delay(5000);
return successPayload('203.0.113.2');
});
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
const result = await service.lookup('203.0.113.2');
expect(result.available).toBe(false);
expect(sets[0]?.ttlSeconds).toBe(60);
expect((sets[0]?.value as CachedIpInfoFailure)?.failureOutcome).toBe('request_failed');
});
it('negative-caches a schema mismatch', async () => {
useLookupHandler(() => HttpResponse.json({}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
const result = await service.lookup('203.0.113.3');
expect(result.available).toBe(false);
expect(sets[0]?.ttlSeconds).toBe(600);
expect((sets[0]?.value as CachedIpInfoFailure)?.failureOutcome).toBe('schema_mismatch');
expect((sets[0]?.value as CachedIpInfoFailure)?.failureHttpStatus).toBe(200);
});
it('negative-caches a quota rejection for longer', async () => {
useLookupHandler(() => new HttpResponse(null, {status: 429}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
await service.lookup('203.0.113.4');
expect(sets[0]?.ttlSeconds).toBe(900);
});
it('returns a cached failure as a clean unavailable result', async () => {
useLookupHandler(() => new HttpResponse(null, {status: 500}));
const {cache} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
await service.lookup('203.0.113.6');
const cached = await service.lookup('203.0.113.6');
expect(cached).not.toHaveProperty('cachedFailure');
expect(cached).not.toHaveProperty('failureOutcome');
expect(cached).not.toHaveProperty('failureHttpStatus');
expect(cached).not.toHaveProperty('cachedAtMs');
expect(cached.ip).toBe('203.0.113.6');
expect(cached.note).toBe('IPInfo HTTP 500');
});
it('writes a cached failure that older readers can still consume', async () => {
useLookupHandler(() => new HttpResponse(null, {status: 500}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
await service.lookup('203.0.113.7');
const entry = sets[0]?.value as CachedIpInfoFailure;
expect(entry.cachedFailure).toBe(true);
expect(entry.failureOutcome).toBe('http_error');
expect(entry.failureHttpStatus).toBe(500);
expect(typeof entry.cachedAtMs).toBe('number');
const legacyView = {...entry, ip: '203.0.113.7'};
expect(legacyView.available).toBe(false);
expect(legacyView.geo.countryCode).toBeNull();
expect(legacyView.asn.number).toBeNull();
expect(legacyView.mobile.name).toBeNull();
expect(legacyView.anonymous.isAnonymous).toBe(false);
expect(legacyView.flags.isMobile).toBe(false);
});
it('keeps the existing success TTL selection', async () => {
useLookupHandler(() => successPayload('203.0.113.8'));
const plain = createRecordingCache();
await createIpInfoService({apiKey: 'token', cache: plain.cache}).lookup('203.0.113.8');
useLookupHandler(() => successPayload('203.0.113.9', {is_vpn: true}));
const anonymous = createRecordingCache();
await createIpInfoService({apiKey: 'token', cache: anonymous.cache}).lookup('203.0.113.9');
expect(plain.sets[0]?.ttlSeconds).toBe(14 * 24 * 60 * 60);
expect(anonymous.sets[0]?.ttlSeconds).toBe(7 * 24 * 60 * 60);
});
it('coalesces concurrent lookups across a failure', async () => {
const requests = useLookupHandler(() => new HttpResponse(null, {status: 500}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
const [first, second] = await Promise.all([service.lookup('203.0.113.10'), service.lookup('203.0.113.10')]);
expect(requests.count()).toBe(1);
expect(sets).toHaveLength(1);
expect(first.available).toBe(false);
expect(second.available).toBe(false);
});
it('coalesces concurrent lookups from different sources into one audited request', async () => {
const requests = useLookupHandler(() => successPayload('203.0.113.13'));
const {cache} = createRecordingCache();
const {logger, events} = createRecordingAuditLogger();
const service = createIpInfoService({apiKey: 'token', cache, auditLogger: logger});
const results = await Promise.all([
service.lookup('203.0.113.13', {source: 'admin.ip_ban', reason: 'ban'}),
service.lookup('203.0.113.13', {source: 'test.b'}),
service.lookup('203.0.113.13', {source: 'test.c'}),
]);
expect(requests.count()).toBe(1);
expect(results.every((result) => result.available)).toBe(true);
expect(events).toHaveLength(1);
expect(events[0]?.source).toBe('admin.ip_ban');
expect(events[0]?.outcome).toBe('http_success');
expect(events[0]?.note).toBe('IPInfo: IP is not anonymous');
});
});
@@ -1,75 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IpInfoRequestAuditEvent} from '@pkgs/geoip/src/IpInfoService';
import {
createPostgresIpInfoCache,
createPostgresIpInfoRequestAuditLogger,
IPINFO_CACHE_TTL_SECONDS,
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
} from '@pkgs/geoip/src/PostgresIpInfoKv';
import type {IPostgresClient} from '@pkgs/postgres/src/Client';
import {describe, expect, it} from 'vitest';
function recordingClient(writes: Array<Array<unknown>>): IPostgresClient {
return {
async query(_text: string, values?: Array<unknown>) {
writes.push(values ?? []);
return {rows: [], rowCount: 1};
},
kvTable() {
return 'kv';
},
} as never;
}
function expectExpiresIn(values: Array<unknown> | undefined, ttlSeconds: number): void {
const expiresAt = values?.[4];
expect(expiresAt).toBeInstanceOf(Date);
const remainingSeconds = ((expiresAt as Date).getTime() - Date.now()) / 1000;
expect(remainingSeconds).toBeGreaterThan(ttlSeconds - 10);
expect(remainingSeconds).toBeLessThanOrEqual(ttlSeconds);
}
const EVENT: IpInfoRequestAuditEvent = {
requestedAt: new Date('2026-09-21T12:00:00.000Z'),
ip: '192.0.2.1',
cacheKey: 'ip:192.0.2.1',
source: 'test',
reason: null,
outcome: 'http_success',
httpStatus: 200,
available: true,
note: 'none',
latencyMs: 12,
requestUrl: 'https://ipinfo.test/192.0.2.1',
responseIp: '192.0.2.1',
countryCode: 'SE',
asnNumber: 64500,
isAnonymous: false,
isTor: false,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
};
describe('Postgres ipinfo KV expiry', () => {
it('expires request audit rows after 90 days', async () => {
const writes: Array<Array<unknown>> = [];
await createPostgresIpInfoRequestAuditLogger({client: recordingClient(writes)}).record(EVENT);
expect(writes).toHaveLength(1);
expect(writes[0]?.[0]).toBe('ipinfo_requests_by_hour');
expectExpiresIn(writes[0], IPINFO_REQUEST_AUDIT_TTL_SECONDS);
});
it('falls back to the 14-day cache default', async () => {
const writes: Array<Array<unknown>> = [];
const cache = createPostgresIpInfoCache({client: recordingClient(writes)});
await cache.set('fallback', {ok: true});
await cache.set('zero', {ok: true}, 0);
await cache.set('short', {ok: true}, 60);
expect(writes.map((values) => values[0])).toEqual(['ipinfo_cache', 'ipinfo_cache', 'ipinfo_cache']);
expectExpiresIn(writes[0], IPINFO_CACHE_TTL_SECONDS);
expectExpiresIn(writes[1], IPINFO_CACHE_TTL_SECONDS);
expectExpiresIn(writes[2], 60);
});
});
@@ -1,130 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
import {createTieredIpInfoCache} from '@pkgs/geoip/src/TieredIpInfoCache';
import {describe, expect, it} from 'vitest';
interface RecordedSet {
key: string;
value: unknown;
ttlSeconds: number | undefined;
}
interface RecordingCache {
cache: IpInfoCache;
store: Map<string, unknown>;
sets: Array<RecordedSet>;
}
function createRecordingCache(): RecordingCache {
const store = new Map<string, unknown>();
const sets: Array<RecordedSet> = [];
return {
store,
sets,
cache: {
async get<T>(key: string): Promise<T | null> {
return (store.get(key) as T | undefined) ?? null;
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
store.set(key, value);
sets.push({key, value, ttlSeconds});
},
},
};
}
describe('TieredIpInfoCache', () => {
it('clamps the hot TTL to the requested TTL and passes the raw TTL to the cold tier', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
await tiered.set('a', {available: false}, 60);
expect(hot.sets).toEqual([{key: 'a', value: {available: false}, ttlSeconds: 60}]);
expect(cold.sets).toEqual([{key: 'a', value: {available: false}, ttlSeconds: 60}]);
});
it('caps the hot TTL at the configured hot window', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
await tiered.set('a', {available: true}, 100000);
expect(hot.sets[0]?.ttlSeconds).toBe(600);
expect(cold.sets[0]?.ttlSeconds).toBe(100000);
});
it('uses the hot window when no TTL is supplied', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
await tiered.set('a', {available: true});
expect(hot.sets[0]?.ttlSeconds).toBe(600);
expect(cold.sets[0]?.ttlSeconds).toBeUndefined();
});
it('skips the cold write when skipColdWrite matches', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({
hot: hot.cache,
cold: cold.cache,
skipColdWrite: (value) => (value as {available?: unknown}).available === false,
});
await tiered.set('a', {available: false}, 60);
await tiered.set('b', {available: true}, 60);
expect(hot.sets.map((entry) => entry.key)).toEqual(['a', 'b']);
expect(cold.sets.map((entry) => entry.key)).toEqual(['b']);
});
it('promotes a cold hit into the hot tier', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
cold.store.set('a', {available: true});
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
const hit = await tiered.get('a');
expect(hit).toEqual({available: true});
expect(hot.sets).toEqual([{key: 'a', value: {available: true}, ttlSeconds: 600}]);
});
it('never promotes a cold hit that skipColdWrite matches', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
cold.store.set('a', {available: false});
const tiered = createTieredIpInfoCache({
hot: hot.cache,
cold: cold.cache,
skipColdWrite: (value) => (value as {available?: unknown}).available === false,
});
const hit = await tiered.get('a');
expect(hit).toEqual({available: false});
expect(hot.sets).toEqual([]);
});
it('never writes a zero TTL', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
await tiered.set('a', {available: false}, 60);
await tiered.set('b', {available: true}, 100000);
await tiered.set('c', {available: true});
cold.store.set('d', {available: true});
await tiered.get('d');
for (const entry of [...hot.sets, ...cold.sets]) {
expect(entry.ttlSeconds === undefined || entry.ttlSeconds > 0).toBe(true);
}
});
});
@@ -9,6 +9,7 @@ import {
deleteChannel,
getChannel,
updateChannel,
updateGuild,
} from '@app/api/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
@@ -58,6 +59,34 @@ describe('Channel Operation Permissions', () => {
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
it('should gate channels created before a guild becomes adult-only', async () => {
const owner = await createTestAccount(harness);
const minor = await createTestAccount(harness, {dateOfBirth: '2010-01-01'});
const guild = await createGuild(harness, owner.token, 'Later Mature Guild');
const category = await createChannel(harness, owner.token, guild.id, 'category', 4);
const child = await createBuilder<{id: string; nsfw_override?: boolean | null}>(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'child', type: 0, parent_id: category.id})
.execute();
const opened = await createBuilder<{id: string}>(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'opened', type: 0, nsfw_override: false})
.execute();
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
expect(systemChannel.nsfw_override ?? null).toBeNull();
expect(category.nsfw_override ?? null).toBeNull();
expect(child.nsfw_override ?? null).toBeNull();
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
await acceptInvite(harness, minor.token, invite.code);
await updateGuild(harness, owner.token, guild.id, {nsfw: true});
for (const channelId of [systemChannel.id, child.id]) {
await createBuilder(harness, minor.token)
.get(`/channels/${channelId}/messages`)
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
}
await createBuilder(harness, minor.token).get(`/channels/${opened.id}/messages`).expect(HTTP_STATUS.OK).execute();
});
it('should reject member from updating channel without MANAGE_CHANNELS', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
+31 -4
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {CachePurgeAdapterName} from '@fluxer/config/src/MasterConfig';
import type {CachePurgeAdapterName, StoreProductSlotName} from '@fluxer/config/src/MasterConfig';
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
export type APIWorkerLaneName = 'realtime' | 'unfurl' | 'lifecycle' | 'batch';
@@ -13,6 +13,11 @@ export interface PushProviderAppConfig {
environment?: PushProviderEnvironment;
}
export interface AppStoreAppConfig {
bundleId: string;
appAppleId: number;
}
export interface APICachePurgeConfig {
adapter: CachePurgeAdapterName;
http: {
@@ -159,9 +164,6 @@ export interface APIConfig {
secure: boolean;
};
};
ipinfo: {
apiKey?: string;
};
blocklistFeeds: {
enabled: boolean;
};
@@ -305,6 +307,31 @@ export interface APIConfig {
apps: Array<PushProviderAppConfig>;
};
};
appStore: {
enabled: boolean;
issuerId?: string;
keyId?: string;
privateKey?: string;
privateKeyPath?: string;
apps: Array<AppStoreAppConfig>;
products: Record<string, StoreProductSlotName>;
};
googlePlay: {
enabled: boolean;
packages: Array<string>;
clientEmail?: string;
privateKey?: string;
privateKeyPath?: string;
serviceAccountJsonPath?: string;
tokenUri: string;
products: Record<string, StoreProductSlotName>;
pushAudience?: string;
pushServiceAccountEmail?: string;
};
storeBilling: {
sandboxUserIds: Array<string>;
sandboxEntitlesAll: boolean;
};
worker: {
mode: APIWorkerMode;
laneName?: APIWorkerLaneName;
@@ -6,7 +6,6 @@ import {fileURLToPath} from 'node:url';
import {DEFAULT_TTL_TABLES} from '@app/api/database/PostgresKvDefaultTtlExpiry';
import * as DonationTables from '@app/api/donation/DonationTables';
import * as Tables from '@app/api/Tables';
import {IPINFO_CACHE_TTL_SECONDS, IPINFO_REQUEST_AUDIT_TTL_SECONDS} from '@pkgs/geoip/src/PostgresIpInfoKv';
import {describe, expect, it} from 'vitest';
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -32,8 +31,6 @@ const DSL_TABLES = [...Object.values(Tables), ...Object.values(DonationTables)];
const DSL_NAMES = new Set<string>(DSL_TABLES.map((table) => table.name));
const NON_DSL_DEFAULTS: Record<string, number | null> = {
ipinfo_cache: IPINFO_CACHE_TTL_SECONDS,
ipinfo_requests_by_hour: IPINFO_REQUEST_AUDIT_TTL_SECONDS,
billing_webhook_events: null,
forensic_identifier_by_key_day: null,
forensic_identifier_by_request: null,
@@ -333,7 +333,7 @@ RETURNING updated_at::text`,
await seed('attachment_upload_traces_by_key', 'at-29', '29 days');
await seed('oauth2_access_tokens', 'oa-8', '8 days');
await seed('donor_magic_link_tokens', 'dm-hour', '1 hour');
await seed('ipinfo_requests_by_hour', 'ip-day', '1 day');
await seed('push_subscriptions', 'ps-day', '1 day');
await seed('jobs_by_id', 'job', '100 days');
await seed('users', 'user', '100 days');
await seed('recent_mentions', 'rm-forever', '1 day', 'infinity');
@@ -346,8 +346,8 @@ RETURNING updated_at::text`,
});
expect(await remaining()).toEqual([
{table_name: 'attachment_upload_traces_by_key', row_key: 'at-29'},
{table_name: 'ipinfo_requests_by_hour', row_key: 'ip-day'},
{table_name: 'jobs_by_id', row_key: 'job'},
{table_name: 'push_subscriptions', row_key: 'ps-day'},
{table_name: 'recent_mentions', row_key: 'rm-day'},
{table_name: 'recent_mentions', row_key: 'rm-forever'},
{table_name: 'recent_mentions', row_key: 'rm-hour'},
@@ -359,13 +359,13 @@ RETURNING updated_at::text`,
expires_at = updated_at + CASE table_name WHEN 'recent_mentions' THEN interval '7 days' WHEN 'attachment_upload_traces_by_key' THEN interval '30 days' ELSE interval '90 days' END AS exact,
CASE WHEN row_key = 'rm-day' THEN updated_at = $1::timestamptz END AS unchanged
FROM ${KV_TABLE}
WHERE row_key IN ('rm-day', 'at-29', 'ip-day')
WHERE row_key IN ('rm-day', 'at-29', 'ps-day')
ORDER BY row_key`,
[mentionWrittenAt],
);
expect(exact.rows).toEqual([
{row_key: 'at-29', exact: true, unchanged: null},
{row_key: 'ip-day', exact: true, unchanged: null},
{row_key: 'ps-day', exact: true, unchanged: null},
{row_key: 'rm-day', exact: true, unchanged: true},
]);
const untouched = await raw.query<{row_key: string; state: string}>(
@@ -449,18 +449,18 @@ FROM generate_series(1, 2300) g`,
});
it('saves where a run stopped and starts the next run there', async () => {
const first = DEFAULT_TTL_TABLES[0]!.name;
const last = DEFAULT_TTL_TABLES.at(-1)!.name;
await seed(first, 'a', '1 hour');
await seed(first, 'z', '1 hour');
await seed(last, 'k', '1 hour');
const first = DEFAULT_TTL_TABLES[0]!;
const last = DEFAULT_TTL_TABLES.at(-1)!;
await seed(first.name, 'a', `${first.defaultTtlSeconds / 2} seconds`);
await seed(first.name, 'z', `${first.defaultTtlSeconds / 2} seconds`);
await seed(last.name, 'k', `${last.defaultTtlSeconds / 2} seconds`);
expect(await expireLegacyDefaultTtlRows(raw, Date.now() - 1)).toEqual({deleted: 0, expiring: 0, complete: false});
expect(await resumePoint()).toEqual({table: first, row_key: '', unset: 0});
expect(await resumePoint()).toEqual({table: first.name, row_key: '', unset: 0});
await raw.query(
`UPDATE ${KV_TABLE} SET row_data = jsonb_build_object('table', $1::text, 'row_key', 'm', 'unset', 0) WHERE table_name = '__fluxer_schema_migrations' AND row_key = $2`,
[first, DEFAULT_TTL_EXPIRY_RESUME],
[first.name, DEFAULT_TTL_EXPIRY_RESUME],
);
expect(await expireLegacyDefaultTtlRows(raw, Date.now() + 60_000)).toEqual({
deleted: 0,
@@ -469,7 +469,7 @@ FROM generate_series(1, 2300) g`,
});
const untouched = await raw.query<{expires_at: Date | null}>(
`SELECT expires_at FROM ${KV_TABLE} WHERE table_name = $1 AND row_key = 'a'`,
[first],
[first.name],
);
expect(untouched.rows).toEqual([{expires_at: null}]);
expect(await resumePoint()).toBeNull();
@@ -7,7 +7,6 @@ import {
} from '@app/api/database/PostgresKvQueryExecutor';
import * as DonationTables from '@app/api/donation/DonationTables';
import * as Tables from '@app/api/Tables';
import {IPINFO_CACHE_TTL_SECONDS, IPINFO_REQUEST_AUDIT_TTL_SECONDS} from '@pkgs/geoip/src/PostgresIpInfoKv';
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
import {ms} from 'itty-time';
@@ -23,8 +22,6 @@ export const DEFAULT_TTL_TABLES: ReadonlyArray<{name: string; defaultTtlSeconds:
? []
: [{name: table.name, defaultTtlSeconds: table.defaultTtlSeconds}],
),
{name: 'ipinfo_cache', defaultTtlSeconds: IPINFO_CACHE_TTL_SECONDS},
{name: 'ipinfo_requests_by_hour', defaultTtlSeconds: IPINFO_REQUEST_AUDIT_TTL_SECONDS},
];
export interface LegacyDefaultTtlExpiryResult {
@@ -18,6 +18,7 @@ export interface GiftCodeRow {
visionary_sequence_number: Nullish<number>;
checkout_session_id: Nullish<string>;
revoked_at?: Nullish<Date>;
premium_reversed_seconds?: Nullish<number>;
version: number;
}
@@ -105,6 +106,7 @@ export const GIFT_CODE_COLUMNS = [
'visionary_sequence_number',
'checkout_session_id',
'revoked_at',
'premium_reversed_seconds',
'version',
] as const;
export const GIFT_CODE_BY_CREATOR_COLUMNS = ['created_by_user_id', 'code'] as const;
@@ -0,0 +1,124 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import type {
StoreEnvironment,
StoreProvider,
StorePurchaseKind,
StorePurchaseState,
StoreSlot,
} from '@app/api/store_billing/StoreBillingTypes';
type Nullish<T> = T | null;
export interface StorePurchaseRow {
store_key: string;
id: bigint;
provider: StoreProvider;
kind: StorePurchaseKind;
slot: StoreSlot;
environment: StoreEnvironment;
app_id: string;
product_id: string;
base_plan_id: Nullish<string>;
store_reference: string;
latest_transaction_id: Nullish<string>;
app_transaction_id: Nullish<string>;
user_id: Nullish<UserID>;
bound_at: Nullish<Date>;
released_at: Nullish<Date>;
account_token: Nullish<string>;
ownership_type: Nullish<string>;
state: StorePurchaseState;
store_state: Nullish<string>;
entitled: boolean;
expires_at: Nullish<Date>;
grace_ends_at: Nullish<Date>;
auto_renew: Nullish<boolean>;
auto_renew_product_id: Nullish<string>;
started_at: Nullish<Date>;
purchased_at: Nullish<Date>;
revoked_at: Nullish<Date>;
revocation_reason: Nullish<string>;
linked_store_key: Nullish<string>;
superseded_by_store_key: Nullish<string>;
acknowledged: boolean;
gift_code: Nullish<string>;
region: Nullish<string>;
last_event_at: Nullish<Date>;
synced_at: Nullish<Date>;
created_at: Date;
updated_at: Date;
version: number;
}
export interface StorePurchaseByUserRow {
user_id: UserID;
store_key: string;
created_at: Date;
}
export interface StoreAccountTokenRow {
token_: string;
user_id: UserID;
created_at: Date;
}
export interface StoreAccountTokenByUserRow {
user_id: UserID;
token_: string;
created_at: Date;
}
export const STORE_PURCHASE_COLUMNS = [
'store_key',
'id',
'provider',
'kind',
'slot',
'environment',
'app_id',
'product_id',
'base_plan_id',
'store_reference',
'latest_transaction_id',
'app_transaction_id',
'user_id',
'bound_at',
'released_at',
'account_token',
'ownership_type',
'state',
'store_state',
'entitled',
'expires_at',
'grace_ends_at',
'auto_renew',
'auto_renew_product_id',
'started_at',
'purchased_at',
'revoked_at',
'revocation_reason',
'linked_store_key',
'superseded_by_store_key',
'acknowledged',
'gift_code',
'region',
'last_event_at',
'synced_at',
'created_at',
'updated_at',
'version',
] as const satisfies ReadonlyArray<keyof StorePurchaseRow>;
export const STORE_PURCHASE_BY_USER_COLUMNS = ['user_id', 'store_key', 'created_at'] as const satisfies ReadonlyArray<
keyof StorePurchaseByUserRow
>;
export const STORE_ACCOUNT_TOKEN_COLUMNS = ['token_', 'user_id', 'created_at'] as const satisfies ReadonlyArray<
keyof StoreAccountTokenRow
>;
export const STORE_ACCOUNT_TOKEN_BY_USER_COLUMNS = ['user_id', 'token_', 'created_at'] as const satisfies ReadonlyArray<
keyof StoreAccountTokenByUserRow
>;
@@ -3,6 +3,9 @@
import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
import {createGuildID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {requireOAuth2ScopeForBearer} from '@app/api/middleware/OAuth2ScopeMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
@@ -11,6 +14,8 @@ import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {GuildCreationPermissionRequiredError} from '@fluxer/errors/src/domains/guild/GuildCreationPermissionRequiredError';
import {SingleCommunityCannotCreateGuildsError} from '@fluxer/errors/src/domains/guild/SingleCommunityCannotCreateGuildsError';
import {SingleCommunityCannotDeleteError} from '@fluxer/errors/src/domains/guild/SingleCommunityCannotDeleteError';
import {SingleCommunityCannotLeaveError} from '@fluxer/errors/src/domains/guild/SingleCommunityCannotLeaveError';
@@ -40,7 +45,8 @@ export function GuildBaseController(app: HonoApp) {
OpenAPI({
operationId: 'create_guild',
summary: 'Create guild',
description: 'Only claimed, email-verified non-bot users can create guilds.',
description:
'Only claimed, email-verified non-bot users can create guilds. A self-hosted instance can restrict creation to admins and users granted the feature_guild_create limit.',
responseSchema: GuildResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
@@ -56,6 +62,19 @@ export function GuildBaseController(app: HonoApp) {
if (!user.isUnclaimedAccount()) {
requireEmailVerified(user, 'guild_creation');
}
if (Config.instance.selfHosted && !policy.guild_create_access) {
const granted =
user.acls.has(AdminACLs.WILDCARD) ||
resolveLimitSafe(
ctx.get('limitConfigService').getConfigSnapshot(),
createLimitMatchContext({user}),
'feature_guild_create',
0,
) > 0;
if (!granted) {
throw new GuildCreationPermissionRequiredError();
}
}
const auditLogReason = ctx.get('auditLogReason') ?? null;
const locale = ctx.get('requestLocale') ?? null;
return ctx.json(await ctx.get('guildService').data.createGuild({user, data, locale}, auditLogReason));
@@ -24,7 +24,6 @@ import type {JoinSourceType} from '@fluxer/constants/src/GuildConstants';
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildMemberUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
export class GuildMemberService {
@@ -47,11 +46,10 @@ export class GuildMemberService {
rateLimitService: IRateLimitService,
private readonly guildAuditLogService: GuildAuditLogService,
limitConfigService: LimitConfigService,
ipInfoService: IpInfoService,
) {
this.userRepository = userRepository;
this.authService = new GuildMemberAuthService(gatewayService, userRepository);
this.validationService = new GuildMemberValidationService(guildRepository, userRepository, ipInfoService);
this.validationService = new GuildMemberValidationService(guildRepository, userRepository);
this.auditService = new GuildMemberAuditService(guildAuditLogService);
this.eventService = new GuildMemberEventService(gatewayService, userCacheService);
this.searchIndexService = new GuildMemberSearchIndexService();
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID, UserID} from '@app/api/BrandedTypes';
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes';
@@ -27,7 +26,6 @@ import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownG
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {isSameIpDecisionMatch} from '@fluxer/ip_utils/src/IpAddress';
import type {GuildBanResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
const SECONDS_PER_DAY = 86_400;
@@ -42,7 +40,6 @@ export class GuildModerationService {
private readonly userCacheService: UserCacheService,
private readonly workerService: IWorkerService<WorkerTaskName>,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly ipInfoService: IpInfoService,
) {
this.searchIndexService = new GuildMemberSearchIndexService();
}
@@ -218,7 +215,7 @@ export class GuildModerationService {
const userEmail = user?.email?.toLowerCase();
for (const ban of bans) {
if (ban.userId === userId) throw new BannedFromGuildError();
if (isSameIpDecisionMatch(userIp, ban.ipAddress) && (await this.shouldEnforceIpBan(userIp, ban.ipAddress))) {
if (isSameIpDecisionMatch(userIp, ban.ipAddress) && !isIpBanExempt(userIp)) {
throw new IpBannedFromGuildError();
}
}
@@ -228,35 +225,6 @@ export class GuildModerationService {
}
}
private async shouldEnforceIpBan(
userIp: string | null | undefined,
bannedIp: string | null | undefined,
): Promise<boolean> {
if (isIpBanExempt(userIp)) {
return false;
}
if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) {
return true;
}
try {
const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, {
source: 'guild.ip_ban',
reason: 'join_cgnat_guard',
});
const highRisk = cgnat || sharedAccess;
if (highRisk) {
Logger.warn(
{userIp, bannedIp},
'Skipping guild IP ban match because IPInfo indicates high shared-network blast-radius risk',
);
}
return !highRisk;
} catch (error) {
Logger.warn({error, userIp, bannedIp}, 'IPInfo blast-radius guard failed while checking guild IP ban');
return true;
}
}
private serializeBanForAudit(ban: GuildBan): Record<string, unknown> {
return {
user_id: ban.userId.toString(),
@@ -58,7 +58,6 @@ import type {
import type {GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
interface StoredAuditLogWebhookResponse extends Omit<AuditLogWebhookResponse, 'type'> {
type: number;
@@ -113,7 +112,6 @@ export class GuildService {
webhookRepository: IWebhookRepository,
guildAuditLogService: GuildAuditLogService,
limitConfigService: LimitConfigService,
ipInfoService: IpInfoService,
) {
const {
cache: cacheService,
@@ -153,7 +151,6 @@ export class GuildService {
rateLimitService,
guildAuditLogService,
limitConfigService,
ipInfoService,
);
this.roles = new GuildRoleService(
guildRepository,
@@ -171,7 +168,6 @@ export class GuildService {
userCacheService,
workerService,
guildAuditLogService,
ipInfoService,
);
this.content = new GuildContentService(
guildRepository,
@@ -134,7 +134,7 @@ export class ChannelOperationsService {
}
}
const requestedNsfwOverride =
params.data.nsfw_override !== undefined ? params.data.nsfw_override : (params.data.nsfw ?? null);
params.data.nsfw_override !== undefined ? params.data.nsfw_override : params.data.nsfw === true ? true : null;
const requestedContentWarningLevel =
params.data.content_warning_level === ContentWarningLevel.CONTENT_WARNING
? ContentWarningLevel.CONTENT_WARNING
@@ -828,7 +828,7 @@ export class GuildOperationsService {
position,
owner_id: null,
recipient_ids: null,
nsfw: false,
nsfw: null,
content_warning_level: null,
content_warning_text: null,
rate_limit_per_user: 0,
@@ -1048,7 +1048,7 @@ export class GuildOperationsService {
position: channel.position,
owner_id: null,
recipient_ids: null,
nsfw: channel.nsfw ?? false,
nsfw: channel.nsfw === true ? true : null,
content_warning_level: null,
content_warning_text: null,
rate_limit_per_user: channel.rate_limit_per_user ?? 0,
@@ -1100,7 +1100,7 @@ export class GuildOperationsService {
position: 0,
owner_id: null,
recipient_ids: null,
nsfw: false,
nsfw: null,
content_warning_level: null,
content_warning_text: null,
rate_limit_per_user: 0,
@@ -2,10 +2,8 @@
import type {GuildID, RoleID, UserID} from '@app/api/BrandedTypes';
import {guildIdToRoleId} from '@app/api/BrandedTypes';
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {Logger} from '@app/api/Logger';
import type {GuildMember} from '@app/api/models/GuildMember';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
@@ -17,7 +15,6 @@ import {IpBannedFromGuildError} from '@fluxer/errors/src/domains/guild/IpBannedF
import {UnknownGuildRoleError} from '@fluxer/errors/src/domains/guild/UnknownGuildRoleError';
import {isSameIpDecisionMatch} from '@fluxer/ip_utils/src/IpAddress';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
function ensureNotEveryoneRole(roleId: RoleID, guildId: GuildID, path: string): void {
if (roleId === guildIdToRoleId(guildId)) {
@@ -29,7 +26,6 @@ export class GuildMemberValidationService {
constructor(
private readonly guildRepository: IGuildRepositoryAggregate,
private readonly userRepository: IUserRepository,
private readonly ipInfoService: IpInfoService,
) {}
async validateAndGetRoleIds(params: {
@@ -102,38 +98,9 @@ export class GuildMemberValidationService {
if (ban.userId === userId) {
throw new BannedFromGuildError();
}
if (isSameIpDecisionMatch(userIp, ban.ipAddress) && (await this.shouldEnforceIpBan(userIp, ban.ipAddress))) {
if (isSameIpDecisionMatch(userIp, ban.ipAddress) && !isIpBanExempt(userIp)) {
throw new IpBannedFromGuildError();
}
}
}
private async shouldEnforceIpBan(
userIp: string | null | undefined,
bannedIp: string | null | undefined,
): Promise<boolean> {
if (isIpBanExempt(userIp)) {
return false;
}
if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) {
return true;
}
try {
const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, {
source: 'guild.member_ip_ban',
reason: 'join_cgnat_guard',
});
const highRisk = cgnat || sharedAccess;
if (highRisk) {
Logger.warn(
{userIp, bannedIp},
'Skipping guild member IP ban match because IPInfo indicates high shared-network blast-radius risk',
);
}
return !highRisk;
} catch (error) {
Logger.warn({error, userIp, bannedIp}, 'IPInfo CGNAT guard failed while checking guild member IP ban');
return true;
}
}
}
@@ -2,6 +2,7 @@
export const GatewayRpcMethodErrorCodes = {
OVERLOADED: 'overloaded',
GUILD_OVERLOADED: 'guild_overloaded',
INTERNAL_ERROR: 'internal_error',
TIMEOUT: 'timeout',
NO_RESPONDERS: 'no_responders',
@@ -296,6 +296,9 @@ export class GatewayService {
if (error.code === GatewayRpcMethodErrorCodes.TIMEOUT) {
return new GatewayTimeoutError();
}
if (error.code === GatewayRpcMethodErrorCodes.GUILD_OVERLOADED) {
return new ServiceUnavailableError({headers: {'Retry-After': '1'}});
}
if (error.code === GatewayRpcMethodErrorCodes.OVERLOADED) {
return new ServiceUnavailableError();
}
@@ -8,6 +8,7 @@ import type {IGatewayRpcTransport} from '@app/api/infrastructure/IGatewayRpcTran
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {afterEach, describe, expect, it} from 'vitest';
@@ -70,4 +71,30 @@ describe('GatewayService gateway error mapping', () => {
expect((error as BadRequestError).status).toBe(400);
expect((error as BadRequestError).code).toBe(APIErrorCodes.INVALID_FORM_BODY);
});
it('returns 503 with Retry-After for an overloaded guild', async () => {
const service = serviceRaising(GatewayRpcMethodErrorCodes.GUILD_OVERLOADED);
const error = await service
.getUserPermissions({guildId: createGuildID(1n), userId: createUserID(2n)})
.catch((raised: unknown) => raised);
expect(error).toBeInstanceOf(ServiceUnavailableError);
const response = (error as ServiceUnavailableError).getResponse();
expect(response.status).toBe(503);
expect(response.headers.get('Retry-After')).toBe('1');
});
it('does not retry a guild overload response', async () => {
let calls = 0;
const client = GatewayRpcClient.createForTests({
async call(): Promise<unknown> {
calls += 1;
throw new GatewayRpcMethodError(GatewayRpcMethodErrorCodes.GUILD_OVERLOADED);
},
async destroy(): Promise<void> {},
});
await expect(client.call('guild.dispatch', {guild_id: '1'})).rejects.toMatchObject({
code: GatewayRpcMethodErrorCodes.GUILD_OVERLOADED,
});
expect(calls).toBe(1);
});
});
@@ -173,6 +173,7 @@ export interface InstancePolicyConfig {
direct_messages_disabled: boolean;
direct_messages_locked: boolean;
premium_mode: InstancePremiumMode;
guild_create_access: boolean;
gif_enabled: boolean | null;
youtube_enabled: boolean | null;
bluesky_enabled: boolean | null;
@@ -647,6 +648,7 @@ const StoredInstancePolicySchema = z.object({
direct_messages_disabled: InstancePolicyUpdateSchema.shape.direct_messages_disabled.default(false),
direct_messages_locked: z.boolean().default(false),
premium_mode: InstancePolicyUpdateSchema.shape.premium_mode.default('everyone'),
guild_create_access: InstancePolicyUpdateSchema.shape.guild_create_access.default(true),
gif_enabled: InstancePolicyServiceUpdateSchema.shape.gif_enabled.default(null),
youtube_enabled: InstancePolicyServiceUpdateSchema.shape.youtube_enabled.default(null),
bluesky_enabled: InstancePolicyServiceUpdateSchema.shape.bluesky_enabled.default(null),
@@ -1773,6 +1775,7 @@ export class InstanceConfigRepository {
single_community: policy.single_community_enabled,
single_community_guild_id: policy.single_community_enabled ? policy.single_community_guild_id : null,
direct_messages_disabled: policy.direct_messages_disabled,
guild_create_access: policy.guild_create_access,
};
}
@@ -75,6 +75,7 @@ const SKIP_CONTENT_FILTER_PATH_PARTS = [
'/admin/blocklists/phrase/',
'/auth/',
'/oauth2/',
'/premium/store/',
'/reports/dsa/email/',
'/users/@me/authorized-ips',
'/users/@me/email-change/',
@@ -24,7 +24,6 @@ import type {ReadStateService} from '@app/api/read_state/ReadStateService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type {IVirusScanService} from '@pkgs/virus_scan/src/IVirusScanService';
interface GuildStackServiceFactoryDependencies {
@@ -50,7 +49,6 @@ interface GuildStackServiceFactoryDependencies {
voiceRoomStore: IVoiceRoomStore;
liveKitService: ILiveKitService;
voiceAvailabilityService: VoiceAvailabilityService | null;
ipInfoService: IpInfoService;
}
export interface GuildStackServices {
@@ -106,7 +104,6 @@ class LazyGuildStackServices implements GuildStackServices {
this.dependencies.webhookRepository,
this.dependencies.guildAuditLogService,
this.dependencies.limitConfigService,
this.dependencies.ipInfoService,
);
return this.cachedGuildService;
}
@@ -6,7 +6,6 @@ import {AdminService} from '@app/api/admin/AdminService';
import {AuthRequestService} from '@app/api/auth/AuthRequestService';
import {DesktopHandoffService} from '@app/api/auth/services/DesktopHandoffService';
import {SsoService} from '@app/api/auth/services/SsoService';
import {buildIpInfoCache, buildIpInfoRequestAuditLogger} from '@app/api/ban/IpInfoCacheFactory';
import type {IBlueskyOAuthService} from '@app/api/bluesky/IBlueskyOAuthService';
import {Config} from '@app/api/Config';
import {createApiContext} from '@app/api/CreateApiContext';
@@ -98,6 +97,7 @@ import {
getReadStateService,
getReportRepository,
getStorageService,
getStoreBillingRepository,
getStreamPreviewService,
getSweegoWebhookService,
getThemeService,
@@ -119,6 +119,8 @@ import {ReportService} from '@app/api/report/ReportService';
import {RpcService} from '@app/api/rpc/RpcService';
import {getReportSearchService} from '@app/api/SearchFactory';
import {SearchService} from '@app/api/search/SearchService';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import {createStoreEntitlementService} from '@app/api/store_billing/StoreEntitlementServiceFactory';
import {StripeService} from '@app/api/stripe/StripeService';
import {AgeVerificationService} from '@app/api/stripe/services/AgeVerificationService';
import type {HonoEnv} from '@app/api/types/HonoEnv';
@@ -135,7 +137,6 @@ import {getRequestClientIp} from '@app/api/utils/RequestClientIp';
import {VoiceService} from '@app/api/voice/VoiceService';
import {WebhookRequestService} from '@app/api/webhook/WebhookRequestService';
import {WebhookService} from '@app/api/webhook/WebhookService';
import {createIpInfoService, createUnavailableIpInfoService, type IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import {createMiddleware} from 'hono/factory';
export {initializeServiceSingletons} from '@app/api/middleware/ServiceSingletons';
@@ -169,33 +170,6 @@ export function shutdownReportService(): void {
}
}
let _ipInfoService: IpInfoService | null = null;
let _injectedIpInfoService: IpInfoService | undefined;
export function setInjectedIpInfoService(service: IpInfoService | undefined): void {
_injectedIpInfoService = service;
}
export function getIpInfoService(): IpInfoService {
if (_injectedIpInfoService) {
return _injectedIpInfoService;
}
if (_ipInfoService) return _ipInfoService;
if (!Config.ipinfo.apiKey) {
_ipInfoService = createUnavailableIpInfoService('IPInfo API key not configured');
return _ipInfoService;
}
const cache = buildIpInfoCache({
hot: getCacheService(),
});
_ipInfoService = createIpInfoService({
apiKey: Config.ipinfo.apiKey,
cache,
auditLogger: buildIpInfoRequestAuditLogger(),
});
return _ipInfoService;
}
let _liveKitWebhookService: LiveKitWebhookService | null = null;
function getLiveKitWebhookService(): LiveKitWebhookService | null {
@@ -254,6 +228,7 @@ class RequestServices implements RequestScopedServices {
private cachedRpcService: RpcService | undefined;
private cachedSearchService: SearchService | undefined;
private cachedStripeService: StripeService | undefined;
private cachedStoreEntitlementService: StoreEntitlementService | undefined;
private cachedAgeVerificationService: AgeVerificationService | undefined;
private cachedDonationService: DonationService | undefined;
private cachedUserService: UserService | undefined;
@@ -345,7 +320,6 @@ class RequestServices implements RequestScopedServices {
voiceRoomStore: this.voiceRooms,
liveKitService: this.liveKit,
voiceAvailabilityService: getVoiceAvailabilityService(),
ipInfoService: getIpInfoService(),
});
return this.cachedGuildStack;
}
@@ -540,7 +514,7 @@ class RequestServices implements RequestScopedServices {
getApplicationRepository(),
this.stripeService.getStripe(),
new JobLedgerRepository(),
getIpInfoService(),
this.storeEntitlementService,
);
return this.cachedAdminService;
}
@@ -767,10 +741,24 @@ class RequestServices implements RequestScopedServices {
this.guildService,
getCacheService(),
getBillingRepository(),
getStoreBillingRepository(),
this.storeEntitlementService,
);
return this.cachedStripeService;
}
get storeEntitlementService(): StoreEntitlementService {
this.cachedStoreEntitlementService ??= createStoreEntitlementService({
userRepository: getUserRepository(),
userCacheService: getUserCacheService(),
gatewayService: this.gatewayService,
kvClient: getKVClient(),
snowflakeService: getSnowflakeService(),
premiumStateReconciliationQueueService: getPremiumStateReconciliationQueueService(),
});
return this.cachedStoreEntitlementService;
}
get ageVerificationService(): AgeVerificationService | undefined {
if (Config.instance.selfHosted) {
return undefined;
@@ -912,6 +900,5 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
export function resetServiceMiddlewareForTesting(): void {
shutdownReportService();
_ipInfoService = null;
_liveKitWebhookService = null;
}
@@ -72,6 +72,11 @@ import {ReadStateRequestService} from '@app/api/read_state/ReadStateRequestServi
import {ReadStateService} from '@app/api/read_state/ReadStateService';
import {ReportRepository} from '@app/api/report/ReportRepository';
import {getGuildSearchService} from '@app/api/SearchFactory';
import {AppStoreServerApiClient} from '@app/api/store_billing/app_store/AppStoreServerApiClient';
import {GooglePlayAccessTokenProvider} from '@app/api/store_billing/google_play/GooglePlayAccessTokenProvider';
import {GooglePlayDeveloperApiClient} from '@app/api/store_billing/google_play/GooglePlayDeveloperApiClient';
import {GooglePlayPushVerifier} from '@app/api/store_billing/google_play/GooglePlayPushVerifier';
import {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository';
import {ThemeService} from '@app/api/theme/ThemeService';
import {EntranceSoundPlayService} from '@app/api/user/entrance_sound/EntranceSoundPlayService';
import {EntranceSoundRepository} from '@app/api/user/entrance_sound/EntranceSoundRepository';
@@ -119,6 +124,13 @@ export const getEmailChangeRepository = singleton(() => new EmailChangeRepositor
export const getPasswordChangeRepository = singleton(() => new PasswordChangeRepository());
const getUserContactChangeLogRepository = singleton(() => new UserContactChangeLogRepository());
export const getDonationRepository = singleton(() => new DonationRepository());
export const getStoreBillingRepository = singleton(() => new StoreBillingRepository());
export const getAppStoreServerApiClient = singleton(() => new AppStoreServerApiClient());
const getGooglePlayAccessTokenProvider = singleton(() => new GooglePlayAccessTokenProvider());
export const getGooglePlayDeveloperApiClient = singleton(
() => new GooglePlayDeveloperApiClient({accessTokenProvider: getGooglePlayAccessTokenProvider()}),
);
export const getGooglePlayPushVerifier = singleton(() => new GooglePlayPushVerifier());
const getAdminApiKeyRepository = singleton(() => new AdminApiKeyRepository());
let instanceConfigRepositoryInstance: InstanceConfigRepository | null = null;
export const getInstanceConfigRepository = singleton(
@@ -18,7 +18,13 @@ interface ParsedAuthHeader {
type: TokenType;
}
const SKIP_PATHS = new Set(['/_health', '/webhooks/livekit', '/webhooks/sweego']);
const SKIP_PATHS = new Set([
'/_health',
'/webhooks/livekit',
'/webhooks/sweego',
'/webhooks/app-store',
'/webhooks/google-play',
]);
const SESSION_TOKEN_PATTERN = /^flx_[A-Za-z0-9]{36}$/;
function parseAuthHeader(authHeader?: string | null): ParsedAuthHeader | null {
+3
View File
@@ -114,6 +114,7 @@ export class GiftCode {
readonly visionarySequenceNumber: number | null;
readonly checkoutSessionId: string | null;
readonly revokedAt: Date | null;
readonly premiumReversedSeconds: number | null;
readonly version: number;
constructor(row: GiftCodeRow) {
@@ -130,6 +131,7 @@ export class GiftCode {
this.visionarySequenceNumber = row.visionary_sequence_number ?? null;
this.checkoutSessionId = row.checkout_session_id ?? null;
this.revokedAt = row.revoked_at ?? null;
this.premiumReversedSeconds = row.premium_reversed_seconds ?? null;
this.version = row.version;
}
@@ -147,6 +149,7 @@ export class GiftCode {
visionary_sequence_number: this.visionarySequenceNumber,
checkout_session_id: this.checkoutSessionId,
revoked_at: this.revokedAt,
premium_reversed_seconds: this.premiumReversedSeconds,
version: this.version,
};
}
+654 -5
View File
@@ -6236,7 +6236,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Only claimed, email-verified non-bot users can create guilds.",
"description": "Only claimed, email-verified non-bot users can create guilds. A self-hosted instance can restrict creation to admins and users granted the feature_guild_create limit.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": true,
@@ -11641,6 +11641,289 @@
]
}
},
"/premium/store": {
"get": {
"operationId": "get_store_billing_context",
"summary": "Get in-app purchase context",
"tags": ["Premium"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/StoreBillingContextResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Returns the account token and the App Store and Google Play products the mobile apps may sell, plus the reason a new subscription is blocked, if any.",
"security": [{"sessionToken": []}]
}
},
"/premium/store/app-store/transactions": {
"post": {
"operationId": "claim_app_store_transaction",
"summary": "Claim App Store transaction",
"tags": ["Premium"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/StorePurchaseClaimResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Verifies a StoreKit 2 signed transaction, links the purchase to the authenticated account and applies it. Calling it again for the same purchase returns the same result. Finish the transaction after a 200 or a 400 or 403 error. Leave it unfinished after a 429, a 5xx or a network failure.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClaimAppStoreTransactionRequest"}}}
}
}
},
"/premium/store/google-play/purchases": {
"post": {
"operationId": "claim_google_play_purchase",
"summary": "Claim Google Play purchase",
"tags": ["Premium"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/StorePurchaseClaimResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Verifies a Google Play purchase token, links the purchase to the authenticated account, acknowledges it and applies it. Calling it again for the same purchase returns the same result.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClaimGooglePlayPurchaseRequest"}}}
}
}
},
"/premium/store/purchases": {
"get": {
"operationId": "list_store_purchases",
"summary": "List in-app purchases",
"tags": ["Premium"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/StorePurchaseListResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Returns the App Store and Google Play purchases linked to the authenticated account.",
"security": [{"sessionToken": []}]
}
},
"/premium/store/purchases/{purchase_id}": {
"delete": {
"operationId": "release_store_purchase",
"summary": "Release in-app subscription",
"tags": ["Premium"],
"responses": {
"204": {"description": "No Content"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Unlinks an App Store or Google Play subscription from the authenticated account so another account can claim it. Requires sudo mode.",
"security": [{"sessionToken": []}],
"parameters": [
{
"name": "purchase_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the store purchase", "$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the store purchase"
}
],
"requestBody": {
"required": false,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SudoVerificationSchema"}}}
}
}
},
"/premium/switch-to-list-price": {
"post": {
"operationId": "switch_subscription_to_list_price",
@@ -22446,7 +22729,7 @@
"description": "The sequence number for lifetime premium subscribers"
},
"premium_grace_ends_at": {
"description": "ISO8601 timestamp at which the post-cancel grace period ends. Set when the subscription is fully canceled in Stripe; perks remain active and the original premium_since is restored on resubscribe until this timestamp passes. Null when not in grace.",
"description": "ISO8601 timestamp at which grace access ends after premium_until passes: after a failed renewal payment (7 days from the renewal for monthly plans, 14 for yearly), after a subscription ends (3 days), or during an App Store or Google Play grace period. Perks stay active and the original premium_since is kept on resubscribe until this timestamp passes. Null when no grace is recorded, in which case access lasts 3 days after premium_until.",
"type": ["string", "null"]
},
"premium_discriminator": {
@@ -25837,6 +26120,162 @@
{"$ref": "#/components/schemas/IneligibleSwitchToListPriceResponse"}
]
},
"StorePurchaseListResponse": {"type": "array", "items": {"$ref": "#/components/schemas/StorePurchaseResponse"}},
"ClaimGooglePlayPurchaseRequest": {
"type": "object",
"properties": {
"purchase_token": {"description": "Purchase token from Google Play Billing", "type": "string"},
"product_id": {"description": "Google Play product identifier of the purchase", "type": "string"},
"package_name": {
"description": "Package name of the app that made the purchase. Must be one of the accepted package names",
"type": "string"
}
},
"required": ["purchase_token", "product_id"]
},
"StorePurchaseClaimResponse": {
"type": "object",
"properties": {
"purchase": {
"properties": {
"id": {
"description": "The unique identifier (snowflake) for this store purchase",
"$ref": "#/components/schemas/SnowflakeStringType"
},
"provider": {
"description": "Store the purchase was made in",
"$ref": "#/components/schemas/StoreProvider"
},
"kind": {
"description": "Whether the purchase is a subscription or a gift",
"$ref": "#/components/schemas/StorePurchaseKind"
},
"slot": {"description": "Fluxer product the purchase is for", "$ref": "#/components/schemas/StoreSlot"},
"product_id": {"type": "string", "description": "Store product identifier"},
"environment": {
"description": "Whether the purchase was a real purchase or a test purchase",
"$ref": "#/components/schemas/StoreEnvironment"
},
"state": {
"description": "Current state of the purchase",
"$ref": "#/components/schemas/StorePurchaseState"
},
"entitled": {"type": "boolean", "description": "Whether the purchase currently grants Plutonium"},
"expires_at": {
"anyOf": [
{
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
{"type": "null"}
],
"description": "When the paid period ends, null for gifts"
},
"entitled_until": {
"anyOf": [
{
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
{"type": "null"}
],
"description": "When access from this purchase ends, including any grace period, null when not entitled"
},
"will_renew": {
"description": "Whether the subscription renews automatically, null for gifts",
"type": ["boolean", "null"]
},
"gift_code": {
"description": "Gift code minted by a gift purchase, null otherwise",
"type": ["string", "null"]
},
"created_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$",
"description": "When Fluxer first saw the purchase"
}
},
"description": "The claimed purchase",
"$ref": "#/components/schemas/StorePurchaseResponse"
},
"gift_code": {
"description": "Gift code minted by a gift purchase, null otherwise",
"type": ["string", "null"]
}
},
"required": ["purchase", "gift_code"],
"additionalProperties": false
},
"ClaimAppStoreTransactionRequest": {
"type": "object",
"properties": {
"signed_transaction": {
"description": "JWS signed transaction from StoreKit 2 (Transaction.jwsRepresentation)",
"type": "string"
}
},
"required": ["signed_transaction"]
},
"StoreBillingContextResponse": {
"type": "object",
"properties": {
"app_account_token": {
"type": "string",
"description": "Lowercase UUID for this account. Pass it as appAccountToken on App Store purchases and as obfuscatedAccountId on Google Play purchases"
},
"app_store": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "description": "Whether App Store purchases are accepted"},
"bundle_ids": {
"type": "array",
"items": {"type": "string"},
"description": "App bundle identifiers whose purchases are accepted"
},
"products": {
"type": "array",
"items": {"$ref": "#/components/schemas/StoreBillingAppStoreProductResponse"},
"description": "App Store products on sale"
}
},
"required": ["enabled", "bundle_ids", "products"],
"additionalProperties": false,
"description": "App Store purchase settings"
},
"google_play": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "description": "Whether Google Play purchases are accepted"},
"package_names": {
"type": "array",
"items": {"type": "string"},
"description": "App package names whose purchases are accepted"
},
"products": {
"type": "array",
"items": {"$ref": "#/components/schemas/StoreBillingGooglePlayProductResponse"},
"description": "Google Play products on sale"
}
},
"required": ["enabled", "package_names", "products"],
"additionalProperties": false,
"description": "Google Play purchase settings"
},
"purchase_blocked_reason": {
"anyOf": [{"$ref": "#/components/schemas/StorePurchaseBlockedReason"}, {"type": "null"}],
"description": "Why this account cannot buy a subscription right now, null when it can"
},
"blocking_provider": {
"anyOf": [{"$ref": "#/components/schemas/StoreBlockingProvider"}, {"type": "null"}],
"description": "Provider of the active subscription that blocks a new one, null when nothing blocks"
}
},
"required": ["app_account_token", "app_store", "google_play", "purchase_blocked_reason", "blocking_provider"],
"additionalProperties": false
},
"PremiumStateResponse": {
"type": "object",
"properties": {
@@ -26008,9 +26447,17 @@
],
"additionalProperties": false
},
"pricing": {"$ref": "#/components/schemas/PremiumPricingState"}
"pricing": {"$ref": "#/components/schemas/PremiumPricingState"},
"store": {
"description": "Active App Store or Google Play subscription, null when no store subscription is active",
"anyOf": [{"$ref": "#/components/schemas/PremiumStoreSubscriptionState"}, {"type": "null"}]
},
"subscription_provider": {
"anyOf": [{"$ref": "#/components/schemas/PremiumSubscriptionProvider"}, {"type": "null"}],
"description": "Billing platform that owns the current recurring subscription, null for gift, lifetime or no subscription. When a Stripe and a store subscription are both active, the one paid through later"
}
},
"required": ["actual", "effective", "billing", "pricing"],
"required": ["actual", "effective", "billing", "pricing", "subscription_provider"],
"additionalProperties": false
},
"SelfServeRefundResponse": {
@@ -29812,9 +30259,18 @@
"direct_messages_disabled": {
"type": "boolean",
"description": "Whether direct messages and friend requests are disabled instance-wide"
},
"guild_create_access": {
"type": "boolean",
"description": "Whether every account can create communities. When false, only admins and accounts granted the feature_guild_create limit can"
}
},
"required": ["single_community", "single_community_guild_id", "direct_messages_disabled"],
"required": [
"single_community",
"single_community_guild_id",
"direct_messages_disabled",
"guild_create_access"
],
"additionalProperties": false,
"description": "Community topology and direct-message policy for this instance"
},
@@ -32852,6 +33308,68 @@
"type": "string",
"enum": ["no_refundable_purchase", "outside_refund_window", "cooldown_active", "feature_unavailable"]
},
"PremiumSubscriptionProvider": {"type": "string", "enum": ["stripe", "app_store", "google_play"]},
"PremiumStoreSubscriptionState": {
"type": "object",
"properties": {
"provider": {
"description": "Store that bills the subscription",
"$ref": "#/components/schemas/StoreProvider"
},
"purchase_id": {
"description": "ID of the store purchase that grants the subscription",
"$ref": "#/components/schemas/SnowflakeStringType"
},
"slot": {"description": "Fluxer subscription product", "$ref": "#/components/schemas/StoreSubscriptionSlot"},
"billing_cycle": {
"description": "Billing cycle of the subscription",
"$ref": "#/components/schemas/StoreBillingCycle"
},
"state": {
"description": "Current state of the store subscription",
"$ref": "#/components/schemas/StorePurchaseState"
},
"expires_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$",
"description": "When the paid period ends"
},
"grace_ends_at": {
"anyOf": [
{
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
{"type": "null"}
],
"description": "When the billing grace period ends, null outside grace"
},
"will_renew": {"type": "boolean", "description": "Whether the subscription renews automatically"},
"manage_url": {
"type": "string",
"description": "Store page where the subscription can be managed or canceled"
},
"environment": {
"description": "Whether the subscription is a real purchase or a test purchase",
"$ref": "#/components/schemas/StoreEnvironment"
}
},
"required": [
"provider",
"purchase_id",
"slot",
"billing_cycle",
"state",
"expires_at",
"grace_ends_at",
"will_renew",
"manage_url",
"environment"
],
"additionalProperties": false
},
"PremiumPricingState": {
"type": "object",
"properties": {
@@ -33117,6 +33635,137 @@
"switch_in_progress"
]
},
"StoreEnvironment": {"type": "string", "enum": ["production", "sandbox"]},
"StorePurchaseState": {
"type": "string",
"enum": [
"pending",
"active",
"grace",
"billing_retry",
"on_hold",
"paused",
"canceled",
"expired",
"revoked",
"superseded",
"purchased",
"fulfilled",
"refunded"
]
},
"StoreBillingCycle": {"type": "string", "enum": ["monthly", "yearly"]},
"StoreSubscriptionSlot": {"type": "string", "enum": ["monthly", "yearly"]},
"StoreProvider": {"type": "string", "enum": ["app_store", "google_play"]},
"StoreBlockingProvider": {"type": "string", "enum": ["stripe", "app_store", "google_play"]},
"StorePurchaseBlockedReason": {
"type": "string",
"enum": ["lifetime", "existing_subscription", "purchase_disabled"]
},
"StoreBillingGooglePlayProductResponse": {
"type": "object",
"properties": {
"product_id": {"type": "string", "description": "Google Play product identifier"},
"base_plan_id": {
"description": "Google Play base plan identifier, null for one-time products",
"type": ["string", "null"]
},
"slot": {
"description": "Fluxer product this Google Play product sells",
"$ref": "#/components/schemas/StoreSlot"
}
},
"required": ["product_id", "base_plan_id", "slot"],
"additionalProperties": false
},
"StoreBillingAppStoreProductResponse": {
"type": "object",
"properties": {
"product_id": {"type": "string", "description": "App Store product identifier"},
"slot": {
"description": "Fluxer product this App Store product sells",
"$ref": "#/components/schemas/StoreSlot"
}
},
"required": ["product_id", "slot"],
"additionalProperties": false
},
"StoreSlot": {"type": "string", "enum": ["monthly", "yearly", "gift_1_month", "gift_1_year"]},
"StorePurchaseResponse": {
"type": "object",
"properties": {
"id": {
"description": "The unique identifier (snowflake) for this store purchase",
"$ref": "#/components/schemas/SnowflakeStringType"
},
"provider": {"description": "Store the purchase was made in", "$ref": "#/components/schemas/StoreProvider"},
"kind": {
"description": "Whether the purchase is a subscription or a gift",
"$ref": "#/components/schemas/StorePurchaseKind"
},
"slot": {"description": "Fluxer product the purchase is for", "$ref": "#/components/schemas/StoreSlot"},
"product_id": {"type": "string", "description": "Store product identifier"},
"environment": {
"description": "Whether the purchase was a real purchase or a test purchase",
"$ref": "#/components/schemas/StoreEnvironment"
},
"state": {"description": "Current state of the purchase", "$ref": "#/components/schemas/StorePurchaseState"},
"entitled": {"type": "boolean", "description": "Whether the purchase currently grants Plutonium"},
"expires_at": {
"anyOf": [
{
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
{"type": "null"}
],
"description": "When the paid period ends, null for gifts"
},
"entitled_until": {
"anyOf": [
{
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
{"type": "null"}
],
"description": "When access from this purchase ends, including any grace period, null when not entitled"
},
"will_renew": {
"description": "Whether the subscription renews automatically, null for gifts",
"type": ["boolean", "null"]
},
"gift_code": {
"description": "Gift code minted by a gift purchase, null otherwise",
"type": ["string", "null"]
},
"created_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$",
"description": "When Fluxer first saw the purchase"
}
},
"required": [
"id",
"provider",
"kind",
"slot",
"product_id",
"environment",
"state",
"entitled",
"expires_at",
"entitled_until",
"will_renew",
"gift_code",
"created_at"
],
"additionalProperties": false
},
"StorePurchaseKind": {"type": "string", "enum": ["subscription", "gift"]},
"IneligibleSwitchToListPriceResponse": {
"type": "object",
"properties": {
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RouteRateLimitConfig} from '@app/api/middleware/RateLimitMiddleware';
import {ms} from 'itty-time';
export const StoreBillingRateLimitConfigs = {
STORE_BILLING_CONTEXT: {
bucket: 'store:context',
config: {limit: 30, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
STORE_BILLING_CLAIM_APP_STORE: {
bucket: 'store:claim:app_store',
config: {limit: 20, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
STORE_BILLING_CLAIM_GOOGLE_PLAY: {
bucket: 'store:claim:google_play',
config: {limit: 20, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
STORE_BILLING_PURCHASES_LIST: {
bucket: 'store:purchases:list',
config: {limit: 30, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
STORE_BILLING_PURCHASE_RELEASE: {
bucket: 'store:purchases:release',
config: {limit: 5, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
STORE_BILLING_APP_STORE_WEBHOOK: {
bucket: 'store:webhook:app_store',
config: {limit: 300, windowMs: ms('1 minute'), exemptFromGlobal: true},
} as RouteRateLimitConfig,
STORE_BILLING_GOOGLE_PLAY_WEBHOOK: {
bucket: 'store:webhook:google_play',
config: {limit: 300, windowMs: ms('1 minute'), exemptFromGlobal: true},
} as RouteRateLimitConfig,
} as const;
@@ -384,7 +384,7 @@ describe('Message Search Permissions', () => {
const guild = await createGuild(harness, owner.token, 'Age Restricted Override Guild');
const channel = await createBuilder<{id: string; nsfw_override?: boolean | null}>(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'override-channel', type: ChannelTypes.GUILD_TEXT, nsfw: false})
.body({name: 'override-channel', type: ChannelTypes.GUILD_TEXT, nsfw_override: false})
.execute();
expect(channel.nsfw_override).toBe(false);
await sendChannelMessage(harness, owner.token, channel.id, 'age restricted override searchable message');
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes';
import {Logger} from '@app/api/Logger';
import type {User} from '@app/api/models/User';
import {isGooglePlayApiError} from '@app/api/store_billing/google_play/GooglePlayApiError';
import type {GooglePlayDeveloperApiClient} from '@app/api/store_billing/google_play/GooglePlayDeveloperApiClient';
import type {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository';
import {
LIFETIME_REFUND_REASON,
PAID_SUBSCRIPTION_STATES,
redactStoreKey,
} from '@app/api/store_billing/StoreBillingTypes';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
export class GooglePlaySettlement {
constructor(
private readonly repository: StoreBillingRepository,
private readonly googlePlayClient: GooglePlayDeveloperApiClient,
) {}
async settleSubscription(row: StorePurchaseRow, owner: User | null): Promise<StorePurchaseRow> {
if (row.kind !== 'subscription' || row.user_id === null || !owner) {
return row;
}
if (owner.premiumType === UserPremiumTypes.LIFETIME) {
return this.refundForLifetimeUser(row);
}
if (row.acknowledged || !PAID_SUBSCRIPTION_STATES.has(row.state)) {
return row;
}
let acknowledged = false;
try {
await this.googlePlayClient.acknowledgeSubscription(row.app_id, row.product_id, row.store_reference);
acknowledged = true;
} catch (error) {
if (isGooglePlayApiError(error) && error.status === 400) {
try {
const purchase = await this.googlePlayClient.getSubscription(row.app_id, row.store_reference);
acknowledged = purchase.acknowledgementState === 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED';
} catch (readError) {
Logger.warn(
{error: readError, storeKey: redactStoreKey(row.store_key)},
'Failed to re-read a Google Play subscription',
);
}
} else {
Logger.warn(
{error, storeKey: redactStoreKey(row.store_key)},
'Failed to acknowledge a Google Play subscription',
);
}
}
if (!acknowledged) {
return row;
}
const updated = await this.repository.updatePurchase(row, {acknowledged: true});
return updated ?? row;
}
async consumeProduct(row: StorePurchaseRow): Promise<boolean> {
try {
await this.googlePlayClient.consumeProduct(row.app_id, row.product_id, row.store_reference);
return true;
} catch (error) {
if (isGooglePlayApiError(error) && error.status === 400) {
try {
const purchase = await this.googlePlayClient.getProduct(row.app_id, row.store_reference);
const lineItem = (purchase.productLineItem ?? []).find((item) => item.productId === row.product_id);
return lineItem?.productOfferDetails?.consumptionState === 'CONSUMPTION_STATE_CONSUMED';
} catch (readError) {
Logger.warn(
{error: readError, storeKey: redactStoreKey(row.store_key)},
'Failed to re-read a Google Play product',
);
return false;
}
}
Logger.warn({error, storeKey: redactStoreKey(row.store_key)}, 'Failed to consume a Google Play gift purchase');
return false;
}
}
async refundUnsupportedGiftQuantity(row: StorePurchaseRow): Promise<StorePurchaseRow> {
if (row.provider !== 'google_play' || row.revoked_at || !row.latest_transaction_id) {
return row;
}
try {
await this.googlePlayClient.refundOrder(row.app_id, row.latest_transaction_id);
} catch (error) {
Logger.warn(
{error, storeKey: redactStoreKey(row.store_key)},
'Failed to refund a Google Play gift bought in a quantity above one',
);
return row;
}
Logger.info(
{storeKey: redactStoreKey(row.store_key)},
'Refunded a Google Play gift bought in a quantity above one',
);
const updated = await this.repository.updatePurchase(row, {revoked_at: new Date()});
return updated ?? row;
}
private async refundForLifetimeUser(row: StorePurchaseRow): Promise<StorePurchaseRow> {
if (
row.revocation_reason === LIFETIME_REFUND_REASON ||
!PAID_SUBSCRIPTION_STATES.has(row.state) ||
!row.latest_transaction_id
) {
return row;
}
try {
await this.googlePlayClient.refundOrder(row.app_id, row.latest_transaction_id);
} catch (error) {
Logger.warn(
{error, storeKey: redactStoreKey(row.store_key)},
'Failed to refund a Google Play purchase for a lifetime user',
);
return row;
}
Logger.info(
{storeKey: redactStoreKey(row.store_key)},
'Refunded a Google Play subscription bought by a lifetime user',
);
const updated = await this.repository.updatePurchase(row, {
revocation_reason: LIFETIME_REFUND_REASON,
revoked_at: new Date(),
entitled: false,
});
return updated ?? row;
}
}
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {APIConfig, AppStoreAppConfig} from '@app/api/config/APIConfig';
import type {StorePurchaseKind, StoreSlot} from '@app/api/store_billing/StoreBillingTypes';
import type {RecurringBillingCycle} from '@app/api/stripe/ProductRegistry';
interface StoreProductSlotShape {
kind: StorePurchaseKind;
billingCycle: RecurringBillingCycle | null;
durationMonths: number;
}
export const STORE_PRODUCT_SLOTS: Record<StoreSlot, StoreProductSlotShape> = {
monthly: {kind: 'subscription', billingCycle: 'monthly', durationMonths: 1},
yearly: {kind: 'subscription', billingCycle: 'yearly', durationMonths: 12},
gift_1_month: {kind: 'gift', billingCycle: null, durationMonths: 1},
gift_1_year: {kind: 'gift', billingCycle: null, durationMonths: 12},
};
export interface GooglePlayProductEntry {
productId: string;
basePlanId: string | null;
slot: StoreSlot;
}
export function getAppStoreConfig(): APIConfig['appStore'] {
return Config.appStore;
}
export function getGooglePlayConfig(): APIConfig['googlePlay'] {
return Config.googlePlay;
}
function hasText(value: string | undefined): boolean {
return value !== undefined && value.trim().length > 0;
}
export function isAppStoreConfigured(): boolean {
const cfg = Config.appStore;
return (
!Config.instance.selfHosted &&
cfg.enabled &&
hasText(cfg.issuerId) &&
hasText(cfg.keyId) &&
(hasText(cfg.privateKey) || hasText(cfg.privateKeyPath)) &&
cfg.apps.length > 0 &&
Object.keys(cfg.products).length > 0
);
}
export function isGooglePlayConfigured(): boolean {
const cfg = Config.googlePlay;
const hasCredentials =
hasText(cfg.serviceAccountJsonPath) ||
(hasText(cfg.clientEmail) && (hasText(cfg.privateKey) || hasText(cfg.privateKeyPath)));
return (
!Config.instance.selfHosted &&
cfg.enabled &&
hasCredentials &&
cfg.packages.length > 0 &&
Object.keys(cfg.products).length > 0
);
}
export function getAppStoreApp(bundleId: string): AppStoreAppConfig | null {
return Config.appStore.apps.find((app) => app.bundleId === bundleId) ?? null;
}
export function getAppStoreProductSlot(productId: string): StoreSlot | null {
return Object.hasOwn(Config.appStore.products, productId) ? Config.appStore.products[productId] : null;
}
export function listAppStoreProducts(): Array<{productId: string; slot: StoreSlot}> {
return Object.entries(Config.appStore.products).map(([productId, slot]) => ({productId, slot}));
}
export function isGooglePlayPackageConfigured(packageName: string): boolean {
return Config.googlePlay.packages.includes(packageName);
}
export function listGooglePlayProducts(): Array<GooglePlayProductEntry> {
return Object.entries(Config.googlePlay.products).map(([key, slot]) => {
const separator = key.indexOf(':');
if (separator === -1) {
return {productId: key, basePlanId: null, slot};
}
return {productId: key.slice(0, separator), basePlanId: key.slice(separator + 1), slot};
});
}
export function getGooglePlaySubscriptionSlot(productId: string, basePlanId: string): StoreSlot | null {
const key = `${productId}:${basePlanId}`;
if (!Object.hasOwn(Config.googlePlay.products, key)) {
return null;
}
const slot = Config.googlePlay.products[key];
return STORE_PRODUCT_SLOTS[slot].kind === 'subscription' ? slot : null;
}
export function getGooglePlayOneTimeProductSlot(productId: string): StoreSlot | null {
if (!Object.hasOwn(Config.googlePlay.products, productId)) {
return null;
}
const slot = Config.googlePlay.products[productId];
return STORE_PRODUCT_SLOTS[slot].kind === 'gift' ? slot : null;
}
export function isGooglePlaySubscriptionProduct(productId: string): boolean {
return listGooglePlayProducts().some(
(entry) => entry.productId === productId && STORE_PRODUCT_SLOTS[entry.slot].kind === 'subscription',
);
}
export function isSandboxEntitlementAllowed(userId: UserID): boolean {
const cfg = Config.storeBilling;
return cfg.sandboxEntitlesAll || cfg.sandboxUserIds.includes(userId.toString());
}
@@ -0,0 +1,278 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
import {Config} from '@app/api/Config';
import {Logger} from '@app/api/Logger';
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {getGooglePlayPushVerifier} from '@app/api/middleware/ServiceSingletons';
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
import type {User} from '@app/api/models/User';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import {AppStoreJwsVerificationError, verifyNotification} from '@app/api/store_billing/app_store/AppStoreJwsVerifier';
import {parseGooglePlayPushEnvelope} from '@app/api/store_billing/google_play/GooglePlayPushVerifier';
import {
isAppStoreConfigured,
isGooglePlayConfigured,
listAppStoreProducts,
listGooglePlayProducts,
} from '@app/api/store_billing/StoreBillingConfig';
import {mapStorePurchaseToResponse} from '@app/api/store_billing/StoreBillingMappers';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import {isStripeSubscriptionActive} from '@app/api/store_billing/StoreEntitlementWriter';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
import {StoreBillingUnavailableError} from '@fluxer/errors/src/domains/payment/StoreBillingUnavailableError';
import {StoreNotificationUnauthorizedError} from '@fluxer/errors/src/domains/payment/StoreNotificationUnauthorizedError';
import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {
ClaimAppStoreTransactionRequest,
ClaimGooglePlayPurchaseRequest,
StoreBillingContextResponse,
StorePurchaseClaimResponse,
StorePurchaseIdParam,
StorePurchaseListResponse,
} from '@fluxer/schema/src/domains/premium/StoreBillingSchemas';
import {createMiddleware} from 'hono/factory';
import {z} from 'zod';
function routeAvailableWhen(isAvailable: () => boolean) {
return createMiddleware<HonoEnv>(async (ctx, next) => {
if (Config.instance.selfHosted && !isAvailable()) {
return AppNotFoundHandler(ctx);
}
return next();
});
}
export const HostedOnlyRoute = routeAvailableWhen(() => false);
const AppStoreNotificationBody = z.object({signedPayload: z.string().min(1)});
async function buildStoreBillingContext(
user: User,
storeEntitlementService: StoreEntitlementService,
): Promise<StoreBillingContextResponse> {
const appStoreEnabled = isAppStoreConfigured();
const googlePlayEnabled = isGooglePlayConfigured();
const [appAccountToken, storeEntitlement] = await Promise.all([
storeEntitlementService.getOrCreateAccountToken(user.id),
storeEntitlementService.getActiveStoreEntitlement(user.id),
]);
let purchaseBlockedReason: StoreBillingContextResponse['purchase_blocked_reason'] = null;
let blockingProvider: StoreBillingContextResponse['blocking_provider'] = null;
if (user.premiumType === UserPremiumTypes.LIFETIME) {
purchaseBlockedReason = 'lifetime';
} else if ((user.premiumFlags & PremiumFlags.PURCHASE_DISABLED) !== 0) {
purchaseBlockedReason = 'purchase_disabled';
} else if (storeEntitlement) {
purchaseBlockedReason = 'existing_subscription';
blockingProvider = storeEntitlement.provider;
} else if (isStripeSubscriptionActive(user, new Date())) {
purchaseBlockedReason = 'existing_subscription';
blockingProvider = 'stripe';
}
return {
app_account_token: appAccountToken,
app_store: {
enabled: appStoreEnabled,
bundle_ids: appStoreEnabled ? Config.appStore.apps.map((app) => app.bundleId) : [],
products: appStoreEnabled
? listAppStoreProducts().map((product) => ({product_id: product.productId, slot: product.slot}))
: [],
},
google_play: {
enabled: googlePlayEnabled,
package_names: googlePlayEnabled ? [...Config.googlePlay.packages] : [],
products: googlePlayEnabled
? listGooglePlayProducts().map((product) => ({
product_id: product.productId,
base_plan_id: product.basePlanId,
slot: product.slot,
}))
: [],
},
purchase_blocked_reason: purchaseBlockedReason,
blocking_provider: blockingProvider,
};
}
export function StoreBillingController(app: HonoApp) {
app.get(
'/premium/store',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_CONTEXT),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'get_store_billing_context',
summary: 'Get in-app purchase context',
description:
'Returns the account token and the App Store and Google Play products the mobile apps may sell, plus the reason a new subscription is blocked, if any.',
responseSchema: StoreBillingContextResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Premium',
}),
async (ctx) => {
return ctx.json(await buildStoreBillingContext(ctx.get('user'), ctx.get('storeEntitlementService')));
},
);
app.post(
'/premium/store/app-store/transactions',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_CLAIM_APP_STORE),
LoginRequired,
DefaultUserOnly,
Validator('json', ClaimAppStoreTransactionRequest),
OpenAPI({
operationId: 'claim_app_store_transaction',
summary: 'Claim App Store transaction',
description:
'Verifies a StoreKit 2 signed transaction, links the purchase to the authenticated account and applies it. Calling it again for the same purchase returns the same result. Finish the transaction after a 200 or a 400 or 403 error. Leave it unfinished after a 429, a 5xx or a network failure.',
responseSchema: StorePurchaseClaimResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Premium',
}),
async (ctx) => {
const {signed_transaction} = ctx.req.valid('json');
const result = await ctx
.get('storeEntitlementService')
.claimAppStoreTransaction(ctx.get('user').id, signed_transaction);
return ctx.json({purchase: mapStorePurchaseToResponse(result.purchase), gift_code: result.giftCode});
},
);
app.post(
'/premium/store/google-play/purchases',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_CLAIM_GOOGLE_PLAY),
LoginRequired,
DefaultUserOnly,
Validator('json', ClaimGooglePlayPurchaseRequest),
OpenAPI({
operationId: 'claim_google_play_purchase',
summary: 'Claim Google Play purchase',
description:
'Verifies a Google Play purchase token, links the purchase to the authenticated account, acknowledges it and applies it. Calling it again for the same purchase returns the same result.',
responseSchema: StorePurchaseClaimResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Premium',
}),
async (ctx) => {
const body = ctx.req.valid('json');
const result = await ctx.get('storeEntitlementService').claimGooglePlayPurchase(ctx.get('user').id, {
purchaseToken: body.purchase_token,
productId: body.product_id,
packageName: body.package_name,
});
return ctx.json({purchase: mapStorePurchaseToResponse(result.purchase), gift_code: result.giftCode});
},
);
app.get(
'/premium/store/purchases',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_PURCHASES_LIST),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'list_store_purchases',
summary: 'List in-app purchases',
description: 'Returns the App Store and Google Play purchases linked to the authenticated account.',
responseSchema: StorePurchaseListResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Premium',
}),
async (ctx) => {
const rows = await ctx.get('storeEntitlementService').listStorePurchases(ctx.get('user').id);
return ctx.json(rows.map(mapStorePurchaseToResponse));
},
);
app.delete(
'/premium/store/purchases/:purchase_id',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_PURCHASE_RELEASE),
LoginRequired,
DefaultUserOnly,
SudoModeMiddleware,
Validator('param', StorePurchaseIdParam),
Validator('json', SudoVerificationSchema),
OpenAPI({
operationId: 'release_store_purchase',
summary: 'Release in-app subscription',
description:
'Unlinks an App Store or Google Play subscription from the authenticated account so another account can claim it. Requires sudo mode.',
requestSchema: SudoVerificationSchema,
requestBodyRequired: false,
responseSchema: null,
statusCode: 204,
security: ['bearerToken', 'sessionToken'],
tags: 'Premium',
}),
async (ctx) => {
const user = ctx.get('user');
const {purchase_id} = ctx.req.valid('param');
await requireSudoMode(ctx, user, ctx.req.valid('json'));
await ctx.get('storeEntitlementService').releaseStorePurchase(user.id, purchase_id);
return ctx.body(null, 204);
},
);
app.post(
'/webhooks/app-store',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_APP_STORE_WEBHOOK),
async (ctx) => {
if (!isAppStoreConfigured()) {
throw new StoreBillingUnavailableError();
}
let signedPayload: string;
try {
signedPayload = AppStoreNotificationBody.parse(await ctx.req.json()).signedPayload;
} catch {
throw new StoreNotificationUnauthorizedError();
}
try {
await verifyNotification(signedPayload);
} catch (error) {
if (error instanceof AppStoreJwsVerificationError) {
Logger.warn({reason: error.reason}, 'Rejected App Store notification');
throw new StoreNotificationUnauthorizedError();
}
throw error;
}
await ctx.get('workerService').addJob('processAppStoreNotification', {signedPayload});
return ctx.body(null, 200);
},
);
app.post(
'/webhooks/google-play',
HostedOnlyRoute,
RateLimitMiddleware(RateLimitConfigs.STORE_BILLING_GOOGLE_PLAY_WEBHOOK),
async (ctx) => {
if (!isGooglePlayConfigured()) {
throw new StoreBillingUnavailableError();
}
await getGooglePlayPushVerifier().verifyAuthorizationHeader(ctx.req.header('Authorization'));
let body: unknown = null;
try {
body = await ctx.req.json();
} catch {
body = null;
}
const message = parseGooglePlayPushEnvelope(body);
if (!message) {
Logger.warn('Ignored a malformed Google Play push message');
return ctx.body(null, 204);
}
await ctx
.get('workerService')
.addJob('processGooglePlayNotification', {messageId: message.messageId, data: message.data});
return ctx.body(null, 204);
},
);
}
@@ -0,0 +1,134 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes';
import {STORE_PRODUCT_SLOTS} from '@app/api/store_billing/StoreBillingConfig';
import {PREMIUM_GRACE_PERIOD_MS} from '@app/api/user/UserHelpers';
import type {AdminStorePurchaseResponse} from '@fluxer/schema/src/domains/admin/AdminStoreBillingSchemas';
import type {
PremiumStoreSubscriptionState,
StorePurchaseResponse,
} from '@fluxer/schema/src/domains/premium/StoreBillingSchemas';
const APP_STORE_MANAGE_URL = 'https://apps.apple.com/account/subscriptions';
const GOOGLE_PLAY_MANAGE_URL = 'https://play.google.com/store/account/subscriptions';
function toIso(value: Date | null | undefined): string | null {
return value ? value.toISOString() : null;
}
export function resolveStoreAccessEnd(row: StorePurchaseRow): Date | null {
if (row.state === 'grace' && row.grace_ends_at) {
return row.grace_ends_at;
}
return row.expires_at ?? null;
}
function isActiveStoreSubscription(row: StorePurchaseRow, now: Date): boolean {
if (row.kind !== 'subscription' || !row.entitled || row.user_id === null) {
return false;
}
const accessEnd = resolveStoreAccessEnd(row);
if (!accessEnd) {
return false;
}
if (row.state === 'grace') {
return accessEnd.getTime() >= now.getTime();
}
return accessEnd.getTime() + PREMIUM_GRACE_PERIOD_MS >= now.getTime();
}
export function selectActiveStoreSubscription(
rows: ReadonlyArray<StorePurchaseRow>,
now: Date,
): StorePurchaseRow | null {
let best: StorePurchaseRow | null = null;
for (const row of rows) {
if (!isActiveStoreSubscription(row, now)) {
continue;
}
if (!best || (resolveStoreAccessEnd(row)?.getTime() ?? 0) > (resolveStoreAccessEnd(best)?.getTime() ?? 0)) {
best = row;
}
}
return best;
}
export function mapStorePurchaseToResponse(row: StorePurchaseRow): StorePurchaseResponse {
const isSubscription = row.kind === 'subscription';
return {
id: row.id.toString(),
provider: row.provider,
kind: row.kind,
slot: row.slot,
product_id: row.product_id,
environment: row.environment,
state: row.state,
entitled: row.entitled,
expires_at: isSubscription ? toIso(row.expires_at) : null,
entitled_until: isSubscription && row.entitled ? toIso(resolveStoreAccessEnd(row)) : null,
will_renew: isSubscription ? (row.auto_renew ?? null) : null,
gift_code: row.gift_code ?? null,
created_at: row.created_at.toISOString(),
};
}
export function mapStorePurchaseToAdminResponse(row: StorePurchaseRow): AdminStorePurchaseResponse {
return {
id: row.id.toString(),
user_id: row.user_id?.toString() ?? null,
provider: row.provider,
kind: row.kind,
slot: row.slot,
environment: row.environment,
app_id: row.app_id,
product_id: row.product_id,
base_plan_id: row.base_plan_id ?? null,
latest_transaction_id: row.latest_transaction_id ?? null,
ownership_type: row.ownership_type ?? null,
state: row.state,
store_state: row.store_state ?? null,
entitled: row.entitled,
expires_at: toIso(row.expires_at),
grace_ends_at: toIso(row.grace_ends_at),
auto_renew: row.auto_renew ?? null,
started_at: toIso(row.started_at),
purchased_at: toIso(row.purchased_at),
revoked_at: toIso(row.revoked_at),
revocation_reason: row.revocation_reason ?? null,
superseded: row.superseded_by_store_key != null,
acknowledged: row.acknowledged,
gift_code: row.gift_code ?? null,
bound_at: toIso(row.bound_at),
last_event_at: toIso(row.last_event_at),
synced_at: toIso(row.synced_at),
created_at: row.created_at.toISOString(),
updated_at: row.updated_at.toISOString(),
};
}
function buildManageUrl(row: StorePurchaseRow): string {
if (row.provider === 'app_store') {
return APP_STORE_MANAGE_URL;
}
const params = new URLSearchParams({sku: row.product_id, package: row.app_id});
return `${GOOGLE_PLAY_MANAGE_URL}?${params.toString()}`;
}
export function mapPremiumStoreSubscriptionState(row: StorePurchaseRow): PremiumStoreSubscriptionState | null {
const billingCycle = STORE_PRODUCT_SLOTS[row.slot].billingCycle;
if (!billingCycle || !row.expires_at || (row.slot !== 'monthly' && row.slot !== 'yearly')) {
return null;
}
return {
provider: row.provider,
purchase_id: row.id.toString(),
slot: row.slot,
billing_cycle: billingCycle,
state: row.state,
expires_at: row.expires_at.toISOString(),
grace_ends_at: row.state === 'grace' ? toIso(row.grace_ends_at) : null,
will_renew: row.auto_renew === true,
manage_url: buildManageUrl(row),
environment: row.environment,
};
}
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import type {UserID} from '@app/api/BrandedTypes';
import {
deleteOneOrMany,
executeConditional,
fetchMany,
fetchOne,
upsertOne,
} from '@app/api/database/CassandraQueryExecution';
import {Db, type DbOp, nextVersion} from '@app/api/database/CassandraTypes';
import {buildPatchFromData} from '@app/api/database/CassandraVersionedUpdate';
import {
STORE_PURCHASE_COLUMNS,
type StoreAccountTokenByUserRow,
type StoreAccountTokenRow,
type StorePurchaseByUserRow,
type StorePurchaseRow,
} from '@app/api/database/types/StoreBillingTypes';
import {StoreAccountTokens, StoreAccountTokensByUser, StorePurchases, StorePurchasesByUser} from '@app/api/Tables';
type StorePurchaseChanges = Partial<Omit<StorePurchaseRow, 'store_key' | 'version' | 'created_at'>>;
type StorePurchasePatch = Partial<{
[K in Exclude<keyof StorePurchaseRow, 'store_key'>]: DbOp<StorePurchaseRow[K]>;
}>;
const ACCOUNT_TOKEN_CREATE_ATTEMPTS = 3;
const FETCH_PURCHASE_QUERY = StorePurchases.selectCql({
where: StorePurchases.where.eq('store_key'),
limit: 1,
});
const FETCH_PURCHASE_KEYS_BY_USER_QUERY = StorePurchasesByUser.selectCql({
where: StorePurchasesByUser.where.eq('user_id'),
});
const FETCH_ACCOUNT_TOKEN_QUERY = StoreAccountTokens.selectCql({
where: StoreAccountTokens.where.eq('token_'),
limit: 1,
});
const FETCH_ACCOUNT_TOKEN_BY_USER_QUERY = StoreAccountTokensByUser.selectCql({
where: StoreAccountTokensByUser.where.eq('user_id'),
limit: 1,
});
export class StoreBillingRepository {
async findPurchase(storeKey: string): Promise<StorePurchaseRow | null> {
return fetchOne<StorePurchaseRow>(FETCH_PURCHASE_QUERY, {store_key: storeKey});
}
async listPurchasesForUser(userId: UserID): Promise<Array<StorePurchaseRow>> {
const refs = await fetchMany<StorePurchaseByUserRow>(FETCH_PURCHASE_KEYS_BY_USER_QUERY, {user_id: userId});
const rows = await Promise.all(refs.map((ref) => this.findPurchase(ref.store_key)));
return rows.filter((row): row is StorePurchaseRow => row !== null && row.user_id === userId);
}
async insertPurchase(row: StorePurchaseRow): Promise<boolean> {
if (row.user_id !== null) {
await this.addUserIndex(row.user_id, row);
}
return executeConditional(StorePurchases.insertIfNotExists(row));
}
async updatePurchase(current: StorePurchaseRow, changes: StorePurchaseChanges): Promise<StorePurchaseRow | null> {
const candidate: StorePurchaseRow = {...current, ...changes};
const patch = buildPatchFromData(candidate, current, STORE_PURCHASE_COLUMNS, ['store_key']) as StorePurchasePatch;
if (Object.keys(patch).length === 0) {
return current;
}
const updatedAt = changes.updated_at ?? new Date();
const version = nextVersion(current.version);
const next: StorePurchaseRow = {...candidate, updated_at: updatedAt, version};
if (next.user_id !== null && next.user_id !== current.user_id) {
await this.addUserIndex(next.user_id, next);
}
const applied = await executeConditional(
StorePurchases.conditionalPatchByPk(
{store_key: current.store_key},
{...patch, updated_at: Db.set(updatedAt), version: Db.set(version)},
{version: current.version},
),
);
if (!applied) {
return null;
}
if (current.user_id !== null && current.user_id !== next.user_id) {
await deleteOneOrMany(StorePurchasesByUser.deleteByPk({user_id: current.user_id, store_key: current.store_key}));
}
return next;
}
async bindPurchase(current: StorePurchaseRow, userId: UserID, boundAt: Date): Promise<StorePurchaseRow | null> {
return this.updatePurchase(current, {user_id: userId, bound_at: boundAt});
}
async unbindPurchase(current: StorePurchaseRow): Promise<StorePurchaseRow | null> {
return this.updatePurchase(current, {user_id: null, bound_at: null});
}
async releasePurchase(current: StorePurchaseRow): Promise<StorePurchaseRow | null> {
return this.updatePurchase(current, {user_id: null, bound_at: null, released_at: new Date()});
}
async setGiftCode(storeKey: string, giftCode: string): Promise<boolean> {
return executeConditional(
StorePurchases.conditionalPatchByPk(
{store_key: storeKey},
{gift_code: Db.set(giftCode), updated_at: Db.set(new Date())},
{kind: 'gift', gift_code: null},
),
);
}
async findUserIdByAccountToken(token: string): Promise<UserID | null> {
const row = await fetchOne<StoreAccountTokenRow>(FETCH_ACCOUNT_TOKEN_QUERY, {token_: token.toLowerCase()});
return row?.user_id ?? null;
}
async findAccountTokenForUser(userId: UserID): Promise<string | null> {
const row = await fetchOne<StoreAccountTokenByUserRow>(FETCH_ACCOUNT_TOKEN_BY_USER_QUERY, {user_id: userId});
return row?.token_ ?? null;
}
async getOrCreateAccountToken(userId: UserID): Promise<string> {
const existing = await this.findAccountTokenForUser(userId);
if (existing) {
return existing;
}
for (let attempt = 0; attempt < ACCOUNT_TOKEN_CREATE_ATTEMPTS; attempt++) {
const token = randomUUID().toLowerCase();
const createdAt = new Date();
const claimed = await executeConditional(
StoreAccountTokens.insertIfNotExists({token_: token, user_id: userId, created_at: createdAt}),
);
if (!claimed) {
continue;
}
const assigned = await executeConditional(
StoreAccountTokensByUser.insertIfNotExists({user_id: userId, token_: token, created_at: createdAt}),
);
if (assigned) {
return token;
}
await deleteOneOrMany(StoreAccountTokens.deleteByPk({token_: token}));
const winner = await this.findAccountTokenForUser(userId);
if (winner) {
return winner;
}
}
throw new Error('Could not allocate a store account token');
}
private async addUserIndex(userId: UserID, row: StorePurchaseRow): Promise<void> {
await upsertOne(
StorePurchasesByUser.upsertAll({user_id: userId, store_key: row.store_key, created_at: row.created_at}),
);
}
}
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes';
import type {StoreProductSlotName} from '@fluxer/config/src/MasterConfig';
export type StoreProvider = 'app_store' | 'google_play';
export type StorePurchaseKind = 'subscription' | 'gift';
export type StoreSlot = StoreProductSlotName;
export type StoreEnvironment = 'production' | 'sandbox';
export type StorePurchaseState =
| 'pending'
| 'active'
| 'grace'
| 'billing_retry'
| 'on_hold'
| 'paused'
| 'canceled'
| 'expired'
| 'revoked'
| 'superseded'
| 'purchased'
| 'fulfilled'
| 'refunded';
const TERMINAL_STORE_PURCHASE_STATES: ReadonlySet<StorePurchaseState> = new Set([
'expired',
'revoked',
'superseded',
'fulfilled',
'refunded',
]);
export const PAID_SUBSCRIPTION_STATES: ReadonlySet<StorePurchaseState> = new Set(['active', 'canceled', 'grace']);
export const LIFETIME_REFUND_REASON = 'lifetime_refund';
export const UNSUPPORTED_QUANTITY_REASON = 'unsupported_quantity';
export const GOOGLE_PLAY_VOIDED_REASON = 'google_play_voided';
export function isTerminalStorePurchaseState(state: StorePurchaseState): boolean {
return TERMINAL_STORE_PURCHASE_STATES.has(state);
}
export function isVoidedGooglePlaySubscriptionStillRenewing(row: StorePurchaseRow): boolean {
return (
row.provider === 'google_play' &&
row.kind === 'subscription' &&
row.state === 'revoked' &&
row.revocation_reason === GOOGLE_PLAY_VOIDED_REASON &&
row.auto_renew === true
);
}
export function buildAppStoreStoreKey(environment: StoreEnvironment, originalTransactionId: string): string {
return `app_store:${environment}:${originalTransactionId}`;
}
export function buildGooglePlayStoreKey(purchaseToken: string): string {
return `google_play:${purchaseToken}`;
}
export function redactStoreKey(storeKey: string): string {
if (!storeKey.startsWith('google_play:')) {
return storeKey;
}
return `google_play:${createHash('sha256').update(storeKey).digest('hex').slice(0, 16)}`;
}
export interface StorePurchaseSnapshot {
storeKey: string;
provider: StoreProvider;
kind: StorePurchaseKind;
slot: StoreSlot;
environment: StoreEnvironment;
appId: string;
productId: string;
basePlanId: string | null;
storeReference: string;
latestTransactionId: string | null;
appTransactionId: string | null;
accountToken: string | null;
ownershipType: string | null;
state: StorePurchaseState;
storeState: string | null;
providerEntitled: boolean;
expiresAt: Date | null;
graceEndsAt: Date | null;
autoRenew: boolean | null;
autoRenewProductId: string | null;
startedAt: Date | null;
purchasedAt: Date | null;
revokedAt: Date | null;
revocationReason: string | null;
linkedStoreKey: string | null;
expiredStoreKey: string | null;
expiredAccountToken: string | null;
acknowledged: boolean;
quantity: number;
region: string | null;
lastEventAt: Date;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import type {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {
getAppStoreServerApiClient,
getGooglePlayDeveloperApiClient,
getStoreBillingRepository,
} from '@app/api/middleware/ServiceSingletons';
import {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
interface StoreEntitlementServiceFactoryParams {
userRepository: IUserRepository;
userCacheService: UserCacheService;
gatewayService: IGatewayService;
kvClient: IKVProvider;
snowflakeService: ISnowflakeService;
premiumStateReconciliationQueueService: PremiumStateReconciliationQueueService;
}
export function createStoreEntitlementService(params: StoreEntitlementServiceFactoryParams): StoreEntitlementService {
return new StoreEntitlementService({
repository: getStoreBillingRepository(),
userRepository: params.userRepository,
userCacheService: params.userCacheService,
gatewayService: params.gatewayService,
kvClient: params.kvClient,
snowflakeService: params.snowflakeService,
appStoreClient: getAppStoreServerApiClient(),
googlePlayClient: getGooglePlayDeveloperApiClient(),
giftReversalHandler: new StripeGiftReversalHandler(
params.userRepository,
params.gatewayService,
params.premiumStateReconciliationQueueService,
),
});
}
@@ -0,0 +1,189 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes';
import type {UserRow} from '@app/api/database/types/UserTypes';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {Logger} from '@app/api/Logger';
import type {User} from '@app/api/models/User';
import {selectActiveStoreSubscription} from '@app/api/store_billing/StoreBillingMappers';
import type {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {clearPerksSanitizedFlag} from '@app/api/user/UserHelpers';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {UserFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
const USER_WRITE_ATTEMPTS = 3;
interface StoreEntitlementWriterDeps {
repository: StoreBillingRepository;
userRepository: IUserRepository;
userCacheService: UserCacheService;
gatewayService: IGatewayService;
}
function sameTime(left: Date | null | undefined, right: Date | null | undefined): boolean {
return (left?.getTime() ?? null) === (right?.getTime() ?? null);
}
export function isStripeSubscriptionActive(user: User, now: Date): boolean {
return (
Boolean(user.stripeSubscriptionId) &&
user.premiumBillingCycle !== null &&
user.premiumUntil !== null &&
user.premiumUntil > now
);
}
function setIfChanged<K extends keyof UserRow>(
patch: Partial<UserRow>,
key: K,
current: UserRow[K],
next: UserRow[K],
): void {
if (current instanceof Date || next instanceof Date) {
if (!sameTime(current as Date | null, next as Date | null)) {
patch[key] = next;
}
return;
}
if ((current ?? null) !== (next ?? null)) {
patch[key] = next;
}
}
function buildGrantPatch(user: User, row: StorePurchaseRow, now: Date): Partial<UserRow> {
const patch: Partial<UserRow> = {};
const expiresAt = row.expires_at;
if (!expiresAt) {
return patch;
}
const oldUntil = user.premiumUntil;
if (isStripeSubscriptionActive(user, now)) {
if (oldUntil && oldUntil.getTime() >= expiresAt.getTime()) {
return patch;
}
setIfChanged(patch, 'premium_type', user.premiumType, UserPremiumTypes.SUBSCRIPTION);
setIfChanged(patch, 'premium_until', oldUntil, expiresAt);
setIfChanged(patch, 'has_ever_purchased', user.hasEverPurchased, true);
return patch;
}
const inStoreGrace =
oldUntil !== null &&
user.premiumGraceEndsAt !== null &&
user.premiumGraceEndsAt.getTime() > oldUntil.getTime() &&
user.premiumGraceEndsAt.getTime() > now.getTime();
const anchorMs = inStoreGrace ? oldUntil.getTime() : Math.max(now.getTime(), oldUntil?.getTime() ?? 0);
const giftEnd = user.premiumGiftExtensionEndsAt;
const shiftMs = expiresAt.getTime() - anchorMs;
const shiftedGiftEnd =
giftEnd && shiftMs > 0 && giftEnd.getTime() > anchorMs ? new Date(giftEnd.getTime() + shiftMs) : giftEnd;
const startedAt = row.started_at ?? now;
const premiumSince = user.premiumSince && user.premiumSince <= startedAt ? user.premiumSince : startedAt;
setIfChanged(patch, 'premium_type', user.premiumType, UserPremiumTypes.SUBSCRIPTION);
setIfChanged(patch, 'premium_since', user.premiumSince, premiumSince);
setIfChanged(patch, 'premium_until', oldUntil, expiresAt);
setIfChanged(patch, 'premium_gift_extension_ends_at', giftEnd, shiftedGiftEnd);
setIfChanged(
patch,
'premium_grace_ends_at',
user.premiumGraceEndsAt,
row.state === 'grace' ? (row.grace_ends_at ?? null) : null,
);
setIfChanged(patch, 'premium_will_cancel', user.premiumWillCancel, row.auto_renew !== true);
setIfChanged(patch, 'premium_billing_cycle', user.premiumBillingCycle, null);
setIfChanged(patch, 'has_ever_purchased', user.hasEverPurchased, true);
setIfChanged(patch, 'premium_flags', user.premiumFlags, clearPerksSanitizedFlag(user.premiumFlags));
return patch;
}
function buildCutPatch(
user: User,
rows: Array<StorePurchaseRow>,
departedRows: Array<StorePurchaseRow>,
now: Date,
): Partial<UserRow> {
const patch: Partial<UserRow> = {};
const premiumUntil = user.premiumUntil;
if (user.premiumType !== UserPremiumTypes.SUBSCRIPTION || !premiumUntil || isStripeSubscriptionActive(user, now)) {
return patch;
}
const candidates = [
...rows.filter((row) => row.kind === 'subscription' && !row.entitled),
...departedRows.filter((row) => row.kind === 'subscription'),
];
const untilMs = premiumUntil.getTime();
const owner = candidates.find((row) =>
[row.expires_at, row.grace_ends_at, row.revoked_at].some((value) => value?.getTime() === untilMs),
);
if (!owner) {
return patch;
}
let cutMs = Math.min(untilMs, now.getTime());
if (owner.revoked_at) {
cutMs = Math.min(untilMs, owner.revoked_at.getTime());
}
const cutAt = new Date(cutMs);
setIfChanged(patch, 'premium_until', premiumUntil, cutAt);
setIfChanged(patch, 'premium_grace_ends_at', user.premiumGraceEndsAt, cutAt);
const giftEnd = user.premiumGiftExtensionEndsAt;
if (giftEnd && giftEnd.getTime() > untilMs && cutMs < untilMs) {
setIfChanged(patch, 'premium_gift_extension_ends_at', giftEnd, new Date(giftEnd.getTime() - (untilMs - cutMs)));
}
return patch;
}
export class StoreEntitlementWriter {
constructor(private readonly deps: StoreEntitlementWriterDeps) {}
async applyEntitlement(userId: UserID, departedRows: Array<StorePurchaseRow>): Promise<void> {
if (Config.instance.selfHosted) {
return;
}
for (let attempt = 1; attempt <= USER_WRITE_ATTEMPTS; attempt++) {
try {
await this.writeEntitlement(userId, departedRows);
return;
} catch (error) {
if (attempt === USER_WRITE_ATTEMPTS) {
throw error;
}
Logger.warn({error, userId: userId.toString(), attempt}, 'Retrying a store entitlement write');
}
}
}
async patchUserAndDispatch(user: User, patch: Partial<UserRow>): Promise<User> {
const updatedUser = await this.deps.userRepository.patchUpsert(user.id, patch, user.toRow());
this.deps.userCacheService.setUserPartialResponseFromUserInBackground(updatedUser);
await this.deps.gatewayService.dispatchPresence({
userId: updatedUser.id,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
return updatedUser;
}
private async writeEntitlement(userId: UserID, departedRows: Array<StorePurchaseRow>): Promise<void> {
const user = await this.deps.userRepository.findUnique(userId);
if (
!user ||
user.isBot ||
user.premiumType === UserPremiumTypes.LIFETIME ||
(user.flags & UserFlags.DELETED) !== 0n
) {
return;
}
const rows = await this.deps.repository.listPurchasesForUser(userId);
const now = new Date();
const best = selectActiveStoreSubscription(rows, now);
const patch = best ? buildGrantPatch(user, best, now) : buildCutPatch(user, rows, departedRows, now);
if (Object.keys(patch).length === 0) {
return;
}
await this.patchUserAndDispatch(user, patch);
Logger.debug({userId: userId.toString(), fields: Object.keys(patch)}, 'Applied store entitlement to user');
}
}
@@ -0,0 +1,162 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes';
import {Logger} from '@app/api/Logger';
import {type GiftCode, mapGiftDurationMonthsToFields} from '@app/api/models/GiftCode';
import type {GooglePlaySettlement} from '@app/api/store_billing/GooglePlaySettlement';
import {STORE_PRODUCT_SLOTS} from '@app/api/store_billing/StoreBillingConfig';
import {selectActiveStoreSubscription} from '@app/api/store_billing/StoreBillingMappers';
import type {StoreBillingRepository} from '@app/api/store_billing/StoreBillingRepository';
import {redactStoreKey, type StoreSlot, UNSUPPORTED_QUANTITY_REASON} from '@app/api/store_billing/StoreBillingTypes';
import type {StoreEntitlementWriter} from '@app/api/store_billing/StoreEntitlementWriter';
import type {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import * as RandomUtils from '@app/api/utils/RandomUtils';
const PURCHASE_WRITE_ATTEMPTS = 4;
const GIFT_REFUND_REVERSAL_REASON = 'store_refund';
interface StoreGiftFulfilmentDeps {
repository: StoreBillingRepository;
userRepository: IUserRepository;
giftReversalHandler: StripeGiftReversalHandler;
googlePlaySettlement: GooglePlaySettlement;
entitlementWriter: StoreEntitlementWriter;
}
export class StoreGiftFulfilment {
constructor(private readonly deps: StoreGiftFulfilmentDeps) {}
async settleGift(row: StorePurchaseRow): Promise<StorePurchaseRow> {
if (row.state === 'refunded') {
if (row.gift_code) {
await this.reverseStoreGift(row.gift_code, row);
}
return row;
}
if (row.revocation_reason === UNSUPPORTED_QUANTITY_REASON) {
return this.deps.googlePlaySettlement.refundUnsupportedGiftQuantity(row);
}
if (!row.entitled || row.user_id === null || (row.state !== 'purchased' && row.state !== 'fulfilled')) {
return row;
}
let current = row;
if (!current.gift_code) {
const candidate = await this.generateUniqueGiftCode();
await this.deps.repository.setGiftCode(current.store_key, candidate);
const reloaded = await this.deps.repository.findPurchase(current.store_key);
if (!reloaded?.gift_code) {
return current;
}
current = reloaded;
}
const giftCode = current.gift_code;
if (!giftCode || current.user_id === null) {
return current;
}
const existingGift = await this.deps.userRepository.findGiftCode(giftCode);
if (!existingGift) {
await this.createStoreGiftCode(giftCode, current.user_id, current.slot);
} else if (existingGift.revokedAt) {
await this.restoreStoreGift(existingGift, current);
}
let acknowledged = current.acknowledged;
if (current.provider === 'google_play' && !acknowledged) {
acknowledged = await this.deps.googlePlaySettlement.consumeProduct(current);
}
if (current.state !== 'fulfilled' || acknowledged !== current.acknowledged) {
for (let attempt = 0; attempt < PURCHASE_WRITE_ATTEMPTS; attempt++) {
const updated = await this.deps.repository.updatePurchase(current, {state: 'fulfilled', acknowledged});
if (updated) {
return updated;
}
const reloaded = await this.deps.repository.findPurchase(current.store_key);
if (!reloaded) {
break;
}
current = reloaded;
}
}
return current;
}
private async createStoreGiftCode(code: string, purchaserId: UserID, slot: StoreSlot): Promise<void> {
const duration = mapGiftDurationMonthsToFields(STORE_PRODUCT_SLOTS[slot].durationMonths);
await this.deps.userRepository.createGiftCode({
code,
duration_months: null,
duration_type: duration.durationType,
duration_quantity: duration.durationQuantity,
created_at: new Date(),
created_by_user_id: purchaserId,
redeemed_at: null,
redeemed_by_user_id: null,
stripe_payment_intent_id: null,
visionary_sequence_number: null,
checkout_session_id: null,
version: 1,
});
const purchaser = await this.deps.userRepository.findUnique(purchaserId);
if (!purchaser) {
return;
}
await this.deps.entitlementWriter.patchUserAndDispatch(purchaser, {
gift_inventory_server_seq: (purchaser.giftInventoryServerSeq ?? 0) + 1,
...(purchaser.hasEverPurchased ? {} : {has_ever_purchased: true}),
});
Logger.info({purchaserId: purchaserId.toString(), slot}, 'Minted a gift code from a store purchase');
}
private async reverseStoreGift(code: string, row: StorePurchaseRow): Promise<void> {
const gift = await this.deps.userRepository.findGiftCode(code);
if (!gift || gift.revokedAt) {
return;
}
const redeemerId = gift.redeemedByUserId;
if (redeemerId !== null) {
await this.reverseRedeemedStoreGift(gift, redeemerId);
}
await this.deps.userRepository.revokeGiftCode(code);
Logger.info(
{storeKey: redactStoreKey(row.store_key), redeemerId: redeemerId?.toString() ?? null},
'Revoked a gift code after a store refund',
);
}
private async reverseRedeemedStoreGift(gift: GiftCode, redeemerId: UserID): Promise<void> {
const redeemerRows = await this.deps.repository.listPurchasesForUser(redeemerId);
if (!selectActiveStoreSubscription(redeemerRows, new Date())) {
await this.deps.giftReversalHandler.handleGiftPremiumReversal(gift, {reason: GIFT_REFUND_REVERSAL_REASON});
return;
}
const redeemer = await this.deps.userRepository.findUnique(redeemerId);
if (!redeemer) {
return;
}
await this.deps.giftReversalHandler.reverseStackedGift(redeemer, gift);
await this.deps.entitlementWriter.applyEntitlement(redeemerId, []);
}
private async restoreStoreGift(gift: GiftCode, row: StorePurchaseRow): Promise<void> {
const redeemerId = gift.redeemedByUserId;
if (redeemerId !== null) {
await this.deps.giftReversalHandler.restoreReversedGift(gift);
await this.deps.entitlementWriter.applyEntitlement(redeemerId, []);
}
await this.deps.userRepository.unrevokeGiftCode(gift.code);
Logger.info(
{storeKey: redactStoreKey(row.store_key), redeemerId: redeemerId?.toString() ?? null},
'Reinstated a gift code after a store refund was reversed',
);
}
private async generateUniqueGiftCode(): Promise<string> {
for (;;) {
const code = RandomUtils.randomString(32);
if (!(await this.deps.userRepository.findGiftCode(code))) {
return code;
}
}
}
}
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {ms, seconds} from 'itty-time';
const NOTIFICATION_IN_FLIGHT_TTL_SECONDS = seconds('90 seconds');
const NOTIFICATION_PROCESSED_TTL_SECONDS = seconds('7 days');
const STORE_NOTIFICATION_MAX_RETRIES = 12;
const STORE_NOTIFICATION_RETRY_BASE_MS = ms('5 minutes');
const STORE_NOTIFICATION_RETRY_MAX_MS = ms('12 hours');
function storeNotificationRetryDelayMs(retry: number): number {
return Math.min(STORE_NOTIFICATION_RETRY_BASE_MS * 2 ** retry, STORE_NOTIFICATION_RETRY_MAX_MS);
}
export async function rescheduleStoreNotification(
helpers: WorkerTaskHelpers,
taskType: 'processAppStoreNotification' | 'processGooglePlayNotification',
payload: Record<string, string>,
retry: number,
error: unknown,
): Promise<boolean> {
if (!helpers.attempt?.isLastAttempt || retry >= STORE_NOTIFICATION_MAX_RETRIES) {
return false;
}
await helpers.addJob(
taskType,
{...payload, retry: retry + 1},
{runAt: new Date(Date.now() + storeNotificationRetryDelayMs(retry))},
);
helpers.logger.warn({error, retry: retry + 1}, 'Rescheduled a store notification after repeated failures');
return true;
}
type StoreNotificationClaimResult = 'claimed' | 'already_processed' | 'in_flight';
export function appStoreNotificationClaimKey(notificationUUID: string): string {
return `app-store-notification:${notificationUUID}`;
}
export function googlePlayMessageClaimKey(messageId: string): string {
return `google-play-message:${messageId}`;
}
export class StoreNotificationClaims {
constructor(private readonly kv: IKVProvider) {}
async tryClaim(key: string): Promise<StoreNotificationClaimResult> {
const claimed = await this.kv.setnx(key, 'in_flight', NOTIFICATION_IN_FLIGHT_TTL_SECONDS);
if (claimed) {
return 'claimed';
}
const current = await this.kv.get(key);
return current === 'processed' ? 'already_processed' : 'in_flight';
}
async markProcessed(key: string): Promise<void> {
await this.kv.setex(key, NOTIFICATION_PROCESSED_TTL_SECONDS, 'processed');
}
async releaseClaim(key: string): Promise<void> {
await this.kv.del(key);
}
}
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import type {StorePurchaseRow} from '@app/api/database/types/StoreBillingTypes';
import {
isTerminalStorePurchaseState,
isVoidedGooglePlaySubscriptionStillRenewing,
LIFETIME_REFUND_REASON,
PAID_SUBSCRIPTION_STATES,
type StorePurchaseState,
UNSUPPORTED_QUANTITY_REASON,
} from '@app/api/store_billing/StoreBillingTypes';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {ms} from 'itty-time';
export const STORE_PURCHASE_REFRESH_QUEUE_KEY = 'store:purchase:refresh';
export const REFRESH_WATCH_DELAY_MS = ms('6 hours');
const REFRESH_AFTER_EXPIRY_MS = ms('10 minutes');
const REFRESH_MAX_DELAY_MS = ms('7 days');
const REFRESH_MIN_DELAY_MS = ms('5 minutes');
const REFRESH_UNSETTLED_DELAY_MS = ms('1 hour');
const REFRESH_UNSETTLED_SANDBOX_DELAY_MS = ms('1 minute');
const REFRESH_LAPSED_DELAY_MS = ms('1 hour');
const WATCHED_STATES: ReadonlySet<StorePurchaseState> = new Set([
'grace',
'billing_retry',
'on_hold',
'paused',
'pending',
]);
function needsGooglePlaySettle(row: StorePurchaseRow): boolean {
if (row.provider !== 'google_play') {
return false;
}
if (row.kind === 'gift') {
if (row.revocation_reason === UNSUPPORTED_QUANTITY_REASON) {
return !row.revoked_at && row.state !== 'refunded';
}
return (
!row.acknowledged &&
row.user_id !== null &&
row.entitled &&
(row.state === 'purchased' || row.state === 'fulfilled')
);
}
return (
!row.acknowledged &&
row.user_id !== null &&
row.revocation_reason !== LIFETIME_REFUND_REASON &&
PAID_SUBSCRIPTION_STATES.has(row.state)
);
}
export async function scheduleStorePurchaseRefresh(kv: IKVProvider, row: StorePurchaseRow): Promise<void> {
if (Config.instance.selfHosted) {
return;
}
const needsSettle = needsGooglePlaySettle(row);
const keepWatching =
needsSettle ||
(row.kind === 'subscription'
? !isTerminalStorePurchaseState(row.state) || isVoidedGooglePlaySubscriptionStillRenewing(row)
: row.state === 'pending');
if (!keepWatching) {
await kv.zrem(STORE_PURCHASE_REFRESH_QUEUE_KEY, row.store_key);
return;
}
const now = Date.now();
let dueAt = now + REFRESH_WATCH_DELAY_MS;
if (needsSettle) {
dueAt = now + (row.environment === 'sandbox' ? REFRESH_UNSETTLED_SANDBOX_DELAY_MS : REFRESH_UNSETTLED_DELAY_MS);
} else if (!WATCHED_STATES.has(row.state) && row.expires_at) {
if (row.expires_at.getTime() <= now) {
dueAt = now + REFRESH_LAPSED_DELAY_MS;
} else {
dueAt = Math.min(row.expires_at.getTime() + REFRESH_AFTER_EXPIRY_MS, now + REFRESH_MAX_DELAY_MS);
dueAt = Math.max(dueAt, now + REFRESH_MIN_DELAY_MS);
}
}
await kv.zadd(STORE_PURCHASE_REFRESH_QUEUE_KEY, dueAt, row.store_key);
}
@@ -0,0 +1,503 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type KeyObject, X509Certificate} from 'node:crypto';
import type {AppStoreAppConfig} from '@app/api/config/APIConfig';
import {getAppleRootCertificates} from '@app/api/store_billing/app_store/AppleRootCertificates';
import {getAppStoreApp} from '@app/api/store_billing/StoreBillingConfig';
import type {StoreEnvironment} from '@app/api/store_billing/StoreBillingTypes';
import {ms} from 'itty-time';
import {compactVerify, decodeProtectedHeader} from 'jose';
import {z} from 'zod';
const APPLE_RECEIPT_SIGNING_EXTENSION_OID = '1.2.840.113635.100.6.11.1';
const APPLE_WWDR_INTERMEDIATE_EXTENSION_OID = '1.2.840.113635.100.6.2.1';
const APPLE_CERTIFICATE_CHAIN_LENGTH = 3;
const CERTIFICATE_DATE_SKEW_MS = ms('1 minute');
const STANDARD_BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
const BASE64URL_SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/;
const DER_SEQUENCE = 0x30;
const DER_OBJECT_IDENTIFIER = 0x06;
const DER_EXTENSIONS_CONTEXT_TAG = 0xa3;
export type AppStoreEnvironmentName = 'Production' | 'Sandbox';
export type AppStoreJwsFailureReason =
| 'malformed'
| 'unsupported_algorithm'
| 'invalid_chain_length'
| 'invalid_certificate'
| 'untrusted_chain'
| 'missing_certificate_extension'
| 'certificate_expired'
| 'invalid_signature'
| 'invalid_payload'
| 'invalid_app_identifier'
| 'invalid_environment';
export class AppStoreJwsVerificationError extends Error {
constructor(
readonly reason: AppStoreJwsFailureReason,
options?: {cause?: unknown},
) {
super(`App Store signed data rejected: ${reason}`, options);
this.name = 'AppStoreJwsVerificationError';
}
}
const AppStoreTransactionPayloadSchema = z.object({
transactionId: z.string().min(1),
originalTransactionId: z.string().min(1),
bundleId: z.string().min(1),
productId: z.string().min(1),
type: z.string().min(1),
purchaseDate: z.number(),
signedDate: z.number(),
environment: z.string(),
originalPurchaseDate: z.number().nullish(),
expiresDate: z.number().nullish(),
webOrderLineItemId: z.string().nullish(),
subscriptionGroupIdentifier: z.string().nullish(),
quantity: z.number().nullish(),
appAccountToken: z.string().nullish(),
appTransactionId: z.string().nullish(),
inAppOwnershipType: z.string().nullish(),
revocationDate: z.number().nullish(),
revocationReason: z.number().nullish(),
revocationType: z.string().nullish(),
revocationPercentage: z.number().nullish(),
isUpgraded: z.boolean().nullish(),
offerType: z.number().nullish(),
offerIdentifier: z.string().nullish(),
offerDiscountType: z.string().nullish(),
offerPeriod: z.string().nullish(),
storefront: z.string().nullish(),
storefrontId: z.string().nullish(),
transactionReason: z.string().nullish(),
currency: z.string().nullish(),
price: z.number().nullish(),
});
const AppStoreRenewalInfoPayloadSchema = z.object({
originalTransactionId: z.string().min(1),
productId: z.string().min(1),
signedDate: z.number(),
environment: z.string(),
autoRenewStatus: z.number().nullish(),
autoRenewProductId: z.string().nullish(),
expirationIntent: z.number().nullish(),
gracePeriodExpiresDate: z.number().nullish(),
isInBillingRetryPeriod: z.boolean().nullish(),
priceIncreaseStatus: z.number().nullish(),
recentSubscriptionStartDate: z.number().nullish(),
renewalDate: z.number().nullish(),
renewalPrice: z.number().nullish(),
currency: z.string().nullish(),
offerType: z.number().nullish(),
offerIdentifier: z.string().nullish(),
offerDiscountType: z.string().nullish(),
offerPeriod: z.string().nullish(),
eligibleWinBackOfferIds: z.array(z.string()).nullish(),
appAccountToken: z.string().nullish(),
appTransactionId: z.string().nullish(),
});
const AppStoreNotificationDataSchema = z.object({
bundleId: z.string().min(1),
environment: z.string(),
appAppleId: z.number().nullish(),
bundleVersion: z.string().nullish(),
signedTransactionInfo: z.string().nullish(),
signedRenewalInfo: z.string().nullish(),
status: z.number().nullish(),
consumptionRequestReason: z.string().nullish(),
});
const AppStoreNotificationSummarySchema = z.object({
bundleId: z.string().min(1),
environment: z.string(),
appAppleId: z.number().nullish(),
requestIdentifier: z.string().nullish(),
productId: z.string().nullish(),
storefrontCountryCodes: z.array(z.string()).nullish(),
failedCount: z.number().nullish(),
succeededCount: z.number().nullish(),
});
const AppStoreNotificationAppDataSchema = z.object({
bundleId: z.string().min(1),
environment: z.string(),
appAppleId: z.number().nullish(),
signedAppTransactionInfo: z.string().nullish(),
});
const AppStoreExternalPurchaseTokenSchema = z.object({
bundleId: z.string().min(1),
appAppleId: z.number().nullish(),
externalPurchaseId: z.string().nullish(),
tokenCreationDate: z.number().nullish(),
});
const AppStoreNotificationPayloadSchema = z.object({
notificationType: z.string().min(1),
notificationUUID: z.string().min(1),
signedDate: z.number(),
subtype: z.string().nullish(),
version: z.string().nullish(),
data: AppStoreNotificationDataSchema.nullish(),
summary: AppStoreNotificationSummarySchema.nullish(),
appData: AppStoreNotificationAppDataSchema.nullish(),
externalPurchaseToken: AppStoreExternalPurchaseTokenSchema.nullish(),
});
type WithEnvironment<T> = Omit<T, 'environment'> & {environment: AppStoreEnvironmentName};
export type AppStoreTransactionPayload = WithEnvironment<z.infer<typeof AppStoreTransactionPayloadSchema>>;
export type AppStoreRenewalInfoPayload = WithEnvironment<z.infer<typeof AppStoreRenewalInfoPayloadSchema>>;
export type AppStoreNotificationPayload = z.infer<typeof AppStoreNotificationPayloadSchema>;
export interface AppStoreVerifiedNotification {
notificationType: string;
subtype: string | null;
notificationUUID: string;
signedDate: number;
environment: AppStoreEnvironmentName;
bundleId: string;
appAppleId: number | null;
status: number | null;
transaction: AppStoreTransactionPayload | null;
renewalInfo: AppStoreRenewalInfoPayload | null;
payload: AppStoreNotificationPayload;
}
export interface AppStoreVerifyOptions {
expectedEnvironment?: StoreEnvironment | null;
}
export function toStoreEnvironment(environment: AppStoreEnvironmentName): StoreEnvironment {
return environment === 'Production' ? 'production' : 'sandbox';
}
function fail(reason: AppStoreJwsFailureReason, cause?: unknown): never {
throw new AppStoreJwsVerificationError(reason, cause === undefined ? undefined : {cause});
}
interface DerElement {
tag: number;
contentStart: number;
end: number;
}
function readDerElement(buffer: Buffer, offset: number, limit: number): DerElement {
if (offset + 2 > limit) {
throw new RangeError('Truncated DER element');
}
const tag = buffer[offset];
if ((tag & 0x1f) === 0x1f) {
throw new RangeError('Unsupported DER tag');
}
const firstLengthByte = buffer[offset + 1];
let position = offset + 2;
let length = firstLengthByte;
if (firstLengthByte >= 0x80) {
const lengthBytes = firstLengthByte & 0x7f;
if (lengthBytes === 0 || lengthBytes > 4 || position + lengthBytes > limit) {
throw new RangeError('Invalid DER length');
}
length = 0;
for (let i = 0; i < lengthBytes; i++) {
length = length * 256 + buffer[position + i];
}
position += lengthBytes;
}
const end = position + length;
if (end > limit) {
throw new RangeError('DER element exceeds its parent');
}
return {tag, contentStart: position, end};
}
function readDerChildren(buffer: Buffer, parent: DerElement): Array<DerElement> {
const children: Array<DerElement> = [];
let offset = parent.contentStart;
while (offset < parent.end) {
const child = readDerElement(buffer, offset, parent.end);
children.push(child);
offset = child.end;
}
return children;
}
function encodeObjectIdentifier(oid: string): Buffer {
const arcs = oid.split('.').map((arc) => Number.parseInt(arc, 10));
const bytes: Array<number> = [arcs[0] * 40 + arcs[1]];
for (const arc of arcs.slice(2)) {
const encoded = [arc & 0x7f];
let remaining = Math.floor(arc / 128);
while (remaining > 0) {
encoded.unshift((remaining & 0x7f) | 0x80);
remaining = Math.floor(remaining / 128);
}
bytes.push(...encoded);
}
return Buffer.from(bytes);
}
function listCertificateExtensionOids(der: Buffer): Array<Buffer> {
const certificate = readDerElement(der, 0, der.length);
if (certificate.tag !== DER_SEQUENCE || certificate.end !== der.length) {
throw new RangeError('Certificate is not a DER sequence');
}
const [tbsCertificate] = readDerChildren(der, certificate);
if (tbsCertificate?.tag !== DER_SEQUENCE) {
throw new RangeError('Certificate has no TBSCertificate');
}
const extensionsWrapper = readDerChildren(der, tbsCertificate).find(
(element) => element.tag === DER_EXTENSIONS_CONTEXT_TAG,
);
if (!extensionsWrapper) {
return [];
}
const [extensions] = readDerChildren(der, extensionsWrapper);
if (extensions?.tag !== DER_SEQUENCE) {
throw new RangeError('Certificate extensions are not a sequence');
}
return readDerChildren(der, extensions).map((extension) => {
const [extensionId] = readDerChildren(der, extension);
if (extension.tag !== DER_SEQUENCE || extensionId?.tag !== DER_OBJECT_IDENTIFIER) {
throw new RangeError('Certificate extension has no identifier');
}
return der.subarray(extensionId.contentStart, extensionId.end);
});
}
export function certificateHasExtension(certificate: X509Certificate, oid: string): boolean {
const expected = encodeObjectIdentifier(oid);
try {
return listCertificateExtensionOids(certificate.raw).some((extensionOid) => extensionOid.equals(expected));
} catch {
return false;
}
}
function parseChainCertificate(encoded: unknown): X509Certificate {
if (typeof encoded !== 'string' || encoded.length % 4 !== 0 || !STANDARD_BASE64_PATTERN.test(encoded)) {
fail('invalid_certificate');
}
try {
return new X509Certificate(Buffer.from(encoded, 'base64'));
} catch (error) {
fail('invalid_certificate', error);
}
}
function safeVerify(certificate: X509Certificate, issuer: X509Certificate): boolean {
try {
return certificate.verify(issuer.publicKey);
} catch {
return false;
}
}
function isValidAt(certificate: X509Certificate, effectiveDate: Date): boolean {
const time = effectiveDate.getTime();
return (
certificate.validFromDate.getTime() <= time + CERTIFICATE_DATE_SKEW_MS &&
certificate.validToDate.getTime() >= time - CERTIFICATE_DATE_SKEW_MS
);
}
export function verifyAppStoreCertificateChain(
x5c: ReadonlyArray<unknown>,
effectiveDate: Date,
roots: ReadonlyArray<X509Certificate> = getAppleRootCertificates(),
): KeyObject {
if (x5c.length !== APPLE_CERTIFICATE_CHAIN_LENGTH) {
fail('invalid_chain_length');
}
const leaf = parseChainCertificate(x5c[0]);
const intermediate = parseChainCertificate(x5c[1]);
const root = roots.find(
(candidate) => intermediate.issuer === candidate.subject && safeVerify(intermediate, candidate),
);
if (!root || !intermediate.ca) {
fail('untrusted_chain');
}
if (leaf.ca || leaf.issuer !== intermediate.subject || !safeVerify(leaf, intermediate)) {
fail('untrusted_chain');
}
if (
!certificateHasExtension(leaf, APPLE_RECEIPT_SIGNING_EXTENSION_OID) ||
!certificateHasExtension(intermediate, APPLE_WWDR_INTERMEDIATE_EXTENSION_OID)
) {
fail('missing_certificate_extension');
}
if (!isValidAt(leaf, effectiveDate) || !isValidAt(intermediate, effectiveDate) || !isValidAt(root, effectiveDate)) {
fail('certificate_expired');
}
return leaf.publicKey;
}
function decodeUnverifiedPayload(segment: string): Record<string, unknown> {
let decoded: unknown;
try {
decoded = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
} catch (error) {
fail('malformed', error);
}
if (decoded === null || typeof decoded !== 'object' || Array.isArray(decoded)) {
fail('malformed');
}
return decoded as Record<string, unknown>;
}
export async function verifyAppStoreSignedData(
signedData: string,
roots: ReadonlyArray<X509Certificate> = getAppleRootCertificates(),
): Promise<unknown> {
if (typeof signedData !== 'string') {
fail('malformed');
}
const segments = signedData.split('.');
if (segments.length !== 3 || !segments.every((segment) => BASE64URL_SEGMENT_PATTERN.test(segment))) {
fail('malformed');
}
let header: ReturnType<typeof decodeProtectedHeader>;
try {
header = decodeProtectedHeader(signedData);
} catch (error) {
fail('malformed', error);
}
if (header.alg !== 'ES256') {
fail('unsupported_algorithm');
}
if (!Array.isArray(header.x5c)) {
fail('invalid_chain_length');
}
const unverified = decodeUnverifiedPayload(segments[1]);
const signedDate = unverified.signedDate;
if (typeof signedDate !== 'number' || !Number.isFinite(signedDate)) {
fail('invalid_payload');
}
const leafKey = verifyAppStoreCertificateChain(header.x5c, new Date(signedDate), roots);
let verifiedBytes: Uint8Array;
try {
const result = await compactVerify(signedData, leafKey, {algorithms: ['ES256']});
verifiedBytes = result.payload;
} catch (error) {
fail('invalid_signature', error);
}
try {
return JSON.parse(Buffer.from(verifiedBytes).toString('utf8'));
} catch (error) {
fail('malformed', error);
}
}
function parsePayload<T>(schema: z.ZodType<T>, payload: unknown): T {
const result = schema.safeParse(payload);
if (!result.success) {
fail('invalid_payload', result.error);
}
return result.data;
}
function resolveEnvironment(
environment: string,
expected: StoreEnvironment | null | undefined,
): AppStoreEnvironmentName {
if (environment !== 'Production' && environment !== 'Sandbox') {
fail('invalid_environment');
}
if (expected && toStoreEnvironment(environment) !== expected) {
fail('invalid_environment');
}
return environment;
}
function resolveApp(bundleId: string): AppStoreAppConfig {
const app = getAppStoreApp(bundleId);
if (!app) {
fail('invalid_app_identifier');
}
return app;
}
export async function verifyTransaction(
signedTransaction: string,
options: AppStoreVerifyOptions = {},
): Promise<AppStoreTransactionPayload> {
const payload = parsePayload(AppStoreTransactionPayloadSchema, await verifyAppStoreSignedData(signedTransaction));
resolveApp(payload.bundleId);
const environment = resolveEnvironment(payload.environment, options.expectedEnvironment);
return {...payload, environment};
}
export async function verifyRenewalInfo(
signedRenewalInfo: string,
options: AppStoreVerifyOptions = {},
): Promise<AppStoreRenewalInfoPayload> {
const payload = parsePayload(AppStoreRenewalInfoPayloadSchema, await verifyAppStoreSignedData(signedRenewalInfo));
const environment = resolveEnvironment(payload.environment, options.expectedEnvironment);
return {...payload, environment};
}
interface NotificationIdentity {
bundleId: string;
appAppleId: number | null;
environment: string;
}
function resolveNotificationIdentity(payload: AppStoreNotificationPayload): NotificationIdentity {
const block = payload.data ?? payload.summary ?? payload.appData;
if (block) {
return {bundleId: block.bundleId, appAppleId: block.appAppleId ?? null, environment: block.environment};
}
const token = payload.externalPurchaseToken;
if (token) {
return {
bundleId: token.bundleId,
appAppleId: token.appAppleId ?? null,
environment: token.externalPurchaseId?.startsWith('SANDBOX') ? 'Sandbox' : 'Production',
};
}
fail('invalid_payload');
}
export async function verifyNotification(
signedPayload: string,
options: AppStoreVerifyOptions = {},
): Promise<AppStoreVerifiedNotification> {
const payload = parsePayload(AppStoreNotificationPayloadSchema, await verifyAppStoreSignedData(signedPayload));
const identity = resolveNotificationIdentity(payload);
const app = resolveApp(identity.bundleId);
const environment = resolveEnvironment(identity.environment, options.expectedEnvironment);
if (environment === 'Production' && identity.appAppleId !== app.appAppleId) {
fail('invalid_app_identifier');
}
const nestedOptions: AppStoreVerifyOptions = {expectedEnvironment: toStoreEnvironment(environment)};
const signedTransactionInfo = payload.data?.signedTransactionInfo;
const signedRenewalInfo = payload.data?.signedRenewalInfo;
const transaction = signedTransactionInfo ? await verifyTransaction(signedTransactionInfo, nestedOptions) : null;
const renewalInfo = signedRenewalInfo ? await verifyRenewalInfo(signedRenewalInfo, nestedOptions) : null;
if (transaction && transaction.bundleId !== identity.bundleId) {
fail('invalid_app_identifier');
}
if (transaction && renewalInfo && transaction.originalTransactionId !== renewalInfo.originalTransactionId) {
fail('invalid_payload');
}
return {
notificationType: payload.notificationType,
subtype: payload.subtype ?? null,
notificationUUID: payload.notificationUUID,
signedDate: payload.signedDate,
environment,
bundleId: identity.bundleId,
appAppleId: identity.appAppleId,
status: payload.data?.status ?? null,
transaction,
renewalInfo,
payload,
};
}
@@ -0,0 +1,253 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type AppStoreRenewalInfoPayload,
type AppStoreTransactionPayload,
toStoreEnvironment,
} from '@app/api/store_billing/app_store/AppStoreJwsVerifier';
import type {
AppStoreLastTransaction,
AppStoreServerApiClient,
} from '@app/api/store_billing/app_store/AppStoreServerApiClient';
import {getAppStoreProductSlot, STORE_PRODUCT_SLOTS} from '@app/api/store_billing/StoreBillingConfig';
import {
buildAppStoreStoreKey,
type StoreEnvironment,
type StorePurchaseSnapshot,
type StorePurchaseState,
type StoreSlot,
} from '@app/api/store_billing/StoreBillingTypes';
export const APP_STORE_SUBSCRIPTION_STATUS_ACTIVE = 1;
export const APP_STORE_SUBSCRIPTION_STATUS_EXPIRED = 2;
export const APP_STORE_SUBSCRIPTION_STATUS_BILLING_RETRY = 3;
export const APP_STORE_SUBSCRIPTION_STATUS_GRACE = 4;
export const APP_STORE_SUBSCRIPTION_STATUS_REVOKED = 5;
const APP_STORE_AUTO_RENEWABLE_TYPE = 'Auto-Renewable Subscription';
const APP_STORE_AUTO_RENEW_OFF = 0;
const APP_STORE_AUTO_RENEW_ON = 1;
export type AppStorePurchaseSyncFailureReason = 'unknown_product' | 'not_found' | 'mismatch' | 'missing_expiry';
export class AppStorePurchaseSyncError extends Error {
constructor(readonly reason: AppStorePurchaseSyncFailureReason) {
super(`App Store purchase could not be synced: ${reason}`);
this.name = 'AppStorePurchaseSyncError';
}
}
export interface AppStorePurchaseLookup {
environment: StoreEnvironment | null;
bundleId: string;
productId: string;
transactionId: string;
originalTransactionId: string;
}
function toDate(value: number | null | undefined): Date | null {
return value === null || value === undefined ? null : new Date(value);
}
function resolveSlot(productId: string): StoreSlot {
const slot = getAppStoreProductSlot(productId);
if (!slot) {
throw new AppStorePurchaseSyncError('unknown_product');
}
return slot;
}
function normalizeAccountToken(token: string | null | undefined): string | null {
return token ? token.toLowerCase() : null;
}
function revocationReason(transaction: AppStoreTransactionPayload): string | null {
if (transaction.revocationType) {
return transaction.revocationType;
}
return transaction.revocationReason === null || transaction.revocationReason === undefined
? null
: String(transaction.revocationReason);
}
export function resolveAppStoreSubscriptionState(
status: number,
transaction: AppStoreTransactionPayload,
renewalInfo: AppStoreRenewalInfoPayload | null,
): StorePurchaseState {
if (status === APP_STORE_SUBSCRIPTION_STATUS_REVOKED || transaction.revocationDate) {
return 'revoked';
}
switch (status) {
case APP_STORE_SUBSCRIPTION_STATUS_ACTIVE:
return renewalInfo?.autoRenewStatus === APP_STORE_AUTO_RENEW_OFF ? 'canceled' : 'active';
case APP_STORE_SUBSCRIPTION_STATUS_GRACE:
return 'grace';
case APP_STORE_SUBSCRIPTION_STATUS_BILLING_RETRY:
return 'billing_retry';
case APP_STORE_SUBSCRIPTION_STATUS_EXPIRED:
return 'expired';
default:
return 'pending';
}
}
export function buildAppStoreSubscriptionSnapshot(item: AppStoreLastTransaction): StorePurchaseSnapshot {
const {transaction, renewalInfo, status} = item;
if (transaction.originalTransactionId !== item.originalTransactionId) {
throw new AppStorePurchaseSyncError('mismatch');
}
if (transaction.type !== APP_STORE_AUTO_RENEWABLE_TYPE) {
throw new AppStorePurchaseSyncError('mismatch');
}
const slot = resolveSlot(transaction.productId);
if (STORE_PRODUCT_SLOTS[slot].kind !== 'subscription') {
throw new AppStorePurchaseSyncError('mismatch');
}
if (transaction.expiresDate === null || transaction.expiresDate === undefined) {
throw new AppStorePurchaseSyncError('missing_expiry');
}
const environment = toStoreEnvironment(transaction.environment);
const state = resolveAppStoreSubscriptionState(status, transaction, renewalInfo);
const graceEndsAt =
state === 'grace' ? new Date(renewalInfo?.gracePeriodExpiresDate ?? transaction.expiresDate) : null;
const autoRenewStatus = renewalInfo?.autoRenewStatus;
const lastEventMs = Math.max(transaction.signedDate, renewalInfo?.signedDate ?? 0);
return {
storeKey: buildAppStoreStoreKey(environment, item.originalTransactionId),
provider: 'app_store',
kind: 'subscription',
slot,
environment,
appId: transaction.bundleId,
productId: transaction.productId,
basePlanId: null,
storeReference: item.originalTransactionId,
latestTransactionId: transaction.transactionId,
appTransactionId: transaction.appTransactionId ?? renewalInfo?.appTransactionId ?? null,
accountToken: normalizeAccountToken(renewalInfo?.appAccountToken ?? transaction.appAccountToken),
ownershipType: transaction.inAppOwnershipType ?? null,
state,
storeState: String(status),
providerEntitled: state === 'active' || state === 'canceled' || state === 'grace',
expiresAt: new Date(transaction.expiresDate),
graceEndsAt,
autoRenew:
autoRenewStatus === null || autoRenewStatus === undefined ? null : autoRenewStatus === APP_STORE_AUTO_RENEW_ON,
autoRenewProductId: renewalInfo?.autoRenewProductId ?? null,
startedAt: new Date(renewalInfo?.recentSubscriptionStartDate ?? transaction.purchaseDate),
purchasedAt: new Date(transaction.purchaseDate),
revokedAt: toDate(transaction.revocationDate),
revocationReason: revocationReason(transaction),
linkedStoreKey: null,
expiredStoreKey: null,
expiredAccountToken: null,
acknowledged: true,
quantity: 1,
region: transaction.storefront ?? null,
lastEventAt: new Date(lastEventMs),
};
}
export function buildAppStoreGiftSnapshot(transaction: AppStoreTransactionPayload): StorePurchaseSnapshot {
if (transaction.type === APP_STORE_AUTO_RENEWABLE_TYPE) {
throw new AppStorePurchaseSyncError('mismatch');
}
const slot = resolveSlot(transaction.productId);
if (STORE_PRODUCT_SLOTS[slot].kind !== 'gift') {
throw new AppStorePurchaseSyncError('mismatch');
}
const environment = toStoreEnvironment(transaction.environment);
const revoked = Boolean(transaction.revocationDate);
return {
storeKey: buildAppStoreStoreKey(environment, transaction.originalTransactionId),
provider: 'app_store',
kind: 'gift',
slot,
environment,
appId: transaction.bundleId,
productId: transaction.productId,
basePlanId: null,
storeReference: transaction.originalTransactionId,
latestTransactionId: transaction.transactionId,
appTransactionId: transaction.appTransactionId ?? null,
accountToken: normalizeAccountToken(transaction.appAccountToken),
ownershipType: transaction.inAppOwnershipType ?? null,
state: revoked ? 'refunded' : 'purchased',
storeState: transaction.revocationType ?? null,
providerEntitled: !revoked,
expiresAt: null,
graceEndsAt: null,
autoRenew: null,
autoRenewProductId: null,
startedAt: new Date(transaction.purchaseDate),
purchasedAt: new Date(transaction.purchaseDate),
revokedAt: toDate(transaction.revocationDate),
revocationReason: revocationReason(transaction),
linkedStoreKey: null,
expiredStoreKey: null,
expiredAccountToken: null,
acknowledged: true,
quantity: transaction.quantity ?? 1,
region: transaction.storefront ?? null,
lastEventAt: new Date(transaction.signedDate),
};
}
function pickLastTransaction(
items: ReadonlyArray<AppStoreLastTransaction>,
originalTransactionId: string,
): AppStoreLastTransaction | null {
let best: AppStoreLastTransaction | null = null;
for (const item of items) {
if (item.originalTransactionId !== originalTransactionId) {
continue;
}
if (!best || (item.transaction.expiresDate ?? 0) > (best.transaction.expiresDate ?? 0)) {
best = item;
}
}
return best;
}
export class AppStorePurchaseSync {
constructor(private readonly client: AppStoreServerApiClient) {}
async loadPurchase(lookup: AppStorePurchaseLookup): Promise<StorePurchaseSnapshot> {
const slot = resolveSlot(lookup.productId);
if (STORE_PRODUCT_SLOTS[slot].kind === 'subscription') {
return await this.loadSubscription(lookup);
}
return await this.loadGift(lookup);
}
private async loadSubscription(lookup: AppStorePurchaseLookup): Promise<StorePurchaseSnapshot> {
const statuses = await this.client.getAllSubscriptionStatuses({
environment: lookup.environment,
bundleId: lookup.bundleId,
transactionId: lookup.originalTransactionId,
});
const item = pickLastTransaction(
statuses.groups.flatMap((group) => group.lastTransactions),
lookup.originalTransactionId,
);
if (!item) {
throw new AppStorePurchaseSyncError('not_found');
}
if (item.transaction.bundleId !== lookup.bundleId) {
throw new AppStorePurchaseSyncError('mismatch');
}
return buildAppStoreSubscriptionSnapshot(item);
}
private async loadGift(lookup: AppStorePurchaseLookup): Promise<StorePurchaseSnapshot> {
const {transaction} = await this.client.getTransactionInfo({
environment: lookup.environment,
bundleId: lookup.bundleId,
transactionId: lookup.transactionId,
});
if (transaction.originalTransactionId !== lookup.originalTransactionId) {
throw new AppStorePurchaseSyncError('mismatch');
}
return buildAppStoreGiftSnapshot(transaction);
}
}
@@ -0,0 +1,415 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {readFile} from 'node:fs/promises';
import {Config} from '@app/api/Config';
import {
type AppStoreRenewalInfoPayload,
type AppStoreTransactionPayload,
verifyRenewalInfo,
verifyTransaction,
} from '@app/api/store_billing/app_store/AppStoreJwsVerifier';
import type {StoreEnvironment} from '@app/api/store_billing/StoreBillingTypes';
import {ms, seconds} from 'itty-time';
import {type CryptoKey, importPKCS8, SignJWT} from 'jose';
import {z} from 'zod';
export const APP_STORE_PRODUCTION_BASE_URL = 'https://api.storekit.apple.com';
export const APP_STORE_SANDBOX_BASE_URL = 'https://api.storekit-sandbox.apple.com';
export const APP_STORE_API_AUDIENCE = 'appstoreconnect-v1';
export const APP_STORE_TRANSACTION_ID_NOT_FOUND_ERROR = 4040010;
const APP_STORE_RETRYABLE_ERROR_CODES: ReadonlySet<number> = new Set([4040002, 4040004, 4040006, 5000001, 4290000]);
const APP_STORE_TOKEN_TTL_SECONDS = seconds('1 hour');
const APP_STORE_TOKEN_REUSE_SECONDS = seconds('50 minutes');
const APP_STORE_REQUEST_TIMEOUT_MS = ms('10 seconds');
const AppStoreErrorBodySchema = z.object({
errorCode: z.number(),
errorMessage: z.string().nullish(),
});
const TransactionInfoResponseSchema = z.object({
signedTransactionInfo: z.string().min(1),
});
const StatusResponseSchema = z.object({
environment: z.string().nullish(),
bundleId: z.string().nullish(),
appAppleId: z.number().nullish(),
data: z.array(
z.object({
subscriptionGroupIdentifier: z.string(),
lastTransactions: z.array(
z.object({
originalTransactionId: z.string().min(1),
status: z.number(),
signedTransactionInfo: z.string().min(1),
signedRenewalInfo: z.string().nullish(),
}),
),
}),
),
});
const NotificationHistoryResponseSchema = z.object({
notificationHistory: z.array(z.object({signedPayload: z.string().min(1)})).nullish(),
hasMore: z.boolean().nullish(),
paginationToken: z.string().nullish(),
});
const TestNotificationResponseSchema = z.object({
testNotificationToken: z.string().min(1),
});
export class AppStoreServerApiError extends Error {
constructor(
message: string,
readonly status: number | null,
readonly errorCode: number | null,
readonly retryable: boolean,
readonly retryAfter: Date | null = null,
) {
super(message);
this.name = 'AppStoreServerApiError';
}
}
export function isAppStoreTransactionNotFound(error: unknown): boolean {
return error instanceof AppStoreServerApiError && error.errorCode === APP_STORE_TRANSACTION_ID_NOT_FOUND_ERROR;
}
export interface AppStoreLastTransaction {
originalTransactionId: string;
status: number;
transaction: AppStoreTransactionPayload;
renewalInfo: AppStoreRenewalInfoPayload | null;
}
export interface AppStoreSubscriptionGroup {
subscriptionGroupIdentifier: string;
lastTransactions: Array<AppStoreLastTransaction>;
}
export interface AppStoreSubscriptionStatuses {
environment: StoreEnvironment;
bundleId: string;
appAppleId: number | null;
groups: Array<AppStoreSubscriptionGroup>;
}
export interface AppStoreTransactionInfo {
environment: StoreEnvironment;
transaction: AppStoreTransactionPayload;
}
interface AppStoreTransactionRequest {
environment: StoreEnvironment | null;
bundleId: string;
transactionId: string;
}
interface AppStoreNotificationHistoryRequest {
environment: StoreEnvironment;
bundleId: string;
startDate: Date;
endDate: Date;
notificationType?: string;
onlyFailures?: boolean;
paginationToken?: string | null;
}
interface AppStoreNotificationHistoryPage {
signedPayloads: Array<string>;
paginationToken: string | null;
}
interface AppStoreSetAppAccountTokenRequest {
environment: StoreEnvironment;
bundleId: string;
originalTransactionId: string;
appAccountToken: string;
}
interface AppStoreRequest {
environment: StoreEnvironment;
bundleId: string;
method: 'GET' | 'PUT' | 'POST';
path: string;
body?: unknown;
}
interface CachedAuthToken {
fingerprint: string;
token: string;
reuseUntilSeconds: number;
}
interface AppStoreServerApiClientOptions {
now?: () => number;
timeoutMs?: number;
}
function normalizePem(value: string): string {
return value.replaceAll('\\n', '\n');
}
function hasText(value: string | undefined): value is string {
return value !== undefined && value.trim().length > 0;
}
function parseRetryAfter(value: string | null): Date | null {
if (!value || !/^\d+$/.test(value.trim())) {
return null;
}
const epochMs = Number.parseInt(value.trim(), 10);
return Number.isSafeInteger(epochMs) ? new Date(epochMs) : null;
}
function parseJsonOrNull(raw: string): unknown {
if (raw.length === 0) {
return null;
}
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function invalidResponse(path: string, status: number): AppStoreServerApiError {
return new AppStoreServerApiError(
`App Store Server API returned an unexpected body for ${path}`,
status,
null,
false,
);
}
export class AppStoreServerApiClient {
private readonly authTokens = new Map<string, CachedAuthToken>();
private readonly signingKeys = new Map<string, Promise<CryptoKey>>();
private readonly now: () => number;
private readonly timeoutMs: number;
constructor(options: AppStoreServerApiClientOptions = {}) {
this.now = options.now ?? Date.now;
this.timeoutMs = options.timeoutMs ?? APP_STORE_REQUEST_TIMEOUT_MS;
}
async getAllSubscriptionStatuses(request: AppStoreTransactionRequest): Promise<AppStoreSubscriptionStatuses> {
return await this.withEnvironmentFallback(request.environment, async (environment) => {
const path = `/inApps/v1/subscriptions/${encodeURIComponent(request.transactionId)}`;
const body = await this.send({environment, bundleId: request.bundleId, method: 'GET', path});
const parsed = StatusResponseSchema.safeParse(body);
if (!parsed.success) {
throw invalidResponse(path, 200);
}
if (parsed.data.bundleId && parsed.data.bundleId !== request.bundleId) {
throw invalidResponse(path, 200);
}
const groups: Array<AppStoreSubscriptionGroup> = [];
for (const group of parsed.data.data) {
const lastTransactions: Array<AppStoreLastTransaction> = [];
for (const item of group.lastTransactions) {
const transaction = await verifyTransaction(item.signedTransactionInfo, {expectedEnvironment: environment});
const renewalInfo = item.signedRenewalInfo
? await verifyRenewalInfo(item.signedRenewalInfo, {expectedEnvironment: environment})
: null;
lastTransactions.push({
originalTransactionId: item.originalTransactionId,
status: item.status,
transaction,
renewalInfo,
});
}
groups.push({subscriptionGroupIdentifier: group.subscriptionGroupIdentifier, lastTransactions});
}
return {environment, bundleId: request.bundleId, appAppleId: parsed.data.appAppleId ?? null, groups};
});
}
async getTransactionInfo(request: AppStoreTransactionRequest): Promise<AppStoreTransactionInfo> {
return await this.withEnvironmentFallback(request.environment, async (environment) => {
const path = `/inApps/v1/transactions/${encodeURIComponent(request.transactionId)}`;
const body = await this.send({environment, bundleId: request.bundleId, method: 'GET', path});
const parsed = TransactionInfoResponseSchema.safeParse(body);
if (!parsed.success) {
throw invalidResponse(path, 200);
}
const transaction = await verifyTransaction(parsed.data.signedTransactionInfo, {
expectedEnvironment: environment,
});
if (transaction.bundleId !== request.bundleId) {
throw invalidResponse(path, 200);
}
return {environment, transaction};
});
}
async setAppAccountToken(request: AppStoreSetAppAccountTokenRequest): Promise<void> {
await this.send({
environment: request.environment,
bundleId: request.bundleId,
method: 'PUT',
path: `/inApps/v1/transactions/${encodeURIComponent(request.originalTransactionId)}/appAccountToken`,
body: {appAccountToken: request.appAccountToken.toLowerCase()},
});
}
async getNotificationHistory(request: AppStoreNotificationHistoryRequest): Promise<AppStoreNotificationHistoryPage> {
const query = request.paginationToken ? `?paginationToken=${encodeURIComponent(request.paginationToken)}` : '';
const path = `/inApps/v1/notifications/history${query}`;
const body = await this.send({
environment: request.environment,
bundleId: request.bundleId,
method: 'POST',
path,
body: {
startDate: request.startDate.getTime(),
endDate: request.endDate.getTime(),
...(request.notificationType ? {notificationType: request.notificationType} : {}),
...(request.onlyFailures ? {onlyFailures: true} : {}),
},
});
const parsed = NotificationHistoryResponseSchema.safeParse(body);
if (!parsed.success) {
throw invalidResponse(path, 200);
}
return {
signedPayloads: (parsed.data.notificationHistory ?? []).map((item) => item.signedPayload),
paginationToken: parsed.data.hasMore ? (parsed.data.paginationToken ?? null) : null,
};
}
async requestTestNotification(request: {environment: StoreEnvironment; bundleId: string}): Promise<string> {
const path = '/inApps/v1/notifications/test';
const body = await this.send({environment: request.environment, bundleId: request.bundleId, method: 'POST', path});
const parsed = TestNotificationResponseSchema.safeParse(body);
if (!parsed.success) {
throw invalidResponse(path, 200);
}
return parsed.data.testNotificationToken;
}
private async withEnvironmentFallback<T>(
environment: StoreEnvironment | null,
run: (environment: StoreEnvironment) => Promise<T>,
): Promise<T> {
if (environment) {
return await run(environment);
}
try {
return await run('production');
} catch (error) {
if (!isAppStoreTransactionNotFound(error)) {
throw error;
}
return await run('sandbox');
}
}
private async send(request: AppStoreRequest): Promise<unknown> {
const baseUrl = request.environment === 'production' ? APP_STORE_PRODUCTION_BASE_URL : APP_STORE_SANDBOX_BASE_URL;
const token = await this.getAuthToken(request.bundleId);
const headers: Record<string, string> = {Authorization: `Bearer ${token}`, Accept: 'application/json'};
if (request.body !== undefined) {
headers['Content-Type'] = 'application/json';
}
let response: Response;
let raw: string;
try {
response = await fetch(`${baseUrl}${request.path}`, {
method: request.method,
headers,
body: request.body === undefined ? undefined : JSON.stringify(request.body),
redirect: 'manual',
signal: AbortSignal.timeout(this.timeoutMs),
});
raw = await response.text();
} catch (error) {
throw new AppStoreServerApiError(
`App Store Server API request failed: ${error instanceof Error ? error.message : String(error)}`,
null,
null,
true,
);
}
const body = parseJsonOrNull(raw);
if (response.ok) {
return body;
}
const parsedError = AppStoreErrorBodySchema.safeParse(body);
const errorCode = parsedError.success ? parsedError.data.errorCode : null;
const retryable =
(errorCode !== null && APP_STORE_RETRYABLE_ERROR_CODES.has(errorCode)) ||
response.status === 429 ||
(errorCode === null && response.status >= 500);
if (response.status === 401) {
this.authTokens.delete(request.bundleId);
}
throw new AppStoreServerApiError(
`App Store Server API ${request.method} ${request.path} failed with ${response.status}${errorCode === null ? '' : ` (${errorCode})`}`,
response.status,
errorCode,
retryable,
parseRetryAfter(response.headers.get('retry-after')),
);
}
private async getAuthToken(bundleId: string): Promise<string> {
const cfg = Config.appStore;
if (!hasText(cfg.issuerId) || !hasText(cfg.keyId)) {
throw new AppStoreServerApiError('App Store Server API credentials are not configured', null, null, false);
}
const privateKey = await this.resolvePrivateKey();
const fingerprint = createHash('sha256')
.update(JSON.stringify([bundleId, cfg.issuerId, cfg.keyId, privateKey]))
.digest('hex');
const nowSeconds = Math.floor(this.now() / 1000);
const cached = this.authTokens.get(bundleId);
if (cached && cached.fingerprint === fingerprint && cached.reuseUntilSeconds > nowSeconds) {
return cached.token;
}
const key = await this.signingKey(privateKey);
const token = await new SignJWT({bid: bundleId})
.setProtectedHeader({alg: 'ES256', kid: cfg.keyId, typ: 'JWT'})
.setIssuer(cfg.issuerId)
.setIssuedAt(nowSeconds)
.setExpirationTime(nowSeconds + APP_STORE_TOKEN_TTL_SECONDS)
.setAudience(APP_STORE_API_AUDIENCE)
.sign(key);
this.authTokens.set(bundleId, {fingerprint, token, reuseUntilSeconds: nowSeconds + APP_STORE_TOKEN_REUSE_SECONDS});
return token;
}
private async resolvePrivateKey(): Promise<string> {
const cfg = Config.appStore;
if (hasText(cfg.privateKey)) {
return normalizePem(cfg.privateKey);
}
if (hasText(cfg.privateKeyPath)) {
return normalizePem(await readFile(cfg.privateKeyPath, 'utf8'));
}
throw new AppStoreServerApiError('App Store Server API credentials are not configured', null, null, false);
}
private async signingKey(privateKey: string): Promise<CryptoKey> {
const cached = this.signingKeys.get(privateKey);
if (cached) {
return await cached;
}
const pending = importPKCS8(privateKey, 'ES256');
this.signingKeys.set(privateKey, pending);
try {
return await pending;
} catch (error) {
this.signingKeys.delete(privateKey);
throw new AppStoreServerApiError(
`App Store Server API signing key is invalid: ${error instanceof Error ? error.message : String(error)}`,
null,
null,
false,
);
}
}
}
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {X509Certificate} from 'node:crypto';
export const APPLE_ROOT_CA_G3_PEM = `-----BEGIN CERTIFICATE-----
MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS
QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u
IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN
MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS
b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y
aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49
AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf
TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517
IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr
MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA
MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4
at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM
6BgD56KyKA==
-----END CERTIFICATE-----
`;
let pinnedRoots: ReadonlyArray<X509Certificate> | null = null;
let injectedRoots: ReadonlyArray<X509Certificate> | undefined;
export function getAppleRootCertificates(): ReadonlyArray<X509Certificate> {
if (injectedRoots !== undefined) {
return injectedRoots;
}
pinnedRoots ??= [new X509Certificate(APPLE_ROOT_CA_G3_PEM)];
return pinnedRoots;
}
export function setInjectedAppleRootCertificates(roots: ReadonlyArray<X509Certificate> | undefined): void {
injectedRoots = roots;
}
@@ -0,0 +1,258 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {readFile} from 'node:fs/promises';
import {Config} from '@app/api/Config';
import {
classifyGooglePlayStatus,
GooglePlayApiError,
parseRetryAfterMs,
} from '@app/api/store_billing/google_play/GooglePlayApiError';
import {ms, seconds} from 'itty-time';
import {type CryptoKey, importPKCS8, SignJWT} from 'jose';
import {z} from 'zod';
export const GOOGLE_PLAY_ANDROIDPUBLISHER_SCOPE = 'https://www.googleapis.com/auth/androidpublisher';
export const GOOGLE_DEFAULT_TOKEN_URI = 'https://oauth2.googleapis.com/token';
const JWT_BEARER_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
const ASSERTION_TTL_SECONDS = seconds('1 hour');
const DEFAULT_EXPIRES_IN_SECONDS = seconds('1 hour');
const REFRESH_MARGIN_MS = ms('5 minutes');
const TOKEN_REQUEST_TIMEOUT_MS = ms('10 seconds');
const ServiceAccountJsonSchema = z.object({
client_email: z.string().optional(),
private_key: z.string().optional(),
private_key_id: z.string().optional(),
token_uri: z.string().optional(),
});
const TokenResponseSchema = z.object({
access_token: z.string().min(1),
expires_in: z.union([z.number(), z.string()]).optional(),
token_type: z.string().optional(),
});
const TokenErrorSchema = z.object({
error: z.string().optional(),
error_description: z.string().optional(),
});
export interface GooglePlayServiceAccountCredentials {
clientEmail: string;
privateKey: string;
privateKeyId: string | null;
tokenUri: string;
}
interface CachedAccessToken {
fingerprint: string;
token: string;
expiresAtMs: number;
}
interface GooglePlayAccessTokenProviderOptions {
now?: () => number;
timeoutMs?: number;
}
function hasText(value: string | undefined): value is string {
return value !== undefined && value.trim().length > 0;
}
function normalizePem(value: string): string {
return value.replaceAll('\\n', '\n');
}
function parseJsonOrNull(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function configFingerprint(): string {
const cfg = Config.googlePlay;
return createHash('sha256')
.update(
JSON.stringify([
cfg.clientEmail ?? null,
cfg.privateKey ?? null,
cfg.privateKeyPath ?? null,
cfg.serviceAccountJsonPath ?? null,
cfg.tokenUri,
]),
)
.digest('hex');
}
export async function resolveGooglePlayCredentials(): Promise<GooglePlayServiceAccountCredentials> {
const cfg = Config.googlePlay;
let serviceAccount: z.infer<typeof ServiceAccountJsonSchema> = {};
if (hasText(cfg.serviceAccountJsonPath)) {
const raw = await readFile(cfg.serviceAccountJsonPath, 'utf8');
const parsed = ServiceAccountJsonSchema.safeParse(parseJsonOrNull(raw));
if (!parsed.success) {
throw new GooglePlayApiError('Google Play service account file is invalid', 'auth', null, 'invalid_credentials');
}
serviceAccount = parsed.data;
}
const clientEmail = hasText(cfg.clientEmail) ? cfg.clientEmail : serviceAccount.client_email;
let privateKey: string | undefined;
if (hasText(cfg.privateKey)) {
privateKey = cfg.privateKey;
} else if (hasText(cfg.privateKeyPath)) {
privateKey = await readFile(cfg.privateKeyPath, 'utf8');
} else {
privateKey = serviceAccount.private_key;
}
if (!hasText(clientEmail) || !hasText(privateKey)) {
throw new GooglePlayApiError('Google Play credentials are not configured', 'auth', null, 'missing_credentials');
}
const tokenUri =
cfg.tokenUri !== GOOGLE_DEFAULT_TOKEN_URI || !hasText(serviceAccount.token_uri)
? cfg.tokenUri
: serviceAccount.token_uri;
return {
clientEmail: clientEmail.trim(),
privateKey: normalizePem(privateKey),
privateKeyId: hasText(serviceAccount.private_key_id) ? serviceAccount.private_key_id : null,
tokenUri,
};
}
function parseExpiresInSeconds(value: number | string | undefined): number {
const parsed = typeof value === 'string' ? Number.parseInt(value.trim(), 10) : value;
if (parsed === undefined || !Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_EXPIRES_IN_SECONDS;
}
return parsed;
}
export class GooglePlayAccessTokenProvider {
private cached: CachedAccessToken | null = null;
private inFlight: {fingerprint: string; promise: Promise<string>} | null = null;
private readonly signingKeys = new Map<string, Promise<CryptoKey>>();
private readonly now: () => number;
private readonly timeoutMs: number;
constructor(options: GooglePlayAccessTokenProviderOptions = {}) {
this.now = options.now ?? Date.now;
this.timeoutMs = options.timeoutMs ?? TOKEN_REQUEST_TIMEOUT_MS;
}
async getAccessToken(): Promise<string> {
const fingerprint = configFingerprint();
const cached = this.cached;
if (cached && cached.fingerprint === fingerprint && this.now() < cached.expiresAtMs - REFRESH_MARGIN_MS) {
return cached.token;
}
if (this.inFlight && this.inFlight.fingerprint === fingerprint) {
return await this.inFlight.promise;
}
const promise = this.fetchAccessToken(fingerprint);
const entry = {fingerprint, promise};
this.inFlight = entry;
try {
return await promise;
} finally {
if (this.inFlight === entry) {
this.inFlight = null;
}
}
}
invalidate(token?: string): void {
if (token === undefined || this.cached?.token === token) {
this.cached = null;
}
}
private async fetchAccessToken(fingerprint: string): Promise<string> {
const credentials = await resolveGooglePlayCredentials();
const assertion = await this.signAssertion(credentials);
let response: Response;
try {
response = await fetch(credentials.tokenUri, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams({grant_type: JWT_BEARER_GRANT_TYPE, assertion}).toString(),
signal: AbortSignal.timeout(this.timeoutMs),
});
} catch (error) {
throw new GooglePlayApiError(
`Google OAuth token request failed: ${error instanceof Error ? error.message : String(error)}`,
'retryable',
null,
'network_error',
);
}
const body: unknown = await response.json().catch(() => null);
if (!response.ok) {
const kind = classifyGooglePlayStatus(response.status);
const parsedError = TokenErrorSchema.safeParse(body);
const reason = parsedError.success ? (parsedError.data.error ?? null) : null;
throw new GooglePlayApiError(
`Google OAuth token request failed with ${response.status}`,
kind === 'retryable' ? 'retryable' : 'auth',
response.status,
reason,
parseRetryAfterMs(response.headers.get('retry-after'), this.now()),
);
}
const parsed = TokenResponseSchema.safeParse(body);
if (!parsed.success) {
throw new GooglePlayApiError(
'Google OAuth token response is malformed',
'retryable',
response.status,
'malformed_response',
);
}
const token = parsed.data.access_token;
if (configFingerprint() === fingerprint) {
this.cached = {
fingerprint,
token,
expiresAtMs: this.now() + parseExpiresInSeconds(parsed.data.expires_in) * 1000,
};
}
return token;
}
private async signAssertion(credentials: GooglePlayServiceAccountCredentials): Promise<string> {
const key = await this.signingKey(credentials.privateKey);
const nowSeconds = Math.floor(this.now() / 1000);
return await new SignJWT({scope: GOOGLE_PLAY_ANDROIDPUBLISHER_SCOPE})
.setProtectedHeader({
alg: 'RS256',
typ: 'JWT',
...(credentials.privateKeyId ? {kid: credentials.privateKeyId} : {}),
})
.setIssuer(credentials.clientEmail)
.setAudience(credentials.tokenUri)
.setIssuedAt(nowSeconds)
.setExpirationTime(nowSeconds + ASSERTION_TTL_SECONDS)
.sign(key);
}
private async signingKey(privateKey: string): Promise<CryptoKey> {
const cacheKey = createHash('sha256').update(privateKey).digest('hex');
const cached = this.signingKeys.get(cacheKey);
if (cached) {
return await cached;
}
const pending = importPKCS8(privateKey, 'RS256');
this.signingKeys.set(cacheKey, pending);
try {
return await pending;
} catch {
this.signingKeys.delete(cacheKey);
throw new GooglePlayApiError('Google Play private key is invalid', 'auth', null, 'invalid_credentials');
}
}
}

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