feat(server): support installable license (#12181)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added support for installing self-hosted team licenses via encrypted license files.
  - Introduced a new "Onetime" license variant for self-hosted environments.
  - Added a GraphQL mutation to upload and install license files.
  - License details now display the license variant.

- **Bug Fixes**
  - Improved error messages for license activation and expiration, including dynamic reasons.

- **Localization**
  - Updated and improved license-related error messages for better clarity.

- **Tests**
  - Added comprehensive end-to-end tests for license installation scenarios.

- **Chores**
  - Enhanced environment variable handling and public key management for license verification.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
forehalo
2025-05-09 04:16:05 +00:00
parent 3db91bdc8e
commit 93e01b4442
34 changed files with 718 additions and 187 deletions

View File

@@ -8,12 +8,15 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import GraphQLUpload, {
type FileUpload,
} from 'graphql-upload/GraphQLUpload.mjs';
import { UseNamedGuard } from '../../base';
import { toBuffer, UseNamedGuard } from '../../base';
import { CurrentUser } from '../../core/auth';
import { AccessController } from '../../core/permission';
import { WorkspaceType } from '../../core/workspaces';
import { SubscriptionRecurring } from '../payment/types';
import { SubscriptionRecurring, SubscriptionVariant } from '../payment/types';
import { LicenseService } from './service';
@ObjectType()
@@ -24,6 +27,9 @@ export class License {
@Field(() => SubscriptionRecurring)
recurring!: string;
@Field(() => SubscriptionVariant, { nullable: true })
variant!: string | null;
@Field(() => Date)
installedAt!: Date;
@@ -82,7 +88,7 @@ export class LicenseResolver {
.workspace(workspaceId)
.assert('Workspace.Payment.Manage');
return this.service.deactivateTeamLicense(workspaceId);
return this.service.removeTeamLicense(workspaceId);
}
@Mutation(() => String)
@@ -99,4 +105,22 @@ export class LicenseResolver {
return url;
}
@Mutation(() => License)
async installLicense(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('license', { type: () => GraphQLUpload }) licenseFile: FileUpload
) {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Payment.Manage');
const buffer = await toBuffer(licenseFile.createReadStream());
const license = await this.service.installLicense(workspaceId, buffer);
return license;
}
}

View File

@@ -1,17 +1,27 @@
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,
EventBus,
InternalServerError,
InvalidLicenseToActivate,
LicenseExpired,
LicenseNotFound,
OnEvent,
UserFriendlyError,
WorkspaceLicenseAlreadyExists,
} from '../../base';
import { Models } from '../../models';
import { SubscriptionPlan, SubscriptionRecurring } from '../payment/types';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionVariant,
} from '../payment/types';
interface License {
plan: SubscriptionPlan;
@@ -20,6 +30,27 @@ interface License {
endAt: number;
}
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);
@@ -27,7 +58,8 @@ export class LicenseService {
constructor(
private readonly db: PrismaClient,
private readonly event: EventBus,
private readonly models: Models
private readonly models: Models,
private readonly crypto: CryptoHelper
) {}
@OnEvent('workspace.subscription.activated')
@@ -79,6 +111,7 @@ export class LicenseService {
expiredAt: true,
quantity: true,
recurring: true,
variant: true,
},
where: {
workspaceId,
@@ -86,6 +119,51 @@ 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) {
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,
},
});
await this.event.emitAsync('workspace.subscription.activated', {
workspaceId,
plan: data.plan,
recurring: data.recurring,
quantity: data.quantity,
});
return installed;
}
async activateTeamLicense(workspaceId: string, licenseKey: string) {
const installedLicense = await this.getLicense(workspaceId);
@@ -132,7 +210,7 @@ export class LicenseService {
return license;
}
async deactivateTeamLicense(workspaceId: string) {
async removeTeamLicense(workspaceId: string) {
const license = await this.db.installedLicense.findUnique({
where: {
workspaceId,
@@ -143,24 +221,31 @@ export class LicenseService {
throw new LicenseNotFound();
}
await this.fetchAffinePro(`/api/team/licenses/${license.key}/deactivate`, {
method: 'POST',
});
await this.db.installedLicense.deleteMany({
where: {
workspaceId,
workspaceId: license.workspaceId,
},
});
if (license.variant !== SubscriptionVariant.Onetime) {
await this.deactivateTeamLicense(license);
}
this.event.emit('workspace.subscription.canceled', {
workspaceId,
workspaceId: license.workspaceId,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
recurring: license.recurring as SubscriptionRecurring,
});
return true;
}
async deactivateTeamLicense(license: InstalledLicense) {
await this.fetchAffinePro(`/api/team/licenses/${license.key}/deactivate`, {
method: 'POST',
});
}
async updateTeamRecurring(key: string, recurring: SubscriptionRecurring) {
await this.fetchAffinePro(`/api/team/licenses/${key}/recurring`, {
method: 'POST',
@@ -222,7 +307,7 @@ export class LicenseService {
let tried = 0;
while (tried++ < 10) {
try {
const res = await this.revalidateLicense(license);
const res = await this.revalidateRecurringLicense(license);
if (res?.quantity === memberRequired) {
return;
@@ -249,11 +334,15 @@ export class LicenseService {
});
for (const license of licenses) {
await this.revalidateLicense(license);
if (license.variant === SubscriptionVariant.Onetime) {
this.revalidateOnetimeLicense(license);
} else {
await this.revalidateRecurringLicense(license);
}
}
}
private async revalidateLicense(license: InstalledLicense) {
private async revalidateRecurringLicense(license: InstalledLicense) {
try {
const res = await this.fetchAffinePro<License>(
`/api/team/licenses/${license.key}/health`,
@@ -342,4 +431,132 @@ export class LicenseService {
);
}
}
private revalidateOnetimeLicense(license: InstalledLicense) {
const buf = license.license;
let valid = !!buf;
if (buf) {
try {
const { data } = this.decryptWorkspaceTeamLicense(
license.workspaceId,
Buffer.from(buf)
);
if (new Date(data.endAt) < new Date()) {
valid = false;
} else {
this.event.emit('workspace.subscription.activated', {
workspaceId: license.workspaceId,
plan: data.plan,
recurring: data.recurring,
quantity: data.quantity,
});
}
} catch {
valid = false;
}
}
if (!valid) {
this.event.emit('workspace.subscription.canceled', {
workspaceId: license.workspaceId,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
});
}
}
private decryptWorkspaceTeamLicense(workspaceId: string, 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(workspaceId, 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.',
});
}
if (parseResult.data.data.workspaceId !== workspaceId) {
throw new InvalidLicenseToActivate({
reason: 'Workspace mismatched with license.',
});
}
return parseResult.data;
}
private decryptLicense(workspaceId: string, buf: Buffer) {
if (buf.length < 2) {
throw new InvalidLicenseToActivate({
reason: 'Invalid license file.',
});
}
try {
const ivLength = buf.readUint8(0);
const authTagLength = buf.readUInt8(1);
const iv = buf.subarray(2, 2 + ivLength);
const tag = buf.subarray(2 + ivLength, 2 + ivLength + authTagLength);
const payload = buf.subarray(2 + ivLength + authTagLength);
const aesKey = this.crypto.sha256(
`WORKSPACE_PAYLOAD_AES_KEY:${workspaceId}`
);
const decipher = createDecipheriv('aes-256-gcm', aesKey, 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
throw new InvalidLicenseToActivate({
reason: 'Workspace mismatched with license.',
});
}
}
}

View File

@@ -62,7 +62,9 @@ export class LicenseController {
await using lock = await this.mutex.acquire(`license-activation:${key}`);
if (!lock) {
throw new InvalidLicenseToActivate();
throw new InvalidLicenseToActivate({
reason: 'Too Many Requests',
});
}
const license = await this.db.license.findUnique({
@@ -72,7 +74,9 @@ export class LicenseController {
});
if (!license) {
throw new InvalidLicenseToActivate();
throw new InvalidLicenseToActivate({
reason: 'License not found',
});
}
const subscription = await this.manager.getSubscription({
@@ -85,7 +89,9 @@ export class LicenseController {
license.installedAt ||
subscription.status !== SubscriptionStatus.Active
) {
throw new InvalidLicenseToActivate();
throw new InvalidLicenseToActivate({
reason: 'Invalid license',
});
}
const validateKey = randomUUID();
@@ -144,7 +150,9 @@ export class LicenseController {
}
if (license.validateKey && license.validateKey !== revalidateKey) {
throw new InvalidLicenseToActivate();
throw new InvalidLicenseToActivate({
reason: 'Invalid validate key',
});
}
const validateKey = randomUUID();