diff --git a/src/Config.ts b/src/Config.ts index 8e8a924ee..2eae9aa60 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -20,11 +20,24 @@ import {checkLicenseKeyValidity, LicenseKeyValidityState} from './helpers/licens import {HyperFormula} from './HyperFormula' import {TranslationPackage} from './i18n' import {FunctionPluginDefinition} from './interpreter' +import {CapabilityRegistry, ResolvedCapabilities} from './license/CapabilityRegistry' +import {unrestrictedEntitlement} from './license/LicenseEntitlement' import {Maybe} from './Maybe' import {ParserConfig} from './parser/ParserConfig' import {ConfigParams, ConfigParamsList} from './ConfigParams' -const privatePool: WeakMap = new WeakMap() +/** + * The license-derived state kept off the public `ConfigParams` surface — see + * {@link Config.licenseCapabilities}. + */ +interface LicensePrivateState { + licenseKeyValidityState: LicenseKeyValidityState, + licenseCapabilities: ResolvedCapabilities, + isLicenseGateActive: boolean, + capabilityRegistry: CapabilityRegistry, +} + +const privatePool: WeakMap = new WeakMap() export class Config implements ConfigParams, ParserConfig { @@ -266,8 +279,19 @@ export class Config implements ConfigParams, ParserConfig { validateNumberToBeAtLeast(this.maxColumns, 'maxColumns', 1) this.context = context + const licenseKeyValidityState = checkLicenseKeyValidity(this.licenseKey) + const capabilityRegistry = new CapabilityRegistry() + // PR 1 (HF-307) ships the gate infrastructure without a real license-key payload adapter — + // that lands in PR 3 as src/license/payloadAdapter.ts. Until then every entitlement resolves + // as unrestricted, so isLicenseGateActive below reduces to today's licenseKeyValidityState + // check and gate B in the interpreter never actually restricts a function. + const licenseCapabilities = capabilityRegistry.resolve(unrestrictedEntitlement()) + privatePool.set(this, { - licenseKeyValidityState: checkLicenseKeyValidity(this.licenseKey) + licenseKeyValidityState, + licenseCapabilities, + isLicenseGateActive: licenseKeyValidityState !== LicenseKeyValidityState.VALID || !licenseCapabilities.unrestricted, + capabilityRegistry, }) configCheckIfParametersNotInConflict( @@ -305,7 +329,40 @@ export class Config implements ConfigParams, ParserConfig { * @internal */ public get licenseKeyValidityState(): LicenseKeyValidityState { - return (privatePool.get(this) as Config).licenseKeyValidityState + return (privatePool.get(this) as LicensePrivateState).licenseKeyValidityState + } + + /** + * The functions and features this config's license entitles it to, already resolved from + * whatever tokens the license key carries. Proxied to its private counterpart for the same + * reason as {@link licenseKeyValidityState}: it must never become part of {@link getConfig}. + * + * @internal + */ + public get licenseCapabilities(): ResolvedCapabilities { + return (privatePool.get(this) as LicensePrivateState).licenseCapabilities + } + + /** + * 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 + * key — is a single boolean read, cheaper than the string-enum comparison it replaces. + * + * @internal + */ + public get isLicenseGateActive(): boolean { + return (privatePool.get(this) as LicensePrivateState).isLicenseGateActive + } + + /** + * The registry used to resolve this config's entitlement into {@link licenseCapabilities}. + * Exposed so the interpreter can tell a custom, instance-registered function apart from a + * built-in outside the capability table without constructing a second registry. + * + * @internal + */ + public get capabilityRegistry(): CapabilityRegistry { + return (privatePool.get(this) as LicensePrivateState).capabilityRegistry } public getConfig(): ConfigParams { diff --git a/src/error-message.ts b/src/error-message.ts index 5e3afdbea..df6548b97 100644 --- a/src/error-message.ts +++ b/src/error-message.ts @@ -77,4 +77,5 @@ export class ErrorMessage { public static FunctionName = (arg: string) => `Function name ${arg} not recognized.` public static NamedExpressionName = (arg: string) => `Named expression ${arg} not recognized.` public static LicenseKey = (arg: string) => `License key is ${arg}.` + public static LicenseCapability = (functionName: string) => `Function ${functionName} is not included in your license.` } diff --git a/src/interpreter/Interpreter.ts b/src/interpreter/Interpreter.ts index 8edf08f0a..2c6e39f2a 100644 --- a/src/interpreter/Interpreter.ts +++ b/src/interpreter/Interpreter.ts @@ -13,6 +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 {ColumnSearchStrategy} from '../Lookup/SearchStrategy' import {Maybe} from '../Maybe' import {NamedExpressions} from '../NamedExpressions' @@ -61,6 +62,17 @@ export class Interpreter { this.criterionBuilder = new CriterionBuilder(config) } + /** + * Resolves a function id to the name it is covered by in the capability table. A function + * registered purely as an alias of another one (`plugin.aliases`) must gate identically to + * its canonical name — otherwise calling a gated function through its alias would silently + * bypass the entitlement check. + */ + private canonicalFunctionId(functionId: string): string { + const plugin = this.functionRegistry.getFunctionPlugin(functionId) + return plugin?.aliases?.[functionId] ?? functionId + } + public evaluateAst(ast: Ast, state: InterpreterState): InterpreterValue { let val = this.evaluateAstWithoutPostprocessing(ast, state) if (isExtendedNumber(val)) { @@ -177,8 +189,17 @@ export class Interpreter { return this.unaryRangeWrapper(this.percentOp, result, state) } case AstNodeType.FUNCTION_CALL: { - if (this.config.licenseKeyValidityState !== LicenseKeyValidityState.VALID && !FunctionRegistry.functionIsProtected(ast.procedureName)) { - return new CellError(ErrorType.LIC, ErrorMessage.LicenseKey(this.config.licenseKeyValidityState)) + if (this.config.isLicenseGateActive && !FunctionRegistry.functionIsProtected(ast.procedureName)) { + const validityState = this.config.licenseKeyValidityState + if (validityState !== LicenseKeyValidityState.VALID) { + return new CellError(ErrorType.LIC, ErrorMessage.LicenseKey(validityState)) + } + + const canonicalId = this.canonicalFunctionId(ast.procedureName) + if (this.config.capabilityRegistry.capabilityOf(canonicalId) !== undefined + && !allowsFunction(this.config.licenseCapabilities, canonicalId)) { + return new CellError(ErrorType.LIC, ErrorMessage.LicenseCapability(ast.procedureName)) + } } const pluginFunction = this.functionRegistry.getFunction(ast.procedureName) if (pluginFunction !== undefined) { diff --git a/src/license/CapabilityRegistry.ts b/src/license/CapabilityRegistry.ts new file mode 100644 index 000000000..61462c82e --- /dev/null +++ b/src/license/CapabilityRegistry.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import {FeatureId, LicenseEntitlement} from './LicenseEntitlement' +import {CAPABILITY_TABLE, CapabilityGrant, refreshCoreGrant} from './capabilities' + +/** + * The capabilities a resolved {@link LicenseEntitlement} grants, ready for gate B (the + * interpreter) and PR 2's `ensureCapability` to query through {@link allowsFunction} and + * {@link allowsFeature}. + */ +export interface ResolvedCapabilities { + /** `true` short-circuits both {@link allowsFunction} and {@link allowsFeature} to `true`. */ + unrestricted: boolean, + functions: ReadonlySet, + features: ReadonlySet, +} + +/** + * Expands a {@link LicenseEntitlement}'s capability tokens against a table of + * {@link CapabilityGrant}s into the concrete functions and features they grant, and answers + * which token, if any, covers a given function id. + */ +export class CapabilityRegistry { + private readonly table: ReadonlyMap + private readonly reverseIndex: ReadonlyMap + + /** + * @param {ReadonlyMap} [table] - capability table to resolve + * against. Omit to use the production {@link CAPABILITY_TABLE}; tests inject their own so the + * 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) + } + + /** + * Inverts a capability table from token → grant into function id → token, so + * {@link capabilityOf} is a single lookup instead of a scan. The first token that lists a + * given function id wins, in table iteration order. + * + * @param {ReadonlyMap} table - the table to invert + */ + private static buildReverseIndex(table: ReadonlyMap): ReadonlyMap { + const index = new Map() + for (const [token, grant] of table) { + for (const functionId of grant.functions) { + if (!index.has(functionId)) { + index.set(functionId, token) + } + } + } + return index + } + + /** + * Expands an entitlement's capability tokens into the concrete functions and features they + * grant. An `unrestricted` entitlement short-circuits to an unrestricted result without + * consulting the table at all. Expansion through `implies` is transitive and cycle-safe (a + * visited set guards against a token implying itself, directly or through others); an + * unrecognized token is skipped without an error. + * + * @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 + */ + public resolve(entitlement: LicenseEntitlement): ResolvedCapabilities { + if (entitlement.unrestricted) { + return {unrestricted: true, functions: new Set(), features: new Set()} + } + + const functions = new Set() + const features = new Set() + const visited = new Set() + const queue = [...entitlement.capabilities] + + while (queue.length > 0) { + const token = queue.shift() as string + if (visited.has(token)) { + continue + } + visited.add(token) + + const grant = this.table.get(token) + if (grant === undefined) { + continue + } + grant.functions.forEach((functionId) => functions.add(functionId)) + grant.features.forEach((feature) => features.add(feature)) + grant.implies?.forEach((impliedToken) => queue.push(impliedToken)) + } + + return {unrestricted: false, functions, features} + } + + /** + * 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 — + * 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. + */ + public capabilityOf(functionId: string): string | undefined { + return this.reverseIndex.get(functionId) + } +} + +/** + * Whether a resolved entitlement allows calling the given function. + */ +export function allowsFunction(resolved: ResolvedCapabilities, functionId: string): boolean { + return resolved.unrestricted || resolved.functions.has(functionId) +} + +/** + * Whether a resolved entitlement allows using the given feature area of the public API. + */ +export function allowsFeature(resolved: ResolvedCapabilities, feature: FeatureId): boolean { + return resolved.unrestricted || resolved.features.has(feature) +} diff --git a/src/license/LicenseEntitlement.ts b/src/license/LicenseEntitlement.ts new file mode 100644 index 000000000..9472d1350 --- /dev/null +++ b/src/license/LicenseEntitlement.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * 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. + */ +export const enum FeatureId { + NamedExpressions = 'named_expressions', + Clipboard = 'clipboard', + Crud = 'crud', + UndoRedo = 'undo_redo', + Batching = 'batching', + CustomFunctions = 'custom_functions', + ImportExport = 'import_export', +} + +/** + * 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. + * - `kind === 'none'`: the entitlement does not expire. + */ +export interface LicenseExpiry { + kind: 'usage' | 'release' | 'none', + /** ISO 'YYYY-MM-DD', or `null` when `kind` is `'none'`. */ + date: string | null, + noticeDays: number, + graceDays: number, +} + +/** + * The resolved set of things a license grants, independent of how the underlying license key + * was parsed. + * + * This is the contract every later HF-307 task consumes: {@link CapabilityRegistry} turns it + * into a `ResolvedCapabilities` set, gate B in the interpreter reads that set, and PR 2's + * `ensureCapability` reads it for the public API. Until PR 3 lands the real license-key payload + * adapter, instances of this are built by hand in tests rather than read from a real key. + */ +export interface LicenseEntitlement { + /** `true` for every key this library fully understands today (`gpl-v3`, legacy keys). */ + unrestricted: boolean, + /** Capability tokens this entitlement grants, recognized by this library version. */ + capabilities: ReadonlySet, + /** + * Tokens present on the license payload that this library version does not recognize. + * Kept for diagnostics and tests; per HF-307 decision D3 nothing public reads this field — an + * unrecognized token never grants a capability, and it does so silently. + */ + unrecognizedCapabilities: readonly string[], + 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. + */ + silent: boolean, + isTrial: boolean, +} + +/** + * 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 + * 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. + */ +export function unrestrictedEntitlement(): LicenseEntitlement { + return { + unrestricted: true, + capabilities: new Set(), + unrecognizedCapabilities: [], + expiry: {kind: 'none', date: null, noticeDays: 0, graceDays: 0}, + silent: false, + isTrial: false, + } +} diff --git a/src/license/capabilities.ts b/src/license/capabilities.ts new file mode 100644 index 000000000..858433607 --- /dev/null +++ b/src/license/capabilities.ts @@ -0,0 +1,56 @@ +/** + * @license + * 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' + +/** + * Describes what a capability token grants: a set of function ids, a set of {@link FeatureId} + * values, and optionally other tokens it implies. `implies` is expanded recursively by + * `CapabilityRegistry.resolve`, not by anything in this file. + */ +export interface CapabilityGrant { + functions: string[], + features: FeatureId[], + implies?: string[], +} + +const coreGrant: CapabilityGrant = { + functions: [], + features: [FeatureId.NamedExpressions, FeatureId.Clipboard, FeatureId.Crud, FeatureId.UndoRedo, FeatureId.Batching], +} + +/** + * The production capability table. + * + * 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). + * + * `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. + */ +export const CAPABILITY_TABLE: ReadonlyMap = new Map([[CORE_TOKEN, coreGrant]]) + +/** + * 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. + */ +export function refreshCoreGrant(): void { + coreGrant.functions = FunctionRegistry.getRegisteredFunctionIds() +}