diff --git a/src/Config.ts b/src/Config.ts index 2eae9aa60..999be0555 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -16,12 +16,12 @@ import {DateTime, instanceOfSimpleDate, SimpleDate, SimpleDateTime, SimpleTime} import {AlwaysDense, ChooseAddressMapping} from './DependencyGraph/AddressMapping/ChooseAddressMappingPolicy' import {ConfigValueEmpty, ExpectedValueOfTypeError} from './errors' import {defaultStringifyCurrency, defaultStringifyDateTime, defaultStringifyDuration} from './format/format' -import {checkLicenseKeyValidity, LicenseKeyValidityState} from './helpers/licenseKeyValidator' +import {LicenseKeyValidityState} from './helpers/licenseKeyValidator' import {HyperFormula} from './HyperFormula' import {TranslationPackage} from './i18n' import {FunctionPluginDefinition} from './interpreter' import {CapabilityRegistry, ResolvedCapabilities} from './license/CapabilityRegistry' -import {unrestrictedEntitlement} from './license/LicenseEntitlement' +import {resolveLicense} from './license/licenseResolution' import {Maybe} from './Maybe' import {ParserConfig} from './parser/ParserConfig' import {ConfigParams, ConfigParamsList} from './ConfigParams' @@ -279,13 +279,9 @@ export class Config implements ConfigParams, ParserConfig { validateNumberToBeAtLeast(this.maxColumns, 'maxColumns', 1) this.context = context - const licenseKeyValidityState = checkLicenseKeyValidity(this.licenseKey) + const {validityState: licenseKeyValidityState, entitlement} = resolveLicense(this.licenseKey) const capabilityRegistry = new CapabilityRegistry() - // PR 1 (HF-307) ships the gate infrastructure without a real license-key payload adapter — - // that lands in PR 3 as src/license/payloadAdapter.ts. Until then every entitlement resolves - // as unrestricted, so isLicenseGateActive below reduces to today's licenseKeyValidityState - // check and gate B in the interpreter never actually restricts a function. - const licenseCapabilities = capabilityRegistry.resolve(unrestrictedEntitlement()) + const licenseCapabilities = capabilityRegistry.resolve(entitlement) privatePool.set(this, { licenseKeyValidityState, diff --git a/src/helpers/licenseKeyValidator.ts b/src/helpers/licenseKeyValidator.ts index 72ae00324..c0a0bbed6 100644 --- a/src/helpers/licenseKeyValidator.ts +++ b/src/helpers/licenseKeyValidator.ts @@ -27,7 +27,7 @@ type ConsoleMessages = { type MessageDescriptor = { template: LicenseKeyValidityState, - vars: TemplateVars, + expiryDate?: Date, } /** @@ -42,6 +42,44 @@ const consoleMessages: ConsoleMessages = { let _notified = false +/** + * Clears the once-per-page-load flag {@link notifyLicenseKeyState} keeps. + * + * Exists for tests only. The flag is module-level and never otherwise reset, so without this the + * whole console-message path is unobservable: the first spec to build any engine consumes the single + * warning and every later assertion sees silence regardless of what the code does. Making the reset + * explicit beats the alternatives — depending on spec-file order is flaky, and under Karma every + * spec shares one browser context, so order tricks do not work there at all. + * + * @internal + */ +export function resetLicenseKeyNotificationForTests(): void { + _notified = false +} + +/** + * Prints the console message for a non-valid license key state, at most once per page load. + * + * Extracted so the typed-key path in `src/license/licenseResolution.ts` reports the same states + * with the same wording and the same once-only behaviour, without duplicating the message table + * or getting a second `_notified` flag of its own — two flags would let a page print two + * warnings for one key. + * + * @param {LicenseKeyValidityState} state - the state to report; `VALID` prints nothing + * @param {Date} [keyValidityDate] - the day the key stopped being valid, used by the `expired` + * message + */ +export function notifyLicenseKeyState(state: LicenseKeyValidityState, keyValidityDate?: Date): void { + if (_notified || state === LicenseKeyValidityState.VALID) { + return + } + + const vars: TemplateVars = keyValidityDate === undefined ? {} : {keyValidityDate: formatDate(keyValidityDate)} + + console.warn(consoleMessages[state](vars)) + _notified = true +} + /** * Checks if the provided license key is grammatically valid or not expired. * @@ -51,7 +89,6 @@ let _notified = false export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityState { const messageDescriptor: MessageDescriptor = { template: LicenseKeyValidityState.MISSING, - vars: {}, } if (licenseKey === 'gpl-v3' || licenseKey === 'internal-use-in-handsontable' || licenseKey === 'hftrial-0168e-1f2b7-47158-70b05-0842f') { @@ -62,7 +99,7 @@ export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityS const releaseDays = Math.floor(new Date(`${month}/${day}/${year}`).getTime() / 8.64e7) const keyValidityDays = extractTime(licenseKey) - messageDescriptor.vars.keyValidityDate = formatDate(new Date((keyValidityDays + 1) * 8.64e7)) + messageDescriptor.expiryDate = new Date((keyValidityDays + 1) * 8.64e7) if (releaseDays > keyValidityDays) { messageDescriptor.template = LicenseKeyValidityState.EXPIRED @@ -74,10 +111,7 @@ export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityS messageDescriptor.template = LicenseKeyValidityState.INVALID } - if (!_notified && messageDescriptor.template !== LicenseKeyValidityState.VALID) { - console.warn(consoleMessages[messageDescriptor.template](messageDescriptor.vars)) - _notified = true - } + notifyLicenseKeyState(messageDescriptor.template, messageDescriptor.expiryDate) return messageDescriptor.template } @@ -85,16 +119,21 @@ export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityS /** * Formats a Date instance to hard-coded format MMMM DD, YYYY. * - * @param {Date} date The date to format. - * @returns {string} + * Read in UTC, not local time. Every date reaching this function is built at UTC midnight — the + * legacy path from a whole number of days since the epoch, the typed-key path from a calendar + * date in the payload — so local getters shifted the day backwards for anyone west of UTC and + * printed an expiry one day earlier than the one the key actually carries. + * + * @param {Date} date The date to format, at UTC midnight. + * @returns {string} The date as `MMMM DD, YYYY`. */ function formatDate(date: Date): string { const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ] - const month = monthNames[date.getMonth()] - const day = date.getDate() - const year = date.getFullYear() + const month = monthNames[date.getUTCMonth()] + const day = date.getUTCDate() + const year = date.getUTCFullYear() return `${month} ${day}, ${year}` } diff --git a/src/license/CapabilityRegistry.ts b/src/license/CapabilityRegistry.ts index 61462c82e..495c51cfb 100644 --- a/src/license/CapabilityRegistry.ts +++ b/src/license/CapabilityRegistry.ts @@ -4,7 +4,7 @@ */ import {FeatureId, LicenseEntitlement} from './LicenseEntitlement' -import {CAPABILITY_TABLE, CapabilityGrant, refreshCoreGrant} from './capabilities' +import {CAPABILITY_TABLE, CapabilityGrant} from './capabilities' /** * The capabilities a resolved {@link LicenseEntitlement} grants, ready for gate B (the @@ -33,9 +33,6 @@ export class CapabilityRegistry { * suite does not depend on its placeholder content. */ constructor(table?: ReadonlyMap) { - if (table === undefined) { - refreshCoreGrant() - } this.table = table ?? CAPABILITY_TABLE this.reverseIndex = CapabilityRegistry.buildReverseIndex(this.table) } diff --git a/src/license/LicenseEntitlement.ts b/src/license/LicenseEntitlement.ts index 9472d1350..5b642f524 100644 --- a/src/license/LicenseEntitlement.ts +++ b/src/license/LicenseEntitlement.ts @@ -24,12 +24,15 @@ export const enum FeatureId { /** * Describes when a license entitlement stops being valid. * - * Per key-spec rev 3 §1.3, `date` is kept as a calendar string rather than an epoch, and is - * INCLUSIVE of its last valid day: - * - `kind === 'usage'`: `date` is compared against the client's LOCAL calendar date — deliberately - * not UTC, the date means the date, wherever the customer is. - * - `kind === 'release'`: `date` is compared LEXICOGRAPHICALLY, as text, against the library's - * build date; no clock is involved. + * `date` is kept as a calendar string rather than an epoch, and is INCLUSIVE of its last valid + * day: + * - `kind === 'usage'`: compared against the current instant in **UTC**. An earlier revision of + * the key spec called for the client's LOCAL calendar date; that was reversed, because the + * offline check and a future online check have to return the same verdict for the same key at + * the same instant, and any rule that reads a local clock breaks that parity. The practical + * cost is that a customer far west of UTC loses the tail of their last local day. + * - `kind === 'release'`: compared against the library's build date; no clock is involved, which + * is what keeps an air-gapped install with a wrong system clock working. * - `kind === 'none'`: the entitlement does not expire. */ export interface LicenseExpiry { @@ -63,9 +66,13 @@ export interface LicenseEntitlement { expiry: LicenseExpiry, /** * When `true`, resolving this entitlement must not print a console message of any kind. - * HF-307 decision D3 (fail-closed, silent): a typed key with no recognized token resolves - * like an explicit `capabilities: []` — core and protected functions only, without a message, - * a warning, or a diagnostics getter. + * + * Set from the key's own flags ONLY — the key spec spells that flag three different ways across + * revisions and even within one revision, and all are honoured. An unrecognized token does NOT + * set it: HF-307 decision D3 makes the *grant* silent (an unknown token grants nothing, with no + * message and no diagnostics getter), which is a different thing from muting the key's console + * output. Coupling them suppressed expiry notices as a side effect of a vocabulary mismatch, and + * was confirmed an implementation error (Kuba, 12.08). */ silent: boolean, isTrial: boolean, diff --git a/src/license/capabilities.ts b/src/license/capabilities.ts index 858433607..6c640d444 100644 --- a/src/license/capabilities.ts +++ b/src/license/capabilities.ts @@ -3,12 +3,52 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ -import {FunctionRegistry} from '../interpreter/FunctionRegistry' import {FeatureId} from './LicenseEntitlement' -/** The single capability token every built-in currently falls under, see {@link CAPABILITY_TABLE}. */ +/** + * The always-granted token. Every entitlement built from a license key includes it. + * + * It grants the calculation operators — and nothing else. In particular it grants NO features: + * per Kuba's decision (task comment, 12.08) feature gating is real, and the gated API areas come + * from the `feat:*` tokens below. The always-on functionality the packaging design assigns to + * core (reads, serialization, teardown) is not behind `ensureCapability` at all. + */ export const CORE_TOKEN = 'core' +/** Grants {@link FeatureId.Crud} — the mutating CRUD surface of the public API. */ +export const CRUD_FEATURE_TOKEN = 'feat:crud' +/** Grants {@link FeatureId.UndoRedo}. */ +export const UNDO_REDO_FEATURE_TOKEN = 'feat:undo_redo' +/** Grants {@link FeatureId.Clipboard}. */ +export const CLIPBOARD_FEATURE_TOKEN = 'feat:clipboard' +/** Grants {@link FeatureId.NamedExpressions}. */ +export const NAMED_EXPRESSIONS_FEATURE_TOKEN = 'feat:named_expressions' +/** Grants {@link FeatureId.Batching}. */ +export const BATCHING_FEATURE_TOKEN = 'feat:batching' + +/** + * Every feature token, in one list, for the shipped-shape adapter: the shipped key vocabulary + * predates feature tokens entirely, so a commercial tier is translated into its functions token + * PLUS all of these — see `licenseTermsOf` for the reasoning. + */ +export const ALL_FEATURE_TOKENS = [ + CRUD_FEATURE_TOKEN, UNDO_REDO_FEATURE_TOKEN, CLIPBOARD_FEATURE_TOKEN, + NAMED_EXPRESSIONS_FEATURE_TOKEN, BATCHING_FEATURE_TOKEN, +] + +/** Math engine package — the free tier's function set. */ +export const FUNCTIONS_1_TOKEN = 'functions_1' +/** Calculated fields package. Cumulative: includes {@link FUNCTIONS_1_TOKEN}'s functions. */ +export const FUNCTIONS_2_TOKEN = 'functions_2' +/** Spreadsheet package. Cumulative: includes {@link FUNCTIONS_2_TOKEN}'s functions. */ +export const FUNCTIONS_3_TOKEN = 'functions_3' +/** Excel simulator package — the entire implemented catalog. */ +export const FUNCTIONS_4_TOKEN = 'functions_4' +/** Spreadsheet add-on. Reserved: recognized, grants nothing yet — see {@link CAPABILITY_TABLE}. */ +export const SPREADSHEET_ADDON_TOKEN = 'spreadsheet' +/** Import/export add-on. Reserved until HF-107 ships the feature it would gate. */ +export const IMPORT_EXPORT_ADDON_TOKEN = 'import_export' + /** * Describes what a capability token grants: a set of function ids, a set of {@link FeatureId} * values, and optionally other tokens it implies. `implies` is expanded recursively by @@ -20,37 +60,155 @@ export interface CapabilityGrant { implies?: string[], } -const coreGrant: CapabilityGrant = { - functions: [], - features: [FeatureId.NamedExpressions, FeatureId.Clipboard, FeatureId.Crud, FeatureId.UndoRedo, FeatureId.Batching], -} +/** + * The calculation operators and their `HF.*` callable forms. Always available in every package — + * the packaging design counts them as engine baseline and advertises them separately from the + * function totals, so they are granted by {@link CORE_TOKEN} rather than by any package. + */ +const OPERATOR_FUNCTIONS = [ + 'HF.ADD', 'HF.CONCAT', 'HF.DIVIDE', 'HF.EQ', 'HF.GT', 'HF.GTE', 'HF.LT', 'HF.LTE', 'HF.MINUS', + 'HF.MULTIPLY', 'HF.NE', 'HF.POW', 'HF.UMINUS', 'HF.UNARY_PERCENT', 'HF.UPLUS', +] + +// An earlier revision granted all five features from CORE_TOKEN, which made feature gating inert +// by construction: no typed key could ever lose an API area. Kuba's call (task comment, 12.08): +// "Feature gating should work, but the legacy keys should grant all feat:* capabilities" — legacy +// keys already resolve to the unrestricted entitlement, so the carve-out costs nothing, and the +// five features moved onto their own `feat:*` tokens below. /** - * The production capability table. + * Package membership, as the LOWEST package that includes each function. + * + * Transcribed from the packaging design's own per-function evidence file. The lists reproduce + * that file's package counts exactly (17 / 51 / 127 / 355 cumulative, plus 15 operators), which + * is how the transcript was checked; `capability-table.spec.ts` pins those counts so a later + * edit cannot drift from them silently. * - * Placeholder content pending HF-331/HF-329 (the real per-package token vocabulary): every - * built-in function, plus the features already wired for gating in PR 2, fall under the single - * {@link CORE_TOKEN}. `FeatureId.CustomFunctions` and `FeatureId.ImportExport` are deliberately - * absent — reserved vocabulary with no grant yet (HF-307 decision D1; HF-107 for ImportExport). + * Two functions of the evidence file are deliberately absent: `OFFSET` and `VERSION` are + * protected built-ins and sit OUTSIDE the token system — the interpreter never gate-checks a + * protected function, so listing them would be dead weight that implies a restriction that does + * not exist. * - * `coreGrant.functions` starts empty and is refreshed on every {@link refreshCoreGrant} call - * rather than populated once here: `src/index.ts` registers HyperFormula's built-in plugins as a - * side effect of being imported, and it does so AFTER `Config` and `Interpreter` — and so this - * module — have already been fully evaluated. Reading the function registry at module-load time - * would capture an empty registry. + * **This membership is a DRAFT and is expected to change before release.** The packaging design + * it follows is still under review, with the free tier's exact contents and the placement of + * several function families among the points not yet settled. */ -export const CAPABILITY_TABLE: ReadonlyMap = new Map([[CORE_TOKEN, coreGrant]]) +const MATH_ENGINE_FUNCTIONS = [ + 'ABS', 'CEILING', 'EXP', 'FLOOR', 'IF', 'LN', 'LOG', 'LOG10', 'MOD', 'POWER', 'PRODUCT', 'ROUND', + 'ROUNDDOWN', 'ROUNDUP', 'SQRT', 'SUM', +] + +/** Added by the calculated-fields package, on top of {@link MATH_ENGINE_FUNCTIONS}. */ +const CALCULATED_FIELDS_FUNCTIONS = [ + 'AND', 'AVERAGE', 'CONCATENATE', 'COUNT', 'DATE', 'DATEDIF', 'DAY', 'DAYS', 'FALSE', 'FIND', 'HOUR', + 'LEFT', 'LEN', 'LOWER', 'MAX', 'MID', 'MIN', 'MINUTE', 'MONTH', 'NOT', 'NOW', 'OR', 'RIGHT', 'SEARCH', + 'SECOND', 'TEXT', 'TEXTJOIN', 'TODAY', 'TRIM', 'TRUE', 'UPPER', 'VALUE', 'XOR', 'YEAR', +] + +/** Added by the spreadsheet package, on top of {@link CALCULATED_FIELDS_FUNCTIONS}. */ +const SPREADSHEET_FUNCTIONS = [ + 'ADDRESS', 'AVERAGEIF', 'CHOOSE', 'COLUMN', 'COLUMNS', 'COUNTIF', 'COUNTIFS', 'DATEVALUE', 'DAYS360', + 'EDATE', 'EOMONTH', 'FILTER', 'FORMULATEXT', 'FV', 'HLOOKUP', 'HYPERLINK', 'IFERROR', 'IFNA', 'INDEX', + 'INTERVAL', 'IPMT', 'IRR', 'ISBINARY', 'ISBLANK', 'ISERR', 'ISERROR', 'ISEVEN', 'ISFORMULA', 'ISLOGICAL', + 'ISNA', 'ISNONTEXT', 'ISNUMBER', 'ISODD', 'ISOWEEKNUM', 'ISREF', 'ISTEXT', 'MATCH', 'MAXIFS', 'MINIFS', + 'NA', 'NETWORKDAYS', 'NETWORKDAYS.INTL', 'NPER', 'NPV', 'PERCENTILE.EXC', 'PERCENTILE.INC', 'PMT', + 'PPMT', 'PV', 'RAND', 'RANDBETWEEN', 'RATE', 'ROW', 'ROWS', 'SORT', 'STDEV.P', 'STDEV.S', 'STDEVA', + 'STDEVPA', 'SUMIF', 'SUMIFS', 'TIME', 'TIMEVALUE', 'UNIQUE', 'VAR.P', 'VAR.S', 'VARA', 'VARPA', + 'VLOOKUP', 'WEEKDAY', 'WEEKNUM', 'WORKDAY', 'WORKDAY.INTL', 'XLOOKUP', 'YEARFRAC', +] /** - * Refreshes the placeholder `core` grant with every function currently in the static function - * registry. Called from `CapabilityRegistry`'s constructor every time it is constructed without - * an explicit table — not just the first time: the static registry can change after the first - * engine is built (`HyperFormula.registerFunctionPlugin`/`unregisterFunctionPlugin` are public, - * documented APIs), and a one-time snapshot would silently go stale for every engine built - * afterward. Cheap (a single array copy from an existing map's keys) and only ever runs once per - * `Config`/engine construction, never on the per-formula hot path, so re-running it every time - * costs nothing worth guarding against with memoization. + * Added by the excel-simulator package, on top of {@link SPREADSHEET_FUNCTIONS} — the rest of the + * implemented catalog. + * + * Enumerated rather than taken from the function registry at run time, even though "all + * functions" would be the shorter way to say it. Reading the registry would sweep in functions + * registered through `HyperFormula.registerFunctionPlugin`, putting a user's OWN custom function + * into a paid package and returning `#LIC!` for it on a smaller licence — the opposite of HF-307 + * decision D1, which drops custom-function gating entirely. A function this table does not list + * is not gated at all, which is exactly the treatment a custom function should get. */ -export function refreshCoreGrant(): void { - coreGrant.functions = FunctionRegistry.getRegisteredFunctionIds() +const EXCEL_SIMULATOR_FUNCTIONS = [ + 'ACOS', 'ACOSH', 'ACOT', 'ACOTH', 'ARABIC', 'ARRAYFORMULA', 'ARRAY_CONSTRAIN', 'ASIN', 'ASINH', 'ATAN', + 'ATAN2', 'ATANH', 'AVEDEV', 'AVERAGEA', 'BASE', 'BESSELI', 'BESSELJ', 'BESSELK', 'BESSELY', 'BETA.DIST', + 'BETA.INV', 'BIN2DEC', 'BIN2HEX', 'BIN2OCT', 'BINOM.DIST', 'BINOM.INV', 'BITAND', 'BITLSHIFT', 'BITOR', + 'BITRSHIFT', 'BITXOR', 'CEILING.MATH', 'CEILING.PRECISE', 'CHAR', 'CHISQ.DIST', 'CHISQ.DIST.RT', + 'CHISQ.INV', 'CHISQ.INV.RT', 'CHISQ.TEST', 'CLEAN', 'CODE', 'COMBIN', 'COMBINA', 'COMPLEX', + 'CONFIDENCE.NORM', 'CONFIDENCE.T', 'CORREL', 'COS', 'COSH', 'COT', 'COTH', 'COUNTA', 'COUNTBLANK', + 'COUNTUNIQUE', 'COVARIANCE.P', 'COVARIANCE.S', 'CSC', 'CSCH', 'CUMIPMT', 'CUMPRINC', 'DAVERAGE', 'DB', + 'DCOUNT', 'DCOUNTA', 'DDB', 'DEC2BIN', 'DEC2HEX', 'DEC2OCT', 'DECIMAL', 'DEGREES', 'DELTA', 'DEVSQ', + 'DGET', 'DMAX', 'DMIN', 'DOLLARDE', 'DOLLARFR', 'DPRODUCT', 'DSTDEV', 'DSTDEVP', 'DSUM', 'DVAR', 'DVARP', + 'EFFECT', 'ERF', 'ERFC', 'EVEN', 'EXACT', 'EXPON.DIST', 'F.DIST', 'F.DIST.RT', 'F.INV', 'F.INV.RT', + 'F.TEST', 'FACT', 'FACTDOUBLE', 'FISHER', 'FISHERINV', 'FLOOR.MATH', 'FLOOR.PRECISE', 'FVSCHEDULE', + 'GAMMA', 'GAMMA.DIST', 'GAMMA.INV', 'GAMMALN', 'GAUSS', 'GCD', 'GEOMEAN', 'HARMEAN', 'HEX2BIN', + 'HEX2DEC', 'HEX2OCT', 'HSTACK', 'HYPGEOM.DIST', 'IFS', 'IMABS', 'IMAGINARY', 'IMARGUMENT', 'IMCONJUGATE', + 'IMCOS', 'IMCOSH', 'IMCOT', 'IMCSC', 'IMCSCH', 'IMDIV', 'IMEXP', 'IMLN', 'IMLOG10', 'IMLOG2', 'IMPOWER', + 'IMPRODUCT', 'IMREAL', 'IMSEC', 'IMSECH', 'IMSIN', 'IMSINH', 'IMSQRT', 'IMSUB', 'IMSUM', 'IMTAN', 'INT', + 'ISPMT', 'LARGE', 'LCM', 'LOGNORM.DIST', 'LOGNORM.INV', 'MAXA', 'MAXPOOL', 'MEDIAN', 'MEDIANPOOL', + 'MINA', 'MIRR', 'MMULT', 'MROUND', 'MULTINOMIAL', 'N', 'NEGBINOM.DIST', 'NOMINAL', 'NORM.DIST', + 'NORM.INV', 'NORM.S.DIST', 'NORM.S.INV', 'OCT2BIN', 'OCT2DEC', 'OCT2HEX', 'ODD', 'PDURATION', 'PHI', + 'PI', 'POISSON.DIST', 'PROPER', 'QUARTILE.EXC', 'QUARTILE.INC', 'QUOTIENT', 'RADIANS', 'REPLACE', 'REPT', + 'ROMAN', 'RRI', 'RSQ', 'SEC', 'SECH', 'SEQUENCE', 'SERIESSUM', 'SHEET', 'SHEETS', 'SIGN', 'SIN', 'SINH', + 'SKEW', 'SKEW.P', 'SLN', 'SLOPE', 'SMALL', 'SPLIT', 'SQRTPI', 'STANDARDIZE', 'STEYX', 'SUBSTITUTE', + 'SUBTOTAL', 'SUMPRODUCT', 'SUMSQ', 'SUMX2MY2', 'SUMX2PY2', 'SUMXMY2', 'SWITCH', 'SYD', 'T', 'T.DIST', + 'T.DIST.2T', 'T.DIST.RT', 'T.INV', 'T.INV.2T', 'T.TEST', 'TAN', 'TANH', 'TBILLEQ', 'TBILLPRICE', + 'TBILLYIELD', 'TDIST', 'TRANSPOSE', 'UNICHAR', 'UNICODE', 'VSTACK', 'WEIBULL.DIST', 'XIRR', 'XNPV', + 'Z.TEST', +] + +const coreGrant: CapabilityGrant = {functions: [...OPERATOR_FUNCTIONS], features: []} +const functions1Grant: CapabilityGrant = {functions: [...MATH_ENGINE_FUNCTIONS], features: []} +const functions2Grant: CapabilityGrant = { + functions: [...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS], features: [], +} +const functions3Grant: CapabilityGrant = { + functions: [...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS, ...SPREADSHEET_FUNCTIONS], features: [], } +const functions4Grant: CapabilityGrant = { + functions: [ + ...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS, ...SPREADSHEET_FUNCTIONS, + ...EXCEL_SIMULATOR_FUNCTIONS, + ], + features: [], +} + +/** + * The production capability table. + * + * The grants are stored FULLY EXPANDED rather than chained through `implies`: the packaging + * design states the enforcement layer must not assume a hierarchy between tokens, and that the + * commercial nesting is expressed by a bigger licence simply listing more functions. The + * cumulative spreads above keep the source DRY without putting that hierarchy into the runtime. + * + * Every grant is STATIC. Nothing here is derived from the function registry at run time, so a + * function registered by a user through `HyperFormula.registerFunctionPlugin` can never appear in + * a package and can never be gated — see {@link EXCEL_SIMULATOR_FUNCTIONS}. The cost is that a + * newly implemented built-in is ungated until it is added here, which the completeness invariant + * in `unit/license/capability-registry.spec.ts` fails on. + * + * The five `feat:*` tokens carry the gated API areas, one feature each, spelled after the draft + * vocabulary in the task. A rev-5 key states them explicitly; the shipped-shape adapter grants + * all five alongside the tier (that vocabulary predates feature tokens); legacy keys resolve to + * the unrestricted entitlement and never consult this table. + * + * The two add-on tokens are RESERVED: recognized, so an issued key carrying one is not reported + * as unrecognized, but granting nothing. `spreadsheet` has no agreed content yet — the packaging + * proposal names a *package* "Spreadsheet" and the pricing task names a "Spreadsheet Bundle" + * add-on, and it is not settled whether those are the same set; guessing would silently sell an + * empty add-on or duplicate a whole tier. `import_export` has nothing to grant until HF-107. + */ +export const CAPABILITY_TABLE: ReadonlyMap = new Map([ + [CORE_TOKEN, coreGrant], + [FUNCTIONS_1_TOKEN, functions1Grant], + [FUNCTIONS_2_TOKEN, functions2Grant], + [FUNCTIONS_3_TOKEN, functions3Grant], + [FUNCTIONS_4_TOKEN, functions4Grant], + [CRUD_FEATURE_TOKEN, {functions: [], features: [FeatureId.Crud]}], + [UNDO_REDO_FEATURE_TOKEN, {functions: [], features: [FeatureId.UndoRedo]}], + [CLIPBOARD_FEATURE_TOKEN, {functions: [], features: [FeatureId.Clipboard]}], + [NAMED_EXPRESSIONS_FEATURE_TOKEN, {functions: [], features: [FeatureId.NamedExpressions]}], + [BATCHING_FEATURE_TOKEN, {functions: [], features: [FeatureId.Batching]}], + [SPREADSHEET_ADDON_TOKEN, {functions: [], features: []}], + [IMPORT_EXPORT_ADDON_TOKEN, {functions: [], features: []}], +]) + diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts new file mode 100644 index 000000000..42aaac584 --- /dev/null +++ b/src/license/licenseResolution.ts @@ -0,0 +1,456 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import { + checkLicenseKeyValidity, + LicenseKeyValidityState, + notifyLicenseKeyState, +} from '../helpers/licenseKeyValidator' +import { + ALL_FEATURE_TOKENS, + CAPABILITY_TABLE, + CORE_TOKEN, + FUNCTIONS_1_TOKEN, + FUNCTIONS_2_TOKEN, + FUNCTIONS_3_TOKEN, + FUNCTIONS_4_TOKEN, +} from './capabilities' +import {LicenseEntitlement, LicenseExpiry, unrestrictedEntitlement} from './LicenseEntitlement' +import {HYPERFORMULA_PRODUCT_NAME} from './vendor/defaultSchema' +import {extractTypedKeyData, TypedKeyData, TypedKeyProductGrant} from './vendor/extractKeyData' +import {parseIsoDate} from './vendor/utils' + +/** Milliseconds in a day, used to turn a grace period in days into a deadline. */ +const MILLISECONDS_PER_DAY = 86400000 + +/** + * Below this value a numeric timestamp is read as epoch SECONDS, above it as milliseconds. + * `1e11` seconds is year 5138, and `1e11` milliseconds is 1973 — no real license date is near + * either, so the split is unambiguous for anything a key can plausibly carry. + */ +const SECONDS_MILLISECONDS_THRESHOLD = 1e11 + +/** The largest value `Date` can represent; beyond it `toISOString()` throws. */ +const MAX_TIMESTAMP = 8640000000000000 + +/** + * Commercial tier names (the shipped key format) mapped to the capability tokens the library + * actually resolves. A tier this map does not know is passed through unchanged, so it surfaces + * as an unrecognized capability rather than being silently swallowed. + * + * A `Map`, not an object literal, and that matters for safety rather than style: the tier is an + * attacker-influenced string, and an object lookup also answers for every `Object.prototype` member, + * so `tier: "constructor"` would resolve to a FUNCTION and `tier: "__proto__"` to an object. Either + * put a non-string into the token list, which then crashed the scan that reads tokens as strings — + * a thrown `TypeError` escaping the `HyperFormula` constructor instead of an `invalid` verdict. + * A `Map` answers only for keys actually put in it, matching {@link CAPABILITY_TABLE}. + */ +const TIER_TO_CAPABILITY_TOKEN: ReadonlyMap = new Map([ + ['freemium', FUNCTIONS_1_TOKEN], + ['crm', FUNCTIONS_2_TOKEN], + ['data_grid', FUNCTIONS_3_TOKEN], + ['excel_simulator', FUNCTIONS_4_TOKEN], +]) + +/** + * The prefix marking a capability token as granting a public-API feature area. + * + * Used to tell "this key names its feature grants" from "this key's vocabulary cannot express + * one" — see the opt-in rule in {@link licenseTermsOf}. + */ +const FEATURE_TOKEN_PREFIX = 'feat:' + +/** + * Flag spellings that suppress console output. + * + * Three, because the key spec is not self-consistent: its normative flags table and its example + * payload (rev 5 §2.3 and §2) say `no-console-warns`, while the runtime-behaviour sections of the + * same revision (§4.3, §5.2) say `silent-console`, and earlier revisions said plain `silent`. A key + * minted against any of those readings must be honoured — a SaaS deployment that asked for silence + * and got console warnings is the failure this list exists to prevent. + */ +const SILENT_CONSOLE_FLAGS = ['silent', 'silent-console', 'no-console-warns'] + +/** The rev-5 fields, which the shipped payload shape does not have. */ +interface Rev5ProductGrant { + capabilities?: unknown, + usage_until?: unknown, + release_until?: unknown, + notice?: unknown, + flags?: unknown, +} + +/** + * Both halves of the license decision, resolved from one reading of the key. + * + * They are deliberately produced together: the two gates ask different questions of the same + * string, and parsing it twice would let them disagree about what it says. + */ +export interface ResolvedLicense { + /** Gate A — may this instance evaluate formulas at all. */ + validityState: LicenseKeyValidityState, + /** Gate B — which functions and API features the key grants. */ + entitlement: LicenseEntitlement, +} + +/** + * What HyperFormula needs from a typed key, after the two payload shapes have been reconciled. + * + * The engine reads TWO payload shapes on purpose: + * + * - the **shipped** shape of `handsontable/license-key` — `tier`, `addons`, `exp`, `grace`, with + * the contract type carried by the key's `[TRIAL]`/`[FREE]`/`[SUB]`/`[PERP]` tag, and with the + * expiry living on the LICENSED product entry (the first schema product present); + * - the shape of key spec **rev 5** — `capabilities`, `usage_until` / `release_until`, `notice`, + * `grace`, `flags`, where every product entry carries its own terms. + * + * The two disagree about nearly every field, rev 5 is still for review, and only the first can + * be minted today. Reading both means an already-issued key keeps working whichever way that + * disagreement is settled. Shape is detected per product entry, by the presence of + * `capabilities`, not guessed from the key type. + */ +interface LicenseTerms { + capabilityTokens: string[], + expiry: LicenseExpiry, + /** Epoch milliseconds of the last licensed day, or `null` when the key never expires. */ + expiryTimestamp: number | null, + /** `true` compares against the build release date, `false` against the clock. */ + comparedAgainstReleaseDate: boolean, + graceDays: number, + isTrial: boolean, + silent: boolean, +} + +/** + * The build's release date as epoch milliseconds (UTC midnight), or `null` when it is unknown or + * malformed. + * + * Read from the same `HT_RELEASE_DATE` (`DD/MM/YYYY`) the legacy validator uses, but **parsed + * differently on purpose**, and the difference is observable — so do not "simplify" either one to + * match the other without reading this. + * + * This function uses `Date.UTC`. The legacy validator builds the same value with + * `new Date(month/day/year)`, which is parsed in the host's LOCAL zone. East of UTC the two land on + * different day numbers for one and the same release date: + * + * ```text + * HT_RELEASE_DATE=10/08/2026 legacy (local) this function (UTC) + * TZ=UTC, TZ=America/Los_Angeles 20675 20675 agree + * TZ=Asia/Tokyo 20674 20675 differ by a day + * TZ=Pacific/Kiritimati 20674 20675 differ by a day + * ``` + * + * UTC is the required reading for a typed key: key spec rev 5 §1.2 makes offline/online parity a + * hard rule — the offline check and a future online check must return the same verdict for the same + * key at the same instant — and any rule reading a local clock breaks it. The legacy path keeps its + * local parse because legacy behaviour is frozen for this release; switching it would move the + * expiry verdict of already-issued legacy keys by a day for every customer east of UTC. + * + * The consequence, flagged rather than hidden: two customers east of UTC, one on a legacy key and + * one on an equivalent typed key, can disagree by a day about whether this build is covered. + * Reconciling them is a product decision, not a refactor. + */ +function releaseDateTimestamp(): number | null { + const [day, month, year] = (process.env.HT_RELEASE_DATE ?? '').split('/') + const timestamp = Date.UTC(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10)) + + return isNaN(timestamp) ? null : timestamp +} + +/** + * Reads a date that may arrive either as a `YYYY-MM-DD` string or as a numeric timestamp, and + * returns it as epoch milliseconds at UTC midnight. Returns `null` when the value is present but + * cannot be read — the caller rejects the whole key in that case rather than treating it as + * "no expiry", which would silently turn a subscription into a perpetual licence. + * + * Both forms are accepted because key spec rev 5 contradicts itself about them: §1.2 mandates + * "bare `YYYY-MM-DD` everywhere, no time component", while §2.1 types the same fields as + * `timestamp` and its example payload carries integers. + * + * The string form goes through the vendored {@link parseIsoDate}, so it gets the same calendar + * round-trip check the shipped shape's `exp` gets: `2027-02-30` is rejected rather than rolling + * over into March and quietly granting two extra days. + * + * @param {unknown} value - the raw payload value, known not to be `undefined` + */ +function readDate(value: unknown): number | null { + if (typeof value === 'string') { + try { + return parseIsoDate(value, 'expiration').timestamp + } catch (error) { + return null + } + } + if (typeof value === 'number' && isFinite(value)) { + const milliseconds = Math.abs(value) < SECONDS_MILLISECONDS_THRESHOLD ? value * 1000 : value + + if (Math.abs(milliseconds) > MAX_TIMESTAMP) { + return null + } + + // Normalize to UTC midnight so an inclusive last-licensed-DAY stays a day, not an instant. + return Math.floor(milliseconds / MILLISECONDS_PER_DAY) * MILLISECONDS_PER_DAY + } + + return null +} + +/** + * A non-negative integer count of days from a payload field, or `0` when it is absent or not one. + * + * @param {unknown} value - the raw payload value + */ +function readDays(value: unknown): number { + return typeof value === 'number' && isFinite(value) && value >= 0 ? Math.floor(value) : 0 +} + +/** + * The strings of a payload array field, ignoring anything that is not a non-empty string. + * + * @param {unknown} value - the raw payload value + */ +function readStrings(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return (value as unknown[]).filter((item): item is string => typeof item === 'string' && item.length > 0) +} + +/** Whether a payload product entry is a usable object rather than `null`, an array or a scalar. */ +function isProductGrant(value: unknown): value is TypedKeyProductGrant & Rev5ProductGrant { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** + * Reconciles the two payload shapes into one set of terms, or `null` when the payload carries a + * term it cannot read — a date that is present but malformed, for instance. Returning `null` + * makes the key INVALID, which is what the shipped shape already does for a malformed `exp`; + * the alternative, treating an unreadable expiry as "never expires", would turn a minting typo + * into a permanent licence. + * + * @param {TypedKeyData} data - the extracted key data + */ +function licenseTermsOf(data: TypedKeyData): LicenseTerms | null { + const hyperformulaEntry: unknown = data.payload.products[HYPERFORMULA_PRODUCT_NAME] + const hyperformulaGrant = isProductGrant(hyperformulaEntry) ? hyperformulaEntry : undefined + + // `capabilities` present but not an array is a term this code cannot read, so the whole key is + // rejected rather than quietly falling through to the shipped-shape branch. That fall-through was + // a free pass in both directions: the key gained every feature it never carried, and its rev-5 + // dates were never read at all, so an expired subscription resolved as perpetual. + if (hyperformulaGrant !== undefined + && hyperformulaGrant.capabilities !== undefined + && !Array.isArray(hyperformulaGrant.capabilities)) { + return null + } + + const isRev5 = hyperformulaGrant !== undefined && Array.isArray(hyperformulaGrant.capabilities) + + // CORE_TOKEN is always granted, but note what it actually grants: the calculation operators - + // NOT a usable set of functions. A key whose only tokens this build does not recognize therefore + // evaluates operators and protected built-ins and returns #LIC! for every function call, + // silently, per HF-307 decision D3. Kuba ratified that cliff as-is on 12.08 (D6-A): "this + // situation should never happen. There is no point in issuing a key if empty capabilities." + const capabilityTokens = [CORE_TOKEN] + + if (hyperformulaGrant !== undefined) { + if (isRev5) { + capabilityTokens.push(...readStrings(hyperformulaGrant.capabilities)) + } else { + if (typeof hyperformulaGrant.tier === 'string' && hyperformulaGrant.tier.length > 0) { + capabilityTokens.push(TIER_TO_CAPABILITY_TOKEN.get(hyperformulaGrant.tier) ?? hyperformulaGrant.tier) + } + capabilityTokens.push(...readStrings(hyperformulaGrant.addons)) + } + } + + // Feature tokens are OPT-IN, never opt-out. A key carrying at least one `feat:*` token demonstrably + // speaks the feature vocabulary, so it gets exactly the areas it names - that is what makes feature + // gating real (Kuba, 12.08: "Feature gating should work"). A key carrying NONE cannot be saying + // "no features", because no vocabulary in circulation can express one: the shipped shape has no + // such field, and the key spec's current HyperFormula token list (rev 5 §2.2 - `functions_1..4`, + // `spreadsheet`, `import_export`) contains no `feat:*` entry at all. So absence means "this key + // does not talk about features", and the task's additive-safety rule - a grant may grow between + // versions, never shrink - makes the whole gated API the only safe reading. + // + // Reading absence as denial instead would hand a dead public API to every key myHOT can mint + // today, HyperFormula-only and Handsontable-only alike; both were verified doing exactly that + // before this rule existed. + if (!capabilityTokens.some((token) => token.indexOf(FEATURE_TOKEN_PREFIX) === 0)) { + capabilityTokens.push(...ALL_FEATURE_TOKENS) + } + + // WHERE the terms live differs by shape. Under rev 5 every product entry carries its own + // dates, notice, grace and flags, so HyperFormula reads its own. Under the shipped shape only + // the LICENSED product may carry `exp` and `grace`, so for a key granting both products those + // live on the Handsontable entry and HyperFormula's own entry has neither. + const licensedEntry: unknown = data.payload.products[data.licensedProductName] + const termsSource = isRev5 ? hyperformulaGrant : (isProductGrant(licensedEntry) ? licensedEntry : undefined) + + let usageUntil: number | null = null + let releaseUntil: number | null = null + + if (isRev5 && termsSource !== undefined) { + if (termsSource.usage_until !== undefined) { + usageUntil = readDate(termsSource.usage_until) + if (usageUntil === null) { + return null + } + } + if (termsSource.release_until !== undefined) { + releaseUntil = readDate(termsSource.release_until) + if (releaseUntil === null) { + return null + } + } + } + + // The two rev-5 date fields are specified as mutually exclusive, but a hand-built payload can + // carry both, and the date used and the axis it is compared against MUST come from the same + // field - otherwise a usage deadline would be checked against the build date, which either + // never expires or expires on the wrong axis. `usage_until` wins, and the axis follows it. + const comparedAgainstReleaseDate = usageUntil === null + && (releaseUntil !== null || data.keyType === 'perpetual') + const expiryTimestamp = usageUntil ?? releaseUntil ?? data.expiryTimestamp + const flags = readStrings(termsSource?.flags) + // A release-date comparison has no grace period: it is static, so there is no window to be + // inside of. + const graceDays = comparedAgainstReleaseDate ? 0 : readDays(termsSource?.grace) + + return { + capabilityTokens, + expiry: expiryTimestamp === null + ? {kind: 'none', date: null, noticeDays: 0, graceDays: 0} + : { + kind: comparedAgainstReleaseDate ? 'release' : 'usage', + // UTC midnight by construction, so this round-trips a payload's own `YYYY-MM-DD` exactly. + date: new Date(expiryTimestamp).toISOString().slice(0, 10), + noticeDays: readDays(termsSource?.notice), + graceDays, + }, + expiryTimestamp, + comparedAgainstReleaseDate, + graceDays, + isTrial: data.keyType === 'trial' || flags.indexOf('trial') !== -1, + // Every spelling the key spec uses for "suppress console output" - see SILENT_CONSOLE_FLAGS. + // The key's flags are the ONLY source of silence: an earlier revision also silenced any key + // carrying an unrecognized token, which suppressed strictly more than D3 asks for (it would + // have swallowed expiry notices too). Kuba confirmed that was an implementation error (12.08). + silent: flags.some((flag) => SILENT_CONSOLE_FLAGS.indexOf(flag) !== -1), + } +} + +/** + * Whether an intact typed key is still valid, and if not, the day it stopped being valid. + * + * A key with no expiry never expires. Otherwise the expiration date is INCLUSIVE of its last + * valid day, and a grace period extends it further. A date compared against the build's release + * date involves no clock at all, which is what keeps an air-gapped install with a wrong system + * clock working. + * + * An unknown release date resolves to "not expired", matching what the legacy validator already + * does when `HT_RELEASE_DATE` is missing: a build that cannot tell its own age must not start + * rejecting keys that customers paid for. + * + * @param {LicenseTerms} terms - the reconciled terms of the key + */ +function validityOf(terms: LicenseTerms): {state: LicenseKeyValidityState, expiredOn?: Date} { + if (terms.expiryTimestamp === null) { + return {state: LicenseKeyValidityState.VALID} + } + + const now = terms.comparedAgainstReleaseDate ? releaseDateTimestamp() : Date.now() + + if (now === null) { + return {state: LicenseKeyValidityState.VALID} + } + + const deadline = terms.expiryTimestamp + MILLISECONDS_PER_DAY + (terms.graceDays * MILLISECONDS_PER_DAY) + + return now < deadline + ? {state: LicenseKeyValidityState.VALID} + // The reported day is the first day NOT covered, which is the convention the legacy validator + // already uses for the same message (it reports `keyValidityDays + 1`). + : {state: LicenseKeyValidityState.EXPIRED, expiredOn: new Date(terms.expiryTimestamp + MILLISECONDS_PER_DAY)} +} + +/** + * Turns the reconciled terms of an intact, unexpired typed key into the entitlement it grants. + * + * Per HF-307 decision D3 this is fail-closed and silent: a token this version does not recognize + * is recorded in `unrecognizedCapabilities` and grants nothing, without a warning, a message, or + * anything public to read it back from. "Silent" there means the *grant* is silent — whether the + * key's console messages are suppressed is decided solely by its `flags` (`terms.silent`), never + * by the presence of an unrecognized token; coupling the two suppressed expiry notices as a side + * effect of a vocabulary mismatch, and was confirmed an implementation error (Kuba, 12.08). + * + * @param {LicenseTerms} terms - the reconciled terms of the key + */ +function entitlementOf(terms: LicenseTerms): LicenseEntitlement { + const unrecognizedCapabilities = terms.capabilityTokens.filter((token) => !CAPABILITY_TABLE.has(token)) + + return { + unrestricted: false, + capabilities: new Set(terms.capabilityTokens), + unrecognizedCapabilities, + expiry: terms.expiry, + silent: terms.silent, + isTrial: terms.isTrial, + } +} + +/** + * Resolves a license key into both gates' inputs. + * + * A typed key is recognized first; anything else — `gpl-v3`, a legacy key, an empty string, or + * a malformed typed key — falls through to {@link checkLicenseKeyValidity} completely unchanged, + * which is what keeps this from touching existing behaviour. + * + * **The invariant this function exists to protect.** Only a VALID typed key resolves to a + * restricted entitlement. Every other outcome — missing, invalid, or expired, for a typed key as + * much as for a legacy one — resolves to {@link unrestrictedEntitlement}. That asymmetry is + * deliberate and load-bearing: gate A already stops formula evaluation on its own (a bad key + * yields `#LIC!` in cells), while gate B additionally makes PR 2's `ensureCapability` throw from + * the CRUD API. Letting a bad key restrict the entitlement would turn today's "formulas fail, + * the API still works" into "the API throws", which is a silent breaking change for every + * existing user whose key lapsed. D3's fail-closed rule governs unrecognized tokens INSIDE an + * otherwise valid key; it is not a rule about invalid keys, and conflating the two is exactly + * the mistake this comment is here to prevent. + * + * A checksum-valid key whose terms cannot be read is INVALID, not a crash and not a free pass: + * every payload field is untrusted, so nothing here may assume a shape. + * + * @param {string} licenseKey - the raw `licenseKey` config value + */ +export function resolveLicense(licenseKey: string): ResolvedLicense { + const typedKeyData = extractTypedKeyData(licenseKey) + + if (typedKeyData === null) { + return { + validityState: checkLicenseKeyValidity(licenseKey), + entitlement: unrestrictedEntitlement(), + } + } + + const terms = licenseTermsOf(typedKeyData) + + if (terms === null) { + notifyLicenseKeyState(LicenseKeyValidityState.INVALID) + + return {validityState: LicenseKeyValidityState.INVALID, entitlement: unrestrictedEntitlement()} + } + + const {state, expiredOn} = validityOf(terms) + + if (!terms.silent) { + notifyLicenseKeyState(state, expiredOn) + } + + return { + validityState: state, + entitlement: state === LicenseKeyValidityState.VALID ? entitlementOf(terms) : unrestrictedEntitlement(), + } +} diff --git a/src/license/vendor/PROVENANCE.md b/src/license/vendor/PROVENANCE.md new file mode 100644 index 000000000..4908e8292 --- /dev/null +++ b/src/license/vendor/PROVENANCE.md @@ -0,0 +1,92 @@ +# Vendored typed-key reader — provenance and drift control + +The files in this directory are a **TypeScript port of code owned by another Handsoncode +repository**, not original HyperFormula code. Treat them as a mirror: fix bugs upstream first, +then re-port. A local-only fix here silently forks the two copies, and a forked checksum or +parser rejects genuine customer keys. + +## Upstream + +| | | +|---|---| +| Repository | `handsontable/license-key` (private) | +| Branch | `develop` | +| Commit | `7553d0d1208f483c3d744e3a1d09c1f51ba48c1e` | +| Ported on | 2026-08-11 | +| Reference docs | the format and design notes kept alongside the upstream sources | + +## Files + +Hashes are of the **upstream** `.js` sources at the commit above, so drift is detectable without +storing a copy of them here. + +| This directory | Upstream `src/typed-key/` | Upstream sha256 | +|---|---|---| +| `constants.ts` | `constants.js` | `2f987427ba3d012917c5972714b964b26f877b2e91a57790b37249928c72f5b6` | +| `defaultSchema.ts` | `default-schema.js` | `f905f1a0a6fef9b0c247a0fdb0642d5018d30fc915314ac8b10976cec8be9fc8` | +| `utils.ts` | `utils.js` | `135a8396bb22f424160fc651e899931d4be807df9b94c6dd24bb1cf6526e0541` | +| `sha512.ts` | `sha512.js` | `668dd1109160b92965a1f9a9c5fb78dfdc1e5b7e93f635a147ae8a6bb2a5d837` | +| `extractKeyData.ts` | `extract-key-data.js` | `e6f854f10c6679136d382afe0bcf4fb1d4f9709416c68247a9cdb2236b7eec23` | + +### Checking for drift + +The check is manual and needs read access to the private repository — HyperFormula's own CI +cannot do it, which is exactly why the hashes are written down here. + +```bash +git clone git@github.com:handsontable/license-key.git +cd license-key/src/typed-key +sha256sum constants.js default-schema.js utils.js sha512.js extract-key-data.js +``` + +Any hash that differs from the table means upstream moved. Re-read the changed file and re-port +it, then update this table together with the code in the same commit. + +## Not vendored, on purpose + +| Upstream file | Why not | +|---|---| +| `generate-key.js` | Mints keys. HyperFormula only ever reads them. | +| `create-engine.js` | Binds the API to a custom schema; HyperFormula uses the default one. | +| `validate-schema.js` | Only reachable when a *custom* schema is passed — dead code here. | +| `validate-key.js` | A two-line boolean wrapper over `extractTypedKeyData`; the extractor is called directly. | + +From `utils.js`, the two generation-side helpers `bytesToBase64` and `stringToBase64Url` are +also left out. Everything else in that file is ported. + +`default-schema.js` is ported **whole**, including the prose wordings that only generation reads. +Two reasons: it keeps the file a faithful copy so the hash check above stays meaningful, and the +keys of `scopeWordings` / `addonWordings` are the tier and add-on vocabulary +(`freemium | crm | data_grid | excel_simulator`, `spreadsheet | import_export`) that the +capability table is keyed on — having it here lets a test assert the two agree. + +## Deliberate divergences from upstream + +`allowJs` is off in HyperFormula's `tsconfig.json` and `strict` is on, so these files are a port +rather than a copy. Beyond adding types, the semantics were kept identical except for the +following, which a drift review should expect to see: + +1. **The custom-schema parameter is dropped.** `extractTypedKeyData(licenseKey, schema?)` becomes + `extractTypedKeyData(licenseKey)`, always reading with `DEFAULT_TYPED_KEY_SCHEMA`. This is what + removes the need for `validate-schema.js`. +2. **`extractTypedKeyData` also returns `licensedProductName`.** Upstream returns the derived + `expiryTimestamp` but not which product entry it came from, and the grace period lives on that + same entry. Returning the name avoids re-implementing the "first schema product present in the + payload" rule in the caller, where it could drift from the rule used to derive the expiry. +3. **`extractExpiryTimestamp` became `resolveLicensedProduct`,** returning + `{name, expiryTimestamp} | null` instead of `number | null | undefined`. Upstream needs the + `undefined` sentinel because `null` already means "never expires"; folding the name in gives + one unambiguous `null` for "malformed". +4. **`stringToUtf8Bytes`'s parameter is named `text`, not `string`,** which is a type keyword in + TypeScript. +5. **Payload fields are typed `unknown`.** Field types are checked when a key is generated, which + constrains nothing about a payload that reaches the reader, so consumers must narrow a field + before using it rather than trusting its declared shape. + +Upstream's `/* eslint-disable */` pragmas were dropped where HyperFormula's own ESLint config +does not need them. + +## Related + +- `src/helpers/licenseKeyHelper.ts` — the validator for the older key format, untouched here. +- `src/license/capabilities.ts` — the capability table keyed on the tier/add-on vocabulary above. diff --git a/src/license/vendor/constants.ts b/src/license/vendor/constants.ts new file mode 100644 index 000000000..f5fd9aac7 --- /dev/null +++ b/src/license/vendor/constants.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/typed-key/constants.js`. + * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + */ + +/** + * The typed license key format versions this library can read. The version is stamped into the + * key payload (the `v` field) at generation and checked automatically at extraction — the format + * version describes HOW the key is parsed (envelope, encoding, checksum), unlike the schema, + * which describes WHAT the key grants. When a new format version ships, it is ADDED here (with + * per-version handling where needed) so one build keeps reading all the already-issued keys. + */ +export const TYPED_KEY_SUPPORTED_VERSIONS: number[] = [1] + +/** + * The length of the checksum (SHA-512 as hex) which postfixes every typed license key. + */ +export const TYPED_KEY_CHECKSUM_LENGTH = 128 diff --git a/src/license/vendor/defaultSchema.ts b/src/license/vendor/defaultSchema.ts new file mode 100644 index 000000000..7cb6d0ca0 --- /dev/null +++ b/src/license/vendor/defaultSchema.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/typed-key/default-schema.js`. + * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + */ + +import {deepFreeze} from './utils' + +/** + * One key type of the typed-key schema: its tag, the legal wording it is spelled with, and + * whether it carries an expiration date and a hard stop. + * + * Only `tag` is read while parsing a key. The remaining fields describe the human-readable prose + * and are used at generation, which HyperFormula never does; they are kept so this file stays a + * faithful copy of the upstream vocabulary and so drift is detectable by hashing. + */ +export interface TypedKeyTypeDefinition { + readonly tag: string, + readonly legalClauses: readonly string[], + readonly expiryWording: string, + readonly expires: boolean, + readonly hasHardStop: boolean, +} + +/** + * One product of the typed-key schema. The keys of `scopeWordings` are the tier vocabulary of + * that product (or `'tier:mode'` pairs for a product with deployment modes), and the keys of + * `addonWordings` are its add-on vocabulary — that is where HyperFormula's + * `freemium | crm | data_grid | excel_simulator` tiers and `spreadsheet | import_export` add-ons + * are defined. + */ +export interface TypedKeyProductDefinition { + readonly name: string, + readonly displayName: string, + readonly modes?: readonly string[], + readonly defaultMode?: string, + readonly scopeWordings: Readonly>, + readonly addonWordings?: Readonly>, +} + +/** + * The typed-key schema: the marketing-owned vocabulary of the license keys. + */ +export interface TypedKeySchema { + readonly keyTypes: Readonly>, + /** ARRAY, not a map — the order is the priority order used to pick the licensed product. */ + readonly products: readonly TypedKeyProductDefinition[], +} + +/** + * The default typed-key schema: the marketing-owned vocabulary of the license keys. The schema + * describes WHAT can be licensed (products, tiers, modes, add-ons) and HOW it is worded in the + * human-readable part of the key (legal clauses, scope wordings, expiry wordings). + * + * The engine (generate/validate/extract) only defines the key FORMAT — the type tag, the + * prose/payload structure, the checksum, the version stamping, and the strict validation rules. + * + * Compatibility rules that matter to a reader such as HyperFormula: + * - key type names and tags are append-only — renaming or removing one makes already-issued keys + * of that type unreadable; + * - product names are append-only for the same reason (the expiration time is derived from the + * first schema product found in the payload); + * - wordings and legal clauses may change freely — they only affect newly generated keys, + * already-issued keys stay valid (the checksum covers whatever prose they were born with). + * + * It is deeply frozen so it cannot be mutated in place. + */ +export const DEFAULT_TYPED_KEY_SCHEMA: TypedKeySchema = deepFreeze({ + // Every key type defines its tag, its legal wording (the "{PRODUCT}" placeholder is replaced + // with the licensed product display name), the beginning of the expiration clause, and two + // flags: "expires" (does the key carry an expiration date) and "hasHardStop" (does it stop + // working "grace" days after the expiration - such keys require the grace period in the + // payload). + keyTypes: { + trial: { + tag: '[TRIAL]', + legalClauses: [ + 'is_granted_for_evaluation_only', + 'Use_in_production_is_not_permitted', + 'Please_report_misuse_to_legal@handsontable.com', + 'For_purchasing_contact_sales@handsontable.com', + ], + expiryWording: 'This_key_will_deactivate_on', + expires: true, + hasHardStop: true, + }, + freemium: { + tag: '[FREE]', + legalClauses: [ + 'is_granted_under_the_Free_plan', + 'Use_is_subject_to_the_{PRODUCT}_Free_License_Terms', + 'Features_beyond_the_Free_plan_require_a_commercial_license', + 'To_upgrade_contact_sales@handsontable.com', + ], + expiryWording: 'This_key_does_not_expire', + expires: false, + hasHardStop: false, + }, + subscription: { + tag: '[SUB]', + legalClauses: [ + 'is_granted_under_a_subscription_license', + 'Use_after_expiry_is_not_permitted_per_the_subscription_agreement', + 'To_renew_contact_sales@handsontable.com', + ], + expiryWording: 'This_key_will_deactivate_on', + expires: true, + hasHardStop: true, + }, + perpetual: { + tag: '[PERP]', + legalClauses: [ + 'is_granted_under_a_perpetual_license', + 'Access_to_new_versions_ends_when_maintenance_expires', + 'Versions_released_before_that_date_may_be_used_indefinitely', + 'To_renew_maintenance_contact_sales@handsontable.com', + ], + expiryWording: 'Maintenance_ends_on', + expires: true, + hasHardStop: false, + }, + }, + // The products, in priority order: the FIRST product of this list found in the payload is the + // "licensed product" - it carries the expiration date and the grace period, and its display + // name is spelled in the key header. + // + // Every product defines its scope wordings (tier, or "tier:mode" when the product supports + // deployment modes) and optionally its add-on wordings. + products: [ + { + name: 'handsontable', + displayName: 'Handsontable', + modes: ['internal', 'saas'], + defaultMode: 'internal', + scopeWordings: { + freemium: 'Free', + 'enterprise:internal': 'Enterprise', + 'enterprise:saas': 'Enterprise_SaaS', + }, + }, + { + name: 'hyperformula', + displayName: 'HyperFormula', + scopeWordings: { + freemium: 'HyperFormula_Free', + crm: 'HyperFormula_CRM', + data_grid: 'HyperFormula_Data_Grid', + excel_simulator: 'HyperFormula_Excel_Simulator', + }, + addonWordings: { + spreadsheet: 'Spreadsheet_addon', + import_export: 'Import_Export_addon', + }, + }, + ], +}) + +/** + * The name of HyperFormula's own product entry in the typed-key payload. Note this is NOT + * necessarily the *licensed* product of a key: a key that grants both Handsontable and + * HyperFormula carries its expiration date on the Handsontable entry, because that product comes + * first in {@link DEFAULT_TYPED_KEY_SCHEMA}'s priority order. + */ +export const HYPERFORMULA_PRODUCT_NAME = 'hyperformula' diff --git a/src/license/vendor/extractKeyData.ts b/src/license/vendor/extractKeyData.ts new file mode 100644 index 000000000..0a95cd753 --- /dev/null +++ b/src/license/vendor/extractKeyData.ts @@ -0,0 +1,215 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/typed-key/extract-key-data.js`. + * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + * + * Two deliberate divergences from upstream, both recorded in the manifest: the custom-schema + * parameter is dropped (HyperFormula always reads with {@link DEFAULT_TYPED_KEY_SCHEMA}, so + * upstream's `validateTypedKeySchema` branch is unreachable here), and the result additionally + * carries {@link TypedKeyData.licensedProductName} so a caller can find the grace period without + * re-implementing the licensed-product rule. + */ + +import {TYPED_KEY_CHECKSUM_LENGTH, TYPED_KEY_SUPPORTED_VERSIONS} from './constants' +import {DEFAULT_TYPED_KEY_SCHEMA} from './defaultSchema' +import {sha512} from './sha512' +import {base64ToString, parseIsoDate, stringToUtf8Bytes} from './utils' + +/** + * One product entry of a typed key payload. + * + * Every field is typed `unknown` on purpose. Field types are checked when a key is generated, + * which constrains nothing about a payload that actually reaches this code, and the checksum + * establishes only that the payload arrived intact. Every consumer must therefore narrow a + * field before using it rather than trusting its declared shape. + */ +export interface TypedKeyProductGrant { + readonly tier?: unknown, + readonly mode?: unknown, + readonly addons?: unknown, + readonly exp?: unknown, + readonly grace?: unknown, +} + +/** + * A typed key payload. Only `v` is verified before this type is handed out (against + * {@link TYPED_KEY_SUPPORTED_VERSIONS}); see {@link TypedKeyProductGrant} for why the rest is + * `unknown`. + */ +export interface TypedKeyPayload { + readonly v: number, + readonly products: Readonly>, + readonly ref?: unknown, + readonly holder?: unknown, + readonly iss?: unknown, +} + +/** + * The machine-readable content of an intact typed license key. + */ +export interface TypedKeyData { + /** One of `'trial'`, `'freemium'`, `'subscription'`, `'perpetual'`. */ + readonly keyType: string, + readonly payload: TypedKeyPayload, + /** + * The expiration time derived from the payload, as epoch milliseconds; `null` means the key + * never expires. `null` rather than a number is deliberate — a real timestamp of `0` (a key + * dated 1970-01-01) must stay distinguishable from "never". + */ + readonly expiryTimestamp: number | null, + /** + * The name of the licensed product: the first schema product present in the payload. It is the + * only entry allowed to carry `exp` and `grace`, so a key granting both Handsontable and + * HyperFormula carries its expiry and grace period on the Handsontable entry. + */ + readonly licensedProductName: string, +} + +/** + * The licensed product of a payload, together with the expiration time derived from it. + */ +interface LicensedProduct { + readonly name: string, + readonly expiryTimestamp: number | null, +} + +/** + * Resolves the licensed product of the payload and derives its expiration time. The expiration + * date (`exp`, in the `YYYY-MM-DD` format) is converted to epoch milliseconds (UTC midnight). A + * payload without the expiration date (a freemium key) maps to `null`, which means the key never + * expires. Returns `null` when the payload does not have the expected shape. + * + * Upstream returns only the timestamp, using `undefined` as its "malformed" sentinel because + * `null` already means "never expires"; folding the product name in lets this return one + * unambiguous `null` instead. + * + * @param {TypedKeyPayload} payload - the key payload + */ +function resolveLicensedProduct(payload: TypedKeyPayload): LicensedProduct | null { + const {products} = payload + + if (products === null || typeof products !== 'object' || Array.isArray(products)) { + return null + } + + const schemaProductNames = DEFAULT_TYPED_KEY_SCHEMA.products.map((schemaProduct) => schemaProduct.name) + + // A payload granting a product this schema does not know cannot be read reliably - the + // licensed product (and so the expiry) could be resolved wrongly. Reject it instead of + // guessing; product lists are append-only and the reading side has to know at least as much as + // the generating one. + if (Object.keys(products).some((name) => schemaProductNames.indexOf(name) === -1)) { + return null + } + + // The licensed product is the first schema product present in the payload (the schema order + // defines the priority). Presence is read own-property only, so an inherited prototype-chain + // property cannot masquerade as a granted product. + const hasOwn = (name: string) => Object.prototype.hasOwnProperty.call(products, name) + const licensedProductName = schemaProductNames.find(hasOwn) + const licensedProduct = licensedProductName === undefined ? undefined : products[licensedProductName] + + if (licensedProductName === undefined || licensedProduct === undefined || licensedProduct === null + || typeof licensedProduct !== 'object' || Array.isArray(licensedProduct)) { + return null + } + if (licensedProduct.exp === undefined) { + return {name: licensedProductName, expiryTimestamp: null} + } + + try { + return {name: licensedProductName, expiryTimestamp: parseIsoDate(String(licensedProduct.exp), 'expiration').timestamp} + } catch (error) { + // A malformed or impossible date - such a payload is not trustworthy. + return null + } +} + +/** + * Extracts the machine-readable data from a typed license key (`[TRIAL]`, `[FREE]`, `[SUB]` or + * `[PERP]`). The function verifies the checksum first, so the returned data is guaranteed to + * belong to an intact key. For a malformed or tampered key `null` is returned. + * + * The expiration time itself is not checked here — it is up to the caller to compare it against + * the current time (trial, subscription) or the build release date (perpetual). + * + * @param {string} licenseKey - the license key to extract the data from + */ +export function extractTypedKeyData(licenseKey: string): TypedKeyData | null { + // The key alphabet has no whitespace, so trimming is lossless - keys are commonly pasted with + // a trailing newline (email, terminal). + const key = `${licenseKey}`.trim() + const keyType = Object.keys(DEFAULT_TYPED_KEY_SCHEMA.keyTypes) + .find((type) => key.indexOf(`${DEFAULT_TYPED_KEY_SCHEMA.keyTypes[type].tag}_`) === 0) + + if (keyType === undefined) { + return null + } + if (key.length <= TYPED_KEY_CHECKSUM_LENGTH) { + return null + } + + const keyBody = key.slice(0, -TYPED_KEY_CHECKSUM_LENGTH) + const checksum = key.slice(-TYPED_KEY_CHECKSUM_LENGTH) + + if (!/^[0-9a-f]+$/.test(checksum)) { + return null + } + if (sha512(stringToUtf8Bytes(keyBody)) !== checksum) { + return null + } + + // The quadruple underscore separates the human-readable part from the machine-readable one. + // The LAST occurrence is used - the payload (base64 of valid UTF-8) can never contain four + // consecutive underscores, while the human-readable part could (underscore runs are sanitized + // at generation, but a lenient search keeps the parser robust). + const separatorIndex = keyBody.lastIndexOf('____') + + if (separatorIndex === -1) { + return null + } + + // The machine-readable part is the payload encoded as URL-safe base64. + const payloadJson = base64ToString(keyBody.slice(separatorIndex + 4)) + + if (payloadJson === null) { + return null + } + + let parsed: unknown + + try { + parsed = JSON.parse(payloadJson) + } catch (error) { + return null + } + + if (parsed === null || typeof parsed !== 'object') { + return null + } + + const payload = parsed as TypedKeyPayload + + // Keys stamped with a format version this library does not know are not readable - the format + // version describes HOW the key is parsed. + if (TYPED_KEY_SUPPORTED_VERSIONS.indexOf(payload.v) === -1) { + return null + } + + const licensedProduct = resolveLicensedProduct(payload) + + if (licensedProduct === null) { + return null + } + + return { + keyType, + payload, + expiryTimestamp: licensedProduct.expiryTimestamp, + licensedProductName: licensedProduct.name, + } +} diff --git a/src/license/vendor/sha512.ts b/src/license/vendor/sha512.ts new file mode 100644 index 000000000..91eab847b --- /dev/null +++ b/src/license/vendor/sha512.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/typed-key/sha512.js`. + * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + */ + +/** + * The SHA-512 round constants. Each 64-bit constant is stored as a pair of 32-bit integers + * (high word first, low word second). + */ +const K: number[] = [ + 0x428a2f98, 0xd728ae22, 0x71374491, 0x23ef65cd, + 0xb5c0fbcf, 0xec4d3b2f, 0xe9b5dba5, 0x8189dbbc, + 0x3956c25b, 0xf348b538, 0x59f111f1, 0xb605d019, + 0x923f82a4, 0xaf194f9b, 0xab1c5ed5, 0xda6d8118, + 0xd807aa98, 0xa3030242, 0x12835b01, 0x45706fbe, + 0x243185be, 0x4ee4b28c, 0x550c7dc3, 0xd5ffb4e2, + 0x72be5d74, 0xf27b896f, 0x80deb1fe, 0x3b1696b1, + 0x9bdc06a7, 0x25c71235, 0xc19bf174, 0xcf692694, + 0xe49b69c1, 0x9ef14ad2, 0xefbe4786, 0x384f25e3, + 0x0fc19dc6, 0x8b8cd5b5, 0x240ca1cc, 0x77ac9c65, + 0x2de92c6f, 0x592b0275, 0x4a7484aa, 0x6ea6e483, + 0x5cb0a9dc, 0xbd41fbd4, 0x76f988da, 0x831153b5, + 0x983e5152, 0xee66dfab, 0xa831c66d, 0x2db43210, + 0xb00327c8, 0x98fb213f, 0xbf597fc7, 0xbeef0ee4, + 0xc6e00bf3, 0x3da88fc2, 0xd5a79147, 0x930aa725, + 0x06ca6351, 0xe003826f, 0x14292967, 0x0a0e6e70, + 0x27b70a85, 0x46d22ffc, 0x2e1b2138, 0x5c26c926, + 0x4d2c6dfc, 0x5ac42aed, 0x53380d13, 0x9d95b3df, + 0x650a7354, 0x8baf63de, 0x766a0abb, 0x3c77b2a8, + 0x81c2c92e, 0x47edaee6, 0x92722c85, 0x1482353b, + 0xa2bfe8a1, 0x4cf10364, 0xa81a664b, 0xbc423001, + 0xc24b8b70, 0xd0f89791, 0xc76c51a3, 0x0654be30, + 0xd192e819, 0xd6ef5218, 0xd6990624, 0x5565a910, + 0xf40e3585, 0x5771202a, 0x106aa070, 0x32bbd1b8, + 0x19a4c116, 0xb8d2d0c8, 0x1e376c08, 0x5141ab53, + 0x2748774c, 0xdf8eeb99, 0x34b0bcb5, 0xe19b48a8, + 0x391c0cb3, 0xc5c95a63, 0x4ed8aa4a, 0xe3418acb, + 0x5b9cca4f, 0x7763e373, 0x682e6ff3, 0xd6b2b8a3, + 0x748f82ee, 0x5defb2fc, 0x78a5636f, 0x43172f60, + 0x84c87814, 0xa1f0ab72, 0x8cc70208, 0x1a6439ec, + 0x90befffa, 0x23631e28, 0xa4506ceb, 0xde82bde9, + 0xbef9a3f7, 0xb2c67915, 0xc67178f2, 0xe372532b, + 0xca273ece, 0xea26619c, 0xd186b8c7, 0x21c0c207, + 0xeada7dd6, 0xcde0eb1e, 0xf57d4f7f, 0xee6ed178, + 0x06f067aa, 0x72176fba, 0x0a637dc5, 0xa2c898a6, + 0x113f9804, 0xbef90dae, 0x1b710b35, 0x131c471b, + 0x28db77f5, 0x23047d84, 0x32caab7b, 0x40c72493, + 0x3c9ebe0a, 0x15c9bebc, 0x431d67c4, 0x9c100d4c, + 0x4cc5d4be, 0xcb3e42b6, 0x597f299c, 0xfc657e2a, + 0x5fcb6fab, 0x3ad6faec, 0x6c44198c, 0x4a475817, +] + +/** + * Converts a 32-bit integer to a zero-padded 8-character hex string. + * + * @param {number} value - the 32-bit integer value + */ +function toHex32(value: number): string { + return `00000000${(value >>> 0).toString(16)}`.slice(-8) +} + +/** + * Calculates the SHA-512 checksum of the passed bytes. The implementation is a plain (pure JS) + * one on purpose. It does not depend on the Web Crypto API (`crypto.subtle`), which browsers + * expose only on secure origins (https). Thanks to that, the checksum can be verified on plain + * http:// pages, for example, intranets of big companies. + * + * A second reason applies on HyperFormula's side: `crypto.subtle.digest` is asynchronous, and + * the license key is read from `Config`'s constructor, which is not. + * + * @param {number[] | Uint8Array} bytes - the bytes to calculate the checksum from + */ +export function sha512(bytes: number[] | Uint8Array): string { + const byteLength = bytes.length + // The message is padded with the 0x80 byte, zeros, and the 128-bit big-endian bit length so + // the total length is a multiple of 128 bytes. + const blockCount = Math.ceil((byteLength + 17) / 128) + const buffer = new Uint8Array(blockCount * 128) + + buffer.set(bytes) + buffer[byteLength] = 0x80 + + const bitLength = byteLength * 8 + const bufferLength = buffer.length + + // The supported message sizes fit well within 2^53 bits, so only the two lowest 32-bit words + // of the 128-bit length field are ever non-zero. + buffer[bufferLength - 7] = Math.floor(bitLength / 0x1000000000000) & 0xff // bits 48-55 + buffer[bufferLength - 6] = Math.floor(bitLength / 0x10000000000) & 0xff // bits 40-47 + buffer[bufferLength - 5] = Math.floor(bitLength / 0x100000000) & 0xff // bits 32-39 + buffer[bufferLength - 4] = (bitLength >>> 24) & 0xff // bits 24-31 + buffer[bufferLength - 3] = (bitLength >>> 16) & 0xff // bits 16-23 + buffer[bufferLength - 2] = (bitLength >>> 8) & 0xff // bits 8-15 + buffer[bufferLength - 1] = bitLength & 0xff // bits 0-7 + + // The initial hash values, stored as [high, low] 32-bit pairs. + const H: number[] = [ + 0x6a09e667, 0xf3bcc908, 0xbb67ae85, 0x84caa73b, + 0x3c6ef372, 0xfe94f82b, 0xa54ff53a, 0x5f1d36f1, + 0x510e527f, 0xade682d1, 0x9b05688c, 0x2b3e6c1f, + 0x1f83d9ab, 0xfb41bd6b, 0x5be0cd19, 0x137e2179, + ] + const wh = new Array(80) + const wl = new Array(80) + + for (let block = 0; block < blockCount; block += 1) { + const offset = block * 128 + + // Prepare the message schedule. + for (let i = 0; i < 16; i += 1) { + const o = offset + i * 8 + + wh[i] = ((buffer[o] << 24) | (buffer[o + 1] << 16) | (buffer[o + 2] << 8) | buffer[o + 3]) >>> 0 + wl[i] = ((buffer[o + 4] << 24) | (buffer[o + 5] << 16) | (buffer[o + 6] << 8) | buffer[o + 7]) >>> 0 + } + + for (let i = 16; i < 80; i += 1) { + const x2h = wh[i - 2] + const x2l = wl[i - 2] + const x15h = wh[i - 15] + const x15l = wl[i - 15] + // smallSigma1 = ROTR^19(x) XOR ROTR^61(x) XOR SHR^6(x) + const s1h = ((x2h >>> 19) | (x2l << 13)) ^ ((x2l >>> 29) | (x2h << 3)) ^ (x2h >>> 6) + const s1l = ((x2l >>> 19) | (x2h << 13)) ^ ((x2h >>> 29) | (x2l << 3)) ^ ((x2l >>> 6) | (x2h << 26)) + // smallSigma0 = ROTR^1(x) XOR ROTR^8(x) XOR SHR^7(x) + const s0h = ((x15h >>> 1) | (x15l << 31)) ^ ((x15h >>> 8) | (x15l << 24)) ^ (x15h >>> 7) + const s0l = ((x15l >>> 1) | (x15h << 31)) ^ ((x15l >>> 8) | (x15h << 24)) ^ ((x15l >>> 7) | (x15h << 25)) + + const lowSum = (s1l >>> 0) + (wl[i - 7] >>> 0) + (s0l >>> 0) + (wl[i - 16] >>> 0) + + wl[i] = lowSum >>> 0 + wh[i] = ((s1h >>> 0) + (wh[i - 7] >>> 0) + (s0h >>> 0) + (wh[i - 16] >>> 0) + + Math.floor(lowSum / 0x100000000)) >>> 0 + } + + let ah = H[0] + let al = H[1] + let bh = H[2] + let bl = H[3] + let ch = H[4] + let cl = H[5] + let dh = H[6] + let dl = H[7] + let eh = H[8] + let el = H[9] + let fh = H[10] + let fl = H[11] + let gh = H[12] + let gl = H[13] + let hh = H[14] + let hl = H[15] + + for (let i = 0; i < 80; i += 1) { + // bigSigma1 = ROTR^14(e) XOR ROTR^18(e) XOR ROTR^41(e) + const bs1h = ((eh >>> 14) | (el << 18)) ^ ((eh >>> 18) | (el << 14)) ^ ((el >>> 9) | (eh << 23)) + const bs1l = ((el >>> 14) | (eh << 18)) ^ ((el >>> 18) | (eh << 14)) ^ ((eh >>> 9) | (el << 23)) + // bigSigma0 = ROTR^28(a) XOR ROTR^34(a) XOR ROTR^39(a) + const bs0h = ((ah >>> 28) | (al << 4)) ^ ((al >>> 2) | (ah << 30)) ^ ((al >>> 7) | (ah << 25)) + const bs0l = ((al >>> 28) | (ah << 4)) ^ ((ah >>> 2) | (al << 30)) ^ ((ah >>> 7) | (al << 25)) + // ch = (e AND f) XOR (NOT e AND g) + const chh = (eh & fh) ^ (~eh & gh) + const chl = (el & fl) ^ (~el & gl) + // maj = (a AND b) XOR (a AND c) XOR (b AND c) + const majh = (ah & bh) ^ (ah & ch) ^ (bh & ch) + const majl = (al & bl) ^ (al & cl) ^ (bl & cl) + + const t1LowSum = (hl >>> 0) + (bs1l >>> 0) + (chl >>> 0) + (K[i * 2 + 1] >>> 0) + (wl[i] >>> 0) + const t1l = t1LowSum >>> 0 + const t1h = ((hh >>> 0) + (bs1h >>> 0) + (chh >>> 0) + (K[i * 2] >>> 0) + + (wh[i] >>> 0) + Math.floor(t1LowSum / 0x100000000)) >>> 0 + + const t2LowSum = (bs0l >>> 0) + (majl >>> 0) + const t2l = t2LowSum >>> 0 + const t2h = ((bs0h >>> 0) + (majh >>> 0) + Math.floor(t2LowSum / 0x100000000)) >>> 0 + + hh = gh + hl = gl + gh = fh + gl = fl + fh = eh + fl = el + + const eLowSum = (dl >>> 0) + t1l + + el = eLowSum >>> 0 + eh = ((dh >>> 0) + t1h + Math.floor(eLowSum / 0x100000000)) >>> 0 + + dh = ch + dl = cl + ch = bh + cl = bl + bh = ah + bl = al + + const aLowSum = t1l + t2l + + al = aLowSum >>> 0 + ah = (t1h + t2h + Math.floor(aLowSum / 0x100000000)) >>> 0 + } + + const stateWords = [ah, al, bh, bl, ch, cl, dh, dl, eh, el, fh, fl, gh, gl, hh, hl] + + for (let i = 0; i < 16; i += 2) { + const stateLowSum = (H[i + 1] >>> 0) + (stateWords[i + 1] >>> 0) + + H[i + 1] = stateLowSum >>> 0 + H[i] = ((H[i] >>> 0) + (stateWords[i] >>> 0) + Math.floor(stateLowSum / 0x100000000)) >>> 0 + } + } + + return H.map(toHex32).join('') +} diff --git a/src/license/vendor/utils.ts b/src/license/vendor/utils.ts new file mode 100644 index 000000000..71e16285a --- /dev/null +++ b/src/license/vendor/utils.ts @@ -0,0 +1,218 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/typed-key/utils.js`. + * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + * + * The two generation-side helpers of the upstream file (`bytesToBase64`, `stringToBase64Url`) are + * deliberately not ported: HyperFormula reads keys, it never mints them. + */ + +/** + * The base64 alphabet. + */ +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +/** + * Recursively freezes the value (and every nested object). Used to make a verified schema + * immutable so it cannot drift from what was validated. + * + * @param {*} value - the value to freeze + */ +export function deepFreeze(value: T): T { + if (value !== null && typeof value === 'object') { + Object.keys(value as unknown as Record).forEach( + (key) => deepFreeze((value as unknown as Record)[key]) + ) + Object.freeze(value) + } + + return value +} + +/** + * A calendar date decomposed into its numeric parts plus the epoch milliseconds of its UTC + * midnight. + */ +export interface ParsedIsoDate { + year: number, + month: number, + day: number, + timestamp: number, +} + +/** + * Parses the date in the `YYYY-MM-DD` format into its numeric parts and the epoch milliseconds + * of its UTC midnight. Throws when the date is malformed or does not exist in the calendar. + * + * @param {string} isoDate - the date to parse + * @param {string} dateLabel - the date name used in the error message + */ +export function parseIsoDate(isoDate: string, dateLabel: string): ParsedIsoDate { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(`${isoDate}`) + + if (match === null) { + throw new Error(`The ${dateLabel} date (${isoDate}) has to be passed in the "YYYY-MM-DD" format.`) + } + + const year = parseInt(match[1], 10) + const month = parseInt(match[2], 10) + const day = parseInt(match[3], 10) + + // Date.UTC maps years 0-99 to 1900-1999, which would make the round-trip check below report a + // "not a valid calendar date" lie. + if (year < 100) { + throw new Error(`The ${dateLabel} date (${isoDate}) has to use a four-digit year of 100 or later.`) + } + + const timestamp = Date.UTC(year, month - 1, day) + const date = new Date(timestamp) + + // An impossible date (e.g. "2027-02-30") makes `Date.UTC` roll over to the next month, so a + // round-trip comparison catches it. + if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) { + throw new Error(`The ${dateLabel} date (${isoDate}) is not a valid calendar date.`) + } + + return { + year, month, day, timestamp, + } +} + +/** + * Encodes the string as UTF-8 bytes. The plain implementation is used on purpose. It does not + * depend on `TextEncoder` or `Buffer`, so the same code works in Node.js and in every browser, + * including plain http:// pages. + * + * @param {string} text - the string to encode + */ +export function stringToUtf8Bytes(text: string): number[] { + const bytes: number[] = [] + + for (let i = 0; i < text.length; i += 1) { + let codePoint = text.charCodeAt(i) + + // Combine a surrogate pair into a single code point. + if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < text.length) { + const lowSurrogate = text.charCodeAt(i + 1) + + if (lowSurrogate >= 0xdc00 && lowSurrogate <= 0xdfff) { + codePoint = ((codePoint - 0xd800) * 0x400) + (lowSurrogate - 0xdc00) + 0x10000 + i += 1 + } + } + + if (codePoint < 0x80) { + bytes.push(codePoint) + } else if (codePoint < 0x800) { + bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f)) + } else if (codePoint < 0x10000) { + bytes.push( + 0xe0 | (codePoint >> 12), + 0x80 | ((codePoint >> 6) & 0x3f), + 0x80 | (codePoint & 0x3f), + ) + } else { + bytes.push( + 0xf0 | (codePoint >> 18), + 0x80 | ((codePoint >> 12) & 0x3f), + 0x80 | ((codePoint >> 6) & 0x3f), + 0x80 | (codePoint & 0x3f), + ) + } + } + + return bytes +} + +/** + * Decodes UTF-8 bytes back into a string. + * + * @param {number[]} bytes - the bytes to decode + */ +export function utf8BytesToString(bytes: number[]): string { + let text = '' + let i = 0 + + while (i < bytes.length) { + const byte = bytes[i] + let codePoint + + if (byte < 0x80) { + codePoint = byte + i += 1 + } else if (byte < 0xe0) { + codePoint = ((byte & 0x1f) << 6) | (bytes[i + 1] & 0x3f) + i += 2 + } else if (byte < 0xf0) { + codePoint = ((byte & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f) + i += 3 + } else { + codePoint = ((byte & 0x07) << 18) | ((bytes[i + 1] & 0x3f) << 12) + | ((bytes[i + 2] & 0x3f) << 6) | (bytes[i + 3] & 0x3f) + i += 4 + } + + if (codePoint >= 0x10000) { + // Split the code point back into a surrogate pair. + codePoint -= 0x10000 + text += String.fromCharCode(0xd800 + (codePoint >> 10), 0xdc00 + (codePoint & 0x3ff)) + } else { + text += String.fromCharCode(codePoint) + } + } + + return text +} + +/** + * Decodes a base64 string (standard or URL-safe alphabet, padding optional) back into bytes. + * Returns `null` when the string is not valid base64. + * + * @param {string} base64 - the base64 string to decode + */ +export function base64ToBytes(base64: string): number[] | null { + const normalized = `${base64}`.replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '') + + if (!/^[A-Za-z0-9+/]*$/.test(normalized) || normalized.length % 4 === 1) { + return null + } + + const bytes: number[] = [] + + for (let i = 0; i < normalized.length; i += 4) { + const chunk = [0, 1, 2, 3].map((offset) => { + const char = normalized.charAt(i + offset) + + // `indexOf('')` would return 0, so the missing characters of the last chunk have to be + // mapped to -1 explicitly. + return char === '' ? -1 : BASE64_ALPHABET.indexOf(char) + }) + + bytes.push((chunk[0] << 2) | (chunk[1] >> 4)) + + if (chunk[2] !== -1) { + bytes.push(((chunk[1] & 0x0f) << 4) | (chunk[2] >> 2)) + } + if (chunk[3] !== -1) { + bytes.push(((chunk[2] & 0x03) << 6) | chunk[3]) + } + } + + return bytes +} + +/** + * Decodes a base64 (standard or URL-safe) string back into a string. Returns `null` when the + * input is not valid base64. + * + * @param {string} base64 - the base64 string to decode + */ +export function base64ToString(base64: string): string | null { + const bytes = base64ToBytes(base64) + + return bytes === null ? null : utf8BytesToString(bytes) +}