Skip to content

HF-307: warn once when a typed key enters its notice window (5/8) - #1736

Closed
marcin-kordas-hoc wants to merge 4 commits into
hf-307-entitlement-gating-pr4from
spike/hf307-notice-window
Closed

marcin-kordas-hoc wants to merge 4 commits into
hf-307-entitlement-gating-pr4from
spike/hf307-notice-window

Conversation

@marcin-kordas-hoc

@marcin-kordas-hoc marcin-kordas-hoc commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator

Consumes the license key's notice field: 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 (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=0 and notice>0 whose 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 the release_until axis), window ends exactly where the soft-stop phase would begin, blind to graceDays by design.
  • licenseKeyValidator.ts: notifyLicenseKeyNotice() with per-key warn-once accounting (_noticedKeys keyed 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.
  • Blocking behaviour at/after expiry is byte-identical to before; the key's silent flags suppress the warning; CHANGELOG entry included.

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 --noEmit clean, eslint --quiet clean 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_until expiry falls inside the key’s configured notice window. The message names the last covered day (“valid until … (UTC)”), respects the key’s silent flags, and never runs for release_until keys or after hard expiry—blocking at/after expiry is unchanged.

licenseResolution introduces expiryWithinNoticeWindow() (usage axis only, window ends at the usage deadline, ignores grace) and only applies noticeDays for rev-5 license shapes. resolveLicense accepts notifyConsole so transient config resolution can skip console output.

licenseKeyValidator adds notifyLicenseKeyNotice() with per-key warn-once tracking (checksum-based identity), separate from the existing once-per-page invalid/missing/expired messages.

Config / mergeConfig pass notifyLicenseMessages into resolution; rebuildWithConfig silences 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.

@qunabu

qunabu commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 18, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@marcin-kordas-hoc
marcin-kordas-hoc force-pushed the spike/hf307-notice-window branch from c1cce95 to cbe23f0 Compare August 18, 2026 06:17
@github-actions

github-actions Bot commented Aug 18, 2026 •

Copy link
Copy Markdown

Performance comparison of head (db92bcd) vs base (cd79b06)

                                     testName |    base |   head | change
-------------------------------------------------------------------------
                                      Sheet A |   495.5 | 496.87 | +0.28%
                                      Sheet B |  162.91 | 164.61 | +1.04%
                                      Sheet T |  144.44 | 144.88 | +0.30%
                                Column ranges |  526.85 | 525.57 | -0.24%
                                Sorted lookup | 15010.7 |  14891 | -0.80%
Sheet A:  change value, add/remove row/column |   18.02 |  17.07 | -5.27%
 Sheet B: change value, add/remove row/column |  148.54 | 150.26 | +1.16%
                   Column ranges - add column |  163.96 |  161.4 | -1.56%
                Column ranges - without batch |  508.77 | 494.92 | -2.72%
                        Column ranges - batch |   126.9 | 126.63 | -0.21%

@marcin-kordas-hoc
marcin-kordas-hoc marked this pull request as ready for review August 18, 2026 07:32
@marcin-kordas-hoc
marcin-kordas-hoc force-pushed the hf-307-entitlement-gating-pr4 branch from 5d67b5d to 46ac32d Compare August 18, 2026 08:57
@marcin-kordas-hoc
marcin-kordas-hoc force-pushed the spike/hf307-notice-window branch from cbe23f0 to 7b5398d Compare August 19, 2026 09:55
@marcin-kordas-hoc

Copy link
Copy Markdown
Collaborator Author

Paired tests PR: handsontable/hyperformula-tests#37 — merge it BEFORE this one (fetch-tests pairs by branch name).

@marcin-kordas-hoc marcin-kordas-hoc changed the title HF-307: warn once when a typed key enters its notice window (5/6) HF-307: warn once when a typed key enters its notice window (5/8) Aug 20, 2026

@Tobiadefami Tobiadefami left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/license/licenseResolution.ts Outdated
marcin-kordas-hoc and others added 4 commits August 26, 2026 03:17
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>
@marcin-kordas-hoc
marcin-kordas-hoc force-pushed the spike/hf307-notice-window branch from aa835bf to db92bcd Compare August 26, 2026 03:34
@marcin-kordas-hoc
marcin-kordas-hoc force-pushed the hf-307-entitlement-gating-pr4 branch from e9863f2 to cd79b06 Compare August 26, 2026 03:34
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.38%. Comparing base (cd79b06) to head (db92bcd).

Additional details and impacted files

Impacted file tree graph

@@                      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           
Files with missing lines Coverage Δ
src/Config.ts 94.69% <100.00%> (ø)
src/HyperFormula.ts 99.76% <100.00%> (ø)
src/helpers/licenseKeyValidator.ts 95.74% <100.00%> (+1.15%) ⬆️
src/license/licenseResolution.ts 97.43% <100.00%> (+0.29%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@marcin-kordas-hoc

Copy link
Copy Markdown
Collaborator Author

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.

sequba added a commit that referenced this pull request Sep 24, 2026
…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>
sequba added a commit that referenced this pull request Oct 7, 2026
…, 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants