import { atom } from '@tldraw/state' import { publishDates, version } from '../../version' import { getDefaultCdnBaseUrl } from '../utils/assets' import { importPublicKey, str2ab } from '../utils/licensing' const GRACE_PERIOD_DAYS = 30 export const FLAGS = { // -- MUTUALLY EXCLUSIVE FLAGS -- // Annual means the license expires after a time period, usually 1 year. ANNUAL_LICENSE: 1, // Perpetual means the license never expires up to the max supported version. PERPETUAL_LICENSE: 1 << 1, // -- ADDITIVE FLAGS -- // Internal means the license is for internal use only. INTERNAL_LICENSE: 1 << 2, // Watermark means the product is watermarked. WITH_WATERMARK: 1 << 3, // Evaluation means the license is for evaluation purposes only. EVALUATION_LICENSE: 1 << 4, // Native means the license is for native apps which switches // on special-case logic. NATIVE_LICENSE: 1 << 5, } const HIGHEST_FLAG = Math.max(...Object.values(FLAGS)) export const PROPERTIES = { ID: 0, HOSTS: 1, FLAGS: 2, EXPIRY_DATE: 3, } const NUMBER_OF_KNOWN_PROPERTIES = Object.keys(PROPERTIES).length const LICENSE_EMAIL = 'sales@tldraw.com' const WATERMARK_TRACK_SRC = `${getDefaultCdnBaseUrl()}/watermarks/watermark-track.svg` /** @internal */ export interface LicenseInfo { id: string hosts: string[] flags: number expiryDate: string } /** @internal */ export type LicenseState = | 'pending' // License validation is in progress | 'licensed' // License is valid and active (no restrictions) | 'licensed-with-watermark' // License is valid but shows watermark (evaluation licenses, WITH_WATERMARK licenses) | 'unlicensed' // No valid license found or license is invalid (development) | 'unlicensed-production' // No valid license in production deployment (missing, invalid, or wrong domain) | 'expired' // License has been expired (30 days past expiration for regular licenses, immediately for evaluation licenses) /** @internal */ export type InvalidLicenseReason = | 'invalid-license-key' | 'no-key-provided' | 'has-key-development-mode' /** @internal */ export type LicenseFromKeyResult = InvalidLicenseKeyResult | ValidLicenseKeyResult /** @internal */ export interface InvalidLicenseKeyResult { isLicenseParseable: false reason: InvalidLicenseReason } /** @internal */ export interface ValidLicenseKeyResult { isLicenseParseable: true license: LicenseInfo isDevelopment: boolean isDomainValid: boolean expiryDate: Date isAnnualLicense: boolean isAnnualLicenseExpired: boolean isPerpetualLicense: boolean isPerpetualLicenseExpired: boolean isInternalLicense: boolean isNativeLicense: boolean isLicensedWithWatermark: boolean isEvaluationLicense: boolean isEvaluationLicenseExpired: boolean daysSinceExpiry: number } /** @internal */ export type TrackType = 'unlicensed' | 'with_watermark' | 'evaluation' | null /** @internal */ export class LicenseManager { private publicKey = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHJh0uUfxHtCGyerXmmatE368Hd9rI6LH9oPDQihnaCryRFWEVeOvf9U/SPbyxX74LFyJs5tYeAHq5Nc0Ax25LQ' public isDevelopment: boolean public isTest: boolean public isCryptoAvailable: boolean state = atom('license state', 'pending') public verbose = true constructor(licenseKey: string | undefined, testPublicKey?: string) { this.isTest = process.env.NODE_ENV === 'test' this.isDevelopment = this.getIsDevelopment() this.publicKey = testPublicKey || this.publicKey this.isCryptoAvailable = !!crypto.subtle this.getLicenseFromKey(licenseKey) .then((result) => { const licenseState = getLicenseState( result, (messages: string[]) => this.outputMessages(messages), this.isDevelopment ) this.maybeTrack(result, licenseState) this.state.set(licenseState) }) .catch((error) => { console.error('License validation failed:', error) this.state.set('unlicensed') }) } private getIsDevelopment() { // If we are using https on a non-localhost domain we assume it's a production env and a development one otherwise return ( !['https:', 'vscode-webview:'].includes(window.location.protocol) || window.location.hostname === 'localhost' || process.env.NODE_ENV !== 'production' ) } private getTrackType(result: LicenseFromKeyResult, licenseState: LicenseState): TrackType { // Track watermark for unlicensed production deployments if (licenseState === 'unlicensed-production') { return 'unlicensed' } if (this.isDevelopment) { return null } if (!result.isLicenseParseable) { return null } // Track evaluation licenses (for analytics, even though no watermark is shown) if (result.isEvaluationLicense) { return 'evaluation' } // Track licenses that show watermarks if (licenseState === 'licensed-with-watermark') { return 'with_watermark' } return null } private maybeTrack(result: LicenseFromKeyResult, licenseState: LicenseState): void { const trackType = this.getTrackType(result, licenseState) if (!trackType) { return } const url = new URL(WATERMARK_TRACK_SRC) url.searchParams.set('version', version) url.searchParams.set('license_type', trackType) if ('license' in result) { url.searchParams.set('license_id', result.license.id) const sku = this.isFlagEnabled(result.license.flags, FLAGS.EVALUATION_LICENSE) ? 'evaluation' : this.isFlagEnabled(result.license.flags, FLAGS.ANNUAL_LICENSE) ? 'annual' : this.isFlagEnabled(result.license.flags, FLAGS.PERPETUAL_LICENSE) ? 'perpetual' : 'unknown' url.searchParams.set('sku', sku) } url.searchParams.set('url', window.location.href) if (process.env.NODE_ENV) { url.searchParams.set('environment', process.env.NODE_ENV) } // eslint-disable-next-line no-restricted-globals fetch(url.toString()) } private async extractLicenseKey(licenseKey: string): Promise { const [data, signature] = licenseKey.split('.') const [prefix, encodedData] = data.split('/') if (!prefix.startsWith('tldraw-')) { throw new Error(`Unsupported prefix '${prefix}'`) } const publicCryptoKey = await importPublicKey(this.publicKey) let isVerified try { isVerified = await crypto.subtle.verify( { name: 'ECDSA', hash: { name: 'SHA-256' }, }, publicCryptoKey, new Uint8Array(str2ab(atob(signature))), new Uint8Array(str2ab(atob(encodedData))) ) } catch (e) { console.error(e) throw new Error('Could not perform signature validation') } if (!isVerified) { throw new Error('Invalid signature') } let decodedData: any try { decodedData = JSON.parse(atob(encodedData)) } catch { throw new Error('Could not parse object') } if (decodedData.length > NUMBER_OF_KNOWN_PROPERTIES) { this.outputMessages([ 'License key contains some unknown properties.', 'You may want to update tldraw packages to a newer version to get access to new functionality.', ]) } return { id: decodedData[PROPERTIES.ID], hosts: decodedData[PROPERTIES.HOSTS], flags: decodedData[PROPERTIES.FLAGS], expiryDate: decodedData[PROPERTIES.EXPIRY_DATE], } } async getLicenseFromKey(licenseKey?: string): Promise { if (!licenseKey) { if (!this.isDevelopment) { this.outputNoLicenseKeyProvided() } return { isLicenseParseable: false, reason: 'no-key-provided' } } if (this.isDevelopment && !this.isCryptoAvailable) { if (this.verbose) { // eslint-disable-next-line no-console console.log( 'tldraw: you seem to be in a development environment that does not support crypto. License not verified.' ) // eslint-disable-next-line no-console console.log('You should check that this works in production separately.') } // We can't parse the license if we are in development mode since crypto // is not available on http return { isLicenseParseable: false, reason: 'has-key-development-mode' } } // Borrowed idea from AG Grid: // Copying from various sources (like PDFs) can include zero-width characters. // This helps makes sure the key validation doesn't fail. let cleanedLicenseKey = licenseKey.replace(/[\u200B-\u200D\uFEFF]/g, '') cleanedLicenseKey = cleanedLicenseKey.replace(/\r?\n|\r/g, '') try { const licenseInfo = await this.extractLicenseKey(cleanedLicenseKey) const expiryDate = new Date(licenseInfo.expiryDate) const isAnnualLicense = this.isFlagEnabled(licenseInfo.flags, FLAGS.ANNUAL_LICENSE) const isPerpetualLicense = this.isFlagEnabled(licenseInfo.flags, FLAGS.PERPETUAL_LICENSE) const isEvaluationLicense = this.isFlagEnabled(licenseInfo.flags, FLAGS.EVALUATION_LICENSE) const daysSinceExpiry = this.getDaysSinceExpiry(expiryDate) const result: ValidLicenseKeyResult = { license: licenseInfo, isLicenseParseable: true, isDevelopment: this.isDevelopment, isDomainValid: this.isDomainValid(licenseInfo), expiryDate, isAnnualLicense, isAnnualLicenseExpired: isAnnualLicense && this.isAnnualLicenseExpired(expiryDate), isPerpetualLicense, isPerpetualLicenseExpired: isPerpetualLicense && this.isPerpetualLicenseExpired(expiryDate), isInternalLicense: this.isFlagEnabled(licenseInfo.flags, FLAGS.INTERNAL_LICENSE), isNativeLicense: this.isNativeLicense(licenseInfo), isLicensedWithWatermark: this.isFlagEnabled(licenseInfo.flags, FLAGS.WITH_WATERMARK), isEvaluationLicense, isEvaluationLicenseExpired: isEvaluationLicense && this.isEvaluationLicenseExpired(expiryDate), daysSinceExpiry, } this.outputLicenseInfoIfNeeded(result) return result } catch (e: any) { this.outputInvalidLicenseKey(e.message) // If the license can't be parsed, it's invalid return { isLicenseParseable: false, reason: 'invalid-license-key' } } } private isDomainValid(licenseInfo: LicenseInfo) { const currentHostname = window.location.hostname.toLowerCase() return licenseInfo.hosts.some((host) => { const normalizedHostOrUrlRegex = host.toLowerCase().trim() // Allow the domain if listed and www variations, 'example.com' allows 'example.com' and 'www.example.com' if ( normalizedHostOrUrlRegex === currentHostname || `www.${normalizedHostOrUrlRegex}` === currentHostname || normalizedHostOrUrlRegex === `www.${currentHostname}` ) { return true } // If host is '*', we allow all domains. if (host === '*') { // All domains allowed. return true } // Native license support // In this case, `normalizedHost` is actually a protocol, e.g. `app-bundle:` if (this.isNativeLicense(licenseInfo)) { return new RegExp(normalizedHostOrUrlRegex).test(window.location.href) } // Glob testing, we only support '*.somedomain.com' right now. if (host.includes('*')) { const globToRegex = new RegExp(host.replace(/\*/g, '.*?')) return globToRegex.test(currentHostname) || globToRegex.test(`www.${currentHostname}`) } // VSCode support if (window.location.protocol === 'vscode-webview:') { const currentUrl = new URL(window.location.href) const extensionId = currentUrl.searchParams.get('extensionId') if (normalizedHostOrUrlRegex === extensionId) { return true } } return false }) } private isNativeLicense(licenseInfo: LicenseInfo) { return this.isFlagEnabled(licenseInfo.flags, FLAGS.NATIVE_LICENSE) } private getExpirationDateWithoutGracePeriod(expiryDate: Date) { return new Date(expiryDate.getFullYear(), expiryDate.getMonth(), expiryDate.getDate()) } private getExpirationDateWithGracePeriod(expiryDate: Date) { return new Date( expiryDate.getFullYear(), expiryDate.getMonth(), expiryDate.getDate() + GRACE_PERIOD_DAYS + 1 // Add 1 day to include the expiration day ) } private isAnnualLicenseExpired(expiryDate: Date) { const expiration = this.getExpirationDateWithGracePeriod(expiryDate) return new Date() >= expiration } private isPerpetualLicenseExpired(expiryDate: Date) { const expiration = this.getExpirationDateWithGracePeriod(expiryDate) const dates = { major: new Date(publishDates.major), minor: new Date(publishDates.minor), } // We allow patch releases, but the major and minor releases should be within the expiration date return dates.major >= expiration || dates.minor >= expiration } private getDaysSinceExpiry(expiryDate: Date): number { const now = new Date() const expiration = this.getExpirationDateWithoutGracePeriod(expiryDate) const diffTime = now.getTime() - expiration.getTime() const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)) return Math.max(0, diffDays) } private isEvaluationLicenseExpired(expiryDate: Date): boolean { // Evaluation licenses have no grace period - they expire immediately const now = new Date() const expiration = this.getExpirationDateWithoutGracePeriod(expiryDate) return now >= expiration } private isFlagEnabled(flags: number, flag: number) { return (flags & flag) === flag } private outputNoLicenseKeyProvided() { // Noop, we don't need to show this message. // this.outputMessages([ // 'No tldraw license key provided!', // `Please reach out to ${LICENSE_EMAIL} if you would like to license tldraw or if you'd like a trial.`, // ]) } private outputInvalidLicenseKey(msg: string) { this.outputMessages(['Invalid tldraw license key', `Reason: ${msg}`]) } private outputLicenseInfoIfNeeded(result: ValidLicenseKeyResult) { // If we added a new flag it will be twice the value of the currently highest flag. // And if all the current flags are on we would get the `HIGHEST_FLAG * 2 - 1`, so anything higher than that means there are new flags. if (result.license.flags >= HIGHEST_FLAG * 2) { this.outputMessages( [ 'Warning: This tldraw license contains some unknown flags.', 'This will still work, however, you may want to update tldraw packages to a newer version to get access to new functionality.', ], 'warning' ) } } private outputMessages(messages: string[], type: 'warning' | 'error' = 'error') { if (this.isTest) return if (this.verbose) { this.outputDelimiter(type) for (const message of messages) { const bgColor = type === 'warning' ? 'orange' : 'crimson' // eslint-disable-next-line no-console console.log( `%c${message}`, `color: white; background: ${bgColor}; padding: 2px; border-radius: 3px;` ) } this.outputDelimiter(type) } } private outputDelimiter(type: 'warning' | 'error' = 'error') { const bgColor = type === 'warning' ? 'orange' : 'crimson' // eslint-disable-next-line no-console console.log( '%c-------------------------------------------------------------------', `color: white; background: ${bgColor}; padding: 2px; border-radius: 3px;` ) } static className = 'tl-watermark_SEE-LICENSE' } export function getLicenseState( result: LicenseFromKeyResult, outputMessages: (messages: string[]) => void, isDevelopment: boolean ): LicenseState { if (!result.isLicenseParseable) { if (isDevelopment) { return 'unlicensed' } // All unlicensed scenarios should not work in production if (result.reason === 'no-key-provided') { outputMessages([ 'No tldraw license key provided!', 'A license is required for production deployments.', `Please reach out to ${LICENSE_EMAIL} to purchase a license.`, ]) } else { outputMessages([ 'Invalid license key. tldraw requires a valid license for production use.', `Please reach out to ${LICENSE_EMAIL} to purchase a license.`, ]) } return 'unlicensed-production' } if (!result.isDomainValid && !result.isDevelopment) { outputMessages([ 'License key is not valid for this domain.', 'A license is required for production deployments.', `Please reach out to ${LICENSE_EMAIL} to purchase a license.`, ]) return 'unlicensed-production' } // Handle evaluation licenses - they expire immediately with no grace period if (result.isEvaluationLicense) { if (result.isEvaluationLicenseExpired) { outputMessages([ 'Your tldraw evaluation license has expired!', `Please reach out to ${LICENSE_EMAIL} to purchase a full license.`, ]) return 'expired' } else { // Valid evaluation license - tracked but no watermark shown return 'licensed' } } // Handle expired regular licenses (both annual and perpetual) if (result.isPerpetualLicenseExpired || result.isAnnualLicenseExpired) { outputMessages([ 'Your tldraw license has been expired for more than 30 days!', `Please reach out to ${LICENSE_EMAIL} to renew your license.`, ]) return 'expired' } // Check if license is past expiry date but within grace period const daysSinceExpiry = result.daysSinceExpiry if (daysSinceExpiry > 0 && !result.isEvaluationLicense) { outputMessages([ 'Your tldraw license has expired.', `License expired ${daysSinceExpiry} days ago.`, `Please reach out to ${LICENSE_EMAIL} to renew your license.`, ]) // Within 30-day grace period: still licensed (no watermark) return 'licensed' } // License is valid, determine if it has watermark if (result.isLicensedWithWatermark) { return 'licensed-with-watermark' } return 'licensed' }