Repository navigation
HF-307: warn once when a typed key enters its notice window (5/8) - #1736
marcin-kordas-hoc wants to merge 4 commits into
Conversation
|
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 | db92bcd | Commit Preview URL Branch Preview URL |
Aug 26 2026, 03:39 AM |
c1cce95 to
cbe23f0
Compare
Performance comparison of head (db92bcd) vs base (cd79b06) |
5d67b5d to
46ac32d
Compare
cbe23f0 to
7b5398d
Compare
|
Paired tests PR: handsontable/hyperformula-tests#37 — merge it BEFORE this one (fetch-tests pairs by branch name). |
Tobiadefami
left a comment
There was a problem hiding this comment.
Reviewed at aa835bf together with the paired tests at c832fff7. I checked the notice-window boundaries, usage/release axes, silence and warn-once behavior, config rebuild path, current automated findings, and later stacked branches. The 12 focused license suites pass (215 tests), and the current engine checks are green. I found one material boundary issue: the notice period opens one day late, as noted inline.
The key's `notice` field was parsed into LicenseExpiry and read by nothing. Now a VALID typed key whose usage_until lies within `notice` days of the current UTC instant prints a single console warning naming the expiry date (UTC marker included). The warn-once identity is the key string, not the process — two engines built with two different keys each get their own warning. release_until-axis keys never warn (rev 5: notice/grace have no effect there), the key's silent flags suppress it, and blocking at/after expiry is byte-identical to before (Kuba's D5-A: hard stop stays in 3.5.0, the full rev 5 §4.1 window model is a follow-up). Trials made this concrete: a trial is just a key with grace=0 and notice>0 whose warnings must surface in the console (packages meeting 12.08). Implemented by a prep-ship lane (task HF-307-notice-window); verified here: license suite 165/165 under Jest, tsc --noEmit clean, eslint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdHPZAjciZFWqGa19Yf7it
Four findings from the spec-to-ship re-review (each cross-confirmed by at least two independent review angles): 1. Message wording: the notice now uses rev 5 section 3.2's own subscription clause - "is valid until <last covered day> (UTC)" - instead of "will expire on". The pre-existing expired message names the first day NOT covered (+1 day, frozen convention), so "expires on Aug 25" followed by "expired on Aug 26" printed two different days for one boundary. 2. The notice read is gated on the key SHAPE (rev 5), not on the field's presence: on the shipped shape the terms come off the LICENSED product's entry - for a dual-product key, Handsontable's - so a stray `notice` field there must not switch HyperFormula's console output on. The expiryWithinNoticeWindow doc also no longer claims kind='usage' implies the date came from usage_until (the envelope-exp fallback is real and documented as accepted standalone; the entitlement re-port removes it). 3. rebuildWithConfig's transient serialization-only Config no longer prints license messages: replacing keyA with keyB used to print keyA's notice in the very call that discards keyA. Config gains an internal-defaulted notifyLicenseMessages parameter, same pattern as showDeprecatedWarns. 4. The warn-once identity is now trim + the trailing 128 chars (the key's own checksum): extractTypedKeyData trims, so 'KEY' and 'KEY\n' are one license and must be one identity; truncation bounds a long-lived process's memory to 128 chars per distinct warned key. CHANGELOG entry gains its PR link. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdHPZAjciZFWqGa19Yf7it
… not before its end The window start was derived from `usageAxisDeadline` (`usage_until + 1 day`), which makes the window a day shorter than the specification's and opens it a day late. The date-semantics fixtures are explicit: for `usage_until` 2027-08-12 with `notice` 60 the warning must appear from 2027-06-13T00:00:00Z, and 2027-08-12 minus 60 days is exactly that day. Section 4.1 counts the window "before `usage_until`", not before the boundary that ends it. Measured before the change, clean processes with a fixed clock: silent at 2027-06-13T00:00:00Z and still silent through 2027-06-13T23:59:59Z, first warning at 2027-06-14T00:00:00Z. After it, the warning appears exactly at 2027-06-13T00:00:00Z and the instant before it stays silent. The same off-by-one cost a trial its first warning day: with `notice` equal to the whole term (45/45), the window is meant to open on the day the key is issued, and it opened the day after. Measured on a 2026-09-26 trial: silent on 2026-08-11, warning from 2026-08-12. The end of the window is unchanged, so a key already past `usage_until` still gets no notice. Reported by Tobiadefami on this PR, including the trial case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same cleanup as on the branches below: a colleague's first name attached to a business decision, removed from a comment this branch introduces. The substance of the comment is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aa835bf to
db92bcd
Compare
e9863f2 to
cd79b06
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## hf-307-entitlement-gating-pr4 #1736 +/- ##
==============================================================
Coverage 97.38% 97.38%
==============================================================
Files 204 204
Lines 16214 16236 +22
Branches 3487 3493 +6
==============================================================
+ Hits 15790 15812 +22
Misses 424 424
🚀 New features to boost your workflow:
|
|
Folded into #1730 so that no PR in the stack shows code a later PR rewrites; the content is unchanged, just consolidated into one PR. |
…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>
…, API guards (#1728) ### 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/`. > The earlier revisions of this description, written while the stack still existed, are in this description's edit history. ### Two gates - **Gate A** — is the key valid? Classic keys are validated and messaged as before, except that the build release date and the expiry date in the console message are now read in UTC (see below); entitlement keys are validated too, with their own console messages. A key that is missing, invalid, or expired in a way that blocks evaluation (a classic key, or a trial past its grace period) blocks the library: - every function except `VERSION` and `OFFSET` evaluates to `#LIC!`, as before; - new: every gated public-API method throws `LicenseCapabilityMissingError` naming 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. - **Gate B** — what does the key grant? New. Functions and public-API areas, resolved on two axes (`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`), and `getAvailableFunctions()` still describes the whole catalog under it. ### What a restricted key does - A function outside the grant evaluates to `#LIC!` rather than disappearing, in its own cell: an array function the license stops reserves no spill range, so it never shows `#SPILL!` instead. - A gated public-API method throws `LicenseCapabilityMissingError`. Its `feature` property names the area the call needed, as a value of `FeatureId`, which the package now exports. - The matching `isItPossibleTo*` predicates (`isItPossibleToAddRows()` and the rest) answer `false` for a call that would throw it, and so do `isThereSomethingToUndo()` and `isThereSomethingToRedo()` without `undo_redo`. - `getAvailableFunctions()` and `getFunctionDetails()` describe only what the instance can actually evaluate. `getRegisteredFunctionNames()` still lists every registered function. - Valid classic 25-character keys, `gpl-v3`, `internal-use-in-handsontable` and `hftrial-0168e-1f2b7-47158-70b05-0842f` are 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-function `fun:<NAME>`. The callable operator forms (`HF.ADD` and friends) sit under `fun: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**, and `feat:all` is how it names all of them; a key naming none is granted none. The key generator (`license-key` 5.1.1) cannot write `fun:*` or `feat:*` 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 `spreadsheet` or `functions_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`'s `vendor/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 its `readEntitlementLicense`, as upstream's README prescribes: the reader verifies the key, picks the `hyperformula` entry, places it in its lifecycle window and reads its flags. `licenseResolution.ts` keeps 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 in `extractKeyData.ts` stays. Following the reader changed three things: - A key that grants other products but not HyperFormula is INVALID and restricts nothing. An earlier revision had it VALID with nothing granted. - A malformed date in another product's entry invalidates the whole key again, as upstream does. - Only `no-console-warns` silences the console. `silent-console` and `silent` are unknown flags; the generator emits neither. `upstream.json` pins the tag. `npm run check:license-key-parser-drift` compares every file git tracks in the directory against the pinned commit and runs in CI (`vendored-parser.yml`; currently red: the `LICENSE_KEY_REPO_TOKEN` secret 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 - **Moving or pasting a formula with an undefined name to another sheet** no longer adds that name as an empty global named expression. Before, the formula's `#NAME?` turned into an empty value, and the workbook serialized a named expression nobody added, which a key without `named_expressions` then 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`). - **One rule for stopped function calls.** `FunctionCallLicenseGate` decides 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. - **API reference examples.** Every `@example` block in `HyperFormula` and its events, and the API reference landing page, passes `licenseKey: '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 - Every engine built in a `docs/guide` example passes `licenseKey: 'gpl-v3'`: without a key, the gated methods those examples call now throw. - `DEV_DOCS.md` describes `src/license/` and the vendored reader, and the new-function checklist has a step for `functionCapabilities.ts`: a built-in missing from it is not gated at all. - The changelog has a Changed entry for what a blocking key does to the gated API. It is not marked as breaking, for the reason under "Two gates". ### Not built here, on purpose - The shared conformance fixtures both products would run in CI. Needs the other side to exist. ### 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_export` token is gone. Merge order: this PR before hyperformula-tests#30. Open: - The drift-check workflow is red: its secret cannot read the upstream repository. - The key generator cannot write `fun:*` or `feat:*` tokens yet, so no key issued today grants a gated function or API area. - Spec rows S32–S35 and S38 have no tests yet. - Two places where the engine and the spec disagree: a custom function evaluates to `#LIC!` under a blocking key, and `getAvailableFunctions()` lists the whole catalog under a blocking key. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!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 throw **`LicenseCapabilityMissingError`**, with matching **`isItPossibleTo*`** / undo-redo predicates returning **`false`**. **`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!`** (except **`VERSION`** / **`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-key` entitlement reader (pinned tag, drift CI, one TS 4.0 cast shim), **`licenseResolution`**, and capability tables in **`functionCapabilities.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. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f9819f7. 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> Co-authored-by: Kuba Sekowski <jakub.sekowski@handsontable.com> Co-authored-by: Kuba Sekowski <kuba.sekowski.dev@gmail.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
Consumes the license key's
noticefield: a VALID typed key whoseusage_untillies withinnoticedays of the current UTC instant prints a single console warning naming the expiry date (with a(UTC)marker). Stacks on #1731; rebased onto its current head on 19.08 (the base moved during PR3/PR4's review passes, which had left this PR conflicting).Why now
Per Kuba's D5-A (ClickUp, 12.08): hard blocking at/after expiry stays in 3.5.0 and the full rev 5 §4.1 window model is a follow-up — but trials made the notice warning concrete for this release: a trial is technically just a key with
grace=0andnotice>0whose warnings must surface in the console (packages meeting 12.08), and the trial mechanism lands in August.What changed
licenseResolution.ts:expiryWithinNoticeWindow()— usage-axis only (rev 5: notice/grace have no effect on therelease_untilaxis), window ends exactly where the soft-stop phase would begin, blind tograceDaysby design.licenseKeyValidator.ts:notifyLicenseKeyNotice()with per-key warn-once accounting (_noticedKeyskeyed by the raw key string) — deliberately not the process-lifetime boolean the state messages use: two engines built with two different keys each get their own warning.Verification
Paired tests:
hyperformula-tests@spike/hf307-notice-window(authored RED-first, 8 assertions: inside/outside window, release_until never warns, expired still hard-blocks, silent suppresses, per-key warn-once across two engines). Full license suite 212/212 under Jest (12 suites,unit/license+unit/helpers/licenseKeyValidator), re-measured after the 19.08 rebase onto the current #1731 (e9863f27) — the earlier 165/165 predated PR3's and PR4's review fixes,tsc --noEmitclean,eslint --quietclean on changed files.🤖 Generated with Claude Code
Note
Medium Risk
Touches license validation and console messaging on every engine build, but changes are additive warnings with explicit silencing for transient configs and no change to expiry blocking or entitlements.
Overview
Adds a one-time console warning for valid typed license keys whose
usage_untilexpiry falls inside the key’s configurednoticewindow. The message names the last covered day (“valid until … (UTC)”), respects the key’s silent flags, and never runs forrelease_untilkeys or after hard expiry—blocking at/after expiry is unchanged.licenseResolutionintroducesexpiryWithinNoticeWindow()(usage axis only, window ends at the usage deadline, ignores grace) and only appliesnoticeDaysfor rev-5 license shapes.resolveLicenseacceptsnotifyConsoleso transient config resolution can skip console output.licenseKeyValidatoraddsnotifyLicenseKeyNotice()with per-key warn-once tracking (checksum-based identity), separate from the existing once-per-page invalid/missing/expired messages.Config/mergeConfigpassnotifyLicenseMessagesinto resolution;rebuildWithConfigsilences notices for the serialization-only config built from the outgoing key while the caller may be replacing it.Reviewed by Cursor Bugbot for commit db92bcd. Bugbot is set up for automated code reviews on this repo. Configure here.