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
+7
View File
@@ -34,6 +34,7 @@ export declare class BackendRuntime {
claimInviteAbuseAction(actionId: string, workerId: string): Promise<boolean>
claimRetryableInviteAbuseActions(workerId: string, limit: number): Promise<Array<RuntimeInviteAbuseClaimedAction>>
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>
commitWorkspaceInviteQuotaV1(reservationId: string, usage: RuntimeWorkspaceInviteQuotaUsage): Promise<boolean>
releaseWorkspaceInviteQuotaV1(reservationId: string): Promise<boolean>
@@ -1496,6 +1497,12 @@ export interface RuntimeVerificationTokenRecord {
expiresAtMs: number
}
export interface RuntimeWorkspaceActionDecision {
allowed: boolean
retryAfterSeconds?: number
reason?: string
}
export interface RuntimeWorkspaceArtifact {
id: string
workspaceId: string
@@ -6,10 +6,10 @@ use llm_adapter::{
AttachmentKind, AttachmentSource, ModelFeature, ModelInput, ModelOutput, ModelRequirements, declared_model_matches,
},
core::{
CoreContent, CoreMessage, CoreRequest, CoreRole, CoreToolDefinition, EmbeddingRequest, ImageOptions,
ImageProviderOptions, ImageRequest, RerankCandidate, RerankRequest, StructuredRequest,
CoreContent, CoreMessage, CoreRequest, CoreRole, CoreToolChoice, CoreToolDefinition, EmbeddingRequest,
ImageOptions, ImageProviderOptions, ImageRequest, RerankCandidate, RerankRequest, StructuredRequest,
},
router::{ExecutablePreparedRoute, ExecutableRequest, dispatch_prepared_route},
router::{ExecutablePreparedRoute, ExecutableRequest, ExecutableResponse, dispatch_prepared_route},
target::{
BackendCredential, BackendEndpoint, BackendOperation, BackendTargetInput, EgressPolicy, compile_backend_target,
},
@@ -30,9 +30,11 @@ pub(super) async fn execute_probe(
checks: Vec<ByokProbeCheckInput>,
) -> RuntimeResult<ByokProbeResultOutput> {
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 {
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"));
}
if !matches!(
@@ -41,6 +43,7 @@ pub(super) async fn execute_probe(
) {
return Err(RuntimeError::invalid_input("unknown BYOK probe operation"));
}
requested.push(key);
}
let mut models = Vec::new();
@@ -160,7 +163,49 @@ fn dispatch_check(
Err(_) => return failed(checked_at, "invalid_probe_request"),
};
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)),
}
}
@@ -169,7 +214,11 @@ fn probe_request_for_operation(operation: &str) -> ExecutableRequest {
let message = CoreMessage {
role: CoreRole::User,
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 {
@@ -177,8 +226,8 @@ fn probe_request_for_operation(operation: &str) -> ExecutableRequest {
model: String::new(),
messages: vec![message],
stream: false,
max_tokens: Some(8),
temperature: Some(0.0),
max_tokens: Some(64),
temperature: None,
tools: if operation == "tool_calling" {
vec![CoreToolDefinition {
name: "byok_probe".to_string(),
@@ -188,7 +237,9 @@ fn probe_request_for_operation(operation: &str) -> ExecutableRequest {
} else {
vec![]
},
tool_choice: None,
tool_choice: (operation == "tool_calling").then_some(CoreToolChoice::Specific {
name: "byok_probe".to_string(),
}),
include: None,
reasoning: None,
response_schema: None,
@@ -202,8 +253,8 @@ fn probe_request_for_operation(operation: &str) -> ExecutableRequest {
"required": ["ok"],
"additionalProperties": false
}),
max_tokens: Some(16),
temperature: Some(0.0),
max_tokens: Some(128),
temperature: None,
reasoning: None,
strict: Some(true),
response_mime_type: Some("application/json".to_string()),
@@ -426,7 +477,48 @@ mod tests {
let mut stream = stream.unwrap();
let request = read_request(&mut stream);
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!({
"id": "resp_smoke",
"model": "smoke-model",
@@ -439,6 +531,25 @@ mod tests {
}],
"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 {
json!({
"id": "chat_smoke",
@@ -489,7 +600,7 @@ mod tests {
#[test]
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);
for dialect in [OpenAiDialect::Responses, OpenAiDialect::ChatCompletions] {
@@ -520,19 +631,39 @@ mod tests {
.iter()
.filter(|request| request.starts_with("POST /v1/responses "))
.count(),
operations.len()
3
);
assert_eq!(
requests
.iter()
.filter(|request| request.starts_with("POST /v1/chat/completions "))
.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_eq!(
requests.iter().filter(|request| request.contains("byok_probe")).count(),
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,
};
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(
r#"
SELECT 1
@@ -22,7 +22,7 @@ async fn invite_abuse_user_quarantined_or_banned(pool: &PgPool, user_id: &str) -
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(
r#"
SELECT 1
@@ -6,6 +6,7 @@ mod reservation;
mod workspace_invite;
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 napi::Result;
use reservation::{
@@ -15,8 +16,8 @@ use reservation::{
use sha2::{Digest, Sha256};
use workspace_invite_policy::{
ActorFacts, InviteAbuseDecision, InviteActivityFacts, QuotaFacts, WorkspaceFacts, build_invite_scopes,
evaluate_projection, high_confidence_invite_abuse, invite_commit_usage_for_scope, source_cohort_subject_key,
subject_hash, sum_domains,
evaluate_projection, high_confidence_invite_abuse, invite_commit_usage_for_scope, new_account_action_retry_after,
source_cohort_subject_key, subject_hash, sum_domains,
};
#[cfg(test)]
@@ -26,7 +27,8 @@ pub(super) use super::{
types::{
RuntimeInviteAbuseActionRequired, RuntimeInviteAbuseClaimedAction, RuntimeMailDeliveryQuotaDecision,
RuntimeMailDeliveryQuotaInput, RuntimeQuotaSourceInput, RuntimeQuotaTargetDomainInput,
RuntimeWorkspaceInviteQuotaDecision, RuntimeWorkspaceInviteQuotaInput, RuntimeWorkspaceInviteQuotaUsage,
RuntimeWorkspaceActionDecision, RuntimeWorkspaceInviteQuotaDecision, RuntimeWorkspaceInviteQuotaInput,
RuntimeWorkspaceInviteQuotaUsage,
},
};
@@ -5,19 +5,32 @@ use sqlx::{PgPool, Row};
use super::{
ActorFacts, BackendRuntime, InviteAbuseDecision, InviteActivityFacts, InviteQuotaConfig, QuotaFacts, QuotaViolation,
RuntimeError, RuntimeInviteAbuseActionRequired, RuntimeResult, RuntimeWorkspaceInviteQuotaDecision,
RuntimeWorkspaceInviteQuotaInput, RuntimeWorkspaceInviteQuotaUsage, WorkspaceFacts, build_invite_scopes,
commit_reservation, evaluate_projection, high_confidence_invite_abuse, invite_commit_usage_for_scope, napi_error,
normalize_domain, release_reservation, reserve_scopes, short_hash, source_cohort_subject_key, source_prefix,
subject_hash, sum_domains, workspace_subject_key,
RuntimeError, RuntimeInviteAbuseActionRequired, RuntimeResult, RuntimeWorkspaceActionDecision,
RuntimeWorkspaceInviteQuotaDecision, RuntimeWorkspaceInviteQuotaInput, RuntimeWorkspaceInviteQuotaUsage,
WorkspaceFacts, build_invite_scopes, commit_reservation, evaluate_projection, high_confidence_invite_abuse,
invite_abuse_user_quarantined_or_banned, invite_abuse_workspace_quarantined, invite_commit_usage_for_scope,
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> {
let row = sqlx::query(
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
WHERE id = $1
LEFT JOIN effective_user_quota_states quota ON quota.user_id = users.id
WHERE users.id = $1
"#,
)
.bind(user_id)
@@ -32,6 +45,7 @@ async fn load_actor(pool: &PgPool, user_id: &str) -> RuntimeResult<ActorFacts> {
registered: row.get("registered"),
email_verified: row.get("email_verified"),
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]
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]
pub async fn assert_workspace_invite_quota_v1(
&self,
@@ -388,6 +445,22 @@ impl BackendRuntime {
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) {
let reason = abuse_decision.reason;
let scope_key = match abuse_decision.subject_kind {
@@ -10,6 +10,7 @@ use super::{
InviteQuotaConfig, RuntimeQuotaTargetDomainInput, RuntimeWorkspaceInviteQuotaInput, ScopeLimit, bucket_seconds,
high_risk_domain, napi_error, normalize_domain, scope, short_hash, source_prefix, workspace_subject_key,
};
use crate::llm::Deployment;
#[derive(Clone, Debug)]
pub(super) struct ActorFacts {
@@ -18,6 +19,7 @@ pub(super) struct ActorFacts {
pub(super) registered: bool,
pub(super) email_verified: bool,
pub(super) disabled: bool,
pub(super) quota_plan: Option<String>,
}
#[derive(Clone, Debug)]
@@ -108,9 +110,6 @@ fn base_invite_limits(
let mut per_day = 15;
let mut per_week = 30;
if account_age < Duration::hours(24) {
return (0, 0, 0, 0);
}
if !actor.email_verified {
single = 1;
per_hour = 1;
@@ -185,6 +184,31 @@ pub(super) fn evaluate_projection(quota: &QuotaFacts, now: DateTime<Utc>) -> Opt
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(
input: &RuntimeWorkspaceInviteQuotaInput,
actor: &ActorFacts,
@@ -442,6 +466,7 @@ mod tests {
registered: true,
email_verified: true,
disabled: false,
quota_plan: None,
}
}
@@ -467,7 +492,7 @@ mod tests {
}
#[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 input = RuntimeWorkspaceInviteQuotaInput {
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")
.unwrap();
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]
+17 -2
View File
@@ -319,6 +319,7 @@ impl TryFrom<CopilotManagedProfileConfigFile> for CopilotManagedProfileConfig {
#[derive(Clone, Debug)]
pub(crate) struct InviteQuotaConfig {
pub(crate) new_account_action_delay_seconds: i64,
pub(crate) high_risk_target_domains: Vec<String>,
pub(crate) subject_hash_salt: String,
pub(crate) mail_class_mapping: BTreeMap<String, String>,
@@ -327,6 +328,7 @@ pub(crate) struct InviteQuotaConfig {
impl Default for InviteQuotaConfig {
fn default() -> Self {
Self {
new_account_action_delay_seconds: 24 * 60 * 60,
high_risk_target_domains: [
"qq.com",
"proton.me",
@@ -479,12 +481,19 @@ fn deployment_from_env() -> Deployment {
#[derive(Default, Deserialize)]
struct AppConfigFile {
auth: Option<AuthConfigFile>,
db: Option<DbConfigFile>,
crypto: Option<CryptoConfigFile>,
copilot: Option<CopilotRuntimeConfigFile>,
indexer: Option<SearchRuntimeConfigFile>,
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthConfigFile {
new_account_action_delay: Option<i64>,
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct SearchRuntimeConfigFile {
@@ -542,7 +551,11 @@ impl AppConfigFile {
}
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]
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([
("auth.newAccountActionDelay", serde_json::json!(123)),
("auth.untrustedPolicyOverride", serde_json::json!("runtime-salt-v2")),
("auth.untrustedDomainList", serde_json::json!(["Example.COM."])),
])
.unwrap();
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_ne!(config.subject_hash_salt, "runtime-salt-v2");
assert_eq!(
@@ -263,6 +263,13 @@ pub struct RuntimeWorkspaceInviteQuotaUsage {
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)]
pub struct RuntimeInviteAbuseActionRequired {
pub action: String,