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

@@ -136,6 +136,8 @@ jobs:
extra-flags: workspaces focus @affine/server-native extra-flags: workspaces focus @affine/server-native
- name: Build Rust - name: Build Rust
uses: ./.github/actions/build-rust uses: ./.github/actions/build-rust
env:
AFFINE_PRO_PUBLIC_KEY: ${{ secrets.AFFINE_PRO_PUBLIC_KEY }}
with: with:
target: ${{ matrix.targets.name }} target: ${{ matrix.targets.name }}
package: '@affine/server-native' package: '@affine/server-native'

View File

@@ -4,6 +4,8 @@ export declare class Tokenizer {
count(content: string, allowedSpecial?: Array<string> | undefined | null): number count(content: string, allowedSpecial?: Array<string> | undefined | null): number
} }
export const AFFINE_PRO_PUBLIC_KEY: string | undefined | null
export declare function fromModelName(modelName: string): Tokenizer | null export declare function fromModelName(modelName: string): Tokenizer | null
export declare function getMime(input: Uint8Array): string export declare function getMime(input: Uint8Array): string

View File

@@ -52,3 +52,6 @@ pub fn merge_updates_in_apply_way(updates: Vec<Buffer>) -> Result<Buffer> {
Ok(buf.into()) Ok(buf.into())
} }
#[napi]
pub const AFFINE_PRO_PUBLIC_KEY: Option<&'static str> = std::option_env!("AFFINE_PRO_PUBLIC_KEY",);

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "installed_licenses" ADD COLUMN "license" BYTEA,
ADD COLUMN "variant" VARCHAR;

View File

@@ -767,10 +767,12 @@ model InstalledLicense {
workspaceId String @unique @map("workspace_id") @db.VarChar workspaceId String @unique @map("workspace_id") @db.VarChar
quantity Int @default(1) @db.Integer quantity Int @default(1) @db.Integer
recurring String @db.VarChar recurring String @db.VarChar
variant String? @db.VarChar
installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3) installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3)
validateKey String @map("validate_key") @db.VarChar validateKey String @map("validate_key") @db.VarChar
validatedAt DateTime @map("validated_at") @db.Timestamptz(3) validatedAt DateTime @map("validated_at") @db.Timestamptz(3)
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3) expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
license Bytes? @db.ByteA
@@map("installed_licenses") @@map("installed_licenses")
} }

View File

@@ -77,11 +77,24 @@ export class TestingApp extends NestApplication {
assert(init.body, 'body is required for gql request'); assert(init.body, 'body is required for gql request');
assert(init.headers, 'headers is required for gql request'); assert(init.headers, 'headers is required for gql request');
const res = await this.request('post', '/graphql') const req = this.request('post', '/graphql')
.send(init?.body)
.set('accept', 'application/json') .set('accept', 'application/json')
.set(init.headers as Record<string, string>); .set(init.headers as Record<string, string>);
if (init.body instanceof FormData) {
for (const [key, value] of init.body.entries()) {
if (value instanceof File) {
req.attach(key, Buffer.from(await value.arrayBuffer()));
} else {
req.field(key, value);
}
}
} else {
req.send(init.body);
}
const res = await req;
return new Response(Buffer.from(JSON.stringify(res.body)), { return new Response(Buffer.from(JSON.stringify(res.body)), {
status: res.status, status: res.status,
headers: res.headers, headers: res.headers,

View File

@@ -0,0 +1,144 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { installLicenseMutation, SubscriptionVariant } from '@affine/graphql';
import { Workspace, WorkspaceRole } from '../../../models';
import {
createApp,
e2e,
MockedUser,
Mockers,
refreshEnv,
type TestingApp,
} from '../test';
const testWorkspaceId = 'd6f52bc7-d62a-4822-804a-335fa7dfe5a6';
const testPublicKey = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkXqe9HR32rSgaNxaePXbwHxoanUq
1DcQTV1twn+G47/HiY+rj7oOw3cLNzOVe7+4Uxn8SMZ/XLImtqFQ6I4WVg==
-----END PUBLIC KEY-----`;
const fixturesDir = join(import.meta.dirname, '__fixtures__');
function getLicense(file: string) {
return new File([readFileSync(join(fixturesDir, file))], 'test-license.lic', {
type: 'application/octet-stream',
});
}
const licenses = {
valid: getLicense('valid.license'),
expired: getLicense('expired.license'),
expiredEndAt: getLicense('expired-end-at.license'),
};
let app: TestingApp;
let workspace: Workspace;
let owner: MockedUser;
e2e.before(async () => {
process.env.DEPLOYMENT_TYPE = 'selfhosted';
process.env.AFFiNE_PRO_PUBLIC_KEY = testPublicKey;
refreshEnv();
app = await createApp();
await app.models.workspace.delete(testWorkspaceId);
owner = await app.signup();
workspace = await app.create(Mockers.Workspace, {
id: testWorkspaceId,
owner,
});
});
e2e.beforeEach(async () => {
await app.login(owner);
});
e2e.after.always(async () => {
await app.close();
});
e2e('should install file license', async t => {
const res = await app.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspace.id,
license: licenses.valid,
},
});
t.is(res.installLicense.variant, SubscriptionVariant.Onetime);
});
e2e('should not allow to install license if not owner', async t => {
const user = await app.signup();
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
type: WorkspaceRole.Collaborator,
});
await t.throwsAsync(
app.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspace.id,
license: licenses.valid,
},
}),
{
message: `You do not have permission to access Space ${workspace.id}.`,
}
);
});
e2e(`should not install other workspace's license file`, async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
});
await t.throwsAsync(
app.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspace.id,
license: licenses.valid,
},
}),
{
message:
'Invalid license to activate. Workspace mismatched with license.',
}
);
});
e2e('should not install expired license', async t => {
await t.throwsAsync(
app.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspace.id,
license: licenses.expired,
},
}),
{
message: 'License has expired.',
}
);
});
e2e('should not install license with expired end date', async t => {
await t.throwsAsync(
app.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspace.id,
license: licenses.expiredEndAt,
},
}),
{
message: 'License has expired.',
}
);
});

View File

