From 66893971a0cf72cd4d130381e9bad09096c16bd9 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 11 Aug 2026 12:12:03 +0000 Subject: [PATCH 01/14] HF-307 PR 2: public API guards (ensureCapability) Task 2.1: LicenseCapabilityMissingError in src/errors.ts, mirroring the existing ~30 error classes; private ensureCapability(feature) in HyperFormula.ts mirroring ensureEvaluationIsNotSuspended. Task 2.2: ensureCapability wired as the FIRST statement (before argument validation) in the ~20 methods spec'd for PR 2 - NamedExpressions (addNamedExpression, changeNamedExpression, removeNamedExpression), Clipboard (copy, cut, paste), Crud (addRows, removeRows, addColumns, removeColumns, moveCells, moveRows, moveColumns, addSheet, removeSheet, clearSheet, setSheetContent, renameSheet, setCellContents), UndoRedo (undo, redo), Batching (batch, suspendEvaluation, resumeEvaluation). Read-only accessors (listNamedExpressions, getNamedExpression, getAllNamedExpressionsSerialized) are left ungated, resolving the open "getter scope" question from the handoff: gate B's own precedent already draws this line at mutation vs. read (it blocks calling a function, not reading a cell's existing value), so a restricted entitlement can still see named expressions that already exist. Task 2.3: BuildEngineFactory.ensureNamedExpressionsCapability - same allowsFeature(FeatureId.NamedExpressions) check, applied only when the namedExpressions argument to buildFromSheets/buildFromSheet/buildEmpty (the three factories buildFromArray/buildFromSheets/buildEmpty resolve to) is non-empty. Deliberately not applied to rebuildWithConfig, which re-serializes named expressions an already-built instance was already allowed to create, rather than accepting them fresh from a caller. Every ensureCapability call is a single boolean read (config.isLicenseGateActive) on the fast path, matching gate B's hot-path property; this ships without a real license-key payload adapter (PR 3), so every entitlement Config can produce today is unrestricted and the guard is a correct, independently-testable no-op in production. Found while writing tests: PR 1's licence.spec.ts restrictEngine() test helper granted an empty feature set, which now also blocks the setCellContents calls those tests use to set up their formulas, before gate B ever runs. Fixed by having that helper grant Crud by default - those tests are about gate B's function-level check, not this PR's Crud feature gate. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/BuildEngineFactory.ts | 27 +++++++++++++++++++++-- src/HyperFormula.ts | 45 +++++++++++++++++++++++++++++++++++++++ src/errors.ts | 25 ++++++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/BuildEngineFactory.ts b/src/BuildEngineFactory.ts index 62202a78c1..69b74eaea3 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 522d7b1509..e0fd1dc6d4 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() } @@ -1824,6 +1830,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 +1903,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 +1980,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 +2056,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 +2150,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 +2237,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 +2326,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 +2365,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 +2406,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 +2458,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 +2785,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 +2861,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 +2935,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 +3003,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 +3691,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 +3733,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 +3781,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 +3818,7 @@ export class HyperFormula implements TypedEmitter { * @category Batch */ public resumeEvaluation(): ExportedChange[] { + this.ensureCapability(FeatureId.Batching) this._evaluationSuspended = false const changes = this.recomputeIfDependencyGraphNeedsIt() this._emitter.emit(Events.EvaluationResumed, changes) @@ -3902,6 +3926,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 +4149,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 +4231,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 +4794,24 @@ 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. + * + * @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 66a73a2add..7f762947c0 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.`) + } +} From 8a26e2be758c6988bbc1784f68e43a089bc6e933 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 11 Aug 2026 13:05:43 +0000 Subject: [PATCH 02/14] HF-307 PR 2: close the Crud gate on row/column reordering Self-review of the just-opened PR found four public mutating methods that ensureCapability never covered: swapRowIndexes, setRowOrder, swapColumnIndexes and setColumnOrder. They permute sheet structure exactly like moveRows/moveColumns, which were gated - so a restricted entitlement with no Crud grant could still reorder every row and column in a sheet, which defeats the gate for a whole class of structural mutation. Each of the four is gated in its own right rather than relying on the swap* method the set*Order pair delegates to, so the license error still precedes their own argument validation (task 2.2's first-statement rule). Also writes down where the line is drawn, because "we chose not to gate this" was previously indistinguishable from "we forgot this": - gated: mutations that create value (sheet, clipboard, undo history, named expressions) - not gated: reads, and teardown that only removes state (clearClipboard, clearUndoStack, clearRedoStack, destroy) - gating cleanup would strand an integration mid-teardown and give a licensee nothing And records the gate-A asymmetry as an invariant: ensureCapability checks entitlement only, never key validity, which is what preserves today's behaviour where a missing key yields #LIC! in cells but keeps the CRUD API working. A later PR that resolves an invalid key to a restricted rather than unrestricted entitlement would silently turn that into a breaking API change - the note is there so that happens on purpose or not at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/HyperFormula.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index e0fd1dc6d4..5f18b4322c 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -1465,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() @@ -1548,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) @@ -1641,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() @@ -1719,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) @@ -4801,6 +4805,22 @@ export class HyperFormula implements TypedEmitter { * 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. + * + * 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 { From c29a9ba400bc3b893a81e308f74e9fd318488dd3 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 11 Aug 2026 13:14:57 +0000 Subject: [PATCH 03/14] HF-307 PR 2: ungate resumeEvaluation - gating it could brick an engine Cursor Bugbot flagged this on the open PR; verified and fixed. resumeEvaluation is the only exit from a suspended engine, and _evaluationSuspended survives rebuildWithConfig. So an instance suspended while Batching was granted, whose entitlement then loses Batching through updateConfig, was stuck suspended permanently: every read throws EvaluationSuspendedError and the sole recovery path threw LicenseCapabilityMissingError. No public escape. suspendEvaluation and batch stay gated - those are the entry points that make the feature worth licensing, and if you cannot enter batching you can never extract value from it. Gating the release valve only strands the caller, which is the same reasoning that already left teardown (clearClipboard, clearUndoStack, clearRedoStack) ungated. Stated as a rule on ensureCapability so it does not get re-added: a capability check must never be reachable only on the way OUT of a state it let the caller into. Two regression tests cover it (resume works after the grant is revoked; the engine is actually left unsuspended afterwards). Both verified by mutation - re-adding the gate fails them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/HyperFormula.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index 5f18b4322c..3c4f9fbc29 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -3822,7 +3822,15 @@ export class HyperFormula implements TypedEmitter { * @category Batch */ public resumeEvaluation(): ExportedChange[] { - this.ensureCapability(FeatureId.Batching) + // 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) @@ -4814,6 +4822,11 @@ export class HyperFormula implements TypedEmitter { * `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 From 88feb2906985c58ff5ee82e0b5541dbeb3dc1133 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 18 Aug 2026 08:52:39 +0000 Subject: [PATCH 04/14] HF-307 PR2: close a Crud bypass through cut+paste, fix docs and a packaging gap Fixes three confirmed findings from an independent spec-to-ship review of PR2 (5-dimension multi-agent workflow, adversarially verified): 1. HIGH - hard-gating bypass. paste() checked only FeatureId.Clipboard, but CrudOperations.paste() dispatches to moveCells() internally when the clipboard holds a cut - the same cell-relocating mutation the public moveCells() requires Crud for. A Clipboard-only entitlement could reach it via cut()+paste(), with no Crud grant ever checked. Fixed by adding a public CrudOperations.isCutClipboard() wrapper and gating paste() on Crud too when the clipboard holds a cut - still checked before argument validation, matching every other ensureCapability call. Verified by mutation: reverting the fix makes the new regression test fail (paste succeeds and actually moves the cell) with the fix reverted. 2. HIGH - packaging gap. LicenseCapabilityMissingError was never imported/exported from src/index.ts, so it was unreachable from the package's public entrypoint: `import {LicenseCapabilityMissingError} from 'hyperformula'` failed to compile (TS2614), the default-export static form was undefined, and no deep import worked either (package.json's exports map only defines "." and the i18n subpaths). A consumer had no supported way to catch this error by type. Fixed by adding it alongside the other ~30 error classes already exported there. 3. MEDIUM - documentation. Added @throws [[LicenseCapabilityMissingError]] to all 27 gated instance methods and the 3 static build factories (buildFromArray/ buildFromSheets/buildEmpty, which throw it via BuildEngineFactory whenever namedExpressions is non-empty against a restricted entitlement) - matching this file's own established per-method @throws convention, which every other exception type already follows. Also corrected the class-level @see list on LicenseCapabilityMissingError itself: it wrongly named resumeEvaluation (which this PR deliberately does NOT gate, to avoid stranding a suspended engine) and omitted all 17 Crud methods; it now lists every method that can actually throw it and explains the resumeEvaluation exclusion. Also generalizes an earlier, narrower finding (a separate manual review had flagged only setCellContents as untested): mutation-testing all 27 ensureCapability call sites found 17 with zero regression protection of their own - a future accidental removal of any one of them would ship silently, since the suite's "one test per feature group" strategy only pinned the group representative. Added one dedicated throw test per previously-uncovered method (setCellContents, removeRows, addColumns, removeColumns, moveCells, moveRows, moveColumns, addSheet, removeSheet, clearSheet, setSheetContent, renameSheet, cut, paste x3 for the bypass fix itself, redo, changeNamedExpression, removeNamedExpression), plus one test proving LicenseCapabilityMissingError is reachable from the public entrypoint (importing from the package root rather than src/errors directly, which is why the packaging gap went unnoticed by the existing suite). Verified: tsc --noEmit clean; eslint 0 new errors; full private suite green (511/511 suites, 6370/6373 tests, 3 pre-existing skips); the paste/cut fix and the index.ts export both re-verified against a freshly rebuilt commonjs package. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/CrudOperations.ts | 11 +++++++++++ src/HyperFormula.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/errors.ts | 26 +++++++++++++++++++++++++- src/index.ts | 3 +++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/CrudOperations.ts b/src/CrudOperations.ts index bf0238dbc9..28686952ce 100644 --- a/src/CrudOperations.ts +++ b/src/CrudOperations.ts @@ -197,6 +197,17 @@ export class CrudOperations { return this.clipboardOperations.clipboard === undefined } + /** + * Returns whether the clipboard currently holds a cut (as opposed to a copy, or nothing) - + * i.e. whether the next {@link paste} call will move cells rather than only copy their content. + * Exposed so the public API layer can gate a cut-then-paste as the cell-relocating mutation it + * actually is (HF-307 PR 2: {@link paste} must require the Crud feature in this case, not just + * Clipboard, since it performs the same move {@link HyperFormula.moveCells} requires Crud for). + */ + public isCutClipboard(): boolean { + return this.clipboardOperations.isCutClipboard() + } + public clearClipboard(): void { this.clipboardOperations.clear() } diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index 3c4f9fbc29..1f57dfa293 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -256,6 +256,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[SheetSizeLimitExceededError]] when sheet size exceeds the limits * @throws [[InvalidArgumentsError]] when sheet is not an array of arrays * @throws [[FunctionPluginValidationError]] when plugin class definition is not consistent with metadata + * @throws [[LicenseCapabilityMissingError]] if namedExpressions is non-empty and the current license entitlement does not grant the NamedExpressions feature * * @example * ```js @@ -296,6 +297,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[SheetSizeLimitExceededError]] when sheet size exceeds the limits * @throws [[InvalidArgumentsError]] when any sheet is not an array of arrays * @throws [[FunctionPluginValidationError]] when plugin class definition is not consistent with metadata + * @throws [[LicenseCapabilityMissingError]] if namedExpressions is non-empty and the current license entitlement does not grant the NamedExpressions feature * * @example * ```js @@ -338,6 +340,8 @@ export class HyperFormula implements TypedEmitter { * @param {Partial} configInput - engine configuration * @param {SerializedNamedExpression[]} namedExpressions - starting named expressions * + * @throws [[LicenseCapabilityMissingError]] if namedExpressions is non-empty and the current license entitlement does not grant the NamedExpressions feature + * * @example * ```js * const namedExpressions = [ @@ -1222,6 +1226,7 @@ export class HyperFormula implements TypedEmitter { * @fires [[valuesUpdated]] if recalculation was triggered by this change * * @throws [[NoOperationToUndoError]] when there is no operation running that can be undone + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the UndoRedo feature * * @example * ```js @@ -1257,6 +1262,7 @@ export class HyperFormula implements TypedEmitter { * @fires [[valuesUpdated]] if recalculation was triggered by this change * * @throws [[NoOperationToRedoError]] when there is no operation running that can be re-done + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the UndoRedo feature * * @example * ```js @@ -1394,6 +1400,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[InvalidArgumentsError]] when the value is not an array of arrays or a raw cell value * @throws [[SheetSizeLimitExceededError]] when performing this operation would result in sheet size limits exceeding * @throws [[ExpectedValueOfTypeError]] if topLeftCornerAddress argument is of wrong type + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -1433,6 +1440,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when rowMapping does not define correct row permutation for some subset of rows of the given sheet * @throws [[SourceLocationHasArrayError]] when the selected position has array inside + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -1529,6 +1537,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when rowMapping does not define correct row permutation for some subset of rows of the given sheet * @throws [[SourceLocationHasArrayError]] when the selected position has array inside + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -1612,6 +1621,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when columnMapping does not define correct column permutation for some subset of columns of the given sheet * @throws [[SourceLocationHasArrayError]] when the selected position has array inside + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -1704,6 +1714,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when columnMapping does not define correct column permutation for some subset of columns of the given sheet * @throws [[SourceLocationHasArrayError]] when the selected position has array inside + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -1818,6 +1829,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[SheetSizeLimitExceededError]] when performing this operation would result in sheet size limits exceeding + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -1892,6 +1904,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[InvalidArgumentsError]] when the given arguments are invalid * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -1965,6 +1978,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when the given arguments are invalid * @throws [[SheetSizeLimitExceededError]] when performing this operation would result in sheet size limits exceeding + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -2041,6 +2055,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when the given arguments are invalid + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -2131,6 +2146,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[SourceLocationHasArrayError]] when the source location has array inside - array cannot be moved * @throws [[TargetLocationHasArrayError]] when the target location has array inside - cells cannot be replaced by the array * @throws [[SheetsNotEqual]] if range provided has distinct sheet numbers for start and end + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -2225,6 +2241,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[InvalidArgumentsError]] when the given arguments are invalid * @throws [[SourceLocationHasArrayError]] when the source location has array inside - array cannot be moved * @throws [[TargetLocationHasArrayError]] when the target location has array inside - cells cannot be replaced by the array + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -2308,6 +2325,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[InvalidArgumentsError]] when the given arguments are invalid * @throws [[SourceLocationHasArrayError]] when the source location has array inside - array cannot be moved * @throws [[TargetLocationHasArrayError]] when the target location has array inside - cells cannot be replaced by the array + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -2350,6 +2368,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[ExpectedValueOfTypeError]] if source is of wrong type * @throws [[SheetsNotEqual]] if range provided has distinct sheet numbers for start and end + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Clipboard feature * * @example * ```js @@ -2391,6 +2410,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if source is of wrong type * @throws [[SheetsNotEqual]] if range provided has distinct sheet numbers for start and end * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Clipboard feature * * @example * ```js @@ -2440,6 +2460,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[NothingToPasteError]] when clipboard is empty * @throws [[TargetLocationHasArrayError]] when the selected target area has array inside * @throws [[ExpectedValueOfTypeError]] if targetLeftCorner is of wrong type + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Clipboard feature, or the Crud feature when pasting a cut * * @example * ```js @@ -2463,6 +2484,15 @@ export class HyperFormula implements TypedEmitter { */ public paste(targetLeftCorner: SimpleCellAddress): ExportedChange[] { this.ensureCapability(FeatureId.Clipboard) + // Pasting a CUT moves cells across the sheet - the same mutation the public moveCells() + // requires Crud for - so a Clipboard-only entitlement must not reach it this way. Pasting a + // COPY only duplicates values/formulas and stays Clipboard-only, correctly. Checked before + // argument validation, same as every other ensureCapability call (HF-307 spec-to-ship review, + // 18.08: found as a hard-gating bypass - a Clipboard-only entitlement could cut() then + // paste() to relocate cells without Crud ever being granted). + if (this._crudOperations.isCutClipboard()) { + this.ensureCapability(FeatureId.Crud) + } if (!isSimpleCellAddress(targetLeftCorner)) { throw new ExpectedValueOfTypeError('SimpleCellAddress', 'targetLeftCorner') } @@ -2770,6 +2800,7 @@ export class HyperFormula implements TypedEmitter { * * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[SheetNameAlreadyTakenError]] when sheet with a given name already exists + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -2845,6 +2876,7 @@ export class HyperFormula implements TypedEmitter { * * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -2919,6 +2951,7 @@ export class HyperFormula implements TypedEmitter { * * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -2991,6 +3024,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[InvalidArgumentsError]] when values argument is not an array of arrays + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -3680,6 +3714,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NoSheetWithIdError]] when the given sheet ID does not exist * @throws [[SheetNameAlreadyTakenError]] when the provided sheet name already exists + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Crud feature * * @example * ```js @@ -3718,6 +3753,8 @@ export class HyperFormula implements TypedEmitter { * @fires [[evaluationSuspended]] always * @fires [[evaluationResumed]] after the recomputation of necessary values * + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Batching feature + * * @example * ```js * const hfInstance = HyperFormula.buildFromSheets({ @@ -3760,6 +3797,8 @@ export class HyperFormula implements TypedEmitter { * * @fires [[evaluationSuspended]] always * + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Batching feature + * * @example * ```js * const hfInstance = HyperFormula.buildFromSheets({ @@ -3918,6 +3957,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[NamedExpressionNameIsInvalidError]] when the named-expression name is not valid * @throws [[NoRelativeAddressesAllowedError]] when the named-expression formula contains relative references * @throws [[NoSheetWithIdError]] if no sheet with given sheetId exists + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the NamedExpressions feature * * @example * ```js @@ -4144,6 +4184,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[NoSheetWithIdError]] if no sheet with given sheetId exists * @throws [[ArrayFormulasNotSupportedError]] when the named expression formula is an array formula * @throws [[NoRelativeAddressesAllowedError]] when the named expression formula contains relative references + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the NamedExpressions feature * * @example * ```js @@ -4226,6 +4267,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[ExpectedValueOfTypeError]] if any of its basic type argument is of wrong type * @throws [[NamedExpressionDoesNotExistError]] when the given expression does not exist. * @throws [[NoSheetWithIdError]] if no sheet with given sheetId exists + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the NamedExpressions feature * * @example * ```js diff --git a/src/errors.ts b/src/errors.ts index 7f762947c0..7c0778d71d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -400,17 +400,41 @@ export class AliasAlreadyExisting extends Error { * 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. * + * This list names every method that can throw it - `resumeEvaluation` is deliberately NOT among + * them: it is the sole exit from a suspended engine, so gating it could strand an instance + * permanently if the entitlement changes mid-suspension (see the note on `HyperFormula. + * ensureCapability`). + * + * @see [[HyperFormula.buildFromArray]] + * @see [[HyperFormula.buildFromSheets]] + * @see [[HyperFormula.buildEmpty]] * @see [[addNamedExpression]] * @see [[changeNamedExpression]] * @see [[removeNamedExpression]] * @see [[copy]] * @see [[cut]] * @see [[paste]] + * @see [[setCellContents]] + * @see [[addRows]] + * @see [[removeRows]] + * @see [[addColumns]] + * @see [[removeColumns]] + * @see [[moveCells]] + * @see [[moveRows]] + * @see [[moveColumns]] + * @see [[swapRowIndexes]] + * @see [[setRowOrder]] + * @see [[swapColumnIndexes]] + * @see [[setColumnOrder]] + * @see [[addSheet]] + * @see [[removeSheet]] + * @see [[clearSheet]] + * @see [[setSheetContent]] + * @see [[renameSheet]] * @see [[undo]] * @see [[redo]] * @see [[batch]] * @see [[suspendEvaluation]] - * @see [[resumeEvaluation]] */ export class LicenseCapabilityMissingError extends Error { constructor(feature: FeatureId) { diff --git a/src/index.ts b/src/index.ts index 23b060cf49..fa0db7f306 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ import { InvalidArgumentsError, LanguageAlreadyRegisteredError, LanguageNotRegisteredError, + LicenseCapabilityMissingError, MissingTranslationError, NamedExpressionDoesNotExistError, NamedExpressionNameIsAlreadyTakenError, @@ -86,6 +87,7 @@ class HyperFormulaNS extends HyperFormula { public static InvalidArgumentsError = InvalidArgumentsError public static LanguageNotRegisteredError = LanguageNotRegisteredError public static LanguageAlreadyRegisteredError = LanguageAlreadyRegisteredError + public static LicenseCapabilityMissingError = LicenseCapabilityMissingError public static MissingTranslationError = MissingTranslationError public static NamedExpressionDoesNotExistError = NamedExpressionDoesNotExistError public static NamedExpressionNameIsAlreadyTakenError = NamedExpressionNameIsAlreadyTakenError @@ -169,6 +171,7 @@ export { InvalidArgumentsError, LanguageAlreadyRegisteredError, LanguageNotRegisteredError, + LicenseCapabilityMissingError, MissingTranslationError, NamedExpressionDoesNotExistError, NamedExpressionNameIsAlreadyTakenError, From e88b96b80366af07b2ec0c81c5ac7800cf2123a8 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Fri, 18 Sep 2026 10:13:24 +0000 Subject: [PATCH 05/14] HF-307/HF-329/HF-306: license key reader, resolution and capability table Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 12 + docs/guide/license-key.md | 77 +++++- docs/guide/types-of-errors.md | 2 +- src/Config.ts | 20 +- src/HyperFormula.ts | 90 ++++-- src/helpers/licenseKeyValidator.ts | 126 ++++++++- src/interpreter/Interpreter.ts | 5 +- src/license/CapabilityRegistry.ts | 56 +++- src/license/LicenseEntitlement.ts | 38 ++- src/license/capabilities.ts | 372 +++++++++++++++++++++++-- src/license/licenseResolution.ts | 391 +++++++++++++++++++++++++++ src/license/vendor/PROVENANCE.md | 100 +++++++ src/license/vendor/constants.ts | 28 ++ src/license/vendor/detectFormat.ts | 70 +++++ src/license/vendor/extractKeyData.ts | 284 +++++++++++++++++++ src/license/vendor/sha512.ts | 217 +++++++++++++++ src/license/vendor/utils.ts | 218 +++++++++++++++ 17 files changed, 2004 insertions(+), 102 deletions(-) create mode 100644 src/license/licenseResolution.ts create mode 100644 src/license/vendor/PROVENANCE.md create mode 100644 src/license/vendor/constants.ts create mode 100644 src/license/vendor/detectFormat.ts create mode 100644 src/license/vendor/extractKeyData.ts create mode 100644 src/license/vendor/sha512.ts create mode 100644 src/license/vendor/utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aeea8dd511..b795f0c100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ 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 parser for the new proprietary license keys to the entitlement key format (a human-readable text ending with a machine-readable block in square brackets), 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 added support for the packaging group-token vocabulary (`fun:all`, `fun:.`, and per-function `fun:` tokens) alongside the existing package tokens (`functions_1`–`functions_4` and the add-ons). A key worded in either vocabulary grants the same functions. [#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 731c5cd08a..61374128d2 100644 --- a/docs/guide/license-key.md +++ b/docs/guide/license-key.md @@ -40,6 +40,18 @@ const options = { } ``` +### Proprietary license key formats + +Your proprietary license key is in one of two formats, and both work the same way: + +* A classic key: 25 characters in five dash-separated groups, for example + `1a2b3-4c5d6-7e8f9-0a1b2-3c4d5`. +* An entitlement key: a short, human-readable license text that ends with a machine-readable + block in square brackets. Assign the whole text to the `licenseKey` option, or just the + bracketed block — the block is the only part HyperFormula reads, so both work. The text around + the block may be re-wrapped on its way to you (for example, by an email client) without + affecting the key; the block itself has to arrive character for character. + ### Proprietary license key validation ::: tip @@ -47,18 +59,75 @@ HyperFormula doesn't use an internet connection to validate your proprietary lic ::: 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 +version of the software, HyperFormula compares the date in your +proprietary license key against one of two references, depending on +the license you purchased: +* The HyperFormula build date, when the key ends maintenance on a set + date (versions released before that date keep working indefinitely) +* The current date (in UTC), when the key ends usage on a set date 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. 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. + +::: 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 4c524877ee..3a0ab3b45c 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 2eae9aa60b..e17b9fd07a 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, @@ -345,7 +341,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 @@ -369,12 +365,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 1f57dfa293..758bec7315 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,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) { @@ -749,14 +780,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 @@ -4598,6 +4634,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(); @@ -4611,9 +4659,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, ) } @@ -4624,9 +4672,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 @@ -4655,8 +4704,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) } /** @@ -4913,7 +4962,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 72ae003241..74c02ec4d4 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/vendor/constants' import {checkKeySchema, extractTime} from './licenseKeyHelper' /** @@ -27,7 +28,7 @@ type ConsoleMessages = { type MessageDescriptor = { template: LicenseKeyValidityState, - vars: TemplateVars, + expiryDate?: Date, } /** @@ -42,6 +43,106 @@ const consoleMessages: ConsoleMessages = { 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 + */ +export function notifyLicenseKeyState(state: LicenseKeyValidityState, keyValidityDate?: Date): void { + if (_notified || state === LicenseKeyValidityState.VALID) { + return + } + + const vars: TemplateVars = keyValidityDate === undefined ? {} : {keyValidityDate: formatDate(keyValidityDate)} + + 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 +152,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 +162,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 +174,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 +182,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 2c6e39f2a0..39ef01b6b1 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 61462c82e3..8d6c29e306 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 @@ -33,9 +33,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) } @@ -62,12 +59,13 @@ export class CapabilityRegistry { /** * 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. + * consulting the table at all. Tokens are matched case-insensitively (the table is keyed by + * the normalized spelling — see {@link normalizeCapabilityToken}). 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 + * hand in a test or produced by the license-key payload adapter */ public resolve(entitlement: LicenseEntitlement): ResolvedCapabilities { if (entitlement.unrestricted) { @@ -77,10 +75,17 @@ export class CapabilityRegistry { const functions = new Set() const features = new Set() const visited = new Set() + // Walked with a read cursor rather than `queue.shift()`: `shift` is O(n) in most engines, which + // made expansion O(n^2) in the number of tokens a key carries - measured at 1.4 s inside the + // constructor for a key with 100 000 tokens, which the format's missing size limit allows. + // `implies` still appends, so the queue has to stay a growable array. const queue = [...entitlement.capabilities] + let cursor = 0 + + while (cursor < queue.length) { + const token = normalizeCapabilityToken(queue[cursor]) - while (queue.length > 0) { - const token = queue.shift() as string + cursor += 1 if (visited.has(token)) { continue } @@ -124,3 +129,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) +} diff --git a/src/license/LicenseEntitlement.ts b/src/license/LicenseEntitlement.ts index 9472d13500..01202acac8 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 8584336073..7dc33b09c6 100644 --- a/src/license/capabilities.ts +++ b/src/license/capabilities.ts @@ -3,12 +3,85 @@ * 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}. */ +/** + * The always-granted token. Every entitlement built from a license key includes it. + * + * It grants the calculation operators — and nothing else. In particular it grants NO features: + * per the ratified HF-307 decision feature gating is real, and the gated API areas come + * from the `feat:*` tokens below. The always-on functionality the packaging design assigns to + * core (reads, serialization, teardown) is not behind `ensureCapability` at all. + */ export const CORE_TOKEN = 'core' +/** 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, +] + +/** Math engine package — the free tier's function set. */ +export const FUNCTIONS_1_TOKEN = 'functions_1' +/** Calculated fields package. Cumulative: includes {@link FUNCTIONS_1_TOKEN}'s functions. */ +export const FUNCTIONS_2_TOKEN = 'functions_2' +/** Spreadsheet package. Cumulative: includes {@link FUNCTIONS_2_TOKEN}'s functions. */ +export const FUNCTIONS_3_TOKEN = 'functions_3' +/** Excel simulator package — the entire implemented catalog. */ +export const FUNCTIONS_4_TOKEN = 'functions_4' +/** + * The packaging doc's whole-catalog token — the excel-simulator package as that doc's own + * vocabulary spells it. Grants exactly what {@link FUNCTIONS_4_TOKEN} grants. + */ +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, a set of {@link FeatureId} * values, and optionally other tokens it implies. `implies` is expanded recursively by @@ -20,37 +93,282 @@ export interface CapabilityGrant { implies?: string[], } -const coreGrant: CapabilityGrant = { - functions: [], - features: [FeatureId.NamedExpressions, FeatureId.Clipboard, FeatureId.Crud, FeatureId.UndoRedo, FeatureId.Batching], -} +/** + * The calculation operators and their `HF.*` callable forms. Always available in every package — + * the packaging design counts them as engine baseline and advertises them separately from the + * function totals, so they are granted by {@link CORE_TOKEN} rather than by any package. + */ +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 production capability table. + * 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'] + +// An earlier revision granted all five features from CORE_TOKEN, which made feature gating inert +// by construction: no restricted key could ever lose an API area. The ratified rule: +// "Feature gating should work, but the legacy keys should grant all feat:* capabilities" — legacy +// keys already resolve to the unrestricted entitlement, so the carve-out costs nothing, and the +// five features moved onto their own `feat:*` tokens below. + +/** + * 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 operators (granted by + * {@link CORE_TOKEN} instead) and the protected built-ins + * (outside the token system, see {@link PROTECTED_BUILT_INS}). Keeping the doc's own membership + * verbatim is what makes this map the SINGLE SOURCE OF TRUTH both token dialects read from — the + * package slices below are DERIVED from these groups, so moving a function between groups moves + * it in both dialects at once, and `capability-table.spec.ts` pins 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']], +]) + +/** The group tokens each package adds, exactly as the packaging doc's §4 table states them. */ +const MATH_ENGINE_GROUPS = ['fun:math.a', 'fun:stat.a', 'fun:logic.a', 'fun:operator.a', 'fun:info.a', 'fun:lookup.a'] +const CALCULATED_FIELDS_GROUPS = ['fun:time.b', 'fun:text.b', 'fun:logic.b', 'fun:math.b', 'fun:stat.b'] +const SPREADSHEET_GROUPS = [ + 'fun:lookup.c', 'fun:math.c', 'fun:stat.c', 'fun:time.c', 'fun:text.c', 'fun:info.c', 'fun:logic.c', + 'fun:finance.c', 'fun:engineer.c', 'fun:array.c', +] + +/** The members of the given groups, concatenated. The groups are disjoint, so this is a union. */ +function membersOfGroups(groupTokens: string[]): string[] { + return groupTokens.reduce( + (members, groupToken) => members.concat(FUNCTION_GROUPS.get(groupToken) ?? []), + [], + ) +} /** - * 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 a group contributes to a package GRANT: the group verbatim, minus the operators + * (granted by {@link CORE_TOKEN} in every package) and the protected built-ins (outside the token + * system entirely). */ -export function refreshCoreGrant(): void { - coreGrant.functions = FunctionRegistry.getRegisteredFunctionIds() +function gatableMembersOfGroups(groupTokens: string[]): string[] { + return membersOfGroups(groupTokens).filter( + (name) => OPERATOR_FUNCTIONS.indexOf(name) === -1 && PROTECTED_BUILT_INS.indexOf(name) === -1, + ) } + +/** + * Package membership, DERIVED from {@link FUNCTION_GROUPS} as the cumulative group unions the + * packaging doc's §4 table states: Math engine = the `.A` groups, Calculated fields = `.A` + `.B`, + * Spreadsheet = `.A` + `.B` + `.C`. Reproducing that union gives 17 / 64 / 161 cumulative + * functions before the two protected built-ins are removed; `capability-table.spec.ts` pins the + * resulting full memberships by name so a re-derivation is a reviewable diff. + */ +const MATH_ENGINE_FUNCTIONS = gatableMembersOfGroups(MATH_ENGINE_GROUPS) + +/** Added by the calculated-fields package, on top of {@link MATH_ENGINE_FUNCTIONS}. */ +const CALCULATED_FIELDS_FUNCTIONS = gatableMembersOfGroups(CALCULATED_FIELDS_GROUPS) + +/** Added by the spreadsheet package, on top of {@link CALCULATED_FIELDS_FUNCTIONS}. */ +const SPREADSHEET_FUNCTIONS = gatableMembersOfGroups(SPREADSHEET_GROUPS) + +/** + * Added by the excel-simulator package, on top of {@link SPREADSHEET_FUNCTIONS} — the rest of the + * implemented catalog. + * + * The packaging document does not itemize this remainder into groups the way it does for the first three + * packages: the excel-simulator package is stated as `fun:all` — the whole catalog, granted as a + * single token rather than assembled from named groups. This list is that remainder, enumerated + * rather than taken from the function registry at run time, even though "all functions" 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 into a paid package + * and returning `#LIC!` for it on a smaller licence — 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 EXCEL_SIMULATOR_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', +] + +const coreGrant: CapabilityGrant = {functions: [...OPERATOR_FUNCTIONS], features: []} +const functions1Grant: CapabilityGrant = {functions: [...MATH_ENGINE_FUNCTIONS], features: []} +const functions2Grant: CapabilityGrant = { + functions: [...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS], features: [], +} +const functions3Grant: CapabilityGrant = { + functions: [...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS, ...SPREADSHEET_FUNCTIONS], features: [], +} +const functions4Grant: CapabilityGrant = { + functions: [ + ...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS, ...SPREADSHEET_FUNCTIONS, + ...EXCEL_SIMULATOR_FUNCTIONS, + ], + features: [], +} + +/** + * One table entry per group token: the group's gatable members, so a key may assemble a package + * from groups instead of naming a `functions_N` slice. `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: gatableMembersOfGroups([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 operators (harmless: core + * grants them anyway) 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][] = functions4Grant.functions + .concat(OPERATOR_FUNCTIONS, 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 engine understands BOTH token dialects in circulation, resolved from the one group registry + * above so they cannot drift apart: + * + * - the key spec's package slices (`functions_1..4`) plus the two add-on tokens — the vocabulary + * the upstream generator's own schema mints today; + * - the packaging doc's group vocabulary (`fun:all`, `fun:.`, + * `fun:`) — §6 of the 12.08 packaging doc. + * + * Accepting the superset is deliberate and spec-clean: an unrecognized token is defined as "a + * grant this version does not implement" (strict-shape/lenient-vocabulary, T7), so implementing + * more tokens than the generator currently mints breaks nothing — and it makes the engine robust + * to the still-open business decision about which dialect keys will finally be worded in + * (owner's call, 20.08). A key's function set is the UNION of everything recognized. + * + * The grants are stored FULLY EXPANDED rather than chained through `implies`: the packaging + * design states the enforcement layer must not assume a hierarchy between tokens, and that the + * commercial nesting is expressed by a bigger licence simply listing more functions. The + * cumulative spreads above keep the source DRY without putting that hierarchy into the runtime. + * + * 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 appear in + * a package and can never be gated — see {@link EXCEL_SIMULATOR_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, spelled after the draft + * vocabulary in the task. 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} (batching included, per the packaging decision). + * `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. Both tokens stay recognized either way, so an issued key carrying one is + * never reported as unrecognized. + * + * Entry ORDER is load-bearing at one spot: `CapabilityRegistry`'s reverse index maps each + * function id to the FIRST token that lists it, so the package slices stay ahead of the group and + * single-function tokens, keeping `capabilityOf`'s answers what they were before the second + * dialect existed. + */ +export const CAPABILITY_TABLE: ReadonlyMap = new Map([ + [CORE_TOKEN, coreGrant], + [FUNCTIONS_1_TOKEN, functions1Grant], + [FUNCTIONS_2_TOKEN, functions2Grant], + [FUNCTIONS_3_TOKEN, functions3Grant], + [FUNCTIONS_4_TOKEN, functions4Grant], + [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: [...functions4Grant.functions], features: []}], + ...groupEntries, + ...singleFunctionEntries, +]) diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts new file mode 100644 index 0000000000..e67b6d9c16 --- /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, CORE_TOKEN, normalizeCapabilityToken} from './capabilities' +import {LicenseEntitlement, LicenseExpiry, unrestrictedEntitlement} from './LicenseEntitlement' +import {detectLicenseKeyFormat} from './vendor/detectFormat' +import {EntitlementKeyData, EntitlementProductGrant, extractEntitlementKeyData} from './vendor/extractKeyData' +import {parseIsoDate} from './vendor/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] + + // CORE_TOKEN is always granted, but note what it actually grants: the calculation operators - + // NOT a usable set of functions. A key whose only tokens this build does not recognize therefore + // evaluates operators and 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 = [CORE_TOKEN] + + 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) + + if (state === LicenseKeyValidityState.VALID) { + const noticeExpiryDate = expiryWithinNoticeWindow(terms) + + if (noticeExpiryDate !== null) { + notifyLicenseKeyNotice(licenseKey, noticeExpiryDate) + } + } + } + + return { + validityState: state, + entitlement: state === LicenseKeyValidityState.VALID ? entitlementOf(terms) : unrestrictedEntitlement(), + } +} diff --git a/src/license/vendor/PROVENANCE.md b/src/license/vendor/PROVENANCE.md new file mode 100644 index 0000000000..4618ebc314 --- /dev/null +++ b/src/license/vendor/PROVENANCE.md @@ -0,0 +1,100 @@ +# 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) | + +This replaces the earlier port of `src/typed-key/` at `7553d0d1` (2026-08-11). Upstream 4.0.0 +(DEV-2512) **deleted** that directory and replaced the tagged key format with the entitlement key +format; the tagged format never reached customers, so the old reader was removed here rather than +kept alongside. + +## 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` | + +`utils.js` and `sha512.js` are byte-identical between `src/typed-key/` at the old pin and +`src/entitlement-key/` at `4.0.0` (same hashes as the previous revision of this table), so their +ports carried over unchanged apart from this file's path references. + +### 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, `notice` and `grace` are verified as + non-negative integers, and the date field is verified only by matching `String(value)` against + `YYYY-MM-DD` — so a payload whose `usage_until` is a single-element array of the right string + passes, and the declared `string` type is then wider than the value. Faithful to upstream, which + stringifies the same way; noted here because the declaration alone reads stronger than the check. + 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. + +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/vendor/constants.ts b/src/license/vendor/constants.ts new file mode 100644 index 0000000000..dd9f5c4ec8 --- /dev/null +++ b/src/license/vendor/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/vendor/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/vendor/detectFormat.ts b/src/license/vendor/detectFormat.ts new file mode 100644 index 0000000000..41fde4befc --- /dev/null +++ b/src/license/vendor/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/vendor/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/vendor/extractKeyData.ts b/src/license/vendor/extractKeyData.ts new file mode 100644 index 0000000000..2b38da207a --- /dev/null +++ b/src/license/vendor/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/vendor/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/vendor/sha512.ts b/src/license/vendor/sha512.ts new file mode 100644 index 0000000000..24f87b6b0d --- /dev/null +++ b/src/license/vendor/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/vendor/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/vendor/utils.ts b/src/license/vendor/utils.ts new file mode 100644 index 0000000000..6bce9c17d7 --- /dev/null +++ b/src/license/vendor/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/vendor/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) +} From bee030bab9cfdf8baa7d18a334cc0e9e87e7360e Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 22 Sep 2026 11:42:18 +0000 Subject: [PATCH 06/14] refactor(HF-307): let a clipboard grant cover pasting a cut Pasting a cut relocates cells, which is the mutation moveCells() requires Crud for, so the paste path reached a Crud-shaped effect through a clipboard-only entitlement. That route is accepted as a product decision: granting the clipboard grants what the clipboard does. Drops the second capability check and the wrapper added to reach it; the clipboard-state helper it delegated to predates this work and stays. The paired suite now pins the accepted behaviour rather than the refusal, so the gate cannot come back unnoticed. Co-Authored-By: Claude Opus 5 (1M context) --- src/CrudOperations.ts | 10 ---------- src/HyperFormula.ts | 12 +++--------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/CrudOperations.ts b/src/CrudOperations.ts index 28686952ce..6cfa54af34 100644 --- a/src/CrudOperations.ts +++ b/src/CrudOperations.ts @@ -197,16 +197,6 @@ export class CrudOperations { return this.clipboardOperations.clipboard === undefined } - /** - * Returns whether the clipboard currently holds a cut (as opposed to a copy, or nothing) - - * i.e. whether the next {@link paste} call will move cells rather than only copy their content. - * Exposed so the public API layer can gate a cut-then-paste as the cell-relocating mutation it - * actually is (HF-307 PR 2: {@link paste} must require the Crud feature in this case, not just - * Clipboard, since it performs the same move {@link HyperFormula.moveCells} requires Crud for). - */ - public isCutClipboard(): boolean { - return this.clipboardOperations.isCutClipboard() - } public clearClipboard(): void { this.clipboardOperations.clear() diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index 1f57dfa293..179e0d36d7 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -2483,16 +2483,10 @@ export class HyperFormula implements TypedEmitter { * @category Clipboard */ public paste(targetLeftCorner: SimpleCellAddress): ExportedChange[] { + // Clipboard alone is enough, including for pasting a CUT - which relocates cells, the same + // mutation the public moveCells() requires Crud for. Granting the clipboard is taken to grant + // what the clipboard does, so this route is deliberately not gated on Crud as well. this.ensureCapability(FeatureId.Clipboard) - // Pasting a CUT moves cells across the sheet - the same mutation the public moveCells() - // requires Crud for - so a Clipboard-only entitlement must not reach it this way. Pasting a - // COPY only duplicates values/formulas and stays Clipboard-only, correctly. Checked before - // argument validation, same as every other ensureCapability call (HF-307 spec-to-ship review, - // 18.08: found as a hard-gating bypass - a Clipboard-only entitlement could cut() then - // paste() to relocate cells without Crud ever being granted). - if (this._crudOperations.isCutClipboard()) { - this.ensureCapability(FeatureId.Crud) - } if (!isSimpleCellAddress(targetLeftCorner)) { throw new ExpectedValueOfTypeError('SimpleCellAddress', 'targetLeftCorner') } From 48ee653a423fb3ea9e9daef2a5817f3eea3be626 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 22 Sep 2026 11:53:51 +0000 Subject: [PATCH 07/14] refactor(HF-307): rename the vendored parser directory and trim its provenance Renames `src/license/vendor/` to `src/license/handsontable-license-key-parser/` so the directory names the repository it mirrors, and renames the paired test directory to match. Drops two provenance paragraphs that only described the older, superseded port rather than the code beside them. Corrects the divergence list, which is the part a drift review reads. Item 3 described upstream's own date check as if it were this port's, which the code has not matched since a type guard was added: upstream matches the stringified value against the date shape, so a value that merely spells a date once stringified is carried into a restricted entitlement, while here it takes the invalid-key path. That is the only divergence which changes what a key is worth, so it is now listed in its own right, with what the key specification says about the field and the note that upstream has not adopted it. The public guide no longer describes the key formats, and the validation section now points at the terms of the contract instead of enumerating the dates it compares. Co-Authored-By: Claude Opus 5 (1M context) --- docs/guide/license-key.md | 24 ++------------- src/helpers/licenseKeyValidator.ts | 2 +- .../PROVENANCE.md | 29 +++++++++---------- .../constants.ts | 2 +- .../detectFormat.ts | 2 +- .../extractKeyData.ts | 2 +- .../sha512.ts | 2 +- .../utils.ts | 2 +- src/license/licenseResolution.ts | 6 ++-- 9 files changed, 25 insertions(+), 46 deletions(-) rename src/license/{vendor => handsontable-license-key-parser}/PROVENANCE.md (78%) rename src/license/{vendor => handsontable-license-key-parser}/constants.ts (87%) rename src/license/{vendor => handsontable-license-key-parser}/detectFormat.ts (94%) rename src/license/{vendor => handsontable-license-key-parser}/extractKeyData.ts (98%) rename src/license/{vendor => handsontable-license-key-parser}/sha512.ts (98%) rename src/license/{vendor => handsontable-license-key-parser}/utils.ts (98%) diff --git a/docs/guide/license-key.md b/docs/guide/license-key.md index 61374128d2..db8321b3ac 100644 --- a/docs/guide/license-key.md +++ b/docs/guide/license-key.md @@ -40,33 +40,15 @@ const options = { } ``` -### Proprietary license key formats - -Your proprietary license key is in one of two formats, and both work the same way: - -* A classic key: 25 characters in five dash-separated groups, for example - `1a2b3-4c5d6-7e8f9-0a1b2-3c4d5`. -* An entitlement key: a short, human-readable license text that ends with a machine-readable - block in square brackets. Assign the whole text to the `licenseKey` option, or just the - bracketed block — the block is the only part HyperFormula reads, so both work. The text around - the block may be re-wrapped on its way to you (for example, by an email client) without - affecting the key; the block itself has to arrive character for character. - ### Proprietary license key validation ::: tip 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 date in your -proprietary license key against one of two references, depending on -the license you purchased: -* The HyperFormula build date, when the key ends maintenance on a set - date (versions released before that date keep working indefinitely) -* The current date (in UTC), when the key ends usage on a set date - -This process doesn't require any connection to the server. +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 diff --git a/src/helpers/licenseKeyValidator.ts b/src/helpers/licenseKeyValidator.ts index 74c02ec4d4..cfef954f36 100644 --- a/src/helpers/licenseKeyValidator.ts +++ b/src/helpers/licenseKeyValidator.ts @@ -3,7 +3,7 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ -import {ENTITLEMENT_KEY_CHECKSUM_LENGTH} from '../license/vendor/constants' +import {ENTITLEMENT_KEY_CHECKSUM_LENGTH} from '../license/handsontable-license-key-parser/constants' import {checkKeySchema, extractTime} from './licenseKeyHelper' /** diff --git a/src/license/vendor/PROVENANCE.md b/src/license/handsontable-license-key-parser/PROVENANCE.md similarity index 78% rename from src/license/vendor/PROVENANCE.md rename to src/license/handsontable-license-key-parser/PROVENANCE.md index 4618ebc314..52e866ca46 100644 --- a/src/license/vendor/PROVENANCE.md +++ b/src/license/handsontable-license-key-parser/PROVENANCE.md @@ -15,11 +15,6 @@ parser rejects genuine customer keys. | 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) | -This replaces the earlier port of `src/typed-key/` at `7553d0d1` (2026-08-11). Upstream 4.0.0 -(DEV-2512) **deleted** that directory and replaced the tagged key format with the entitlement key -format; the tagged format never reached customers, so the old reader was removed here rather than -kept alongside. - ## Files Hashes are of the **upstream** `.js` sources at the tag above, so drift is detectable without @@ -33,10 +28,6 @@ storing a copy of them here. | `utils.ts` | `utils.js` | `135a8396bb22f424160fc651e899931d4be807df9b94c6dd24bb1cf6526e0541` | | `sha512.ts` | `sha512.js` | `668dd1109160b92965a1f9a9c5fb78dfdc1e5b7e93f635a147ae8a6bb2a5d837` | -`utils.js` and `sha512.js` are byte-identical between `src/typed-key/` at the old pin and -`src/entitlement-key/` at `4.0.0` (same hashes as the previous revision of this table), so their -ports carried over unchanged apart from this file's path references. - ### Checking for drift The check is manual and needs read access to the private repository — HyperFormula's own CI @@ -80,13 +71,19 @@ following, which a drift review should expect to see: 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, `notice` and `grace` are verified as - non-negative integers, and the date field is verified only by matching `String(value)` against - `YYYY-MM-DD` — so a payload whose `usage_until` is a single-element array of the right string - passes, and the declared `string` type is then wider than the value. Faithful to upstream, which - stringifies the same way; noted here because the declaration alone reads stronger than the check. - 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. + `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. diff --git a/src/license/vendor/constants.ts b/src/license/handsontable-license-key-parser/constants.ts similarity index 87% rename from src/license/vendor/constants.ts rename to src/license/handsontable-license-key-parser/constants.ts index dd9f5c4ec8..270be27fe1 100644 --- a/src/license/vendor/constants.ts +++ b/src/license/handsontable-license-key-parser/constants.ts @@ -5,7 +5,7 @@ /** * Vendored from `handsontable/license-key`, `src/entitlement-key/constants.js`. - * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + * See `src/license/handsontable-license-key-parser/PROVENANCE.md` before editing — this file is a port, not original code. */ /** diff --git a/src/license/vendor/detectFormat.ts b/src/license/handsontable-license-key-parser/detectFormat.ts similarity index 94% rename from src/license/vendor/detectFormat.ts rename to src/license/handsontable-license-key-parser/detectFormat.ts index 41fde4befc..183afc66e1 100644 --- a/src/license/vendor/detectFormat.ts +++ b/src/license/handsontable-license-key-parser/detectFormat.ts @@ -5,7 +5,7 @@ /** * Vendored from `handsontable/license-key`, `src/entitlement-key/detect-format.js`. - * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + * See `src/license/handsontable-license-key-parser/PROVENANCE.md` before editing — this file is a port, not original code. */ /** diff --git a/src/license/vendor/extractKeyData.ts b/src/license/handsontable-license-key-parser/extractKeyData.ts similarity index 98% rename from src/license/vendor/extractKeyData.ts rename to src/license/handsontable-license-key-parser/extractKeyData.ts index 2b38da207a..6a71b3f310 100644 --- a/src/license/vendor/extractKeyData.ts +++ b/src/license/handsontable-license-key-parser/extractKeyData.ts @@ -5,7 +5,7 @@ /** * Vendored from `handsontable/license-key`, `src/entitlement-key/extract-key-data.js`. - * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + * 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 diff --git a/src/license/vendor/sha512.ts b/src/license/handsontable-license-key-parser/sha512.ts similarity index 98% rename from src/license/vendor/sha512.ts rename to src/license/handsontable-license-key-parser/sha512.ts index 24f87b6b0d..e65aee16ea 100644 --- a/src/license/vendor/sha512.ts +++ b/src/license/handsontable-license-key-parser/sha512.ts @@ -5,7 +5,7 @@ /** * Vendored from `handsontable/license-key`, `src/entitlement-key/sha512.js`. - * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + * See `src/license/handsontable-license-key-parser/PROVENANCE.md` before editing — this file is a port, not original code. */ /** diff --git a/src/license/vendor/utils.ts b/src/license/handsontable-license-key-parser/utils.ts similarity index 98% rename from src/license/vendor/utils.ts rename to src/license/handsontable-license-key-parser/utils.ts index 6bce9c17d7..1a1a46d99b 100644 --- a/src/license/vendor/utils.ts +++ b/src/license/handsontable-license-key-parser/utils.ts @@ -5,7 +5,7 @@ /** * Vendored from `handsontable/license-key`, `src/entitlement-key/utils.js`. - * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + * 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. diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts index e67b6d9c16..2167310992 100644 --- a/src/license/licenseResolution.ts +++ b/src/license/licenseResolution.ts @@ -11,9 +11,9 @@ import { } from '../helpers/licenseKeyValidator' import {ALL_FEATURE_TOKENS, CAPABILITY_TABLE, CORE_TOKEN, normalizeCapabilityToken} from './capabilities' import {LicenseEntitlement, LicenseExpiry, unrestrictedEntitlement} from './LicenseEntitlement' -import {detectLicenseKeyFormat} from './vendor/detectFormat' -import {EntitlementKeyData, EntitlementProductGrant, extractEntitlementKeyData} from './vendor/extractKeyData' -import {parseIsoDate} from './vendor/utils' +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 From 7d5ac2d722bbf96c62afc8eb50159a5dd02ed0ce Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 22 Sep 2026 12:50:43 +0000 Subject: [PATCH 08/14] docs(HF-307): stop promising a Crud refusal paste() can no longer make The throws tag still said paste() refuses without the Crud feature when the clipboard holds a cut. That check was dropped when the bypass was accepted, so the tag documented an exception the method cannot raise, three lines above a comment explaining why it does not. Co-Authored-By: Claude Opus 5 (1M context) --- src/HyperFormula.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index 179e0d36d7..c66de99d5e 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -2460,7 +2460,7 @@ export class HyperFormula implements TypedEmitter { * @throws [[NothingToPasteError]] when clipboard is empty * @throws [[TargetLocationHasArrayError]] when the selected target area has array inside * @throws [[ExpectedValueOfTypeError]] if targetLeftCorner is of wrong type - * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Clipboard feature, or the Crud feature when pasting a cut + * @throws [[LicenseCapabilityMissingError]] if the current license entitlement does not grant the Clipboard feature * * @example * ```js From 14f1ed515e060de880a988b3e445b337ed7ae59c Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 22 Sep 2026 13:38:46 +0000 Subject: [PATCH 09/14] style(HF-307): close the gap the removed wrapper left Co-Authored-By: Claude Opus 5 (1M context) --- src/CrudOperations.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/CrudOperations.ts b/src/CrudOperations.ts index 6cfa54af34..bf0238dbc9 100644 --- a/src/CrudOperations.ts +++ b/src/CrudOperations.ts @@ -197,7 +197,6 @@ export class CrudOperations { return this.clipboardOperations.clipboard === undefined } - public clearClipboard(): void { this.clipboardOperations.clear() } From dd731a9d1428917b895f0f035755ed5128a43e58 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 22 Sep 2026 14:36:39 +0000 Subject: [PATCH 10/14] docs(HF-307): name the parser change without describing the key format The guide no longer documents what a proprietary key looks like, and the changelog entry carried the same description in the same words. It says what changed and what is unaffected instead. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b795f0c100..3a3c05f9be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### 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 parser for the new proprietary license keys to the entitlement key format (a human-readable text ending with a machine-readable block in square brackets), 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 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 added support for the packaging group-token vocabulary (`fun:all`, `fun:.`, and per-function `fun:` tokens) alongside the existing package tokens (`functions_1`–`functions_4` and the add-ons). A key worded in either vocabulary grants the same functions. [#1730](https://github.com/handsontable/hyperformula/pull/1730) ### Fixed From ce18576889ab122961e9edd3e6bba414355b0fc7 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 22 Sep 2026 14:50:33 +0000 Subject: [PATCH 11/14] fix(HF-307): stop blaming the installed version for a usage-based expiry A key can run out along two axes and only one of them is about the build in use. A maintenance key stops covering releases, so an older version keeps working and installing one is the fix; 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. Both went through one message, because the wording predates the usage axis this work introduced. The axis now reaches the message, and the classic 25-character format keeps the old wording, being release-only. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + src/helpers/licenseKeyValidator.ts | 29 +++++++++++++++++++++++++---- src/license/licenseResolution.ts | 2 +- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3c05f9be..f2cdf763ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### 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 added support for the packaging group-token vocabulary (`fun:all`, `fun:.`, and per-function `fun:` tokens) alongside the existing package tokens (`functions_1`–`functions_4` and the add-ons). A key worded in either vocabulary grants the same functions. [#1730](https://github.com/handsontable/hyperformula/pull/1730) diff --git a/src/helpers/licenseKeyValidator.ts b/src/helpers/licenseKeyValidator.ts index cfef954f36..76f9c0a65a 100644 --- a/src/helpers/licenseKeyValidator.ts +++ b/src/helpers/licenseKeyValidator.ts @@ -18,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, } @@ -36,8 +42,14 @@ 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.', } @@ -84,13 +96,22 @@ export function resetLicenseKeyNotificationForTests(): void { * @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): void { +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)} + const vars: TemplateVars = keyValidityDate === undefined + ? {} + : {keyValidityDate: formatDate(keyValidityDate), axis: expiryAxis} console.warn(consoleMessages[state](vars)) _notified = true diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts index 2167310992..e01b012ac3 100644 --- a/src/license/licenseResolution.ts +++ b/src/license/licenseResolution.ts @@ -373,7 +373,7 @@ export function resolveLicense(licenseKey: string, notifyConsole: boolean = true const {state, expiredOn} = validityOf(terms) if (notifyConsole && !terms.silent) { - notifyLicenseKeyState(state, expiredOn) + notifyLicenseKeyState(state, expiredOn, terms.comparedAgainstReleaseDate ? 'release' : 'usage') if (state === LicenseKeyValidityState.VALID) { const noticeExpiryDate = expiryWithinNoticeWindow(terms) From 8d5c39a177bf8d1cef35e01b8e6156a4a88f354b Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 22 Sep 2026 16:37:50 +0000 Subject: [PATCH 12/14] docs(HF-307): fix a JSDoc line missed in the previous merge commit The licenseGrantsFunction JSDoc still named the removed licenseCapabilities.unrestricted field; the edit was made on disk in the same session as the CapabilityRegistry.ts merge conflict but not staged. Co-Authored-By: Claude Opus 5 (1M context) --- src/HyperFormula.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/HyperFormula.ts b/src/HyperFormula.ts index 08a78aae78..7723687996 100644 --- a/src/HyperFormula.ts +++ b/src/HyperFormula.ts @@ -715,7 +715,8 @@ export class HyperFormula implements TypedEmitter { * * 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. + * 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 From 258786ed7354e51510b440a167d15fb18549b24f Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 23 Sep 2026 14:36:59 +0000 Subject: [PATCH 13/14] refactor(HF-307): read capabilities only, never packages Following the review's answer to which reading applies: the engine knows what each capability token grants and nothing about which tokens make up a package. - The package tokens functions_1..4 are gone, with the three group lists and cumulative grants that defined them. A package reaches the engine only as the list of group tokens a generator writes into the key, so a key's functions are the union of the tokens it names. Measured against the packaging document's own membership lists, every package keeps exactly the functions it had. - The always-granted core token is gone. The operator callable forms such as HF.ADD are now gated like any other function, through fun:operator.a or their own single-function token, as the review asked. The infix operators are not function calls and never reach the entitlement check, so they work under any key. - fun:all now also covers the operator callable forms, since it names the whole catalog. The ordering note on the table is dropped: the registry's reverse index is only ever consulted to ask whether a function is covered at all, so which token covers it first no longer matters. The upstream generator's default schema still words HyperFormula packages as functions_1..4, so a key minted from it grants no function until that schema emits group tokens instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- src/license/capabilities.ts | 212 ++++++++++--------------------- src/license/licenseResolution.ts | 14 +- 3 files changed, 72 insertions(+), 156 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2cdf763ea..5a617907e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - 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 added support for the packaging group-token vocabulary (`fun:all`, `fun:.`, and per-function `fun:` tokens) alongside the existing package tokens (`functions_1`–`functions_4` and the add-ons). A key worded in either vocabulary grants the same functions. [#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 diff --git a/src/license/capabilities.ts b/src/license/capabilities.ts index 9a1da6e26e..da6d7393ad 100644 --- a/src/license/capabilities.ts +++ b/src/license/capabilities.ts @@ -5,15 +5,10 @@ import {FeatureId} from './LicenseEntitlement' -/** - * The always-granted token. Every entitlement built from a license key includes it. - * - * It grants the calculation operators — and nothing else. In particular it grants NO features: - * per the ratified HF-307 decision feature gating is real, and the gated API areas come - * from the `feat:*` tokens below. The always-on functionality the packaging design assigns to - * core (reads, serialization, teardown) is not behind `ensureCapability` at all. - */ -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' @@ -36,18 +31,7 @@ export const ALL_FEATURE_TOKENS = [ NAMED_EXPRESSIONS_FEATURE_TOKEN, BATCHING_FEATURE_TOKEN, ] -/** Math engine package — the free tier's function set. */ -export const FUNCTIONS_1_TOKEN = 'functions_1' -/** Calculated fields package. Cumulative: includes {@link FUNCTIONS_1_TOKEN}'s functions. */ -export const FUNCTIONS_2_TOKEN = 'functions_2' -/** Spreadsheet package. Cumulative: includes {@link FUNCTIONS_2_TOKEN}'s functions. */ -export const FUNCTIONS_3_TOKEN = 'functions_3' -/** Excel simulator package — the entire implemented catalog. */ -export const FUNCTIONS_4_TOKEN = 'functions_4' -/** - * The packaging doc's whole-catalog token — the excel-simulator package as that doc's own - * vocabulary spells it. Grants exactly what {@link FUNCTIONS_4_TOKEN} grants. - */ +/** 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}, @@ -93,9 +77,11 @@ export interface CapabilityGrant { } /** - * The calculation operators and their `HF.*` callable forms. Always available in every package — - * the packaging design counts them as engine baseline and advertises them separately from the - * function totals, so they are granted by {@link CORE_TOKEN} rather than by any package. + * 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', @@ -111,25 +97,16 @@ const OPERATOR_FUNCTIONS = [ */ const PROTECTED_BUILT_INS = ['OFFSET', 'VERSION'] -// An earlier revision granted all five features from CORE_TOKEN, which made feature gating inert -// by construction: no restricted key could ever lose an API area. The ratified rule: -// "Feature gating should work, but the legacy keys should grant all feat:* capabilities" — legacy -// keys already resolve to the unrestricted entitlement, so the carve-out costs nothing, and the -// five features moved onto their own `feat:*` tokens below. - /** * 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. * * 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 operators (granted by - * {@link CORE_TOKEN} instead) and the protected built-ins - * (outside the token system, see {@link PROTECTED_BUILT_INS}). Keeping the doc's own membership - * verbatim is what makes this map the SINGLE SOURCE OF TRUTH both token dialects read from — the - * package slices below are DERIVED from these groups, so moving a function between groups moves - * it in both dialects at once, and `capability-table.spec.ts` pins each group's size against the - * doc's published counts so a re-transcription is a reviewable diff. + * 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. * * The doc freezes group names as API surface: once shipped inside license keys, a rename is a * breaking change. @@ -175,63 +152,35 @@ export const FUNCTION_GROUPS: ReadonlyMap = new Map([ ['fun:array.c', ['ARRAYFORMULA', 'ARRAY_CONSTRAIN']], ]) -/** The group tokens each package adds, exactly as the packaging doc's §4 table states them. */ -const MATH_ENGINE_GROUPS = ['fun:math.a', 'fun:stat.a', 'fun:logic.a', 'fun:operator.a', 'fun:info.a', 'fun:lookup.a'] -const CALCULATED_FIELDS_GROUPS = ['fun:time.b', 'fun:text.b', 'fun:logic.b', 'fun:math.b', 'fun:stat.b'] -const SPREADSHEET_GROUPS = [ - 'fun:lookup.c', 'fun:math.c', 'fun:stat.c', 'fun:time.c', 'fun:text.c', 'fun:info.c', 'fun:logic.c', - 'fun:finance.c', 'fun:engineer.c', 'fun:array.c', -] - -/** The members of the given groups, concatenated. The groups are disjoint, so this is a union. */ -function membersOfGroups(groupTokens: string[]): string[] { - return groupTokens.reduce( - (members, groupToken) => members.concat(FUNCTION_GROUPS.get(groupToken) ?? []), - [], - ) -} - /** - * The members a group contributes to a package GRANT: the group verbatim, minus the operators - * (granted by {@link CORE_TOKEN} in every package) and the protected built-ins (outside the token - * system entirely). + * 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 */ -function gatableMembersOfGroups(groupTokens: string[]): string[] { - return membersOfGroups(groupTokens).filter( - (name) => OPERATOR_FUNCTIONS.indexOf(name) === -1 && PROTECTED_BUILT_INS.indexOf(name) === -1, - ) +function gatableMembersOf(groupToken: string): string[] { + return (FUNCTION_GROUPS.get(groupToken) ?? []).filter((name) => PROTECTED_BUILT_INS.indexOf(name) === -1) } -/** - * Package membership, DERIVED from {@link FUNCTION_GROUPS} as the cumulative group unions the - * packaging doc's §4 table states: Math engine = the `.A` groups, Calculated fields = `.A` + `.B`, - * Spreadsheet = `.A` + `.B` + `.C`. Reproducing that union gives 17 / 64 / 161 cumulative - * functions before the two protected built-ins are removed; `capability-table.spec.ts` pins the - * resulting full memberships by name so a re-derivation is a reviewable diff. - */ -const MATH_ENGINE_FUNCTIONS = gatableMembersOfGroups(MATH_ENGINE_GROUPS) - -/** Added by the calculated-fields package, on top of {@link MATH_ENGINE_FUNCTIONS}. */ -const CALCULATED_FIELDS_FUNCTIONS = gatableMembersOfGroups(CALCULATED_FIELDS_GROUPS) - -/** Added by the spreadsheet package, on top of {@link CALCULATED_FIELDS_FUNCTIONS}. */ -const SPREADSHEET_FUNCTIONS = gatableMembersOfGroups(SPREADSHEET_GROUPS) +/** 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)), + [], +) /** - * Added by the excel-simulator package, on top of {@link SPREADSHEET_FUNCTIONS} — the rest of the - * implemented catalog. + * 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. * - * The packaging document does not itemize this remainder into groups the way it does for the first three - * packages: the excel-simulator package is stated as `fun:all` — the whole catalog, granted as a - * single token rather than assembled from named groups. This list is that remainder, enumerated - * rather than taken from the function registry at run time, even though "all functions" 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 into a paid package - * and returning `#LIC!` for it on a smaller licence — 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. + * 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 EXCEL_SIMULATOR_FUNCTIONS = [ +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', @@ -256,25 +205,12 @@ const EXCEL_SIMULATOR_FUNCTIONS = [ 'WEIBULL.DIST', 'WORKDAY.INTL', 'Z.TEST', ] -const coreGrant: CapabilityGrant = {functions: [...OPERATOR_FUNCTIONS], features: []} -const functions1Grant: CapabilityGrant = {functions: [...MATH_ENGINE_FUNCTIONS], features: []} -const functions2Grant: CapabilityGrant = { - functions: [...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS], features: [], -} -const functions3Grant: CapabilityGrant = { - functions: [...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS, ...SPREADSHEET_FUNCTIONS], features: [], -} -const functions4Grant: CapabilityGrant = { - functions: [ - ...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS, ...SPREADSHEET_FUNCTIONS, - ...EXCEL_SIMULATOR_FUNCTIONS, - ], - features: [], -} +/** 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: the group's gatable members, so a key may assemble a package - * from groups instead of naming a `functions_N` slice. `fun:info.a` and `fun:lookup.a` resolve to + * 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 @@ -282,19 +218,19 @@ const functions4Grant: CapabilityGrant = { */ const groupEntries: [string, CapabilityGrant][] = Array.from(FUNCTION_GROUPS.keys()).map((groupToken) => [ groupToken, - {functions: gatableMembersOfGroups([groupToken]), features: []}, + {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 operators (harmless: core - * grants them anyway) and the protected built-ins (empty grants, as above). Alias names get no + * 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][] = functions4Grant.functions - .concat(OPERATOR_FUNCTIONS, PROTECTED_BUILT_INS) +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: []}, @@ -304,55 +240,35 @@ const singleFunctionEntries: [string, CapabilityGrant][] = functions4Grant.funct * The production capability table, keyed by NORMALIZED token spelling — look up through * {@link normalizeCapabilityToken}, never with a raw key string. * - * The engine understands BOTH token dialects in circulation, resolved from the one group registry - * above so they cannot drift apart: + * The vocabulary, and nothing else: * - * - the key spec's package slices (`functions_1..4`) plus the two add-on tokens — the vocabulary - * the upstream generator's own schema mints today; - * - the packaging doc's group vocabulary (`fun:all`, `fun:.`, - * `fun:`) — §6 of the 12.08 packaging doc. + * - 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. * - * Accepting the superset is deliberate and spec-clean: an unrecognized token is defined as "a - * grant this version does not implement" (strict-shape/lenient-vocabulary, T7), so implementing - * more tokens than the generator currently mints breaks nothing — and it makes the engine robust - * to the still-open business decision about which dialect keys will finally be worded in - * (owner's call, 20.08). A key's function set is the UNION of everything recognized. - * - * Every grant is stored FULLY EXPANDED, and no grant refers to another: the packaging design - * states the enforcement layer must not assume a hierarchy between tokens, and that the - * commercial nesting is expressed by a bigger licence simply listing more functions. The - * cumulative spreads above keep the source DRY without putting that hierarchy into the runtime. + * 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 appear in - * a package and can never be gated — see {@link EXCEL_SIMULATOR_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. + * 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, spelled after the draft - * vocabulary in the task. 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 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} (batching included, per the packaging decision). - * `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. Both tokens stay recognized either way, so an issued key carrying one is - * never reported as unrecognized. - * - * Entry ORDER is load-bearing at one spot: `CapabilityRegistry`'s reverse index maps each - * function id to the FIRST token that lists it, so the package slices stay ahead of the group and - * single-function tokens, keeping `capabilityOf`'s answers what they were before the second - * dialect existed. + * {@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([ - [CORE_TOKEN, coreGrant], - [FUNCTIONS_1_TOKEN, functions1Grant], - [FUNCTIONS_2_TOKEN, functions2Grant], - [FUNCTIONS_3_TOKEN, functions3Grant], - [FUNCTIONS_4_TOKEN, functions4Grant], [CRUD_FEATURE_TOKEN, {functions: [], features: [FeatureId.Crud]}], [UNDO_REDO_FEATURE_TOKEN, {functions: [], features: [FeatureId.UndoRedo]}], [CLIPBOARD_FEATURE_TOKEN, {functions: [], features: [FeatureId.Clipboard]}], @@ -367,7 +283,7 @@ export const CAPABILITY_TABLE: ReadonlyMap = new Map([ features: [FeatureId.Crud, FeatureId.UndoRedo, FeatureId.Clipboard, FeatureId.Batching], }], [IMPORT_EXPORT_ADDON_TOKEN, {functions: [], features: [FeatureId.ImportExport]}], - [FUN_ALL_TOKEN, {functions: [...functions4Grant.functions], features: []}], + [FUN_ALL_TOKEN, {functions: [...ALL_GATABLE_FUNCTIONS], features: []}], ...groupEntries, ...singleFunctionEntries, ]) diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts index e01b012ac3..c8338c17eb 100644 --- a/src/license/licenseResolution.ts +++ b/src/license/licenseResolution.ts @@ -9,7 +9,7 @@ import { notifyLicenseKeyNotice, notifyLicenseKeyState, } from '../helpers/licenseKeyValidator' -import {ALL_FEATURE_TOKENS, CAPABILITY_TABLE, CORE_TOKEN, normalizeCapabilityToken} from './capabilities' +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' @@ -132,12 +132,12 @@ function releaseDateTimestamp(): number | null { function licenseTermsOf(data: EntitlementKeyData): LicenseTerms { const grant: EntitlementProductGrant | undefined = data.products[HYPERFORMULA_PRODUCT_NAME] - // CORE_TOKEN is always granted, but note what it actually grants: the calculation operators - - // NOT a usable set of functions. A key whose only tokens this build does not recognize therefore - // evaluates operators and 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 = [CORE_TOKEN] + // 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 From 15d7231c62a94fe10ac60ca17e92807efe7ce86b Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 24 Sep 2026 05:10:51 +0000 Subject: [PATCH 14/14] docs(HF-307): drop the core token from the coverage invariant's description The invariant no longer has a core token to fall back on, and the spec it cites lives in the paired test repository, which the comment now says. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/license/CapabilityRegistry.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/license/CapabilityRegistry.ts b/src/license/CapabilityRegistry.ts index acf8c6a198..3e95b891c1 100644 --- a/src/license/CapabilityRegistry.ts +++ b/src/license/CapabilityRegistry.ts @@ -111,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. */