diff --git a/CHANGELOG.md b/CHANGELOG.md index aeea8dd51..5a617907e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Added support for proprietary license keys that grant a subset of the library ("feature packages and add-ons"). A function your key does not include evaluates to a `#LIC!` error, and the corresponding parts of the API throw a `LicenseCapabilityMissingError`. Keys that grant everything, including `gpl-v3`, are unaffected. [#1728](https://github.com/handsontable/hyperformula/pull/1728) [#1729](https://github.com/handsontable/hyperformula/pull/1729) [#1730](https://github.com/handsontable/hyperformula/pull/1730) +- Added a one-time console notice when a license key's usage-based expiry date falls within its configured notice period, naming the key's last covered day ("valid until … (UTC)"). The notice is silenced by the key's own silent flag, and never fires for a key expiring on the perpetual (`release_until`) axis. Blocking behavior at and after expiry is unchanged. [#1730](https://github.com/handsontable/hyperformula/pull/1730) +- Added grants to the two commercial add-on tokens: `spreadsheet` (the Spreadsheet Bundle) now grants the CRUD, undo/redo, clipboard, and batching feature areas, and `import_export` grants the reserved import/export feature that nothing gates on until the feature ships. A key naming neither add-on keeps every feature area it has today. [#1730](https://github.com/handsontable/hyperformula/pull/1730) + +### Changed + +- Changed `getAvailableFunctions()` and `getFunctionDetails()` to describe only the functions the instance's license key includes, so they no longer advertise a function that would evaluate to a `#LIC!` error. A missing, invalid, or expired key does not shorten the list. [#1730](https://github.com/handsontable/hyperformula/pull/1730) +- Changed the console message for an expired proprietary license key to stop naming the installed version when the key ran out on the usage axis rather than the maintenance axis. A maintenance key stops covering releases, so an older build keeps working; a usage-based key stops being valid at all, and there is no version to fall back to. Classic 25-character license keys have only the maintenance axis, so their message is unchanged. [#1730](https://github.com/handsontable/hyperformula/pull/1730) +- Changed the parser for the new proprietary license keys to the entitlement key format, following its upstream specification. This replaces the tagged key format, which was never issued to anyone. Classic 25-character license keys and `gpl-v3` are unaffected. [#1730](https://github.com/handsontable/hyperformula/pull/1730) +- Changed the license capability tokens to be matched case-insensitively, and to grant functions through the packaging group-token vocabulary only: `fun:all`, `fun:.`, and per-function `fun:` tokens. A key names the capabilities it grants, and the engine grants their union; it keeps no notion of which tokens make up a package. The callable forms of the calculation operators, such as `HF.ADD`, are granted by `fun:operator.A`, while the infix operators themselves work under any key. [#1730](https://github.com/handsontable/hyperformula/pull/1730) + ### Fixed - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) diff --git a/docs/guide/license-key.md b/docs/guide/license-key.md index 731c5cd08..db8321b3a 100644 --- a/docs/guide/license-key.md +++ b/docs/guide/license-key.md @@ -46,19 +46,70 @@ const options = { HyperFormula doesn't use an internet connection to validate your proprietary license key. ::: -To determine whether a user is still entitled to use a particular -version of the software, HyperFormula compares the time between -two dates: -* The HyperFormula build date -* The date in your proprietary license key +Which versions of HyperFormula a key covers, and for how long, follows from the +terms of your contract. Your key carries those terms, and HyperFormula applies +them locally, without any connection to a server. + +## Feature packages and add-ons + +A proprietary license key may grant the whole library, or only part of it. If your key covers +everything you buy nothing new to think about, and neither does the GPLv3 key `gpl-v3`, which +always grants everything. + +If your key grants only part of the library, then: + +* A function your key doesn't include evaluates to a `#LIC!` error, in the same way as any other + [error value](types-of-errors.md). Everything else in the sheet keeps calculating. +* An API method your key doesn't include throws a `LicenseCapabilityMissingError` when you call + it. Getters never throw; `copy()` and `cut()` do, because they belong to the clipboard feature. +* [`getAvailableFunctions()`](../api/classes/hyperformula.md#getavailablefunctions) and + [`getFunctionDetails()`](../api/classes/hyperformula.md#getfunctiondetails) describe only the + functions your key includes, so a function picker built from them never offers a function that + then fails. + +Custom functions you register yourself are available whatever your key grants, as long as they use +an id of their own. The licence covers built-in ids, so a plugin registered under a built-in id your +key does not include is treated as that built-in and stays unavailable — it will not be described and +it evaluates to `#LIC!`. Pick an id the built-in catalogue does not use and this cannot happen. + +Two commercial add-ons build on top of a package: + +* **Spreadsheet Bundle** grants the CRUD API (adding, removing, and moving rows, columns, sheets, + and cell contents), undo/redo, clipboard operations, and batching (`batch()` / + `suspendEvaluation()`; `resumeEvaluation()` is deliberately never gated, so an engine can always + leave a suspended state). It does not grant named expressions, which stay outside both add-ons. +* **Import/export** is reserved for a future release. HyperFormula doesn't have an import/export + feature yet, so this add-on doesn't grant or restrict anything today. + +In this release, not having either add-on doesn't restrict anything either: a key that names no +feature token at all is granted every feature area — CRUD, undo/redo, clipboard, named expressions +and batching — regardless of whether it names these add-ons. Every key issued today is of that +shape, so the add-on tokens describe what was sold rather than changing what the engine allows. -This process doesn't require any connection to the server. +::: tip +To find out which package your key includes, check your order confirmation or +[contact our team](contact.md). HyperFormula deliberately reports nothing about the contents of +your key at runtime. +::: ## License key notifications If your license key is missing, invalid, or expired, you see a corresponding notification in the console. +In that case every licence-gated function call evaluates to a `#LIC!` error — but no API method +starts throwing, and `getAvailableFunctions()` still describes the full set of functions. A key +problem never narrows what the library reports it can do. + +Arithmetic keeps working: operators such as `=A1+B1` are not function calls, so nothing gates them. +`VERSION()` and `OFFSET()` are function calls, but they are protected built-ins that sit outside the +licence system entirely, so they keep evaluating too. A sheet with a key problem therefore does not +go blank. + +A **valid** key can print one notification too: if it expires on a set date and that date is +within the notice period your license carries, the console names the last day the key covers. It +is a heads-up only — nothing is restricted while a key is valid, and the message appears once. + ## License key support If you have any issues with your license key, [contact our team](contact.md). \ No newline at end of file diff --git a/docs/guide/types-of-errors.md b/docs/guide/types-of-errors.md index 4c524877e..3a0ab3b45 100644 --- a/docs/guide/types-of-errors.md +++ b/docs/guide/types-of-errors.md @@ -37,4 +37,4 @@ according to the language settings. | #VALUE! | Wrong type of argument | It occurs when a formula tries to improperly use different types of data. For example, you will see this error when you will try to add a string to a number. | | #CYCLE! | Circular reference | It occurs when a formula refers to its own cell, both directly and indirectly. | | #ERROR! | An error occurred | It indicates that there is an unknown error in a formula. | -| #LIC! | Invalid license key | It occurs when the license key is invalid, expired, or missing. | \ No newline at end of file +| #LIC! | License key problem | It occurs when the license key is invalid, expired, or missing, or when the function is not included in the [feature package](license-key.md#feature-packages-and-add-ons) your license key grants. | \ No newline at end of file diff --git a/src/Config.ts b/src/Config.ts index 6bd96d761..3505fb141 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' @@ -180,7 +180,7 @@ export class Config implements ConfigParams, ParserConfig { /** @inheritDoc */ public readonly matchWholeCell: boolean - constructor(options: Partial = {}, showDeprecatedWarns: boolean = true) { + constructor(options: Partial = {}, showDeprecatedWarns: boolean = true, notifyLicenseMessages: boolean = true) { const { accentSensitive, caseSensitive, @@ -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, notifyLicenseMessages) 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, @@ -346,7 +342,7 @@ export class Config implements ConfigParams, ParserConfig { /** * Whether gate B (the entitlement check in the interpreter) needs to run at all for this - * config. `false` — the common case, for `gpl-v3`, legacy keys, and an unrestricted typed + * config. `false` — the common case, for `gpl-v3`, legacy keys, and an unrestricted entitlement * key — is a single boolean read, cheaper than the string-enum comparison it replaces. * * @internal @@ -370,12 +366,12 @@ export class Config implements ConfigParams, ParserConfig { return getFullConfigFromPartial(this) } - public mergeConfig(init: Partial): Config { + public mergeConfig(init: Partial, notifyLicenseMessages: boolean = true): Config { const mergedConfig: ConfigParams = Object.assign({}, this.getConfig(), init) Config.warnDeprecatedOptions(init) - return new Config(mergedConfig, false) + return new Config(mergedConfig, false, notifyLicenseMessages) } private static warnDeprecatedOptions(options: Partial) { diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index c66de99d5..772368799 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -44,7 +44,7 @@ import { import {Evaluator} from './Evaluator' import {ExportedChange, Exporter} from './Exporter' import {LicenseKeyValidityState} from './helpers/licenseKeyValidator' -import {allowsFeature} from './license/CapabilityRegistry' +import {allowsFeature, licenseAllowsFunction} from './license/CapabilityRegistry' import {FeatureId} from './license/LicenseEntitlement' import {buildTranslationPackage, RawTranslationPackage, TranslationPackage} from './i18n' import {FunctionPluginDefinition} from './interpreter' @@ -706,26 +706,58 @@ export class HyperFormula implements TypedEmitter { return {doc, metadata, aliasOf: metadataKey !== functionId ? metadataKey : undefined} } + /** + * Whether an instance's license lets it evaluate the given function id, and therefore whether the + * metadata API may describe it. Mirrors the gate-B branch the interpreter runs per function call + * (`Interpreter.evaluateAstWithoutPostprocessing`, the `FUNCTION_CALL` case), through the same + * [[licenseAllowsFunction]] rule and the same alias canonicalisation, so a listed function is + * always one that actually evaluates. + * + * Gate B only, deliberately — never the license key's validity state. A missing, invalid or expired + * key resolves to an unrestricted entitlement (the invariant `resolveLicense` documents), so it + * reaches this method with both `licenseCapabilities` axes set to `'all'` and every function + * stays listed. + * That is the intended answer: a key problem is reported on the console and by `#LIC!` in cells, + * and narrowing the catalogue to the two protected built-ins would leave an integrator who has not + * wired up their key yet with an empty function picker and no clue why. The list narrows only for + * a *valid* key that genuinely does not include a function — the case where the answer is useful. + * + * @param {string} functionId - the id as registered, which may be an alias + * @param {FunctionRegistry} functionRegistry - the engine's registry, which resolves the alias map + * @param {Config} config - the instance's config, holding its resolved entitlement + */ + private static licenseListsFunction(functionId: string, functionRegistry: FunctionRegistry, config: Config): boolean { + if (!config.isLicenseGateActive || FunctionRegistry.functionIsProtected(functionId)) { + return true + } + const plugin = functionRegistry.getFunctionPlugin(functionId) + const canonicalId = plugin?.aliases?.[functionId] ?? functionId + return licenseAllowsFunction(config.capabilityRegistry, config.licenseCapabilities, canonicalId) + } + /** * Builds the function list for every id registered in an engine's own registry. Documented functions use their * catalogue entry; custom functions are listed with their name only. Sorted by localized name with * `localeCompare`, so the order follows the host's collation rules, with the language-independent canonical name * as a stable tiebreaker for entries that share a localized name. * - * Takes the [[TranslationPackage]] rather than deriving it from a language code: an instance must describe its - * functions under the package its own evaluator uses (`Config.translationPackage`), which is a snapshot taken + * Takes the instance's whole [[Config]] rather than a language code: an instance must describe its functions + * under the translation package its own evaluator uses (`Config.translationPackage`), which is a snapshot taken * when the instance was built and can differ from whatever is registered globally for the same code today. - * Deriving it here instead would let this method report a localized name the instance refuses to evaluate. + * Deriving it here instead would let this method report a localized name the instance refuses to evaluate. The + * config also carries the resolved entitlement, for the same reason — see [[licenseListsFunction]]. * * @param {FunctionRegistry} functionRegistry - the engine's registry, the source of both the ids and their plugins - * @param {TranslationPackage} language - the translation package to translate the names under + * @param {Config} config - the instance's config: the translation package and the resolved license entitlement */ - private static buildAvailableFunctions(functionRegistry: FunctionRegistry, language: TranslationPackage): FunctionListEntry[] { + private static buildAvailableFunctions(functionRegistry: FunctionRegistry, config: Config): FunctionListEntry[] { + const language = config.translationPackage const translate = (id: string) => language.getMaybeFunctionTranslation(id) return functionRegistry.getListableFunctionIds() // The interpreter refuses to evaluate ids the active language has no translation entry for // (FunctionRegistry.getFunction), so an untranslated function would be advertised but uncallable. .filter(id => language.isFunctionTranslated(id)) + .filter(id => HyperFormula.licenseListsFunction(id, functionRegistry, config)) .map(id => { const resolved = HyperFormula.resolveFunctionMetadata(id, functionRegistry.getFunctionPlugin(id)) if (resolved === undefined) { @@ -749,14 +781,19 @@ export class HyperFormula implements TypedEmitter { * * @param {string} functionId - the language-independent function id (canonical id or alias) * @param {FunctionRegistry} functionRegistry - the engine's registry, which resolves the id to its plugin - * @param {TranslationPackage} language - the translation package to translate the names under + * @param {Config} config - the instance's config: the translation package and the resolved license entitlement */ - private static buildFunctionDetailsFor(functionId: string, functionRegistry: FunctionRegistry, language: TranslationPackage): FunctionDetails | undefined { - // Mirrors the filter in buildAvailableFunctions: an id the active language cannot evaluate - // (no translation entry) gets no details either, so the list and the details always agree. + private static buildFunctionDetailsFor(functionId: string, functionRegistry: FunctionRegistry, config: Config): FunctionDetails | undefined { + const language = config.translationPackage + // Mirrors the filters in buildAvailableFunctions: an id the active language cannot evaluate + // (no translation entry), or one this instance's license does not grant, gets no details + // either, so the list and the details always agree. if (!language.isFunctionTranslated(functionId)) { return undefined } + if (!HyperFormula.licenseListsFunction(functionId, functionRegistry, config)) { + return undefined + } const resolved = HyperFormula.resolveFunctionMetadata(functionId, functionRegistry.getFunctionPlugin(functionId)) if (resolved === undefined) { return undefined @@ -4592,6 +4629,18 @@ export class HyperFormula implements TypedEmitter { * plugin registered without translations for that language. A translation set to an empty string is not a missing * entry: it falls back to the canonical id, so the function stays listed under its canonical name. * + * A function the instance's license key does not include is omitted for the same reason: it would evaluate to a + * `#LIC!` error. The list therefore answers "what can this engine compute", not "what does this package contain". + * Two consequences worth knowing: + * - A missing, invalid or expired license key does **not** shorten the list. Such a key restricts nothing by + * entitlement — it is reported on the console, and every licence-gated function call evaluates to `#LIC!` — so + * the full catalogue is still described. `VERSION()` and `OFFSET()` are protected built-ins outside the licence + * system, so they keep evaluating. Use it to build a function picker before a key is configured. + * - A custom (user-registered) function is omitted only if it took a built-in id the key excludes. The rule is + * "not covered by the capability table", not "not user-registered", so a plugin registered under an id the + * built-in catalogue already uses is treated as that built-in. Registered under an id of its own, a custom + * function is never omitted. See {@link getFunctionDetails}, which states the same exception. + * * @example * ```js * const hfInstance = HyperFormula.buildEmpty(); @@ -4605,9 +4654,9 @@ export class HyperFormula implements TypedEmitter { public getAvailableFunctions(): FunctionListEntry[] { return HyperFormula.buildAvailableFunctions( this._functionRegistry, - // The instance's own package, the one its evaluator uses — not a fresh global lookup, which could describe - // the functions under a package this instance never adopted. - this._config.translationPackage, + // The instance's own config: its translation package (not a fresh global lookup, which could describe the + // functions under a package this instance never adopted) and its resolved license entitlement. + this._config, ) } @@ -4618,9 +4667,10 @@ export class HyperFormula implements TypedEmitter { * documentation link (`documentationUrl`) and usage examples (`examples`) — every built-in authors both. * Resolves both built-in and custom (user-registered) functions, as well as aliases. An alias reports its * target's metadata (including examples, which spell the target's name) under the alias id, with the target id - * exposed as `aliasOf`. Returns `undefined` when the function id is unknown, not registered in this instance, or - * has no translation entry for the configured language (an untranslated id cannot be evaluated, so it is not - * described either, which keeps this method consistent with [[getAvailableFunctions]]). + * exposed as `aliasOf`. Returns `undefined` when the function id is unknown, not registered in this instance, has + * no translation entry for the configured language, or is not included in this instance's license key (neither an + * untranslated nor an unlicensed id can be evaluated, so neither is described — which keeps this method consistent + * with [[getAvailableFunctions]], including its behaviour for a missing, invalid or expired key). * For a custom function, `category` is `'Custom'`, there is no `shortDescription`, `documentationUrl` or * `examples`, and parameters are reported positionally (`Arg1`, `Arg2`, ...). A custom plugin registered over a * built-in id is the exception: the catalogue is keyed by function id, so it reports that built-in's authored @@ -4649,8 +4699,8 @@ export class HyperFormula implements TypedEmitter { */ public getFunctionDetails(canonicalName: string): FunctionDetails | undefined { validateArgToType(canonicalName, 'string', 'canonicalName') - // The instance's own package, the one its evaluator uses — see getAvailableFunctions. - return HyperFormula.buildFunctionDetailsFor(canonicalName, this._functionRegistry, this._config.translationPackage) + // The instance's own config, for the same reasons as getAvailableFunctions. + return HyperFormula.buildFunctionDetailsFor(canonicalName, this._functionRegistry, this._config) } /** @@ -4907,7 +4957,10 @@ export class HyperFormula implements TypedEmitter { */ private rebuildWithConfig(newParams: Partial): void { const newConfig = this._config.mergeConfig(newParams) - const configNewLanguage = this._config.mergeConfig({language: newParams.language}) + // The second argument silences license console messages for this transient Config: it is + // built from the OUTGOING config purely to reserialize sheets, and must not print an expiry + // notice for the key the caller may be replacing in this very call. + const configNewLanguage = this._config.mergeConfig({language: newParams.language}, false) const serializedSheets = this._serialization.withNewConfig(configNewLanguage, this._namedExpressions).getAllSheetsSerialized() const serializedNamedExpressions = this._serialization.getAllNamedExpressionsSerialized() diff --git a/src/helpers/licenseKeyValidator.ts b/src/helpers/licenseKeyValidator.ts index 72ae00324..76f9c0a65 100644 --- a/src/helpers/licenseKeyValidator.ts +++ b/src/helpers/licenseKeyValidator.ts @@ -3,6 +3,7 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ +import {ENTITLEMENT_KEY_CHECKSUM_LENGTH} from '../license/handsontable-license-key-parser/constants' import {checkKeySchema, extractTime} from './licenseKeyHelper' /** @@ -17,6 +18,12 @@ export const enum LicenseKeyValidityState { type LicenseKeyInvalidState = Exclude +/** + * Which deadline a key ran out against: the date of the build in use (`release`) or the wall + * clock (`usage`). + */ +export type LicenseExpiryAxis = 'release' | 'usage' + interface TemplateVars { [key: string]: string, } @@ -27,7 +34,7 @@ type ConsoleMessages = { type MessageDescriptor = { template: LicenseKeyValidityState, - vars: TemplateVars, + expiryDate?: Date, } /** @@ -35,13 +42,128 @@ type MessageDescriptor = { */ const consoleMessages: ConsoleMessages = { invalid: () => 'The license key for HyperFormula is invalid.', - expired: ({keyValidityDate}) => 'The license key for HyperFormula expired' + - ` on ${keyValidityDate}, and is not valid for the installed version.`, + // Two wordings, because a key can run out along either of two axes and only one of them is + // about the build you installed. A maintenance key stops covering RELEASES after its date, so + // an older version keeps working and the fix is to install one; a usage-based key stops being + // valid at all, and telling its holder the key "is not valid for the installed version" sends + // them to downgrade, which changes nothing. + expired: ({keyValidityDate, axis}) => axis === 'usage' + ? `The license key for HyperFormula expired on ${keyValidityDate}.` + : `The license key for HyperFormula expired on ${keyValidityDate}, and is not valid for the installed version.`, missing: () => 'The license key for HyperFormula is missing.', } let _notified = false +/** + * Identities (see {@link noticeIdentityOf}) of license keys that have already printed their + * expiry-approaching notice. + * + * Deliberately keyed per key rather than a single boolean like {@link _notified} + * above: that flag reports one of a handful of states that mean the same thing regardless of + * which key triggered them ("a key is invalid", "a key is missing"), so once-per-page-load is the + * right behaviour for it. Two different keys approaching their OWN expiry are two different + * events, and a page that swaps keys (or a test suite that builds one engine per key) must still + * warn for the second one even though the first already consumed a shared flag. + */ +const _noticedKeys = new Set() + +/** + * Clears the once-per-page-load flag {@link notifyLicenseKeyState} keeps, and the per-key set + * {@link notifyLicenseKeyNotice} keeps. + * + * Exists for tests only. Both are 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 + _noticedKeys.clear() +} + +/** + * Prints the console message for a non-valid license key state, at most once per page load. + * + * Extracted so the entitlement-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 + * @param {LicenseExpiryAxis} [expiryAxis] - which axis the key ran out along. Defaults to + * `release`, which is the only axis the classic 25-character format has, so its message is + * unchanged. + */ +export function notifyLicenseKeyState( + state: LicenseKeyValidityState, + keyValidityDate?: Date, + expiryAxis: LicenseExpiryAxis = 'release', +): void { + if (_notified || state === LicenseKeyValidityState.VALID) { + return + } + + const vars: TemplateVars = keyValidityDate === undefined + ? {} + : {keyValidityDate: formatDate(keyValidityDate), axis: expiryAxis} + + console.warn(consoleMessages[state](vars)) + _notified = true +} + +/** + * Prints a one-time notice that a VALID entitlement key's usage-until expiry is approaching, at + * most once per distinct license key. + * + * Called from `src/license/licenseResolution.ts`'s `resolveLicense`, alongside + * {@link notifyLicenseKeyState} — see that function's doc for why the two share this module + * instead of each keeping a message table and a flag of their own. + * + * The wording is rev 5 §3.2's own subscription clause ("valid until {date} (UTC)"), naming the + * key's LAST covered day. It deliberately does not say "expires on": the pre-existing expired + * message reports the first day NOT covered (`validityOf`'s convention, +1 day), and two messages + * for the same key must not name two different days for the same boundary. "Valid until Aug 25" + * followed later by "expired on Aug 26" is consistent; "expires on Aug 25" followed by + * "expired on Aug 26" is a support ticket. + * + * @param {string} licenseKey - the raw key string; only its identity is retained, see below + * @param {Date} expiryDate - the last covered day of the key's usage-until axis, at UTC midnight + */ +export function notifyLicenseKeyNotice(licenseKey: string, expiryDate: Date): void { + const identity = noticeIdentityOf(licenseKey) + + if (_noticedKeys.has(identity)) { + return + } + + console.warn(`The HyperFormula license key is valid until ${formatDate(expiryDate)} (UTC). To renew the license, contact sales@handsontable.com.`) + _noticedKeys.add(identity) +} + +/** + * The warn-once identity of a key: its trailing 129 characters, after trimming — for an intact + * entitlement key, the sha512 checksum plus the closing bracket that ends the machine-readable + * block, unique per distinct key content. + * + * Trimmed because the reader ignores trailing whitespace (it looks for the block, not for the end + * of the string), so `'KEY'` and `'KEY\n'` are one license and must be one identity here too. + * Reading from the END rather than the start also makes the whole artifact and its bare `[...]` + * block — which the format says are equally valid spellings of the same license — one identity. + * + * Truncated because the set retains its entries for the life of the process: a multi-tenant server + * building one engine per customer-supplied key would otherwise accumulate every full key string + * it has ever warned about; 129 characters per entry bounds that to the checksum alone. + */ +function noticeIdentityOf(licenseKey: string): string { + return licenseKey.trim().slice(-(ENTITLEMENT_KEY_CHECKSUM_LENGTH + 1)) +} + /** * Checks if the provided license key is grammatically valid or not expired. * @@ -51,7 +173,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 +183,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 +195,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 +203,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 entitlement-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/interpreter/Interpreter.ts b/src/interpreter/Interpreter.ts index 2c6e39f2a..39ef01b6b 100644 --- a/src/interpreter/Interpreter.ts +++ b/src/interpreter/Interpreter.ts @@ -13,7 +13,7 @@ import {DependencyGraph} from '../DependencyGraph' import {FormulaVertex} from '../DependencyGraph/FormulaVertex' import {ErrorMessage} from '../error-message' import {LicenseKeyValidityState} from '../helpers/licenseKeyValidator' -import {allowsFunction} from '../license/CapabilityRegistry' +import {licenseAllowsFunction} from '../license/CapabilityRegistry' import {ColumnSearchStrategy} from '../Lookup/SearchStrategy' import {Maybe} from '../Maybe' import {NamedExpressions} from '../NamedExpressions' @@ -196,8 +196,7 @@ export class Interpreter { } const canonicalId = this.canonicalFunctionId(ast.procedureName) - if (this.config.capabilityRegistry.capabilityOf(canonicalId) !== undefined - && !allowsFunction(this.config.licenseCapabilities, canonicalId)) { + if (!licenseAllowsFunction(this.config.capabilityRegistry, this.config.licenseCapabilities, canonicalId)) { return new CellError(ErrorType.LIC, ErrorMessage.LicenseCapability(ast.procedureName)) } } diff --git a/src/license/CapabilityRegistry.ts b/src/license/CapabilityRegistry.ts index adbcfc4f4..3e95b891c 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, normalizeCapabilityToken} from './capabilities' /** * The capabilities a resolved {@link LicenseEntitlement} grants, ready for gate B (the @@ -38,9 +38,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) } @@ -67,9 +64,10 @@ export class CapabilityRegistry { /** * Expands an entitlement's capability tokens into the concrete functions and features they * grant. An `unrestricted` entitlement short-circuits to `'all'` on BOTH axes without - * consulting the table at all — every grant in the table stands on its own — a token never - * refers to another — so this is a flat pass over the entitlement's own tokens; an - * unrecognized token is skipped without an error, and a repeated one adds nothing twice. + * consulting the table at all. Tokens are matched case-insensitively (the table is keyed by + * the normalized spelling — see {@link normalizeCapabilityToken}). Every grant stands on its + * own — a token never refers to another — so this is a flat pass over the entitlement's own + * tokens; an unrecognized token is skipped without an error, and a repeated one adds nothing. * * Setting `'all'` on both axes here, in the same object literal, is deliberate: this is the * only place `entitlement.unrestricted` is read, so a future edit that touches one axis and @@ -79,7 +77,7 @@ export class CapabilityRegistry { * both are pinned separately rather than with one combined assertion. * * @param {LicenseEntitlement} entitlement - the entitlement to resolve, e.g. one built by - * hand in a test or produced by PR 3's license-key payload adapter + * hand in a test or produced by the license-key payload adapter */ public resolve(entitlement: LicenseEntitlement): ResolvedCapabilities { if (entitlement.unrestricted) { @@ -88,8 +86,18 @@ export class CapabilityRegistry { const functions = new Set() const features = new Set() + // A key may carry a great many tokens - the format sets no size limit - and a repeated one + // grants nothing new, so each distinct spelling is expanded once. Expansion itself is a single + // pass: no grant refers to another, so there is nothing to walk. + const visited = new Set() + + for (const rawToken of entitlement.capabilities) { + const token = normalizeCapabilityToken(rawToken) + if (visited.has(token)) { + continue + } + visited.add(token) - for (const token of entitlement.capabilities) { const grant = this.table.get(token) if (grant === undefined) { continue @@ -103,9 +111,9 @@ export class CapabilityRegistry { /** * Returns the capability token a function id is covered by, or `undefined` if this registry's - * table does not cover it. The completeness invariant in - * `unit/license/capability-registry.spec.ts` guarantees every built-in registered in the - * static function registry is covered by the table, the core token, or the protected list — + * table does not cover it. The completeness invariant in the paired `hyperformula-tests` suite + * (`unit/license/capability-registry.spec.ts`) guarantees every built-in registered in the + * static function registry is covered by the table or the protected list — * so `undefined` for a function known to the current instance's function registry means it is * a custom, instance-registered function rather than an unlisted built-in. */ @@ -127,3 +135,34 @@ export function allowsFunction(resolved: ResolvedCapabilities, functionId: strin export function allowsFeature(resolved: ResolvedCapabilities, feature: FeatureId): boolean { return resolved.features === 'all' || resolved.features.has(feature) } + +/** + * Whether the license lets an instance evaluate — and therefore describe — the given function. + * + * The rule both gate-B function call sites share: a function the capability table does not cover + * at all is allowed. {@link CapabilityRegistry.capabilityOf} returns `undefined` only for an id no + * token lists, which the completeness invariant in `unit/license/capability-registry.spec.ts` + * guarantees is not an unlisted built-in but a custom, instance-registered function — exempt from + * gate B by decision D1. Everything the table does cover has to be granted by the entitlement. + * + * Extracted so the interpreter and the function metadata API cannot drift apart. The metadata API + * exists to describe the functions an instance can actually evaluate, so a second spelling of this + * rule would eventually let it advertise a function that then returns `#LIC!` — the exact failure + * removing the static metadata methods (HF-349) was meant to prevent. + * + * Note this is gate B only: it says nothing about {@link LicenseKeyValidityState}. Callers that + * also need gate A check it separately, because the two gates have different answers for the same + * key — see the comment on `resolveLicense`. + * + * @param {CapabilityRegistry} registry - the registry the capabilities were resolved against + * @param {ResolvedCapabilities} resolved - the instance's resolved capabilities + * @param {string} canonicalFunctionId - the function id, already resolved through the alias map + */ +export function licenseAllowsFunction( + registry: CapabilityRegistry, + resolved: ResolvedCapabilities, + canonicalFunctionId: string, +): boolean { + return registry.capabilityOf(canonicalFunctionId) === undefined + || allowsFunction(resolved, canonicalFunctionId) +} diff --git a/src/license/LicenseEntitlement.ts b/src/license/LicenseEntitlement.ts index 9472d1350..01202acac 100644 --- a/src/license/LicenseEntitlement.ts +++ b/src/license/LicenseEntitlement.ts @@ -6,10 +6,13 @@ /** * Identifies a feature area of the public API that a license entitlement can gate. * - * `CustomFunctions` and `ImportExport` are reserved vocabulary: they exist so a license payload - * is free to carry them, but no capability grant in this release maps to either of them yet. - * HF-307 decision D1 drops function-registration gating (and the `CustomFunctions` grant) from - * this release; `ImportExport` has no gated methods until HF-107 lands. + * `CustomFunctions` is reserved vocabulary: it exists so a license payload is free to carry it, + * but HF-307 decision D1 drops function-registration gating (and the `CustomFunctions` grant) + * from this release, so no capability grant maps to it. + * + * `ImportExport` IS granted, by the `import_export` add-on token (2026-08-12 packages meeting) — + * but it gates no public method yet, because HF-107 hasn't shipped the import/export feature it + * would gate. The grant exists; the gate does not, yet. */ export const enum FeatureId { NamedExpressions = 'named_expressions', @@ -24,12 +27,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 +69,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. */ silent: boolean, isTrial: boolean, @@ -74,7 +84,7 @@ export interface LicenseEntitlement { /** * The unrestricted entitlement: legacy keys and `gpl-v3` resolve to this today. * - * HF-307 decision D3 (fail-closed, silent) means a typed key whose tokens this library version + * HF-307 decision D3 (fail-closed, silent) means an entitlement key whose tokens this library version * does not recognize at all no longer maps here — it resolves to an entitlement with an empty, * silent capability set instead of falling back to unrestricted access. Do not reuse this * function for that case. diff --git a/src/license/capabilities.ts b/src/license/capabilities.ts index 0a32bf3d2..da6d7393a 100644 --- a/src/license/capabilities.ts +++ b/src/license/capabilities.ts @@ -3,11 +3,68 @@ * 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}. */ -export const CORE_TOKEN = 'core' +// The engine reads CAPABILITIES, never packages. A license key carries a list of capability +// tokens and the engine grants the union of what those tokens name; which tokens make up which +// commercial package is decided where keys are minted, not here. In the words of the packaging +// design: "Nothing else about packaging exists at the technical layer." + +/** 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 opt-in rule in `licenseTermsOf`: a key naming no + * `feat:*` token at all is granted all of these, because no key vocabulary in circulation can + * express "no features" — see that function 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, +] + +/** The whole implemented catalog of built-in functions, operator callable forms included. */ +export const FUN_ALL_TOKEN = 'fun:all' +/** + * Spreadsheet Bundle add-on (2026-08-12 packages meeting). Grants {@link FeatureId.Crud}, + * {@link FeatureId.UndoRedo}, {@link FeatureId.Clipboard} and {@link FeatureId.Batching} — see + * {@link CAPABILITY_TABLE}. + */ +export const SPREADSHEET_ADDON_TOKEN = 'spreadsheet' +/** + * Import/export add-on (2026-08-12 packages meeting). Grants {@link FeatureId.ImportExport}, a + * RESERVED grant: nothing in the public API is gated on it yet, because HF-107 hasn't shipped the + * import/export feature it would gate. + */ +export const IMPORT_EXPORT_ADDON_TOKEN = 'import_export' + +/** + * The canonical spelling of a capability token for table lookups. + * + * Token names are case-insensitive — the packaging doc states it outright for its `fun:*` + * vocabulary, and tolerating case on the other tokens costs nothing since none of them collide + * under lowercasing. Surrounding whitespace is trimmed for a sharper reason than tidiness: every + * rule that reads a token has to read the SAME token, and a padded one used to be read two + * different ways at once — `' feat:crud'` failed the `feat:` prefix test that decides whether a key + * speaks the feature vocabulary, so the key was granted all five feature areas instead of the one + * it named, while `'feat:crud '` passed that test and then missed the table, granting none. + * + * Normalization happens at LOOKUP, never at storage: an entitlement carries the key's own + * spellings (they are diagnostics), and {@link CAPABILITY_TABLE} is keyed by the normalized form. + * + * @param {string} token - a capability token as the key spells it + */ +export function normalizeCapabilityToken(token: string): string { + return token.trim().toLowerCase() +} /** * Describes what a capability token grants: a set of function ids and a set of {@link FeatureId} @@ -19,37 +76,214 @@ export interface CapabilityGrant { features: FeatureId[], } -const coreGrant: CapabilityGrant = { - functions: [], - features: [FeatureId.NamedExpressions, FeatureId.Clipboard, FeatureId.Crud, FeatureId.UndoRedo, FeatureId.Batching], -} +/** + * The `HF.*` callable forms of the calculation operators — the members of `fun:operator.a`. + * + * They are gated like every other function: a key reaches them through `fun:operator.a`, a + * single-function token, or `fun:all`. Only the callable forms are affected. The infix operators + * themselves (`=A1+B1`) are not function calls, never reach gate B, and work under any key. + */ +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', +] + +/** + * The two protected built-ins. Both are named by the packaging doc (`fun:lookup.A`, `fun:info.A`) + * but sit OUTSIDE the token system today — the interpreter never gate-checks a protected + * function, so granting them would be dead weight that implies a restriction that does not exist. + * The doc calls this a "technical limitation" on both; their tokens below are recognized but + * grant nothing. + */ +const PROTECTED_BUILT_INS = ['OFFSET', 'VERSION'] /** - * The production capability table. + * The 21 function groups of the packaging doc, keyed by their group tokens in normalized + * (lowercase) spelling — the doc writes them `fun:.` and declares all token names + * case-insensitive. * - * 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). + * Transcribed 1:1 from section 6 of the internal packaging design document ("HF function groups + * and packages"), INCLUDING the members that resolve to no grant here: the protected built-ins, + * which sit outside the token system (see {@link PROTECTED_BUILT_INS}). Keeping the doc's own + * membership verbatim is what lets `capability-table.spec.ts` pin each group's size against the + * doc's published counts, so a re-transcription is a reviewable diff. * - * `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. + * The doc freezes group names as API surface: once shipped inside license keys, a rename is a + * breaking change. */ -export const CAPABILITY_TABLE: ReadonlyMap = new Map([[CORE_TOKEN, coreGrant]]) +export const FUNCTION_GROUPS: ReadonlyMap = new Map([ + ['fun:math.a', ['ABS', 'LOG', 'MOD', 'POWER', 'PRODUCT', 'ROUND', 'ROUNDDOWN', 'ROUNDUP', 'SQRT', 'SUM']], + ['fun:stat.a', ['AVERAGE', 'COUNT', 'MAX', 'MIN']], + ['fun:logic.a', ['IF']], + ['fun:operator.a', [...OPERATOR_FUNCTIONS]], + ['fun:info.a', ['VERSION']], + ['fun:lookup.a', ['OFFSET']], + ['fun:time.b', [ + 'DATE', 'DATEDIF', 'DATEVALUE', 'DAY', 'DAYS', 'EOMONTH', 'HOUR', 'ISOWEEKNUM', 'MINUTE', 'MONTH', + 'NETWORKDAYS', 'SECOND', 'TODAY', 'WEEKDAY', 'WEEKNUM', 'WORKDAY', 'YEAR', + ]], + ['fun:text.b', [ + 'CONCATENATE', 'EXACT', 'LEFT', 'LEN', 'LOWER', 'MID', 'REPLACE', 'REPT', 'RIGHT', 'SEARCH', + 'SUBSTITUTE', 'TEXT', 'TRIM', 'UPPER', 'VALUE', + ]], + ['fun:logic.b', ['AND', 'FALSE', 'IFS', 'NOT', 'OR', 'SWITCH', 'TRUE', 'XOR']], + ['fun:math.b', ['RAND', 'RANDBETWEEN', 'SUMIF', 'SUMIFS']], + ['fun:stat.b', ['AVERAGEIF', 'COUNTIF', 'STDEV.S']], + ['fun:lookup.c', [ + 'ADDRESS', 'CHOOSE', 'COLUMN', 'COLUMNS', 'FILTER', 'HLOOKUP', 'HSTACK', 'HYPERLINK', 'INDEX', 'MATCH', + 'ROW', 'ROWS', 'SORT', 'TRANSPOSE', 'UNIQUE', 'VLOOKUP', 'VSTACK', 'XLOOKUP', + ]], + ['fun:math.c', [ + 'ACOS', 'ASIN', 'ATAN', 'ATAN2', 'CEILING', 'COS', 'EVEN', 'EXP', 'FLOOR', 'INT', 'LN', 'MROUND', 'ODD', + 'PI', 'QUOTIENT', 'SEQUENCE', 'SIGN', 'SIN', 'SUBTOTAL', 'SUMPRODUCT', 'SUMSQ', 'SUMXMY2', 'TAN', + ]], + ['fun:stat.c', [ + 'AVERAGEA', 'COUNTA', 'COUNTBLANK', 'COUNTIFS', 'LARGE', 'MAXIFS', 'MEDIAN', 'MINIFS', 'PERCENTILE.INC', + 'SMALL', 'STDEV.P', 'STDEVA', 'STDEVPA', 'VAR.P', 'VAR.S', + ]], + ['fun:time.c', ['DAYS360', 'EDATE', 'NOW', 'TIME', 'YEARFRAC']], + ['fun:text.c', ['CHAR', 'CLEAN', 'CODE', 'FIND', 'PROPER', 'T', 'TEXTJOIN', 'UNICHAR']], + ['fun:info.c', [ + 'ISBLANK', 'ISERR', 'ISERROR', 'ISEVEN', 'ISLOGICAL', 'ISNA', 'ISNUMBER', 'ISODD', 'ISTEXT', 'N', 'NA', + ]], + ['fun:logic.c', ['IFERROR', 'IFNA']], + ['fun:finance.c', ['FV', 'IPMT', 'IRR', 'NPV', 'PMT', 'PPMT', 'PV', 'RATE', 'SLN', 'XIRR', 'XNPV']], + ['fun:engineer.c', ['DEC2HEX', 'HEX2DEC']], + ['fun:array.c', ['ARRAYFORMULA', 'ARRAY_CONSTRAIN']], +]) /** - * 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. + * The members of a group that a grant can carry: the group verbatim, minus the protected + * built-ins, which are always available and must never become table-covered (a covered function + * is gated for every key not granting it). + * + * @param {string} groupToken - a group token, in normalized spelling */ -export function refreshCoreGrant(): void { - coreGrant.functions = FunctionRegistry.getRegisteredFunctionIds() +function gatableMembersOf(groupToken: string): string[] { + return (FUNCTION_GROUPS.get(groupToken) ?? []).filter((name) => PROTECTED_BUILT_INS.indexOf(name) === -1) } + +/** Every function some group names, the operator callable forms included. The groups are disjoint. */ +const GROUPED_FUNCTIONS = Array.from(FUNCTION_GROUPS.keys()).reduce( + (members, groupToken) => members.concat(gatableMembersOf(groupToken)), + [], +) + +/** + * The implemented functions no group names — the packaging design's niche tail, reachable only + * through {@link FUN_ALL_TOKEN} or their own single-function token. + * + * Enumerated rather than taken from the function registry at run time, even though "everything + * not in a group" 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 under a licence token and returning `#LIC!` for it — 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. + */ +const UNGROUPED_FUNCTIONS = [ + 'ACOSH', 'ACOT', 'ACOTH', 'ARABIC', 'ASINH', 'ATANH', 'AVEDEV', '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', 'CHISQ.DIST', + 'CHISQ.DIST.RT', 'CHISQ.INV', 'CHISQ.INV.RT', 'CHISQ.TEST', 'COMBIN', 'COMBINA', 'COMPLEX', + 'CONFIDENCE.NORM', 'CONFIDENCE.T', 'CORREL', 'COSH', 'COT', 'COTH', 'COUNTUNIQUE', 'COVARIANCE.P', + 'COVARIANCE.S', 'CSC', 'CSCH', 'CUMIPMT', 'CUMPRINC', 'DAVERAGE', 'DB', 'DCOUNT', 'DCOUNTA', 'DDB', + 'DEC2BIN', 'DEC2OCT', 'DECIMAL', 'DEGREES', 'DELTA', 'DEVSQ', 'DGET', 'DMAX', 'DMIN', 'DOLLARDE', + 'DOLLARFR', 'DPRODUCT', 'DSTDEV', 'DSTDEVP', 'DSUM', 'DVAR', 'DVARP', 'EFFECT', 'ERF', 'ERFC', + 'EXPON.DIST', 'F.DIST', 'F.DIST.RT', 'F.INV', 'F.INV.RT', 'F.TEST', 'FACT', 'FACTDOUBLE', 'FISHER', + 'FISHERINV', 'FLOOR.MATH', 'FLOOR.PRECISE', 'FORMULATEXT', 'FVSCHEDULE', 'GAMMA', 'GAMMA.DIST', + 'GAMMA.INV', 'GAMMALN', 'GAUSS', 'GCD', 'GEOMEAN', 'HARMEAN', 'HEX2BIN', 'HEX2OCT', 'HYPGEOM.DIST', + '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', 'INTERVAL', 'ISBINARY', 'ISFORMULA', 'ISNONTEXT', 'ISPMT', + 'ISREF', 'LCM', 'LOG10', 'LOGNORM.DIST', 'LOGNORM.INV', 'MAXA', 'MAXPOOL', 'MEDIANPOOL', 'MINA', 'MIRR', + 'MMULT', 'MULTINOMIAL', 'NEGBINOM.DIST', 'NETWORKDAYS.INTL', 'NOMINAL', 'NORM.DIST', 'NORM.INV', + 'NORM.S.DIST', 'NORM.S.INV', 'NPER', 'OCT2BIN', 'OCT2DEC', 'OCT2HEX', 'PDURATION', 'PERCENTILE.EXC', + 'PHI', 'POISSON.DIST', 'QUARTILE.EXC', 'QUARTILE.INC', 'RADIANS', 'ROMAN', 'RRI', 'RSQ', 'SEC', 'SECH', + 'SERIESSUM', 'SHEET', 'SHEETS', 'SINH', 'SKEW', 'SKEW.P', 'SLOPE', 'SPLIT', 'SQRTPI', 'STANDARDIZE', + 'STEYX', 'SUMX2MY2', 'SUMX2PY2', 'SYD', 'T.DIST', 'T.DIST.2T', 'T.DIST.RT', 'T.INV', 'T.INV.2T', 'T.TEST', + 'TANH', 'TBILLEQ', 'TBILLPRICE', 'TBILLYIELD', 'TDIST', 'TIMEVALUE', 'UNICODE', 'VARA', 'VARPA', + 'WEIBULL.DIST', 'WORKDAY.INTL', 'Z.TEST', +] + +/** The whole gatable catalog: what {@link FUN_ALL_TOKEN} grants. */ +const ALL_GATABLE_FUNCTIONS = GROUPED_FUNCTIONS.concat(UNGROUPED_FUNCTIONS) + +/** + * One table entry per group token, granting the group's gatable members. `fun:info.a` and + * `fun:lookup.a` resolve to + * EMPTY grants on purpose — their members are the protected built-ins, which are always available + * and must never become table-covered (a covered function is gated for every key not granting + * it). The tokens stay recognized either way, so a key carrying them is never reported as + * unrecognized: they are the doc's bookkeeping identifiers for functionality every key gets. + */ +const groupEntries: [string, CapabilityGrant][] = Array.from(FUNCTION_GROUPS.keys()).map((groupToken) => [ + groupToken, + {functions: gatableMembersOf(groupToken), features: []}, +]) + +/** + * One table entry per canonical function name: the packaging doc's single-function tokens + * (`fun:`), "for surgical grants: custom deals, previews, per-function + * exceptions". One exists for EVERY canonical name — including the operator callable forms and + * the protected built-ins (empty grants, as above). Alias names get no + * token of their own: tokens reference canonical names, and an alias travels with its canonical + * function because the gates canonicalize before consulting the table. + */ +const singleFunctionEntries: [string, CapabilityGrant][] = ALL_GATABLE_FUNCTIONS + .concat(PROTECTED_BUILT_INS) + .map((name) => [ + `fun:${normalizeCapabilityToken(name)}`, + {functions: PROTECTED_BUILT_INS.indexOf(name) === -1 ? [name] : [], features: []}, + ]) + +/** + * The production capability table, keyed by NORMALIZED token spelling — look up through + * {@link normalizeCapabilityToken}, never with a raw key string. + * + * The vocabulary, and nothing else: + * + * - function tokens, per §6 of the packaging design: `fun:all`, the group tokens + * `fun:.`, and one `fun:` per canonical function; + * - one `feat:*` token per gated API area; + * - the two add-on tokens the key generator's schema mints, each naming the features it grants. + * + * No token here names a package, and no grant refers to another token. Which tokens a commercial + * package consists of is the generator's knowledge, expressed by the bigger licence simply + * listing more tokens — so a key's function set is the union of everything it names that this + * table recognizes, and an unrecognized token is inert (strict-shape/lenient-vocabulary, T7). + * + * 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 be gated + * — see {@link UNGROUPED_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. A key may state them + * explicitly; a key naming none is granted all five (the opt-in rule in `licenseTermsOf`); legacy + * keys resolve to the unrestricted entitlement and never consult this table. + * + * The two add-on tokens, wired per the 2026-08-12 packages meeting: `spreadsheet` backs the + * 'Spreadsheet Bundle' add-on and grants {@link FeatureId.Crud}, {@link FeatureId.UndoRedo}, + * {@link FeatureId.Clipboard} and {@link FeatureId.Batching}. `import_export` backs the + * import-export add-on and grants {@link FeatureId.ImportExport} — a RESERVED grant, since nothing + * in the public API is gated on it yet: HF-107 hasn't shipped the feature it would gate. + */ +export const CAPABILITY_TABLE: ReadonlyMap = new Map([ + [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]}], + // NamedExpressions is absent on purpose: the Spreadsheet Bundle was scoped at the 12.08 packages + // meeting to the four areas below, and named expressions was not among them. It is recorded here + // so the omission reads as the decision it is rather than as a transcription slip, and so that + // moving it into the bundle stays a product call rather than a silent edit. + [SPREADSHEET_ADDON_TOKEN, { + functions: [], + features: [FeatureId.Crud, FeatureId.UndoRedo, FeatureId.Clipboard, FeatureId.Batching], + }], + [IMPORT_EXPORT_ADDON_TOKEN, {functions: [], features: [FeatureId.ImportExport]}], + [FUN_ALL_TOKEN, {functions: [...ALL_GATABLE_FUNCTIONS], features: []}], + ...groupEntries, + ...singleFunctionEntries, +]) diff --git a/src/license/handsontable-license-key-parser/PROVENANCE.md b/src/license/handsontable-license-key-parser/PROVENANCE.md new file mode 100644 index 000000000..52e866ca4 --- /dev/null +++ b/src/license/handsontable-license-key-parser/PROVENANCE.md @@ -0,0 +1,97 @@ +# Vendored entitlement-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) | +| Tag | `4.0.0` | +| Commit | `c50ef40a6` (the `4.0.0` release commit; on `develop` as `1acddafa8`) | +| Ported on | 2026-08-20 | +| Reference docs | the format and design notes kept alongside the upstream sources; the byte-level rules are also specified in the key spec's "Technical implementation" addendum (T1–T14) | + +## Files + +Hashes are of the **upstream** `.js` sources at the tag above, so drift is detectable without +storing a copy of them here. + +| This directory | Upstream `src/entitlement-key/` | Upstream sha256 | +|---|---|---| +| `constants.ts` | `constants.js` | `6e2ad68d1a316abdec3f89bf04260a2cc4098f76525427d919f22cb25fb077d6` | +| `detectFormat.ts` | `detect-format.js` | `7dc037fd70e7c64078a0fe29b42cb33ecf25e8f4d8963ae9bfb16b69479d267f` | +| `extractKeyData.ts` | `extract-key-data.js` | `afd0858768879764ea016d2bc4fca692a0cd12214c1dfda6932d7ed9e4f32e45` | +| `utils.ts` | `utils.js` | `135a8396bb22f424160fc651e899931d4be807df9b94c6dd24bb1cf6526e0541` | +| `sha512.ts` | `sha512.js` | `668dd1109160b92965a1f9a9c5fb78dfdc1e5b7e93f635a147ae8a6bb2a5d837` | + +### 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/entitlement-key +sha256sum constants.js detect-format.js extract-key-data.js utils.js sha512.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 + +The entitlement reader is deliberately schema-free upstream (unknown products, tokens and flags +are tolerated, so nothing about *reading* a key depends on the vocabulary), which keeps the +vendored surface small: everything schema- and generation-side stays out. + +| Upstream file | Why not | +|---|---| +| `generate-key.js`, `build-payload.js`, `build-prose.js` | Mint keys. HyperFormula only ever reads them. | +| `default-schema.js` | The generator's vocabulary (packages, add-ons, wordings, templates). The reader needs no schema; the only name this library reads is its own product entry, kept as `HYPERFORMULA_PRODUCT_NAME` in `src/license/licenseResolution.ts`. | +| `create-engine.js`, `resolve-schema.js`, `validate-schema.js`, `validate-record.js` | Bind and verify a caller's schema/record at generation time — generator-side. | +| `validate-key.js` | A two-line boolean wrapper over `extractEntitlementKeyData`; 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. + +## 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. **`detectFormat.ts` keeps its literals in a `Map`,** where upstream uses an object literal + behind a `hasOwnProperty` guard. Same behaviour for every input (including `constructor` and + `__proto__`); the `Map` is this repository's idiom for lookups keyed by untrusted strings. +2. **`stringToUtf8Bytes`'s parameter is named `text`, not `string`,** which is a type keyword in + TypeScript. +3. **The normalized product entry is typed** (`EntitlementProductGrant`), which upstream's plain + JavaScript does not do. The types state what the reader CHECKS, and the checks are upstream's: + `capabilities` and `flags` are verified element by element, and `notice` and `grace` are verified + as non-negative integers. Everything the reader does not verify — unknown fields are preserved on + purpose — sits behind an `unknown`-valued index signature, so consumers must narrow before use. +4. **The date field is checked for type, not only for shape** (`isIsoDate` in `extractKeyData.ts`). + Upstream matches `String(value)` against `YYYY-MM-DD`, so a `usage_until` that is a single-element + array of the right string passes its shape check and the declared `string` type ends up wider than + the value. Here a non-string is rejected outright. This is the one divergence that CHANGES which + keys are accepted, so it is called out separately: a malformed key that upstream would carry into + a RESTRICTED entitlement takes the invalid-key path instead. The key spec's addendum (T7) makes + the field a real calendar date, so the stricter reading is the specified one and it is upstream + that deviates — but upstream has not adopted it (checked at `c50ef40a`, `develop` and `master`; + no pull request or issue proposes it), so this is a live fork of behaviour, not a re-port waiting + to happen. Re-check it at every drift review. + +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 legacy 25-character key format, + untouched here (upstream 4.0.0 still exports it too). +- `src/license/licenseResolution.ts` — the consumer: routes on `detectLicenseKeyFormat` and turns + the extracted payload into an entitlement. +- `src/license/capabilities.ts` — the capability table the payload's tokens are resolved against. diff --git a/src/license/handsontable-license-key-parser/constants.ts b/src/license/handsontable-license-key-parser/constants.ts new file mode 100644 index 000000000..270be27fe --- /dev/null +++ b/src/license/handsontable-license-key-parser/constants.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/entitlement-key/constants.js`. + * See `src/license/handsontable-license-key-parser/PROVENANCE.md` before editing — this file is a port, not original code. + */ + +/** + * The length of the checksum (SHA-512 as hex) which postfixes the payload inside the + * machine-readable block of every entitlement license key. + */ +export const ENTITLEMENT_KEY_CHECKSUM_LENGTH = 128 + +/** + * The two mutually exclusive date fields of a product entry. Exactly one of them has to be + * present: + * + * - `usage_until` — the last licensed day (inclusive, compared in UTC), + * - `release_until` — builds released on or before that day may be used forever (compared + * against the build release date as text, no clock involved). + * + * The pair replaces the contract type — nothing in the payload says "subscription" or + * "perpetual". + */ +export const DATE_FIELDS: readonly string[] = ['usage_until', 'release_until'] diff --git a/src/license/handsontable-license-key-parser/detectFormat.ts b/src/license/handsontable-license-key-parser/detectFormat.ts new file mode 100644 index 000000000..183afc66e --- /dev/null +++ b/src/license/handsontable-license-key-parser/detectFormat.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/entitlement-key/detect-format.js`. + * See `src/license/handsontable-license-key-parser/PROVENANCE.md` before editing — this file is a port, not original code. + */ + +/** + * The name of each license key format {@link detectLicenseKeyFormat} can answer with. + */ +export type LicenseKeyFormat = + | 'entitlement' + | 'legacy' + | 'non-commercial-and-evaluation' + | 'gpl-v3' + | 'unknown' + +/** + * The literal keys that stand for a licence rather than encode one. + */ +const LITERAL_KEYS: ReadonlyMap = new Map([ + ['non-commercial-and-evaluation', 'non-commercial-and-evaluation'], + ['gpl-v3', 'gpl-v3'], +]) + +/** + * The classic 25-character key, once its dashes are stripped. + */ +const LEGACY_KEY = /^[0-9a-fA-F]{25}$/ + +/** + * Tells which license key format a string is in, without validating it. + * + * The entitlement key format removed the leading type tag, so a key no longer announces itself + * in its first characters — it now ends with the bracketed machine-readable block instead. + * Products that accept several formats need one place that makes the distinction, and this is it. + * + * The answer is about SHAPE only. A returned `'entitlement'` means "route this to the + * entitlement validator", not "this key is valid". + * + * @param {unknown} licenseKey - the license key to inspect + */ +export function detectLicenseKeyFormat(licenseKey: unknown): LicenseKeyFormat { + if (typeof licenseKey !== 'string') { + return 'unknown' + } + + const key = licenseKey.trim() + const literal = LITERAL_KEYS.get(key.toLowerCase()) + + if (literal !== undefined) { + return literal + } + + // The bracketed block closes an entitlement key. Its presence is what separates the new format + // from everything else, so it is checked before the shape-based ones. + const blockStart = key.lastIndexOf('[') + + if (blockStart !== -1 && key.indexOf(']', blockStart) !== -1) { + return 'entitlement' + } + if (LEGACY_KEY.test(key.replace(/-/g, ''))) { + return 'legacy' + } + + return 'unknown' +} diff --git a/src/license/handsontable-license-key-parser/extractKeyData.ts b/src/license/handsontable-license-key-parser/extractKeyData.ts new file mode 100644 index 000000000..6a71b3f31 --- /dev/null +++ b/src/license/handsontable-license-key-parser/extractKeyData.ts @@ -0,0 +1,284 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/entitlement-key/extract-key-data.js`. + * See `src/license/handsontable-license-key-parser/PROVENANCE.md` before editing — this file is a port, not original code. + * + * Unlike the typed-key reader this file replaces, the entitlement reader is deliberately + * SCHEMA-FREE upstream: unknown products, capabilities and flags are all tolerated, so nothing + * about reading a key depends on the vocabulary — which is what lets a product vendor this + * parser on its own. + */ + +import {DATE_FIELDS, ENTITLEMENT_KEY_CHECKSUM_LENGTH} from './constants' +import {sha512} from './sha512' +import {base64ToString, parseIsoDate, stringToUtf8Bytes} from './utils' + +/** + * The alphabet of the encoded payload — URL-safe base64 without padding. The checksum (lowercase + * hex) is a subset of it, which is what lets the two be split by a fixed length from the right. + */ +const ENCODED_PAYLOAD = /^[A-Za-z0-9\-_]+$/ +const CHECKSUM = /^[0-9a-f]+$/ + +/** + * One normalized product entry of an entitlement key payload. + * + * The named fields are guaranteed by {@link normalizeProductEntry}: `capabilities` and `flags` + * are arrays of strings (`flags` normalized to `[]` when absent), `notice` and `grace` are + * non-negative integers, and exactly one of `usage_until` / `release_until` is present and is a + * real `YYYY-MM-DD` calendar date. Any OTHER field the entry carries is preserved verbatim under + * its own name — a field added to the format later must reach an application running an older + * vendored parser — which is what the index signature is for. + */ +export interface EntitlementProductGrant { + readonly capabilities: readonly string[], + readonly usage_until?: string, + readonly release_until?: string, + readonly notice: number, + readonly grace: number, + readonly flags: readonly string[], + readonly [field: string]: unknown, +} + +/** + * The machine-readable content of an intact entitlement license key: the granted products, each + * with its capabilities, its single date (`usage_until` or `release_until`), its `notice` and + * `grace` windows in days, and its `flags`. + */ +export interface EntitlementKeyData { + readonly products: Readonly>, +} + +/** + * Returns `true` when the value is a plain object. + * + * @param {unknown} value - the value to check + */ +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** + * Returns `true` when the value is a non-negative integer. + * + * @param {unknown} value - the value to check + */ +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && Math.floor(value) === value && value >= 0 +} + +/** + * Returns `true` when the value is an array of strings. + * + * @param {unknown} value - the value to check + */ +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string') +} + +/** + * Returns `true` when the value is a real calendar date in the `YYYY-MM-DD` format. A time + * component, an offset, a numeric timestamp and a date that does not exist are all rejected — + * the format is the whole contract, and a validator that accepted two spellings would hide a + * timezone bug at generation instead of surfacing it. + * + * @param {unknown} value - the value to check + */ +function isIsoDate(value: unknown): boolean { + // `parseIsoDate` stringifies its argument before matching the `YYYY-MM-DD` shape, so a value + // that is not a string but spells a date once stringified — a single-element array is the + // realistic case — would pass a shape check the format makes fatal, and a malformed key would + // end up granting a RESTRICTED entitlement instead of taking the invalid-key path. The type is + // part of the shape, so it is rejected here rather than left to the stringifying matcher. + if (typeof value !== 'string') { + return false + } + + try { + parseIsoDate(value, 'license') + + return true + } catch (error) { + return false + } +} + +/** + * Adds an own, ordinary property. + * + * Both the product names and the field names of a product entry come from JSON, so `__proto__` + * is a name an attacker can put in a key. A plain assignment would go through the + * `Object.prototype` setter: the value would vanish from `Object.keys` while still resolving + * through the chain. + * + * @param {object} target - the object to add the property to + * @param {string} key - the property name + * @param {unknown} value - the property value + */ +function defineOwn(target: object, key: string, value: unknown): void { + Object.defineProperty(target, key, { + value, enumerable: true, writable: true, configurable: true, + }) +} + +/** + * Verifies and normalizes one product entry. + * + * Strict about SHAPE: exactly one of the two dates, a real date, and the two window sizes. A key + * that gets this wrong is malformed, not merely unknown, and reading it would mean guessing what + * was licensed. + * + * Lenient about VOCABULARY: an unrecognised capability token, an unrecognised flag and an + * unrecognised extra field are all kept and ignored. Without that leniency every token added on + * the issuing side would break every library version already deployed in the field. + * + * Returns `null` when the entry is malformed. + * + * @param {unknown} entry - the product entry of the payload + */ +function normalizeProductEntry(entry: unknown): EntitlementProductGrant | null { + if (!isPlainObject(entry)) { + return null + } + if (!isStringArray(entry.capabilities)) { + return null + } + + const presentDateFields = DATE_FIELDS.filter((field) => entry[field] !== undefined) + + // Exactly one date per product. "Both" and "neither" are each a different commercial shape + // that the format cannot express, so neither may be silently resolved by whichever field the + // parser happens to read first. + if (presentDateFields.length !== 1) { + return null + } + if (!isIsoDate(entry[presentDateFields[0]])) { + return null + } + if (!isNonNegativeInteger(entry.notice) || !isNonNegativeInteger(entry.grace)) { + return null + } + if (entry.flags !== undefined && !isStringArray(entry.flags)) { + return null + } + + // Start from everything the entry carries, so a field this version does not know survives + // into the result instead of being silently dropped. A field added to the format later is + // exactly the case an already-vendored parser has to survive, and a reader that quietly + // discards it makes the field invisible to the application on top. + const normalized = {} + + Object.keys(entry).forEach((field) => defineOwn(normalized, field, entry[field])) + + defineOwn(normalized, 'capabilities', entry.capabilities.slice()) + defineOwn(normalized, 'notice', entry.notice) + defineOwn(normalized, 'grace', entry.grace) + // An absent array and an empty one mean the same thing. Normalizing here keeps + // `flags.indexOf('trial')` safe at every call site. + defineOwn(normalized, 'flags', entry.flags === undefined ? [] : entry.flags.slice()) + defineOwn(normalized, presentDateFields[0], entry[presentDateFields[0]]) + + return normalized as EntitlementProductGrant +} + +/** + * Extracts the machine-readable data from an entitlement license key. + * + * The checksum is verified first, so the returned data is guaranteed to belong to an intact + * block. For a malformed or tampered key `null` is returned — reporting an invalid key is the + * caller's job, not this function's. + * + * Only the bracketed block matters. The prose in front of it is neither parsed nor covered by + * the checksum, so the caller may pass the whole artifact or just the `[...]` block, and + * rewrapped or re-pasted text still validates. + * + * No schema is needed. Unknown products, capabilities and flags are all tolerated, so nothing + * about reading a key depends on the vocabulary — which is what lets a product vendor this + * parser on its own. + * + * @param {string} licenseKey - the license key to extract the data from + */ +export function extractEntitlementKeyData(licenseKey: string): EntitlementKeyData | null { + if (typeof licenseKey !== 'string') { + return null + } + + // The machine-readable block closes the key. Searching backwards means a bracket inside the + // prose cannot shadow it. + const blockStart = licenseKey.lastIndexOf('[') + + if (blockStart === -1) { + return null + } + + const blockEnd = licenseKey.indexOf(']', blockStart) + + if (blockEnd === -1) { + return null + } + + const content = licenseKey.slice(blockStart + 1, blockEnd) + + if (content.length <= ENTITLEMENT_KEY_CHECKSUM_LENGTH) { + return null + } + + const encodedPayload = content.slice(0, -ENTITLEMENT_KEY_CHECKSUM_LENGTH) + const checksum = content.slice(-ENTITLEMENT_KEY_CHECKSUM_LENGTH) + + if (!ENCODED_PAYLOAD.test(encodedPayload) || !CHECKSUM.test(checksum)) { + return null + } + if (sha512(stringToUtf8Bytes(encodedPayload)) !== checksum) { + return null + } + + const payloadJson = base64ToString(encodedPayload) + + if (payloadJson === null) { + return null + } + + let payload: unknown + + try { + payload = JSON.parse(payloadJson) + } catch (error) { + return null + } + + if (!isPlainObject(payload)) { + return null + } + + const rawProducts = payload.products + + if (!isPlainObject(rawProducts)) { + return null + } + + const products = {} + let malformed = false + + Object.keys(rawProducts).forEach((name) => { + const entry = normalizeProductEntry(rawProducts[name]) + + if (entry === null) { + malformed = true + + return + } + + defineOwn(products, name, entry) + }) + + if (malformed) { + return null + } + + return {products} +} diff --git a/src/license/handsontable-license-key-parser/sha512.ts b/src/license/handsontable-license-key-parser/sha512.ts new file mode 100644 index 000000000..e65aee16e --- /dev/null +++ b/src/license/handsontable-license-key-parser/sha512.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/entitlement-key/sha512.js`. + * See `src/license/handsontable-license-key-parser/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/handsontable-license-key-parser/utils.ts b/src/license/handsontable-license-key-parser/utils.ts new file mode 100644 index 000000000..1a1a46d99 --- /dev/null +++ b/src/license/handsontable-license-key-parser/utils.ts @@ -0,0 +1,218 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/entitlement-key/utils.js`. + * See `src/license/handsontable-license-key-parser/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) +} diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts new file mode 100644 index 000000000..c8338c17e --- /dev/null +++ b/src/license/licenseResolution.ts @@ -0,0 +1,391 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import { + checkLicenseKeyValidity, + LicenseKeyValidityState, + notifyLicenseKeyNotice, + notifyLicenseKeyState, +} from '../helpers/licenseKeyValidator' +import {ALL_FEATURE_TOKENS, CAPABILITY_TABLE, normalizeCapabilityToken} from './capabilities' +import {LicenseEntitlement, LicenseExpiry, unrestrictedEntitlement} from './LicenseEntitlement' +import {detectLicenseKeyFormat} from './handsontable-license-key-parser/detectFormat' +import {EntitlementKeyData, EntitlementProductGrant, extractEntitlementKeyData} from './handsontable-license-key-parser/extractKeyData' +import {parseIsoDate} from './handsontable-license-key-parser/utils' + +/** Milliseconds in a day, used to turn a grace period in days into a deadline. */ +const MILLISECONDS_PER_DAY = 86400000 + +/** + * The name of HyperFormula's own product entry in an entitlement key payload. Every product + * entry carries its own capabilities, dates and windows, so this is the only entry this library + * reads — a key granting other products alongside (or instead of) HyperFormula is a valid key + * whose other entries are simply not for us. + */ +export const HYPERFORMULA_PRODUCT_NAME = 'hyperformula' + +/** + * 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 6 §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'] + +/** + * 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 an entitlement key, read from its own product entry. + * + * The entry's shape is guaranteed by the vendored reader ({@link extractEntitlementKeyData} + * returns `null` for anything malformed), so unlike the typed-key adapter this replaces, nothing + * here re-checks field types or reconciles competing payload shapes: the entitlement format is + * the only shape there is, and a key granting HyperFormula nothing is simply a key with no + * `hyperformula` entry. + */ +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 an entitlement key: key spec rev 6 §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 entitlement 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 HyperFormula's terms out of an intact entitlement key payload. + * + * Total on purpose: the vendored reader has already rejected every malformed shape, so every + * field read here is exactly what {@link EntitlementProductGrant} promises. A payload without a + * `hyperformula` entry — including `products: {}` — is a VALID key that grants this library + * nothing and never expires for it; per HF-307 decision D6-A that cliff is silent. Note this + * differs from the typed-key format this replaces, where a key licensed to another product + * carried the expiry HyperFormula was checked against: an entitlement key's product entries each + * carry their own terms, so another product's dates are not ours to read. + * + * @param {EntitlementKeyData} data - the extracted key data + */ +function licenseTermsOf(data: EntitlementKeyData): LicenseTerms { + const grant: EntitlementProductGrant | undefined = data.products[HYPERFORMULA_PRODUCT_NAME] + + // Nothing is granted implicitly: a key's functions are exactly what its own tokens name. A key + // whose tokens this build does not recognize therefore still evaluates the infix operators (they + // are not function calls) and the protected built-ins, and returns #LIC! for every function call, + // silently, per HF-307 decision D3. That cliff is ratified as-is (D6-A): "this situation should + // never happen. There is no point in issuing a key if empty capabilities." + const capabilityTokens: string[] = [] + + if (grant !== undefined) { + // Appended one by one rather than with `push(...grant.capabilities)`. The array comes from an + // attacker-influenced payload and the format sets no size limit (the spec addendum lists + // "payload size" as an open question on its own page), and spreading an array into a call puts + // one argument per stack slot: measured, a checksum-valid key carrying 125 000 tokens threw + // `RangeError: Maximum call stack size exceeded` out of `HyperFormula.buildFromArray` instead + // of resolving to a verdict. A malformed or hostile key must produce INVALID, never a throw. + grant.capabilities.forEach((token) => capabilityTokens.push(token)) + } + + // 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 (the ratified decision: "Feature gating should work"). A key carrying NONE cannot + // be saying "no features", because no vocabulary in circulation can express one: the key spec's + // current HyperFormula token list (rev 6 §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. + // The trigger is a feature token this version RECOGNIZES, not merely one that looks like a + // feature token. An unrecognized `feat:*` token has to be inert (D3: "unrecognized token should + // not grant the capability (silently ignored)"), and a purely syntactic prefix test makes it the + // opposite of inert - it suppresses the fallback, so the key ends up with ZERO of the five areas. + // Measured before this guard existed: a key carrying `functions_1` plus a single unknown + // `feat:teleport` had CRUD, undo, clipboard, named expressions and batching all throwing, while + // the same key without that token had all five. That is the additive-safety rule inverted - an + // older build meeting a key minted by a newer generator, or a one-character typo at issuing time, + // would revoke the whole gated API rather than ignore a word it does not know. + const namesAKnownFeature = capabilityTokens.some((token) => { + const normalized = normalizeCapabilityToken(token) + + return normalized.indexOf(FEATURE_TOKEN_PREFIX) === 0 && CAPABILITY_TABLE.has(normalized) + }) + + if (!namesAKnownFeature) { + capabilityTokens.push(...ALL_FEATURE_TOKENS) + } + + // Exactly one of the two date fields is present on an intact entry (the reader enforces it), + // and the date used and the axis it is compared against come from that same field. The date is + // carried as the payload's own `YYYY-MM-DD` string, never routed through `Date` formatting - + // the key spec's fixture J11 exists because `toISOString()` shortens every licence issued east + // of UTC by a day. + const expiryDate = grant === undefined ? undefined : (grant.usage_until ?? grant.release_until) + const comparedAgainstReleaseDate = grant !== undefined && grant.release_until !== undefined + const expiryTimestamp = expiryDate === undefined ? null : parseIsoDate(expiryDate, 'expiration').timestamp + const flags = grant === undefined ? [] : grant.flags + // A release-date comparison has no grace period: it is static, so there is no window to be + // inside of. + const graceDays = comparedAgainstReleaseDate || grant === undefined ? 0 : grant.grace + + return { + capabilityTokens, + expiry: expiryDate === undefined || expiryTimestamp === null + ? {kind: 'none', date: null, noticeDays: 0, graceDays: 0} + : { + kind: comparedAgainstReleaseDate ? 'release' : 'usage', + date: expiryDate, + // Read off HyperFormula's OWN entry, which is what makes the shape gate structural here: + // the tagged format took its terms from the LICENSED product's entry, so a `notice` field + // another product added for its own purposes could switch HyperFormula's console output + // on (fixed under gate in the previous PR). An entitlement key carries per-entry terms, so + // another product's `notice` is not reachable from here at all. + noticeDays: grant === undefined ? 0 : grant.notice, + graceDays, + }, + expiryTimestamp, + comparedAgainstReleaseDate, + graceDays, + isTrial: 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). That was confirmed an implementation error. + silent: flags.some((flag) => SILENT_CONSOLE_FLAGS.indexOf(flag) !== -1), + } +} + +/** + * Whether an intact entitlement 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 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(deadline)} +} + +/** + * The day a VALID key's usage-until expiry falls on, if the current UTC instant is within its + * notice window — `null` otherwise, which covers "no notice window configured" (`noticeDays` is + * `0`, which is also what a key with no HyperFormula entry resolves to) just as much as "not close + * enough yet" or "already past its usage-until day". + * + * Deliberately blind to `graceDays`: notice is about the usage_until axis itself, not about the + * grace extension past it. Key spec rev 6 §4.1 sequences notice, then a soft-stop window, then the + * hard-stop this build already enforces; only the hard stop and this notice are built for 3.5.0 + * (decision D5-A), so the window checked here ends exactly where the soft-stop phase would + * begin, rather than reaching into grace and printing a notice for a key already past its expiry. + * + * `release_until`-axis keys never reach here with a non-`null` result — `kind` is `'usage'` only + * when the date came from `usage_until` (see {@link licenseTermsOf}) — matching the spec's rule + * that notice and grace have no effect on that axis. The converse holds too now: the tagged + * format let an entry with no date of its own fall through to the key envelope's `exp`, so + * `'usage'` did not imply `usage_until`; an entitlement key has no envelope date to fall back to. + * + * @param {LicenseTerms} terms - the terms of the key + */ +function expiryWithinNoticeWindow(terms: LicenseTerms): Date | null { + if (terms.expiry.kind !== 'usage' || terms.expiry.noticeDays <= 0 || terms.expiryTimestamp === null) { + return null + } + + // The window ends at the first instant no longer on the usage_until day — the same boundary + // `validityOf` uses before adding its grace term — and opens `notice` days before the licensed + // day ITSELF, not before that end. Counting back from the end would shorten the window by a day: + // the date-semantics fixtures pin 2027-06-13T00:00:00Z for usage_until 2027-08-12 with notice 60, + // and a trial whose notice equals its whole term must warn from the day it is issued. + const usageAxisDeadline = terms.expiryTimestamp + MILLISECONDS_PER_DAY + const noticeWindowStart = terms.expiryTimestamp - (terms.expiry.noticeDays * MILLISECONDS_PER_DAY) + const now = Date.now() + + return now >= noticeWindowStart && now < usageAxisDeadline ? new Date(terms.expiryTimestamp) : null +} + +/** + * Turns the terms of an intact, unexpired entitlement 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. + * + * @param {LicenseTerms} terms - the terms of the key + */ +function entitlementOf(terms: LicenseTerms): LicenseEntitlement { + const unrecognizedCapabilities = terms.capabilityTokens.filter( + (token) => !CAPABILITY_TABLE.has(normalizeCapabilityToken(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. + * + * Routing follows the vendored {@link detectLicenseKeyFormat}, whose test order is normative + * (key spec addendum, T12): the literals, then the trailing bracketed block that marks an + * entitlement key, then the legacy 25-character shape. Everything that is not an entitlement key + * — `gpl-v3`, a legacy key, an empty string — falls through to {@link checkLicenseKeyValidity} + * completely unchanged, which is what keeps this from touching existing behaviour. A string that + * carries a bracketed block routes here even when the block is garbage: such a key is INVALID, + * not a legacy key that happens to contain brackets. + * + * **The invariant this function exists to protect.** Only a VALID entitlement key resolves to a + * restricted entitlement. Every other outcome — missing, invalid, or expired, for an entitlement + * 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 payload shape 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 the vendored reader + * has not verified. + * + * @param {string} licenseKey - the raw `licenseKey` config value + * @param {boolean} notifyConsole - pass `false` for a resolution whose result exists only to be + * thrown away (e.g. the transient serialization-only `Config` that `rebuildWithConfig` builds + * from the OUTGOING config) — such a resolution must not print notices for a key the caller is + * in the middle of replacing. Legacy keys notify inside {@link checkLicenseKeyValidity} behind a + * once-per-page-load flag, so they cannot double-print regardless of this parameter. + */ +export function resolveLicense(licenseKey: string, notifyConsole: boolean = true): ResolvedLicense { + if (detectLicenseKeyFormat(licenseKey) !== 'entitlement') { + return { + validityState: checkLicenseKeyValidity(licenseKey), + entitlement: unrestrictedEntitlement(), + } + } + + const data = extractEntitlementKeyData(licenseKey) + + if (data === null) { + if (notifyConsole) { + notifyLicenseKeyState(LicenseKeyValidityState.INVALID) + } + + return {validityState: LicenseKeyValidityState.INVALID, entitlement: unrestrictedEntitlement()} + } + + const terms = licenseTermsOf(data) + const {state, expiredOn} = validityOf(terms) + + if (notifyConsole && !terms.silent) { + notifyLicenseKeyState(state, expiredOn, terms.comparedAgainstReleaseDate ? 'release' : 'usage') + + if (state === LicenseKeyValidityState.VALID) { + const noticeExpiryDate = expiryWithinNoticeWindow(terms) + + if (noticeExpiryDate !== null) { + notifyLicenseKeyNotice(licenseKey, noticeExpiryDate) + } + } + } + + return { + validityState: state, + entitlement: state === LicenseKeyValidityState.VALID ? entitlementOf(terms) : unrestrictedEntitlement(), + } +}