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
) {}