mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-24 13:17:10 +08:00
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:
@@ -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
@@ -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",
|
||||||
|
|||||||
Vendored
+7
@@ -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\""))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+80
-7
@@ -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("a), 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 {
|
||||||
|
|||||||
+90
-4
@@ -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)),
|
||||||
|
"a("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("a("free", 3)),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
Some(23 * 60 * 60)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
new_account_action_retry_after(
|
||||||
|
Deployment::SelfHosted,
|
||||||
|
&invite_config(),
|
||||||
|
&fresh_actor,
|
||||||
|
Some("a("free", 3)),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
new_account_action_retry_after(
|
||||||
|
Deployment::Cloud,
|
||||||
|
&invite_config(),
|
||||||
|
&fresh_actor,
|
||||||
|
Some("a("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("a("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("a("free", 3)),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
+107
-99
@@ -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));
|
||||||
|
|
||||||
|
|||||||
+50
@@ -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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+49
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user