feat(server): entitlement based model (#14996)

#### PR Dependency Tree


* **PR #14996** 👈

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**
  * Admin mutations to grant/revoke commercial entitlements.
  * New Doc comment-update permission.
  * Realtime user/workspace quota-state endpoints and live-update rooms.

* **Bug Fixes**
  * More accurate readable-doc filtering and permission evaluation.

* **Refactor**
* Workspace feature management moved to entitlement-based model;
permission and quota pipelines redesigned.
  * Admin workspace UI now edits flags only (feature toggles removed).

* **Tests**
* Extensive new and updated tests for permissions, entitlements, quota,
projection, and backfills.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14996?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-05-19 22:48:05 +08:00
committed by GitHub
parent 103ad2a810
commit c53457691d
150 changed files with 13463 additions and 2458 deletions
@@ -1,24 +1,24 @@
import { Injectable } from '@nestjs/common';
import { ActionForbidden } from '../../../base';
import { QuotaStateService } from '../../../core/quota/state';
import { Models, WorkspaceRole } from '../../../models';
@Injectable()
export class ByokEntitlementPolicy {
constructor(private readonly models: Models) {}
private isUserPlanEntitled(features: string[]) {
return (
features.includes('pro_plan_v1') ||
features.includes('lifetime_pro_plan_v1') ||
features.includes('unlimited_copilot')
);
}
constructor(
private readonly models: Models,
private readonly quotaState: QuotaStateService
) {}
async hasAiPlan(userId?: string) {
if (!userId) return false;
const features = await this.models.userFeature.list(userId);
return this.isUserPlanEntitled(features);
const state = await this.quotaState.reconcileUserQuotaState(userId);
const flags = state.flags as { unlimitedCopilot?: boolean };
return (
flags.unlimitedCopilot ||
['pro', 'lifetime_pro', 'ai'].includes(state.plan)
);
}
async hasManagementAccess(workspaceId: string, userId?: string) {
@@ -59,7 +59,7 @@ export class ByokEntitlementPolicy {
async hasLocalEntitlement(workspaceId: string, userId?: string) {
if (env.selfhosted) return true;
if (await this.models.workspaceFeature.has(workspaceId, 'team_plan_v1')) {
if (await this.hasWorkspaceTeamPlan(workspaceId)) {
return true;
}
@@ -73,7 +73,7 @@ export class ByokEntitlementPolicy {
async hasServerEntitlement(workspaceId: string) {
if (env.selfhosted) return true;
if (await this.models.workspaceFeature.has(workspaceId, 'team_plan_v1')) {
if (await this.hasWorkspaceTeamPlan(workspaceId)) {
return true;
}
@@ -102,4 +102,20 @@ export class ByokEntitlementPolicy {
throw new ActionForbidden('BYOK requires Pro, Team, or Believer.');
}
}
private async hasWorkspaceTeamPlan(workspaceId: string) {
try {
const state =
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
return ['team', 'selfhost_team'].includes(state.plan);
} catch (error) {
if (
error instanceof Error &&
error.message === 'Workspace owner not found'
) {
return false;
}
throw error;
}
}
}
@@ -13,7 +13,7 @@ import { SafeIntResolver } from 'graphql-scalars';
import { Throttle } from '../../../base';
import { CurrentUser } from '../../../core/auth';
import { AccessController } from '../../../core/permission';
import { PermissionAccess } from '../../../core/permission';
import { WorkspaceType } from '../../../core/workspaces';
import { ByokEntitlementPolicy } from './policy';
import { ByokKeyConfig, ByokLocalLeaseProvider, ByokService } from './service';
@@ -259,7 +259,7 @@ class CreateWorkspaceByokLocalLeaseInput {
@Resolver(() => WorkspaceType)
export class WorkspaceByokResolver {
constructor(
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly entitlement: ByokEntitlementPolicy,
private readonly byok: ByokService
) {}
@@ -2,7 +2,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
import { z } from 'zod';
import { OnEvent } from '../../../base';
import { AccessController } from '../../../core/permission';
import { PermissionAccess } from '../../../core/permission';
import {
RealtimePublisher,
RealtimeRegistry,
@@ -19,7 +19,7 @@ export function workspaceEmbeddingRoom(workspaceId: string) {
@Injectable()
export class CopilotEmbeddingRealtimeProvider implements OnModuleInit {
constructor(
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly models: Models,
private readonly context: CopilotContextService,
private readonly registry: RealtimeRegistry,
@@ -37,10 +37,7 @@ import {
UserFriendlyError,
} from '../../../base';
import { CurrentUser } from '../../../core/auth';
import {
AccessController,
WorkspacePolicyService,
} from '../../../core/permission';
import { PermissionAccess } from '../../../core/permission';
import {
ContextBlob,
ContextCategories,
@@ -60,7 +57,7 @@ import { getSignal, MAX_EMBEDDABLE_SIZE, readStream } from '../utils';
import { CopilotContextService } from './service';
async function assertAccess(
ac: AccessController,
ac: PermissionAccess,
userId: string,
workspaceId: string
) {
@@ -73,7 +70,7 @@ async function assertAccess(
async function getSession(
context: CopilotContextService,
ac: AccessController,
ac: PermissionAccess,
userId: string,
contextId: string,
options: { workspaceId?: string; sessionId?: string } = {}
@@ -292,7 +289,7 @@ class ContextMatchedDocChunk implements DocChunkSimilarity {
@Resolver(() => CopilotType)
export class CopilotContextRootResolver {
constructor(
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly event: EventBus,
private readonly mutex: RequestMutex,
private readonly chatSession: ChatSessionService,
@@ -441,8 +438,7 @@ export class CopilotContextRootResolver {
@Resolver(() => CopilotContextType)
export class CopilotContextResolver {
constructor(
private readonly ac: AccessController,
private readonly policy: WorkspacePolicyService,
private readonly ac: PermissionAccess,
private readonly models: Models,
private readonly mutex: RequestMutex,
private readonly context: CopilotContextService,
@@ -745,7 +741,11 @@ export class CopilotContextResolver {
const blobId = createHash('sha256').update(buffer).digest('base64url');
const { filename, mimetype } = content;
await this.policy.assertCanUploadBlob(user.id, session.workspaceId);
await this.ac
.user(user.id)
.workspace(session.workspaceId)
.allowLocal()
.assert('Workspace.Blobs.Write');
await this.storage.put(user.id, session.workspaceId, blobId, buffer);
const file = await session.addFile(
blobId,
@@ -7,7 +7,7 @@ import {
ImageFormatNotSupported,
sniffMime,
} from '../../../base';
import { WorkspacePolicyService } from '../../../core/permission';
import { PermissionAccess } from '../../../core/permission';
import { processImage } from '../../../native';
import { CompatSubmissionStore } from '../compat/submission-store';
import type { PromptMessage } from '../providers/types';
@@ -29,7 +29,7 @@ type CreateInboxMessage = {
export class ConversationInboxService {
constructor(
private readonly chatSession: ChatSessionService,
private readonly policy: WorkspacePolicyService,
private readonly ac: PermissionAccess,
private readonly storage: CopilotStorage,
private readonly submissions: CompatSubmissionStore
) {}
@@ -49,7 +49,11 @@ export class ConversationInboxService {
);
if (blobs.length) {
await this.policy.assertCanUploadBlob(userId, session.config.workspaceId);
await this.ac
.user(userId)
.workspace(session.config.workspaceId)
.allowLocal()
.assert('Workspace.Blobs.Write');
}
for (const blob of blobs) {
@@ -14,16 +14,8 @@ export class ConversationPolicy {
) {}
async getQuota(userId: string) {
const isCopilotUser = await this.models.userFeature.has(
userId,
'unlimited_copilot'
);
let limit: number | undefined;
if (!isCopilotUser) {
const quota = await this.quota.getUserQuota(userId);
limit = quota.copilotActionLimit;
}
const quota = await this.quota.getUserQuota(userId);
const limit = quota.copilotActionLimit;
const used = await this.models.copilotSession.countUserMessages(userId);
@@ -3,7 +3,7 @@ import { pick } from 'lodash-es';
import z from 'zod/v3';
import { DocReader, DocWriter } from '../../../core/doc';
import { AccessController } from '../../../core/permission';
import { PermissionAccess } from '../../../core/permission';
import { clearEmbeddingChunk } from '../../../models';
import { IndexerService } from '../../indexer';
import { CopilotContextService } from '../context/service';
@@ -99,7 +99,7 @@ function defineTool<T extends z.ZodTypeAny>(
@Injectable()
export class WorkspaceMcpProvider {
constructor(
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly reader: DocReader,
private readonly writer: DocWriter,
private readonly context: CopilotContextService,
@@ -28,7 +28,7 @@ import {
TooManyRequest,
} from '../../base';
import { CurrentUser } from '../../core/auth';
import { AccessController, DocAction } from '../../core/permission';
import { DocAction, PermissionAccess } from '../../core/permission';
import { UserType } from '../../core/user';
import type { ListSessionOptions, UpdateChatSession } from '../../models';
import { CompatHistoryProjector } from './compat/history-projector';
@@ -365,7 +365,7 @@ export class CopilotResolver {
private readonly modelNames = new Map<string, string>();
constructor(
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly mutex: RequestMutex,
private readonly prompt: PromptService,
private readonly chatSession: ChatSessionService,
@@ -815,7 +815,7 @@ export class CopilotResolver {
@Throttle()
@Resolver(() => UserType)
export class UserCopilotResolver {
constructor(private readonly ac: AccessController) {}
constructor(private readonly ac: PermissionAccess) {}
@ResolveField(() => CopilotType)
async copilot(
@@ -2,8 +2,7 @@ import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { ServerFeature, ServerService } from '../../../../core';
import { SubscriptionService } from '../../../payment/service';
import { SubscriptionPlan, SubscriptionStatus } from '../../../payment/types';
import { QuotaStateService } from '../../../../core/quota/state';
import type { ChatSession } from '../../session';
import { type ToolsConfig } from '../../types';
import { getTools } from '../../utils';
@@ -47,15 +46,14 @@ export class CapabilityPolicyHost {
}
try {
const subscription = await this.moduleRef
.get(SubscriptionService, { strict: false })
.select(SubscriptionPlan.AI)
.getSubscription({
userId,
plan: SubscriptionPlan.AI,
} as never);
return subscription?.status === SubscriptionStatus.Active;
const state = await this.moduleRef
.get(QuotaStateService, { strict: false })
.reconcileUserQuotaState(userId);
const flags = state.flags as { unlimitedCopilot?: boolean };
return (
!!flags.unlimitedCopilot ||
['pro', 'lifetime_pro', 'ai'].includes(state.plan)
);
} catch {
return false;
}
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Models } from '../../../models';
import { QuotaStateService } from '../../../core/quota/state';
import { PromptService } from '../prompt/service';
export const DEFAULT_EMBEDDING_MODEL = 'gemini-embedding-001';
@@ -9,7 +9,7 @@ export const DEFAULT_RERANK_MODEL = 'gpt-4o-mini';
@Injectable()
export class TaskPolicy {
constructor(
private readonly models: Models,
private readonly quotaState: QuotaStateService,
private readonly prompts: PromptService
) {}
@@ -25,10 +25,11 @@ export class TaskPolicy {
const prompt = await this.prompts.get('Transcript audio');
if (!prompt) return;
const hasAccess = await this.models.userFeature.has(
userId,
'unlimited_copilot'
);
const state = await this.quotaState.reconcileUserQuotaState(userId);
const flags = state.flags as { unlimitedCopilot?: boolean };
const hasAccess =
!!flags.unlimitedCopilot ||
['pro', 'lifetime_pro', 'ai'].includes(state.plan);
return prompt.optionalModels[hasAccess ? 1 : 0] ?? prompt.model;
}
}
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Config } from '../../../base';
import { DocReader, DocWriter } from '../../../core/doc';
import { AccessController } from '../../../core/permission';
import { PermissionAccess } from '../../../core/permission';
import { Models } from '../../../models';
import { IndexerService } from '../../indexer';
import type { NodeTextMiddleware } from '../config';
@@ -48,7 +48,7 @@ export type ProviderSpecificToolResolver = (
export class ToolRuntime {
constructor(
private readonly config: Config,
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly context: CopilotContextService,
private readonly docReader: DocReader,
private readonly docWriter: DocWriter,
@@ -1,7 +1,7 @@
import { Logger } from '@nestjs/common';
import { z } from 'zod';
import { AccessController } from '../../../core/permission';
import { PermissionAccess } from '../../../core/permission';
import { toolError } from './error';
import { defineTool } from './tool';
import type { ContextSession, CopilotChatOptions } from './types';
@@ -9,7 +9,7 @@ import type { ContextSession, CopilotChatOptions } from './types';
const logger = new Logger('ContextBlobReadTool');
export const buildBlobContentGetter = (
ac: AccessController,
ac: PermissionAccess,
context: ContextSession | null
) => {
const getBlobContent = async (
@@ -1,6 +1,6 @@
import { z } from 'zod';
import type { AccessController } from '../../../core/permission';
import type { PermissionAccess } from '../../../core/permission';
import type { Models } from '../../../models';
import type { IndexerService, SearchDoc } from '../../indexer';
import { workspaceSyncRequiredError } from './doc-sync';
@@ -9,7 +9,7 @@ import { defineTool } from './tool';
import type { CopilotChatOptions } from './types';
export const buildDocKeywordSearchGetter = (
ac: AccessController,
ac: PermissionAccess,
indexerService: IndexerService,
models: Models
) => {
@@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common';
import { z } from 'zod';
import { DocReader } from '../../../core/doc';
import { AccessController } from '../../../core/permission';
import { PermissionAccess } from '../../../core/permission';
import { Models } from '../../../models';
import {
documentSyncPendingError,
@@ -18,7 +18,7 @@ const isToolError = (result: ToolError | object): result is ToolError =>
'type' in result && result.type === 'error';
export const buildDocContentGetter = (
ac: AccessController,
ac: PermissionAccess,
docReader: DocReader,
models: Models
) => {
@@ -1,7 +1,7 @@
import { omit } from 'lodash-es';
import { z } from 'zod';
import type { AccessController } from '../../../core/permission';
import type { PermissionAccess } from '../../../core/permission';
import {
type ChunkSimilarity,
clearEmbeddingChunk,
@@ -19,7 +19,7 @@ const getEmbeddingRouteContext = (options: CopilotChatOptions) => ({
});
export const buildDocSearchGetter = (
ac: AccessController,
ac: PermissionAccess,
context: CopilotContextService,
sessionId: string | undefined,
models: Models
@@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common';
import { z } from 'zod';
import { DocWriter } from '../../../core/doc';
import { AccessController } from '../../../core/permission';
import { PermissionAccess } from '../../../core/permission';
import { toolError } from './error';
import { defineTool } from './tool';
import type { CopilotChatOptions } from './types';
@@ -15,7 +15,7 @@ const stripLeadingH1 = (content: string) =>
const sanitizeTitle = (title: string) => title.replace(/[\r\n]+/g, ' ').trim();
export const buildDocCreateHandler = (
ac: AccessController,
ac: PermissionAccess,
writer: DocWriter
) => {
return async (
@@ -57,7 +57,7 @@ export const buildDocCreateHandler = (
};
export const buildDocUpdateHandler = (
ac: AccessController,
ac: PermissionAccess,
writer: DocWriter
) => {
return async (
@@ -95,7 +95,7 @@ export const buildDocUpdateHandler = (
};
export const buildDocUpdateMetaHandler = (
ac: AccessController,
ac: PermissionAccess,
writer: DocWriter
) => {
return async (options: CopilotChatOptions, docId: string, title: string) => {
@@ -2,7 +2,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
import { z } from 'zod';
import { CopilotTranscriptionJobNotFound } from '../../../base';
import { AccessController } from '../../../core/permission';
import { PermissionAccess } from '../../../core/permission';
import {
RealtimeRegistry,
realtimeTranscriptTaskRoom,
@@ -13,7 +13,7 @@ import { CopilotTranscriptionReader } from './reader';
@Injectable()
export class CopilotTranscriptRealtimeProvider implements OnModuleInit {
constructor(
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly transcript: CopilotTranscriptionReader,
private readonly registry: RealtimeRegistry
) {}
@@ -21,7 +21,7 @@ import {
type FileUpload,
} from '../../../base';
import { CurrentUser } from '../../../core/auth';
import { AccessController } from '../../../core/permission';
import { PermissionAccess } from '../../../core/permission';
import { CopilotType } from '../resolver';
import type { TranscriptionJob } from './job';
import { buildLegacyProjection } from './projection';
@@ -295,7 +295,7 @@ const FinishedStatus: Set<AiJobStatus> = new Set([
@Resolver(() => CopilotType)
export class CopilotTranscriptionResolver {
constructor(
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly transcript: CopilotTranscriptionService
) {}
@@ -24,7 +24,7 @@ import {
UserFriendlyError,
} from '../../../base';
import { CurrentUser } from '../../../core/auth';
import { AccessController } from '../../../core/permission';
import { PermissionAccess } from '../../../core/permission';
import { WorkspaceType } from '../../../core/workspaces';
import { COPILOT_LOCKER } from '../resolver';
import { MAX_EMBEDDABLE_SIZE } from '../utils';
@@ -49,7 +49,7 @@ export class CopilotWorkspaceConfigType {
*/
@Resolver(() => WorkspaceType)
export class CopilotWorkspaceEmbeddingResolver {
constructor(private readonly ac: AccessController) {}
constructor(private readonly ac: PermissionAccess) {}
@ResolveField(() => CopilotWorkspaceConfigType, {
complexity: 2,
@@ -70,7 +70,7 @@ export class CopilotWorkspaceEmbeddingResolver {
@Resolver(() => CopilotWorkspaceConfigType)
export class CopilotWorkspaceEmbeddingConfigResolver {
constructor(
private readonly ac: AccessController,
private readonly ac: PermissionAccess,
private readonly mutex: Mutex,
private readonly copilotWorkspace: CopilotWorkspaceService
) {}