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
@@ -18,7 +18,7 @@ export interface AuthConfig {
allowSignupForOauth: boolean;
requireEmailDomainVerification: boolean;
requireEmailVerification: boolean;
newAccountShareActionDelay: number;
newAccountActionDelay: number;
trustedCloudflareHeaders: boolean;
signInRateLimit: ConfigItem<{
ttl: number;
@@ -56,8 +56,8 @@ defineModuleConfig('auth', {
desc: 'Whether require email verification before accessing restricted resources(not implemented).',
default: true,
},
newAccountShareActionDelay: {
desc: 'Minimum account age in seconds before new accounts can invite members or create share links.',
newAccountActionDelay: {
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,
shape: z.number().int().min(0),
},
@@ -121,6 +121,12 @@ export type RuntimeWorkspaceInviteQuotaUsage = {
targetDomains: RuntimeQuotaTargetDomainInput[];
};
export type RuntimeWorkspaceActionDecision = {
allowed: boolean;
retryAfterSeconds?: number;
reason?: string;
};
export type RuntimeInviteAbuseAction =
| 'ban_actor'
| 'quarantine_actor'
@@ -213,6 +219,10 @@ export type RuntimeMailDeliveryQuotaDecision = {
};
type RuntimeQuotaMethods = RuntimeInstance & {
evaluateWorkspaceActionV1(
actorUserId: string,
workspaceId: string
): Promise<RuntimeWorkspaceActionDecision>;
assertWorkspaceInviteQuotaV1(
input: RuntimeWorkspaceInviteQuotaInput
): Promise<NativeRuntimeWorkspaceInviteQuotaDecision>;
@@ -328,6 +338,7 @@ export class BackendRuntimeProvider
!updates.copilot &&
!updates.crypto &&
!updates.db &&
!updates.auth &&
!updates.indexer &&
!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(
reservationId: string,
usage: RuntimeWorkspaceInviteQuotaUsage
@@ -15,6 +15,9 @@ import { Mockers } from '../../../__tests__/mocks';
import { Config } from '../../../base';
import { ActionForbidden, TooManyRequest } from '../../../base/error';
import { Models, WorkspaceRole } from '../../../models';
import { BackendRuntimeProvider } from '../../backend-runtime';
import { EntitlementService } from '../../entitlement';
import { QuotaService } from '../../quota';
import {
getAbuseRequestSource,
InviteAbuseDispositionService,
@@ -23,6 +26,7 @@ import {
let app: TestingApp;
const quota = {
assertWorkspaceActionAllowed: Sinon.stub(),
assertWorkspaceInviteQuota: Sinon.stub(),
commitWorkspaceInviteQuota: Sinon.stub(),
releaseWorkspaceInviteQuota: Sinon.stub(),
@@ -41,6 +45,7 @@ test.before(async () => {
});
test.beforeEach(() => {
quota.assertWorkspaceActionAllowed.reset();
quota.assertWorkspaceInviteQuota.reset();
quota.commitWorkspaceInviteQuota.reset();
quota.releaseWorkspaceInviteQuota.reset();
@@ -345,8 +350,8 @@ test('workspace quarantine blocks invite link creation', async t => {
updated_at = now()
`;
const previousDelay = config.auth.newAccountShareActionDelay;
config.auth.newAccountShareActionDelay = 0;
const previousDelay = config.auth.newAccountActionDelay;
config.auth.newAccountActionDelay = 0;
try {
await app.login(owner);
await t.throwsAsync(
@@ -359,32 +364,58 @@ test('workspace quarantine blocks invite link creation', async t => {
})
);
} finally {
config.auth.newAccountShareActionDelay = previousDelay;
config.auth.newAccountActionDelay = previousDelay;
}
});
test('domain workspace name blocks invite link creation', async t => {
const config = app.get(Config);
test('workspace action admission applies exemption before content policy', async t => {
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);
await db.user.update({
where: { id: owner.id },
data: { createdAt: new Date() },
});
const workspace = await app.create(Mockers.Workspace, {
owner,
name: 'Join example.com',
});
const previousDelay = config.auth.newAccountShareActionDelay;
config.auth.newAccountShareActionDelay = 0;
try {
await app.login(owner);
await t.throwsAsync(
app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
})
);
} finally {
config.auth.newAccountShareActionDelay = previousDelay;
}
await t.throwsAsync(
inviteQuota.assertWorkspaceActionAllowed({
actorUserId: owner.id,
workspaceId: workspace.id,
action: 'inviteMember',
}),
{ instanceOf: ActionForbidden }
);
await app.get(EntitlementService).upsertAdminGrant({
targetType: 'user',
targetId: owner.id,
plan: 'pro',
});
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) {
if (!value) {
return;
@@ -237,6 +229,28 @@ export class InviteQuotaAssertService {
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: {
actorUserId: string;
workspaceId: string;
@@ -384,7 +398,11 @@ export class InviteQuotaAssertService {
private mapDecision(
decision: RuntimeWorkspaceInviteQuotaDecision
): 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 TooManyRequest();
@@ -15,9 +15,7 @@ import { Prisma, PrismaClient } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars';
import {
ActionForbidden,
Cache,
Config,
DocActionDenied,
DocDefaultRoleCanNotBeOwner,
DocNotFound,
@@ -46,7 +44,7 @@ import {
PermissionAccess,
} from '../../permission';
import { PublicUserType, WorkspaceUserType } from '../../user';
import { canUserExecuteLimitedActions } from '../abuse';
import { InviteQuotaAssertService } from '../abuse';
import { DocGrantsService } from '../doc-grants';
import { WorkspaceType } from '../types';
import { TimeBucket, TimeWindow } from './analytics-types';
@@ -302,51 +300,10 @@ export class WorkspaceDocResolver {
private readonly models: Models,
private readonly cache: Cache,
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, {
description: 'Cloud page metadata of workspace',
complexity: 2,
@@ -475,7 +432,8 @@ export class WorkspaceDocResolver {
}
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,
docId,
action: 'publishDoc',
@@ -1,4 +1,3 @@
import { Logger } from '@nestjs/common';
import {
Args,
Context,
@@ -39,7 +38,6 @@ import {
import type { GraphqlContext } from '../../../base/graphql';
import { Models, type WorkspaceUserCompat } from '../../../models';
import { CurrentUser, Public } from '../../auth';
import { BackendRuntimeProvider } from '../../backend-runtime';
import { containsUrlOrDomain } from '../../content-policy';
import {
PermissionAccess,
@@ -49,11 +47,7 @@ import {
import { QuotaService } from '../../quota';
import { UserType } from '../../user';
import { validators } from '../../utils/validators';
import {
canUserExecuteLimitedActions,
getAbuseRequestSource,
InviteQuotaAssertService,
} from '../abuse';
import { getAbuseRequestSource, InviteQuotaAssertService } from '../abuse';
import { WorkspaceService } from '../service';
import {
InvitationType,
@@ -92,8 +86,6 @@ function aggregateTargetDomains(candidates: InviteCandidate[]) {
*/
@Resolver(() => WorkspaceType)
export class WorkspaceMemberResolver {
private readonly logger = new Logger(WorkspaceMemberResolver.name);
constructor(
private readonly cache: Cache,
private readonly event: EventBus,
@@ -105,55 +97,9 @@ export class WorkspaceMemberResolver {
private readonly workspaceService: WorkspaceService,
private readonly quota: QuotaService,
private readonly config: Config,
private readonly inviteQuota: InviteQuotaAssertService,
private readonly runtime: BackendRuntimeProvider
private readonly inviteQuota: InviteQuotaAssertService
) {}
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) {
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
if (containsUrlOrDomain(workspace.name)) {
@@ -287,6 +233,12 @@ export class WorkspaceMemberResolver {
return results;
}
await this.inviteQuota.assertWorkspaceActionAllowed({
actorUserId: me.id,
workspaceId,
action: 'inviteMember',
});
// lock to prevent concurrent invite
const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag);
@@ -452,7 +404,8 @@ export class WorkspaceMemberResolver {
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
await this.assertCanInviteOrShare(user.id, {
await this.inviteQuota.assertWorkspaceActionAllowed({
actorUserId: user.id,
workspaceId,
action: 'createInviteLink',
});