feat(server): support selfhost licenses (#8947)

This commit is contained in:
forehalo
2025-01-22 10:21:07 +00:00
parent 22e424d7de
commit 994d758c07
31 changed files with 1653 additions and 127 deletions
@@ -0,0 +1,11 @@
import { OptionalModule } from '../../base';
import { PermissionModule } from '../../core/permission';
import { QuotaModule } from '../../core/quota';
import { LicenseResolver } from './resolver';
import { LicenseService } from './service';
@OptionalModule({
imports: [QuotaModule, PermissionModule],
providers: [LicenseService, LicenseResolver],
})
export class LicenseModule {}
@@ -0,0 +1,126 @@
import {
Args,
Field,
Int,
Mutation,
ObjectType,
Parent,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { ActionForbidden, Config } from '../../base';
import { CurrentUser } from '../../core/auth';
import { Permission, PermissionService } from '../../core/permission';
import { WorkspaceType } from '../../core/workspaces';
import { SubscriptionRecurring } from '../payment/types';
import { LicenseService } from './service';
@ObjectType()
export class License {
@Field(() => Int)
quantity!: number;
@Field(() => SubscriptionRecurring)
recurring!: string;
@Field(() => Date)
installedAt!: Date;
@Field(() => Date)
validatedAt!: Date;
@Field(() => Date, { nullable: true })
expiredAt!: Date | null;
}
@Resolver(() => WorkspaceType)
export class LicenseResolver {
constructor(
private readonly config: Config,
private readonly service: LicenseService,
private readonly permission: PermissionService
) {}
@ResolveField(() => License, {
complexity: 2,
description: 'The selfhost license of the workspace',
nullable: true,
})
async license(
@CurrentUser() user: CurrentUser,
@Parent() workspace: WorkspaceType
): Promise<License | null> {
// NOTE(@forehalo):
// we can't simply disable license resolver for non-selfhosted server
// it will make the gql codegen messed up.
if (!this.config.isSelfhosted) {
return null;
}
await this.permission.checkWorkspaceIs(
workspace.id,
user.id,
Permission.Owner
);
return this.service.getLicense(workspace.id);
}
@Mutation(() => License)
async activateLicense(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('license') license: string
) {
if (!this.config.isSelfhosted) {
throw new ActionForbidden();
}
await this.permission.checkWorkspaceIs(
workspaceId,
user.id,
Permission.Owner
);
return this.service.activateTeamLicense(workspaceId, license);
}
@Mutation(() => Boolean)
async deactivateLicense(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
if (!this.config.isSelfhosted) {
throw new ActionForbidden();
}
await this.permission.checkWorkspaceIs(
workspaceId,
user.id,
Permission.Owner
);
return this.service.deactivateTeamLicense(workspaceId);
}
@Mutation(() => String)
async createSelfhostWorkspaceCustomerPortal(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
if (!this.config.isSelfhosted) {
throw new ActionForbidden();
}
await this.permission.checkWorkspaceIs(
workspaceId,
user.id,
Permission.Owner
);
const { url } = await this.service.createCustomerPortal(workspaceId);
return url;
}
}
@@ -0,0 +1,343 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InstalledLicense, PrismaClient } from '@prisma/client';
import {
EventEmitter,
type EventPayload,
InternalServerError,
LicenseNotFound,
OnEvent,
UserFriendlyError,
WorkspaceLicenseAlreadyExists,
} from '../../base';
import { PermissionService } from '../../core/permission';
import { QuotaManagementService, QuotaType } from '../../core/quota';
import { SubscriptionPlan, SubscriptionRecurring } from '../payment/types';
interface License {
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
quantity: number;
endAt: number;
}
@Injectable()
export class LicenseService {
private readonly logger = new Logger(LicenseService.name);
constructor(
private readonly db: PrismaClient,
private readonly quota: QuotaManagementService,
private readonly event: EventEmitter,
private readonly permission: PermissionService
) {}
async getLicense(workspaceId: string) {
return this.db.installedLicense.findUnique({
select: {
installedAt: true,
validatedAt: true,
expiredAt: true,
quantity: true,
recurring: true,
},
where: {
workspaceId,
},
});
}
async activateTeamLicense(workspaceId: string, licenseKey: string) {
const installedLicense = await this.getLicense(workspaceId);
if (installedLicense) {
throw new WorkspaceLicenseAlreadyExists();
}
const data = await this.fetch<License>(
`/api/team/licenses/${licenseKey}/activate`,
{
method: 'POST',
}
);
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,
},
});
this.event.emit('workspace.subscription.activated', {
workspaceId,
plan: data.plan,
recurring: data.recurring,
quantity: data.quantity,
});
return license;
}
async deactivateTeamLicense(workspaceId: string) {
const license = await this.db.installedLicense.findUnique({
where: {
workspaceId,
},
});
if (!license) {
throw new LicenseNotFound();
}
await this.fetch(`/api/team/licenses/${license.key}/deactivate`, {
method: 'POST',
});
await this.db.installedLicense.deleteMany({
where: {
workspaceId,
},
});
this.event.emit('workspace.subscription.canceled', {
workspaceId,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
});
}
async updateTeamRecurring(key: string, recurring: SubscriptionRecurring) {
await this.fetch(`/api/team/licenses/${key}/recurring`, {
method: 'POST',
body: JSON.stringify({
recurring,
}),
});
}
async createCustomerPortal(workspaceId: string) {
const license = await this.db.installedLicense.findUnique({
where: {
workspaceId,
},
});
if (!license) {
throw new LicenseNotFound();
}
return this.fetch<{ url: string }>(
`/api/team/licenses/${license.key}/create-customer-portal`,
{
method: 'POST',
}
);
}
@OnEvent('workspace.members.updated')
async updateTeamSeats(payload: EventPayload<'workspace.members.updated'>) {
const { workspaceId, count } = payload;
const license = await this.db.installedLicense.findUnique({
where: {
workspaceId,
},
});
if (!license) {
return;
}
await this.fetch(`/api/team/licenses/${license.key}/seats`, {
method: 'POST',
body: JSON.stringify({
quantity: count,
}),
headers: {
'Content-Type': 'application/json',
},
});
// stripe payment is async, we can't directly the charge result in update calling
await this.waitUntilLicenseUpdated(license, count);
}
private async waitUntilLicenseUpdated(
license: InstalledLicense,
memberRequired: number
) {
let tried = 0;
while (tried++ < 10) {
try {
const res = await this.revalidateLicense(license);
if (res?.quantity === memberRequired) {
break;
}
} catch (e) {
this.logger.error('Failed to check license health', e);
}
await new Promise(resolve => setTimeout(resolve, tried * 2000));
}
// fallback to health check if we can't get the upgrade result immediately
throw new Error('Timeout checking seat update result.');
}
@Cron(CronExpression.EVERY_10_MINUTES)
async licensesHealthCheck() {
const licenses = await this.db.installedLicense.findMany({
where: {
validatedAt: {
lte: new Date(Date.now() - 1000 * 60 * 60),
},
},
});
for (const license of licenses) {
await this.revalidateLicense(license);
}
}
private async revalidateLicense(license: InstalledLicense) {
try {
const res = await this.fetch<License>(
`/api/team/licenses/${license.key}/health`
);
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),
},
});
this.event.emit('workspace.subscription.activated', {
workspaceId: license.workspaceId,
plan: res.plan,
recurring: res.recurring,
quantity: res.quantity,
});
return res;
} catch (e) {
this.logger.error('Failed to revalidate license', e);
// only treat known error as invalid license response
if (
e instanceof UserFriendlyError &&
e.name !== 'internal_server_error'
) {
this.event.emit('workspace.subscription.canceled', {
workspaceId: license.workspaceId,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
});
}
return null;
}
}
private async fetch<T = any>(
path: string,
init?: RequestInit
): Promise<T & { res: Response }> {
try {
const res = await fetch('https://app.affine.pro' + path, {
...init,
headers: {
'Content-Type': 'application/json',
},
});
if (!res.ok) {
const body = (await res.json()) as UserFriendlyError;
throw new UserFriendlyError(
body.type as any,
body.name as any,
body.message,
body.data
);
}
const data = (await res.json()) as T;
return {
...data,
res,
};
} catch (e) {
if (e instanceof UserFriendlyError) {
throw e;
}
throw new InternalServerError(
e instanceof Error
? e.message
: 'Failed to contact with https://app.affine.pro'
);
}
}
@OnEvent('workspace.subscription.activated')
async onWorkspaceSubscriptionUpdated({
workspaceId,
plan,
recurring,
quantity,
}: EventPayload<'workspace.subscription.activated'>) {
switch (plan) {
case SubscriptionPlan.SelfHostedTeam:
await this.quota.addTeamWorkspace(
workspaceId,
`${recurring} team subscription activated`
);
await this.quota.updateWorkspaceConfig(
workspaceId,
QuotaType.TeamPlanV1,
{ memberLimit: quantity }
);
await this.permission.refreshSeatStatus(workspaceId, quantity);
break;
default:
break;
}
}
@OnEvent('workspace.subscription.canceled')
async onWorkspaceSubscriptionCanceled({
workspaceId,
plan,
}: EventPayload<'workspace.subscription.canceled'>) {
switch (plan) {
case SubscriptionPlan.SelfHostedTeam:
await this.quota.removeTeamWorkspace(workspaceId);
break;
default:
break;
}
}
}