Skip to content

HF-307 PR 3/4: make typed license keys work — vendored reader, resolution, capability table - #1730

Open
marcin-kordas-hoc wants to merge 7 commits into
hf-307-entitlement-gating-pr2from
hf-307-entitlement-gating-pr3
Open

HF-307 PR 3/4: make typed license keys work — vendored reader, resolution, capability table#1730
marcin-kordas-hoc wants to merge 7 commits into
hf-307-entitlement-gating-pr2from
hf-307-entitlement-gating-pr3

Conversation

@marcin-kordas-hoc

@marcin-kordas-hoc marcin-kordas-hoc commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

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/vendor/ 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, 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.


Note

High Risk
Changes commercial license validation, entitlement resolution, and function/API gating—any bug can block paying customers or silently widen or narrow access.

Overview
Typed commercial keys ([TRIAL]/[FREE]/[SUB]/[PERP]) are now recognized instead of always falling through to invalid legacy handling. A vendored TypeScript port of handsontable/license-key lives under src/license/vendor/ (checksum, payload extraction, default schema), with PROVENANCE.md for upstream drift checks.

resolveLicense reads the key once and returns both gate A (validityState) and gate B (entitlement). Non-typed keys still use checkLicenseKeyValidity unchanged. Only valid typed keys get a restricted entitlement; missing/invalid/expired typed keys keep unrestricted entitlement so lapsed keys stay “formulas #LIC!, CRUD still works” rather than API throws.

The production capability table replaces the single core grant that mirrored the whole registry: core (operators only), functions_1functions_4 (static per-package function lists), separate feat:* tokens for CRUD/undo/clipboard/named expressions/batching, and empty reserved add-on tokens. CapabilityRegistry no longer calls refreshCoreGrant at construction.

License console warnings are centralized in notifyLicenseKeyState (once per page load, shared with the typed path); expiry dates in messages use UTC formatting. Shipped-shape keys (tier/addons) and rev-5 shape (capabilities, dates, flags) are both reconciled in licenseTermsOf, with shipped keys granted all feat:* when the payload names no feature tokens.

Reviewed by Cursor Bugbot for commit 7c25bbc. Bugbot is set up for automated code reviews on this repo. Configure here.

marcin-kordas-hoc and others added 3 commits August 11, 2026 18:17
Ports the read side of the typed license key format into src/license/vendor/ as
TypeScript (allowJs is off and strict is on, so this is a port rather than a
copy). Nothing consumes it yet - the key-to-entitlement adapter follows in the
next commit.

Vendored: constants, the default schema, the six reader-side helpers of utils,
the pure-JS SHA-512, and the key-data extractor. Not vendored: key generation
and the schema validator, which are unreachable here because HyperFormula only
ever reads keys and always reads them with the default schema.

The delivery form follows the key spec's own recommendation of a vendored copy
with 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 of the upstream
sources, so drift is detectable by re-cloning and re-hashing, and lists the
deliberate divergences - notably that the extractor drops the custom-schema
parameter and additionally returns licensedProductName, since the grace period
lives on the licensed product entry and re-deriving "the first schema product
present in the payload" in the caller could drift from the rule used to derive
the expiry.

Payload fields are typed unknown: field types are checked when a key is
generated, which constrains nothing about a payload that reaches this code, so
consumers must narrow rather than trust a declared shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
Before this commit a genuine typed key did not work at all. The validity check
recognizes three fixed strings and the older 25-character format; a typed key
matched none of them and fell through to INVALID, so every formula returned
#LIC! and the console warned that a paid-for key was invalid. Verified by
building an engine with a real, unexpired subscription key before touching
anything.

resolveLicense reads the key once and answers both gates from that single
reading, so they cannot disagree about what the string says. A typed key is
recognized first; anything else - gpl-v3, an older-format key, an empty string,
a typed key with a broken checksum - falls through to checkLicenseKeyValidity
completely untouched. That is what keeps existing behaviour bit-identical: the
existing function is not modified, only extracted from (notifyLicenseKeyState),
so both paths report the same states with the same wording and share the
one-warning-per-page flag rather than each getting their own.

Expiry follows the format's own rules: a key with no expiration date never
expires; trial and subscription keep working for `grace` days past an inclusive
expiration date, against the clock; a perpetual key compares its maintenance end
against the build release date, so an air-gapped install with a wrong clock is
unaffected. An unknown release date resolves to "not expired", matching what the
existing validator already does - a build that cannot tell its own age must not
start rejecting keys customers paid for.

The invariant this PR must not break is enforced here and mutation-tested: only
a VALID typed key resolves to a restricted entitlement. Missing, invalid and
expired all resolve to unrestrictedEntitlement(), for typed keys exactly as for
the older format. 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.

