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
@@ -11,7 +11,7 @@ import {
import { ActionForbidden, AuthenticationRequired, Config } from '../../base';
import { CurrentUser } from '../../core/auth';
import { ServerConfigType } from '../../core/config/types';
import { AccessController } from '../../core/permission';
import { PermissionAccess } from '../../core/permission';
import { UserType } from '../../core/user';
import { WorkspaceType } from '../../core/workspaces';
import { Models } from '../../models';
@@ -113,7 +113,7 @@ export class CalendarAccountResolver {
export class WorkspaceCalendarResolver {
constructor(
private readonly calendar: CalendarService,
private readonly access: AccessController
private readonly access: PermissionAccess
) {}
@ResolveField(() => [WorkspaceCalendarObjectType])
@@ -133,7 +133,7 @@ export class WorkspaceCalendarResolver {
export class WorkspaceCalendarEventsResolver {
constructor(
private readonly calendar: CalendarService,
private readonly access: AccessController
private readonly access: PermissionAccess
) {}
@ResolveField(() => [CalendarEventObjectType])
@@ -162,7 +162,7 @@ export class CalendarMutationResolver {
private readonly calendar: CalendarService,
private readonly oauth: CalendarOAuthService,
private readonly models: Models,
private readonly access: AccessController
private readonly access: PermissionAccess
) {}
@Mutation(() => String)
@@ -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
) {}
@@ -2313,6 +2313,67 @@ test('should search docs by keyword work', async t => {
);
});
test('should search docs by keyword with doc id filter', async t => {
const workspaceId = workspace.id;
const docId1 = randomUUID();
const docId2 = randomUUID();
await module.create(Mockers.DocMeta, {
workspaceId,
docId: docId1,
title: 'hello filtered 1',
});
await module.create(Mockers.DocMeta, {
workspaceId,
docId: docId2,
title: 'hello filtered 2',
});
await indexerService.write(
SearchTable.block,
[
{
workspaceId,
docId: docId1,
blockId: 'filtered-block1',
content: 'hello filtered',
flavour: 'affine:text',
createdByUserId: user.id,
updatedByUserId: user.id,
createdAt: new Date('2025-06-20T00:00:00.000Z'),
updatedAt: new Date('2025-06-20T00:00:00.000Z'),
},
{
workspaceId,
docId: docId2,
blockId: 'filtered-block2',
content: 'hello filtered',
flavour: 'affine:text',
createdByUserId: user.id,
updatedByUserId: user.id,
createdAt: new Date('2025-06-20T00:00:01.000Z'),
updatedAt: new Date('2025-06-20T00:00:01.000Z'),
},
],
{
refresh: true,
}
);
const rows = await indexerService.searchDocsByKeyword(
workspaceId,
'hello filtered',
{
docIds: [docId2],
}
);
t.deepEqual(
rows.map(row => row.docId),
[docId2]
);
});
// #endregion
test('should rebuild manticore indexes and requeue workspaces', async t => {
@@ -4,6 +4,7 @@ import { Module } from '@nestjs/common';
import { ServerConfigModule } from '../../core/config';
import { PermissionModule } from '../../core/permission';
import { QuotaServiceModule } from '../../core/quota';
import { IndexerEvent } from './event';
import { SearchProviderFactory } from './factory';
import { IndexerJob } from './job';
@@ -12,7 +13,7 @@ import { IndexerResolver } from './resolver';
import { IndexerService } from './service';
@Module({
imports: [ServerConfigModule, PermissionModule],
imports: [ServerConfigModule, PermissionModule, QuotaServiceModule],
providers: [
IndexerResolver,
IndexerService,
@@ -1,18 +1,19 @@
import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Prisma, PrismaClient } from '@prisma/client';
import { CurrentUser } from '../../core/auth';
import { AccessController } from '../../core/permission';
import { PermissionAccess, PermissionService } from '../../core/permission';
import { QuotaStateService } from '../../core/quota/state';
import { UserType } from '../../core/user';
import { WorkspaceType } from '../../core/workspaces';
import { Models } from '../../models';
import { AggregateBucket } from './providers';
import { IndexerService, SearchNodeWithMeta } from './service';
import { IndexerService } from './service';
import {
AggregateInput,
AggregateResultObjectType,
SearchDocObjectType,
SearchDocsInput,
SearchInput,
SearchQuery,
SearchQueryOccur,
SearchQueryType,
SearchResultObjectType,
@@ -22,8 +23,10 @@ import {
export class IndexerResolver {
constructor(
private readonly indexer: IndexerService,
private readonly ac: AccessController,
private readonly models: Models
private readonly ac: PermissionAccess,
private readonly db: PrismaClient,
private readonly permission: PermissionService,
private readonly quotaState: QuotaStateService
) {}
@ResolveField(() => SearchResultObjectType, {
@@ -37,18 +40,22 @@ export class IndexerResolver {
// currentUser can read the workspace
await this.ac.user(me.id).workspace(workspace.id).assert('Workspace.Read');
this.#addWorkspaceFilter(workspace, input);
if (!(await this.#addReadableDocFilter(workspace, me, input))) {
return {
nodes: [],
pagination: {
count: 0,
hasMore: false,
},
};
}
const result = await this.indexer.search(input);
const nodes = await this.#filterUserReadableDocs(
workspace,
me,
result.nodes
);
return {
nodes,
nodes: result.nodes,
pagination: {
count: result.total,
hasMore: nodes.length > 0,
hasMore: result.nodes.length > 0,
nextCursor: result.nextCursor,
},
};
@@ -65,24 +72,22 @@ export class IndexerResolver {
// currentUser can read the workspace
await this.ac.user(me.id).workspace(workspace.id).assert('Workspace.Read');
this.#addWorkspaceFilter(workspace, input);
if (!(await this.#addReadableDocFilter(workspace, me, input))) {
return {
buckets: [],
pagination: {
count: 0,
hasMore: false,
},
};
}
const result = await this.indexer.aggregate(input);
const needs: AggregateBucket[] = [];
for (const bucket of result.buckets) {
bucket.hits.nodes = await this.#filterUserReadableDocs(
workspace,
me,
bucket.hits.nodes as SearchNodeWithMeta[]
);
if (bucket.hits.nodes.length > 0) {
needs.push(bucket);
}
}
return {
buckets: needs,
buckets: result.buckets,
pagination: {
count: result.total,
hasMore: needs.length > 0,
hasMore: result.buckets.length > 0,
nextCursor: result.nextCursor,
},
};
@@ -96,19 +101,17 @@ export class IndexerResolver {
@Parent() workspace: WorkspaceType,
@Args('input') input: SearchDocsInput
): Promise<SearchDocObjectType[]> {
const readableDocIds = await this.#readableDocIdsForSearch(workspace, me);
const docs = await this.indexer.searchDocsByKeyword(
workspace.id,
input.keyword,
{
limit: input.limit,
docIds: readableDocIds ?? undefined,
}
);
const needs = await this.ac
.user(me.id)
.workspace(workspace.id)
.docs(docs, 'Doc.Read');
return needs;
return docs;
}
#addWorkspaceFilter(
@@ -130,36 +133,84 @@ export class IndexerResolver {
};
}
/**
* filter user readable docs on team workspace
*/
async #filterUserReadableDocs(
async #addReadableDocFilter(
workspace: WorkspaceType,
user: UserType,
nodes: SearchNodeWithMeta[]
input: SearchInput | AggregateInput
) {
if (nodes.length === 0) {
return nodes;
const docIds = await this.#readableDocIdsForSearch(workspace, user);
if (docIds === null) {
return true;
}
const isTeamWorkspace = await this.models.workspaceFeature.has(
workspace.id,
'team_plan_v1'
if (docIds.length === 0) {
return false;
}
input.query = {
type: SearchQueryType.boolean,
occur: SearchQueryOccur.must,
queries: [input.query, this.#docIdFilterQuery(docIds)],
};
return true;
}
async #readableDocIdsForSearch(workspace: WorkspaceType, user: UserType) {
const state = await this.quotaState.reconcileWorkspaceQuotaState(
workspace.id
);
const isTeamWorkspace =
state.plan === 'team' || state.plan === 'selfhost_team';
if (!isTeamWorkspace) {
return nodes;
return null;
}
const needs = await this.ac
.user(user.id)
.workspace(workspace.id)
.docs(
nodes.map(node => ({
node,
docId: node._source.docId,
})),
'Doc.Read'
);
return needs.map(node => node.node);
return await this.#listReadableDocIds(workspace, user);
}
#docIdFilterQuery(docIds: string[]): SearchQuery {
return {
type: SearchQueryType.boolean,
occur: SearchQueryOccur.should,
queries: docIds.map(docId => ({
type: SearchQueryType.match,
field: 'docId',
match: docId,
})),
};
}
async #listReadableDocIds(workspace: WorkspaceType, user: UserType) {
const input = {
userId: user.id,
workspaceId: workspace.id,
action: 'Doc.Read',
docIdColumn: Prisma.raw('candidate_docs.doc_id'),
} as const;
const predicate = this.permission.docReadableSqlPredicate(input);
const fallbackPredicate =
this.permission.fallbackDocReadableSqlPredicate(input);
const query = (predicate: Prisma.Sql) =>
this.db.$queryRaw<{ docId: string }[]>`
WITH candidate_docs AS (
SELECT "workspace_pages"."page_id" AS doc_id
FROM "workspace_pages"
WHERE "workspace_pages"."workspace_id" = ${workspace.id}
UNION
SELECT "snapshots"."guid" AS doc_id
FROM "snapshots"
WHERE "snapshots"."workspace_id" = ${workspace.id}
)
SELECT candidate_docs.doc_id AS "docId"
FROM candidate_docs
WHERE ${predicate}
`;
const rows = await query(predicate).catch(error => {
if (!fallbackPredicate) {
throw error;
}
return query(fallbackPredicate);
});
return rows.map(row => row.docId);
}
}
@@ -497,8 +497,13 @@ export class IndexerService {
keyword: string,
options?: {
limit?: number;
docIds?: string[];
}
): Promise<SearchDoc[]> {
if (options?.docIds?.length === 0) {
return [];
}
const limit = options?.limit ?? 20;
const result = await this.aggregate({
table: SearchTable.block,
@@ -512,6 +517,19 @@ export class IndexerService {
field: 'workspaceId',
match: workspaceId,
},
...(options?.docIds
? [
{
type: SearchQueryType.boolean,
occur: SearchQueryOccur.should,
queries: options.docIds.map(docId => ({
type: SearchQueryType.match,
field: 'docId',
match: docId,
})),
},
]
: []),
{
type: SearchQueryType.boolean,
occur: SearchQueryOccur.must,
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { EntitlementModule } from '../../core/entitlement';
import { FeatureModule } from '../../core/features';
import { PermissionModule } from '../../core/permission';
import { QuotaModule } from '../../core/quota';
@@ -8,7 +9,13 @@ import { AdminLicenseResolver, LicenseResolver } from './resolver';
import { LicenseService } from './service';
@Module({
imports: [FeatureModule, QuotaModule, PermissionModule, WorkspaceModule],
imports: [
FeatureModule,
QuotaModule,
PermissionModule,
WorkspaceModule,
EntitlementModule,
],
providers: [LicenseService, LicenseResolver, AdminLicenseResolver],
})
export class LicenseModule {}
@@ -15,7 +15,7 @@ import GraphQLUpload, {
import { toBuffer, UseNamedGuard } from '../../base';
import { CurrentUser } from '../../core/auth';
import { Admin } from '../../core/common';
import { AccessController } from '../../core/permission';
import { PermissionAccess } from '../../core/permission';
import { WorkspaceType } from '../../core/workspaces';
import {
SubscriptionPlan,
@@ -86,7 +86,7 @@ export class AdminLicensePreview {
export class LicenseResolver {
constructor(
private readonly service: LicenseService,
private readonly ac: AccessController
private readonly ac: PermissionAccess
) {}
@ResolveField(() => License, {
@@ -1,9 +1,6 @@
import { createDecipheriv, createVerify } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InstalledLicense, PrismaClient } from '@prisma/client';
import { z } from 'zod';
import {
CryptoHelper,
@@ -16,8 +13,11 @@ import {
UserFriendlyError,
WorkspaceLicenseAlreadyExists,
} from '../../base';
import { EntitlementService } from '../../core/entitlement';
import { WorkspacePolicyService } from '../../core/permission';
import { QuotaStateService } from '../../core/quota/state';
import { Models } from '../../models';
import { ResolvedEntitlement, resolveEntitlementV1 } from '../../native';
import {
SubscriptionPlan,
SubscriptionRecurring,
@@ -31,6 +31,8 @@ interface License {
endAt: number;
}
const AFFINE_PRO_REQUEST_TIMEOUT = 10_000;
export interface LicensePreview {
id: string;
workspaceId: string;
@@ -45,27 +47,6 @@ export interface LicensePreview {
valid: boolean;
}
const BaseLicenseSchema = z.object({
entity: z.string().nonempty(),
issuer: z.string().nonempty(),
issuedAt: z.string().datetime(),
expiresAt: z.string().datetime(),
});
const TeamLicenseSchema = z
.object({
subject: z.literal(SubscriptionPlan.SelfHostedTeam),
data: z.object({
id: z.string().nonempty(),
workspaceId: z.string().nonempty(),
plan: z.literal(SubscriptionPlan.SelfHostedTeam),
recurring: z.nativeEnum(SubscriptionRecurring),
quantity: z.number().positive(),
endAt: z.string().datetime(),
}),
})
.extend(BaseLicenseSchema.shape);
@Injectable()
export class LicenseService {
private readonly logger = new Logger(LicenseService.name);
@@ -75,37 +56,11 @@ export class LicenseService {
private readonly event: EventBus,
private readonly models: Models,
private readonly crypto: CryptoHelper,
private readonly policy: WorkspacePolicyService
private readonly policy: WorkspacePolicyService,
private readonly entitlement: EntitlementService,
private readonly quotaState: QuotaStateService
) {}
@OnEvent('workspace.subscription.activated')
async onWorkspaceSubscriptionUpdated({
workspaceId,
plan,
recurring,
quantity,
}: Events['workspace.subscription.activated']) {
switch (plan) {
case SubscriptionPlan.SelfHostedTeam:
await this.models.workspaceFeature.add(
workspaceId,
'team_plan_v1',
`${recurring} team subscription activated`,
{
memberLimit: quantity,
}
);
this.event.emit('workspace.members.allocateSeats', {
workspaceId,
quantity,
});
await this.policy.reconcileWorkspaceQuotaState(workspaceId);
break;
default:
break;
}
}
@OnEvent('workspace.subscription.canceled')
async onWorkspaceSubscriptionCanceled({
workspaceId,
@@ -137,74 +92,54 @@ export class LicenseService {
}
async installLicense(workspaceId: string, license: Buffer) {
const payload = this.decryptWorkspaceTeamLicense(workspaceId, license);
const data = payload.data;
const now = new Date();
if (new Date(payload.expiresAt) < now || new Date(data.endAt) < now) {
const resolved = this.resolveWorkspaceTeamLicense(workspaceId, license);
if (!resolved.valid) {
throw new LicenseExpired();
}
const installed = await this.db.installedLicense.upsert({
where: {
workspaceId,
},
update: {
key: data.id,
expiredAt: new Date(data.endAt),
validatedAt: new Date(),
recurring: data.recurring,
quantity: data.quantity,
variant: SubscriptionVariant.Onetime,
license,
},
create: {
key: data.id,
workspaceId,
expiredAt: new Date(data.endAt),
validateKey: '',
validatedAt: new Date(),
recurring: data.recurring,
quantity: data.quantity,
variant: SubscriptionVariant.Onetime,
license,
},
});
const validatedAt = new Date();
await this.event.emitAsync('workspace.subscription.activated', {
workspaceId,
plan: data.plan,
recurring: data.recurring,
quantity: data.quantity,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: this.licenseRecurring(resolved),
quantity: this.licenseQuantity(resolved),
});
await this.entitlement.upsertFromSelfhostLicense({
workspaceId,
recurring: this.licenseRecurring(resolved),
quantity: this.licenseQuantity(resolved),
expiresAt: this.licenseExpiresAt(resolved),
validatedAt,
variant: SubscriptionVariant.Onetime,
license,
});
return installed;
return this.db.installedLicense.findUniqueOrThrow({
where: { workspaceId },
});
}
previewLicense(license: Buffer): LicensePreview {
const payload = this.decryptWorkspaceTeamLicensePayload(license);
const data = payload.data;
const now = new Date();
const expiresAt = new Date(payload.expiresAt);
const endAt = new Date(data.endAt);
if (expiresAt < now || endAt < now) {
const resolved = this.resolveWorkspaceTeamLicense(null, license);
if (!resolved.valid) {
throw new InvalidLicenseToActivate({
reason: 'Invalid license.',
});
}
const expiresAt = this.licenseExpiresAt(resolved);
return {
id: data.id,
workspaceId: data.workspaceId,
plan: data.plan,
recurring: data.recurring,
quantity: data.quantity,
issuedAt: new Date(payload.issuedAt),
id: this.licenseSubjectId(resolved),
workspaceId: this.licenseWorkspaceId(resolved),
plan: SubscriptionPlan.SelfHostedTeam,
recurring: this.licenseRecurring(resolved),
quantity: this.licenseQuantity(resolved),
issuedAt: new Date(resolved.issuedAt ?? ''),
expiresAt,
endAt,
entity: payload.entity,
issuer: payload.issuer,
endAt: expiresAt,
entity: resolved.entity ?? '',
issuer: resolved.issuer ?? '',
valid: true,
};
}
@@ -215,6 +150,12 @@ export class LicenseService {
if (installedLicense) {
throw new WorkspaceLicenseAlreadyExists();
}
const occupiedLicense = await this.db.installedLicense.findUnique({
where: { key: licenseKey },
});
if (occupiedLicense) {
throw new WorkspaceLicenseAlreadyExists();
}
const data = await this.fetchAffinePro<License>(
`/api/team/licenses/${licenseKey}/activate`,
@@ -223,28 +164,9 @@ export class LicenseService {
}
);
const license = await this.db.installedLicense.upsert({
where: {
workspaceId,
},
update: {
key: licenseKey,
validatedAt: new Date(),
validateKey: data.res.headers.get('x-next-validate-key') ?? '',
expiredAt: new Date(data.endAt),
recurring: data.recurring,
quantity: data.quantity,
},
create: {
workspaceId,
key: licenseKey,
expiredAt: new Date(data.endAt),
validatedAt: new Date(),
validateKey: data.res.headers.get('x-next-validate-key') ?? '',
recurring: data.recurring,
quantity: data.quantity,
},
});
const validatedAt = new Date();
const expiresAt = this.remoteLicenseExpiresAt(data);
const validateKey = data.res.headers.get('x-next-validate-key') ?? '';
this.event.emit('workspace.subscription.activated', {
workspaceId,
@@ -252,7 +174,40 @@ export class LicenseService {
recurring: data.recurring,
quantity: data.quantity,
});
return license;
await this.entitlement.upsertFromValidatedSelfhostLicense({
workspaceId,
licenseKey,
recurring: data.recurring,
quantity: data.quantity,
expiresAt,
validatedAt,
validateKey,
});
return this.db.installedLicense.upsert({
where: { workspaceId },
update: {
key: licenseKey,
quantity: data.quantity,
recurring: data.recurring,
variant: null,
validateKey,
validatedAt,
expiredAt: expiresAt,
license: null,
},
create: {
workspaceId,
key: licenseKey,
quantity: data.quantity,
recurring: data.recurring,
variant: null,
validateKey,
validatedAt,
expiredAt: expiresAt,
license: null,
},
});
}
async removeTeamLicense(workspaceId: string) {
@@ -271,6 +226,7 @@ export class LicenseService {
workspaceId: license.workspaceId,
},
});
await this.entitlement.revokeBySubject('selfhost_license', license.key);
if (license.variant !== SubscriptionVariant.Onetime) {
await this.deactivateTeamLicense(license);
@@ -334,9 +290,11 @@ export class LicenseService {
}
if (license.variant === SubscriptionVariant.Onetime) {
const state =
await this.quotaState.reconcileWorkspaceQuotaState(workspaceId);
this.event.emit('workspace.members.allocateSeats', {
workspaceId,
quantity: license.quantity,
quantity: state.seatLimit ?? 0,
});
return;
@@ -389,7 +347,7 @@ export class LicenseService {
for (const license of licenses) {
if (license.variant === SubscriptionVariant.Onetime) {
this.revalidateOnetimeLicense(license);
await this.revalidateOnetimeLicense(license);
} else {
await this.revalidateRecurringLicense(license);
}
@@ -407,18 +365,9 @@ export class LicenseService {
}
);
await this.db.installedLicense.update({
where: {
key: license.key,
},
data: {
validatedAt: new Date(),
validateKey: res.res.headers.get('x-next-validate-key') ?? '',
quantity: res.quantity,
recurring: res.recurring,
expiredAt: new Date(res.endAt),
},
});
const validatedAt = new Date();
const expiresAt = this.remoteLicenseExpiresAt(res);
const validateKey = res.res.headers.get('x-next-validate-key') ?? '';
this.event.emit('workspace.subscription.activated', {
workspaceId: license.workspaceId,
@@ -426,6 +375,39 @@ export class LicenseService {
recurring: res.recurring,
quantity: res.quantity,
});
await this.db.installedLicense.update({
where: { key: license.key },
data: {
quantity: res.quantity,
recurring: res.recurring,
validateKey,
validatedAt,
expiredAt: expiresAt,
},
});
if (license.license) {
await this.entitlement.upsertFromSelfhostLicense({
workspaceId: license.workspaceId,
licenseKey: license.key,
recurring: res.recurring,
quantity: res.quantity,
expiresAt,
validatedAt,
validateKey,
license: Buffer.from(license.license),
});
} else {
await this.entitlement.upsertFromValidatedSelfhostLicense({
workspaceId: license.workspaceId,
licenseKey: license.key,
recurring: res.recurring,
quantity: res.quantity,
expiresAt,
validatedAt,
validateKey,
});
}
return res;
} catch (e) {
@@ -441,12 +423,21 @@ export class LicenseService {
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
});
await this.entitlement.revokeBySubject('selfhost_license', license.key);
}
return null;
}
}
private remoteLicenseExpiresAt(license: Pick<License, 'endAt'>) {
const expiresAt = new Date(license.endAt);
if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= new Date()) {
throw new LicenseExpired();
}
return expiresAt;
}
private async fetchAffinePro<T = any>(
path: string,
init?: RequestInit
@@ -455,8 +446,14 @@ export class LicenseService {
process.env.AFFINE_PRO_SERVER_ENDPOINT ?? 'https://app.affine.pro';
try {
const signal =
init?.signal ??
(AbortSignal.timeout
? AbortSignal.timeout(AFFINE_PRO_REQUEST_TIMEOUT)
: undefined);
const res = await fetch(endpoint + path, {
...init,
signal,
headers: {
'Content-Type': 'application/json',
...init?.headers,
@@ -486,25 +483,33 @@ export class LicenseService {
}
}
private revalidateOnetimeLicense(license: InstalledLicense) {
private async revalidateOnetimeLicense(license: InstalledLicense) {
const buf = license.license;
let valid = !!buf;
if (buf) {
try {
const { data } = this.decryptWorkspaceTeamLicense(
const resolved = this.resolveWorkspaceTeamLicense(
license.workspaceId,
Buffer.from(buf)
);
if (new Date(data.endAt) < new Date()) {
if (!resolved.valid) {
valid = false;
} else {
this.event.emit('workspace.subscription.activated', {
workspaceId: license.workspaceId,
plan: data.plan,
recurring: data.recurring,
quantity: data.quantity,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: this.licenseRecurring(resolved),
quantity: this.licenseQuantity(resolved),
});
await this.entitlement.upsertFromSelfhostLicense({
workspaceId: license.workspaceId,
recurring: this.licenseRecurring(resolved),
quantity: this.licenseQuantity(resolved),
expiresAt: this.licenseExpiresAt(resolved),
validatedAt: new Date(),
license: Buffer.from(buf),
});
}
} catch {
@@ -518,116 +523,101 @@ export class LicenseService {
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
});
await this.entitlement.revokeBySubject('selfhost_license', license.key);
}
}
private decryptWorkspaceTeamLicense(workspaceId: string, buf: Buffer) {
const payload = this.decryptWorkspaceTeamLicensePayload(buf);
if (new Date(payload.expiresAt) < new Date()) {
throw new InvalidLicenseToActivate({
reason:
'License file has expired. Please contact with Affine support to fetch a latest one.',
});
}
if (payload.data.workspaceId !== workspaceId) {
throw new InvalidLicenseToActivate({
reason: 'Workspace mismatched with license.',
});
}
return payload;
}
private decryptWorkspaceTeamLicensePayload(buf: Buffer) {
private resolveWorkspaceTeamLicense(workspaceId: string | null, buf: Buffer) {
if (!this.crypto.AFFiNEProPublicKey) {
throw new InternalServerError(
'License public key is not loaded. Please contact with Affine support.'
);
}
// we use workspace id as aes key hash plain text content
// verify signature to make sure the payload or signature is not forged
const { payload: payloadStr, signature, iv } = this.decryptLicense(buf);
const verifier = createVerify('rsa-sha256');
verifier.update(iv);
verifier.update(payloadStr);
const valid = verifier.verify(
this.crypto.AFFiNEProPublicKey,
signature,
'hex'
);
if (!valid) {
throw new InvalidLicenseToActivate({
reason: 'Invalid license signature.',
});
}
const payload = JSON.parse(payloadStr);
const parseResult = TeamLicenseSchema.safeParse(payload);
if (!parseResult.success) {
throw new InvalidLicenseToActivate({
reason: 'Invalid license payload.',
});
}
return parseResult.data;
}
private decryptLicense(buf: Buffer) {
if (!this.crypto.AFFiNEProLicenseAESKey) {
throw new InternalServerError(
'License AES key is not loaded. Please contact with Affine support.'
);
}
if (buf.length < 2) {
const resolved = resolveEntitlementV1({
deploymentType: 'selfhosted',
targetType: 'workspace',
targetId: workspaceId ?? undefined,
signedPayload: buf,
publicKey: this.crypto.AFFiNEProPublicKey.toString(),
licenseAesKey: this.crypto.AFFiNEProLicenseAESKey.toString('hex'),
now: new Date().toISOString(),
});
if (resolved.errorCode === 'workspace_mismatch') {
throw new InvalidLicenseToActivate({
reason: 'Invalid license file.',
reason: 'Workspace mismatched with license.',
});
}
try {
const ivLength = buf.readUint8(0);
const authTagLength = buf.readUInt8(1);
if (!resolved.valid && resolved.errorCode === 'expired_end_at') {
throw new LicenseExpired();
}
const iv = buf.subarray(2, 2 + ivLength);
const tag = buf.subarray(2 + ivLength, 2 + ivLength + authTagLength);
const payload = buf.subarray(2 + ivLength + authTagLength);
const decipher = createDecipheriv(
'aes-256-gcm',
this.crypto.AFFiNEProLicenseAESKey,
iv,
{
authTagLength,
}
);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([
decipher.update(payload),
decipher.final(),
]);
const data = JSON.parse(decrypted.toString('utf-8')) as {
payload: string;
signature: string;
};
return {
...data,
iv,
};
} catch {
// we use workspace id as aes key hash plain text content
if (!resolved.valid && resolved.status === 'expired') {
throw new InvalidLicenseToActivate({
reason: 'Failed to verify the license.',
reason:
'License file has expired. Please contact with Affine support to fetch a latest one.',
});
}
if (!resolved.valid && resolved.status !== 'expired') {
throw new InvalidLicenseToActivate({
reason: resolved.errorMessage ?? 'Failed to verify the license.',
});
}
return resolved;
}
private licenseSubjectId(resolved: ResolvedEntitlement) {
if (!resolved.subjectId) {
throw new InvalidLicenseToActivate({
reason: 'Invalid license payload.',
});
}
return resolved.subjectId;
}
private licenseWorkspaceId(resolved: ResolvedEntitlement) {
if (!resolved.targetId) {
throw new InvalidLicenseToActivate({
reason: 'Invalid license payload.',
});
}
return resolved.targetId;
}
private licenseRecurring(resolved: ResolvedEntitlement) {
if (!resolved.recurring) {
throw new InvalidLicenseToActivate({
reason: 'Invalid license payload.',
});
}
return resolved.recurring as SubscriptionRecurring;
}
private licenseQuantity(resolved: ResolvedEntitlement) {
if (!resolved.quantity) {
throw new InvalidLicenseToActivate({
reason: 'Invalid license payload.',
});
}
return resolved.quantity;
}
private licenseExpiresAt(resolved: ResolvedEntitlement) {
if (!resolved.expiresAt) {
throw new InvalidLicenseToActivate({
reason: 'Invalid license payload.',
});
}
return new Date(resolved.expiresAt);
}
}
@@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient, Provider } from '@prisma/client';
import { EventBus, JobQueue, OneHour, OnJob } from '../../base';
import { EntitlementService } from '../../core/entitlement';
import { RevenueCatWebhookHandler } from './revenuecat';
import { SubscriptionService } from './service';
import { StripeFactory } from './stripe';
@@ -34,7 +35,8 @@ export class SubscriptionCronJobs {
private readonly queue: JobQueue,
private readonly rcHandler: RevenueCatWebhookHandler,
private readonly stripeFactory: StripeFactory,
private readonly subscription: SubscriptionService
private readonly subscription: SubscriptionService,
private readonly entitlement: EntitlementService
) {}
private getDateRange(after: number, base: number | Date = Date.now()) {
@@ -157,6 +159,12 @@ export class SubscriptionCronJobs {
});
for (const subscription of subscriptions) {
await this.entitlement.revokeCloudSubscription({
targetId: subscription.targetId,
plan: subscription.plan,
subscriptionId: subscription.id,
stripeSubscriptionId: subscription.stripeSubscriptionId,
});
await this.db.subscription.delete({
where: {
targetId_plan: {
@@ -2,47 +2,32 @@ import { Injectable } from '@nestjs/common';
import { EventBus, OnEvent } from '../../base';
import { WorkspacePolicyService } from '../../core/permission';
import { QuotaStateService } from '../../core/quota/state';
import { WorkspaceService } from '../../core/workspaces';
import { Models } from '../../models';
import { SubscriptionPlan, SubscriptionRecurring } from './types';
import { SubscriptionPlan } from './types';
@Injectable()
export class PaymentEventHandlers {
constructor(
private readonly workspace: WorkspaceService,
private readonly models: Models,
private readonly event: EventBus,
private readonly policy: WorkspacePolicyService
private readonly policy: WorkspacePolicyService,
private readonly quotaState: QuotaStateService,
private readonly event: EventBus
) {}
@OnEvent('workspace.subscription.activated')
async onWorkspaceSubscriptionUpdated({
workspaceId,
plan,
recurring,
quantity,
}: Events['workspace.subscription.activated']) {
switch (plan) {
case 'team': {
const isTeam = await this.workspace.isTeamWorkspace(workspaceId);
await this.models.workspaceFeature.add(
workspaceId,
'team_plan_v1',
`${recurring} team subscription activated`,
{
memberLimit: quantity,
}
);
this.event.emit('workspace.members.allocateSeats', {
workspaceId,
quantity,
});
if (!isTeam) {
// this event will triggered when subscription is activated or changed
// we only send emails when the team workspace is activated
await this.workspace.sendTeamWorkspaceUpgradedEmail(workspaceId);
}
await this.policy.reconcileWorkspaceQuotaState(workspaceId);
break;
}
default:
@@ -68,22 +53,10 @@ export class PaymentEventHandlers {
async onUserSubscriptionUpdated({
userId,
plan,
recurring,
}: Events['user.subscription.activated']) {
switch (plan) {
case SubscriptionPlan.AI:
await this.models.userFeature.add(
userId,
'unlimited_copilot',
'subscription activated'
);
break;
case SubscriptionPlan.Pro:
await this.models.userFeature.switchQuota(
userId,
recurring === 'lifetime' ? 'lifetime_pro_plan_v1' : 'pro_plan_v1',
'subscription activated'
);
await this.policy.reconcileOwnedWorkspaces(userId);
break;
default:
@@ -95,43 +68,35 @@ export class PaymentEventHandlers {
async onUserSubscriptionCanceled({
userId,
plan,
recurring,
}: Events['user.subscription.canceled']) {
switch (plan) {
case SubscriptionPlan.AI:
await this.models.userFeature.remove(userId, 'unlimited_copilot');
break;
case SubscriptionPlan.Pro: {
// if user disputed a lifetime plan, we just switch them to free plan directly
if (recurring === SubscriptionRecurring.Lifetime) {
await this.models.userFeature.switchQuota(
userId,
'free_plan_v1',
'lifetime subscription canceled'
);
await this.policy.reconcileOwnedWorkspaces(userId);
break;
}
// edge case: when user switch from recurring Pro plan to `Lifetime` plan,
// a subscription canceled event will be triggered because `Lifetime` plan is not subscription based
const isLifetimeUser = await this.models.userFeature.has(
userId,
'lifetime_pro_plan_v1'
);
if (!isLifetimeUser) {
await this.models.userFeature.switchQuota(
userId,
'free_plan_v1',
'subscription canceled'
);
await this.policy.reconcileOwnedWorkspaces(userId);
}
await this.policy.reconcileOwnedWorkspaces(userId);
break;
}
default:
break;
}
}
@OnEvent('entitlement.changed')
async onEntitlementChanged({
targetType,
targetId,
}: Events['entitlement.changed']) {
if (targetType !== 'workspace') {
return;
}
const state = await this.quotaState.reconcileWorkspaceQuotaState(targetId);
if (state.plan !== 'team' && state.plan !== 'selfhost_team') {
return;
}
this.event.emit('workspace.members.allocateSeats', {
workspaceId: targetId,
quantity: state.seatLimit ?? 0,
});
}
}
@@ -3,6 +3,7 @@ import './config';
import { Module } from '@nestjs/common';
import { ServerConfigModule } from '../../core';
import { EntitlementModule } from '../../core/entitlement';
import { FeatureModule } from '../../core/features';
import { MailModule } from '../../core/mail';
import { PermissionModule } from '../../core/permission';
@@ -41,6 +42,7 @@ import { StripeWebhook } from './webhook';
WorkspaceModule,
MailModule,
ServerConfigModule,
EntitlementModule,
],
providers: [
StripeFactory,
@@ -19,6 +19,7 @@ import {
TooManyRequest,
URLHelper,
} from '../../../base';
import { EntitlementService } from '../../../core/entitlement';
import { EarlyAccessType, FeatureService } from '../../../core/features';
import { StripeFactory } from '../stripe';
import {
@@ -64,7 +65,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
private readonly feature: FeatureService,
private readonly event: EventBus,
private readonly url: URLHelper,
private readonly mutex: Mutex
private readonly mutex: Mutex,
private readonly entitlement: EntitlementService
) {
super(stripeProvider, db);
}
@@ -254,7 +256,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
const subscriptionData = this.transformSubscription(subscription);
return this.db.subscription.upsert({
const saved = await this.db.subscription.upsert({
where: {
stripeSubscriptionId: stripeSubscription.id,
},
@@ -270,6 +272,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
...omit(subscriptionData, ['provider', 'iapStore']),
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
return saved;
}
async deleteStripeSubscription({
@@ -285,6 +289,11 @@ export class UserSubscriptionManager extends SubscriptionManager {
});
if (result.count > 0) {
await this.entitlement.revokeCloudSubscription({
targetId: userId,
plan: lookupKey.plan,
stripeSubscriptionId: stripeSubscription.id,
});
this.event.emit('user.subscription.canceled', {
userId,
plan: lookupKey.plan,
@@ -416,7 +425,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
if (prevSubscription) {
if (prevSubscription.stripeSubscriptionId) {
await this.db.subscription.update({
const subscription = await this.db.subscription.update({
where: {
id: prevSubscription.id,
},
@@ -431,6 +440,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
nextBillAt: null,
},
});
await this.entitlement.upsertFromCloudSubscription(subscription);
await this.stripe.subscriptions.cancel(
prevSubscription.stripeSubscriptionId,
@@ -440,7 +450,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
);
}
} else {
await this.db.subscription.create({
const subscription = await this.db.subscription.create({
data: {
targetId: knownInvoice.userId,
stripeSubscriptionId: null,
@@ -452,6 +462,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
nextBillAt: null,
},
});
await this.entitlement.upsertFromCloudSubscription(subscription);
}
this.event.emit('user.subscription.activated', {
@@ -552,6 +563,10 @@ export class UserSubscriptionManager extends SubscriptionManager {
plan: lookupKey.plan,
recurring: lookupKey.recurring,
});
await this.entitlement.upsertFromCloudSubscription({
...subscription,
targetId: userId,
});
return subscription;
}
@@ -582,6 +597,12 @@ export class UserSubscriptionManager extends SubscriptionManager {
canceledAt: new Date(),
},
});
await this.entitlement.revokeCloudSubscription({
targetId: userId,
plan: lookupKey.plan,
subscriptionId: subscription.id,
stripeSubscriptionId: subscription.stripeSubscriptionId,
});
this.event.emit('user.subscription.canceled', {
userId,
@@ -621,7 +642,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
}
if (subscription) {
await this.db.subscription.update({
const saved = await this.db.subscription.update({
where: { id: subscription.id },
data: {
status: SubscriptionStatus.Active,
@@ -631,8 +652,9 @@ export class UserSubscriptionManager extends SubscriptionManager {
end,
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
} else {
await this.db.subscription.create({
const saved = await this.db.subscription.create({
data: {
targetId: userId,
stripeSubscriptionId: null,
@@ -643,6 +665,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
nextBillAt: null,
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
}
this.event.emit('user.subscription.activated', {
@@ -10,6 +10,7 @@ import {
SubscriptionPlanNotFound,
URLHelper,
} from '../../../base';
import { EntitlementService } from '../../../core/entitlement';
import { Models } from '../../../models';
import { StripeFactory } from '../stripe';
import {
@@ -50,7 +51,8 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
db: PrismaClient,
private readonly url: URLHelper,
private readonly event: EventBus,
private readonly models: Models
private readonly models: Models,
private readonly entitlement: EntitlementService
) {
super(stripeProvider, db);
}
@@ -155,7 +157,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
});
}
return this.db.subscription.upsert({
const saved = await this.db.subscription.upsert({
where: {
provider: Provider.stripe,
stripeSubscriptionId: stripeSubscription.id,
@@ -175,6 +177,8 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
...omit(subscriptionData, 'provider', 'iapStore'),
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
return saved;
}
async deleteStripeSubscription({
@@ -194,6 +198,11 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
});
if (result.count > 0) {
await this.entitlement.revokeCloudSubscription({
targetId: workspaceId,
plan: lookupKey.plan,
stripeSubscriptionId: stripeSubscription.id,
});
this.event.emit('workspace.subscription.canceled', {
workspaceId,
plan: lookupKey.plan,
@@ -27,7 +27,7 @@ import {
WorkspaceIdRequiredToUpdateTeamSubscription,
} from '../../base';
import { CurrentUser, Public } from '../../core/auth';
import { AccessController } from '../../core/permission';
import { PermissionAccess } from '../../core/permission';
import { UserType } from '../../core/user';
import { WorkspaceType } from '../../core/workspaces';
import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager';
@@ -665,7 +665,7 @@ export class WorkspaceSubscriptionResolver {
constructor(
private readonly service: WorkspaceSubscriptionManager,
private readonly db: PrismaClient,
private readonly ac: AccessController
private readonly ac: PermissionAccess
) {}
@ResolveField(() => SubscriptionType, {
@@ -11,6 +11,7 @@ import {
OnJob,
sleep,
} from '../../../base';
import { EntitlementService } from '../../../core/entitlement';
import {
SubscriptionPlan,
SubscriptionRecurring,
@@ -32,7 +33,8 @@ export class RevenueCatWebhookHandler {
private readonly db: PrismaClient,
private readonly config: Config,
private readonly event: EventBus,
private readonly queue: JobQueue
private readonly queue: JobQueue,
private readonly entitlement: EntitlementService
) {}
@OnEvent('revenuecat.webhook')
@@ -197,6 +199,10 @@ export class RevenueCatWebhookHandler {
},
});
if (result.count > 0) {
await this.entitlement.revokeCloudSubscription({
targetId: appUserId,
plan: mapping.plan,
});
this.event.emit('user.subscription.canceled', {
userId: appUserId,
plan: mapping.plan,
@@ -207,7 +213,7 @@ export class RevenueCatWebhookHandler {
continue;
}
await this.db.subscription.upsert({
const saved = await this.db.subscription.upsert({
where: {
targetId_plan: { targetId: appUserId, plan: mapping.plan },
},
@@ -252,6 +258,7 @@ export class RevenueCatWebhookHandler {
trialEnd: null,
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
if (
status === SubscriptionStatus.Active ||
@@ -278,6 +285,12 @@ export class RevenueCatWebhookHandler {
if (toBeCleanup.length) {
for (const sub of toBeCleanup) {
await this.db.subscription.deleteMany({ where: { id: sub.id } });
await this.entitlement.revokeCloudSubscription({
targetId: appUserId,
plan: sub.plan as SubscriptionPlan,
subscriptionId: sub.id,
stripeSubscriptionId: sub.stripeSubscriptionId,
});
this.event.emit('user.subscription.canceled', {
userId: appUserId,
plan: sub.plan as SubscriptionPlan,