diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index 010de791d..7492f97a6 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -136,6 +136,8 @@ jobs: extra-flags: workspaces focus @affine/server-native - name: Build Rust uses: ./.github/actions/build-rust + env: + AFFINE_PRO_PUBLIC_KEY: ${{ secrets.AFFINE_PRO_PUBLIC_KEY }} with: target: ${{ matrix.targets.name }} package: '@affine/server-native' diff --git a/packages/backend/native/index.d.ts b/packages/backend/native/index.d.ts index 6e4498a80..2263bf3fb 100644 --- a/packages/backend/native/index.d.ts +++ b/packages/backend/native/index.d.ts @@ -4,6 +4,8 @@ export declare class Tokenizer { count(content: string, allowedSpecial?: Array | undefined | null): number } +export const AFFINE_PRO_PUBLIC_KEY: string | undefined | null + export declare function fromModelName(modelName: string): Tokenizer | null export declare function getMime(input: Uint8Array): string diff --git a/packages/backend/native/src/lib.rs b/packages/backend/native/src/lib.rs index 0f79dcb45..c6da005d2 100644 --- a/packages/backend/native/src/lib.rs +++ b/packages/backend/native/src/lib.rs @@ -52,3 +52,6 @@ pub fn merge_updates_in_apply_way(updates: Vec) -> Result { Ok(buf.into()) } + +#[napi] +pub const AFFINE_PRO_PUBLIC_KEY: Option<&'static str> = std::option_env!("AFFINE_PRO_PUBLIC_KEY",); diff --git a/packages/backend/server/migrations/20250508073308_installable_license/migration.sql b/packages/backend/server/migrations/20250508073308_installable_license/migration.sql new file mode 100644 index 000000000..b69f4c3a8 --- /dev/null +++ b/packages/backend/server/migrations/20250508073308_installable_license/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "installed_licenses" ADD COLUMN "license" BYTEA, +ADD COLUMN "variant" VARCHAR; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index e0cc426de..650bec0e6 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -767,10 +767,12 @@ model InstalledLicense { workspaceId String @unique @map("workspace_id") @db.VarChar quantity Int @default(1) @db.Integer recurring String @db.VarChar + variant String? @db.VarChar installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3) validateKey String @map("validate_key") @db.VarChar validatedAt DateTime @map("validated_at") @db.Timestamptz(3) expiredAt DateTime? @map("expired_at") @db.Timestamptz(3) + license Bytes? @db.ByteA @@map("installed_licenses") } diff --git a/packages/backend/server/src/__tests__/e2e/create-app.ts b/packages/backend/server/src/__tests__/e2e/create-app.ts index 412526b0b..61c4ba2c8 100644 --- a/packages/backend/server/src/__tests__/e2e/create-app.ts +++ b/packages/backend/server/src/__tests__/e2e/create-app.ts @@ -77,11 +77,24 @@ export class TestingApp extends NestApplication { assert(init.body, 'body is required for gql request'); assert(init.headers, 'headers is required for gql request'); - const res = await this.request('post', '/graphql') - .send(init?.body) + const req = this.request('post', '/graphql') .set('accept', 'application/json') .set(init.headers as Record); + 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)), { status: res.status, headers: res.headers, diff --git a/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired-end-at.license b/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired-end-at.license new file mode 100644 index 000000000..3690e0515 Binary files /dev/null and b/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired-end-at.license differ diff --git a/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired.license b/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired.license new file mode 100644 index 000000000..3c0d06848 Binary files /dev/null and b/packages/backend/server/src/__tests__/e2e/license/__fixtures__/expired.license differ diff --git a/packages/backend/server/src/__tests__/e2e/license/__fixtures__/valid.license b/packages/backend/server/src/__tests__/e2e/license/__fixtures__/valid.license new file mode 100644 index 000000000..84943b133 Binary files /dev/null and b/packages/backend/server/src/__tests__/e2e/license/__fixtures__/valid.license differ diff --git a/packages/backend/server/src/__tests__/e2e/license/resolver.spec.ts b/packages/backend/server/src/__tests__/e2e/license/resolver.spec.ts new file mode 100644 index 000000000..162761985 --- /dev/null +++ b/packages/backend/server/src/__tests__/e2e/license/resolver.spec.ts @@ -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.', + } + ); +}); diff --git a/packages/backend/server/src/__tests__/e2e/test.ts b/packages/backend/server/src/__tests__/e2e/test.ts index 7b6972dd3..60b6c18a0 100644 --- a/packages/backend/server/src/__tests__/e2e/test.ts +++ b/packages/backend/server/src/__tests__/e2e/test.ts @@ -1,5 +1,6 @@ import test, { registerCompletionHandler } from 'ava'; +import { Env } from '../../env'; import { type TestingApp } from './create-app'; export const e2e = test; @@ -9,3 +10,11 @@ export const app: TestingApp = globalThis.app; registerCompletionHandler(async () => { await app.close(); }); + +export function refreshEnv() { + globalThis.env = new Env(); +} + +export * from '../mocks'; +export { createApp } from './create-app'; +export type { TestingApp }; diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 780721fee..bc2941980 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -811,18 +811,17 @@ export const USER_FRIENDLY_ERRORS = { }, invalid_license_to_activate: { type: 'bad_request', - message: 'Invalid license to activate.', + args: { reason: 'string' }, + message: ({ reason }) => `Invalid license to activate. ${reason}`, }, invalid_license_update_params: { type: 'invalid_input', args: { reason: 'string' }, message: ({ reason }) => `Invalid license update params. ${reason}`, }, - workspace_members_exceed_limit_to_downgrade: { + license_expired: { type: 'bad_request', - args: { limit: 'number' }, - message: ({ limit }) => - `You cannot downgrade the workspace from team workspace because there are more than ${limit} members that are currently active.`, + message: 'License has expired.', }, // version errors diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index b6796ffe9..657f4dee7 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -914,10 +914,14 @@ export class LicenseNotFound extends UserFriendlyError { super('resource_not_found', 'license_not_found', message); } } +@ObjectType() +class InvalidLicenseToActivateDataType { + @Field() reason!: string +} export class InvalidLicenseToActivate extends UserFriendlyError { - constructor(message?: string) { - super('bad_request', 'invalid_license_to_activate', message); + constructor(args: InvalidLicenseToActivateDataType, message?: string | ((args: InvalidLicenseToActivateDataType) => string)) { + super('bad_request', 'invalid_license_to_activate', message, args); } } @ObjectType() @@ -930,14 +934,10 @@ export class InvalidLicenseUpdateParams extends UserFriendlyError { super('invalid_input', 'invalid_license_update_params', message, args); } } -@ObjectType() -class WorkspaceMembersExceedLimitToDowngradeDataType { - @Field() limit!: number -} -export class WorkspaceMembersExceedLimitToDowngrade extends UserFriendlyError { - constructor(args: WorkspaceMembersExceedLimitToDowngradeDataType, message?: string | ((args: WorkspaceMembersExceedLimitToDowngradeDataType) => string)) { - super('bad_request', 'workspace_members_exceed_limit_to_downgrade', message, args); +export class LicenseExpired extends UserFriendlyError { + constructor(message?: string) { + super('bad_request', 'license_expired', message); } } @ObjectType() @@ -1100,7 +1100,7 @@ export enum ErrorNames { LICENSE_NOT_FOUND, INVALID_LICENSE_TO_ACTIVATE, INVALID_LICENSE_UPDATE_PARAMS, - WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE, + LICENSE_EXPIRED, UNSUPPORTED_CLIENT_VERSION, NOTIFICATION_NOT_FOUND, MENTION_USER_DOC_ACCESS_DENIED, @@ -1114,5 +1114,5 @@ registerEnumType(ErrorNames, { export const ErrorDataUnionType = createUnionType({ name: 'ErrorDataUnion', 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, }); diff --git a/packages/backend/server/src/base/helpers/__tests__/crypto.spec.ts b/packages/backend/server/src/base/helpers/__tests__/crypto.spec.ts index f29f76f73..9b927a8db 100644 --- a/packages/backend/server/src/base/helpers/__tests__/crypto.spec.ts +++ b/packages/backend/server/src/base/helpers/__tests__/crypto.spec.ts @@ -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, // the encrypted result will always be the same - t.is(encrypted, 'AAAAAAAAAAAAAAAAWUDlJRhzP+SZ3avvmLcgnou+q4E11w=='); + t.is(encrypted, 'AAAAAAAAAAAAAAAAOXbR/9glITL3BcO3kPd6fGOMasSkPQ=='); t.is(decrypted, data); stub.restore(); diff --git a/packages/backend/server/src/base/helpers/crypto.ts b/packages/backend/server/src/base/helpers/crypto.ts index 7767f1c36..b8408b103 100644 --- a/packages/backend/server/src/base/helpers/crypto.ts +++ b/packages/backend/server/src/base/helpers/crypto.ts @@ -2,7 +2,6 @@ import { createCipheriv, createDecipheriv, createHash, - createPrivateKey, createPublicKey, createSign, createVerify, @@ -12,12 +11,13 @@ import { timingSafeEqual, } from 'node:crypto'; -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { hash as hashPassword, verify as verifyPassword, } from '@node-rs/argon2'; +import { AFFINE_PRO_PUBLIC_KEY } from '../../native'; import { Config } from '../config'; import { OnEvent } from '../event'; @@ -37,20 +37,7 @@ function generatePrivateKey(): string { return key.toString('utf8'); } -function readPrivateKey(privateKey: string) { - return createPrivateKey({ - key: Buffer.from(privateKey), - format: 'pem', - type: 'sec1', - }) - .export({ - format: 'pem', - type: 'pkcs8', - }) - .toString('utf8'); -} - -function readPublicKey(privateKey: string) { +function generatePublicKey(privateKey: string) { return createPublicKey({ key: Buffer.from(privateKey), }) @@ -59,7 +46,9 @@ function readPublicKey(privateKey: string) { } @Injectable() -export class CryptoHelper { +export class CryptoHelper implements OnModuleInit { + logger = new Logger(CryptoHelper.name); + keyPair!: { publicKey: 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) {} @OnEvent('config.init') @@ -84,9 +81,8 @@ export class CryptoHelper { } private setup() { - const key = this.config.crypto.privateKey || generatePrivateKey(); - const privateKey = readPrivateKey(key); - const publicKey = readPublicKey(key); + const privateKey = this.config.crypto.privateKey || generatePrivateKey(); + const publicKey = generatePublicKey(privateKey); this.keyPair = { publicKey: Buffer.from(publicKey), @@ -187,4 +183,18 @@ export class CryptoHelper { sha256(data: string) { 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; + } } diff --git a/packages/backend/server/src/core/workspaces/service.ts b/packages/backend/server/src/core/workspaces/service.ts index 0dbd03381..37c2799be 100644 --- a/packages/backend/server/src/core/workspaces/service.ts +++ b/packages/backend/server/src/core/workspaces/service.ts @@ -257,6 +257,11 @@ export class WorkspaceService { workspaceId, quantity ); + + if (!pendings.length) { + return; + } + const owner = await this.models.workspaceUser.getOwner(workspaceId); for (const member of pendings) { try { diff --git a/packages/backend/server/src/env.ts b/packages/backend/server/src/env.ts index 39ddce4a6..0d55b85e7 100644 --- a/packages/backend/server/src/env.ts +++ b/packages/backend/server/src/env.ts @@ -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 pkg from '../package.json' with { type: 'json' }; @@ -9,6 +10,8 @@ declare global { var env: Readonly; // oxlint-disable-next-line no-var var readEnv: (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; }; +globalThis.CUSTOM_CONFIG_PATH = join(homedir(), '.affine/config'); globalThis.readEnv = function readEnv( env: string, defaultValue: T, diff --git a/packages/backend/server/src/models/workspace-user.ts b/packages/backend/server/src/models/workspace-user.ts index d5b47225f..1a6a6499f 100644 --- a/packages/backend/server/src/models/workspace-user.ts +++ b/packages/backend/server/src/models/workspace-user.ts @@ -427,6 +427,6 @@ export class WorkspaceUserModel extends BaseModel { data: { status: WorkspaceMemberStatus.NeedMoreSeat }, }); - return groups.Email; + return groups.Email ?? []; } } diff --git a/packages/backend/server/src/native.ts b/packages/backend/server/src/native.ts index 0bb8ebefa..d47bd0df7 100644 --- a/packages/backend/server/src/native.ts +++ b/packages/backend/server/src/native.ts @@ -21,3 +21,4 @@ export const parseDoc = serverNativeModule.parseDoc; export const Tokenizer = serverNativeModule.Tokenizer; export const fromModelName = serverNativeModule.fromModelName; export const htmlSanitize = serverNativeModule.htmlSanitize; +export const AFFINE_PRO_PUBLIC_KEY = serverNativeModule.AFFINE_PRO_PUBLIC_KEY; diff --git a/packages/backend/server/src/plugins/license/resolver.ts b/packages/backend/server/src/plugins/license/resolver.ts index 75ebb01e2..0108d401d 100644 --- a/packages/backend/server/src/plugins/license/resolver.ts +++ b/packages/backend/server/src/plugins/license/resolver.ts @@ -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; + } } diff --git a/packages/backend/server/src/plugins/license/service.ts b/packages/backend/server/src/plugins/license/service.ts index 0adf33e1e..cc6fc74ad 100644 --- a/packages/backend/server/src/plugins/license/service.ts +++ b/packages/backend/server/src/plugins/license/service.ts @@ -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( `/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.', + }); + } + } } diff --git a/packages/backend/server/src/plugins/payment/license/controller.ts b/packages/backend/server/src/plugins/payment/license/controller.ts index 80a8b95cc..652f75422 100644 --- a/packages/backend/server/src/plugins/payment/license/controller.ts +++ b/packages/backend/server/src/plugins/payment/license/controller.ts @@ -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(); diff --git a/packages/backend/server/src/prelude.ts b/packages/backend/server/src/prelude.ts index a05f98f5f..475feb82d 100644 --- a/packages/backend/server/src/prelude.ts +++ b/packages/backend/server/src/prelude.ts @@ -1,15 +1,12 @@ import 'reflect-metadata'; import { existsSync, readFileSync } from 'node:fs'; -import { homedir } from 'node:os'; import { join } from 'node:path'; import { config } from 'dotenv'; import { createGlobalEnv } from './env'; -const CUSTOM_CONFIG_PATH = `${homedir()}/.affine/config`; - function loadPrivateKey() { const file = join(CUSTOM_CONFIG_PATH, 'private.key'); if (!process.env.AFFINE_PRIVATE_KEY && existsSync(file)) { diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index e705c710c..288cf8688 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -464,7 +464,7 @@ type EditorType { 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 { ACCESS_DENIED @@ -541,6 +541,7 @@ enum ErrorNames { INVALID_PASSWORD_LENGTH INVALID_RUNTIME_CONFIG_TYPE INVALID_SUBSCRIPTION_PARAMETERS + LICENSE_EXPIRED LICENSE_NOT_FOUND LICENSE_REVEALED LINK_EXPIRED @@ -589,7 +590,6 @@ enum ErrorNames { WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION WORKSPACE_LICENSE_ALREADY_EXISTS - WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE WORKSPACE_PERMISSION_NOT_FOUND WRONG_SIGN_IN_CREDENTIALS WRONG_SIGN_IN_METHOD @@ -671,6 +671,10 @@ type InvalidHistoryTimestampDataType { timestamp: String! } +type InvalidLicenseToActivateDataType { + reason: String! +} + type InvalidLicenseUpdateParamsDataType { reason: String! } @@ -880,6 +884,7 @@ type License { quantity: Int! recurring: SubscriptionRecurring! validatedAt: DateTime! + variant: SubscriptionVariant } type LimitedUserType { @@ -1034,6 +1039,7 @@ type Mutation { """import users""" 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") inviteMembers(emails: [String!]!, workspaceId: String!): [InviteResult!]! 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 } -type WorkspaceMembersExceedLimitToDowngradeDataType { - limit: Int! -} - type WorkspacePermissionNotFoundDataType { spaceId: String! } diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 5524ef6a5..c23ad056a 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -15,17 +15,14 @@ export const passwordLimitsFragment = `fragment PasswordLimits on PasswordLimits minLength maxLength }`; -export const activateLicenseMutation = { - id: 'activateLicenseMutation' as const, - op: 'activateLicense', - query: `mutation activateLicense($workspaceId: String!, $license: String!) { - activateLicense(workspaceId: $workspaceId, license: $license) { - installedAt - validatedAt - } -}`, -}; - +export const licenseFragment = `fragment license on License { + expiredAt + installedAt + quantity + recurring + validatedAt + variant +}`; export const adminServerConfigQuery = { id: 'adminServerConfigQuery' as const, 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 = { id: 'deleteAccountMutation' as const, op: 'deleteAccount', @@ -1065,22 +1054,6 @@ export const getIsOwnerQuery = { 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 = { id: 'getMemberCountByWorkspaceIdQuery' as const, 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 = { id: 'listNotificationsQuery' as const, op: 'listNotifications', diff --git a/packages/common/graphql/src/graphql/activate-license.gql b/packages/common/graphql/src/graphql/license/activate-license.gql similarity index 77% rename from packages/common/graphql/src/graphql/activate-license.gql rename to packages/common/graphql/src/graphql/license/activate-license.gql index 3434fa52b..d56cc280d 100644 --- a/packages/common/graphql/src/graphql/activate-license.gql +++ b/packages/common/graphql/src/graphql/license/activate-license.gql @@ -1,6 +1,7 @@ +#import './license.gql' + mutation activateLicense($workspaceId: String!, $license: String!) { activateLicense(workspaceId: $workspaceId, license: $license) { - installedAt - validatedAt + ...license } } \ No newline at end of file diff --git a/packages/common/graphql/src/graphql/deactivate-license.gql b/packages/common/graphql/src/graphql/license/deactivate-license.gql similarity index 100% rename from packages/common/graphql/src/graphql/deactivate-license.gql rename to packages/common/graphql/src/graphql/license/deactivate-license.gql diff --git a/packages/common/graphql/src/graphql/get-license.gql b/packages/common/graphql/src/graphql/license/get-license.gql similarity index 54% rename from packages/common/graphql/src/graphql/get-license.gql rename to packages/common/graphql/src/graphql/license/get-license.gql index cc176c676..4a7eee25f 100644 --- a/packages/common/graphql/src/graphql/get-license.gql +++ b/packages/common/graphql/src/graphql/license/get-license.gql @@ -1,11 +1,9 @@ +#import './license.gql' + query getLicense($workspaceId: String!) { workspace(id: $workspaceId) { license { - expiredAt - installedAt - quantity - recurring - validatedAt + ...license } } } diff --git a/packages/common/graphql/src/graphql/license/install-license.gql b/packages/common/graphql/src/graphql/license/install-license.gql new file mode 100644 index 000000000..b58f429e5 --- /dev/null +++ b/packages/common/graphql/src/graphql/license/install-license.gql @@ -0,0 +1,7 @@ +#import './license.gql' + +mutation installLicense($workspaceId: String!, $license: Upload!) { + installLicense(workspaceId: $workspaceId, license: $license) { + ...license + } +} \ No newline at end of file diff --git a/packages/common/graphql/src/graphql/license/license.gql b/packages/common/graphql/src/graphql/license/license.gql new file mode 100644 index 000000000..7f1f5efe3 --- /dev/null +++ b/packages/common/graphql/src/graphql/license/license.gql @@ -0,0 +1,8 @@ +fragment license on License { + expiredAt + installedAt + quantity + recurring + validatedAt + variant +} \ No newline at end of file diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index add6ebba7..8444ef8a7 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -598,6 +598,7 @@ export type ErrorDataUnion = | HttpRequestErrorDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType + | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidPasswordLengthDataType @@ -622,7 +623,6 @@ export type ErrorDataUnion = | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType - | WorkspaceMembersExceedLimitToDowngradeDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType; @@ -701,6 +701,7 @@ export 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_EXPIRED', LICENSE_NOT_FOUND = 'LICENSE_NOT_FOUND', LICENSE_REVEALED = 'LICENSE_REVEALED', 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_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_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', @@ -837,6 +837,11 @@ export interface InvalidHistoryTimestampDataType { timestamp: Scalars['String']['output']; } +export interface InvalidLicenseToActivateDataType { + __typename?: 'InvalidLicenseToActivateDataType'; + reason: Scalars['String']['output']; +} + export interface InvalidLicenseUpdateParamsDataType { __typename?: 'InvalidLicenseUpdateParamsDataType'; reason: Scalars['String']['output']; @@ -1029,6 +1034,7 @@ export interface License { quantity: Scalars['Int']['output']; recurring: SubscriptionRecurring; validatedAt: Scalars['DateTime']['output']; + variant: Maybe; } export interface LimitedUserType { @@ -1166,6 +1172,7 @@ export interface Mutation { grantMember: Scalars['Boolean']['output']; /** import users */ importUsers: Array; + installLicense: License; /** @deprecated use [inviteMembers] instead */ inviteBatch: Array; inviteMembers: Array; @@ -1391,6 +1398,11 @@ export interface MutationImportUsersArgs { input: ImportUsersInput; } +export interface MutationInstallLicenseArgs { + license: Scalars['Upload']['input']; + workspaceId: Scalars['String']['input']; +} + export interface MutationInviteBatchArgs { emails: Array; sendInviteMail?: InputMaybe; @@ -2291,11 +2303,6 @@ export enum WorkspaceMemberStatus { UnderReview = 'UnderReview', } -export interface WorkspaceMembersExceedLimitToDowngradeDataType { - __typename?: 'WorkspaceMembersExceedLimitToDowngradeDataType'; - limit: Scalars['Int']['output']; -} - export interface WorkspacePermissionNotFoundDataType { __typename?: 'WorkspacePermissionNotFoundDataType'; spaceId: Scalars['String']['output']; @@ -2474,20 +2481,6 @@ export interface TokenType { 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 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 DeleteAccountMutation = { @@ -3693,25 +3677,6 @@ export type GetIsOwnerQueryVariables = Exact<{ 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<{ workspaceId: Scalars['String']['input']; }>; @@ -4059,6 +4024,81 @@ export type LeaveWorkspaceMutation = { 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<{ pagination: PaginationInput; }>; @@ -4795,11 +4835,6 @@ export type Queries = variables: GetIsOwnerQueryVariables; response: GetIsOwnerQuery; } - | { - name: 'getLicenseQuery'; - variables: GetLicenseQueryVariables; - response: GetLicenseQuery; - } | { name: 'getMemberCountByWorkspaceIdQuery'; variables: GetMemberCountByWorkspaceIdQueryVariables; @@ -4895,6 +4930,11 @@ export type Queries = variables: InvoicesQueryVariables; response: InvoicesQuery; } + | { + name: 'getLicenseQuery'; + variables: GetLicenseQueryVariables; + response: GetLicenseQuery; + } | { name: 'listNotificationsQuery'; variables: ListNotificationsQueryVariables; @@ -4952,11 +4992,6 @@ export type Queries = }; export type Mutations = - | { - name: 'activateLicenseMutation'; - variables: ActivateLicenseMutationVariables; - response: ActivateLicenseMutation; - } | { name: 'createChangePasswordUrlMutation'; variables: CreateChangePasswordUrlMutationVariables; @@ -5162,11 +5197,6 @@ export type Mutations = variables: CreateWorkspaceMutationVariables; response: CreateWorkspaceMutation; } - | { - name: 'deactivateLicenseMutation'; - variables: DeactivateLicenseMutationVariables; - response: DeactivateLicenseMutation; - } | { name: 'deleteAccountMutation'; variables: DeleteAccountMutationVariables; @@ -5192,6 +5222,21 @@ export type Mutations = variables: LeaveWorkspaceMutationVariables; response: LeaveWorkspaceMutation; } + | { + name: 'activateLicenseMutation'; + variables: ActivateLicenseMutationVariables; + response: ActivateLicenseMutation; + } + | { + name: 'deactivateLicenseMutation'; + variables: DeactivateLicenseMutationVariables; + response: DeactivateLicenseMutation; + } + | { + name: 'installLicenseMutation'; + variables: InstallLicenseMutationVariables; + response: InstallLicenseMutation; + } | { name: 'mentionUserMutation'; variables: MentionUserMutationVariables; diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 9f72efab9..513fbf6e3 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -8441,9 +8441,11 @@ export function useAFFiNEI18N(): { */ ["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}}` */ @@ -8451,11 +8453,9 @@ export function useAFFiNEI18N(): { readonly reason: 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: { - readonly limit: string; - }): string; + ["error.LICENSE_EXPIRED"](): string; /** * `Unsupported client with version [{{clientVersion}}], required version is [{{requiredVersion}}].` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index b1ac55ba6..5d84a5b29 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2087,9 +2087,9 @@ "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.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.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.NOTIFICATION_NOT_FOUND": "Notification not found.", "error.MENTION_USER_DOC_ACCESS_DENIED": "Mentioned user can not access doc {{docId}}.", diff --git a/tools/cli/src/bundle.ts b/tools/cli/src/bundle.ts index a8d375fba..a595c97a8 100644 --- a/tools/cli/src/bundle.ts +++ b/tools/cli/src/bundle.ts @@ -120,9 +120,19 @@ const defaultDevServerConfig: DevServerConfiguration = { }, ], }, - headers: { - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', + headers: (req): Record => { + if ( + [/^\/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: [ {