Full unit suite green (6260 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
Replaces the single-core-token placeholder with the four function packages of
the packaging design, and teaches the adapter both payload shapes.

The membership is transcribed from that design's own per-function evidence file
rather than invented. The transcript was checked by reproducing the file's five
published counts exactly: 370 rows, and 17 / 51 / 127 / 355 cumulative plus 15
operators. Coverage was checked the other way too - all 423 registered function
ids resolve into the 370 canonical entries once HF's 53 declared aliases are
canonicalised, with zero uncovered, which is also what makes the rule that
aliases travel with their canonical function true here for free.

THIS MEMBERSHIP IS A DRAFT and is marked as such in the source. The packaging
design is still under review, with the free tier's exact contents and the
placement of several function families not yet settled. Landing it now is a
deliberate call, not a claim that it is final. capability-table.spec.ts pins the
counts so a later edit cannot drift from the evidence silently.

Both payload shapes are read, per product entry, by detecting `capabilities`:
the shipped shape (tier/addons/exp/grace, contract type from the key tag) and
the newer specified shape (capabilities/usage_until/release_until/notice/flags,
with no commercial vocabulary in the payload). The two disagree about nearly
every field, the newer one is still under review, and only the first can be
minted today, so reading both means an already-issued key keeps working
whichever way that is settled. The newer spec also contradicts itself on whether
its dates are YYYY-MM-DD strings or numeric timestamps, so both are accepted.

Commercial tier names are translated to capability tokens in the adapter, not
mirrored into the table, so the table speaks one vocabulary. An unknown tier
passes through untranslated and surfaces as an unrecognized capability rather
than being swallowed.

Grants are stored fully expanded rather than chained through `implies`: the
design states the enforcement layer must not assume a hierarchy between tokens.
Operators are granted by the core token as engine baseline, and the protected
built-ins OFFSET and VERSION are listed nowhere, since the interpreter never
gate-checks them.

Features are all still granted by the core token. The evidence covers functions
only; nothing has decided whether undo/redo or the clipboard is a paid feature,
and restricting one here would both invent a product decision and make PR 2's
ensureCapability start throwing from the CRUD API for real keys.

Full unit suite green (6272 tests). Table membership mutation-tested: moving one
function across a package boundary fails the gating tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
@qunabu

qunabu commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 11, 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 7c25bbc Commit Preview URL

Branch Preview URL
Aug 13 2026, 02:01 PM

Comment thread src/license/licenseResolution.ts
'PPMT', 'PV', 'RAND', 'RANDBETWEEN', 'RATE', 'ROW', 'ROWS', 'SORT', 'STDEV.P', 'STDEV.S', 'STDEVA',
'STDEVPA', 'SUMIF', 'SUMIFS', 'TIME', 'TIMEVALUE', 'UNIQUE', 'VAR.P', 'VAR.S', 'VARA', 'VARPA',
'VLOOKUP', 'WEEKDAY', 'WEEKNUM', 'WORKDAY', 'WORKDAY.INTL', 'XLOOKUP', 'YEARFRAC',
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Capability counts do not match

Medium Severity

The package lists claim cumulative sizes of 17 / 51 / 127, but the arrays actually total 16 / 50 / 125. INT is a clear omission from the math-engine set, so a valid freemium typed key gets #LIC! for INT even though the transcript and pinned counts say that tier has 17 functions.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b60b5d9. Configure here.

Comment thread src/license/licenseResolution.ts Outdated

return now < deadline
? {state: LicenseKeyValidityState.VALID}
: {state: LicenseKeyValidityState.EXPIRED, expiredOn: new Date(terms.expiryTimestamp)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Usage expiry ignores local date

Medium Severity

Usage-based typed keys are validated with Date.now() against a UTC-midnight deadline, but LicenseExpiry for kind: 'usage' is defined to use the client's local calendar date. Customers west of UTC can lose the last licensed day, and customers east of UTC can keep it into the next local day.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b60b5d9. Configure here.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

Performance comparison of head (7c25bbc) vs base (c29a9ba)

                                     testName |    base |    head | change
--------------------------------------------------------------------------
                                      Sheet A |  500.38 |   512.7 | +2.46%
                                      Sheet B |  164.19 |  164.21 | +0.01%
                                      Sheet T |  148.01 |     150 | +1.34%
                                Column ranges |  480.18 |  478.44 | -0.36%
                                Sorted lookup | 14810.2 | 14369.2 | -2.98%
Sheet A:  change value, add/remove row/column |   16.67 |   16.46 | -1.26%
 Sheet B: change value, add/remove row/column |  139.51 |  137.82 | -1.21%
                   Column ranges - add column |  155.97 |  157.98 | +1.29%
                Column ranges - without batch |  483.99 |  471.97 | -2.48%
                        Column ranges - batch |  122.15 |  120.36 | -1.47%

@marcin-kordas-hoc

Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread src/license/capabilities.ts Outdated
Comment thread src/license/licenseResolution.ts Outdated
…l-open dates

Bugbot and a code-review pass found real defects in the previous three commits.
Each was reproduced before fixing and is now pinned by a test.

TWO CRASHES on checksum-valid keys. A key whose HyperFormula entry was `null`
rather than an object threw "Cannot read properties of null (reading 'tier')"
straight out of the Config constructor, and a numeric date outside Date's range
threw "Invalid time value" from toISOString. Both killed engine construction,
where every other malformed key merely resolves to INVALID. Every payload field
is untrusted; nothing may assume a shape now.

CUSTOM FUNCTIONS WERE GATED. `functions_4` was filled from the function registry
at run time, which swept in anything registered through registerFunctionPlugin -
putting a user's OWN function into the most expensive package and returning
#LIC! for it on every smaller licence, the opposite of decision D1. The
excel-simulator set is now enumerated statically like the other three, so the
whole table is static and a function it does not list is not gated at all,
which is exactly the treatment a custom function should get. The cost is that a
newly implemented built-in is ungated until added here, which the completeness
invariant fails on - a much better failure mode.

FAIL-OPEN DATES. An unreadable rev-5 date resolved to "never expires", turning a
minting typo into a permanent licence, while the shipped shape already rejects a
malformed `exp`. A present-but-unreadable date now invalidates the key. String
dates go through the vendored parseIsoDate, so `2027-02-30` is rejected rather
than rolling over into March and granting two extra days.

WRONG SOURCE FOR REV-5 TERMS. Dates, notice and grace were read from the
licensed product entry for both shapes, but that rule belongs to the shipped
shape; under rev 5 every product entry carries its own terms. HyperFormula now
reads its own under rev 5, and flags no longer disagree with the rest.

Also: the expired-on date now reports the first day NOT covered, the convention
the legacy validator already uses, so the two paths no longer differ by a day.

Corrected a comment that the capability-table commit had invalidated: the core
token grants operators and the API surface, NOT a usable function set, so a key
whose tokens this build does not recognize evaluates operators only and returns
#LIC! for every function, silently. That cliff is deliberate per D3 but severe;
it is now described accurately and flagged for review rather than misdescribed.

Two review findings were checked and rejected: the package arrays total
16/50/125 rather than the documented 17/51/127 because OFFSET and VERSION are
protected and deliberately excluded, and INT is an excel-simulator function in
the evidence, not a math-engine one.

Full unit suite green (6305 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
@marcin-kordas-hoc

Copy link
Copy Markdown
Collaborator Author

bugbot run

formatDate used local getters on a date built at UTC midnight, so anyone west of
UTC saw a console warning naming the day BEFORE the one their key carries.

This is pre-existing rather than new - the legacy path builds its date the same
way, from a whole number of days since the epoch - so fixing the shared helper
corrects both paths rather than leaving two conventions. No test asserts the
message text, and nothing else calls formatDate.

Verified by running the same expired key under TZ=Pacific/Midway (UTC-11),
TZ=UTC and TZ=Pacific/Kiritimati (UTC+14): all three now print "January 2,
2020" for a key whose exp is 2020-01-01, which is the first day NOT covered -
the convention the legacy path already used.

Deliberately not covered by a test: the only observable is console.warn, and
the warn-once flag is module-level and never reset, so such a test would fire
only when it happened to run first in the module registry. An order-dependent
test is worse than none here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2c65499. Configure here.

...EXCEL_SIMULATOR_FUNCTIONS,
],
features: [],
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Capability lists miss documented counts

Medium Severity

MATH_ENGINE_FUNCTIONS has 16 entries and the cumulative package lists total 50 / 125 / 353, but this file claims an exact transcript of 17 / 51 / 127 / 355 (plus 15 operators). Two functions from the evidence file never made it into the grants, so tier membership and gate B coverage drift from the packaging design the table is supposed to enforce.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2c65499. Configure here.

const timestamp = Date.UTC(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10))

return isNaN(timestamp) ? null : timestamp
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Release date TZ disagrees with legacy

Medium Severity

releaseDateTimestamp parses HT_RELEASE_DATE with Date.UTC, while checkLicenseKeyValidity still builds the same env value via new Date(month/day/year) (local time). The comment promises perpetual typed keys and legacy keys agree on “this build”, but east-of-UTC environments can get different day numbers and therefore different expiry verdicts for the same calendar release date.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2c65499. Configure here.

marcin-kordas-hoc and others added 2 commits August 12, 2026 17:06
…swers 12.08

Two changes from Kuba's answers on the task (comment of 12.08):

"Feature gating should work, but the legacy keys should grant all feat:*
capabilities." An earlier revision granted all five features from CORE_TOKEN,
which made feature gating inert by construction - no typed key could ever lose
an API area. The five features now live on their own feat:* tokens (spelled
after the task's draft vocabulary), and core grants the operators alone. A
rev-5 key states its feature grants explicitly; the shipped shape - whose
vocabulary predates feature tokens and whose tiers are products sold with the
full API - is granted all five by the adapter, so an existing shipped-shape
key's API behaviour is unchanged. Legacy keys resolve to the unrestricted
entitlement, which is the carve-out Kuba named, already in place.

"One unrecognized token currently silences the ENTIRE key - this seems like an
implementation error." Confirmed and decoupled: silence now comes solely from
the key's flags. The coupling suppressed strictly more than D3 asks for - a
vocabulary mismatch would have swallowed expiry notices too.

The #LIC! cliff comment is updated to record D6-A: Kuba ratified D3 as-is
("this situation should never happen. There is no point in issuing a key if
empty capabilities.").

Tests: handsontable/hyperformula-tests#32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
…honoured

Three fixes, all from reading key spec rev 5 (CU doc 8cnjcyf-31675 page
8cnjcyf-48155, updated 12.08) against the code and running the result.

**Feature tokens are OPT-IN, not opt-out.** The previous revision granted the
five feature areas only in the shipped-shape branch, so a key was denied every
gated API area unless it explicitly named `feat:*` tokens. Two key classes that
myHOT can mint TODAY do exactly that:

- rev-5 keys. §2.2 lists HyperFormula's whole token vocabulary as `functions_1..4`,
  `spreadsheet`, `import_export` - there is NO `feat:*` entry at all. Minting the
  spec's own §2 example payload and running it: setCellContents, addRows, copy,
  undo, addNamedExpression and batch ALL threw.
- shipped-shape keys whose payload carries no usable `hyperformula` entry, i.e.
  Handsontable-only keys and keys with `hyperformula: null`. These fell outside
  the branch that did the granting, so they lost the API that `core` used to give
  them - and, being gate-A VALID, they lost it without even a console warning.

So absence of a `feat:*` token cannot mean "no features": no vocabulary in
circulation can express one. It means "this key does not talk about features",
and the task's additive-safety rule - a grant may grow, never shrink - makes the
whole gated API the only safe reading. A key that DOES name a `feat:*` token
still gets exactly the areas it names, which is what Kuba asked for ("Feature
gating should work").

**`no-console-warns` is honoured.** rev 5 is not self-consistent about the flag:
its normative table and example payload (§2.3, §2) say `no-console-warns`, its
runtime sections (§4.3, §5.2) say `silent-console`, earlier revisions said plain
`silent`. Only the last two were recognised, so a doc-conformant SaaS key printed
console warnings it had explicitly asked to suppress. All three now count.

**An unreadable `capabilities` rejects the key.** `capabilities` present but not
an array fell through to the shipped-shape branch, which was a free pass twice
over: the key gained every feature it never carried, and its rev-5 dates were
never read, so a subscription expired in 2020 resolved as perpetual. It now
returns null (INVALID), matching what the module already does for an unreadable
date and what its own doc comment promises.

Also adds `resetLicenseKeyNotificationForTests` (@internal): the warn-once flag
is module-level and never reset, which made the whole console-message path
untestable - deleting the notify call left all 6300 tests green.

Tests: handsontable/hyperformula-tests#32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.20635% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.38%. Comparing base (c29a9ba) to head (7c25bbc).

Files with missing lines Patch % Lines
src/license/licenseResolution.ts 97.14% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                        Coverage Diff                        @@
##           hf-307-entitlement-gating-pr2    #1730      +/-   ##
=================================================================
+ Coverage                          97.33%   97.38%   +0.04%     
=================================================================
  Files                                198      204       +6     
  Lines                              15835    16194     +359     
  Branches                            3473     3557      +84     
=================================================================
+ Hits                               15413    15770     +357     
- Misses                               414      416       +2     
  Partials                               8        8              
Files with missing lines Coverage Δ
src/Config.ts 94.69% <100.00%> (-0.05%) ⬇️
src/helpers/licenseKeyValidator.ts 94.59% <100.00%> (+4.27%) ⬆️
src/license/CapabilityRegistry.ts 100.00% <100.00%> (ø)
src/license/LicenseEntitlement.ts 100.00% <ø> (ø)
src/license/capabilities.ts 100.00% <100.00%> (ø)
src/license/vendor/constants.ts 100.00% <100.00%> (ø)
src/license/vendor/defaultSchema.ts 100.00% <100.00%> (ø)
src/license/vendor/extractKeyData.ts 100.00% <100.00%> (ø)
src/license/vendor/sha512.ts 100.00% <100.00%> (ø)
src/license/vendor/utils.ts 100.00% <100.00%> (ø)
... and 1 more
🚀 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.

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.

2 participants