Repository navigation
HF-307: license-key entitlement gating — capability model, key reader, API guards - #1728
Conversation
Implements tasks 1.1-1.5 of the HF-307 spec (rev 2.4): the license entitlement model, the capability registry, and gate B in the interpreter's FUNCTION_CALL evaluation. Gate A (existing key-validity check) is untouched. New: - src/license/LicenseEntitlement.ts: FeatureId, LicenseExpiry, LicenseEntitlement, unrestrictedEntitlement() - src/license/capabilities.ts: CapabilityGrant, CAPABILITY_TABLE (placeholder: every built-in under one 'core' token, refreshed from the static function registry on every CapabilityRegistry construction rather than at module load, since src/index.ts registers built-in plugins only after Config/Interpreter have already been evaluated) - src/license/CapabilityRegistry.ts: resolve() (transitive, cycle-safe `implies` expansion), capabilityOf(), allowsFunction(), allowsFeature() Modified: - src/Config.ts: licenseCapabilities/isLicenseGateActive/ capabilityRegistry added to the existing privatePool WeakMap, never exposed through getConfig() - src/interpreter/Interpreter.ts: gate B added after gate A in the FUNCTION_CALL case, with a custom-function exemption predicate (capabilityOf() === undefined) and alias canonicalization so an alias gates identically to its canonical name - src/error-message.ts: ErrorMessage.LicenseCapability Decision deltas applied (Kuba, 2026-08-10): D1 (custom_functions token dropped this release, FeatureId.CustomFunctions kept as reserved vocabulary) and D3 (fail-closed + silent: an entitlement with no recognized token grants only core, without a message, warning, or diagnostics getter - unrestrictedEntitlement() no longer covers that case). D2 and D4 are out of scope for this PR. PR 1 ships without a real license-key payload adapter (PR 3), so Config always resolves an unrestricted entitlement for now - gate B is a correct, independently-testable no-op in production until PR 3 lands. Full PR description, verification notes, and drafted private-repo tests (no credentials available this session for hyperformula-tests): marcin-kb/handoffs/hf-307-pr1-tests/ Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
|
Task linked: HF-307 Implement feature packages and add-ons in HF |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
hyperformula-docs | f9819f7 | Commit Preview URL Branch Preview URL |
Oct 07 2026, 04:19 PM |
Performance comparison of head (f9819f7) vs base (5abbbd9) |
Tobiadefami
left a comment
There was a problem hiding this comment.
Reviewed at 0a966e3 together with the paired tests at 28952a33. The entitlement model, capability resolution, private Config wiring, and interpreter gate look good at this layer. The focused paired suite passes (4 suites / 23 tests), the current engine checks are green, and I found no material issue in this PR.
…ants No grant in the table ever named another token, and the table is documented as holding its entries fully expanded, so the recursive walk resolved nothing that a flat pass over the entitlement's own tokens does not. Removing the field takes the queue, the visited set and the cycle guard with it. Repeating a token still grants what it grants once, which the paired suite now pins directly rather than through implication. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kuba Sękowski's review of #1728 (src/license/CapabilityRegistry.ts:16): a single `unrestricted: boolean` cannot express a key that grants every function but not every feature, or the reverse - both axes could only be granted together or neither. ResolvedCapabilities.functions and .features are now each `ReadonlySet<T> | 'all'`, and allowsFunction/allowsFeature check their own axis independently. `unrestricted` carries a fail-open invariant (resolveLicense: a missing, invalid or expired key resolves to an unrestricted entitlement, by design - gate A already stops evaluation on a bad key, so gate B must not turn a lapsed legacy key into a throwing API). Splitting it opens a matching fail-closed trap: an edit that sets 'all' on one axis and forgets the other silently narrows a working install instead of leaking anything. Both axes are set in the same object literal in CapabilityRegistry.resolve() for exactly that reason, and the paired test commit pins each axis separately so a one-axis regression fails only one test. Does not touch LicenseEntitlement.unrestricted, a distinct field on the pre-resolution entitlement that this change leaves alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
> **Update 2026-09-22 — review round with @sequba.** The sections below are the PR as written > before that round, kept for the reasoning they record. Where a sentence no longer matches the > code it is corrected inline and marked, rather than rewritten away. ### Context HF-307 (feature packages / entitlement gating). This is PR 2 of 4: guards on the public API. Stacked on hf-307-entitlement-gating-pr1 (#1728) - depends on `CapabilityRegistry`/`allowsFeature`/`FeatureId`/`Config.licenseCapabilities` from that PR. Mechanical, public-repo-only, no key-format knowledge needed. New: - `src/errors.ts` - `LicenseCapabilityMissingError`, mirroring the existing ~30 error classes. Now also exported from the public entrypoint (`src/index.ts`) - see finding 6. - `src/HyperFormula.ts` - private `ensureCapability(feature: FeatureId)`, mirroring `ensureEvaluationIsNotSuspended`: a single `isLicenseGateActive` boolean read on the fast path, then `allowsFeature` against the resolved entitlement. - `src/BuildEngineFactory.ts` - private static `ensureNamedExpressionsCapability(config, namedExpressions)`, the build-time counterpart (task 2.3). - ~~`src/CrudOperations.ts` - public `isCutClipboard()`~~ **removed 22.09**: the reviewer accepted the cut-paste bypass, so the second capability check it fed is gone and the wrapper with it. See the correction under finding 6. Modified: `ensureCapability` wired as the **first statement** (before argument validation - a license error should beat a type error) in the ~20 methods from spec §05 task 2.2: - NamedExpressions: `addNamedExpression`, `changeNamedExpression`, `removeNamedExpression` - Clipboard: `copy`, `cut`, `paste` — Clipboard only. (The conditional **Crud** check described under finding 6 was removed 22.09.) - Crud: `addRows`, `removeRows`, `addColumns`, `removeColumns`, `moveCells`, `moveRows`, `moveColumns`, `addSheet`, `removeSheet`, `clearSheet`, `setSheetContent`, `renameSheet`, `setCellContents`, `swapRowIndexes`, `setRowOrder`, `swapColumnIndexes`, `setColumnOrder` - UndoRedo: `undo`, `redo` - Batching: `batch`, `suspendEvaluation` — but **not** `resumeEvaluation`, see finding 4 below `CustomFunctions` is not gated anywhere, per HF-307 decision D1. `ImportExport` has no methods to gate yet (HF-107). **Where the line is drawn** (written into the `ensureCapability` JSDoc so a later change has to move it on purpose): - **Gated** — mutations that create value: the sheet, the clipboard, the undo history, the named-expression set. - **Not gated** — reads (`getCellValue`, `listNamedExpressions`, `getAllNamedExpressionsSerialized`, the `isItPossibleTo*` predicates) and teardown 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. This mirrors gate B, which blocks *calling* a function rather than *reading* an already-computed value. There is a test pinning this, because "we chose not to gate cleanup" is otherwise indistinguishable from "we forgot these" — which is exactly how the reordering gap below survived the first pass, and how the cut/paste gap in finding 6 survived this one. **Open question resolved:** the handoff left "is `getAllNamedExpressions` gated or read-only-exempt?" open. There's no such method today (it's `listNamedExpressions`, `getNamedExpression`, `getAllNamedExpressionsSerialized`) - I left all three ungated. This follows gate B's own precedent: it blocks *calling* a function, not *reading* a cell's already-computed value, so a restricted entitlement can still see named expressions that already exist. Flagging for reviewers in case the intended scope was broader. Task 2.3: `BuildEngineFactory.ensureNamedExpressionsCapability` runs the same `allowsFeature(FeatureId.NamedExpressions)` check when the `namedExpressions` argument to `buildFromSheets`/`buildFromSheet`/`buildEmpty` (what `buildFromArray`/`buildFromSheets`/`buildEmpty` resolve to) is non-empty; an empty list is never checked. Deliberately **not** applied to `rebuildWithConfig` - that path re-serializes named expressions an already-built instance was already allowed to create (e.g. on `updateConfig()`), rather than accepting them fresh from a caller, so a later entitlement change must not retroactively break an existing instance's own state. This ships without a real license-key payload adapter (PR 3), so every entitlement `Config` can produce today is unrestricted - `ensureCapability` and the build-time check are correct, independently-testable no-ops in production until that adapter lands, exactly like gate B in PR 1. ### Self-review findings (after the PR was first opened) Recording these in the open rather than quietly amending, since several are things a reviewer should get to disagree with. Findings 4 and 5 came from Cursor Bugbot's inline comments and were confirmed here before fixing. Finding 6 came from an independent spec-to-ship review run against this PR specifically after it had already been open for review (a 5-dimension multi-agent pass: guard completeness, test-quality-by-mutation, the shipped build artifact, docs/CHANGELOG accuracy, and merge mechanics - each finding adversarially re-verified before being reported). **1. The Crud gate was bypassable — fixed in `8a26e2be7`.** The first pass gated `moveRows`/`moveColumns` but missed four structurally identical methods: `swapRowIndexes`, `setRowOrder`, `swapColumnIndexes`, `setColumnOrder`. A restricted entitlement with no Crud grant could still permute every row and column in a sheet, which defeats the gate for a whole class of structural mutation. All four are now gated *in their own right* rather than relying on the `swap*` method that the `set*Order` pair delegates to — otherwise `set*Order`'s own `mappingFromOrder` validation would run first and a type error would beat the license error, violating task 2.2's first-statement rule. **2. `ensureCapability` checks gate B only, never gate A — deliberate, and now an explicit invariant.** Today a missing or invalid key yields `#LIC!` in cells while the CRUD API keeps working; this PR preserves that exactly. The risk is forward-looking: if PR 3's key adapter resolves an invalid key to a *restricted* entitlement rather than an unrestricted one, every gated method here starts throwing and that becomes a silent breaking API change. This PR is what makes that reachable, so the invariant is documented at the guard itself. **Reviewers: worth confirming this is the intended split before PR 3 lands.** **3. Known evidence gap in the task 2.3 tests.** The `BuildEngineFactory` gate is exercised by calling the private static directly, because the public path (`buildFromArray(sheet, config, namedExpressions)` → throws) *cannot* be reached today — the factories construct their own `Config`, and every `Config` currently resolves unrestricted. So Codecov's 100% patch coverage overstates the evidence for that one check: the integration path is unproven until PR 3 makes a restricted `Config` constructible. Flagging rather than papering over it. **4. Gating `resumeEvaluation` could brick an engine — fixed in `c29a9ba40`.** Raised by Bugbot, confirmed. `resumeEvaluation` is the *only* exit from a suspended engine, and `_evaluationSuspended` survives `rebuildWithConfig`. So: suspend while Batching is granted → `updateConfig` produces an entitlement without Batching → the instance is permanently unusable, because every read throws `EvaluationSuspendedError` and the sole recovery path threw `LicenseCapabilityMissingError`. It is now ungated. `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. Generalised into 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** — the same reasoning that leaves teardown ungated. **5. One of PR 1's tests was vacuous — fixed in the paired tests PR.** Also raised by Bugbot. PR 1's custom-function-exemption test built the sheet with `'=CUSTOMFUNC()'` already in it, so the formula was evaluated during `buildFromArray` while the entitlement was still unrestricted; `restrictEngine` then changed nothing that `getCellValue` would re-read, and the assertion passed against a cached value. I verified this rather than assuming it — deleting the exemption from `Interpreter.ts` outright left the test **still passing**. It now writes the formula via `setCellContents` *after* restricting, matching the shape its sibling tests already had, and the same mutation now correctly fails it. The two new `resumeEvaluation` regression tests were verified the same way (re-adding the gate fails them). **6. `paste` was a hard-gating bypass, plus a packaging gap and a documentation gap — found by an independent spec-to-ship review, all fixed.** - **Bypass (HIGH).** `paste()` checked only `FeatureId.Clipboard`, but `CrudOperations.paste()` dispatches to `moveCells()` internally when the clipboard holds a cut - the exact 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. Reproduced live (the move fully executed, no exception, under a Clipboard-only entitlement) before fixing. Fixed by adding `CrudOperations.isCutClipboard()` and additionally gating `paste()` on `Crud` when the clipboard holds a cut - still checked before argument validation. **Reversed 22.09 at the reviewer's request** ("Drop this check. We accept this bypass"): granting the clipboard is taken to grant what the clipboard does, so this route is deliberately not gated on Crud as well. The check, the wrapper and the stale `@throws` tag are gone; the paired spec now pins the acceptance - a clipboard-only entitlement relocates the cell - so the hole cannot be closed again by accident. - **Packaging gap (HIGH).** `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 (the `exports` map in `package.json` only defines `.` and the i18n subpaths). A consumer had no supported way to catch this error by type - the existing test suite never caught it because it imports the class directly from `src/errors`, bypassing the public entrypoint entirely. Fixed by exporting it alongside the other ~30 error classes already there. - **Documentation gap (MEDIUM).** None of the 27 gated methods carried a `@throws [[LicenseCapabilityMissingError]]` tag, breaking this file's own established per-method `@throws` convention (every *other* exception type already gets one). The class-level `@see` list on `LicenseCapabilityMissingError` itself also wrongly named `resumeEvaluation` (which finding 4 deliberately excludes) and omitted all 17 Crud methods. Fixed: all 27 gated instance methods plus the 3 static build factories now carry the tag, and the `@see` list is corrected and complete. The same review also generalised finding coverage on the test side: mutation-testing every one of the 27 `ensureCapability` call sites found 17 with zero regression protection of their own (the "one test per feature group" strategy only pinned the group representative) - see the paired tests PR for the added coverage. *Process note, since it generalises:* Bugbot's findings 4 and 5 were missed by the automated review tier that scrapes them (a regex looking for `Severity: Medium` against Bugbot's actual `**Medium Severity**`), which reported a clean PASS. Finding 6 was missed by both Bugbot and a prior manual review pass (which had only caught the narrower, single-method version of the test-coverage gap). Worth reading Bugbot's inline comments directly rather than trusting an aggregator, and worth an independent adversarial pass even after a PR looks clean. ### How did you test your changes? - `tsc --noEmit` and `tsc -p tsconfig.test.json`: clean. - `eslint` on all changed files: 0 errors (pre-existing warning categories only, none introduced by this change). - Manual verification against the built engine (ts-node scratch script, not committed): every gated method throws `LicenseCapabilityMissingError` under a simulated restricted entitlement, an unrestricted entitlement (today's default) is unaffected, and `buildFromArray` with named expressions under the default unrestricted entitlement does not throw. Re-verified against a freshly rebuilt `commonjs` package for finding 6 specifically (the cut+paste fix and the `index.ts` export). - Private test suite pushed: [hyperformula-tests#31](handsontable/hyperformula-tests#31) - `unit/license/public-api-guards.spec.ts` (42 tests: one positive + one negative per feature group, a validation-order test, the build-time bypass pair, five covering the reordering gap, one pinning the ungated-cleanup rule, two for the `resumeEvaluation` deadlock, one dedicated throw test per previously-group-only-covered method (15 of them), the cut/paste bypass control+regression pairs, and one proving `LicenseCapabilityMissingError` is reachable from the public entrypoint) plus fixes to PR 1's `unit/licence.spec.ts` (the test-helper interaction described in that PR, and the vacuous exemption test in finding 5). - **Mutation-checked, not just green:** the two `resumeEvaluation` tests, the repaired custom-function test, the cut+paste bypass fix itself, and a sample of the newly-added per-method tests (`removeRows`, `changeNamedExpression`) were each verified by reverting the corresponding source line and confirming the new test fails, then restoring. - Full suite against the paired branch: **511 suites / 6373 tests, 6370 passed, 3 pre-existing skips, 0 failures.** ### Types of changes - [ ] Breaking change - [x] New feature or improvement - [x] Bug fix - [ ] Additional language file, or a change to an existing language file (translations) - [ ] Change to the documentation ### Related issues: 1. HF-307 (internal tracker; no public GitHub issue for this one) ### Checklist: - [x] I have reviewed the guidelines about Contributing to HyperFormula and I confirm that my code follows the code style of this project. - [ ] I have signed the Contributor License Agreement. *(please confirm/attach on your end - I can't verify this from here)* - [ ] My change is compliant with the OpenDocument standard. *(N/A - no worksheet function behaviour changes in this PR)* - [ ] My change is compatible with Microsoft Excel. *(N/A - same reason)* - [ ] My change is compatible with Google Sheets. *(N/A - same reason)* - [ ] I described my changes in the CHANGELOG.md file. *(intentionally not done - internal-only change, no user-visible behaviour yet; a CHANGELOG entry lands with the PR that actually activates the gates for customers)* - [x] My changes require a documentation update. *(done - @throws tags and the @see list on LicenseCapabilityMissingError, see finding 6)* - [ ] My changes require a migration guide. *(no)* <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Wide API surface change: many mutation entry points can throw when restricted entitlements land in PR 3; deliberate exclusions (ungated `resumeEvaluation`, Clipboard-only paste) affect runtime behavior under partial licenses. > > **Overview** > Adds **license entitlement guards (HF-307 PR 2)** on the public HyperFormula API so restricted entitlements throw `LicenseCapabilityMissingError` instead of mutating the engine. > > A private `ensureCapability` runs as the **first statement** on gated instance methods when `isLicenseGateActive` is true: **Crud** (sheet/cell/row/column moves and structural edits), **UndoRedo**, **Clipboard** (`copy`/`cut`/`paste`—paste is Clipboard-only even when a cut relocates cells), **Batching** (`batch`, `suspendEvaluation` only; **`resumeEvaluation` stays ungated** so entitlement changes cannot strand a suspended instance), and **NamedExpressions** (add/change/remove). Reads, `isItPossibleTo*` helpers, and teardown (`clearClipboard`, undo/redo stack clears, `destroy`) stay ungated. > > **Build-time:** `BuildEngineFactory.ensureNamedExpressionsCapability` rejects non-empty initial `namedExpressions` without the feature on `buildFromSheets`/`buildFromSheet`/`buildEmpty`, but not on `rebuildWithConfig`. The new error is exported from `src/index.ts`, with `@throws` docs on affected methods. Until PR 3 supplies restricted entitlements, these checks are no-ops for today’s default configs. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 690ef69. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…n, and capability table (#1730) > **Update 2026-09-22 — review round with @sequba.** The sections below are the PR as written > before that round, kept for the reasoning they record. Where a sentence no longer matches the > code it is corrected inline and marked, rather than rewritten away. ## Update 2026-09-18 — folded #1731, #1736, #1737, #1740, #1741 into this PR So that no PR in this stack shows code a later PR rewrites, this PR now contains, squashed with no loss of content: the entitlement reader (rev 6, was #1740), the notice-window warning (was #1736), the two add-on tokens (was #1737), the function-metadata filtering (was #1731), and both capability-token dialects (was #1741). Those five PRs are closed, folded here. The stack is now `#1728 → #1729 → this PR → #1743`. Two things below are stale as a result and are corrected here rather than edited in place, so the history stays legible: the title above (fixed), and every PR-number reference in the body below still says `#1740`/`#1741`/etc. — read those as "now #1730". The CHANGELOG entries in the diff are already repointed to #1730. Everything below this line is the PR as it was written across 2026-08-11 through 2026-08-26, before the fold — the decisions and verification it documents did not change. --- Third of four. Stacked on PR 2 (#1729), so review the commits on top of it. **Paired tests: handsontable/hyperformula-tests#32 — merge that first.** ## Why this PR exists at all **A genuine typed license key did not work before this.** `checkLicenseKeyValidity` recognizes three fixed strings and the older 25-character format; a typed key matches none of them and falls through to `INVALID`. I verified this by building an engine with a real, unexpired `[SUB]` key before touching anything: ``` validityState = "invalid" C1 = #LIC! "License key is invalid." ``` So a paying customer pasting a valid key got `#LIC!` in every cell plus a console warning. An entitlement adapter alone would not have helped — gate A kills the formulas first. ## What lands here **1. Vendor the typed-key reader** into `src/license/handsontable-license-key-parser/` (~~`src/license/vendor/`~~, **renamed 22.09** at the reviewer's request so the directory names the repository it mirrors) as TypeScript (`allowJs` is off, `strict` is on, so this is a port, not a copy). This follows the key spec's own recommendation of a vendored copy plus a drift check rather than a shared package: a private dependency would break `npm install` for open-source users of this GPL package. `PROVENANCE.md` records the upstream commit and a per-file sha256, so drift is detectable by re-cloning and re-hashing, and lists the deliberate divergences. **2. Resolve typed keys into both gates.** `resolveLicense` reads the key once and answers both gates from that single reading, so they cannot disagree about what the string says. Anything that is not a typed key — `gpl-v3`, an older-format key, an empty string, a typed key with a broken checksum — falls through to `checkLicenseKeyValidity` with its **verdict unchanged**. That function is extracted from rather than rewritten, so both paths share one warning-per-page flag instead of each getting their own. Two additive edits to the file it lives in, both affecting the legacy path too and both deliberate: `formatDate` now reads its date in UTC (it printed the expiry a day early west of UTC — a latent bug on the legacy path as well), and an `@internal` reset for the warn-once flag was added because it made the console path untestable. **3. The real capability table**, plus reading both payload shapes. ## Please confirm two things **The gate-A/gate-B invariant.** Only a VALID typed key resolves to a restricted entitlement; missing, invalid and expired all resolve to `unrestrictedEntitlement()`. Gate A already stops formula evaluation on its own — letting a bad key restrict the entitlement *as well* would make PR 2's `ensureCapability` throw from the CRUD API, turning today's "formulas fail, the API still works" into a silent breaking change for every user whose key lapsed. Mutation-tested. **The capability table is a DRAFT.** Membership is transcribed from the packaging design's own per-function evidence file rather than invented, and the transcript is checked two ways: it reproduces that file's five published counts exactly (370 rows; 17 / 51 / 127 / 355 cumulative plus 15 operators), and all 423 registered function ids resolve into the 370 canonical entries once the 53 declared aliases are canonicalised, with zero uncovered. But the design is still under review — the free tier's exact contents and the placement of several function families are not settled. Landing it now was a deliberate call, not a claim that it is final. ## Judgement calls worth a reviewer's eye - **Both payload shapes are read**, detected per product entry by the presence of `capabilities`: the shipped shape (`tier`/`addons`/`exp`) and the newer specified shape (`capabilities`/`usage_until`/`release_until`/`notice`/`flags`). They disagree about nearly every field, the newer one is still under review, and only the first can be minted today. The newer spec also contradicts itself on whether its dates are `YYYY-MM-DD` strings or numeric timestamps, so both are accepted. - **Feature gating is REAL** (updated 12.08, per Kuba's answers on the task). An earlier revision of this PR granted all five feature areas from the `core` token, which made feature gating inert by construction. The five features now live on their own `feat:*` tokens: a rev-5 key grants exactly the `feat:*` tokens it carries; the shipped shape — whose vocabulary predates feature tokens — is granted all five by the adapter, so a shipped-shape key's API behaviour is unchanged; legacy keys resolve unrestricted (Kuba's carve-out). The exits stay ungated (`resumeEvaluation`, teardown), per the never-gate-the-exit rule. - **An unrecognized token no longer silences the key** (updated 12.08). Silence comes solely from the key's `flags`; the earlier coupling (`silent || unrecognized > 0`) suppressed strictly more than D3 asks for and was confirmed an implementation error by Kuba. - **The two add-on tokens are recognized but empty.** `spreadsheet` has no agreed content — the packaging design names a *package* "Spreadsheet" and the pricing work names a "Spreadsheet Bundle" *add-on*, and whether those are the same set is unsettled; guessing either way silently sells an empty add-on or duplicates a whole tier. Pinned by a test so filling it has to be deliberate. - **`noticeDays` is 0 for the shipped shape**, which has no notice field. Inventing a default in the parser would put a product decision in the wrong place. The newer shape carries `notice` and it is used. - **`PROVENANCE.md` names the upstream private repo and commit.** That is what makes the documented drift check runnable. Ratified by Kuba on the task (D7-A, 12.08): kept as-is. ## Update 13.08 — key spec rev 5 read against the code Reading rev 5 ([doc](https://app.clickup.com/9015210959/docs/8cnjcyf-31675/8cnjcyf-48155), updated 12.08) and running its own example payload through this branch turned up three things, all fixed in the last commit: - **Feature tokens are now OPT-IN.** §2.2 lists HyperFormula's entire token vocabulary as `functions_1..4`, `spreadsheet`, `import_export` — there is no `feat:*` entry at all. So a key that names no feature token cannot be saying "no features"; nothing in circulation can express one. Minting the spec's own §2 payload and running it: `setCellContents`, `addRows`, `copy`, `undo`, `addNamedExpression` and `batch` **all threw**. The same hole hit Handsontable-only keys and keys with `hyperformula: null`, which lost the API that `core` used to give them, silently (gate-A VALID = no console warning). A key that *does* name a `feat:*` token still gets exactly what it names, which is the gating Kuba asked for. - **`no-console-warns` is honoured.** rev 5 spells the flag three ways — §2.3 and the §2 example say `no-console-warns`, §4.3/§5.2 say `silent-console`, earlier revisions said `silent`. Only the last two were recognised, so a doc-conformant SaaS key printed console warnings it had explicitly asked to suppress. - **An unreadable `capabilities` rejects the key.** Present-but-not-an-array fell through to the shipped-shape branch, which was a free pass twice: every feature granted, and the rev-5 dates never read — a subscription expired in 2020 resolved as perpetual. **Still divergent from rev 5, deliberately:** §4.1 says a non-trial key never hard-blocks, even past grace ("Trial: block. Non-trial: error only (18.1)"). This branch keeps EXPIRED → `#LIC!`, per Kuba's D5=A (hard blocking stays this release; the notice/soft-stop/hard-stop model is a follow-up). ## Verification Full unit suite green — **512 suites, 6344 tests** (after the 13.08 update). Each of the three fixes above is mutation-verified: removing the opt-in rule fails 9 tests, removing `no-console-warns` fails 1, removing the `capabilities` guard fails 2, and cutting the console notification fails 1 (it used to fail none). Beyond that, the parts that matter were mutation-tested rather than trusted: a wrong SHA-512 round constant, a one-bit UTF-8 error, a broken invariant and a moved package boundary each fail the tests that are supposed to catch them. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Changes commercial licensing, formula evaluation, and API gating paths; incorrect gate-A/gate-B or capability resolution could block paying customers or expose paid features. > > **Overview** > Adds **proprietary license entitlements**: vendored **entitlement-key** parsing (rev 6) and **`resolveLicense`** wire `Config` to gate A (key validity → `#LIC!` on function calls) and gate B (capability tokens → restricted functions/API). Legacy/`gpl-v3` paths stay on **`checkLicenseKeyValidity`**; only **valid** entitlement keys get a restricted entitlement—missing/invalid/expired keys still resolve **unrestricted** so the CRUD API does not start throwing while formulas fail. > > Replaces the placeholder **`core`** grant with a static **`fun:*` / `feat:*`** capability table (group tokens, per-function tokens, **`spreadsheet`** and **`import_export`** add-ons), case-insensitive token normalization, and shared **`licenseAllowsFunction`** for the interpreter and **`getAvailableFunctions()` / `getFunctionDetails()`** (lists shrink only for valid partial keys, not for bad keys). > > **Console licensing UX**: split expired messaging for usage vs release axes, UTC date formatting, one-time approaching-expiry notice per key, optional silence via config rebuild and key flags; docs/changelog describe feature packages and `#LIC!` semantics. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 15d7231. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> ## ⛔ Update 18.08 — upstream retired this key format the same day, and one claim above is wrong A scoped spec-to-ship review of this PR (5 dimensions, every finding adversarially verified) confirmed 5 findings. Four are test-hardening and are fixed in the paired PR — see [hyperformula-tests#32](handsontable/hyperformula-tests#32 "Update 18.08". The fifth needs a decision, not a patch, so nothing here has been changed for it yet. **The vendored reader cannot read the format upstream now ships.** `handsontable/license-key` commit `01eae6530d4f` (2026-08-18 08:44 UTC, DEV-2512, "Replace typed license keys with the entitlement key format (rev 6)"), released as **4.0.0**, **deletes `src/typed-key/` outright** — the exact five files `PROVENANCE.md` pins and hashes. Its own docs are explicit: "The typed key format was **removed**, not deprecated… this package cannot read a typed key." The replacement differs in three ways this reader gates on: | | this branch | entitlement format (rev 6 / 4.0.0) | |---|---|---| | format marker | requires a leading `[SUB]_`/`[FREE]_`/`[TRIAL]_`/`[PERP]_` tag (`extractKeyData.ts:147`) | **no tag** — the key *ends* with a bracketed block, which is the only marker | | payload version | requires `v: 1` (`extractKeyData.ts:199`) | **no `v` field at all** — payload is exactly `{"products":{…}}` | | checksum domain | SHA-512 over the whole key body, prose included | over the **encoded payload only** — an explicit reversal, so the prose becomes editable | | unknown product | invalidates the key (`extractKeyData.ts:105`) | tolerated — "kept, and ignored" | Measured, not inferred: an authentic 4.0.0-format key built per upstream's own `build-payload.js` gives `extractTypedKeyData(key)` → `null`, `resolveLicense(key).validityState` → `invalid`, and `C1` → `#LIC!` "License key is invalid.". Passing only the `[…]` block behaves the same. The retired tagged format still resolves correctly on the same build — so this is a format gap, not a robustness one. Independently, Budzio published the byte-level spec for that format this morning as a new page under key-spec rev 6 ("Technical implementation", sections `T1`–`T8`, created 09:45 UTC). It matches upstream and confirms all four rows above. Three of its rules this branch **already satisfies**: checksum verified before any decode or parse ("order is normative"), uppercase hex rejected rather than normalised, and `__proto__`-safe result construction. **What is genuinely at stake, and what is not.** The invariant holds throughout: a rev-6 key resolves INVALID and therefore to `unrestrictedEntitlement()`, so the CRUD API keeps working and only formulas fail (verified). And myHOT PR #73 (merged to `master` 12:37 UTC today, pinning `license-key#4.0.0`) states **"the typed keys never reached production"**, with the legacy 25-character keys still generated in parallel and validating exactly as before — that path is untouched here. So the correction owed to this PR's own opening claim: no customer ever held a typed key, so "a paying customer pasting a valid key got `#LIC!`" describes a format that was never issued. The live exposure is narrower but real — per that PR's surfacing table, **trial orders and the HubSpot trial webhook now surface the entitlement key**, while commercial orders and transactional emails still show the legacy key only. **Why this is not fixed in this PR.** Re-porting is the obvious move and upstream made it cheaper than the original port (`sha512.js` and `utils.js` are unchanged; the new `extractEntitlementKeyData` is deliberately schema-free and built to be vendored, so only the extractor, schema and constants need re-porting). But it is not a patch to slip in: adopting the new shape rules verbatim also makes `capabilities` mandatory and **invalidates every payload shape this branch currently reads**, rev 6 is still status "for review", and the work is arguably [HF-329](https://app.clickup.com/t/86cb119tz)'s (unassigned, due 21.08, untouched since 04.08 — it now has concrete content). Raised on the task as **D9**; the merge freeze until the spec owner returns means escalating is the action, not a delay. Also worth a reviewer's eye, since it sharpens the still-unanswered **D8**: rev 6's own examples use `capabilities: ["functions_1", "spreadsheet"]` / `["core"]` — i.e. **exactly the token names this branch implements** — while the packaging design published 12.08 states its vocabulary is `fun:<family>.<A|B|C>` and that "nothing else about packaging exists at the technical layer". The two documents disagree, and this branch conforms to the key spec rather than to the packaging doc. A key minted in the packaging vocabulary resolves VALID with the whole gated API and **zero functions**, silently (gate A says VALID, so nothing prints, and `unrecognizedCapabilities` is exposed nowhere). That outcome is now pinned by a test in the paired PR — pinned, not endorsed, so whichever way D8 lands has to change it. --- ### Changed in the 2026-09-22 review round - The guide no longer describes the key formats, and the validation section points at the terms of the contract instead of enumerating the two date axes. The changelog entry for the parser change was reworded the same way, since it carried the same description. - `PROVENANCE.md`: two paragraphs about the superseded `src/typed-key/` port removed; the divergence list corrected. Its item 3 described *upstream's* date check as if it were this port's, which has not been true since a type guard was added, and that guard — the only divergence that changes which keys are accepted — was missing from the list entirely. - The expired-key console message no longer blames the installed version when the key ran out on the usage axis. That wording predates this axis and was correct for every key `develop` can see; it is not for a subscription key. - The `implies` field and its recursive expansion are gone (see #1728), so no capability token refers to another. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Kuba Sekowski <jakub.sekowski@handsontable.com>
…cate its static form (#1743) > **Update 2026-09-22 — review round with @sequba.** The sections below are the PR as written > before that round, kept for the reasoning they record. Where a sentence no longer matches the > code it is corrected inline and marked, rather than rewritten away. ## Update 2026-09-18 — rebased onto the collapsed engine/tests PRs `hyperformula#1730` and `hyperformula-tests#32` were folded from five PRs each into one, so this PR's base moved to `#1730`. Nothing in this PR's own diff changed; the "9/9" numbering and the `#1741` base reference below are stale — the stack is now `#1728 → #1729 → #1730 → this PR`. --- 9/9 of the HF-307 stack, stacked on #1741. Pairs with `hyperformula-tests#43` — **merge the tests PR first**. Finishes decision **D2** on the one API it missed. ## The problem `getRegisteredFunctionNames()` returned the whole catalogue under a restricted key (measured: 422 names on a calculated-fields key, including functions that evaluate to `#LIC!`). A function picker built on it has exactly the problem #1724 (HF-349) and #1731's metadata filter exist to prevent. Flagged in #1731's "found, not fixed here" section; this closes it. ## What changed **Instance method** — now lists exactly what the instance can evaluate, sharing the one `licenseListsFunction` rule with the interpreter and the metadata API so the three surfaces cannot drift: - reads `getListableFunctionIds()` instead of `getRegisteredFunctionIds()` — the protected built-ins are included uniformly (`OFFSET` was missing before; `getAvailableFunctions` already listed it); - reads `config.translationPackage` — the instance's own snapshot — instead of a fresh global `getLanguage` lookup, which can report a localized name the instance refuses to evaluate, and which throws once the host unregisters that language code; - filters by the license, with the invariant intact: a missing, invalid, or expired key does **not** shorten the list. **Static method — deprecated, not removed.** See below; this is a change from the first version of this PR. ## The static: deprecated rather than removed (changed after review) The first version of this PR deleted `HyperFormula.getRegisteredFunctionNames(code)`, citing HF-349 as precedent. That was wrong, and the review caught it: ``` $ git show 3.4.0:src/HyperFormula.ts | grep -c "public static getRegisteredFunctionNames" # 1 ``` The method **is in the released 3.4.0 tag**, and HF-349's own commit message says its removal was free precisely because *"Both methods are unreleased, which is the only free moment to remove them."* So the precedent does not extend here: deleting it would be a breaking change in a minor release, against the Semantic Versioning this project states it follows, and DEV_DOCS's Definition of Done would require a migration-guide section that `docs/guide/` has no 3.x home for. So it is now `@deprecated` with the wording this repo already uses for that situation (`arraySizeMethod` / `arrayFunction` in 3.1.0: "deprecated and will be removed in one of the next major releases"), plus a `Deprecated` changelog entry. **Overturnable in one comment** if you would rather take the break now — the direction is Kuba's D2 either way; only the timing changed. The deprecation notice is explicit that the two are not interchangeable: the static translates into any registered language without an engine, so migrating means building one (`HyperFormula.buildEmpty({ language: 'plPL' }).getRegisteredFunctionNames()`). ## D2's precondition, discharged Kuba's D2 answer made the removal conditional: *"perhaps we can remove the static methods, I'll check which methods does formula-builder use"*. That check is done — searched the org, and **both consumers call the instance form, not the static one**: | Consumer | Call site | Form | |---|---|---| | `formula-builder` | `packages/core/src/engine/functionCatalog.ts:81` — `this.engine?.getRegisteredFunctionNames?.()`, in try/catch, declared optional in `engine/types.ts:66` | instance | | `aurasheet` | `src/core/FormulaEngine.ts:97` — `this.hf.getRegisteredFunctionNames()` | instance | Both use it to build a **function picker** (`functionCatalog.ts`; `FormulaAutocompletePlugin.ts`) — the surface #1724's rationale was about — so the license filter added here improves both rather than disturbing them. Under `gpl-v3` or any unrestricted key their lists are unchanged. ## Spec-to-ship review (2026-08-20): also fixed here - **The translation-snapshot change was unpinned.** Mutation-verified: reverting it to the global lookup left 309 tests green, even though the commit message and the new JSDoc both name it. Now pinned by a test that unregisters the language after the engine is built — the scenario that distinguishes the two implementations, since `getLanguage` throws for an unregistered code while the snapshot keeps working. - **The licence guide listed only two of the three narrowing methods.** It now names this one too, in both places. - **The new JSDoc over-claimed parity** with `getAvailableFunctions`: for a function whose translation is the empty string this method returns `''` while that one falls back to the canonical id. The claim is now scoped to the ids and the licence rule, with the naming difference stated. - Reverting the static removal also removed the docs-build change it required, so `docs/.vuepress/config.js` is untouched by this PR again (it no longer builds one engine per documentation page). ## Testing New suite pins the alignment in both directions (agrees with `getAvailableFunctions` name for name; narrows on a restricted key; never narrows on a bad key; aliases gate with their canonical; answers from the instance's own snapshot). Full private suite: **517 suites / 6462 passing, 3 pre-existing skips**; `tsc --noEmit` and ESLint clean. ## Note for review `functions-metadata.spec.ts` now skips listed ids with no plugin: `OFFSET` is listed (it is callable) but parse-time resolved, so it legitimately has no registry metadata. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019pxNP45obT2LZfjitaCv9o ## The codecov/project dip, traced `codecov/project` was red at −0.02% while `codecov/patch` reported 100% of the diff hit. Rather than write that off as a threshold artifact, I measured coverage on this commit and on its base and diffed the per-file numbers: | file | base (8/9) | this PR, before the fix | |---|---|---| | `src/HyperFormula.ts` | 674 / 675 | 675 / 676 | | `src/interpreter/FunctionRegistry.ts` | 130 / 130 | **129 / 130** | | total | 12 933 / 13 268 | 12 933 / 13 269 | So the covered count did not move and one previously covered line stopped executing — a real consequence of this change, not a rounding artifact. The line was the **instance** `FunctionRegistry.prototype.getRegisteredFunctionIds()`, whose only caller in the whole repository was the method this PR rewrites. Nothing in `src/`, nothing in the private suite, and nothing outside (the class is not exported from `src/index.ts`) calls it any more. Removed, since this change is what orphaned it. The static `FunctionRegistry.getRegisteredFunctionIds()` is untouched and still used — by the deprecated static method above and by three specs. For the record, the single uncovered line left in `HyperFormula.ts` is pre-existing and not mine: `removeNamedExpression`'s unreachable `return []`, which already carries a `codecov note` comment explaining why it cannot be hit. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Documentation and deprecation only; no runtime behavior changes in this diff. > > **Overview** > **Deprecates** the static `HyperFormula.getRegisteredFunctionNames(code)` API (removal planned in a future major) and documents how it differs from the instance method. > > The static form is documented as reflecting the **global** function registry and any registered language without an engine; callers should migrate via `HyperFormula.buildEmpty({ language: '…' }).getRegisteredFunctionNames()`. The **instance** `getRegisteredFunctionNames()` JSDoc now states it lists everything in the instance registry (registration, not license entitlement) and points license-aware UIs to `getAvailableFunctions()`. > > `CHANGELOG.md` adds a **Deprecated** entry for the static method. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a8ffef3. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --- ### Open in the 2026-09-22 review round - @sequba: "`getRegisteredFunctionNames` should still list ALL functions". Agreed on the principle — this method answers for the REGISTRY, and registration is not entitlement, while `getAvailableFunctions()` and `getFunctionDetails()` answer about availability and stay filtered. That removes most of this PR's substance, leaving the deprecation note and a docs correction. **Awaiting a decision on whether to reduce this PR to that or close it with the deprecation folded into #1730.** - Fixed here meanwhile: `docs/guide/release-notes.md` now carries the same retroactive `### Removed` section for 3.4.0 that `CHANGELOG.md` gained in this PR's history. The two are mirrors and only one had it. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Kuba Sekowski <jakub.sekowski@handsontable.com>
Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
ba3f8a6 to
42a3c7c
Compare
…one place The interpreter now reads the license decisions from its config once, in its constructor, instead of through the config's getters on every function call, and skips the alias and table lookups for a key that grants every function. A config never changes after it is built, and updateConfig builds a new interpreter, so the cached values cannot go stale. test:performance shows no regression against develop. Alias canonicalisation, used by both the interpreter and the function metadata API, moves to FunctionRegistry.getCanonicalFunctionId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… license model - LicenseCapabilityMissingError carries the feature the call needed in its feature property, and FeatureId is exported (by name and as HyperFormula.FeatureId), so callers no longer parse the message. Its description now also covers a key that blocks every gated feature. - Entitlement-key console messages print every time a key is resolved; the per-key de-duplication, and with it the use of the reader's internal sha512 and encoding modules, is gone. The reader's types are imported through its index. - The unlicensed-key messages get their own notifier, and the message helpers take non-null parameters and have JSDoc. - Remove FeatureId.CustomFunctions and LicenseEntitlement.unrecognizedCapabilities, which nothing used, and stop exporting FUNCTION_GROUPS. - TypeDoc no longer documents the vendored reader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ew output - The license key guide says that a missing or invalid key blocks every gated API method with a LicenseCapabilityMissingError, lists the gated methods, and says what keeps working. - VERSION's catalogue description and the changelog say that it returns only the version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Requests time out after 30 s and the job after 10 minutes; a failure is reported as an access, network or configuration problem (a 301 is a moved or renamed repository), never as drift. - Only files git tracks are compared, as git stores them, so untracked files and CRLF checkouts no longer fail the check. - Tags are read 100 per page following the pagination, a leading "v" and prerelease suffixes are understood, and files are fetched at the pinned commit rather than the tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ommit - Read tracked files with `:./path`, so they resolve from the repository root like the listing does, also when the checkout sits inside another repository. - Failures are classified as a moved repository (301), no access (401/403/404), a GitHub API problem (any other status), a network problem (a socket error or timeout) or an unexpected response. - Messages and the 404 hint name the pinned commit the files are read at, not only the tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…license docs - The isItPossibleTo* predicates answer false when the method they ask about would throw a LicenseCapabilityMissingError: under a key that blocks evaluation, or a key without the feature. isFeatureAllowed is the one rule behind them and ensureFeatureAllowed. - Comments and the API reference distinguish a key that blocks evaluation (missing, invalid, an expired classic key, a trial past its grace period), which restricts nothing by grant, from an expired key that keeps evaluating with its own grants. The @throws lines of the gated methods also cover a key that blocks evaluation. - The static getRegisteredFunctionNames no longer claims the instance form answers under the license. - types-of-errors.md and the license key guide describe the same rules. - The changelog gains a Fixed entry for the time-zone bug in classic-key validation that this PR fixes. - Use US spelling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 0d00cec. Configure here.
…stive table Replace the two state lists, with their implicit "blocks" default, by a Record<LicenseState, LifecycleVerdict>. A state added upstream now fails compilation until it is classified. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The JSDoc is published by TypeDoc; describe the expired perpetual key without naming the payload field, as the license guide does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under a missing or invalid key, an expired classic key, or a trial past its grace period, the gated methods throw, a build with named expressions throws, and isItPossibleTo* returns false. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without a key, the gated methods the examples call now throw. Every engine built in docs/guide gets licenseKey: 'gpl-v3', as the docs content guide requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds src/license and the vendored reader to the layout and the core modules, and a step to the new-function checklist: a built-in missing from functionCapabilities.ts is not gated at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without a key, the gated methods the examples call now throw a LicenseCapabilityMissingError. Every engine built in a JSDoc @example block of HyperFormula and of its events, and in the API reference landing page, now gets licenseKey: 'gpl-v3', as the guide examples already do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The license error used to stop them first. moveColumns moved a column to where it already was, getNamedExpressionValue passed a sheet name as the scope, changeNamedExpression changed a global expression that did not exist, and getAllNamedExpressionsSerialized added a name that is not a valid named-expression name. The expected results in their comments now match what the examples return. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e without undo_redo The undo history records operations whatever the key grants, so the predicate answered true for an undo() that then threw. Both now answer false when the license does not allow the UndoRedo feature, like the isItPossibleTo* predicates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The array size predictor sized a call the license stops like any other, so the call filled its whole spill range with #LIC!, or showed #SPILL! instead when a cell in that range was not empty. The rule that decides which calls the license stops now lives in FunctionCallLicenseGate, which both the interpreter and the predictor use; the predictor sizes a stopped call as a single cell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…adds a named expression Moving or pasting a formula across sheets copies a name the source sheet defines locally to the global scope. It also did so for a name defined nowhere: the placeholder kept for an undefined name became an empty global named expression, the formula's #NAME? turned into an empty value, and the workbook serialized a named expression its author never added - which, with the license gate, a key without named_expressions could not build again. Only a name the source sheet defines locally is copied now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #1728 +/- ##
===========================================
- Coverage 97.32% 97.26% -0.07%
===========================================
Files 195 203 +8
Lines 15739 16022 +283
Branches 3461 3555 +94
===========================================
+ Hits 15318 15583 +265
- Misses 413 431 +18
Partials 8 8
🚀 New features to boost your workflow:
|

What this is
HF-307, feature packages and add-ons. A proprietary license key can now grant a subset of the library instead of only answering yes/no. The key carries a flat list of capability tokens, and the engine grants the union of what those tokens name.
This started as a stack of four PRs and is now one. #1729, #1730 and #1743 were merged into this branch; #1731, #1736, #1737, #1740 and #1741 were closed once their content landed here. Paired tests: handsontable/hyperformula-tests#30.
Twenty-four files are new; 22 of those live under
src/license/.Two gates
VERSIONandOFFSETevaluates to#LIC!, as before;LicenseCapabilityMissingErrornaming the key's state (License key is missing. Feature crud is not available.), and so does building an engine with named expressions. Before, such a key stopped only function calls: operator formulas, plain data and the whole API kept working. Code that builds an engine without a valid key now gets this error from its first gated call. The changelog lists it under Changed.functions,features), each either a set or'all'.One invariant binds them: only a key that lets the build evaluate restricts anything through gate B: a valid key, or an expired subscription or perpetual key, which keeps its own grants. A missing, invalid or blocking expired key resolves to an entitlement that restricts nothing, and gate A alone stops it. Its errors therefore always name the key's state (
License key is invalid.), never a missing grant (… is not included in your license), andgetAvailableFunctions()still describes the whole catalog under it.What a restricted key does
#LIC!rather than disappearing, in its own cell: an array function the license stops reserves no spill range, so it never shows#SPILL!instead.LicenseCapabilityMissingError. Itsfeatureproperty names the area the call needed, as a value ofFeatureId, which the package now exports.isItPossibleTo*predicates (isItPossibleToAddRows()and the rest) answerfalsefor a call that would throw it, and so doisThereSomethingToUndo()andisThereSomethingToRedo()withoutundo_redo.getAvailableFunctions()andgetFunctionDetails()describe only what the instance can actually evaluate.getRegisteredFunctionNames()still lists every registered function.gpl-v3,internal-use-in-handsontableandhftrial-0168e-1f2b7-47158-70b05-0842fare unaffected: they resolve to an unrestricted entitlement and never consult the capability table. An invalid or expired classic key gets gate A's behavior above.Capability tokens
Function grants use the packaging vocabulary:
fun:all,fun:<family>.<A|B|C>and per-functionfun:<NAME>. The callable operator forms (HF.ADDand friends) sit underfun:operator.A; infix operators work under any key. Token names are matched case-insensitively.Gated API areas use
feat:*tokens, one area each. A key is granted exactly the areas it names, andfeat:allis how it names all of them; a key naming none is granted none. The key generator (license-key5.1.1) cannot writefun:*orfeat:*tokens yet, so a key it issues today grants no gated function and no gated API area.An earlier revision read the absence of a
feat:*token as "this key does not talk about features" and granted all five areas; that rule was removed deliberately.The engine knows no packages and no add-ons. A key may carry a word like
spreadsheetorfunctions_1; it is read as any other word this version does not know: it grants nothing, nothing reports it, and it never subtracts anything.The key reader
src/license/handsontable-license-key-parser/is a copy of the reader upstream publishes for products (license-key'svendor/entitlement-key-reader/, tag 5.1.1). Twelve of its thirteen files are byte-identical; the thirteenth differs by one cast, explained below. The engine calls itsreadEntitlementLicense, as upstream's README prescribes: the reader verifies the key, picks thehyperformulaentry, places it in its lifecycle window and reads its flags.licenseResolution.tskeeps what the guide leaves to the product: the meaning of the capability tokens, the console messages, and which lifecycle states block evaluation. A non-trial key past its grace period, or past the build its maintenance covers, reports EXPIRED and prints a console error but keeps evaluating with its own grants. A trial past its grace still gives#LIC!. Classic 25-character keys are read as before, with one fix: the build release date they are compared with is parsed in UTC, and the expiry date in their console message is printed in UTC. Previously, east of UTC, a classic key that expired the day before the build was released was still accepted, and west of UTC, the console printed an expiry date one day too early. The changelog has a Fixed entry for it.Since 5.x, keys are in format version 2: the payload carries a digest of the prose, so a key whose prose was edited or removed (the bare
[...]block), or that has text after its block, is invalid (M7). Whitespace and line breaks saved as text (\n) are ignored anywhere in the key, inside the block too. Following that, the license key guide tells users to pass the whole key as issued. The spec's state table has the matching rows (S31 changed, S39 added).Console messages for entitlement keys are the specification's text, per lifecycle state, the same table Handsontable prints (
handsontable/src/helpers/mixed.ts): the date exactly as the key carries it, a warning while the key works and an error once it has run out (the grace period included). They print every time a key is resolved: unlike classic keys, which keep their once-per-page flag, entitlement keys keep no record of what they already printed, so one key can print the same message more than once on a page. The cast inextractKeyData.tsstays.Following the reader changed three things:
no-console-warnssilences the console.silent-consoleandsilentare unknown flags; the generator emits neither.upstream.jsonpins the tag.npm run check:license-key-parser-driftcompares every file git tracks in the directory against the pinned commit and runs in CI (vendored-parser.yml; currently red: theLICENSE_KEY_REPO_TOKENsecret exists but cannot read the upstream repository). One declared divergence, a single cast for TypeScript 4.0, is recorded there with its expiry.Also in this PR
#NAME?turned into an empty value, and the workbook serialized a named expression nobody added, which a key withoutnamed_expressionsthen could not build again. The bug predates this PR; the license gate turned it into a hard failure. Only a name the source sheet defines locally is copied to the global scope now (Operations.updateNamedExpressionsForTargetAddress).FunctionCallLicenseGatedecides which calls the license stops and with which#LIC!error. The interpreter and the array size predictor both use it, which is what keeps a stopped array function to one cell.@exampleblock inHyperFormulaand its events, and the API reference landing page, passeslicenseKey: 'gpl-v3'. Four examples that threw for their own reasons (moveColumns,getNamedExpressionValue,changeNamedExpression,getAllNamedExpressionsSerialized) are fixed.VERSION()VERSION()returns only the version (HyperFormula v3.4.0), with no license status, under every key. The function's catalog description and the changelog say so.Docs
docs/guideexample passeslicenseKey: 'gpl-v3': without a key, the gated methods those examples call now throw.DEV_DOCS.mddescribessrc/license/and the vendored reader, and the new-function checklist has a step forfunctionCapabilities.ts: a built-in missing from it is not gated at all.Not built here, on purpose
Review
Reviewed on 2026-09-22, 2026-09-23, 2026-09-24, 2026-09-28 and 2026-09-30, with the 2026-09-29 rulings in the thread. The
feat:import_exporttoken is gone. Merge order: this PR before hyperformula-tests#30.Open:
fun:*orfeat:*tokens yet, so no key issued today grants a gated function or API area.#LIC!under a blocking key, andgetAvailableFunctions()lists the whole catalog under a blocking key.🤖 Generated with Claude Code
Note
High Risk
Changes core licensing behavior for formula evaluation and most mutating public API paths; incorrect gating or key parsing could block paying customers or expose paid features.
Overview
Adds feature-package licensing: proprietary keys can grant subsets of functions (
fun:*tokens) and API areas (feat:*tokens). Restricted functions evaluate to#LIC!; gated API methods throwLicenseCapabilityMissingError, with matchingisItPossibleTo*/ undo-redo predicates returningfalse.getAvailableFunctions()/getFunctionDetails()now list only what the instance’s key allows.Gate A still blocks evaluation for missing/invalid keys and blocking expirations (classic or trial past grace): almost all functions become
#LIC!(exceptVERSION/OFFSET), and gated API—including building with named expressions—throws instead of silently allowing edits.VERSION()no longer embeds license status.Integrates a vendored
handsontable/license-keyentitlement reader (pinned tag, drift CI, one TS 4.0 cast shim),licenseResolution, and capability tables infunctionCapabilities.ts/featureCapabilities.ts. Classic 25-char keys get a UTC fix for release vs expiry comparison and console dates.Also fixes move/paste of formulas referencing undefined names on another sheet (no spurious global named expression), updates docs/examples with
licenseKey: 'gpl-v3', and excludes the vendored tree from ESLint/Codecov.Reviewed by Cursor Bugbot for commit f9819f7. Bugbot is set up for automated code reviews on this repo. Configure here.