diff --git a/src/BuildEngineFactory.ts b/src/BuildEngineFactory.ts index 62202a78c..69b74eaea 100644 --- a/src/BuildEngineFactory.ts +++ b/src/BuildEngineFactory.ts @@ -10,7 +10,7 @@ import {Config} from './Config' import {CrudOperations} from './CrudOperations' import {DateTimeHelper} from './DateTimeHelper' import {DependencyGraph} from './DependencyGraph' -import {SheetSizeLimitExceededError} from './errors' +import {LicenseCapabilityMissingError, SheetSizeLimitExceededError} from './errors' import {Evaluator} from './Evaluator' import {Exporter} from './Exporter' import {GraphBuilder} from './GraphBuilder' @@ -19,6 +19,8 @@ import {ArithmeticHelper} from './interpreter/ArithmeticHelper' import {FunctionRegistry} from './interpreter/FunctionRegistry' import {Interpreter} from './interpreter/Interpreter' import {LazilyTransformingAstService} from './LazilyTransformingAstService' +import {allowsFeature} from './license/CapabilityRegistry' +import {FeatureId} from './license/LicenseEntitlement' import {buildColumnSearchStrategy, ColumnSearchStrategy} from './Lookup/SearchStrategy' import {NamedExpressions} from './NamedExpressions' import {NumberLiteralHelper} from './NumberLiteralHelper' @@ -50,23 +52,44 @@ export type EngineState = { export class BuildEngineFactory { public static buildFromSheets(sheets: Sheets, configInput: Partial = {}, namedExpressions: SerializedNamedExpression[] = []): EngineState { const config = new Config(configInput) + this.ensureNamedExpressionsCapability(config, namedExpressions) return this.buildEngine(config, sheets, namedExpressions) } public static buildFromSheet(sheet: Sheet, configInput: Partial = {}, namedExpressions: SerializedNamedExpression[] = []): EngineState { const config = new Config(configInput) + this.ensureNamedExpressionsCapability(config, namedExpressions) const newsheetprefix = config.translationPackage.getUITranslation(UIElement.NEW_SHEET_PREFIX) + '1' return this.buildEngine(config, {[newsheetprefix]: sheet}, namedExpressions) } public static buildEmpty(configInput: Partial = {}, namedExpressions: SerializedNamedExpression[] = []): EngineState { - return this.buildEngine(new Config(configInput), {}, namedExpressions) + const config = new Config(configInput) + this.ensureNamedExpressionsCapability(config, namedExpressions) + return this.buildEngine(config, {}, namedExpressions) } public static rebuildWithConfig(config: Config, sheets: Sheets, namedExpressions: SerializedNamedExpression[], stats: Statistics): EngineState { return this.buildEngine(config, sheets, namedExpressions, stats) } + /** + * Throws if `namedExpressions` is non-empty and `config`'s entitlement does not grant + * {@link FeatureId.NamedExpressions} (HF-307 PR 2, task 2.3 - the build-time counterpart of + * {@link HyperFormula.ensureCapability}). An empty list is never checked: building an engine + * with no named expressions never touches the feature. Deliberately not called from + * {@link rebuildWithConfig}, which re-serializes named expressions an already-built instance + * created (and was allowed to create) rather than accepting them fresh from a caller. + */ + private static ensureNamedExpressionsCapability(config: Config, namedExpressions: SerializedNamedExpression[]): void { + if (namedExpressions.length === 0) { + return + } + if (config.isLicenseGateActive && !allowsFeature(config.licenseCapabilities, FeatureId.NamedExpressions)) { + throw new LicenseCapabilityMissingError(FeatureId.NamedExpressions) + } + } + private static buildEngine(config: Config, sheets: Sheets = {}, inputNamedExpressions: SerializedNamedExpression[] = [], stats: Statistics = config.useStats ? new Statistics() : new EmptyStatistics()): EngineState { stats.start(StatType.BUILD_ENGINE_TOTAL) diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index 522d7b150..3c4f9fbc2 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -38,11 +38,14 @@ import { ExpectedValueOfTypeError, LanguageAlreadyRegisteredError, LanguageNotRegisteredError, + LicenseCapabilityMissingError, NotAFormulaError, } from './errors' import {Evaluator} from './Evaluator' import {ExportedChange, Exporter} from './Exporter' import {LicenseKeyValidityState} from './helpers/licenseKeyValidator' +import {allowsFeature} from './license/CapabilityRegistry' +import {FeatureId} from './license/LicenseEntitlement' import {buildTranslationPackage, RawTranslationPackage, TranslationPackage} from './i18n' import {FunctionPluginDefinition} from './interpreter' import {FUNCTION_DOCS} from './interpreter/functionMetadata' @@ -1237,6 +1240,7 @@ export class HyperFormula implements TypedEmitter { * @category Undo and Redo */ public undo(): ExportedChange[] { + this.ensureCapability(FeatureId.UndoRedo) this._crudOperations.undo() return this.recomputeIfDependencyGraphNeedsIt() } @@ -1275,6 +1279,7 @@ export class HyperFormula implements TypedEmitter { * @category Undo and Redo */ public redo(): ExportedChange[] { + this.ensureCapability(FeatureId.UndoRedo) this._crudOperations.redo() return this.recomputeIfDependencyGraphNeedsIt() } @@ -1407,6 +1412,7 @@ export class HyperFormula implements TypedEmitter { * @category Cells */ public setCellContents(topLeftCornerAddress: SimpleCellAddress, cellContents: RawCellContent[][] | RawCellContent): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) this._crudOperations.setCellContents(topLeftCornerAddress, cellContents) return this.recomputeIfDependencyGraphNeedsIt() } @@ -1459,6 +1465,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public swapRowIndexes(sheetId: number, rowMapping: [number, number][]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.setRowOrder(sheetId, rowMapping) return this.recomputeIfDependencyGraphNeedsIt() @@ -1542,6 +1549,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public setRowOrder(sheetId: number, newRowOrder: number[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') const mapping = this._crudOperations.mappingFromOrder(sheetId, newRowOrder, 'row') return this.swapRowIndexes(sheetId, mapping) @@ -1635,6 +1643,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public swapColumnIndexes(sheetId: number, columnMapping: [number, number][]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.setColumnOrder(sheetId, columnMapping) return this.recomputeIfDependencyGraphNeedsIt() @@ -1713,6 +1722,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public setColumnOrder(sheetId: number, newColumnOrder: number[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') const mapping = this._crudOperations.mappingFromOrder(sheetId, newColumnOrder, 'column') return this.swapColumnIndexes(sheetId, mapping) @@ -1824,6 +1834,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public addRows(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.addRows(sheetId, ...indexes) return this.recomputeIfDependencyGraphNeedsIt() @@ -1896,6 +1907,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public removeRows(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.removeRows(sheetId, ...indexes) return this.recomputeIfDependencyGraphNeedsIt() @@ -1972,6 +1984,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public addColumns(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.addColumns(sheetId, ...indexes) return this.recomputeIfDependencyGraphNeedsIt() @@ -2047,6 +2060,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public removeColumns(sheetId: number, ...indexes: ColumnRowIndex[]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.removeColumns(sheetId, ...indexes) return this.recomputeIfDependencyGraphNeedsIt() @@ -2140,6 +2154,7 @@ export class HyperFormula implements TypedEmitter { * @category Cells */ public moveCells(source: SimpleCellRange, destinationLeftCorner: SimpleCellAddress): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) if (!isSimpleCellAddress(destinationLeftCorner)) { throw new ExpectedValueOfTypeError('SimpleCellAddress', 'destinationLeftCorner') } @@ -2226,6 +2241,7 @@ export class HyperFormula implements TypedEmitter { * @category Rows */ public moveRows(sheetId: number, startRow: number, numberOfRows: number, targetRow: number): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') validateArgToType(startRow, 'number', 'startRow') validateArgToType(numberOfRows, 'number', 'numberOfRows') @@ -2314,6 +2330,7 @@ export class HyperFormula implements TypedEmitter { * @category Columns */ public moveColumns(sheetId: number, startColumn: number, numberOfColumns: number, targetColumn: number): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') validateArgToType(startColumn, 'number', 'startColumn') validateArgToType(numberOfColumns, 'number', 'numberOfColumns') @@ -2352,6 +2369,7 @@ export class HyperFormula implements TypedEmitter { * @category Clipboard */ public copy(source: SimpleCellRange): CellValue[][] { + this.ensureCapability(FeatureId.Clipboard) if (!isSimpleCellRange(source)) { throw new ExpectedValueOfTypeError('SimpleCellRange', 'source') } @@ -2392,6 +2410,7 @@ export class HyperFormula implements TypedEmitter { * @category Clipboard */ public cut(source: SimpleCellRange): CellValue[][] { + this.ensureCapability(FeatureId.Clipboard) if (!isSimpleCellRange(source)) { throw new ExpectedValueOfTypeError('SimpleCellRange', 'source') } @@ -2443,6 +2462,7 @@ export class HyperFormula implements TypedEmitter { * @category Clipboard */ public paste(targetLeftCorner: SimpleCellAddress): ExportedChange[] { + this.ensureCapability(FeatureId.Clipboard) if (!isSimpleCellAddress(targetLeftCorner)) { throw new ExpectedValueOfTypeError('SimpleCellAddress', 'targetLeftCorner') } @@ -2769,6 +2789,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public addSheet(sheetName?: string): string { + this.ensureCapability(FeatureId.Crud) if (sheetName !== undefined) { validateArgToType(sheetName, 'string', 'sheetName') } @@ -2844,6 +2865,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public removeSheet(sheetId: number): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') const displayName = this.sheetMapping.getSheetName(sheetId) as string this._crudOperations.removeSheet(sheetId) @@ -2917,6 +2939,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public clearSheet(sheetId: number): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.clearSheet(sheetId) return this.recomputeIfDependencyGraphNeedsIt() @@ -2984,6 +3007,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public setSheetContent(sheetId: number, values: RawCellContent[][]): ExportedChange[] { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') this._crudOperations.setSheetContent(sheetId, values) return this.recomputeIfDependencyGraphNeedsIt() @@ -3671,6 +3695,7 @@ export class HyperFormula implements TypedEmitter { * @category Sheets */ public renameSheet(sheetId: number, newName: string): void { + this.ensureCapability(FeatureId.Crud) validateArgToType(sheetId, 'number', 'sheetId') validateArgToType(newName, 'string', 'newName') const oldName = this._crudOperations.renameSheet(sheetId, newName) @@ -3712,6 +3737,7 @@ export class HyperFormula implements TypedEmitter { * @category Batch */ public batch(batchOperations: () => void): ExportedChange[] { + this.ensureCapability(FeatureId.Batching) this.suspendEvaluation() this._crudOperations.beginUndoRedoBatchMode() try { @@ -3759,6 +3785,7 @@ export class HyperFormula implements TypedEmitter { * @category Batch */ public suspendEvaluation(): void { + this.ensureCapability(FeatureId.Batching) this._evaluationSuspended = true this._emitter.emit(Events.EvaluationSuspended) } @@ -3795,6 +3822,15 @@ export class HyperFormula implements TypedEmitter { * @category Batch */ public resumeEvaluation(): ExportedChange[] { + // Deliberately NOT gated, unlike suspendEvaluation and batch. This is the only exit from + // a suspended engine, and _evaluationSuspended survives rebuildWithConfig: an instance + // suspended while Batching was granted, whose entitlement then loses Batching via + // updateConfig, would be stuck suspended forever - every read throws + // EvaluationSuspendedError and the sole recovery path would throw + // LicenseCapabilityMissingError. Gating the two entry points is what makes the feature + // licensable; gating the release valve only strands the caller, which is the same reason + // teardown (clearClipboard, clearUndoStack, clearRedoStack) is ungated. See the note on + // ensureCapability. this._evaluationSuspended = false const changes = this.recomputeIfDependencyGraphNeedsIt() this._emitter.emit(Events.EvaluationResumed, changes) @@ -3902,6 +3938,7 @@ export class HyperFormula implements TypedEmitter { * @category Named Expressions */ public addNamedExpression(expressionName: string, expression: RawCellContent, scope?: number, options?: NamedExpressionOptions): ExportedChange[] { + this.ensureCapability(FeatureId.NamedExpressions) validateArgToType(expressionName, 'string', 'expressionName') if (scope !== undefined) { validateArgToType(scope, 'number', 'scope') @@ -4124,6 +4161,7 @@ export class HyperFormula implements TypedEmitter { * @category Named Expressions */ public changeNamedExpression(expressionName: string, newExpression: RawCellContent, scope?: number, options?: NamedExpressionOptions): ExportedChange[] { + this.ensureCapability(FeatureId.NamedExpressions) validateArgToType(expressionName, 'string', 'expressionName') if (scope !== undefined) { validateArgToType(scope, 'number', 'scope') @@ -4205,6 +4243,7 @@ export class HyperFormula implements TypedEmitter { * @category Named Expressions */ public removeNamedExpression(expressionName: string, scope?: number): ExportedChange[] { + this.ensureCapability(FeatureId.NamedExpressions) validateArgToType(expressionName, 'string', 'expressionName') if (scope !== undefined) { validateArgToType(scope, 'number', 'scope') @@ -4767,6 +4806,45 @@ export class HyperFormula implements TypedEmitter { } } + /** + * Throws an error if the current license entitlement does not grant the given feature. + * A no-op read (`isLicenseGateActive === false`) whenever this instance's entitlement is + * unrestricted, i.e. for every key this library fully understands today (HF-307 PR 1); the + * check only does work once a real license-key payload adapter (a later HF-307 PR) can + * produce a restricted entitlement. + * + * Where the line is drawn, so a later change does not move it by accident: + * - **Gated:** methods that create value by mutating the sheet, the clipboard, the undo + * history, or the named-expression set. + * - **Not gated:** reads (`getCellValue`, `listNamedExpressions`, + * `getAllNamedExpressionsSerialized`, the `isItPossibleTo*` predicates) and teardown or + * cleanup that only ever removes state (`clearClipboard`, `clearUndoStack`, + * `clearRedoStack`, `destroy`). Gating cleanup would let a restricted entitlement strand + * an integration mid-teardown while giving a licensee nothing, and mirrors gate B, which + * blocks *calling* a function rather than *reading* an already-computed value. + * - **Not gated, for the same reason:** `resumeEvaluation`, the sole exit from a suspended + * engine. Gate the entry points (`suspendEvaluation`, `batch`) and the feature is + * licensable; gate the release valve too and an entitlement change mid-suspension leaves + * the instance permanently unusable. A capability check must never be reachable only on + * the way out of a state it let the caller into. + * + * Note this checks gate B (entitlement) only, never gate A (key validity). That asymmetry + * with the interpreter's gate B - which checks key validity first - is deliberate: it keeps + * today's behaviour for a missing or invalid key, where formulas yield `#LIC!` but the CRUD + * API keeps working. A later PR that resolves an invalid key to a *restricted* entitlement + * rather than an unrestricted one would silently turn that into a breaking API change. + * + * @internal + */ + private ensureCapability(feature: FeatureId): void { + if (!this._config.isLicenseGateActive) { + return + } + if (!allowsFeature(this._config.licenseCapabilities, feature)) { + throw new LicenseCapabilityMissingError(feature) + } + } + /** * Parses a formula string and extracts its AST and dependencies. * diff --git a/src/errors.ts b/src/errors.ts index 66a73a2ad..7f762947c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -4,6 +4,7 @@ */ import {SimpleCellAddress} from './Cell' +import {FeatureId} from './license/LicenseEntitlement' /** * Error thrown when the sheet of a given ID does not exist. @@ -392,3 +393,27 @@ export class AliasAlreadyExisting extends Error { super(`Alias id ${name} in plugin ${pluginName} already defined as a function or alias.`) } } + +/** + * Error thrown when a public API method is called for a {@link FeatureId} that the current + * license entitlement does not grant. Mirrors gate B's `ErrorMessage.LicenseCapability`, but + * this one guards the API surface itself (HF-307 PR 2) rather than a formula evaluation, so it + * is thrown synchronously instead of surfacing as a cell error. + * + * @see [[addNamedExpression]] + * @see [[changeNamedExpression]] + * @see [[removeNamedExpression]] + * @see [[copy]] + * @see [[cut]] + * @see [[paste]] + * @see [[undo]] + * @see [[redo]] + * @see [[batch]] + * @see [[suspendEvaluation]] + * @see [[resumeEvaluation]] + */ +export class LicenseCapabilityMissingError extends Error { + constructor(feature: FeatureId) { + super(`Feature ${feature} is not included in your license.`) + } +}