mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 00:43:40 +10:00
feat: add license integration (#2346)
Changes: - Adds integration for the license server. - Prevent adding flags that the instance is not allowed to add
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import type { TLicenseClaim } from '../../types/license';
|
||||
import {
|
||||
LICENSE_FILE_NAME,
|
||||
type TCachedLicense,
|
||||
type TLicenseResponse,
|
||||
ZCachedLicenseSchema,
|
||||
ZLicenseResponseSchema,
|
||||
} from '../../types/license';
|
||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '../../types/subscription';
|
||||
import { env } from '../../utils/env';
|
||||
|
||||
const LICENSE_KEY = env('NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY');
|
||||
const LICENSE_SERVER_URL =
|
||||
env('INTERNAL_OVERRIDE_LICENSE_SERVER_URL') || 'https://license.documenso.com';
|
||||
|
||||
export class LicenseClient {
|
||||
private static instance: LicenseClient | null = null;
|
||||
|
||||
/**
|
||||
* We cache the license in memory incase there is permission issues with
|
||||
* retrieving the license from the local file system.
|
||||
*/
|
||||
private cachedLicense: TCachedLicense | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Start the license client.
|
||||
*
|
||||
* This will ping the license server with the configured license key and store
|
||||
* the response locally in a JSON file.
|
||||
*/
|
||||
public static async start(): Promise<void> {
|
||||
if (LicenseClient.instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = new LicenseClient();
|
||||
|
||||
LicenseClient.instance = instance;
|
||||
|
||||
try {
|
||||
await instance.initialize();
|
||||
} catch (err) {
|
||||
// Do nothing.
|
||||
console.error('[License] Failed to verify license:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current license client instance.
|
||||
*/
|
||||
public static getInstance(): LicenseClient | null {
|
||||
return LicenseClient.instance;
|
||||
}
|
||||
|
||||
public async getCachedLicense(): Promise<TCachedLicense | null> {
|
||||
if (this.cachedLicense) {
|
||||
return this.cachedLicense;
|
||||
}
|
||||
|
||||
const localLicenseFile = await this.loadFromFile();
|
||||
|
||||
return localLicenseFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force resync the license from the license server.
|
||||
*
|
||||
* This will re-ping the license server and update the cached license file.
|
||||
*/
|
||||
public async resync(): Promise<void> {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
console.log('[License] Checking license with server...');
|
||||
|
||||
const cachedLicense = await this.loadFromFile();
|
||||
|
||||
if (cachedLicense) {
|
||||
this.cachedLicense = cachedLicense;
|
||||
}
|
||||
|
||||
const response = await this.pingLicenseServer();
|
||||
|
||||
// If server is not responding, or erroring, use the cached license.
|
||||
if (!response) {
|
||||
console.warn('[License] License server not responding, using cached license.');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedFlags = response?.data?.flags || {};
|
||||
|
||||
// Check for unauthorized flag usage
|
||||
const unauthorizedFlagUsage = await this.checkUnauthorizedFlagUsage(allowedFlags);
|
||||
|
||||
if (unauthorizedFlagUsage) {
|
||||
console.warn('[License] Found unauthorized flag usage.');
|
||||
}
|
||||
|
||||
let status: TCachedLicense['derivedStatus'] = 'NOT_FOUND';
|
||||
|
||||
if (response?.data?.status) {
|
||||
status = response.data.status;
|
||||
}
|
||||
|
||||
if (unauthorizedFlagUsage) {
|
||||
status = 'UNAUTHORIZED';
|
||||
}
|
||||
|
||||
const data: TCachedLicense = {
|
||||
lastChecked: new Date().toISOString(),
|
||||
license: response?.data || null,
|
||||
requestedLicenseKey: LICENSE_KEY,
|
||||
unauthorizedFlagUsage,
|
||||
derivedStatus: status,
|
||||
};
|
||||
|
||||
this.cachedLicense = data;
|
||||
await this.saveToFile(data);
|
||||
|
||||
console.log('[License] License check completed successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ping the license server to get the license response.
|
||||
*
|
||||
* If license not found returns null.
|
||||
*/
|
||||
private async pingLicenseServer(): Promise<TLicenseResponse | null> {
|
||||
if (!LICENSE_KEY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = new URL('api/license', LICENSE_SERVER_URL).toString();
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ license: LICENSE_KEY }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`License server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return ZLicenseResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
private async saveToFile(data: TCachedLicense): Promise<void> {
|
||||
const licenseFilePath = path.join(process.cwd(), LICENSE_FILE_NAME);
|
||||
|
||||
try {
|
||||
await fs.writeFile(licenseFilePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('[License] Failed to save license file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFromFile(): Promise<TCachedLicense | null> {
|
||||
const licenseFilePath = path.join(process.cwd(), LICENSE_FILE_NAME);
|
||||
|
||||
try {
|
||||
const fileContents = await fs.readFile(licenseFilePath, 'utf-8');
|
||||
|
||||
return ZCachedLicenseSchema.parse(JSON.parse(fileContents));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any organisation claims are using flags that are not permitted by the current license.
|
||||
*/
|
||||
private async checkUnauthorizedFlagUsage(licenseFlags: Partial<TLicenseClaim>): Promise<boolean> {
|
||||
// Get flags that are NOT permitted by the license by subtracting the allowed flags from the license flags.
|
||||
const disallowedFlags = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).filter(
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
(flag) => flag.isEnterprise && !licenseFlags[flag.key as keyof TLicenseClaim],
|
||||
);
|
||||
|
||||
let unauthorizedFlagUsage = false;
|
||||
|
||||
if (IS_BILLING_ENABLED() && !licenseFlags.billing) {
|
||||
unauthorizedFlagUsage = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const organisationWithUnauthorizedFlags = await prisma.organisationClaim.findFirst({
|
||||
where: {
|
||||
OR: disallowedFlags.map((flag) => ({
|
||||
flags: {
|
||||
path: [flag.key],
|
||||
equals: true,
|
||||
},
|
||||
})),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
organisation: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
flags: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (organisationWithUnauthorizedFlags) {
|
||||
unauthorizedFlagUsage = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[License] Failed to check unauthorized flag usage:', error);
|
||||
}
|
||||
|
||||
return unauthorizedFlagUsage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Note: Keep this in sync with the Documenso License Server schemas.
|
||||
*/
|
||||
export const ZLicenseClaimSchema = z.object({
|
||||
emailDomains: z.boolean().optional(),
|
||||
embedAuthoring: z.boolean().optional(),
|
||||
embedAuthoringWhiteLabel: z.boolean().optional(),
|
||||
cfr21: z.boolean().optional(),
|
||||
authenticationPortal: z.boolean().optional(),
|
||||
billing: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Note: Keep this in sync with the Documenso License Server schemas.
|
||||
*/
|
||||
export const ZLicenseRequestSchema = z.object({
|
||||
license: z.string().min(1, 'License key is required'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Note: Keep this in sync with the Documenso License Server schemas.
|
||||
*/
|
||||
export const ZLicenseResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
// Note that this is nullable, null means license was not found.
|
||||
data: z
|
||||
.object({
|
||||
status: z.enum(['ACTIVE', 'EXPIRED', 'PAST_DUE']),
|
||||
createdAt: z.coerce.date(),
|
||||
name: z.string(),
|
||||
periodEnd: z.coerce.date(),
|
||||
cancelAtPeriodEnd: z.boolean(),
|
||||
licenseKey: z.string(),
|
||||
flags: ZLicenseClaimSchema,
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type TLicenseClaim = z.infer<typeof ZLicenseClaimSchema>;
|
||||
export type TLicenseRequest = z.infer<typeof ZLicenseRequestSchema>;
|
||||
export type TLicenseResponse = z.infer<typeof ZLicenseResponseSchema>;
|
||||
|
||||
/**
|
||||
* Schema for the cached license data stored in the file.
|
||||
*/
|
||||
export const ZCachedLicenseSchema = z.object({
|
||||
/**
|
||||
* The last time the license was synced.
|
||||
*/
|
||||
lastChecked: z.string(),
|
||||
|
||||
/**
|
||||
* The raw license response from the license server.
|
||||
*/
|
||||
license: ZLicenseResponseSchema.shape.data,
|
||||
|
||||
/**
|
||||
* The license key that is currently stored on the system environment variable.
|
||||
*/
|
||||
requestedLicenseKey: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Whether the current license has unauthorized flag usage.
|
||||
*/
|
||||
unauthorizedFlagUsage: z.boolean(),
|
||||
|
||||
/**
|
||||
* The derived status of the license. This is calculated based on the license response and the unauthorized flag usage.
|
||||
*/
|
||||
derivedStatus: z.enum([
|
||||
'UNAUTHORIZED', // Unauthorized flag usage detected, overrides everything except PAST_DUE since that's a grace period.
|
||||
'ACTIVE', // License is active and everything is good.
|
||||
'EXPIRED', // License is expired and there is no unauthorized flag usage.
|
||||
'PAST_DUE', // License is past due.
|
||||
'NOT_FOUND', // Requested license key is not found.
|
||||
]),
|
||||
});
|
||||
|
||||
export type TCachedLicense = z.infer<typeof ZCachedLicenseSchema>;
|
||||
|
||||
export const LICENSE_FILE_NAME = '.documenso-license.json';
|
||||
@@ -42,6 +42,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
{
|
||||
label: string;
|
||||
key: keyof TClaimFlags;
|
||||
isEnterprise?: boolean;
|
||||
}
|
||||
> = {
|
||||
unlimitedDocuments: {
|
||||
@@ -59,10 +60,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
emailDomains: {
|
||||
key: 'emailDomains',
|
||||
label: 'Email domains',
|
||||
isEnterprise: true,
|
||||
},
|
||||
embedAuthoring: {
|
||||
key: 'embedAuthoring',
|
||||
label: 'Embed authoring',
|
||||
isEnterprise: true,
|
||||
},
|
||||
embedSigning: {
|
||||
key: 'embedSigning',
|
||||
@@ -71,6 +74,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
embedAuthoringWhiteLabel: {
|
||||
key: 'embedAuthoringWhiteLabel',
|
||||
label: 'White label for embed authoring',
|
||||
isEnterprise: true,
|
||||
},
|
||||
embedSigningWhiteLabel: {
|
||||
key: 'embedSigningWhiteLabel',
|
||||
@@ -79,10 +83,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
cfr21: {
|
||||
key: 'cfr21',
|
||||
label: '21 CFR',
|
||||
isEnterprise: true,
|
||||
},
|
||||
authenticationPortal: {
|
||||
key: 'authenticationPortal',
|
||||
label: 'Authentication portal',
|
||||
isEnterprise: true,
|
||||
},
|
||||
allowLegacyEnvelopes: {
|
||||
key: 'allowLegacyEnvelopes',
|
||||
|
||||
Reference in New Issue
Block a user