@@ -1,5 +1,6 @@
import test, { registerCompletionHandler } from 'ava'; import test, { registerCompletionHandler } from 'ava';
import { Env } from '../../env';
import { type TestingApp } from './create-app'; import { type TestingApp } from './create-app';
export const e2e = test; export const e2e = test;
@@ -9,3 +10,11 @@ export const app: TestingApp = globalThis.app;
registerCompletionHandler(async () => { registerCompletionHandler(async () => {
await app.close(); await app.close();
}); });
export function refreshEnv() {
globalThis.env = new Env();
}
export * from '../mocks';
export { createApp } from './create-app';
export type { TestingApp };

View File

@@ -811,18 +811,17 @@ export const USER_FRIENDLY_ERRORS = {
}, },
invalid_license_to_activate: { invalid_license_to_activate: {
type: 'bad_request', type: 'bad_request',
message: 'Invalid license to activate.', args: { reason: 'string' },
message: ({ reason }) => `Invalid license to activate. ${reason}`,
}, },
invalid_license_update_params: { invalid_license_update_params: {
type: 'invalid_input', type: 'invalid_input',
args: { reason: 'string' }, args: { reason: 'string' },
message: ({ reason }) => `Invalid license update params. ${reason}`, message: ({ reason }) => `Invalid license update params. ${reason}`,
}, },
workspace_members_exceed_limit_to_downgrade: { license_expired: {
type: 'bad_request', type: 'bad_request',
args: { limit: 'number' }, message: 'License has expired.',
message: ({ limit }) =>
`You cannot downgrade the workspace from team workspace because there are more than ${limit} members that are currently active.`,
}, },
// version errors // version errors

View File

@@ -914,10 +914,14 @@ export class LicenseNotFound extends UserFriendlyError {
super('resource_not_found', 'license_not_found', message); super('resource_not_found', 'license_not_found', message);
} }
} }
@ObjectType()
class InvalidLicenseToActivateDataType {
@Field() reason!: string
}
export class InvalidLicenseToActivate extends UserFriendlyError { export class InvalidLicenseToActivate extends UserFriendlyError {
constructor(message?: string) { constructor(args: InvalidLicenseToActivateDataType, message?: string | ((args: InvalidLicenseToActivateDataType) => string)) {
super('bad_request', 'invalid_license_to_activate', message); super('bad_request', 'invalid_license_to_activate', message, args);
} }
} }
@ObjectType() @ObjectType()
@@ -930,14 +934,10 @@ export class InvalidLicenseUpdateParams extends UserFriendlyError {
super('invalid_input', 'invalid_license_update_params', message, args); super('invalid_input', 'invalid_license_update_params', message, args);
} }
} }
@ObjectType()
class WorkspaceMembersExceedLimitToDowngradeDataType {
@Field() limit!: number
}
export class WorkspaceMembersExceedLimitToDowngrade extends UserFriendlyError { export class LicenseExpired extends UserFriendlyError {
constructor(args: WorkspaceMembersExceedLimitToDowngradeDataType, message?: string | ((args: WorkspaceMembersExceedLimitToDowngradeDataType) => string)) { constructor(message?: string) {
super('bad_request', 'workspace_members_exceed_limit_to_downgrade', message, args); super('bad_request', 'license_expired', message);
} }
} }
@ObjectType() @ObjectType()
@@ -1100,7 +1100,7 @@ export enum ErrorNames {
LICENSE_NOT_FOUND, LICENSE_NOT_FOUND,
INVALID_LICENSE_TO_ACTIVATE, INVALID_LICENSE_TO_ACTIVATE,
INVALID_LICENSE_UPDATE_PARAMS, INVALID_LICENSE_UPDATE_PARAMS,
WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE, LICENSE_EXPIRED,
UNSUPPORTED_CLIENT_VERSION, UNSUPPORTED_CLIENT_VERSION,
NOTIFICATION_NOT_FOUND, NOTIFICATION_NOT_FOUND,
MENTION_USER_DOC_ACCESS_DENIED, MENTION_USER_DOC_ACCESS_DENIED,
@@ -1114,5 +1114,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({ export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion', name: 'ErrorDataUnion',
types: () => types: () =>
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType] as const, [GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType] as const,
}); });

View File

