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
@@ -0,0 +1,126 @@
import type { GetCurrentUserProfileQuery } from '@affine/graphql';
import type { UserQuotaStateSnapshot } from '@affine/realtime';
import { Framework } from '@toeverything/infra';
import { Subject } from 'rxjs';
import { describe, expect, test, vi } from 'vitest';
import { AuthService } from '../services/auth';
import { UserQuotaStore } from '../stores/user-quota';
import { UserQuota } from './user-quota';
type Quota = NonNullable<GetCurrentUserProfileQuery['currentUser']>['quota'];
const authService = {
session: {
['account$']: {
value: { id: 'user-1' },
},
},
} as unknown as AuthService;
function createQuotaState(
overrides: Partial<UserQuotaStateSnapshot> = {}
): UserQuotaStateSnapshot {
return {
userId: 'user-1',
plan: 'pro',
sourceEntitlementId: 'entitlement-1',
blobLimit: 1024,
storageQuota: 2048,
usedStorageQuota: 512,
historyPeriodSeconds: 30 * 24 * 60 * 60,
copilotActionLimit: null,
flags: {},
known: true,
stale: false,
lastReconciledAt: null,
staleAfter: null,
createdAt: new Date(0),
updatedAt: new Date(0),
...overrides,
};
}
function createQuota(overrides: Partial<Quota> = {}): Quota {
return {
name: 'Legacy',
blobLimit: 1024,
storageQuota: 2048,
historyPeriod: 30 * 24 * 60 * 60,
memberLimit: 8,
humanReadable: {
name: 'Legacy',
blobLimit: '1 KB',
storageQuota: '2 KB',
historyPeriod: '30 days',
memberLimit: '8',
},
...overrides,
};
}
function createStore(
overrides: Partial<UserQuotaStore>,
eventSubject = new Subject<{ type: 'ready' } | { changed: true }>()
) {
return {
fetchUserQuotaState: vi.fn(),
subscribeUserQuotaState: vi.fn(() => eventSubject),
fetchUserQuota: vi.fn(),
...overrides,
} as unknown as UserQuotaStore;
}
function createEntity(store: UserQuotaStore) {
const framework = new Framework();
framework
.service(AuthService, authService)
.store(UserQuotaStore, store)
.entity(UserQuota, [AuthService, UserQuotaStore]);
return framework.provider().createEntity(UserQuota);
}
describe('UserQuota', () => {
test('uses realtime quota state snapshots and refreshes on quota events', async () => {
const events$ = new Subject<{ type: 'ready' } | { changed: true }>();
const store = createStore(
{
fetchUserQuotaState: vi
.fn()
.mockResolvedValueOnce(createQuotaState({ usedStorageQuota: 512 }))
.mockResolvedValueOnce(createQuotaState({ usedStorageQuota: 768 })),
},
events$
);
const quota = createEntity(store);
quota.revalidate();
await vi.waitFor(() => expect(quota.used$.value).toBe(512));
expect(quota.quota$.value?.humanReadable.historyPeriod).toBe('30 days');
events$.next({ changed: true });
await vi.waitFor(() => expect(quota.used$.value).toBe(768));
expect(store.fetchUserQuotaState).toHaveBeenCalledTimes(2);
expect(store.fetchUserQuota).not.toHaveBeenCalled();
quota.dispose();
});
test('falls back to legacy GraphQL quota when realtime request fails', async () => {
const store = createStore({
fetchUserQuotaState: vi.fn().mockRejectedValue(new Error('offline')),
fetchUserQuota: vi.fn().mockResolvedValue({
quota: createQuota(),
used: 256,
}),
});
const quota = createEntity(store);
quota.revalidate();
await vi.waitFor(() => expect(quota.quota$.value?.name).toBe('Legacy'));
expect(quota.used$.value).toBe(256);
quota.dispose();
});
});
@@ -1,26 +1,74 @@
import type { QuotaQuery } from '@affine/graphql';
import {
catchErrorInto,
effect,
Entity,
exhaustMapSwitchUntilChanged,
fromPromise,
LiveData,
onComplete,
onStart,
smartRetry,
} from '@toeverything/infra';
import type { GetCurrentUserProfileQuery } from '@affine/graphql';
import type {
RealtimeTopicEventOf,
UserQuotaStateSnapshot,
} from '@affine/realtime';
import { Entity, LiveData } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import bytes from 'bytes';
import { map, tap } from 'rxjs';
import { RealtimeLiveQuery } from '../realtime/live-query';
import type { AuthService } from '../services/auth';
import type { UserQuotaStore } from '../stores/user-quota';
type QuotaType = NonNullable<
GetCurrentUserProfileQuery['currentUser']
>['quota'];
const DAY_SECONDS = 24 * 60 * 60;
function formatSize(size: number) {
return size === 0 ? '0 B' : (bytes.format(size) ?? '0 B');
}
function formatHistoryPeriod(value: number) {
return `${(value / DAY_SECONDS).toFixed(0)} days`;
}
function userMemberLimit(plan: string) {
return plan === 'pro' || plan === 'lifetime_pro' || plan === 'selfhost_free'
? 10
: 3;
}
function planName(plan: string) {
switch (plan) {
case 'pro':
case 'selfhost_free':
return 'Pro';
case 'lifetime_pro':
return 'Lifetime Pro';
case 'ai':
return 'AI';
default:
return 'Free';
}
}
function userQuotaFromState(state: UserQuotaStateSnapshot): QuotaType {
const name = planName(state.plan);
const memberLimit = userMemberLimit(state.plan);
return {
name,
blobLimit: state.blobLimit,
storageQuota: state.storageQuota,
historyPeriod: state.historyPeriodSeconds,
memberLimit,
humanReadable: {
name,
blobLimit: formatSize(state.blobLimit),
storageQuota: formatSize(state.storageQuota),
historyPeriod: formatHistoryPeriod(state.historyPeriodSeconds),
memberLimit: memberLimit.toString(),
},
};
}
export class UserQuota extends Entity {
quota$ = new LiveData<NonNullable<QuotaQuery['currentUser']>['quota'] | null>(
null
);
quota$ = new LiveData<QuotaType | null>(null);
isRevalidating$ = new LiveData(false);
error$ = new LiveData<any | null>(null);
/** Used storage in bytes */
used$ = new LiveData<number | null>(null);
/** Formatted used storage */
@@ -53,8 +101,17 @@ export class UserQuota extends Entity {
: null
);
isRevalidating$ = new LiveData(false);
error$ = new LiveData<any | null>(null);
private started = false;
private readonly liveQuery = new RealtimeLiveQuery<
{ quota: QuotaType; used: number },
RealtimeTopicEventOf<'user.quota-state.changed'>
>({
request: signal => this.requestQuota(signal),
subscribe: () => this.store.subscribeUserQuotaState(),
applySnapshot: data => this.applyQuota(data.quota, data.used),
applyEvent: () => 'revalidate',
onError: error => this.error$.next(error),
});
constructor(
private readonly authService: AuthService,
@@ -63,42 +120,19 @@ export class UserQuota extends Entity {
super();
}
revalidate = effect(
map(() => ({
accountId: this.authService.session.account$.value?.id,
})),
exhaustMapSwitchUntilChanged(
(a, b) => a.accountId === b.accountId,
({ accountId }) =>
fromPromise(async signal => {
if (!accountId) {
return; // no quota if no user
}
const { quota, used } = await this.store.fetchUserQuota(signal);
return { quota, used };
}).pipe(
smartRetry(),
tap(data => {
if (data) {
const { quota, used } = data;
this.quota$.next(quota);
this.used$.next(used);
} else {
this.quota$.next(null);
this.used$.next(null);
}
}),
catchErrorInto(this.error$),
onStart(() => this.isRevalidating$.next(true)),
onComplete(() => this.isRevalidating$.next(false))
),
() => {
// Reset the state when the user is changed
this.reset();
}
)
);
revalidate = () => {
if (!this.authService.session.account$.value?.id) {
this.liveQuery.stop();
this.started = false;
this.reset();
return;
}
if (!this.started) {
this.started = true;
this.liveQuery.start();
}
this.liveQuery.revalidate();
};
reset() {
this.quota$.next(null);
@@ -108,6 +142,28 @@ export class UserQuota extends Entity {
}
override dispose(): void {
this.revalidate.unsubscribe();
this.liveQuery.dispose();
}
private applyQuota(quota: QuotaType | null, used: number | null) {
this.error$.next(null);
this.quota$.next(quota);
this.used$.next(used);
}
private async requestQuota(signal: AbortSignal) {
this.isRevalidating$.setValue(true);
try {
const state = await this.store.fetchUserQuotaState(signal);
return {
quota: userQuotaFromState(state),
used: state.usedStorageQuota,
};
} catch {
const { quota, used } = await this.store.fetchUserQuota(signal);
return { quota, used };
} finally {
this.isRevalidating$.setValue(false);
}
}
}
@@ -165,7 +165,7 @@ export function configureCloudModule(framework: Framework) {
.entity(Subscription, [AuthService, ServerService, SubscriptionStore])
.entity(SubscriptionPrices, [ServerService, SubscriptionStore])
.service(UserQuotaService)
.store(UserQuotaStore, [GraphQLService])
.store(UserQuotaStore, [GraphQLService, NbstoreService])
.entity(UserQuota, [AuthService, UserQuotaStore])
.service(UserCopilotQuotaService)
.store(UserCopilotQuotaStore, [GraphQLService])
@@ -1,13 +1,33 @@
import { getCurrentUserProfileQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { NbstoreService } from '../../storage';
import type { GraphQLService } from '../services/graphql';
export class UserQuotaStore extends Store {
constructor(private readonly graphqlService: GraphQLService) {
constructor(
private readonly graphqlService: GraphQLService,
private readonly nbstoreService: NbstoreService
) {
super();
}
async fetchUserQuotaState(abortSignal?: AbortSignal) {
const response = await this.nbstoreService.realtime.request(
'user.quota-state.get',
{},
{ signal: abortSignal, timeoutMs: 10000 }
);
return response.state;
}
subscribeUserQuotaState() {
return this.nbstoreService.realtime.subscribe(
'user.quota-state.changed',
{}
);
}
async fetchUserQuota(abortSignal?: AbortSignal) {
const data = await this.graphqlService.gql({
query: getCurrentUserProfileQuery,
@@ -1,4 +1,3 @@
import { Permission } from '@affine/graphql';
import {
backoffRetry,
effect,
@@ -46,9 +45,11 @@ export class WorkspacePermission extends Entity {
signal
);
const isOwner = info.workspace.permissions.Workspace_Delete;
return {
isOwner: info.workspace.role === Permission.Owner,
isAdmin: info.workspace.role === Permission.Admin,
isOwner,
isAdmin:
!isOwner && info.workspace.permissions.Workspace_Settings_Update,
isTeam: info.workspace.team,
};
} else {
@@ -0,0 +1,130 @@
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { WorkspaceQuotaQuery } from '@affine/graphql';
import type { WorkspaceQuotaStateSnapshot } from '@affine/realtime';
import { Framework } from '@toeverything/infra';
import { Subject } from 'rxjs';
import { describe, expect, test, vi } from 'vitest';
import { WorkspaceQuotaStore } from '../stores/quota';
import { WorkspaceQuota } from './quota';
type Quota = WorkspaceQuotaQuery['workspace']['quota'];
const workspaceService = {
workspace: { id: 'workspace-1' },
} as unknown as WorkspaceService;
function createQuotaState(
overrides: Partial<WorkspaceQuotaStateSnapshot> = {}
): WorkspaceQuotaStateSnapshot {
return {
workspaceId: 'workspace-1',
plan: 'Team',
sourceEntitlementId: 'entitlement-1',
ownerUserId: 'user-1',
usesOwnerQuota: false,
seatLimit: 10,
memberCount: 3,
overcapacityMemberCount: 0,
blobLimit: 1024,
storageQuota: 2048,
usedStorageQuota: 512,
historyPeriodSeconds: 30 * 24 * 60 * 60,
readonly: false,
readonlyReasons: [],
flags: {},
known: true,
stale: false,
lastReconciledAt: null,
staleAfter: null,
createdAt: new Date(0),
updatedAt: new Date(0),
...overrides,
};
}
function createQuota(overrides: Partial<Quota> = {}): Quota {
return {
name: 'Legacy',
blobLimit: 1024,
storageQuota: 2048,
usedStorageQuota: 256,
historyPeriod: 30 * 24 * 60 * 60 * 1000,
memberLimit: 8,
memberCount: 2,
overcapacityMemberCount: 0,
humanReadable: {
name: 'Legacy',
blobLimit: '1 KB',
storageQuota: '2 KB',
historyPeriod: '30 days',
memberLimit: '8',
memberCount: '2',
overcapacityMemberCount: '0',
},
...overrides,
};
}
function createStore(
overrides: Partial<WorkspaceQuotaStore>,
eventSubject = new Subject<{ type: 'ready' } | { changed: true }>()
) {
return {
fetchWorkspaceQuotaState: vi.fn(),
subscribeWorkspaceQuotaState: vi.fn(() => eventSubject),
fetchWorkspaceQuota: vi.fn(),
...overrides,
} as unknown as WorkspaceQuotaStore;
}
function createEntity(store: WorkspaceQuotaStore) {
const framework = new Framework();
framework
.service(WorkspaceService, workspaceService)
.store(WorkspaceQuotaStore, store)
.entity(WorkspaceQuota, [WorkspaceService, WorkspaceQuotaStore]);
return framework.provider().createEntity(WorkspaceQuota);
}
describe('WorkspaceQuota', () => {
test('uses realtime quota state snapshots and refreshes on quota events', async () => {
const events$ = new Subject<{ type: 'ready' } | { changed: true }>();
const store = createStore(
{
fetchWorkspaceQuotaState: vi
.fn()
.mockResolvedValueOnce(createQuotaState({ memberCount: 3 }))
.mockResolvedValueOnce(createQuotaState({ memberCount: 4 })),
},
events$
);
const quota = createEntity(store);
quota.revalidate();
await vi.waitFor(() => expect(quota.quota$.value?.memberCount).toBe(3));
expect(quota.quota$.value?.humanReadable.historyPeriod).toBe('30 days');
events$.next({ changed: true });
await vi.waitFor(() => expect(quota.quota$.value?.memberCount).toBe(4));
expect(store.fetchWorkspaceQuotaState).toHaveBeenCalledTimes(2);
expect(store.fetchWorkspaceQuota).not.toHaveBeenCalled();
quota.dispose();
});
test('falls back to legacy GraphQL quota when realtime request fails', async () => {
const store = createStore({
fetchWorkspaceQuotaState: vi.fn().mockRejectedValue(new Error('offline')),
fetchWorkspaceQuota: vi.fn().mockResolvedValue(createQuota()),
});
const quota = createEntity(store);
quota.revalidate();
await vi.waitFor(() => expect(quota.quota$.value?.name).toBe('Legacy'));
expect(quota.error$.value).toBeNull();
quota.dispose();
});
});
@@ -1,31 +1,90 @@
import { DebugLogger } from '@affine/debug';
import type { WorkspaceQuotaQuery } from '@affine/graphql';
import {
catchErrorInto,
effect,
Entity,
exhaustMapWithTrailing,
fromPromise,
LiveData,
onComplete,
onStart,
smartRetry,
} from '@toeverything/infra';
import type {
RealtimeTopicEventOf,
WorkspaceQuotaStateSnapshot,
} from '@affine/realtime';
import { Entity, LiveData } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import bytes from 'bytes';
import { tap } from 'rxjs';
import { RealtimeLiveQuery } from '../../cloud/realtime/live-query';
import type { WorkspaceService } from '../../workspace';
import type { WorkspaceQuotaStore } from '../stores/quota';
type QuotaType = WorkspaceQuotaQuery['workspace']['quota'];
const logger = new DebugLogger('affine:workspace-permission');
const DAY_SECONDS = 24 * 60 * 60;
function formatSize(size: number) {
return size === 0 ? '0 B' : (bytes.format(size) ?? '0 B');
}
function formatHistoryPeriod(value: number) {
return `${(value / DAY_SECONDS).toFixed(0)} days`;
}
function planName(plan: string) {
switch (plan) {
case 'pro':
case 'selfhost_free':
return 'Pro';
case 'lifetime_pro':
return 'Lifetime Pro';
case 'ai':
return 'AI';
case 'team':
case 'selfhost_team':
return 'Team';
default:
return 'Free';
}
}
function workspaceQuotaFromState(
state: WorkspaceQuotaStateSnapshot
): QuotaType {
const name = planName(state.plan);
return {
name,
blobLimit: state.blobLimit,
storageQuota: state.storageQuota,
usedStorageQuota: state.usedStorageQuota,
historyPeriod: state.historyPeriodSeconds,
memberLimit: state.seatLimit,
memberCount: state.memberCount,
overcapacityMemberCount: state.overcapacityMemberCount,
humanReadable: {
name,
blobLimit: formatSize(state.blobLimit),
storageQuota: formatSize(state.storageQuota),
historyPeriod: formatHistoryPeriod(state.historyPeriodSeconds),
memberLimit: state.seatLimit.toString(),
memberCount: state.memberCount.toString(),
overcapacityMemberCount: state.overcapacityMemberCount.toString(),
},
};
}
export class WorkspaceQuota extends Entity {
quota$ = new LiveData<QuotaType | null>(null);
isRevalidating$ = new LiveData(false);
error$ = new LiveData<any>(null);
private started = false;
private readonly liveQuery = new RealtimeLiveQuery<
QuotaType,
RealtimeTopicEventOf<'workspace.quota-state.changed'>
>({
request: signal => this.requestQuota(signal),
subscribe: () =>
this.store.subscribeWorkspaceQuotaState(
this.workspaceService.workspace.id
),
applySnapshot: quota => this.applyQuota(quota),
applyEvent: () => 'revalidate',
onError: error => this.handleError(error),
});
/** Used storage in bytes */
used$ = new LiveData<number | null>(null);
@@ -66,34 +125,13 @@ export class WorkspaceQuota extends Entity {
super();
}
revalidate = effect(
exhaustMapWithTrailing(() => {
return fromPromise(async signal => {
const data = await this.store.fetchWorkspaceQuota(
this.workspaceService.workspace.id,
signal
);
return { quota: data, used: data.usedStorageQuota };
}).pipe(
smartRetry(),
tap(data => {
if (data) {
const { quota, used } = data;
this.quota$.next(quota);
this.used$.next(used);
} else {
this.quota$.next(null);
this.used$.next(null);
}
}),
catchErrorInto(this.error$, error => {
logger.error('Failed to fetch workspace quota', error);
}),
onStart(() => this.isRevalidating$.setValue(true)),
onComplete(() => this.isRevalidating$.setValue(false))
);
})
);
revalidate = () => {
if (!this.started) {
this.started = true;
this.liveQuery.start();
}
this.liveQuery.revalidate();
};
waitForRevalidation(signal?: AbortSignal) {
this.revalidate();
@@ -111,6 +149,36 @@ export class WorkspaceQuota extends Entity {
}
override dispose(): void {
this.revalidate.unsubscribe();
this.liveQuery.dispose();
}
private applyQuota(quota: QuotaType | null) {
this.error$.next(null);
this.quota$.next(quota);
this.used$.next(quota?.usedStorageQuota ?? null);
}
private handleError(error: unknown) {
logger.error('Failed to fetch workspace quota', error);
this.error$.next(error);
}
private async requestQuota(signal: AbortSignal) {
this.isRevalidating$.setValue(true);
try {
return workspaceQuotaFromState(
await this.store.fetchWorkspaceQuotaState(
this.workspaceService.workspace.id,
signal
)
);
} catch {
return await this.store.fetchWorkspaceQuota(
this.workspaceService.workspace.id,
signal
);
} finally {
this.isRevalidating$.setValue(false);
}
}
}
@@ -4,6 +4,7 @@ export { QuotaCheck } from './views/quota-check';
import { type Framework } from '@toeverything/infra';
import { WorkspaceServerService } from '../cloud';
import { NbstoreService } from '../storage';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { WorkspaceQuota } from './entities/quota';
import { WorkspaceQuotaService } from './services/quota';
@@ -13,6 +14,6 @@ export function configureQuotaModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(WorkspaceQuotaService)
.store(WorkspaceQuotaStore, [WorkspaceServerService])
.store(WorkspaceQuotaStore, [WorkspaceServerService, NbstoreService])
.entity(WorkspaceQuota, [WorkspaceService, WorkspaceQuotaStore]);
}
@@ -2,11 +2,32 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud';
import { workspaceQuotaQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { NbstoreService } from '../../storage';
export class WorkspaceQuotaStore extends Store {
constructor(private readonly workspaceServerService: WorkspaceServerService) {
constructor(
private readonly workspaceServerService: WorkspaceServerService,
private readonly nbstoreService: NbstoreService
) {
super();
}
async fetchWorkspaceQuotaState(workspaceId: string, signal?: AbortSignal) {
const response = await this.nbstoreService.realtime.request(
'workspace.quota-state.get',
{ workspaceId },
{ signal, timeoutMs: 10000 }
);
return response.state;
}
subscribeWorkspaceQuotaState(workspaceId: string) {
return this.nbstoreService.realtime.subscribe(
'workspace.quota-state.changed',
{ workspaceId }
);
}
async fetchWorkspaceQuota(workspaceId: string, signal?: AbortSignal) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
@@ -5,7 +5,6 @@ import {
deleteWorkspaceMutation,
getWorkspaceInfoQuery,
getWorkspacesQuery,
Permission,
ServerDeploymentType,
ServerFeature,
} from '@affine/graphql';
@@ -355,13 +354,12 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
const info = await this.getWorkspaceInfo(id, signal);
const isOwner = info.workspace.permissions.Workspace_Delete;
const isAdmin =
!isOwner && info.workspace.permissions.Workspace_Settings_Update;
if (!cloudData && !localData) {
return {
isOwner: info.workspace.role === Permission.Owner,
isAdmin: info.workspace.role === Permission.Admin,
isTeam: info.workspace.team,
isEmpty,
};
return { isOwner, isAdmin, isTeam: info.workspace.team, isEmpty };
}
const client = getWorkspaceProfileWorker();
@@ -374,8 +372,8 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
return {
name: result.name,
avatar: result.avatar,
isOwner: info.workspace.role === Permission.Owner,
isAdmin: info.workspace.role === Permission.Admin,
isOwner,
isAdmin,
isTeam: info.workspace.team,
isEmpty,
};