Skip to content
Closed
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
63 changes: 60 additions & 3 deletions src/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Config, { licenseKeyValidityState: LicenseKeyValidityState }> = 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<Config, LicensePrivateState> = new WeakMap()

export class Config implements ConfigParams, ParserConfig {

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/error-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
}
25 changes: 23 additions & 2 deletions src/interpreter/Interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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) {
Expand Down
126 changes: 126 additions & 0 deletions src/license/CapabilityRegistry.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
features: ReadonlySet<FeatureId>,
}

/**
* 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<string, CapabilityGrant>
private readonly reverseIndex: ReadonlyMap<string, string>

/**
* @param {ReadonlyMap<string, CapabilityGrant>} [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<string, CapabilityGrant>) {
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<string, CapabilityGrant>} table - the table to invert
*/
private static buildReverseIndex(table: ReadonlyMap<string, CapabilityGrant>): ReadonlyMap<string, string> {
const index = new Map<string, string>()
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<string>(), features: new Set<FeatureId>()}
}

const functions = new Set<string>()
const features = new Set<FeatureId>()
const visited = new Set<string>()
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)
}
91 changes: 91 additions & 0 deletions src/license/LicenseEntitlement.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
/**
* 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<string>(),
unrecognizedCapabilities: [],
expiry: {kind: 'none', date: null, noticeDays: 0, graceDays: 0},
silent: false,
isTrial: false,
}
}
Loading
Loading