@@ -50,7 +50,7 @@ test('should be able to encrypt and decrypt', t => {
// we are using a stub to make sure the iv is always 0, // we are using a stub to make sure the iv is always 0,
// the encrypted result will always be the same // the encrypted result will always be the same
t.is(encrypted, 'AAAAAAAAAAAAAAAAWUDlJRhzP+SZ3avvmLcgnou+q4E11w=='); t.is(encrypted, 'AAAAAAAAAAAAAAAAOXbR/9glITL3BcO3kPd6fGOMasSkPQ==');
t.is(decrypted, data); t.is(decrypted, data);
stub.restore(); stub.restore();

View File

@@ -2,7 +2,6 @@ import {
createCipheriv, createCipheriv,
createDecipheriv, createDecipheriv,
createHash, createHash,
createPrivateKey,
createPublicKey, createPublicKey,
createSign, createSign,
createVerify, createVerify,
@@ -12,12 +11,13 @@ import {
timingSafeEqual, timingSafeEqual,
} from 'node:crypto'; } from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { import {
hash as hashPassword, hash as hashPassword,
verify as verifyPassword, verify as verifyPassword,
} from '@node-rs/argon2'; } from '@node-rs/argon2';
import { AFFINE_PRO_PUBLIC_KEY } from '../../native';
import { Config } from '../config'; import { Config } from '../config';
import { OnEvent } from '../event'; import { OnEvent } from '../event';
@@ -37,20 +37,7 @@ function generatePrivateKey(): string {
return key.toString('utf8'); return key.toString('utf8');
} }
function readPrivateKey(privateKey: string) { function generatePublicKey(privateKey: string) {
return createPrivateKey({
key: Buffer.from(privateKey),
format: 'pem',
type: 'sec1',
})
.export({
format: 'pem',
type: 'pkcs8',
})
.toString('utf8');
}
function readPublicKey(privateKey: string) {
return createPublicKey({ return createPublicKey({
key: Buffer.from(privateKey), key: Buffer.from(privateKey),
}) })
@@ -59,7 +46,9 @@ function readPublicKey(privateKey: string) {
} }
@Injectable() @Injectable()
export class CryptoHelper { export class CryptoHelper implements OnModuleInit {
logger = new Logger(CryptoHelper.name);
keyPair!: { keyPair!: {
publicKey: Buffer; publicKey: Buffer;
privateKey: Buffer; privateKey: Buffer;
@@ -69,6 +58,14 @@ export class CryptoHelper {
}; };
}; };
AFFiNEProPublicKey: Buffer | null = null;
onModuleInit() {
if (env.selfhosted) {
this.AFFiNEProPublicKey = this.loadAFFiNEProPublicKey();
}
}
constructor(private readonly config: Config) {} constructor(private readonly config: Config) {}
@OnEvent('config.init') @OnEvent('config.init')
@@ -84,9 +81,8 @@ export class CryptoHelper {
} }
private setup() { private setup() {
const key = this.config.crypto.privateKey || generatePrivateKey(); const privateKey = this.config.crypto.privateKey || generatePrivateKey();
const privateKey = readPrivateKey(key); const publicKey = generatePublicKey(privateKey);
const publicKey = readPublicKey(key);
this.keyPair = { this.keyPair = {
publicKey: Buffer.from(publicKey), publicKey: Buffer.from(publicKey),
@@ -187,4 +183,18 @@ export class CryptoHelper {
sha256(data: string) { sha256(data: string) {
return createHash('sha256').update(data).digest(); return createHash('sha256').update(data).digest();
} }
private loadAFFiNEProPublicKey() {
if (AFFINE_PRO_PUBLIC_KEY) {
return Buffer.from(AFFINE_PRO_PUBLIC_KEY);
} else {
this.logger.warn('AFFINE_PRO_PUBLIC_KEY is not set at compile time.');
}
if (!env.prod && process.env.AFFiNE_PRO_PUBLIC_KEY) {
return Buffer.from(process.env.AFFiNE_PRO_PUBLIC_KEY);
}
return null;
}
} }

View File

@@ -257,6 +257,11 @@ export class WorkspaceService {
workspaceId, workspaceId,
quantity quantity
); );
if (!pendings.length) {
return;
}
const owner = await this.models.workspaceUser.getOwner(workspaceId); const owner = await this.models.workspaceUser.getOwner(workspaceId);
for (const member of pendings) { for (const member of pendings) {
try { try {

View File

@@ -1,4 +1,5 @@
import { resolve } from 'node:path'; import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import pkg from '../package.json' with { type: 'json' }; import pkg from '../package.json' with { type: 'json' };
@@ -9,6 +10,8 @@ declare global {
var env: Readonly<Env>; var env: Readonly<Env>;
// oxlint-disable-next-line no-var // oxlint-disable-next-line no-var
var readEnv: <T>(key: string, defaultValue: T, availableValues?: T[]) => T; var readEnv: <T>(key: string, defaultValue: T, availableValues?: T[]) => T;
// oxlint-disable-next-line no-var
var CUSTOM_CONFIG_PATH: string;
} }
} }
@@ -50,6 +53,7 @@ export type AppEnv = {
version: string; version: string;
}; };
globalThis.CUSTOM_CONFIG_PATH = join(homedir(), '.affine/config');
globalThis.readEnv = function readEnv<T>( globalThis.readEnv = function readEnv<T>(
env: string, env: string,
defaultValue: T, defaultValue: T,

View File

@@ -427,6 +427,6 @@ export class WorkspaceUserModel extends BaseModel {
data: { status: WorkspaceMemberStatus.NeedMoreSeat }, data: { status: WorkspaceMemberStatus.NeedMoreSeat },
}); });
return groups.Email; return groups.Email ?? [];
} }
} }

View File

@@ -21,3 +21,4 @@ export const parseDoc = serverNativeModule.parseDoc;
export const Tokenizer = serverNativeModule.Tokenizer; export const Tokenizer = serverNativeModule.Tokenizer;
export const fromModelName = serverNativeModule.fromModelName; export const fromModelName = serverNativeModule.fromModelName;
export const htmlSanitize = serverNativeModule.htmlSanitize; export const htmlSanitize = serverNativeModule.htmlSanitize;
export const AFFINE_PRO_PUBLIC_KEY = serverNativeModule.AFFINE_PRO_PUBLIC_KEY;

View File

