Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ 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)

### 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. [#1731](https://github.com/handsontable/hyperformula/pull/1731)

## [3.4.0] - 2026-08-10

### Added
Expand Down
29 changes: 29 additions & 0 deletions docs/guide/license-key.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,40 @@ two dates:

This process doesn't require any connection to the 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. Methods that only read data never throw.
* [`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 always available, whatever your key grants.

::: 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 function 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; it stops formulas from calculating until you fix the key.

## License key support

If you have any issues with your license key, [contact our team](contact.md).
2 changes: 1 addition & 1 deletion docs/guide/types-of-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,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. |
| #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. |
81 changes: 63 additions & 18 deletions src/HyperFormula.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -702,26 +702,57 @@ 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 `licenseCapabilities.unrestricted` set 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) {
Expand All @@ -745,14 +776,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
Expand Down Expand Up @@ -4556,6 +4592,14 @@ 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 function evaluates to `#LIC!` — so the full catalogue
* is still described. Use it to build a function picker before a key is configured.
* - Custom (user-registered) functions are never omitted; the license covers built-ins only.
*
* @example
* ```js
* const hfInstance = HyperFormula.buildEmpty();
Expand All @@ -4569,9 +4613,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,
)
}

Expand All @@ -4582,9 +4626,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
Expand Down Expand Up @@ -4613,8 +4658,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)
}

/**
Expand Down
5 changes: 2 additions & 3 deletions src/interpreter/Interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
}
}
Expand Down
31 changes: 31 additions & 0 deletions src/license/CapabilityRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,34 @@ export function allowsFunction(resolved: ResolvedCapabilities, functionId: strin
export function allowsFeature(resolved: ResolvedCapabilities, feature: FeatureId): boolean {
return resolved.unrestricted || 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)
}
Loading
Loading