fix(server): improve self hosted usability (#15510)

fix #15505
fix #15502
fix #15496
fix #15491

#### PR Dependency Tree


* **PR #15510** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added configurable delays for invitations, invite links, and document
publishing by newly created accounts.
- Added workspace action checks that explain blocked actions and retry
timing.
- BYOK setup now verifies model capabilities and saves only validated
options.

- **Bug Fixes**
- Improved BYOK probing for chat, structured responses, tool calls,
embeddings, reranking, and image generation.
  - Preserved probe request order and strengthened response validation.
  - Authentication configuration changes now reload correctly.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-08-22 04:47:21 +08:00
committed by GitHub
parent 591f874dad
commit dec1a01449
22 changed files with 683 additions and 279 deletions
+2 -2
View File
@@ -199,9 +199,9 @@
"description": "Whether require email verification before accessing restricted resources(not implemented).\n@default true", "description": "Whether require email verification before accessing restricted resources(not implemented).\n@default true",
"default": true "default": true
}, },
"newAccountShareActionDelay": { "newAccountActionDelay": {
"type": "number", "type": "number",
"description": "Minimum account age in seconds before new accounts can invite members or create share links.\n@default 86400", "description": "Minimum account age in seconds before new accounts can invite members, create invite links, or publish documents. Set to 0 to disable.\n@default 86400",
"default": 86400 "default": 86400
}, },
"trustedCloudflareHeaders": { "trustedCloudflareHeaders": {
Generated
+2 -2
View File
@@ -4758,9 +4758,9 @@ dependencies = [
[[package]] [[package]]
name = "llm_adapter" name = "llm_adapter"
version = "0.2.20" version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d13be366ea35d2a9966ad5770e3d070e495af4e1e6dd009638dee4b55ab45ed" checksum = "02c6b4fa5178b8183331a7d51e8f7ece5421ecb966d3431f3d9ce3c724c6fcc0"
dependencies = [ dependencies = [
"base64", "base64",
"jsonschema", "jsonschema",
+7
View File
@@ -34,6 +34,7 @@ export declare class BackendRuntime {
claimInviteAbuseAction(actionId: string, workerId: string): Promise<boolean> claimInviteAbuseAction(actionId: string, workerId: string): Promise<boolean>
claimRetryableInviteAbuseActions(workerId: string, limit: number): Promise<Array<RuntimeInviteAbuseClaimedAction>> claimRetryableInviteAbuseActions(workerId: string, limit: number): Promise<Array<RuntimeInviteAbuseClaimedAction>>
markInviteAbuseAction(actionId: string, workerId: string, status: string, error?: string | undefined | null): Promise<boolean> markInviteAbuseAction(actionId: string, workerId: string, status: string, error?: string | undefined | null): Promise<boolean>
evaluateWorkspaceActionV1(actorUserId: string, workspaceId: string): Promise<RuntimeWorkspaceActionDecision>
assertWorkspaceInviteQuotaV1(input: RuntimeWorkspaceInviteQuotaInput): Promise<RuntimeWorkspaceInviteQuotaDecision> assertWorkspaceInviteQuotaV1(input: RuntimeWorkspaceInviteQuotaInput): Promise<RuntimeWorkspaceInviteQuotaDecision>
commitWorkspaceInviteQuotaV1(reservationId: string, usage: RuntimeWorkspaceInviteQuotaUsage): Promise<boolean> commitWorkspaceInviteQuotaV1(reservationId: string, usage: RuntimeWorkspaceInviteQuotaUsage): Promise<boolean>
releaseWorkspaceInviteQuotaV1(reservationId: string): Promise<boolean> releaseWorkspaceInviteQuotaV1(reservationId: string): Promise<boolean>
@@ -1496,6 +1497,12 @@ export interface RuntimeVerificationTokenRecord {
expiresAtMs: number expiresAtMs: number
} }
export interface RuntimeWorkspaceActionDecision {
allowed: boolean
retryAfterSeconds?: number
reason?: string
}
export interface RuntimeWorkspaceArtifact { export interface RuntimeWorkspaceArtifact {
id: string id: string
workspaceId: string workspaceId: string
@@ -6,10 +6,10 @@ use llm_adapter::{
AttachmentKind, AttachmentSource, ModelFeature, ModelInput, ModelOutput, ModelRequirements, declared_model_matches, AttachmentKind, AttachmentSource, ModelFeature, ModelInput, ModelOutput, ModelRequirements, declared_model_matches,
}, },
core::{ core::{
CoreContent, CoreMessage, CoreRequest, CoreRole, CoreToolDefinition, EmbeddingRequest, ImageOptions, CoreContent, CoreMessage, CoreRequest, CoreRole, CoreToolChoice, CoreToolDefinition, EmbeddingRequest,
ImageProviderOptions, ImageRequest, RerankCandidate, RerankRequest, StructuredRequest, ImageOptions, ImageProviderOptions, ImageRequest, RerankCandidate, RerankRequest, StructuredRequest,
}, },
router::{ExecutablePreparedRoute, ExecutableRequest, dispatch_prepared_route}, router::{ExecutablePreparedRoute, ExecutableRequest, ExecutableResponse, dispatch_prepared_route},
target::{ target::{
BackendCredential, BackendEndpoint, BackendOperation, BackendTargetInput, EgressPolicy, compile_backend_target, BackendCredential, BackendEndpoint, BackendOperation, BackendTargetInput, EgressPolicy, compile_backend_target,
}, },
@@ -30,9 +30,11 @@ pub(super) async fn execute_probe(
checks: Vec<ByokProbeCheckInput>, checks: Vec<ByokProbeCheckInput>,
) -> RuntimeResult<ByokProbeResultOutput> { ) -> RuntimeResult<ByokProbeResultOutput> {
let tested_at_ms = chrono::Utc::now().timestamp_millis(); let tested_at_ms = chrono::Utc::now().timestamp_millis();
let mut requested = HashSet::new(); let mut requested = Vec::new();
let mut requested_set = HashSet::new();
for check in checks { for check in checks {
if !requested.insert((check.model_id.clone(), check.operation.clone())) { let key = (check.model_id.clone(), check.operation.clone());
if !requested_set.insert(key.clone()) {
return Err(RuntimeError::invalid_input("duplicate BYOK probe check")); return Err(RuntimeError::invalid_input("duplicate BYOK probe check"));
} }
if !matches!( if !matches!(
@@ -41,6 +43,7 @@ pub(super) async fn execute_probe(
) { ) {
return Err(RuntimeError::invalid_input("unknown BYOK probe operation")); return Err(RuntimeError::invalid_input("unknown BYOK probe operation"));
} }
requested.push(key);
} }
let mut models = Vec::new(); let mut models = Vec::new();
@@ -160,7 +163,49 @@ fn dispatch_check(
Err(_) => return failed(checked_at, "invalid_probe_request"), Err(_) => return failed(checked_at, "invalid_probe_request"),
}; };
match dispatch_prepared_route(&DefaultHttpClient::default(), &route) { match dispatch_prepared_route(&DefaultHttpClient::default(), &route) {
Ok(_) => verified(checked_at), Ok(ExecutableResponse::Chat(response)) => {
let valid = if operation == "tool_calling" {
response
.message
.content
.iter()
.any(|content| matches!(content, CoreContent::ToolCall { name, .. } if name == "byok_probe"))
} else {
response
.message
.content
.iter()
.any(|content| matches!(content, CoreContent::Text { text } if !text.trim().is_empty()))
};
if valid {
verified(checked_at)
} else {
failed(checked_at, "invalid_response")
}
}
Ok(ExecutableResponse::Structured(response)) => {
let valid = response.output_json.as_ref().is_some_and(|output| {
let ExecutableRequest::Structured(request) = &route.request else {
return false;
};
llm_adapter::schema::validate_json_schema(&request.schema, output).is_ok()
});
if valid {
verified(checked_at)
} else {
failed(checked_at, "invalid_response")
}
}
Ok(ExecutableResponse::Embedding(response)) if operation == "embedding" && !response.embeddings.is_empty() => {
verified(checked_at)
}
Ok(ExecutableResponse::Rerank(response)) if operation == "rerank" && !response.scores.is_empty() => {
verified(checked_at)
}
Ok(ExecutableResponse::Image(response)) if operation == "image" && !response.images.is_empty() => {
verified(checked_at)
}
Ok(_) => failed(checked_at, "invalid_response"),
Err(error) => failed(checked_at, backend_error_kind(&error)), Err(error) => failed(checked_at, backend_error_kind(&error)),
} }
} }
@@ -169,7 +214,11 @@ fn probe_request_for_operation(operation: &str) -> ExecutableRequest {
let message = CoreMessage { let message = CoreMessage {
role: CoreRole::User, role: CoreRole::User,
content: vec![CoreContent::Text { content: vec![CoreContent::Text {
text: "Reply with OK.".to_string(), text: match operation {
"tool_calling" => "Call the byok_probe tool.".to_string(),
"structured" => "Return exactly {\"ok\":true}.".to_string(),
_ => "Reply with OK.".to_string(),
},
}], }],
}; };
match operation { match operation {
@@ -177,8 +226,8 @@ fn probe_request_for_operation(operation: &str) -> ExecutableRequest {
model: String::new(), model: String::new(),
messages: vec![message], messages: vec![message],
stream: false, stream: false,
max_tokens: Some(8), max_tokens: Some(64),
temperature: Some(0.0), temperature: None,
tools: if operation == "tool_calling" { tools: if operation == "tool_calling" {
vec![CoreToolDefinition { vec![CoreToolDefinition {
name: "byok_probe".to_string(), name: "byok_probe".to_string(),
@@ -188,7 +237,9 @@ fn probe_request_for_operation(operation: &str) -> ExecutableRequest {
} else { } else {
vec![] vec![]
}, },
tool_choice: None, tool_choice: (operation == "tool_calling").then_some(CoreToolChoice::Specific {
name: "byok_probe".to_string(),
}),
include: None, include: None,
reasoning: None, reasoning: None,
response_schema: None, response_schema: None,
@@ -202,8 +253,8 @@ fn probe_request_for_operation(operation: &str) -> ExecutableRequest {
"required": ["ok"], "required": ["ok"],
"additionalProperties": false "additionalProperties": false
}), }),
max_tokens: Some(16), max_tokens: Some(128),
temperature: Some(0.0), temperature: None,
reasoning: None, reasoning: None,
strict: Some(true), strict: Some(true),
response_mime_type: Some("application/json".to_string()), response_mime_type: Some("application/json".to_string()),
@@ -426,7 +477,48 @@ mod tests {
let mut stream = stream.unwrap(); let mut stream = stream.unwrap();
let request = read_request(&mut stream); let request = read_request(&mut stream);
let responses = request.starts_with("POST /v1/responses "); let responses = request.starts_with("POST /v1/responses ");
let body = if responses { let embedding = request.starts_with("POST /v1/embeddings ");
let image = request.starts_with("POST /v1/images/generations ");
let rerank = request.contains("\"logprobs\":true");
let tool_calling = request.contains("byok_probe");
let body = if embedding {
json!({
"model": "smoke-model",
"data": [{ "embedding": [0.1], "index": 0 }],
"usage": { "prompt_tokens": 1, "total_tokens": 1 }
})
} else if image {
json!({
"created": 0,
"data": [{ "url": "https://example.com/smoke.png" }]
})
} else if rerank {
json!({
"model": "smoke-model",
"choices": [{
"logprobs": { "content": [{
"top_logprobs": [
{ "token": "Yes", "logprob": 0.0 },
{ "token": "No", "logprob": -1.0 }
]
}] }
}]
})
} else if responses && tool_calling {
json!({
"id": "resp_smoke",
"model": "smoke-model",
"status": "completed",
"output": [{
"type": "function_call",
"id": "fc_smoke",
"call_id": "call_smoke",
"name": "byok_probe",
"arguments": "{}"
}],
"usage": { "input_tokens": 1, "output_tokens": 1, "total_tokens": 2 }
})
} else if responses {
json!({ json!({
"id": "resp_smoke", "id": "resp_smoke",
"model": "smoke-model", "model": "smoke-model",
@@ -439,6 +531,25 @@ mod tests {
}], }],
"usage": { "input_tokens": 1, "output_tokens": 1, "total_tokens": 2 } "usage": { "input_tokens": 1, "output_tokens": 1, "total_tokens": 2 }
}) })
} else if tool_calling {
json!({
"id": "chat_smoke",
"model": "smoke-model",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_smoke",
"type": "function",
"function": { "name": "byok_probe", "arguments": "{}" }
}]
},
"finish_reason": "tool_calls"
}],
"usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 }
})
} else { } else {
json!({ json!({
"id": "chat_smoke", "id": "chat_smoke",
@@ -489,7 +600,7 @@ mod tests {
#[test] #[test]
fn openai_compatible_probe_smoke_uses_the_selected_dialect() { fn openai_compatible_probe_smoke_uses_the_selected_dialect() {
let operations = ["chat", "structured", "tool_calling"]; let operations = ["chat", "structured", "tool_calling", "embedding", "rerank", "image"];
let (endpoint, requests, server) = serve_openai_compatible(operations.len() * 2); let (endpoint, requests, server) = serve_openai_compatible(operations.len() * 2);
for dialect in [OpenAiDialect::Responses, OpenAiDialect::ChatCompletions] { for dialect in [OpenAiDialect::Responses, OpenAiDialect::ChatCompletions] {
@@ -520,19 +631,39 @@ mod tests {
.iter() .iter()
.filter(|request| request.starts_with("POST /v1/responses ")) .filter(|request| request.starts_with("POST /v1/responses "))
.count(), .count(),
operations.len() 3
); );
assert_eq!( assert_eq!(
requests requests
.iter() .iter()
.filter(|request| request.starts_with("POST /v1/chat/completions ")) .filter(|request| request.starts_with("POST /v1/chat/completions "))
.count(), .count(),
operations.len() 5
);
assert_eq!(
requests
.iter()
.filter(|request| request.starts_with("POST /v1/embeddings "))
.count(),
2
);
assert_eq!(
requests
.iter()
.filter(|request| request.starts_with("POST /v1/images/generations "))
.count(),
2
); );
assert!(requests.iter().all(|request| !request.contains("/models"))); assert!(requests.iter().all(|request| !request.contains("/models")));
assert_eq!( assert_eq!(
requests.iter().filter(|request| request.contains("byok_probe")).count(), requests.iter().filter(|request| request.contains("byok_probe")).count(),
2 2
); );
assert!(
requests
.iter()
.filter(|request| !request.contains("\"logprobs\":true"))
.all(|request| !request.contains("\"temperature\""))
);
} }
} }
@@ -5,7 +5,7 @@ use super::{
BackendRuntime, RuntimeError, RuntimeInviteAbuseClaimedAction, RuntimeResult, napi_error, workspace_subject_key, BackendRuntime, RuntimeError, RuntimeInviteAbuseClaimedAction, RuntimeResult, napi_error, workspace_subject_key,
}; };
async fn invite_abuse_user_quarantined_or_banned(pool: &PgPool, user_id: &str) -> RuntimeResult<bool> { pub(super) async fn invite_abuse_user_quarantined_or_banned(pool: &PgPool, user_id: &str) -> RuntimeResult<bool> {
let row: Option<i32> = sqlx::query_scalar( let row: Option<i32> = sqlx::query_scalar(
r#" r#"
SELECT 1 SELECT 1
@@ -22,7 +22,7 @@ async fn invite_abuse_user_quarantined_or_banned(pool: &PgPool, user_id: &str) -
Ok(row.is_some()) Ok(row.is_some())
} }
async fn invite_abuse_workspace_quarantined(pool: &PgPool, workspace_id: &str) -> RuntimeResult<bool> { pub(super) async fn invite_abuse_workspace_quarantined(pool: &PgPool, workspace_id: &str) -> RuntimeResult<bool> {
let row: Option<i32> = sqlx::query_scalar( let row: Option<i32> = sqlx::query_scalar(
r#" r#"
SELECT 1 SELECT 1
@@ -6,6 +6,7 @@ mod reservation;
mod workspace_invite; mod workspace_invite;
mod workspace_invite_policy; mod workspace_invite_policy;
use invite_abuse_actions::{invite_abuse_user_quarantined_or_banned, invite_abuse_workspace_quarantined};
use mail_delivery::{build_mail_scopes, decision_from_violation as mail_decision_from_violation, mail_class}; use mail_delivery::{build_mail_scopes, decision_from_violation as mail_decision_from_violation, mail_class};
use napi::Result; use napi::Result;
use reservation::{ use reservation::{
@@ -15,8 +16,8 @@ use reservation::{
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use workspace_invite_policy::{ use workspace_invite_policy::{
ActorFacts, InviteAbuseDecision, InviteActivityFacts, QuotaFacts, WorkspaceFacts, build_invite_scopes, ActorFacts, InviteAbuseDecision, InviteActivityFacts, QuotaFacts, WorkspaceFacts, build_invite_scopes,
evaluate_projection, high_confidence_invite_abuse, invite_commit_usage_for_scope, source_cohort_subject_key, evaluate_projection, high_confidence_invite_abuse, invite_commit_usage_for_scope, new_account_action_retry_after,
subject_hash, sum_domains, source_cohort_subject_key, subject_hash, sum_domains,
}; };
#[cfg(test)] #[cfg(test)]
@@ -26,7 +27,8 @@ pub(super) use super::{
types::{ types::{
RuntimeInviteAbuseActionRequired, RuntimeInviteAbuseClaimedAction, RuntimeMailDeliveryQuotaDecision, RuntimeInviteAbuseActionRequired, RuntimeInviteAbuseClaimedAction, RuntimeMailDeliveryQuotaDecision,
RuntimeMailDeliveryQuotaInput, RuntimeQuotaSourceInput, RuntimeQuotaTargetDomainInput, RuntimeMailDeliveryQuotaInput, RuntimeQuotaSourceInput, RuntimeQuotaTargetDomainInput,
RuntimeWorkspaceInviteQuotaDecision, RuntimeWorkspaceInviteQuotaInput, RuntimeWorkspaceInviteQuotaUsage, RuntimeWorkspaceActionDecision, RuntimeWorkspaceInviteQuotaDecision, RuntimeWorkspaceInviteQuotaInput,
RuntimeWorkspaceInviteQuotaUsage,
}, },
}; };
@@ -5,19 +5,32 @@ use sqlx::{PgPool, Row};
use super::{ use super::{
ActorFacts, BackendRuntime, InviteAbuseDecision, InviteActivityFacts, InviteQuotaConfig, QuotaFacts, QuotaViolation, ActorFacts, BackendRuntime, InviteAbuseDecision, InviteActivityFacts, InviteQuotaConfig, QuotaFacts, QuotaViolation,
RuntimeError, RuntimeInviteAbuseActionRequired, RuntimeResult, RuntimeWorkspaceInviteQuotaDecision, RuntimeError, RuntimeInviteAbuseActionRequired, RuntimeResult, RuntimeWorkspaceActionDecision,
RuntimeWorkspaceInviteQuotaInput, RuntimeWorkspaceInviteQuotaUsage, WorkspaceFacts, build_invite_scopes, RuntimeWorkspaceInviteQuotaDecision, RuntimeWorkspaceInviteQuotaInput, RuntimeWorkspaceInviteQuotaUsage,
commit_reservation, evaluate_projection, high_confidence_invite_abuse, invite_commit_usage_for_scope, napi_error, WorkspaceFacts, build_invite_scopes, commit_reservation, evaluate_projection, high_confidence_invite_abuse,
normalize_domain, release_reservation, reserve_scopes, short_hash, source_cohort_subject_key, source_prefix, invite_abuse_user_quarantined_or_banned, invite_abuse_workspace_quarantined, invite_commit_usage_for_scope,
subject_hash, sum_domains, workspace_subject_key, napi_error, new_account_action_retry_after, normalize_domain, release_reservation, reserve_scopes, short_hash,
source_cohort_subject_key, source_prefix, subject_hash, sum_domains, workspace_subject_key,
}; };
async fn load_actor(pool: &PgPool, user_id: &str) -> RuntimeResult<ActorFacts> { async fn load_actor(pool: &PgPool, user_id: &str) -> RuntimeResult<ActorFacts> {
let row = sqlx::query( let row = sqlx::query(
r#" r#"
SELECT email, created_at, registered, email_verified IS NOT NULL AS email_verified, disabled SELECT
users.email,
users.created_at,
users.registered,
users.email_verified IS NOT NULL AS email_verified,
users.disabled,
CASE
WHEN quota.known
AND NOT quota.stale
AND (quota.stale_after IS NULL OR quota.stale_after > clock_timestamp())
THEN quota.plan
END AS quota_plan
FROM users FROM users
WHERE id = $1 LEFT JOIN effective_user_quota_states quota ON quota.user_id = users.id
WHERE users.id = $1
"#, "#,
) )
.bind(user_id) .bind(user_id)
@@ -32,6 +45,7 @@ async fn load_actor(pool: &PgPool, user_id: &str) -> RuntimeResult<ActorFacts> {
registered: row.get("registered"), registered: row.get("registered"),
email_verified: row.get("email_verified"), email_verified: row.get("email_verified"),
disabled: row.get("disabled"), disabled: row.get("disabled"),
quota_plan: row.get("quota_plan"),
}) })
} }
@@ -285,6 +299,49 @@ fn decision_from_violation(violation: QuotaViolation, reason: &str) -> RuntimeWo
#[napi_derive::napi] #[napi_derive::napi]
impl BackendRuntime { impl BackendRuntime {
#[napi]
pub async fn evaluate_workspace_action_v1(
&self,
actor_user_id: String,
workspace_id: String,
) -> Result<RuntimeWorkspaceActionDecision> {
let runtime_config = self.config()?;
let pool = self.pool().await?;
if invite_abuse_user_quarantined_or_banned(&pool, &actor_user_id).await? {
return Ok(RuntimeWorkspaceActionDecision {
allowed: false,
retry_after_seconds: None,
reason: Some("abuse_subject".to_string()),
});
}
if invite_abuse_workspace_quarantined(&pool, &workspace_id).await? {
return Ok(RuntimeWorkspaceActionDecision {
allowed: false,
retry_after_seconds: None,
reason: Some("abuse_workspace".to_string()),
});
}
let now: DateTime<Utc> = sqlx::query_scalar("SELECT clock_timestamp()")
.fetch_one(&pool)
.await
.map_err(|err| RuntimeError::database("failed to read database clock", err))?;
let actor = load_actor(&pool, &actor_user_id).await?;
let quota = load_quota(&pool, &workspace_id).await?;
let current_quota = quota.as_ref().filter(|quota| evaluate_projection(quota, now).is_none());
let retry_after_seconds = new_account_action_retry_after(
runtime_config.deployment,
&runtime_config.invite_quota,
&actor,
current_quota,
now,
);
Ok(RuntimeWorkspaceActionDecision {
allowed: retry_after_seconds.is_none(),
retry_after_seconds,
reason: retry_after_seconds.map(|_| "new_account_action_delay".to_string()),
})
}
#[napi] #[napi]
pub async fn assert_workspace_invite_quota_v1( pub async fn assert_workspace_invite_quota_v1(
&self, &self,
@@ -388,6 +445,22 @@ impl BackendRuntime {
action_required: None, action_required: None,
}); });
} }
if let Some(retry_after_seconds) =
new_account_action_retry_after(runtime_config.deployment, config, &actor, Some(&quota), now)
{
return Ok(RuntimeWorkspaceInviteQuotaDecision {
allowed: false,
reservation_id: None,
retry_after_seconds: Some(retry_after_seconds),
reason: Some("new_account_action_delay".to_string()),
scope_key: None,
window_seconds: None,
limit: None,
current: None,
requested: Some(input.target_count),
action_required: None,
});
}
if let Some(abuse_decision) = high_confidence_invite_abuse(&input, &actor, config) { if let Some(abuse_decision) = high_confidence_invite_abuse(&input, &actor, config) {
let reason = abuse_decision.reason; let reason = abuse_decision.reason;
let scope_key = match abuse_decision.subject_kind { let scope_key = match abuse_decision.subject_kind {
@@ -10,6 +10,7 @@ use super::{
InviteQuotaConfig, RuntimeQuotaTargetDomainInput, RuntimeWorkspaceInviteQuotaInput, ScopeLimit, bucket_seconds, InviteQuotaConfig, RuntimeQuotaTargetDomainInput, RuntimeWorkspaceInviteQuotaInput, ScopeLimit, bucket_seconds,
high_risk_domain, napi_error, normalize_domain, scope, short_hash, source_prefix, workspace_subject_key, high_risk_domain, napi_error, normalize_domain, scope, short_hash, source_prefix, workspace_subject_key,
}; };
use crate::llm::Deployment;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(super) struct ActorFacts { pub(super) struct ActorFacts {
@@ -18,6 +19,7 @@ pub(super) struct ActorFacts {
pub(super) registered: bool, pub(super) registered: bool,
pub(super) email_verified: bool, pub(super) email_verified: bool,
pub(super) disabled: bool, pub(super) disabled: bool,
pub(super) quota_plan: Option<String>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -108,9 +110,6 @@ fn base_invite_limits(
let mut per_day = 15; let mut per_day = 15;
let mut per_week = 30; let mut per_week = 30;
if account_age < Duration::hours(24) {
return (0, 0, 0, 0);
}
if !actor.email_verified { if !actor.email_verified {
single = 1; single = 1;
per_hour = 1; per_hour = 1;
@@ -185,6 +184,31 @@ pub(super) fn evaluate_projection(quota: &QuotaFacts, now: DateTime<Utc>) -> Opt
None None
} }
pub(super) fn new_account_action_retry_after(
deployment: Deployment,
config: &InviteQuotaConfig,
actor: &ActorFacts,
workspace_quota: Option<&QuotaFacts>,
now: DateTime<Utc>,
) -> Option<i32> {
if deployment == Deployment::SelfHosted || config.new_account_action_delay_seconds <= 0 {
return None;
}
if actor
.quota_plan
.as_deref()
.is_some_and(|plan| matches!(plan, "pro" | "lifetime_pro" | "ai"))
|| workspace_quota.map(|quota| quota.plan.as_str()).is_some_and(|plan| {
matches!(plan, "pro" | "lifetime_pro" | "ai") || plan.contains("team") && !plan.contains("trial")
})
{
return None;
}
let remaining = config.new_account_action_delay_seconds - (now - actor.created_at).num_seconds();
(remaining > 0).then(|| remaining.min(i64::from(i32::MAX)) as i32)
}
pub(super) fn build_invite_scopes( pub(super) fn build_invite_scopes(
input: &RuntimeWorkspaceInviteQuotaInput, input: &RuntimeWorkspaceInviteQuotaInput,
actor: &ActorFacts, actor: &ActorFacts,
@@ -442,6 +466,7 @@ mod tests {
registered: true, registered: true,
email_verified: true, email_verified: true,
disabled: false, disabled: false,
quota_plan: None,
} }
} }
@@ -467,7 +492,7 @@ mod tests {
} }
#[test] #[test]
fn seat_based_weekly_limit_binds_paid_team_and_high_risk_domain() { fn invite_scopes_apply_plan_ceiling_domain_risk_and_graduated_limits() {
let now = Utc.with_ymd_and_hms(2026, 7, 6, 0, 0, 0).single().unwrap(); let now = Utc.with_ymd_and_hms(2026, 7, 6, 0, 0, 0).single().unwrap();
let input = RuntimeWorkspaceInviteQuotaInput { let input = RuntimeWorkspaceInviteQuotaInput {
actor_user_id: "u1".to_string(), actor_user_id: "u1".to_string(),
@@ -500,6 +525,67 @@ mod tests {
.find(|scope| scope.scope_key == "invite:quota_subject_domain:workspace:w1:qq.com") .find(|scope| scope.scope_key == "invite:quota_subject_domain:workspace:w1:qq.com")
.unwrap(); .unwrap();
assert_eq!(high_risk.limit, 5); assert_eq!(high_risk.limit, 5);
let fresh_actor_scopes = build_invite_scopes(
&input,
&user(now - Duration::hours(1)),
&workspace(now - Duration::hours(1)),
&quota("paid_team", 10),
&InviteActivityFacts::default(),
&invite_config(),
now,
)
.unwrap();
assert_eq!(fresh_actor_scopes[0].limit, 3);
let mut fresh_actor = user(now - Duration::hours(1));
assert_eq!(
new_account_action_retry_after(
Deployment::Cloud,
&invite_config(),
&fresh_actor,
Some(&quota("free", 3)),
now,
),
Some(23 * 60 * 60)
);
assert_eq!(
new_account_action_retry_after(
Deployment::SelfHosted,
&invite_config(),
&fresh_actor,
Some(&quota("free", 3)),
now,
),
None
);
assert_eq!(
new_account_action_retry_after(
Deployment::Cloud,
&invite_config(),
&fresh_actor,
Some(&quota("paid_team", 10)),
now,
),
None
);
let mut no_delay = invite_config();
no_delay.new_account_action_delay_seconds = 0;
assert_eq!(
new_account_action_retry_after(Deployment::Cloud, &no_delay, &fresh_actor, Some(&quota("free", 3)), now,),
None
);
fresh_actor.quota_plan = Some("pro".to_string());
assert_eq!(
new_account_action_retry_after(
Deployment::Cloud,
&invite_config(),
&fresh_actor,
Some(&quota("free", 3)),
now,
),
None
);
} }
#[test] #[test]
+17 -2
View File
@@ -319,6 +319,7 @@ impl TryFrom<CopilotManagedProfileConfigFile> for CopilotManagedProfileConfig {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct InviteQuotaConfig { pub(crate) struct InviteQuotaConfig {
pub(crate) new_account_action_delay_seconds: i64,
pub(crate) high_risk_target_domains: Vec<String>, pub(crate) high_risk_target_domains: Vec<String>,
pub(crate) subject_hash_salt: String, pub(crate) subject_hash_salt: String,
pub(crate) mail_class_mapping: BTreeMap<String, String>, pub(crate) mail_class_mapping: BTreeMap<String, String>,
@@ -327,6 +328,7 @@ pub(crate) struct InviteQuotaConfig {
impl Default for InviteQuotaConfig { impl Default for InviteQuotaConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
new_account_action_delay_seconds: 24 * 60 * 60,
high_risk_target_domains: [ high_risk_target_domains: [
"qq.com", "qq.com",
"proton.me", "proton.me",
@@ -479,12 +481,19 @@ fn deployment_from_env() -> Deployment {
#[derive(Default, Deserialize)] #[derive(Default, Deserialize)]
struct AppConfigFile { struct AppConfigFile {
auth: Option<AuthConfigFile>,
db: Option<DbConfigFile>, db: Option<DbConfigFile>,
crypto: Option<CryptoConfigFile>, crypto: Option<CryptoConfigFile>,
copilot: Option<CopilotRuntimeConfigFile>, copilot: Option<CopilotRuntimeConfigFile>,
indexer: Option<SearchRuntimeConfigFile>, indexer: Option<SearchRuntimeConfigFile>,
} }
#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthConfigFile {
new_account_action_delay: Option<i64>,
}
#[derive(Default, Deserialize)] #[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase", default)] #[serde(rename_all = "camelCase", default)]
struct SearchRuntimeConfigFile { struct SearchRuntimeConfigFile {
@@ -542,7 +551,11 @@ impl AppConfigFile {
} }
fn invite_quota_config(&self) -> InviteQuotaConfig { fn invite_quota_config(&self) -> InviteQuotaConfig {
InviteQuotaConfig::default() let mut config = InviteQuotaConfig::default();
if let Some(delay) = self.auth.as_ref().and_then(|auth| auth.new_account_action_delay) {
config.new_account_action_delay_seconds = delay.max(0);
}
config
} }
} }
@@ -985,14 +998,16 @@ mod tests {
} }
#[test] #[test]
fn invite_quota_policy_is_internal_not_app_configurable() { fn invite_abuse_policy_is_internal_while_action_delay_is_configurable() {
let app_config = app_config_from_flat_overrides([ let app_config = app_config_from_flat_overrides([
("auth.newAccountActionDelay", serde_json::json!(123)),
("auth.untrustedPolicyOverride", serde_json::json!("runtime-salt-v2")), ("auth.untrustedPolicyOverride", serde_json::json!("runtime-salt-v2")),
("auth.untrustedDomainList", serde_json::json!(["Example.COM."])), ("auth.untrustedDomainList", serde_json::json!(["Example.COM."])),
]) ])
.unwrap(); .unwrap();
let config = app_config.invite_quota_config(); let config = app_config.invite_quota_config();
assert_eq!(config.new_account_action_delay_seconds, 123);
assert!(!config.high_risk_target_domains.contains(&"example.com".to_string())); assert!(!config.high_risk_target_domains.contains(&"example.com".to_string()));
assert_ne!(config.subject_hash_salt, "runtime-salt-v2"); assert_ne!(config.subject_hash_salt, "runtime-salt-v2");
assert_eq!( assert_eq!(
@@ -263,6 +263,13 @@ pub struct RuntimeWorkspaceInviteQuotaUsage {
pub target_domains: Vec<RuntimeQuotaTargetDomainInput>, pub target_domains: Vec<RuntimeQuotaTargetDomainInput>,
} }
#[napi_derive::napi(object)]
pub struct RuntimeWorkspaceActionDecision {
pub allowed: bool,
pub retry_after_seconds: Option<i32>,
pub reason: Option<String>,
}
#[napi_derive::napi(object)] #[napi_derive::napi(object)]
pub struct RuntimeInviteAbuseActionRequired { pub struct RuntimeInviteAbuseActionRequired {
pub action: String, pub action: String,
@@ -18,7 +18,7 @@ export interface AuthConfig {
allowSignupForOauth: boolean; allowSignupForOauth: boolean;
requireEmailDomainVerification: boolean; requireEmailDomainVerification: boolean;
requireEmailVerification: boolean; requireEmailVerification: boolean;
newAccountShareActionDelay: number; newAccountActionDelay: number;
trustedCloudflareHeaders: boolean; trustedCloudflareHeaders: boolean;
signInRateLimit: ConfigItem<{ signInRateLimit: ConfigItem<{
ttl: number; ttl: number;
@@ -56,8 +56,8 @@ defineModuleConfig('auth', {
desc: 'Whether require email verification before accessing restricted resources(not implemented).', desc: 'Whether require email verification before accessing restricted resources(not implemented).',
default: true, default: true,
}, },
newAccountShareActionDelay: { newAccountActionDelay: {
desc: 'Minimum account age in seconds before new accounts can invite members or create share links.', desc: 'Minimum account age in seconds before new accounts can invite members, create invite links, or publish documents. Set to 0 to disable.',
default: 24 * 60 * 60, default: 24 * 60 * 60,
shape: z.number().int().min(0), shape: z.number().int().min(0),
}, },
@@ -121,6 +121,12 @@ export type RuntimeWorkspaceInviteQuotaUsage = {
targetDomains: RuntimeQuotaTargetDomainInput[]; targetDomains: RuntimeQuotaTargetDomainInput[];
}; };
export type RuntimeWorkspaceActionDecision = {
allowed: boolean;
retryAfterSeconds?: number;
reason?: string;
};
export type RuntimeInviteAbuseAction = export type RuntimeInviteAbuseAction =
| 'ban_actor' | 'ban_actor'
| 'quarantine_actor' | 'quarantine_actor'
@@ -213,6 +219,10 @@ export type RuntimeMailDeliveryQuotaDecision = {
}; };
type RuntimeQuotaMethods = RuntimeInstance & { type RuntimeQuotaMethods = RuntimeInstance & {
evaluateWorkspaceActionV1(
actorUserId: string,
workspaceId: string
): Promise<RuntimeWorkspaceActionDecision>;
assertWorkspaceInviteQuotaV1( assertWorkspaceInviteQuotaV1(
input: RuntimeWorkspaceInviteQuotaInput input: RuntimeWorkspaceInviteQuotaInput
): Promise<NativeRuntimeWorkspaceInviteQuotaDecision>; ): Promise<NativeRuntimeWorkspaceInviteQuotaDecision>;
@@ -328,6 +338,7 @@ export class BackendRuntimeProvider
!updates.copilot && !updates.copilot &&
!updates.crypto && !updates.crypto &&
!updates.db && !updates.db &&
!updates.auth &&
!updates.indexer && !updates.indexer &&
!updates.storages !updates.storages
) { ) {
@@ -537,6 +548,12 @@ export class BackendRuntimeProvider
); );
} }
async evaluateWorkspaceActionV1(actorUserId: string, workspaceId: string) {
return await this.measured('evaluateWorkspaceActionV1', rt =>
this.quotaRuntime(rt).evaluateWorkspaceActionV1(actorUserId, workspaceId)
);
}
async commitWorkspaceInviteQuotaV1( async commitWorkspaceInviteQuotaV1(
reservationId: string, reservationId: string,
usage: RuntimeWorkspaceInviteQuotaUsage usage: RuntimeWorkspaceInviteQuotaUsage
@@ -15,6 +15,9 @@ import { Mockers } from '../../../__tests__/mocks';
import { Config } from '../../../base'; import { Config } from '../../../base';
import { ActionForbidden, TooManyRequest } from '../../../base/error'; import { ActionForbidden, TooManyRequest } from '../../../base/error';
import { Models, WorkspaceRole } from '../../../models'; import { Models, WorkspaceRole } from '../../../models';
import { BackendRuntimeProvider } from '../../backend-runtime';
import { EntitlementService } from '../../entitlement';
import { QuotaService } from '../../quota';
import { import {
getAbuseRequestSource, getAbuseRequestSource,
InviteAbuseDispositionService, InviteAbuseDispositionService,
@@ -23,6 +26,7 @@ import {
let app: TestingApp; let app: TestingApp;
const quota = { const quota = {
assertWorkspaceActionAllowed: Sinon.stub(),
assertWorkspaceInviteQuota: Sinon.stub(), assertWorkspaceInviteQuota: Sinon.stub(),
commitWorkspaceInviteQuota: Sinon.stub(), commitWorkspaceInviteQuota: Sinon.stub(),
releaseWorkspaceInviteQuota: Sinon.stub(), releaseWorkspaceInviteQuota: Sinon.stub(),
@@ -41,6 +45,7 @@ test.before(async () => {
}); });
test.beforeEach(() => { test.beforeEach(() => {
quota.assertWorkspaceActionAllowed.reset();
quota.assertWorkspaceInviteQuota.reset(); quota.assertWorkspaceInviteQuota.reset();
quota.commitWorkspaceInviteQuota.reset(); quota.commitWorkspaceInviteQuota.reset();
quota.releaseWorkspaceInviteQuota.reset(); quota.releaseWorkspaceInviteQuota.reset();
@@ -345,8 +350,8 @@ test('workspace quarantine blocks invite link creation', async t => {
updated_at = now() updated_at = now()
`; `;
const previousDelay = config.auth.newAccountShareActionDelay; const previousDelay = config.auth.newAccountActionDelay;
config.auth.newAccountShareActionDelay = 0; config.auth.newAccountActionDelay = 0;
try { try {
await app.login(owner); await app.login(owner);
await t.throwsAsync( await t.throwsAsync(
@@ -359,32 +364,58 @@ test('workspace quarantine blocks invite link creation', async t => {
}) })
); );
} finally { } finally {
config.auth.newAccountShareActionDelay = previousDelay; config.auth.newAccountActionDelay = previousDelay;
} }
}); });
test('domain workspace name blocks invite link creation', async t => { test('workspace action admission applies exemption before content policy', async t => {
const config = app.get(Config); const db = app.get(PrismaClient);
const inviteQuota = new InviteQuotaAssertService(
app.get(Config),
app.get(QuotaService),
app.get(BackendRuntimeProvider),
app.get(InviteAbuseDispositionService)
);
const owner = await app.create(Mockers.User); const owner = await app.create(Mockers.User);
await db.user.update({
where: { id: owner.id },
data: { createdAt: new Date() },
});
const workspace = await app.create(Mockers.Workspace, { const workspace = await app.create(Mockers.Workspace, {
owner, owner,
name: 'Join example.com', name: 'Join example.com',
}); });
const previousDelay = config.auth.newAccountShareActionDelay; await t.throwsAsync(
config.auth.newAccountShareActionDelay = 0; inviteQuota.assertWorkspaceActionAllowed({
try { actorUserId: owner.id,
await app.login(owner); workspaceId: workspace.id,
await t.throwsAsync( action: 'inviteMember',
app.gql({ }),
query: createInviteLinkMutation, { instanceOf: ActionForbidden }
variables: { );
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay, await app.get(EntitlementService).upsertAdminGrant({
}, targetType: 'user',
}) targetId: owner.id,
); plan: 'pro',
} finally { });
config.auth.newAccountShareActionDelay = previousDelay; await t.notThrowsAsync(
} inviteQuota.assertWorkspaceActionAllowed({
actorUserId: owner.id,
workspaceId: workspace.id,
action: 'inviteMember',
})
);
await app.login(owner);
await t.throwsAsync(
app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
})
);
}); });
@@ -39,14 +39,6 @@ declare global {
} }
} }
export function canUserExecuteLimitedActions(
user: { createdAt: Date },
minimumAccountAgeMs: number
) {
if (minimumAccountAgeMs <= 0) return true;
return Date.now() - user.createdAt.getTime() >= minimumAccountAgeMs;
}
function parseAsn(value: string | undefined) { function parseAsn(value: string | undefined) {
if (!value) { if (!value) {
return; return;
@@ -237,6 +229,28 @@ export class InviteQuotaAssertService {
private readonly disposition: InviteAbuseDispositionService private readonly disposition: InviteAbuseDispositionService
) {} ) {}
async assertWorkspaceActionAllowed(input: {
actorUserId: string;
workspaceId: string;
action: 'inviteMember' | 'createInviteLink' | 'publishDoc';
docId?: string;
}) {
const decision = await this.runtime.evaluateWorkspaceActionV1(
input.actorUserId,
input.workspaceId
);
if (decision.allowed) return;
this.logger.warn('Workspace action rejected', {
...input,
reason: decision.reason,
retryAfter: decision.retryAfterSeconds,
});
throw new ActionForbidden(
'This feature is temporarily unavailable for you.'
);
}
async assertWorkspaceInviteQuota(input: { async assertWorkspaceInviteQuota(input: {
actorUserId: string; actorUserId: string;
workspaceId: string; workspaceId: string;
@@ -384,7 +398,11 @@ export class InviteQuotaAssertService {
private mapDecision( private mapDecision(
decision: RuntimeWorkspaceInviteQuotaDecision decision: RuntimeWorkspaceInviteQuotaDecision
): UserFriendlyError { ): UserFriendlyError {
if (decision.reason === 'abuse_subject' || decision.actionRequired) { if (
decision.reason === 'abuse_subject' ||
decision.reason === 'new_account_action_delay' ||
decision.actionRequired
) {
return new ActionForbidden('This feature is temporarily unavailable.'); return new ActionForbidden('This feature is temporarily unavailable.');
} }
return new TooManyRequest(); return new TooManyRequest();
@@ -15,9 +15,7 @@ import { Prisma, PrismaClient } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars'; import { SafeIntResolver } from 'graphql-scalars';
import { import {
ActionForbidden,
Cache, Cache,
Config,
DocActionDenied, DocActionDenied,
DocDefaultRoleCanNotBeOwner, DocDefaultRoleCanNotBeOwner,
DocNotFound, DocNotFound,
@@ -46,7 +44,7 @@ import {
PermissionAccess, PermissionAccess,
} from '../../permission'; } from '../../permission';
import { PublicUserType, WorkspaceUserType } from '../../user'; import { PublicUserType, WorkspaceUserType } from '../../user';
import { canUserExecuteLimitedActions } from '../abuse'; import { InviteQuotaAssertService } from '../abuse';
import { DocGrantsService } from '../doc-grants'; import { DocGrantsService } from '../doc-grants';
import { WorkspaceType } from '../types'; import { WorkspaceType } from '../types';
import { TimeBucket, TimeWindow } from './analytics-types'; import { TimeBucket, TimeWindow } from './analytics-types';
@@ -302,51 +300,10 @@ export class WorkspaceDocResolver {
private readonly models: Models, private readonly models: Models,
private readonly cache: Cache, private readonly cache: Cache,
private readonly event: EventBus, private readonly event: EventBus,
private readonly config: Config, private readonly runtime: BackendRuntimeProvider,
private readonly runtime: BackendRuntimeProvider private readonly inviteQuota: InviteQuotaAssertService
) {} ) {}
private async assertCanShare(
userId: string,
context: { workspaceId: string; docId: string; action: 'publishDoc' }
) {
if (await this.runtime.isInviteAbuseUserQuarantinedOrBanned(userId)) {
this.logger.warn('Share action blocked for quarantined actor', {
userId,
...context,
});
throw new ActionForbidden(
'This feature is temporarily unavailable for you.'
);
}
if (
await this.runtime.isInviteAbuseWorkspaceQuarantined(context.workspaceId)
) {
this.logger.warn('Share action blocked for quarantined workspace', {
userId,
...context,
});
throw new ActionForbidden(
'This feature is temporarily unavailable for you.'
);
}
const user = await this.models.user.get(userId);
const newAccountAgeMs = this.config.auth.newAccountShareActionDelay * 1000;
if (!user || !canUserExecuteLimitedActions(user, newAccountAgeMs)) {
this.logger.warn('Share action blocked for new account', {
userId,
email: user?.email,
createdAt: user?.createdAt,
accountAgeMs: user ? Date.now() - user.createdAt.getTime() : null,
minimumAccountAgeMs: newAccountAgeMs,
...context,
});
throw new ActionForbidden(
'This feature is temporarily unavailable for you.'
);
}
}
@ResolveField(() => WorkspaceDocMeta, { @ResolveField(() => WorkspaceDocMeta, {
description: 'Cloud page metadata of workspace', description: 'Cloud page metadata of workspace',
complexity: 2, complexity: 2,
@@ -475,7 +432,8 @@ export class WorkspaceDocResolver {
} }
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish'); await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
await this.assertCanShare(user.id, { await this.inviteQuota.assertWorkspaceActionAllowed({
actorUserId: user.id,
workspaceId, workspaceId,
docId, docId,
action: 'publishDoc', action: 'publishDoc',
@@ -1,4 +1,3 @@
import { Logger } from '@nestjs/common';
import { import {
Args, Args,
Context, Context,
@@ -39,7 +38,6 @@ import {
import type { GraphqlContext } from '../../../base/graphql'; import type { GraphqlContext } from '../../../base/graphql';
import { Models, type WorkspaceUserCompat } from '../../../models'; import { Models, type WorkspaceUserCompat } from '../../../models';
import { CurrentUser, Public } from '../../auth'; import { CurrentUser, Public } from '../../auth';
import { BackendRuntimeProvider } from '../../backend-runtime';
import { containsUrlOrDomain } from '../../content-policy'; import { containsUrlOrDomain } from '../../content-policy';
import { import {
PermissionAccess, PermissionAccess,
@@ -49,11 +47,7 @@ import {
import { QuotaService } from '../../quota'; import { QuotaService } from '../../quota';
import { UserType } from '../../user'; import { UserType } from '../../user';
import { validators } from '../../utils/validators'; import { validators } from '../../utils/validators';
import { import { getAbuseRequestSource, InviteQuotaAssertService } from '../abuse';
canUserExecuteLimitedActions,
getAbuseRequestSource,
InviteQuotaAssertService,
} from '../abuse';
import { WorkspaceService } from '../service'; import { WorkspaceService } from '../service';
import { import {
InvitationType, InvitationType,
@@ -92,8 +86,6 @@ function aggregateTargetDomains(candidates: InviteCandidate[]) {
*/ */
@Resolver(() => WorkspaceType) @Resolver(() => WorkspaceType)
export class WorkspaceMemberResolver { export class WorkspaceMemberResolver {
private readonly logger = new Logger(WorkspaceMemberResolver.name);
constructor( constructor(
private readonly cache: Cache, private readonly cache: Cache,
private readonly event: EventBus, private readonly event: EventBus,
@@ -105,55 +97,9 @@ export class WorkspaceMemberResolver {
private readonly workspaceService: WorkspaceService, private readonly workspaceService: WorkspaceService,
private readonly quota: QuotaService, private readonly quota: QuotaService,
private readonly config: Config, private readonly config: Config,
private readonly inviteQuota: InviteQuotaAssertService, private readonly inviteQuota: InviteQuotaAssertService
private readonly runtime: BackendRuntimeProvider
) {} ) {}
private async assertCanInviteOrShare(
userId: string,
context: {
workspaceId: string;
action: 'createInviteLink';
}
) {
if (await this.runtime.isInviteAbuseUserQuarantinedOrBanned(userId)) {
this.logger.warn('Share action blocked for quarantined actor', {
userId,
...context,
});
throw new ActionForbidden(
'This feature is temporarily unavailable for you.'
);
}
if (
await this.runtime.isInviteAbuseWorkspaceQuarantined(context.workspaceId)
) {
this.logger.warn('Share action blocked for quarantined workspace', {
userId,
...context,
});
throw new ActionForbidden(
'This feature is temporarily unavailable for you.'
);
}
// Member invites are owned by native quota; this guard stays for invite links until share/link actions migrate.
const user = await this.models.user.get(userId);
const newAccountAgeMs = this.config.auth.newAccountShareActionDelay * 1000;
if (!user || !canUserExecuteLimitedActions(user, newAccountAgeMs)) {
this.logger.warn('Share action blocked for new account', {
userId,
email: user?.email,
createdAt: user?.createdAt,
accountAgeMs: user ? Date.now() - user.createdAt.getTime() : null,
minimumAccountAgeMs: newAccountAgeMs,
...context,
});
throw new ActionForbidden(
'This feature is temporarily unavailable for you.'
);
}
}
private async assertWorkspaceNameCanInvite(workspaceId: string) { private async assertWorkspaceNameCanInvite(workspaceId: string) {
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId); const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
if (containsUrlOrDomain(workspace.name)) { if (containsUrlOrDomain(workspace.name)) {
@@ -287,6 +233,12 @@ export class WorkspaceMemberResolver {
return results; return results;
} }
await this.inviteQuota.assertWorkspaceActionAllowed({
actorUserId: me.id,
workspaceId,
action: 'inviteMember',
});
// lock to prevent concurrent invite // lock to prevent concurrent invite
const lockFlag = `invite:${workspaceId}`; const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag); await using lock = await this.mutex.acquire(lockFlag);
@@ -452,7 +404,8 @@ export class WorkspaceMemberResolver {
.user(user.id) .user(user.id)
.workspace(workspaceId) .workspace(workspaceId)
.assert('Workspace.Users.Manage'); .assert('Workspace.Users.Manage');
await this.assertCanInviteOrShare(user.id, { await this.inviteQuota.assertWorkspaceActionAllowed({
actorUserId: user.id,
workspaceId, workspaceId,
action: 'createInviteLink', action: 'createInviteLink',
}); });
+2 -2
View File
@@ -87,9 +87,9 @@
"type": "Boolean", "type": "Boolean",
"desc": "Whether require email verification before accessing restricted resources(not implemented)." "desc": "Whether require email verification before accessing restricted resources(not implemented)."
}, },
"newAccountShareActionDelay": { "newAccountActionDelay": {
"type": "Number", "type": "Number",
"desc": "Minimum account age in seconds before new accounts can invite members or create share links." "desc": "Minimum account age in seconds before new accounts can invite members, create invite links, or publish documents. Set to 0 to disable."
}, },
"trustedCloudflareHeaders": { "trustedCloudflareHeaders": {
"type": "Boolean", "type": "Boolean",
@@ -58,9 +58,9 @@ export const KNOWN_CONFIG_GROUPS = [
'allowSignup', 'allowSignup',
'allowSignupForOauth', 'allowSignupForOauth',
{ {
key: 'newAccountShareActionDelay', key: 'newAccountActionDelay',
type: 'Number', type: 'Number',
desc: 'Minimum account age in seconds before new accounts can invite members or create share links.', desc: 'Minimum account age in seconds before accounts can invite members, create invite links, or publish documents. Set to 0 to disable.',
}, },
// nested json object // nested json object
{ {
@@ -27,6 +27,7 @@ import {
type ModelDeclaration, type ModelDeclaration,
modelUseCases, modelUseCases,
probeChecks, probeChecks,
retainVerifiedCapabilities,
} from './model-utils'; } from './model-utils';
import type { ByokDefinition, ByokKey, ByokSettings, GqlFn } from './types'; import type { ByokDefinition, ByokKey, ByokSettings, GqlFn } from './types';
import { ByokStorage } from './types'; import { ByokStorage } from './types';
@@ -138,7 +139,7 @@ export const AddKeyModal = ({
const invalidateTest = () => setTestStatus(null); const invalidateTest = () => setTestStatus(null);
const runProbe = useCallback(async () => { const runProbe = useCallback(async () => {
if (!gql) return false; if (!gql) return { passed: false, definition };
const canReuseServerCredential = const canReuseServerCredential =
editingKey?.storage === ByokStorage.server && !apiKey; editingKey?.storage === ByokStorage.server && !apiKey;
const checks = probeChecks(models, includeImageProbe); const checks = probeChecks(models, includeImageProbe);
@@ -159,21 +160,19 @@ export const AddKeyModal = ({
}, },
}); });
const probe = result.probeWorkspaceByokDraft; const probe = result.probeWorkspaceByokDraft;
const verifiedChecks = new Set( const nextModels = retainVerifiedCapabilities(models, probe.models);
probe.models.flatMap(model => const nextDefinition = { ...definition, models: nextModels };
model.checks const hasVerifiedCheck = probe.models.some(model =>
.filter(check => check.status.kind === 'verified') model.checks.some(check => check.status.kind === 'verified')
.map(check => `${model.modelId}\0${check.operation}`)
)
); );
const passed = const passed =
checks.length > 0 && checks.length > 0 &&
probe.connection.kind === 'verified' && probe.connection.kind === 'verified' &&
checks.every(check => hasVerifiedCheck &&
verifiedChecks.has(`${check.modelId}\0${check.operation}`) nextModels.some(model => model.enabled && model.capabilities.length > 0);
); if (passed) setModels(nextModels);
setTestStatus(passed ? 'passed' : 'failed'); setTestStatus(passed ? 'passed' : 'failed');
return passed; return { passed, definition: nextDefinition };
}, [ }, [
apiKey, apiKey,
definition, definition,
@@ -185,112 +184,118 @@ export const AddKeyModal = ({
workspaceId, workspaceId,
]); ]);
const persist = useCallback(async () => { const persist = useCallback(
if (!gql) return; async (persistedDefinition = definition) => {
if (storage === ByokStorage.local) { if (!gql) return;
const saved = await upsertLocalKey(workspaceId, { if (storage === ByokStorage.local) {
id: const saved = await upsertLocalKey(workspaceId, {
editingKey?.storage === ByokStorage.local id:
? editingKey.id editingKey?.storage === ByokStorage.local
: crypto.randomUUID(), ? editingKey.id
provider, : crypto.randomUUID(),
name, provider,
description, name,
credential: apiKey, description,
definition, credential: apiKey,
sortOrder: definition: persistedDefinition,
editingKey?.storage === ByokStorage.local sortOrder:
? editingKey.sortOrder editingKey?.storage === ByokStorage.local
: localKeys.length, ? editingKey.sortOrder
enabled: profileEnabled, : localKeys.length,
}); enabled: profileEnabled,
if (!saved) {
notify.error({
title: byokT(t, 'notify.local-save-failed.title'),
message: byokT(t, 'notify.local-save-failed.message'),
}); });
return; if (!saved) {
} notify.error({
setLocalKeys(await readLocalKeys(workspaceId)); title: byokT(t, 'notify.local-save-failed.title'),
} else if (editingKey?.storage === ByokStorage.server) { message: byokT(t, 'notify.local-save-failed.message'),
if (editingKey.revision === undefined) { });
notify.error({ return;
title: byokT(t, 'notify.reload-required.title'), }
message: byokT(t, 'notify.reload-required.message'), setLocalKeys(await readLocalKeys(workspaceId));
} else if (editingKey?.storage === ByokStorage.server) {
if (editingKey.revision === undefined) {
notify.error({
title: byokT(t, 'notify.reload-required.title'),
message: byokT(t, 'notify.reload-required.message'),
});
return;
}
await gql({
query: replaceWorkspaceByokProfileMutation,
variables: {
input: {
workspaceId,
profileId: editingKey.id,
expectedRevision: editingKey.revision,
name,
description: description || null,
credential: apiKey || null,
definition: persistedDefinition,
enabled: profileEnabled,
},
},
}); });
return; await onSaved();
} else {
await gql({
query: createWorkspaceByokProfileMutation,
variables: {
input: {
workspaceId,
provider,
name,
description: description || null,
credential: apiKey,
definition: persistedDefinition,
enabled: profileEnabled,
},
},
});
await onSaved();
} }
await gql({ onOpenChange(false);
query: replaceWorkspaceByokProfileMutation, },
variables: { [
input: { apiKey,
workspaceId, definition,
profileId: editingKey.id, description,
expectedRevision: editingKey.revision, editingKey,
name, gql,
description: description || null, localKeys.length,
credential: apiKey || null, name,
definition, onOpenChange,
enabled: profileEnabled, onSaved,
}, provider,
}, profileEnabled,
}); setLocalKeys,
await onSaved(); storage,
} else { t,
await gql({ workspaceId,
query: createWorkspaceByokProfileMutation, ]
variables: { );
input: {
workspaceId,
provider,
name,
description: description || null,
credential: apiKey,
definition,
enabled: profileEnabled,
},
},
});
await onSaved();
}
onOpenChange(false);
}, [
apiKey,
definition,
description,
editingKey,
gql,
localKeys.length,
name,
onOpenChange,
onSaved,
provider,
profileEnabled,
setLocalKeys,
storage,
t,
workspaceId,
]);
const connect = useCallback(async () => { const connect = useCallback(async () => {
if (busyRef.current) return; if (busyRef.current) return;
busyRef.current = true; busyRef.current = true;
setBusy(true); setBusy(true);
try { try {
const passed = testStatus === 'passed' || (await runProbe()); const probe =
if (!passed) { testStatus === 'passed'
? { passed: true, definition }
: await runProbe();
if (!probe.passed) {
notify.error({ notify.error({
title: byokT(t, 'notify.test-failed.title'), title: byokT(t, 'notify.test-failed.title'),
message: byokT(t, 'notify.operation-failed.message'), message: byokT(t, 'notify.operation-failed.message'),
}); });
return; return;
} }
await persist(); await persist(probe.definition);
} finally { } finally {
busyRef.current = false; busyRef.current = false;
setBusy(false); setBusy(false);
} }
}, [persist, runProbe, t, testStatus]); }, [definition, persist, runProbe, t, testStatus]);
const testConnection = useCallback(async () => { const testConnection = useCallback(async () => {
if (busyRef.current) return; if (busyRef.current) return;
@@ -309,7 +314,10 @@ export const AddKeyModal = ({
!!name.trim() && !!name.trim() &&
hasCredential && hasCredential &&
models.length > 0 && models.length > 0 &&
models.every(model => model.modelId.trim() && model.capabilities.length) && models.every(
model =>
model.modelId.trim() && (!model.enabled || model.capabilities.length)
) &&
new Set(models.map(model => model.modelId.trim())).size === models.length && new Set(models.map(model => model.modelId.trim())).size === models.length &&
(!customEndpoint || (!!endpoint.trim() && dialect !== null)); (!customEndpoint || (!!endpoint.trim() && dialect !== null));
@@ -11,6 +11,7 @@ import {
capabilitiesForUseCases, capabilitiesForUseCases,
type ModelDeclaration, type ModelDeclaration,
modelUseCases, modelUseCases,
retainVerifiedCapabilities,
} from './model-utils'; } from './model-utils';
describe('BYOK model capabilities', () => { describe('BYOK model capabilities', () => {
@@ -60,4 +61,53 @@ describe('BYOK model capabilities', () => {
capabilitiesForUseCases(model, ['chat', 'actions', 'vision']) capabilitiesForUseCases(model, ['chat', 'actions', 'vision'])
).toEqual([capability]); ).toEqual([capability]);
}); });
test('drops capabilities that imply failed uses while keeping independent uses', () => {
const embeddingCapability = {
input: [ByokModelInput.text],
output: [ByokModelOutput.embedding],
features: [],
attachmentKinds: [],
attachmentSources: [],
};
const model: ModelDeclaration = {
modelId: 'multimodal-tools',
enabled: true,
capabilities: [
{
input: [ByokModelInput.text, ByokModelInput.image],
output: [ByokModelOutput.text],
features: [ByokModelFeature.tool_calling],
attachmentKinds: [ByokAttachmentKind.image],
attachmentSources: [
ByokAttachmentSource.url,
ByokAttachmentSource.data,
ByokAttachmentSource.bytes,
ByokAttachmentSource.file_handle,
],
},
embeddingCapability,
],
};
const retained = retainVerifiedCapabilities(
[model],
[
{
modelId: model.modelId,
checks: [
{ operation: 'chat', status: { kind: 'failed' } },
{ operation: 'tool_calling', status: { kind: 'verified' } },
{ operation: 'embedding', status: { kind: 'verified' } },
],
},
]
)[0];
expect(retained).toEqual({
...model,
capabilities: [embeddingCapability],
});
expect(modelUseCases(retained)).toEqual(['embedding']);
});
}); });
@@ -163,6 +163,55 @@ export function probeChecks(models: ModelDeclaration[], includeImage: boolean) {
); );
} }
export function retainVerifiedCapabilities(
models: ModelDeclaration[],
probeModels: Array<{
modelId: string;
checks: Array<{ operation: string; status: { kind: string } }>;
}>
) {
return models.map(model => {
const probe = probeModels.find(item => item.modelId === model.modelId);
if (!probe) return model;
const failedOperations = new Set(
probe.checks
.filter(check => check.status.kind !== 'verified')
.map(check => check.operation)
);
const selectedUseCases = modelUseCases(model);
const verifiedUseCases = selectedUseCases.filter(useCase => {
const operation = useCase === 'actions' ? 'tool_calling' : useCase;
if (failedOperations.has(operation)) return false;
const represented = modelUseCases({
...model,
capabilities: [capabilityForUseCase(useCase)],
});
return represented.every(
representedUseCase =>
!failedOperations.has(
representedUseCase === 'actions'
? 'tool_calling'
: representedUseCase
)
);
});
const rebuiltCapabilities =
selectedUseCases.length > 0 &&
verifiedUseCases.length === selectedUseCases.length
? model.capabilities
: verifiedUseCases.map(capabilityForUseCase);
const capabilities = rebuiltCapabilities.length
? rebuiltCapabilities
: model.capabilities;
return {
...model,
enabled: rebuiltCapabilities.length > 0 && model.enabled,
capabilities,
};
});
}
export function catalogModels(settings: ByokSettings, provider: ByokProvider) { export function catalogModels(settings: ByokSettings, provider: ByokProvider) {
return ( return (
settings.catalog.providers.find(item => item.provider === provider) settings.catalog.providers.find(item => item.provider === provider)
-1
View File
@@ -147,7 +147,6 @@ echo "iOS MARKETING_VERSION: $ios_new_version (app version: $new_version)"
update_app_version_in_helm_charts ".github/helm/affine/Chart.yaml" "$new_version" update_app_version_in_helm_charts ".github/helm/affine/Chart.yaml" "$new_version"
update_app_version_in_helm_charts ".github/helm/affine/charts/graphql/Chart.yaml" "$new_version" update_app_version_in_helm_charts ".github/helm/affine/charts/graphql/Chart.yaml" "$new_version"
update_app_version_in_helm_charts ".github/helm/affine/charts/front/Chart.yaml" "$new_version" update_app_version_in_helm_charts ".github/helm/affine/charts/front/Chart.yaml" "$new_version"
update_app_version_in_helm_charts ".github/helm/affine/charts/doc/Chart.yaml" "$new_version"
update_app_stream_version "packages/frontend/apps/electron/resources/affine.metainfo.xml" "$new_version" update_app_stream_version "packages/frontend/apps/electron/resources/affine.metainfo.xml" "$new_version"