@@ -8,12 +8,15 @@ import {
ResolveField, ResolveField,
Resolver, Resolver,
} from '@nestjs/graphql'; } 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 { CurrentUser } from '../../core/auth';
import { AccessController } from '../../core/permission'; import { AccessController } from '../../core/permission';
import { WorkspaceType } from '../../core/workspaces'; import { WorkspaceType } from '../../core/workspaces';
import { SubscriptionRecurring } from '../payment/types'; import { SubscriptionRecurring, SubscriptionVariant } from '../payment/types';
import { LicenseService } from './service'; import { LicenseService } from './service';
@ObjectType() @ObjectType()
@@ -24,6 +27,9 @@ export class License {
@Field(() => SubscriptionRecurring) @Field(() => SubscriptionRecurring)
recurring!: string; recurring!: string;
@Field(() => SubscriptionVariant, { nullable: true })
variant!: string | null;
@Field(() => Date) @Field(() => Date)
installedAt!: Date; installedAt!: Date;
@@ -82,7 +88,7 @@ export class LicenseResolver {
.workspace(workspaceId) .workspace(workspaceId)
.assert('Workspace.Payment.Manage'); .assert('Workspace.Payment.Manage');
return this.service.deactivateTeamLicense(workspaceId); return this.service.removeTeamLicense(workspaceId);
} }
@Mutation(() => String) @Mutation(() => String)
@@ -99,4 +105,22 @@ export class LicenseResolver {
return url; 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 { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { InstalledLicense, PrismaClient } from '@prisma/client'; import { InstalledLicense, PrismaClient } from '@prisma/client';
import { z } from 'zod';
import { import {
CryptoHelper,
EventBus, EventBus,
InternalServerError, InternalServerError,
InvalidLicenseToActivate,
LicenseExpired,
LicenseNotFound, LicenseNotFound,
OnEvent, OnEvent,
UserFriendlyError, UserFriendlyError,
WorkspaceLicenseAlreadyExists, WorkspaceLicenseAlreadyExists,
} from '../../base'; } from '../../base';
import { Models } from '../../models'; import { Models } from '../../models';
import { SubscriptionPlan, SubscriptionRecurring } from '../payment/types'; import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionVariant,
} from '../payment/types';
interface License { interface License {
plan: SubscriptionPlan; plan: SubscriptionPlan;
@@ -20,6 +30,27 @@ interface License {
endAt: number; 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() @Injectable()
export class LicenseService { export class LicenseService {
private readonly logger = new Logger(LicenseService.name); private readonly logger = new Logger(LicenseService.name);
@@ -27,7 +58,8 @@ export class LicenseService {
constructor( constructor(
private readonly db: PrismaClient, private readonly db: PrismaClient,
private readonly event: EventBus, private readonly event: EventBus,
private readonly models: Models private readonly models: Models,
private readonly crypto: CryptoHelper
) {} ) {}
@OnEvent('workspace.subscription.activated') @OnEvent('workspace.subscription.activated')
@@ -79,6 +111,7 @@ export class LicenseService {
expiredAt: true, expiredAt: true,
quantity: true, quantity: true,
recurring: true, recurring: true,
variant: true,
}, },
where: { where: {
workspaceId, 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) { async activateTeamLicense(workspaceId: string, licenseKey: string) {
const installedLicense = await this.getLicense(workspaceId); const installedLicense = await this.getLicense(workspaceId);
@@ -132,7 +210,7 @@ export class LicenseService {
return license; return license;
} }
async deactivateTeamLicense(workspaceId: string) { async removeTeamLicense(workspaceId: string) {
const license = await this.db.installedLicense.findUnique({ const license = await this.db.installedLicense.findUnique({
where: { where: {
workspaceId, workspaceId,
@@ -143,24 +221,31 @@ export class LicenseService {
throw new LicenseNotFound(); throw new LicenseNotFound();
} }
await this.fetchAffinePro(`/api/team/licenses/${license.key}/deactivate`, {
method: 'POST',
});
await this.db.installedLicense.deleteMany({ await this.db.installedLicense.deleteMany({
where: { where: {
workspaceId, workspaceId: license.workspaceId,
}, },
}); });
if (license.variant !== SubscriptionVariant.Onetime) {
await this.deactivateTeamLicense(license);
}
this.event.emit('workspace.subscription.canceled', { this.event.emit('workspace.subscription.canceled', {
workspaceId, workspaceId: license.workspaceId,
plan: SubscriptionPlan.SelfHostedTeam, plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly, recurring: license.recurring as SubscriptionRecurring,
}); });
return true; return true;
} }
async deactivateTeamLicense(license: InstalledLicense) {
await this.fetchAffinePro(`/api/team/licenses/${license.key}/deactivate`, {
method: 'POST',
});
}
async updateTeamRecurring(key: string, recurring: SubscriptionRecurring) { async updateTeamRecurring(key: string, recurring: SubscriptionRecurring) {
await this.fetchAffinePro(`/api/team/licenses/${key}/recurring`, { await this.fetchAffinePro(`/api/team/licenses/${key}/recurring`, {
method: 'POST', method: 'POST',
@@ -222,7 +307,7 @@ export class LicenseService {
let tried = 0; let tried = 0;
while (tried++ < 10) { while (tried++ < 10) {
try { try {
const res = await this.revalidateLicense(license); const res = await this.revalidateRecurringLicense(license);
if (res?.quantity === memberRequired) { if (res?.quantity === memberRequired) {
return; return;
@@ -249,11 +334,15 @@ export class LicenseService {
}); });
for (const license of licenses) { 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 { try {
const res = await this.fetchAffinePro<License>( const res = await this.fetchAffinePro<License>(
`/api/team/licenses/${license.key}/health`, `/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}`); await using lock = await this.mutex.acquire(`license-activation:${key}`);
if (!lock) { if (!lock) {
throw new InvalidLicenseToActivate(); throw new InvalidLicenseToActivate({
reason: 'Too Many Requests',
});
} }
const license = await this.db.license.findUnique({ const license = await this.db.license.findUnique({
@@ -72,7 +74,9 @@ export class LicenseController {
}); });
if (!license) { if (!license) {
throw new InvalidLicenseToActivate(); throw new InvalidLicenseToActivate({
reason: 'License not found',
});
} }
const subscription = await this.manager.getSubscription({ const subscription = await this.manager.getSubscription({
@@ -85,7 +89,9 @@ export class LicenseController {
license.installedAt || license.installedAt ||
subscription.status !== SubscriptionStatus.Active subscription.status !== SubscriptionStatus.Active
) { ) {
throw new InvalidLicenseToActivate(); throw new InvalidLicenseToActivate({
reason: 'Invalid license',
});
} }
const validateKey = randomUUID(); const validateKey = randomUUID();
@@ -144,7 +150,9 @@ export class LicenseController {
} }
if (license.validateKey && license.validateKey !== revalidateKey) { if (license.validateKey && license.validateKey !== revalidateKey) {
throw new InvalidLicenseToActivate(); throw new InvalidLicenseToActivate({
reason: 'Invalid validate key',
});
} }
const validateKey = randomUUID(); const validateKey = randomUUID();

View File

@@ -1,15 +1,12 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { config } from 'dotenv'; import { config } from 'dotenv';
import { createGlobalEnv } from './env'; import { createGlobalEnv } from './env';
const CUSTOM_CONFIG_PATH = `${homedir()}/.affine/config`;
function loadPrivateKey() { function loadPrivateKey() {
const file = join(CUSTOM_CONFIG_PATH, 'private.key'); const file = join(CUSTOM_CONFIG_PATH, 'private.key');
if (!process.env.AFFINE_PRIVATE_KEY && existsSync(file)) { if (!process.env.AFFINE_PRIVATE_KEY && existsSync(file)) {

View File

@@ -464,7 +464,7 @@ type EditorType {
name: String! name: String!
} }
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
enum ErrorNames { enum ErrorNames {
ACCESS_DENIED ACCESS_DENIED
@@ -541,6 +541,7 @@ enum ErrorNames {
INVALID_PASSWORD_LENGTH INVALID_PASSWORD_LENGTH
INVALID_RUNTIME_CONFIG_TYPE INVALID_RUNTIME_CONFIG_TYPE
INVALID_SUBSCRIPTION_PARAMETERS INVALID_SUBSCRIPTION_PARAMETERS
LICENSE_EXPIRED
LICENSE_NOT_FOUND LICENSE_NOT_FOUND
LICENSE_REVEALED LICENSE_REVEALED
LINK_EXPIRED LINK_EXPIRED
@@ -589,7 +590,6 @@ enum ErrorNames {
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION
WORKSPACE_LICENSE_ALREADY_EXISTS WORKSPACE_LICENSE_ALREADY_EXISTS
WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE
WORKSPACE_PERMISSION_NOT_FOUND WORKSPACE_PERMISSION_NOT_FOUND
WRONG_SIGN_IN_CREDENTIALS WRONG_SIGN_IN_CREDENTIALS
WRONG_SIGN_IN_METHOD WRONG_SIGN_IN_METHOD
@@ -671,6 +671,10 @@ type InvalidHistoryTimestampDataType {
timestamp: String! timestamp: String!
} }
type InvalidLicenseToActivateDataType {
reason: String!
}
type InvalidLicenseUpdateParamsDataType { type InvalidLicenseUpdateParamsDataType {
reason: String! reason: String!
} }
@@ -880,6 +884,7 @@ type License {
quantity: Int! quantity: Int!
recurring: SubscriptionRecurring! recurring: SubscriptionRecurring!
validatedAt: DateTime! validatedAt: DateTime!
variant: SubscriptionVariant
} }
type LimitedUserType { type LimitedUserType {
@@ -1034,6 +1039,7 @@ type Mutation {
"""import users""" """import users"""
importUsers(input: ImportUsersInput!): [UserImportResultType!]! importUsers(input: ImportUsersInput!): [UserImportResultType!]!
installLicense(license: Upload!, workspaceId: String!): License!
inviteBatch(emails: [String!]!, sendInviteMail: Boolean @deprecated(reason: "never used"), workspaceId: String!): [InviteResult!]! @deprecated(reason: "use [inviteMembers] instead") inviteBatch(emails: [String!]!, sendInviteMail: Boolean @deprecated(reason: "never used"), workspaceId: String!): [InviteResult!]! @deprecated(reason: "use [inviteMembers] instead")
inviteMembers(emails: [String!]!, workspaceId: String!): [InviteResult!]! inviteMembers(emails: [String!]!, workspaceId: String!): [InviteResult!]!
leaveWorkspace(sendLeaveMail: Boolean @deprecated(reason: "no used anymore"), workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean! leaveWorkspace(sendLeaveMail: Boolean @deprecated(reason: "no used anymore"), workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
@@ -1730,10 +1736,6 @@ enum WorkspaceMemberStatus {
UnderReview UnderReview
} }
type WorkspaceMembersExceedLimitToDowngradeDataType {
limit: Int!
}
type WorkspacePermissionNotFoundDataType { type WorkspacePermissionNotFoundDataType {
spaceId: String! spaceId: String!
} }

View File

@@ -15,17 +15,14 @@ export const passwordLimitsFragment = `fragment PasswordLimits on PasswordLimits
minLength minLength
maxLength maxLength
}`; }`;
export const activateLicenseMutation = { export const licenseFragment = `fragment license on License {
id: 'activateLicenseMutation' as const, expiredAt
op: 'activateLicense', installedAt
query: `mutation activateLicense($workspaceId: String!, $license: String!) { quantity
activateLicense(workspaceId: $workspaceId, license: $license) { recurring
installedAt validatedAt
validatedAt variant
} }`;
}`,
};
export const adminServerConfigQuery = { export const adminServerConfigQuery = {
id: 'adminServerConfigQuery' as const, id: 'adminServerConfigQuery' as const,
op: 'adminServerConfig', op: 'adminServerConfig',
@@ -893,14 +890,6 @@ export const createWorkspaceMutation = {
}`, }`,
}; };
export const deactivateLicenseMutation = {
id: 'deactivateLicenseMutation' as const,
op: 'deactivateLicense',
query: `mutation deactivateLicense($workspaceId: String!) {
deactivateLicense(workspaceId: $workspaceId)
}`,
};
export const deleteAccountMutation = { export const deleteAccountMutation = {
id: 'deleteAccountMutation' as const, id: 'deleteAccountMutation' as const,
op: 'deleteAccount', op: 'deleteAccount',
@@ -1065,22 +1054,6 @@ export const getIsOwnerQuery = {
deprecations: ["'isOwner' is deprecated: use WorkspaceType[role] instead"], deprecations: ["'isOwner' is deprecated: use WorkspaceType[role] instead"],
}; };
export const getLicenseQuery = {
id: 'getLicenseQuery' as const,
op: 'getLicense',
query: `query getLicense($workspaceId: String!) {
workspace(id: $workspaceId) {
license {
expiredAt
installedAt
quantity
recurring
validatedAt
}
}
}`,
};
export const getMemberCountByWorkspaceIdQuery = { export const getMemberCountByWorkspaceIdQuery = {
id: 'getMemberCountByWorkspaceIdQuery' as const, id: 'getMemberCountByWorkspaceIdQuery' as const,
op: 'getMemberCountByWorkspaceId', op: 'getMemberCountByWorkspaceId',
@@ -1391,6 +1364,50 @@ export const leaveWorkspaceMutation = {
}`, }`,
}; };
export const activateLicenseMutation = {
id: 'activateLicenseMutation' as const,
op: 'activateLicense',
query: `mutation activateLicense($workspaceId: String!, $license: String!) {
activateLicense(workspaceId: $workspaceId, license: $license) {
...license
}
}
${licenseFragment}`,
};
export const deactivateLicenseMutation = {
id: 'deactivateLicenseMutation' as const,
op: 'deactivateLicense',
query: `mutation deactivateLicense($workspaceId: String!) {
deactivateLicense(workspaceId: $workspaceId)
}`,
};
export const getLicenseQuery = {
id: 'getLicenseQuery' as const,
op: 'getLicense',
query: `query getLicense($workspaceId: String!) {
workspace(id: $workspaceId) {
license {
...license
}
}
}
${licenseFragment}`,
};
export const installLicenseMutation = {
id: 'installLicenseMutation' as const,
op: 'installLicense',
query: `mutation installLicense($workspaceId: String!, $license: Upload!) {
installLicense(workspaceId: $workspaceId, license: $license) {
...license
}
}
${licenseFragment}`,
file: true,
};
export const listNotificationsQuery = { export const listNotificationsQuery = {
id: 'listNotificationsQuery' as const, id: 'listNotificationsQuery' as const,
op: 'listNotifications', op: 'listNotifications',

View File

@@ -1,6 +1,7 @@
#import './license.gql'
mutation activateLicense($workspaceId: String!, $license: String!) { mutation activateLicense($workspaceId: String!, $license: String!) {
activateLicense(workspaceId: $workspaceId, license: $license) { activateLicense(workspaceId: $workspaceId, license: $license) {
installedAt ...license
validatedAt
} }
} }

View File

@@ -1,11 +1,9 @@
#import './license.gql'
query getLicense($workspaceId: String!) { query getLicense($workspaceId: String!) {
workspace(id: $workspaceId) { workspace(id: $workspaceId) {
license { license {
expiredAt ...license
installedAt
quantity
recurring
validatedAt
} }
} }
} }

View File

@@ -0,0 +1,7 @@
#import './license.gql'
mutation installLicense($workspaceId: String!, $license: Upload!) {
installLicense(workspaceId: $workspaceId, license: $license) {
...license
}
}

View File

@@ -0,0 +1,8 @@
fragment license on License {
expiredAt
installedAt
quantity
recurring
validatedAt
variant
}

View File

@@ -598,6 +598,7 @@ export type ErrorDataUnion =
| HttpRequestErrorDataType | HttpRequestErrorDataType
| InvalidEmailDataType | InvalidEmailDataType
| InvalidHistoryTimestampDataType | InvalidHistoryTimestampDataType
| InvalidLicenseToActivateDataType
| InvalidLicenseUpdateParamsDataType | InvalidLicenseUpdateParamsDataType
| InvalidOauthCallbackCodeDataType | InvalidOauthCallbackCodeDataType
| InvalidPasswordLengthDataType | InvalidPasswordLengthDataType
@@ -622,7 +623,6 @@ export type ErrorDataUnion =
| UnsupportedSubscriptionPlanDataType | UnsupportedSubscriptionPlanDataType
| ValidationErrorDataType | ValidationErrorDataType
| VersionRejectedDataType | VersionRejectedDataType
| WorkspaceMembersExceedLimitToDowngradeDataType
| WorkspacePermissionNotFoundDataType | WorkspacePermissionNotFoundDataType
| WrongSignInCredentialsDataType; | WrongSignInCredentialsDataType;
@@ -701,6 +701,7 @@ export enum ErrorNames {
INVALID_PASSWORD_LENGTH = 'INVALID_PASSWORD_LENGTH', INVALID_PASSWORD_LENGTH = 'INVALID_PASSWORD_LENGTH',
INVALID_RUNTIME_CONFIG_TYPE = 'INVALID_RUNTIME_CONFIG_TYPE', INVALID_RUNTIME_CONFIG_TYPE = 'INVALID_RUNTIME_CONFIG_TYPE',
INVALID_SUBSCRIPTION_PARAMETERS = 'INVALID_SUBSCRIPTION_PARAMETERS', INVALID_SUBSCRIPTION_PARAMETERS = 'INVALID_SUBSCRIPTION_PARAMETERS',
LICENSE_EXPIRED = 'LICENSE_EXPIRED',
LICENSE_NOT_FOUND = 'LICENSE_NOT_FOUND', LICENSE_NOT_FOUND = 'LICENSE_NOT_FOUND',
LICENSE_REVEALED = 'LICENSE_REVEALED', LICENSE_REVEALED = 'LICENSE_REVEALED',
LINK_EXPIRED = 'LINK_EXPIRED', LINK_EXPIRED = 'LINK_EXPIRED',
@@ -749,7 +750,6 @@ export enum ErrorNames {
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION = 'WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION', WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION = 'WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION',
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION = 'WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION', WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION = 'WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION',
WORKSPACE_LICENSE_ALREADY_EXISTS = 'WORKSPACE_LICENSE_ALREADY_EXISTS', WORKSPACE_LICENSE_ALREADY_EXISTS = 'WORKSPACE_LICENSE_ALREADY_EXISTS',
WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE = 'WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE',
WORKSPACE_PERMISSION_NOT_FOUND = 'WORKSPACE_PERMISSION_NOT_FOUND', WORKSPACE_PERMISSION_NOT_FOUND = 'WORKSPACE_PERMISSION_NOT_FOUND',
WRONG_SIGN_IN_CREDENTIALS = 'WRONG_SIGN_IN_CREDENTIALS', WRONG_SIGN_IN_CREDENTIALS = 'WRONG_SIGN_IN_CREDENTIALS',
WRONG_SIGN_IN_METHOD = 'WRONG_SIGN_IN_METHOD', WRONG_SIGN_IN_METHOD = 'WRONG_SIGN_IN_METHOD',
@@ -837,6 +837,11 @@ export interface InvalidHistoryTimestampDataType {
timestamp: Scalars['String']['output']; timestamp: Scalars['String']['output'];
} }
export interface InvalidLicenseToActivateDataType {
__typename?: 'InvalidLicenseToActivateDataType';
reason: Scalars['String']['output'];
}
export interface InvalidLicenseUpdateParamsDataType { export interface InvalidLicenseUpdateParamsDataType {
__typename?: 'InvalidLicenseUpdateParamsDataType'; __typename?: 'InvalidLicenseUpdateParamsDataType';
reason: Scalars['String']['output']; reason: Scalars['String']['output'];
@@ -1029,6 +1034,7 @@ export interface License {
quantity: Scalars['Int']['output']; quantity: Scalars['Int']['output'];
recurring: SubscriptionRecurring; recurring: SubscriptionRecurring;
validatedAt: Scalars['DateTime']['output']; validatedAt: Scalars['DateTime']['output'];
variant: Maybe<SubscriptionVariant>;
} }
export interface LimitedUserType { export interface LimitedUserType {
@@ -1166,6 +1172,7 @@ export interface Mutation {
grantMember: Scalars['Boolean']['output']; grantMember: Scalars['Boolean']['output'];
/** import users */ /** import users */
importUsers: Array<UserImportResultType>; importUsers: Array<UserImportResultType>;
installLicense: License;
/** @deprecated use [inviteMembers] instead */ /** @deprecated use [inviteMembers] instead */
inviteBatch: Array<InviteResult>; inviteBatch: Array<InviteResult>;
inviteMembers: Array<InviteResult>; inviteMembers: Array<InviteResult>;
@@ -1391,6 +1398,11 @@ export interface MutationImportUsersArgs {
input: ImportUsersInput; input: ImportUsersInput;
} }
export interface MutationInstallLicenseArgs {
license: Scalars['Upload']['input'];
workspaceId: Scalars['String']['input'];
}
export interface MutationInviteBatchArgs { export interface MutationInviteBatchArgs {
emails: Array<Scalars['String']['input']>; emails: Array<Scalars['String']['input']>;
sendInviteMail?: InputMaybe<Scalars['Boolean']['input']>; sendInviteMail?: InputMaybe<Scalars['Boolean']['input']>;
@@ -2291,11 +2303,6 @@ export enum WorkspaceMemberStatus {
UnderReview = 'UnderReview', UnderReview = 'UnderReview',
} }
export interface WorkspaceMembersExceedLimitToDowngradeDataType {
__typename?: 'WorkspaceMembersExceedLimitToDowngradeDataType';
limit: Scalars['Int']['output'];
}
export interface WorkspacePermissionNotFoundDataType { export interface WorkspacePermissionNotFoundDataType {
__typename?: 'WorkspacePermissionNotFoundDataType'; __typename?: 'WorkspacePermissionNotFoundDataType';
spaceId: Scalars['String']['output']; spaceId: Scalars['String']['output'];
@@ -2474,20 +2481,6 @@ export interface TokenType {
token: Scalars['String']['output']; token: Scalars['String']['output'];
} }
export type ActivateLicenseMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
license: Scalars['String']['input'];
}>;
export type ActivateLicenseMutation = {
__typename?: 'Mutation';
activateLicense: {
__typename?: 'License';
installedAt: string;
validatedAt: string;
};
};
export type AdminServerConfigQueryVariables = Exact<{ [key: string]: never }>; export type AdminServerConfigQueryVariables = Exact<{ [key: string]: never }>;
export type AdminServerConfigQuery = { export type AdminServerConfigQuery = {
@@ -3495,15 +3488,6 @@ export type CreateWorkspaceMutation = {
}; };
}; };
export type DeactivateLicenseMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
}>;
export type DeactivateLicenseMutation = {
__typename?: 'Mutation';
deactivateLicense: boolean;
};
export type DeleteAccountMutationVariables = Exact<{ [key: string]: never }>; export type DeleteAccountMutationVariables = Exact<{ [key: string]: never }>;
export type DeleteAccountMutation = { export type DeleteAccountMutation = {
@@ -3693,25 +3677,6 @@ export type GetIsOwnerQueryVariables = Exact<{
export type GetIsOwnerQuery = { __typename?: 'Query'; isOwner: boolean }; export type GetIsOwnerQuery = { __typename?: 'Query'; isOwner: boolean };
export type GetLicenseQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
}>;
export type GetLicenseQuery = {
__typename?: 'Query';
workspace: {
__typename?: 'WorkspaceType';
license: {
__typename?: 'License';
expiredAt: string | null;
installedAt: string;
quantity: number;
recurring: SubscriptionRecurring;
validatedAt: string;
} | null;
};
};
export type GetMemberCountByWorkspaceIdQueryVariables = Exact<{ export type GetMemberCountByWorkspaceIdQueryVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
}>; }>;
@@ -4059,6 +4024,81 @@ export type LeaveWorkspaceMutation = {
leaveWorkspace: boolean; leaveWorkspace: boolean;
}; };
export type ActivateLicenseMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
license: Scalars['String']['input'];
}>;
export type ActivateLicenseMutation = {
__typename?: 'Mutation';
activateLicense: {
__typename?: 'License';
expiredAt: string | null;
installedAt: string;
quantity: number;
recurring: SubscriptionRecurring;
validatedAt: string;
variant: SubscriptionVariant | null;
};
};
export type DeactivateLicenseMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
}>;
export type DeactivateLicenseMutation = {
__typename?: 'Mutation';
deactivateLicense: boolean;
};
export type GetLicenseQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
}>;
export type GetLicenseQuery = {
__typename?: 'Query';
workspace: {
__typename?: 'WorkspaceType';
license: {
__typename?: 'License';
expiredAt: string | null;
installedAt: string;
quantity: number;
recurring: SubscriptionRecurring;
validatedAt: string;
variant: SubscriptionVariant | null;
} | null;
};
};
export type InstallLicenseMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
license: Scalars['Upload']['input'];
}>;
export type InstallLicenseMutation = {
__typename?: 'Mutation';
installLicense: {
__typename?: 'License';
expiredAt: string | null;
installedAt: string;
quantity: number;
recurring: SubscriptionRecurring;
validatedAt: string;
variant: SubscriptionVariant | null;
};
};
export type LicenseFragment = {
__typename?: 'License';
expiredAt: string | null;
installedAt: string;
quantity: number;
recurring: SubscriptionRecurring;
validatedAt: string;
variant: SubscriptionVariant | null;
};
export type ListNotificationsQueryVariables = Exact<{ export type ListNotificationsQueryVariables = Exact<{
pagination: PaginationInput; pagination: PaginationInput;
}>; }>;
@@ -4795,11 +4835,6 @@ export type Queries =
variables: GetIsOwnerQueryVariables; variables: GetIsOwnerQueryVariables;
response: GetIsOwnerQuery; response: GetIsOwnerQuery;
} }
| {
name: 'getLicenseQuery';
variables: GetLicenseQueryVariables;
response: GetLicenseQuery;
}
| { | {
name: 'getMemberCountByWorkspaceIdQuery'; name: 'getMemberCountByWorkspaceIdQuery';
variables: GetMemberCountByWorkspaceIdQueryVariables; variables: GetMemberCountByWorkspaceIdQueryVariables;
@@ -4895,6 +4930,11 @@ export type Queries =
variables: InvoicesQueryVariables; variables: InvoicesQueryVariables;
response: InvoicesQuery; response: InvoicesQuery;
} }
| {
name: 'getLicenseQuery';
variables: GetLicenseQueryVariables;
response: GetLicenseQuery;
}
| { | {
name: 'listNotificationsQuery'; name: 'listNotificationsQuery';
variables: ListNotificationsQueryVariables; variables: ListNotificationsQueryVariables;
@@ -4952,11 +4992,6 @@ export type Queries =
}; };
export type Mutations = export type Mutations =
| {
name: 'activateLicenseMutation';
variables: ActivateLicenseMutationVariables;
response: ActivateLicenseMutation;
}
| { | {
name: 'createChangePasswordUrlMutation'; name: 'createChangePasswordUrlMutation';
variables: CreateChangePasswordUrlMutationVariables; variables: CreateChangePasswordUrlMutationVariables;
@@ -5162,11 +5197,6 @@ export type Mutations =
variables: CreateWorkspaceMutationVariables; variables: CreateWorkspaceMutationVariables;
response: CreateWorkspaceMutation; response: CreateWorkspaceMutation;
} }
| {
name: 'deactivateLicenseMutation';
variables: DeactivateLicenseMutationVariables;
response: DeactivateLicenseMutation;
}
| { | {
name: 'deleteAccountMutation'; name: 'deleteAccountMutation';
variables: DeleteAccountMutationVariables; variables: DeleteAccountMutationVariables;
@@ -5192,6 +5222,21 @@ export type Mutations =
variables: LeaveWorkspaceMutationVariables; variables: LeaveWorkspaceMutationVariables;
response: LeaveWorkspaceMutation; response: LeaveWorkspaceMutation;
} }
| {
name: 'activateLicenseMutation';
variables: ActivateLicenseMutationVariables;
response: ActivateLicenseMutation;
}
| {
name: 'deactivateLicenseMutation';
variables: DeactivateLicenseMutationVariables;
response: DeactivateLicenseMutation;
}
| {
name: 'installLicenseMutation';
variables: InstallLicenseMutationVariables;
response: InstallLicenseMutation;
}
| { | {
name: 'mentionUserMutation'; name: 'mentionUserMutation';
variables: MentionUserMutationVariables; variables: MentionUserMutationVariables;

View File

@@ -8441,9 +8441,11 @@ export function useAFFiNEI18N(): {
*/ */
["error.LICENSE_NOT_FOUND"](): string; ["error.LICENSE_NOT_FOUND"](): string;
/** /**
* `Invalid license to activate.` * `Invalid license to activate. {{reason}}`
*/ */
["error.INVALID_LICENSE_TO_ACTIVATE"](): string; ["error.INVALID_LICENSE_TO_ACTIVATE"](options: {
readonly reason: string;
}): string;
/** /**
* `Invalid license update params. {{reason}}` * `Invalid license update params. {{reason}}`
*/ */
@@ -8451,11 +8453,9 @@ export function useAFFiNEI18N(): {
readonly reason: string; readonly reason: string;
}): string; }): string;
/** /**
* `You cannot downgrade the workspace from team workspace because there are more than {{limit}} members that are currently active.` * `License has expired.`
*/ */
["error.WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE"](options: { ["error.LICENSE_EXPIRED"](): string;
readonly limit: string;
}): string;
/** /**
* `Unsupported client with version [{{clientVersion}}], required version is [{{requiredVersion}}].` * `Unsupported client with version [{{clientVersion}}], required version is [{{requiredVersion}}].`
*/ */

View File

@@ -2087,9 +2087,9 @@
"error.LICENSE_REVEALED": "License key has been revealed. Please check your mail box of the one provided during checkout.", "error.LICENSE_REVEALED": "License key has been revealed. Please check your mail box of the one provided during checkout.",
"error.WORKSPACE_LICENSE_ALREADY_EXISTS": "Workspace already has a license applied.", "error.WORKSPACE_LICENSE_ALREADY_EXISTS": "Workspace already has a license applied.",
"error.LICENSE_NOT_FOUND": "License not found.", "error.LICENSE_NOT_FOUND": "License not found.",
"error.INVALID_LICENSE_TO_ACTIVATE": "Invalid license to activate.", "error.INVALID_LICENSE_TO_ACTIVATE": "Invalid license to activate. {{reason}}",
"error.INVALID_LICENSE_UPDATE_PARAMS": "Invalid license update params. {{reason}}", "error.INVALID_LICENSE_UPDATE_PARAMS": "Invalid license update params. {{reason}}",
"error.WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE": "You cannot downgrade the workspace from team workspace because there are more than {{limit}} members that are currently active.", "error.LICENSE_EXPIRED": "License has expired.",
"error.UNSUPPORTED_CLIENT_VERSION": "Unsupported client with version [{{clientVersion}}], required version is [{{requiredVersion}}].", "error.UNSUPPORTED_CLIENT_VERSION": "Unsupported client with version [{{clientVersion}}], required version is [{{requiredVersion}}].",
"error.NOTIFICATION_NOT_FOUND": "Notification not found.", "error.NOTIFICATION_NOT_FOUND": "Notification not found.",
"error.MENTION_USER_DOC_ACCESS_DENIED": "Mentioned user can not access doc {{docId}}.", "error.MENTION_USER_DOC_ACCESS_DENIED": "Mentioned user can not access doc {{docId}}.",

View File

@@ -120,9 +120,19 @@ const defaultDevServerConfig: DevServerConfiguration = {
}, },
], ],
}, },
headers: { headers: (req): Record<string, string | string[]> => {
'Cross-Origin-Opener-Policy': 'same-origin', if (
'Cross-Origin-Embedder-Policy': 'require-corp', [/^\/api/, /^\/socket\.io/, /^\/graphql/].some(path =>
path.test(req.path)
)
) {
return {};
}
return {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
};
}, },
proxy: [ proxy: [
{ {