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
@@ -150,7 +150,6 @@ export const KNOWN_CONFIG_GROUPS = [
module: 'copilot',
fields: [
'enabled',
'scenarios',
'providers.openai',
'providers.gemini',
'providers.anthropic',
@@ -7,7 +7,6 @@ import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import type { FeatureType } from '@affine/graphql';
import {
adminUpdateWorkspaceMutation,
adminWorkspaceQuery,
@@ -17,10 +16,8 @@ import { AccountIcon } from '@blocksuite/icons/rc';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { FeatureToggleList } from '../../../components/shared/feature-toggle-list';
import { useMutateQueryResource, useMutation } from '../../../use-mutation';
import { useQuery } from '../../../use-query';
import { useServerConfig } from '../../common';
import { RightPanelHeader } from '../../header';
import { useRightPanel } from '../../panel/context';
import type { WorkspaceDetail } from '../schema';
@@ -69,7 +66,6 @@ function WorkspacePanelContent({
workspace: WorkspaceDetail;
onClose: () => void;
}) {
const serverConfig = useServerConfig();
const { setHasDirtyChanges } = useRightPanel();
const revalidate = useMutateQueryResource();
const { trigger: updateWorkspace, isMutating } = useMutation({
@@ -78,7 +74,6 @@ function WorkspacePanelContent({
const normalizedWorkspace = useMemo(
() => ({
features: [...workspace.features],
flags: {
public: workspace.public,
enableAi: workspace.enableAi,
@@ -91,14 +86,10 @@ function WorkspacePanelContent({
[workspace]
);
const [featureSelection, setFeatureSelection] = useState<FeatureType[]>(
normalizedWorkspace.features
);
const [flags, setFlags] = useState(normalizedWorkspace.flags);
const [baseline, setBaseline] = useState(normalizedWorkspace);
useEffect(() => {
setFeatureSelection(normalizedWorkspace.features);
setFlags(normalizedWorkspace.flags);
setBaseline(normalizedWorkspace);
}, [normalizedWorkspace]);
@@ -110,20 +101,14 @@ function WorkspacePanelContent({
flags.enableSharing !== baseline.flags.enableSharing ||
flags.enableUrlPreview !== baseline.flags.enableUrlPreview ||
flags.enableDocEmbedding !== baseline.flags.enableDocEmbedding ||
flags.name !== baseline.flags.name ||
featureSelection.length !== baseline.features.length ||
featureSelection.some(f => !baseline.features.includes(f))
flags.name !== baseline.flags.name
);
}, [baseline, featureSelection, flags]);
}, [baseline, flags]);
useEffect(() => {
setHasDirtyChanges(hasChanges);
}, [hasChanges, setHasDirtyChanges]);
const handleFeaturesChange = useCallback((features: FeatureType[]) => {
setFeatureSelection(features);
}, []);
const handleSave = useCallback(() => {
const update = async () => {
try {
@@ -136,7 +121,6 @@ function WorkspacePanelContent({
enableUrlPreview: flags.enableUrlPreview,
enableDocEmbedding: flags.enableDocEmbedding,
name: flags.name || null,
features: featureSelection,
},
});
await Promise.all([
@@ -146,7 +130,6 @@ function WorkspacePanelContent({
toast.success('Workspace updated successfully');
setBaseline({
flags: { ...flags },
features: [...featureSelection],
});
setHasDirtyChanges(false);
onClose();
@@ -156,7 +139,6 @@ function WorkspacePanelContent({
};
update().catch(() => {});
}, [
featureSelection,
flags,
onClose,
revalidate,
@@ -239,18 +221,6 @@ function WorkspacePanelContent({
/>
</div>
<div className="space-y-3 rounded-xl border border-border/60 bg-card p-3 shadow-sm">
<div className="text-sm font-medium">Features</div>
<FeatureToggleList
features={serverConfig.availableWorkspaceFeatures ?? []}
selected={featureSelection}
onChange={handleFeaturesChange}
className="grid grid-cols-1 gap-2"
control="checkbox"
controlPosition="left"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<MetricCard
label="Snapshot Size"