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'; declare global { // eslint-disable-next-line no-var var __documenso_license_client__: LicenseClient | undefined; } export class LicenseClient { /** * 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. * * Uses globalThis to store the singleton instance so that it's shared across * different bundles (e.g. Hono and Remix) at runtime. */ public static async start(): Promise { if (globalThis.__documenso_license_client__) { return; } const instance = new LicenseClient(); globalThis.__documenso_license_client__ = instance; try { await instance.initialize(); } catch (err) { // Do nothing. console.error('[License] Failed to verify license:', err); } } /** * Get the current license client instance. * * Returns the shared instance from globalThis, ensuring both Hono and Remix * bundles access the same instance. */ public static getInstance(): LicenseClient | null { return globalThis.__documenso_license_client__ ?? null; } public async getCachedLicense(): Promise { 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 { await this.initialize(); } private async initialize(): Promise { console.log('[License] Checking license with server...'); const cachedLicense = await this.loadFromFile(); if (cachedLicense) { this.cachedLicense = cachedLicense; } let response: TLicenseResponse | null = null; try { response = await this.pingLicenseServer(); } catch (err) { // If server is not responding, or erroring, use the cached license. console.warn('[License] License server not responding, using cached license.'); console.error(err); 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.'); console.log(`[License] Unauthorized Flag Usage: ${unauthorizedFlagUsage ? 'Yes' : 'No'}`); console.log(`[License] Derived Status: ${status}`); console.log(`[License] Status: ${response?.data?.status}`); console.log(`[License] Flags: ${JSON.stringify(allowedFlags)}`); } /** * Ping the license server to get the license response. * * If license not found returns null. */ private async pingLicenseServer(): Promise { 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 { 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 { 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): Promise { // 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; } }