From c7bce3e84e11512863e4dfaced9f01033bea83db Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 11 Aug 2026 18:17:05 +0000 Subject: [PATCH 1/9] HF-307 PR 3: vendor the typed-key reader 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 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/license/vendor/PROVENANCE.md | 92 +++++++++++ src/license/vendor/constants.ts | 23 +++ src/license/vendor/defaultSchema.ts | 168 +++++++++++++++++++++ src/license/vendor/extractKeyData.ts | 215 ++++++++++++++++++++++++++ src/license/vendor/sha512.ts | 217 ++++++++++++++++++++++++++ src/license/vendor/utils.ts | 218 +++++++++++++++++++++++++++ 6 files changed, 933 insertions(+) create mode 100644 src/license/vendor/PROVENANCE.md create mode 100644 src/license/vendor/constants.ts create mode 100644 src/license/vendor/defaultSchema.ts create mode 100644 src/license/vendor/extractKeyData.ts create mode 100644 src/license/vendor/sha512.ts create mode 100644 src/license/vendor/utils.ts diff --git a/src/license/vendor/PROVENANCE.md b/src/license/vendor/PROVENANCE.md new file mode 100644 index 000000000..4908e8292 --- /dev/null +++ b/src/license/vendor/PROVENANCE.md @@ -0,0 +1,92 @@ +# Vendored typed-key reader — provenance and drift control + +The files in this directory are a **TypeScript port of code owned by another Handsoncode +repository**, not original HyperFormula code. Treat them as a mirror: fix bugs upstream first, +then re-port. A local-only fix here silently forks the two copies, and a forked checksum or +parser rejects genuine customer keys. + +## Upstream + +| | | +|---|---| +| Repository | `handsontable/license-key` (private) | +| Branch | `develop` | +| Commit | `7553d0d1208f483c3d744e3a1d09c1f51ba48c1e` | +| Ported on | 2026-08-11 | +| Reference docs | the format and design notes kept alongside the upstream sources | + +## Files + +Hashes are of the **upstream** `.js` sources at the commit above, so drift is detectable without +storing a copy of them here. + +| This directory | Upstream `src/typed-key/` | Upstream sha256 | +|---|---|---| +| `constants.ts` | `constants.js` | `2f987427ba3d012917c5972714b964b26f877b2e91a57790b37249928c72f5b6` | +| `defaultSchema.ts` | `default-schema.js` | `f905f1a0a6fef9b0c247a0fdb0642d5018d30fc915314ac8b10976cec8be9fc8` | +| `utils.ts` | `utils.js` | `135a8396bb22f424160fc651e899931d4be807df9b94c6dd24bb1cf6526e0541` | +| `sha512.ts` | `sha512.js` | `668dd1109160b92965a1f9a9c5fb78dfdc1e5b7e93f635a147ae8a6bb2a5d837` | +| `extractKeyData.ts` | `extract-key-data.js` | `e6f854f10c6679136d382afe0bcf4fb1d4f9709416c68247a9cdb2236b7eec23` | + +### Checking for drift + +The check is manual and needs read access to the private repository — HyperFormula's own CI +cannot do it, which is exactly why the hashes are written down here. + +```bash +git clone git@github.com:handsontable/license-key.git +cd license-key/src/typed-key +sha256sum constants.js default-schema.js utils.js sha512.js extract-key-data.js +``` + +Any hash that differs from the table means upstream moved. Re-read the changed file and re-port +it, then update this table together with the code in the same commit. + +## Not vendored, on purpose + +| Upstream file | Why not | +|---|---| +| `generate-key.js` | Mints keys. HyperFormula only ever reads them. | +| `create-engine.js` | Binds the API to a custom schema; HyperFormula uses the default one. | +| `validate-schema.js` | Only reachable when a *custom* schema is passed — dead code here. | +| `validate-key.js` | A two-line boolean wrapper over `extractTypedKeyData`; the extractor is called directly. | + +From `utils.js`, the two generation-side helpers `bytesToBase64` and `stringToBase64Url` are +also left out. Everything else in that file is ported. + +`default-schema.js` is ported **whole**, including the prose wordings that only generation reads. +Two reasons: it keeps the file a faithful copy so the hash check above stays meaningful, and the +keys of `scopeWordings` / `addonWordings` are the tier and add-on vocabulary +(`freemium | crm | data_grid | excel_simulator`, `spreadsheet | import_export`) that the +capability table is keyed on — having it here lets a test assert the two agree. + +## Deliberate divergences from upstream + +`allowJs` is off in HyperFormula's `tsconfig.json` and `strict` is on, so these files are a port +rather than a copy. Beyond adding types, the semantics were kept identical except for the +following, which a drift review should expect to see: + +1. **The custom-schema parameter is dropped.** `extractTypedKeyData(licenseKey, schema?)` becomes + `extractTypedKeyData(licenseKey)`, always reading with `DEFAULT_TYPED_KEY_SCHEMA`. This is what + removes the need for `validate-schema.js`. +2. **`extractTypedKeyData` also returns `licensedProductName`.** Upstream returns the derived + `expiryTimestamp` but not which product entry it came from, and the grace period lives on that + same entry. Returning the name avoids re-implementing the "first schema product present in the + payload" rule in the caller, where it could drift from the rule used to derive the expiry. +3. **`extractExpiryTimestamp` became `resolveLicensedProduct`,** returning + `{name, expiryTimestamp} | null` instead of `number | null | undefined`. Upstream needs the + `undefined` sentinel because `null` already means "never expires"; folding the name in gives + one unambiguous `null` for "malformed". +4. **`stringToUtf8Bytes`'s parameter is named `text`, not `string`,** which is a type keyword in + TypeScript. +5. **Payload fields are typed `unknown`.** Field types are checked when a key is generated, which + constrains nothing about a payload that reaches the reader, so consumers must narrow a field + before using it rather than trusting its declared shape. + +Upstream's `/* eslint-disable */` pragmas were dropped where HyperFormula's own ESLint config +does not need them. + +## Related + +- `src/helpers/licenseKeyHelper.ts` — the validator for the older key format, untouched here. +- `src/license/capabilities.ts` — the capability table keyed on the tier/add-on vocabulary above. diff --git a/src/license/vendor/constants.ts b/src/license/vendor/constants.ts new file mode 100644 index 000000000..f5fd9aac7 --- /dev/null +++ b/src/license/vendor/constants.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/typed-key/constants.js`. + * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + */ + +/** + * The typed license key format versions this library can read. The version is stamped into the + * key payload (the `v` field) at generation and checked automatically at extraction — the format + * version describes HOW the key is parsed (envelope, encoding, checksum), unlike the schema, + * which describes WHAT the key grants. When a new format version ships, it is ADDED here (with + * per-version handling where needed) so one build keeps reading all the already-issued keys. + */ +export const TYPED_KEY_SUPPORTED_VERSIONS: number[] = [1] + +/** + * The length of the checksum (SHA-512 as hex) which postfixes every typed license key. + */ +export const TYPED_KEY_CHECKSUM_LENGTH = 128 diff --git a/src/license/vendor/defaultSchema.ts b/src/license/vendor/defaultSchema.ts new file mode 100644 index 000000000..7cb6d0ca0 --- /dev/null +++ b/src/license/vendor/defaultSchema.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/typed-key/default-schema.js`. + * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + */ + +import {deepFreeze} from './utils' + +/** + * One key type of the typed-key schema: its tag, the legal wording it is spelled with, and + * whether it carries an expiration date and a hard stop. + * + * Only `tag` is read while parsing a key. The remaining fields describe the human-readable prose + * and are used at generation, which HyperFormula never does; they are kept so this file stays a + * faithful copy of the upstream vocabulary and so drift is detectable by hashing. + */ +export interface TypedKeyTypeDefinition { + readonly tag: string, + readonly legalClauses: readonly string[], + readonly expiryWording: string, + readonly expires: boolean, + readonly hasHardStop: boolean, +} + +/** + * One product of the typed-key schema. The keys of `scopeWordings` are the tier vocabulary of + * that product (or `'tier:mode'` pairs for a product with deployment modes), and the keys of + * `addonWordings` are its add-on vocabulary — that is where HyperFormula's + * `freemium | crm | data_grid | excel_simulator` tiers and `spreadsheet | import_export` add-ons + * are defined. + */ +export interface TypedKeyProductDefinition { + readonly name: string, + readonly displayName: string, + readonly modes?: readonly string[], + readonly defaultMode?: string, + readonly scopeWordings: Readonly>, + readonly addonWordings?: Readonly>, +} + +/** + * The typed-key schema: the marketing-owned vocabulary of the license keys. + */ +export interface TypedKeySchema { + readonly keyTypes: Readonly>, + /** ARRAY, not a map — the order is the priority order used to pick the licensed product. */ + readonly products: readonly TypedKeyProductDefinition[], +} + +/** + * The default typed-key schema: the marketing-owned vocabulary of the license keys. The schema + * describes WHAT can be licensed (products, tiers, modes, add-ons) and HOW it is worded in the + * human-readable part of the key (legal clauses, scope wordings, expiry wordings). + * + * The engine (generate/validate/extract) only defines the key FORMAT — the type tag, the + * prose/payload structure, the checksum, the version stamping, and the strict validation rules. + * + * Compatibility rules that matter to a reader such as HyperFormula: + * - key type names and tags are append-only — renaming or removing one makes already-issued keys + * of that type unreadable; + * - product names are append-only for the same reason (the expiration time is derived from the + * first schema product found in the payload); + * - wordings and legal clauses may change freely — they only affect newly generated keys, + * already-issued keys stay valid (the checksum covers whatever prose they were born with). + * + * It is deeply frozen so it cannot be mutated in place. + */ +export const DEFAULT_TYPED_KEY_SCHEMA: TypedKeySchema = deepFreeze({ + // Every key type defines its tag, its legal wording (the "{PRODUCT}" placeholder is replaced + // with the licensed product display name), the beginning of the expiration clause, and two + // flags: "expires" (does the key carry an expiration date) and "hasHardStop" (does it stop + // working "grace" days after the expiration - such keys require the grace period in the + // payload). + keyTypes: { + trial: { + tag: '[TRIAL]', + legalClauses: [ + 'is_granted_for_evaluation_only', + 'Use_in_production_is_not_permitted', + 'Please_report_misuse_to_legal@handsontable.com', + 'For_purchasing_contact_sales@handsontable.com', + ], + expiryWording: 'This_key_will_deactivate_on', + expires: true, + hasHardStop: true, + }, + freemium: { + tag: '[FREE]', + legalClauses: [ + 'is_granted_under_the_Free_plan', + 'Use_is_subject_to_the_{PRODUCT}_Free_License_Terms', + 'Features_beyond_the_Free_plan_require_a_commercial_license', + 'To_upgrade_contact_sales@handsontable.com', + ], + expiryWording: 'This_key_does_not_expire', + expires: false, + hasHardStop: false, + }, + subscription: { + tag: '[SUB]', + legalClauses: [ + 'is_granted_under_a_subscription_license', + 'Use_after_expiry_is_not_permitted_per_the_subscription_agreement', + 'To_renew_contact_sales@handsontable.com', + ], + expiryWording: 'This_key_will_deactivate_on', + expires: true, + hasHardStop: true, + }, + perpetual: { + tag: '[PERP]', + legalClauses: [ + 'is_granted_under_a_perpetual_license', + 'Access_to_new_versions_ends_when_maintenance_expires', + 'Versions_released_before_that_date_may_be_used_indefinitely', + 'To_renew_maintenance_contact_sales@handsontable.com', + ], + expiryWording: 'Maintenance_ends_on', + expires: true, + hasHardStop: false, + }, + }, + // The products, in priority order: the FIRST product of this list found in the payload is the + // "licensed product" - it carries the expiration date and the grace period, and its display + // name is spelled in the key header. + // + // Every product defines its scope wordings (tier, or "tier:mode" when the product supports + // deployment modes) and optionally its add-on wordings. + products: [ + { + name: 'handsontable', + displayName: 'Handsontable', + modes: ['internal', 'saas'], + defaultMode: 'internal', + scopeWordings: { + freemium: 'Free', + 'enterprise:internal': 'Enterprise', + 'enterprise:saas': 'Enterprise_SaaS', + }, + }, + { + name: 'hyperformula', + displayName: 'HyperFormula', + scopeWordings: { + freemium: 'HyperFormula_Free', + crm: 'HyperFormula_CRM', + data_grid: 'HyperFormula_Data_Grid', + excel_simulator: 'HyperFormula_Excel_Simulator', + }, + addonWordings: { + spreadsheet: 'Spreadsheet_addon', + import_export: 'Import_Export_addon', + }, + }, + ], +}) + +/** + * The name of HyperFormula's own product entry in the typed-key payload. Note this is NOT + * necessarily the *licensed* product of a key: a key that grants both Handsontable and + * HyperFormula carries its expiration date on the Handsontable entry, because that product comes + * first in {@link DEFAULT_TYPED_KEY_SCHEMA}'s priority order. + */ +export const HYPERFORMULA_PRODUCT_NAME = 'hyperformula' diff --git a/src/license/vendor/extractKeyData.ts b/src/license/vendor/extractKeyData.ts new file mode 100644 index 000000000..0a95cd753 --- /dev/null +++ b/src/license/vendor/extractKeyData.ts @@ -0,0 +1,215 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/typed-key/extract-key-data.js`. + * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + * + * Two deliberate divergences from upstream, both recorded in the manifest: the custom-schema + * parameter is dropped (HyperFormula always reads with {@link DEFAULT_TYPED_KEY_SCHEMA}, so + * upstream's `validateTypedKeySchema` branch is unreachable here), and the result additionally + * carries {@link TypedKeyData.licensedProductName} so a caller can find the grace period without + * re-implementing the licensed-product rule. + */ + +import {TYPED_KEY_CHECKSUM_LENGTH, TYPED_KEY_SUPPORTED_VERSIONS} from './constants' +import {DEFAULT_TYPED_KEY_SCHEMA} from './defaultSchema' +import {sha512} from './sha512' +import {base64ToString, parseIsoDate, stringToUtf8Bytes} from './utils' + +/** + * One product entry of a typed key payload. + * + * Every field is typed `unknown` on purpose. Field types are checked when a key is generated, + * which constrains nothing about a payload that actually reaches this code, and the checksum + * establishes only that the payload arrived intact. Every consumer must therefore narrow a + * field before using it rather than trusting its declared shape. + */ +export interface TypedKeyProductGrant { + readonly tier?: unknown, + readonly mode?: unknown, + readonly addons?: unknown, + readonly exp?: unknown, + readonly grace?: unknown, +} + +/** + * A typed key payload. Only `v` is verified before this type is handed out (against + * {@link TYPED_KEY_SUPPORTED_VERSIONS}); see {@link TypedKeyProductGrant} for why the rest is + * `unknown`. + */ +export interface TypedKeyPayload { + readonly v: number, + readonly products: Readonly>, + readonly ref?: unknown, + readonly holder?: unknown, + readonly iss?: unknown, +} + +/** + * The machine-readable content of an intact typed license key. + */ +export interface TypedKeyData { + /** One of `'trial'`, `'freemium'`, `'subscription'`, `'perpetual'`. */ + readonly keyType: string, + readonly payload: TypedKeyPayload, + /** + * The expiration time derived from the payload, as epoch milliseconds; `null` means the key + * never expires. `null` rather than a number is deliberate — a real timestamp of `0` (a key + * dated 1970-01-01) must stay distinguishable from "never". + */ + readonly expiryTimestamp: number | null, + /** + * The name of the licensed product: the first schema product present in the payload. It is the + * only entry allowed to carry `exp` and `grace`, so a key granting both Handsontable and + * HyperFormula carries its expiry and grace period on the Handsontable entry. + */ + readonly licensedProductName: string, +} + +/** + * The licensed product of a payload, together with the expiration time derived from it. + */ +interface LicensedProduct { + readonly name: string, + readonly expiryTimestamp: number | null, +} + +/** + * Resolves the licensed product of the payload and derives its expiration time. The expiration + * date (`exp`, in the `YYYY-MM-DD` format) is converted to epoch milliseconds (UTC midnight). A + * payload without the expiration date (a freemium key) maps to `null`, which means the key never + * expires. Returns `null` when the payload does not have the expected shape. + * + * Upstream returns only the timestamp, using `undefined` as its "malformed" sentinel because + * `null` already means "never expires"; folding the product name in lets this return one + * unambiguous `null` instead. + * + * @param {TypedKeyPayload} payload - the key payload + */ +function resolveLicensedProduct(payload: TypedKeyPayload): LicensedProduct | null { + const {products} = payload + + if (products === null || typeof products !== 'object' || Array.isArray(products)) { + return null + } + + const schemaProductNames = DEFAULT_TYPED_KEY_SCHEMA.products.map((schemaProduct) => schemaProduct.name) + + // A payload granting a product this schema does not know cannot be read reliably - the + // licensed product (and so the expiry) could be resolved wrongly. Reject it instead of + // guessing; product lists are append-only and the reading side has to know at least as much as + // the generating one. + if (Object.keys(products).some((name) => schemaProductNames.indexOf(name) === -1)) { + return null + } + + // The licensed product is the first schema product present in the payload (the schema order + // defines the priority). Presence is read own-property only, so an inherited prototype-chain + // property cannot masquerade as a granted product. + const hasOwn = (name: string) => Object.prototype.hasOwnProperty.call(products, name) + const licensedProductName = schemaProductNames.find(hasOwn) + const licensedProduct = licensedProductName === undefined ? undefined : products[licensedProductName] + + if (licensedProductName === undefined || licensedProduct === undefined || licensedProduct === null + || typeof licensedProduct !== 'object' || Array.isArray(licensedProduct)) { + return null + } + if (licensedProduct.exp === undefined) { + return {name: licensedProductName, expiryTimestamp: null} + } + + try { + return {name: licensedProductName, expiryTimestamp: parseIsoDate(String(licensedProduct.exp), 'expiration').timestamp} + } catch (error) { + // A malformed or impossible date - such a payload is not trustworthy. + return null + } +} + +/** + * Extracts the machine-readable data from a typed license key (`[TRIAL]`, `[FREE]`, `[SUB]` or + * `[PERP]`). The function verifies the checksum first, so the returned data is guaranteed to + * belong to an intact key. For a malformed or tampered key `null` is returned. + * + * The expiration time itself is not checked here — it is up to the caller to compare it against + * the current time (trial, subscription) or the build release date (perpetual). + * + * @param {string} licenseKey - the license key to extract the data from + */ +export function extractTypedKeyData(licenseKey: string): TypedKeyData | null { + // The key alphabet has no whitespace, so trimming is lossless - keys are commonly pasted with + // a trailing newline (email, terminal). + const key = `${licenseKey}`.trim() + const keyType = Object.keys(DEFAULT_TYPED_KEY_SCHEMA.keyTypes) + .find((type) => key.indexOf(`${DEFAULT_TYPED_KEY_SCHEMA.keyTypes[type].tag}_`) === 0) + + if (keyType === undefined) { + return null + } + if (key.length <= TYPED_KEY_CHECKSUM_LENGTH) { + return null + } + + const keyBody = key.slice(0, -TYPED_KEY_CHECKSUM_LENGTH) + const checksum = key.slice(-TYPED_KEY_CHECKSUM_LENGTH) + + if (!/^[0-9a-f]+$/.test(checksum)) { + return null + } + if (sha512(stringToUtf8Bytes(keyBody)) !== checksum) { + return null + } + + // The quadruple underscore separates the human-readable part from the machine-readable one. + // The LAST occurrence is used - the payload (base64 of valid UTF-8) can never contain four + // consecutive underscores, while the human-readable part could (underscore runs are sanitized + // at generation, but a lenient search keeps the parser robust). + const separatorIndex = keyBody.lastIndexOf('____') + + if (separatorIndex === -1) { + return null + } + + // The machine-readable part is the payload encoded as URL-safe base64. + const payloadJson = base64ToString(keyBody.slice(separatorIndex + 4)) + + if (payloadJson === null) { + return null + } + + let parsed: unknown + + try { + parsed = JSON.parse(payloadJson) + } catch (error) { + return null + } + + if (parsed === null || typeof parsed !== 'object') { + return null + } + + const payload = parsed as TypedKeyPayload + + // Keys stamped with a format version this library does not know are not readable - the format + // version describes HOW the key is parsed. + if (TYPED_KEY_SUPPORTED_VERSIONS.indexOf(payload.v) === -1) { + return null + } + + const licensedProduct = resolveLicensedProduct(payload) + + if (licensedProduct === null) { + return null + } + + return { + keyType, + payload, + expiryTimestamp: licensedProduct.expiryTimestamp, + licensedProductName: licensedProduct.name, + } +} diff --git a/src/license/vendor/sha512.ts b/src/license/vendor/sha512.ts new file mode 100644 index 000000000..91eab847b --- /dev/null +++ b/src/license/vendor/sha512.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/typed-key/sha512.js`. + * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + */ + +/** + * The SHA-512 round constants. Each 64-bit constant is stored as a pair of 32-bit integers + * (high word first, low word second). + */ +const K: number[] = [ + 0x428a2f98, 0xd728ae22, 0x71374491, 0x23ef65cd, + 0xb5c0fbcf, 0xec4d3b2f, 0xe9b5dba5, 0x8189dbbc, + 0x3956c25b, 0xf348b538, 0x59f111f1, 0xb605d019, + 0x923f82a4, 0xaf194f9b, 0xab1c5ed5, 0xda6d8118, + 0xd807aa98, 0xa3030242, 0x12835b01, 0x45706fbe, + 0x243185be, 0x4ee4b28c, 0x550c7dc3, 0xd5ffb4e2, + 0x72be5d74, 0xf27b896f, 0x80deb1fe, 0x3b1696b1, + 0x9bdc06a7, 0x25c71235, 0xc19bf174, 0xcf692694, + 0xe49b69c1, 0x9ef14ad2, 0xefbe4786, 0x384f25e3, + 0x0fc19dc6, 0x8b8cd5b5, 0x240ca1cc, 0x77ac9c65, + 0x2de92c6f, 0x592b0275, 0x4a7484aa, 0x6ea6e483, + 0x5cb0a9dc, 0xbd41fbd4, 0x76f988da, 0x831153b5, + 0x983e5152, 0xee66dfab, 0xa831c66d, 0x2db43210, + 0xb00327c8, 0x98fb213f, 0xbf597fc7, 0xbeef0ee4, + 0xc6e00bf3, 0x3da88fc2, 0xd5a79147, 0x930aa725, + 0x06ca6351, 0xe003826f, 0x14292967, 0x0a0e6e70, + 0x27b70a85, 0x46d22ffc, 0x2e1b2138, 0x5c26c926, + 0x4d2c6dfc, 0x5ac42aed, 0x53380d13, 0x9d95b3df, + 0x650a7354, 0x8baf63de, 0x766a0abb, 0x3c77b2a8, + 0x81c2c92e, 0x47edaee6, 0x92722c85, 0x1482353b, + 0xa2bfe8a1, 0x4cf10364, 0xa81a664b, 0xbc423001, + 0xc24b8b70, 0xd0f89791, 0xc76c51a3, 0x0654be30, + 0xd192e819, 0xd6ef5218, 0xd6990624, 0x5565a910, + 0xf40e3585, 0x5771202a, 0x106aa070, 0x32bbd1b8, + 0x19a4c116, 0xb8d2d0c8, 0x1e376c08, 0x5141ab53, + 0x2748774c, 0xdf8eeb99, 0x34b0bcb5, 0xe19b48a8, + 0x391c0cb3, 0xc5c95a63, 0x4ed8aa4a, 0xe3418acb, + 0x5b9cca4f, 0x7763e373, 0x682e6ff3, 0xd6b2b8a3, + 0x748f82ee, 0x5defb2fc, 0x78a5636f, 0x43172f60, + 0x84c87814, 0xa1f0ab72, 0x8cc70208, 0x1a6439ec, + 0x90befffa, 0x23631e28, 0xa4506ceb, 0xde82bde9, + 0xbef9a3f7, 0xb2c67915, 0xc67178f2, 0xe372532b, + 0xca273ece, 0xea26619c, 0xd186b8c7, 0x21c0c207, + 0xeada7dd6, 0xcde0eb1e, 0xf57d4f7f, 0xee6ed178, + 0x06f067aa, 0x72176fba, 0x0a637dc5, 0xa2c898a6, + 0x113f9804, 0xbef90dae, 0x1b710b35, 0x131c471b, + 0x28db77f5, 0x23047d84, 0x32caab7b, 0x40c72493, + 0x3c9ebe0a, 0x15c9bebc, 0x431d67c4, 0x9c100d4c, + 0x4cc5d4be, 0xcb3e42b6, 0x597f299c, 0xfc657e2a, + 0x5fcb6fab, 0x3ad6faec, 0x6c44198c, 0x4a475817, +] + +/** + * Converts a 32-bit integer to a zero-padded 8-character hex string. + * + * @param {number} value - the 32-bit integer value + */ +function toHex32(value: number): string { + return `00000000${(value >>> 0).toString(16)}`.slice(-8) +} + +/** + * Calculates the SHA-512 checksum of the passed bytes. The implementation is a plain (pure JS) + * one on purpose. It does not depend on the Web Crypto API (`crypto.subtle`), which browsers + * expose only on secure origins (https). Thanks to that, the checksum can be verified on plain + * http:// pages, for example, intranets of big companies. + * + * A second reason applies on HyperFormula's side: `crypto.subtle.digest` is asynchronous, and + * the license key is read from `Config`'s constructor, which is not. + * + * @param {number[] | Uint8Array} bytes - the bytes to calculate the checksum from + */ +export function sha512(bytes: number[] | Uint8Array): string { + const byteLength = bytes.length + // The message is padded with the 0x80 byte, zeros, and the 128-bit big-endian bit length so + // the total length is a multiple of 128 bytes. + const blockCount = Math.ceil((byteLength + 17) / 128) + const buffer = new Uint8Array(blockCount * 128) + + buffer.set(bytes) + buffer[byteLength] = 0x80 + + const bitLength = byteLength * 8 + const bufferLength = buffer.length + + // The supported message sizes fit well within 2^53 bits, so only the two lowest 32-bit words + // of the 128-bit length field are ever non-zero. + buffer[bufferLength - 7] = Math.floor(bitLength / 0x1000000000000) & 0xff // bits 48-55 + buffer[bufferLength - 6] = Math.floor(bitLength / 0x10000000000) & 0xff // bits 40-47 + buffer[bufferLength - 5] = Math.floor(bitLength / 0x100000000) & 0xff // bits 32-39 + buffer[bufferLength - 4] = (bitLength >>> 24) & 0xff // bits 24-31 + buffer[bufferLength - 3] = (bitLength >>> 16) & 0xff // bits 16-23 + buffer[bufferLength - 2] = (bitLength >>> 8) & 0xff // bits 8-15 + buffer[bufferLength - 1] = bitLength & 0xff // bits 0-7 + + // The initial hash values, stored as [high, low] 32-bit pairs. + const H: number[] = [ + 0x6a09e667, 0xf3bcc908, 0xbb67ae85, 0x84caa73b, + 0x3c6ef372, 0xfe94f82b, 0xa54ff53a, 0x5f1d36f1, + 0x510e527f, 0xade682d1, 0x9b05688c, 0x2b3e6c1f, + 0x1f83d9ab, 0xfb41bd6b, 0x5be0cd19, 0x137e2179, + ] + const wh = new Array(80) + const wl = new Array(80) + + for (let block = 0; block < blockCount; block += 1) { + const offset = block * 128 + + // Prepare the message schedule. + for (let i = 0; i < 16; i += 1) { + const o = offset + i * 8 + + wh[i] = ((buffer[o] << 24) | (buffer[o + 1] << 16) | (buffer[o + 2] << 8) | buffer[o + 3]) >>> 0 + wl[i] = ((buffer[o + 4] << 24) | (buffer[o + 5] << 16) | (buffer[o + 6] << 8) | buffer[o + 7]) >>> 0 + } + + for (let i = 16; i < 80; i += 1) { + const x2h = wh[i - 2] + const x2l = wl[i - 2] + const x15h = wh[i - 15] + const x15l = wl[i - 15] + // smallSigma1 = ROTR^19(x) XOR ROTR^61(x) XOR SHR^6(x) + const s1h = ((x2h >>> 19) | (x2l << 13)) ^ ((x2l >>> 29) | (x2h << 3)) ^ (x2h >>> 6) + const s1l = ((x2l >>> 19) | (x2h << 13)) ^ ((x2h >>> 29) | (x2l << 3)) ^ ((x2l >>> 6) | (x2h << 26)) + // smallSigma0 = ROTR^1(x) XOR ROTR^8(x) XOR SHR^7(x) + const s0h = ((x15h >>> 1) | (x15l << 31)) ^ ((x15h >>> 8) | (x15l << 24)) ^ (x15h >>> 7) + const s0l = ((x15l >>> 1) | (x15h << 31)) ^ ((x15l >>> 8) | (x15h << 24)) ^ ((x15l >>> 7) | (x15h << 25)) + + const lowSum = (s1l >>> 0) + (wl[i - 7] >>> 0) + (s0l >>> 0) + (wl[i - 16] >>> 0) + + wl[i] = lowSum >>> 0 + wh[i] = ((s1h >>> 0) + (wh[i - 7] >>> 0) + (s0h >>> 0) + (wh[i - 16] >>> 0) + + Math.floor(lowSum / 0x100000000)) >>> 0 + } + + let ah = H[0] + let al = H[1] + let bh = H[2] + let bl = H[3] + let ch = H[4] + let cl = H[5] + let dh = H[6] + let dl = H[7] + let eh = H[8] + let el = H[9] + let fh = H[10] + let fl = H[11] + let gh = H[12] + let gl = H[13] + let hh = H[14] + let hl = H[15] + + for (let i = 0; i < 80; i += 1) { + // bigSigma1 = ROTR^14(e) XOR ROTR^18(e) XOR ROTR^41(e) + const bs1h = ((eh >>> 14) | (el << 18)) ^ ((eh >>> 18) | (el << 14)) ^ ((el >>> 9) | (eh << 23)) + const bs1l = ((el >>> 14) | (eh << 18)) ^ ((el >>> 18) | (eh << 14)) ^ ((eh >>> 9) | (el << 23)) + // bigSigma0 = ROTR^28(a) XOR ROTR^34(a) XOR ROTR^39(a) + const bs0h = ((ah >>> 28) | (al << 4)) ^ ((al >>> 2) | (ah << 30)) ^ ((al >>> 7) | (ah << 25)) + const bs0l = ((al >>> 28) | (ah << 4)) ^ ((ah >>> 2) | (al << 30)) ^ ((ah >>> 7) | (al << 25)) + // ch = (e AND f) XOR (NOT e AND g) + const chh = (eh & fh) ^ (~eh & gh) + const chl = (el & fl) ^ (~el & gl) + // maj = (a AND b) XOR (a AND c) XOR (b AND c) + const majh = (ah & bh) ^ (ah & ch) ^ (bh & ch) + const majl = (al & bl) ^ (al & cl) ^ (bl & cl) + + const t1LowSum = (hl >>> 0) + (bs1l >>> 0) + (chl >>> 0) + (K[i * 2 + 1] >>> 0) + (wl[i] >>> 0) + const t1l = t1LowSum >>> 0 + const t1h = ((hh >>> 0) + (bs1h >>> 0) + (chh >>> 0) + (K[i * 2] >>> 0) + + (wh[i] >>> 0) + Math.floor(t1LowSum / 0x100000000)) >>> 0 + + const t2LowSum = (bs0l >>> 0) + (majl >>> 0) + const t2l = t2LowSum >>> 0 + const t2h = ((bs0h >>> 0) + (majh >>> 0) + Math.floor(t2LowSum / 0x100000000)) >>> 0 + + hh = gh + hl = gl + gh = fh + gl = fl + fh = eh + fl = el + + const eLowSum = (dl >>> 0) + t1l + + el = eLowSum >>> 0 + eh = ((dh >>> 0) + t1h + Math.floor(eLowSum / 0x100000000)) >>> 0 + + dh = ch + dl = cl + ch = bh + cl = bl + bh = ah + bl = al + + const aLowSum = t1l + t2l + + al = aLowSum >>> 0 + ah = (t1h + t2h + Math.floor(aLowSum / 0x100000000)) >>> 0 + } + + const stateWords = [ah, al, bh, bl, ch, cl, dh, dl, eh, el, fh, fl, gh, gl, hh, hl] + + for (let i = 0; i < 16; i += 2) { + const stateLowSum = (H[i + 1] >>> 0) + (stateWords[i + 1] >>> 0) + + H[i + 1] = stateLowSum >>> 0 + H[i] = ((H[i] >>> 0) + (stateWords[i] >>> 0) + Math.floor(stateLowSum / 0x100000000)) >>> 0 + } + } + + return H.map(toHex32).join('') +} diff --git a/src/license/vendor/utils.ts b/src/license/vendor/utils.ts new file mode 100644 index 000000000..71e16285a --- /dev/null +++ b/src/license/vendor/utils.ts @@ -0,0 +1,218 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/typed-key/utils.js`. + * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + * + * The two generation-side helpers of the upstream file (`bytesToBase64`, `stringToBase64Url`) are + * deliberately not ported: HyperFormula reads keys, it never mints them. + */ + +/** + * The base64 alphabet. + */ +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +/** + * Recursively freezes the value (and every nested object). Used to make a verified schema + * immutable so it cannot drift from what was validated. + * + * @param {*} value - the value to freeze + */ +export function deepFreeze(value: T): T { + if (value !== null && typeof value === 'object') { + Object.keys(value as unknown as Record).forEach( + (key) => deepFreeze((value as unknown as Record)[key]) + ) + Object.freeze(value) + } + + return value +} + +/** + * A calendar date decomposed into its numeric parts plus the epoch milliseconds of its UTC + * midnight. + */ +export interface ParsedIsoDate { + year: number, + month: number, + day: number, + timestamp: number, +} + +/** + * Parses the date in the `YYYY-MM-DD` format into its numeric parts and the epoch milliseconds + * of its UTC midnight. Throws when the date is malformed or does not exist in the calendar. + * + * @param {string} isoDate - the date to parse + * @param {string} dateLabel - the date name used in the error message + */ +export function parseIsoDate(isoDate: string, dateLabel: string): ParsedIsoDate { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(`${isoDate}`) + + if (match === null) { + throw new Error(`The ${dateLabel} date (${isoDate}) has to be passed in the "YYYY-MM-DD" format.`) + } + + const year = parseInt(match[1], 10) + const month = parseInt(match[2], 10) + const day = parseInt(match[3], 10) + + // Date.UTC maps years 0-99 to 1900-1999, which would make the round-trip check below report a + // "not a valid calendar date" lie. + if (year < 100) { + throw new Error(`The ${dateLabel} date (${isoDate}) has to use a four-digit year of 100 or later.`) + } + + const timestamp = Date.UTC(year, month - 1, day) + const date = new Date(timestamp) + + // An impossible date (e.g. "2027-02-30") makes `Date.UTC` roll over to the next month, so a + // round-trip comparison catches it. + if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) { + throw new Error(`The ${dateLabel} date (${isoDate}) is not a valid calendar date.`) + } + + return { + year, month, day, timestamp, + } +} + +/** + * Encodes the string as UTF-8 bytes. The plain implementation is used on purpose. It does not + * depend on `TextEncoder` or `Buffer`, so the same code works in Node.js and in every browser, + * including plain http:// pages. + * + * @param {string} text - the string to encode + */ +export function stringToUtf8Bytes(text: string): number[] { + const bytes: number[] = [] + + for (let i = 0; i < text.length; i += 1) { + let codePoint = text.charCodeAt(i) + + // Combine a surrogate pair into a single code point. + if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < text.length) { + const lowSurrogate = text.charCodeAt(i + 1) + + if (lowSurrogate >= 0xdc00 && lowSurrogate <= 0xdfff) { + codePoint = ((codePoint - 0xd800) * 0x400) + (lowSurrogate - 0xdc00) + 0x10000 + i += 1 + } + } + + if (codePoint < 0x80) { + bytes.push(codePoint) + } else if (codePoint < 0x800) { + bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f)) + } else if (codePoint < 0x10000) { + bytes.push( + 0xe0 | (codePoint >> 12), + 0x80 | ((codePoint >> 6) & 0x3f), + 0x80 | (codePoint & 0x3f), + ) + } else { + bytes.push( + 0xf0 | (codePoint >> 18), + 0x80 | ((codePoint >> 12) & 0x3f), + 0x80 | ((codePoint >> 6) & 0x3f), + 0x80 | (codePoint & 0x3f), + ) + } + } + + return bytes +} + +/** + * Decodes UTF-8 bytes back into a string. + * + * @param {number[]} bytes - the bytes to decode + */ +export function utf8BytesToString(bytes: number[]): string { + let text = '' + let i = 0 + + while (i < bytes.length) { + const byte = bytes[i] + let codePoint + + if (byte < 0x80) { + codePoint = byte + i += 1 + } else if (byte < 0xe0) { + codePoint = ((byte & 0x1f) << 6) | (bytes[i + 1] & 0x3f) + i += 2 + } else if (byte < 0xf0) { + codePoint = ((byte & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f) + i += 3 + } else { + codePoint = ((byte & 0x07) << 18) | ((bytes[i + 1] & 0x3f) << 12) + | ((bytes[i + 2] & 0x3f) << 6) | (bytes[i + 3] & 0x3f) + i += 4 + } + + if (codePoint >= 0x10000) { + // Split the code point back into a surrogate pair. + codePoint -= 0x10000 + text += String.fromCharCode(0xd800 + (codePoint >> 10), 0xdc00 + (codePoint & 0x3ff)) + } else { + text += String.fromCharCode(codePoint) + } + } + + return text +} + +/** + * Decodes a base64 string (standard or URL-safe alphabet, padding optional) back into bytes. + * Returns `null` when the string is not valid base64. + * + * @param {string} base64 - the base64 string to decode + */ +export function base64ToBytes(base64: string): number[] | null { + const normalized = `${base64}`.replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '') + + if (!/^[A-Za-z0-9+/]*$/.test(normalized) || normalized.length % 4 === 1) { + return null + } + + const bytes: number[] = [] + + for (let i = 0; i < normalized.length; i += 4) { + const chunk = [0, 1, 2, 3].map((offset) => { + const char = normalized.charAt(i + offset) + + // `indexOf('')` would return 0, so the missing characters of the last chunk have to be + // mapped to -1 explicitly. + return char === '' ? -1 : BASE64_ALPHABET.indexOf(char) + }) + + bytes.push((chunk[0] << 2) | (chunk[1] >> 4)) + + if (chunk[2] !== -1) { + bytes.push(((chunk[1] & 0x0f) << 4) | (chunk[2] >> 2)) + } + if (chunk[3] !== -1) { + bytes.push(((chunk[2] & 0x03) << 6) | chunk[3]) + } + } + + return bytes +} + +/** + * Decodes a base64 (standard or URL-safe) string back into a string. Returns `null` when the + * input is not valid base64. + * + * @param {string} base64 - the base64 string to decode + */ +export function base64ToString(base64: string): string | null { + const bytes = base64ToBytes(base64) + + return bytes === null ? null : utf8BytesToString(bytes) +} From 060b1fff30c1f7ecc9c683d9f31bf19c88f1144b Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 11 Aug 2026 18:17:31 +0000 Subject: [PATCH 2/9] HF-307 PR 3: resolve typed license keys into both gates 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 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/Config.ts | 12 +- src/helpers/licenseKeyValidator.ts | 33 ++++- src/license/licenseResolution.ts | 220 +++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 15 deletions(-) create mode 100644 src/license/licenseResolution.ts diff --git a/src/Config.ts b/src/Config.ts index 2eae9aa60..999be0555 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -16,12 +16,12 @@ import {DateTime, instanceOfSimpleDate, SimpleDate, SimpleDateTime, SimpleTime} import {AlwaysDense, ChooseAddressMapping} from './DependencyGraph/AddressMapping/ChooseAddressMappingPolicy' import {ConfigValueEmpty, ExpectedValueOfTypeError} from './errors' import {defaultStringifyCurrency, defaultStringifyDateTime, defaultStringifyDuration} from './format/format' -import {checkLicenseKeyValidity, LicenseKeyValidityState} from './helpers/licenseKeyValidator' +import {LicenseKeyValidityState} from './helpers/licenseKeyValidator' import {HyperFormula} from './HyperFormula' import {TranslationPackage} from './i18n' import {FunctionPluginDefinition} from './interpreter' import {CapabilityRegistry, ResolvedCapabilities} from './license/CapabilityRegistry' -import {unrestrictedEntitlement} from './license/LicenseEntitlement' +import {resolveLicense} from './license/licenseResolution' import {Maybe} from './Maybe' import {ParserConfig} from './parser/ParserConfig' import {ConfigParams, ConfigParamsList} from './ConfigParams' @@ -279,13 +279,9 @@ export class Config implements ConfigParams, ParserConfig { validateNumberToBeAtLeast(this.maxColumns, 'maxColumns', 1) this.context = context - const licenseKeyValidityState = checkLicenseKeyValidity(this.licenseKey) + const {validityState: licenseKeyValidityState, entitlement} = resolveLicense(this.licenseKey) const capabilityRegistry = new CapabilityRegistry() - // PR 1 (HF-307) ships the gate infrastructure without a real license-key payload adapter — - // that lands in PR 3 as src/license/payloadAdapter.ts. Until then every entitlement resolves - // as unrestricted, so isLicenseGateActive below reduces to today's licenseKeyValidityState - // check and gate B in the interpreter never actually restricts a function. - const licenseCapabilities = capabilityRegistry.resolve(unrestrictedEntitlement()) + const licenseCapabilities = capabilityRegistry.resolve(entitlement) privatePool.set(this, { licenseKeyValidityState, diff --git a/src/helpers/licenseKeyValidator.ts b/src/helpers/licenseKeyValidator.ts index 72ae00324..4a7454067 100644 --- a/src/helpers/licenseKeyValidator.ts +++ b/src/helpers/licenseKeyValidator.ts @@ -27,7 +27,7 @@ type ConsoleMessages = { type MessageDescriptor = { template: LicenseKeyValidityState, - vars: TemplateVars, + expiryDate?: Date, } /** @@ -42,6 +42,29 @@ const consoleMessages: ConsoleMessages = { let _notified = false +/** + * Prints the console message for a non-valid license key state, at most once per page load. + * + * Extracted so the typed-key path in `src/license/licenseResolution.ts` reports the same states + * with the same wording and the same once-only behaviour, without duplicating the message table + * or getting a second `_notified` flag of its own — two flags would let a page print two + * warnings for one key. + * + * @param {LicenseKeyValidityState} state - the state to report; `VALID` prints nothing + * @param {Date} [keyValidityDate] - the day the key stopped being valid, used by the `expired` + * message + */ +export function notifyLicenseKeyState(state: LicenseKeyValidityState, keyValidityDate?: Date): void { + if (_notified || state === LicenseKeyValidityState.VALID) { + return + } + + const vars: TemplateVars = keyValidityDate === undefined ? {} : {keyValidityDate: formatDate(keyValidityDate)} + + console.warn(consoleMessages[state](vars)) + _notified = true +} + /** * Checks if the provided license key is grammatically valid or not expired. * @@ -51,7 +74,6 @@ let _notified = false export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityState { const messageDescriptor: MessageDescriptor = { template: LicenseKeyValidityState.MISSING, - vars: {}, } if (licenseKey === 'gpl-v3' || licenseKey === 'internal-use-in-handsontable' || licenseKey === 'hftrial-0168e-1f2b7-47158-70b05-0842f') { @@ -62,7 +84,7 @@ export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityS const releaseDays = Math.floor(new Date(`${month}/${day}/${year}`).getTime() / 8.64e7) const keyValidityDays = extractTime(licenseKey) - messageDescriptor.vars.keyValidityDate = formatDate(new Date((keyValidityDays + 1) * 8.64e7)) + messageDescriptor.expiryDate = new Date((keyValidityDays + 1) * 8.64e7) if (releaseDays > keyValidityDays) { messageDescriptor.template = LicenseKeyValidityState.EXPIRED @@ -74,10 +96,7 @@ export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityS messageDescriptor.template = LicenseKeyValidityState.INVALID } - if (!_notified && messageDescriptor.template !== LicenseKeyValidityState.VALID) { - console.warn(consoleMessages[messageDescriptor.template](messageDescriptor.vars)) - _notified = true - } + notifyLicenseKeyState(messageDescriptor.template, messageDescriptor.expiryDate) return messageDescriptor.template } diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts new file mode 100644 index 000000000..ac756ef01 --- /dev/null +++ b/src/license/licenseResolution.ts @@ -0,0 +1,220 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +import { + checkLicenseKeyValidity, + LicenseKeyValidityState, + notifyLicenseKeyState, +} from '../helpers/licenseKeyValidator' +import {CAPABILITY_TABLE, CORE_TOKEN} from './capabilities' +import {LicenseEntitlement, LicenseExpiry, unrestrictedEntitlement} from './LicenseEntitlement' +import {HYPERFORMULA_PRODUCT_NAME} from './vendor/defaultSchema' +import {extractTypedKeyData, TypedKeyData, TypedKeyProductGrant} from './vendor/extractKeyData' + +/** Milliseconds in a day, used to turn the payload's grace period into a deadline. */ +const MILLISECONDS_PER_DAY = 86400000 + +/** + * Both halves of the license decision, resolved from one reading of the key. + * + * They are deliberately produced together: the two gates ask different questions of the same + * string, and parsing it twice would let them disagree about what it says. + */ +export interface ResolvedLicense { + /** Gate A — may this instance evaluate formulas at all. */ + validityState: LicenseKeyValidityState, + /** Gate B — which functions and API features the key grants. */ + entitlement: LicenseEntitlement, +} + +/** + * The build's release date as epoch milliseconds (UTC midnight), or `null` when it is unknown or + * malformed. + * + * Read from the same `HT_RELEASE_DATE` (`DD/MM/YYYY`) the legacy validator uses, so a perpetual + * typed key and a legacy key agree on what "this build" means. + */ +function releaseDateTimestamp(): number | null { + const [day, month, year] = (process.env.HT_RELEASE_DATE ?? '').split('/') + const timestamp = Date.UTC(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10)) + + return isNaN(timestamp) ? null : timestamp +} + +/** + * The grace period of the licensed product, in days; `0` when the payload does not carry one or + * carries something that is not a non-negative integer. + * + * Read from the LICENSED product rather than from HyperFormula's own entry, because that is the + * only entry allowed to carry `exp` and `grace` — for a key granting both products, both live on + * the Handsontable entry. + * + * @param {TypedKeyData} data - the extracted key data + */ +function graceDaysOf(data: TypedKeyData): number { + const {grace} = data.payload.products[data.licensedProductName] + + return typeof grace === 'number' && isFinite(grace) && grace >= 0 ? Math.floor(grace) : 0 +} + +/** + * Whether an intact typed key is still valid, and if not, the day it stopped being valid. + * + * The comparison depends on the key type, per the format's own rules: + * - `freemium` never expires; + * - `trial` and `subscription` have a hard stop: they keep working for `grace` days past the + * expiration date, compared against the current time; + * - `perpetual` has no grace period, and its maintenance end date is compared against the + * build's release date rather than the clock, so an air-gapped install with a wrong system + * clock is not affected. + * + * An unknown release date resolves to "not expired", matching what the legacy validator already + * does when `HT_RELEASE_DATE` is missing: a build that cannot tell its own age must not start + * rejecting keys that customers paid for. + * + * @param {TypedKeyData} data - the extracted key data + */ +function typedKeyValidity(data: TypedKeyData): {state: LicenseKeyValidityState, expiredOn?: Date} { + if (data.expiryTimestamp === null) { + return {state: LicenseKeyValidityState.VALID} + } + + const isPerpetual = data.keyType === 'perpetual' + const now = isPerpetual ? releaseDateTimestamp() : Date.now() + + if (now === null) { + return {state: LicenseKeyValidityState.VALID} + } + + // The expiration date is INCLUSIVE of its last valid day, and a hard stop extends it by the + // grace period; a perpetual key has no grace period. + const deadline = data.expiryTimestamp + MILLISECONDS_PER_DAY + + (isPerpetual ? 0 : graceDaysOf(data) * MILLISECONDS_PER_DAY) + + return now < deadline + ? {state: LicenseKeyValidityState.VALID} + : {state: LicenseKeyValidityState.EXPIRED, expiredOn: new Date(data.expiryTimestamp)} +} + +/** + * The expiry descriptor of an entitlement built from an intact typed key. + * + * `noticeDays` is `0` because the typed key format carries no notice period — there is no + * `notice` field in the payload, and inventing a default here would put a product decision in + * the parser. If pre-expiry warnings are wanted, the number belongs in the payload schema (which + * marketing owns) or in an explicit HyperFormula constant, not in this function. + * + * @param {TypedKeyData} data - the extracted key data + */ +function expiryOf(data: TypedKeyData): LicenseExpiry { + if (data.expiryTimestamp === null) { + return {kind: 'none', date: null, noticeDays: 0, graceDays: 0} + } + + return { + kind: data.keyType === 'perpetual' ? 'release' : 'usage', + // `expiryTimestamp` is UTC midnight by construction, so this round-trips the payload's own + // `YYYY-MM-DD` exactly. + date: new Date(data.expiryTimestamp).toISOString().slice(0, 10), + noticeDays: 0, + graceDays: data.keyType === 'perpetual' ? 0 : graceDaysOf(data), + } +} + +/** + * The capability tokens HyperFormula's own entry of the payload grants: its tier plus its + * add-ons, both of which are only honoured when they are non-empty strings. + * + * {@link CORE_TOKEN} is always included. Without it a key whose tier this version does not + * recognize would block every built-in function, and HF-307 decision D3 says such a key falls + * back to core and protected functions — not to nothing. + * + * @param {TypedKeyProductGrant | undefined} grant - HyperFormula's entry, absent for a key that + * licenses another product only + */ +function capabilityTokensOf(grant: TypedKeyProductGrant | undefined): string[] { + const tokens = [CORE_TOKEN] + + if (grant === undefined) { + return tokens + } + if (typeof grant.tier === 'string' && grant.tier.length > 0) { + tokens.push(grant.tier) + } + if (Array.isArray(grant.addons)) { + grant.addons.forEach((addon) => { + if (typeof addon === 'string' && addon.length > 0) { + tokens.push(addon) + } + }) + } + + return tokens +} + +/** + * Turns an intact typed key into the entitlement it grants. + * + * Per HF-307 decision D3 this is fail-closed and silent: a token this version does not recognize + * is recorded in `unrecognizedCapabilities` and grants nothing, without a warning, a message, or + * anything public to read it back from. + * + * @param {TypedKeyData} data - the extracted key data + */ +function entitlementFromTypedKey(data: TypedKeyData): LicenseEntitlement { + const tokens = capabilityTokensOf(data.payload.products[HYPERFORMULA_PRODUCT_NAME]) + const unrecognizedCapabilities = tokens.filter((token) => !CAPABILITY_TABLE.has(token)) + + return { + unrestricted: false, + capabilities: new Set(tokens), + unrecognizedCapabilities, + expiry: expiryOf(data), + silent: unrecognizedCapabilities.length > 0, + isTrial: data.keyType === 'trial', + } +} + +/** + * Resolves a license key into both gates' inputs. + * + * A typed key is recognized first; anything else — `gpl-v3`, a legacy key, an empty string, or + * a malformed typed key — falls through to {@link checkLicenseKeyValidity} completely unchanged, + * which is what keeps this from touching existing behaviour. + * + * **The invariant this function exists to protect.** Only a VALID typed key resolves to a + * restricted entitlement. Every other outcome — missing, invalid, or expired, for a typed key as + * much as for a legacy one — resolves to {@link unrestrictedEntitlement}. That asymmetry is + * deliberate and load-bearing: gate A already stops formula evaluation on its own (a bad key + * yields `#LIC!` in cells), while gate B additionally makes PR 2's `ensureCapability` throw from + * the CRUD API. Letting a bad key restrict the entitlement would turn today's "formulas fail, + * the API still works" into "the API throws", which is a silent breaking change for every + * existing user whose key lapsed. D3's fail-closed rule governs unrecognized tokens INSIDE an + * otherwise valid key; it is not a rule about invalid keys, and conflating the two is exactly + * the mistake this comment is here to prevent. + * + * @param {string} licenseKey - the raw `licenseKey` config value + */ +export function resolveLicense(licenseKey: string): ResolvedLicense { + const typedKeyData = extractTypedKeyData(licenseKey) + + if (typedKeyData === null) { + return { + validityState: checkLicenseKeyValidity(licenseKey), + entitlement: unrestrictedEntitlement(), + } + } + + const {state, expiredOn} = typedKeyValidity(typedKeyData) + + notifyLicenseKeyState(state, expiredOn) + + return { + validityState: state, + entitlement: state === LicenseKeyValidityState.VALID + ? entitlementFromTypedKey(typedKeyData) + : unrestrictedEntitlement(), + } +} From b60b5d936327d7a328c30b323a9cfa727e762607 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Tue, 11 Aug 2026 18:17:59 +0000 Subject: [PATCH 3/9] HF-307 PR 3: real capability table, and read both payload shapes 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 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/license/CapabilityRegistry.ts | 4 +- src/license/capabilities.ts | 151 +++++++++++++--- src/license/licenseResolution.ts | 274 ++++++++++++++++++++---------- 3 files changed, 311 insertions(+), 118 deletions(-) diff --git a/src/license/CapabilityRegistry.ts b/src/license/CapabilityRegistry.ts index 61462c82e..e631fc274 100644 --- a/src/license/CapabilityRegistry.ts +++ b/src/license/CapabilityRegistry.ts @@ -4,7 +4,7 @@ */ import {FeatureId, LicenseEntitlement} from './LicenseEntitlement' -import {CAPABILITY_TABLE, CapabilityGrant, refreshCoreGrant} from './capabilities' +import {CAPABILITY_TABLE, CapabilityGrant, refreshDynamicGrants} from './capabilities' /** * The capabilities a resolved {@link LicenseEntitlement} grants, ready for gate B (the @@ -34,7 +34,7 @@ export class CapabilityRegistry { */ constructor(table?: ReadonlyMap) { if (table === undefined) { - refreshCoreGrant() + refreshDynamicGrants() } this.table = table ?? CAPABILITY_TABLE this.reverseIndex = CapabilityRegistry.buildReverseIndex(this.table) diff --git a/src/license/capabilities.ts b/src/license/capabilities.ts index 858433607..34bb569ab 100644 --- a/src/license/capabilities.ts +++ b/src/license/capabilities.ts @@ -6,9 +6,27 @@ import {FunctionRegistry} from '../interpreter/FunctionRegistry' import {FeatureId} from './LicenseEntitlement' -/** The single capability token every built-in currently falls under, see {@link CAPABILITY_TABLE}. */ +/** + * The always-granted token. Every entitlement built from a license key includes it. + * + * It grants the calculation operators and the whole gated public API surface — see + * {@link CORE_FEATURES} for why the features live here rather than on a package. + */ export const CORE_TOKEN = 'core' +/** Math engine package — the free tier's function set. */ +export const FUNCTIONS_1_TOKEN = 'functions_1' +/** Calculated fields package. Cumulative: includes {@link FUNCTIONS_1_TOKEN}'s functions. */ +export const FUNCTIONS_2_TOKEN = 'functions_2' +/** Spreadsheet package. Cumulative: includes {@link FUNCTIONS_2_TOKEN}'s functions. */ +export const FUNCTIONS_3_TOKEN = 'functions_3' +/** Excel simulator package — the entire implemented catalog. */ +export const FUNCTIONS_4_TOKEN = 'functions_4' +/** Spreadsheet add-on. Reserved: recognized, grants nothing yet — see {@link CAPABILITY_TABLE}. */ +export const SPREADSHEET_ADDON_TOKEN = 'spreadsheet' +/** Import/export add-on. Reserved until HF-107 ships the feature it would gate. */ +export const IMPORT_EXPORT_ADDON_TOKEN = 'import_export' + /** * Describes what a capability token grants: a set of function ids, a set of {@link FeatureId} * values, and optionally other tokens it implies. `implies` is expanded recursively by @@ -20,37 +38,122 @@ export interface CapabilityGrant { implies?: string[], } -const coreGrant: CapabilityGrant = { - functions: [], - features: [FeatureId.NamedExpressions, FeatureId.Clipboard, FeatureId.Crud, FeatureId.UndoRedo, FeatureId.Batching], +/** + * The calculation operators and their `HF.*` callable forms. Always available in every package — + * the packaging design counts them as engine baseline and advertises them separately from the + * function totals, so they are granted by {@link CORE_TOKEN} rather than by any package. + */ +const OPERATOR_FUNCTIONS = [ + 'HF.ADD', 'HF.CONCAT', 'HF.DIVIDE', 'HF.EQ', 'HF.GT', 'HF.GTE', 'HF.LT', 'HF.LTE', 'HF.MINUS', + 'HF.MULTIPLY', 'HF.NE', 'HF.POW', 'HF.UMINUS', 'HF.UNARY_PERCENT', 'HF.UPLUS', +] + +/** + * The features {@link CORE_TOKEN} grants — which is all of them. + * + * No package restricts the public API surface in this draft, deliberately. The packaging + * evidence covers FUNCTIONS only; nothing has decided whether, say, undo/redo or the clipboard + * belongs to a paid tier. Restricting one here would both invent a product decision and make + * PR 2's `ensureCapability` start throwing from the CRUD API for real keys, so the conservative + * choice is to grant them all until that decision exists. + */ +const CORE_FEATURES = [ + FeatureId.NamedExpressions, FeatureId.Clipboard, FeatureId.Crud, FeatureId.UndoRedo, FeatureId.Batching, +] + +/** + * Package membership, as the LOWEST package that includes each function. + * + * Transcribed from the packaging design's own per-function evidence file. The lists reproduce + * that file's package counts exactly (17 / 51 / 127 / 355 cumulative, plus 15 operators), which + * is how the transcript was checked; `capability-table.spec.ts` pins those counts so a later + * edit cannot drift from them silently. + * + * Two functions of the evidence file are deliberately absent: `OFFSET` and `VERSION` are + * protected built-ins and sit OUTSIDE the token system — the interpreter never gate-checks a + * protected function, so listing them would be dead weight that implies a restriction that does + * not exist. + * + * **This membership is a DRAFT and is expected to change before release.** The packaging design + * it follows is still under review, with the free tier's exact contents and the placement of + * several function families among the points not yet settled. + */ +const MATH_ENGINE_FUNCTIONS = [ + 'ABS', 'CEILING', 'EXP', 'FLOOR', 'IF', 'LN', 'LOG', 'LOG10', 'MOD', 'POWER', 'PRODUCT', 'ROUND', + 'ROUNDDOWN', 'ROUNDUP', 'SQRT', 'SUM', +] + +/** Added by the calculated-fields package, on top of {@link MATH_ENGINE_FUNCTIONS}. */ +const CALCULATED_FIELDS_FUNCTIONS = [ + 'AND', 'AVERAGE', 'CONCATENATE', 'COUNT', 'DATE', 'DATEDIF', 'DAY', 'DAYS', 'FALSE', 'FIND', 'HOUR', + 'LEFT', 'LEN', 'LOWER', 'MAX', 'MID', 'MIN', 'MINUTE', 'MONTH', 'NOT', 'NOW', 'OR', 'RIGHT', 'SEARCH', + 'SECOND', 'TEXT', 'TEXTJOIN', 'TODAY', 'TRIM', 'TRUE', 'UPPER', 'VALUE', 'XOR', 'YEAR', +] + +/** Added by the spreadsheet package, on top of {@link CALCULATED_FIELDS_FUNCTIONS}. */ +const SPREADSHEET_FUNCTIONS = [ + 'ADDRESS', 'AVERAGEIF', 'CHOOSE', 'COLUMN', 'COLUMNS', 'COUNTIF', 'COUNTIFS', 'DATEVALUE', 'DAYS360', + 'EDATE', 'EOMONTH', 'FILTER', 'FORMULATEXT', 'FV', 'HLOOKUP', 'HYPERLINK', 'IFERROR', 'IFNA', 'INDEX', + 'INTERVAL', 'IPMT', 'IRR', 'ISBINARY', 'ISBLANK', 'ISERR', 'ISERROR', 'ISEVEN', 'ISFORMULA', 'ISLOGICAL', + 'ISNA', 'ISNONTEXT', 'ISNUMBER', 'ISODD', 'ISOWEEKNUM', 'ISREF', 'ISTEXT', 'MATCH', 'MAXIFS', 'MINIFS', + 'NA', 'NETWORKDAYS', 'NETWORKDAYS.INTL', 'NPER', 'NPV', 'PERCENTILE.EXC', 'PERCENTILE.INC', 'PMT', + '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', +] + +const coreGrant: CapabilityGrant = {functions: [...OPERATOR_FUNCTIONS], features: [...CORE_FEATURES]} +const functions1Grant: CapabilityGrant = {functions: [...MATH_ENGINE_FUNCTIONS], features: []} +const functions2Grant: CapabilityGrant = { + functions: [...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS], features: [], +} +const functions3Grant: CapabilityGrant = { + functions: [...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS, ...SPREADSHEET_FUNCTIONS], features: [], } +const functions4Grant: CapabilityGrant = {functions: [], features: []} /** * The production capability table. * - * Placeholder content pending HF-331/HF-329 (the real per-package token vocabulary): every - * built-in function, plus the features already wired for gating in PR 2, fall under the single - * {@link CORE_TOKEN}. `FeatureId.CustomFunctions` and `FeatureId.ImportExport` are deliberately - * absent — reserved vocabulary with no grant yet (HF-307 decision D1; HF-107 for ImportExport). + * The grants are stored FULLY EXPANDED rather than chained through `implies`: the packaging + * design states the enforcement layer must not assume a hierarchy between tokens, and that the + * commercial nesting is expressed by a bigger licence simply listing more functions. The + * cumulative spreads above keep the source DRY without putting that hierarchy into the runtime. + * + * `functions_4` (the entire catalog) is filled by {@link refreshDynamicGrants} instead of being + * listed, so it keeps covering functions added after this file was written. * - * `coreGrant.functions` starts empty and is refreshed on every {@link refreshCoreGrant} call - * rather than populated once here: `src/index.ts` registers HyperFormula's built-in plugins as a - * side effect of being imported, and it does so AFTER `Config` and `Interpreter` — and so this - * module — have already been fully evaluated. Reading the function registry at module-load time - * would capture an empty registry. + * The two add-on tokens are RESERVED: recognized, so an issued key carrying one is not reported + * as unrecognized, but granting nothing. `spreadsheet` has no agreed content yet — the packaging + * proposal names a *package* "Spreadsheet" and the pricing task names a "Spreadsheet Bundle" + * add-on, and it is not settled whether those are the same set; guessing would silently sell an + * empty add-on or duplicate a whole tier. `import_export` has nothing to grant until HF-107. */ -export const CAPABILITY_TABLE: ReadonlyMap = new Map([[CORE_TOKEN, coreGrant]]) +export const CAPABILITY_TABLE: ReadonlyMap = new Map([ + [CORE_TOKEN, coreGrant], + [FUNCTIONS_1_TOKEN, functions1Grant], + [FUNCTIONS_2_TOKEN, functions2Grant], + [FUNCTIONS_3_TOKEN, functions3Grant], + [FUNCTIONS_4_TOKEN, functions4Grant], + [SPREADSHEET_ADDON_TOKEN, {functions: [], features: []}], + [IMPORT_EXPORT_ADDON_TOKEN, {functions: [], features: []}], +]) /** - * Refreshes the placeholder `core` grant with every function currently in the static function - * registry. Called from `CapabilityRegistry`'s constructor every time it is constructed without - * an explicit table — not just the first time: the static registry can change after the first - * engine is built (`HyperFormula.registerFunctionPlugin`/`unregisterFunctionPlugin` are public, - * documented APIs), and a one-time snapshot would silently go stale for every engine built - * afterward. Cheap (a single array copy from an existing map's keys) and only ever runs once per - * `Config`/engine construction, never on the per-formula hot path, so re-running it every time - * costs nothing worth guarding against with memoization. + * Refreshes the grants that depend on what is currently registered — today only + * `functions_4`, the entire implemented catalog. + * + * Called from `CapabilityRegistry`'s constructor every time it is constructed without an explicit + * table, not just the first time: the static registry can change after the first engine is built + * (`HyperFormula.registerFunctionPlugin`/`unregisterFunctionPlugin` are public, documented APIs), + * and a one-time snapshot would silently go stale for every engine built afterwards. Cheap (a + * single array copy from an existing map's keys) and only ever runs once per `Config`/engine + * construction, never on the per-formula hot path. + * + * Reading the registry at module-load time instead would capture an empty one: `src/index.ts` + * registers the built-in plugins as a side effect of being imported, AFTER `Config` and + * `Interpreter` — and so this module — have been fully evaluated. */ -export function refreshCoreGrant(): void { - coreGrant.functions = FunctionRegistry.getRegisteredFunctionIds() +export function refreshDynamicGrants(): void { + functions4Grant.functions = FunctionRegistry.getRegisteredFunctionIds() } diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts index ac756ef01..4e65bd35d 100644 --- a/src/license/licenseResolution.ts +++ b/src/license/licenseResolution.ts @@ -8,14 +8,40 @@ import { LicenseKeyValidityState, notifyLicenseKeyState, } from '../helpers/licenseKeyValidator' -import {CAPABILITY_TABLE, CORE_TOKEN} from './capabilities' +import { + CAPABILITY_TABLE, + CORE_TOKEN, + FUNCTIONS_1_TOKEN, + FUNCTIONS_2_TOKEN, + FUNCTIONS_3_TOKEN, + FUNCTIONS_4_TOKEN, +} from './capabilities' import {LicenseEntitlement, LicenseExpiry, unrestrictedEntitlement} from './LicenseEntitlement' import {HYPERFORMULA_PRODUCT_NAME} from './vendor/defaultSchema' import {extractTypedKeyData, TypedKeyData, TypedKeyProductGrant} from './vendor/extractKeyData' -/** Milliseconds in a day, used to turn the payload's grace period into a deadline. */ +/** Milliseconds in a day, used to turn a grace period in days into a deadline. */ const MILLISECONDS_PER_DAY = 86400000 +/** + * Below this value a numeric timestamp is read as epoch SECONDS, above it as milliseconds. + * `1e11` seconds is year 5138, and `1e11` milliseconds is 1973 — no real license date is near + * either, so the split is unambiguous for anything a key can plausibly carry. + */ +const SECONDS_MILLISECONDS_THRESHOLD = 1e11 + +/** + * Commercial tier names (the shipped key format) mapped to the capability tokens the library + * actually resolves. A tier this map does not know is passed through unchanged, so it surfaces + * as an unrecognized capability rather than being silently swallowed. + */ +const TIER_TO_CAPABILITY_TOKEN: Record = { + freemium: FUNCTIONS_1_TOKEN, + crm: FUNCTIONS_2_TOKEN, + data_grid: FUNCTIONS_3_TOKEN, + excel_simulator: FUNCTIONS_4_TOKEN, +} + /** * Both halves of the license decision, resolved from one reading of the key. * @@ -29,6 +55,33 @@ export interface ResolvedLicense { entitlement: LicenseEntitlement, } +/** + * What HyperFormula needs from a typed key, after the two payload shapes have been reconciled. + * + * The engine reads TWO payload shapes on purpose: + * + * - the **shipped** shape of `handsontable/license-key` — `tier`, `addons`, `exp`, `grace`, with + * the contract type carried by the key's `[TRIAL]`/`[FREE]`/`[SUB]`/`[PERP]` tag; + * - the shape of key spec **rev 5** — `capabilities`, `usage_until` / `release_until`, `notice`, + * `grace`, `flags`, with no commercial vocabulary in the payload at all. + * + * The two disagree about nearly every field, rev 5 is still for review, and only the first can + * be minted today. Reading both means an already-issued key keeps working whichever way that + * disagreement is settled. Shape is detected per product entry, by the presence of + * `capabilities`, not guessed from the key type. + */ +interface LicenseTerms { + capabilityTokens: string[], + expiry: LicenseExpiry, + /** Epoch milliseconds of the last licensed day, or `null` when the key never expires. */ + expiryTimestamp: number | null, + /** `true` compares against the build release date, `false` against the clock. */ + comparedAgainstReleaseDate: boolean, + graceDays: number, + isTrial: boolean, + silent: boolean, +} + /** * The build's release date as epoch milliseconds (UTC midnight), or `null` when it is unknown or * malformed. @@ -44,136 +97,172 @@ function releaseDateTimestamp(): number | null { } /** - * The grace period of the licensed product, in days; `0` when the payload does not carry one or - * carries something that is not a non-negative integer. + * Reads a date that may arrive either as a `YYYY-MM-DD` string or as a numeric timestamp, and + * returns it as epoch milliseconds at UTC midnight; `null` when it is neither. * - * Read from the LICENSED product rather than from HyperFormula's own entry, because that is the - * only entry allowed to carry `exp` and `grace` — for a key granting both products, both live on - * the Handsontable entry. + * Both forms are accepted because key spec rev 5 contradicts itself about them: §1.2 mandates + * "bare `YYYY-MM-DD` everywhere, no time component", while §2.1 types the same fields as + * `timestamp` and its example payload carries integers. Accepting both costs a few lines and + * removes the need to have guessed right. * - * @param {TypedKeyData} data - the extracted key data + * @param {unknown} value - the raw payload value */ -function graceDaysOf(data: TypedKeyData): number { - const {grace} = data.payload.products[data.licensedProductName] +function readDate(value: unknown): number | null { + if (typeof value === 'string') { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) - return typeof grace === 'number' && isFinite(grace) && grace >= 0 ? Math.floor(grace) : 0 + if (match === null) { + return null + } + const timestamp = Date.UTC(parseInt(match[1], 10), parseInt(match[2], 10) - 1, parseInt(match[3], 10)) + + return isNaN(timestamp) ? null : timestamp + } + if (typeof value === 'number' && isFinite(value)) { + const milliseconds = Math.abs(value) < SECONDS_MILLISECONDS_THRESHOLD ? value * 1000 : value + + // Normalize to UTC midnight so an inclusive last-licensed-DAY stays a day, not an instant. + return Math.floor(milliseconds / MILLISECONDS_PER_DAY) * MILLISECONDS_PER_DAY + } + + return null } /** - * Whether an intact typed key is still valid, and if not, the day it stopped being valid. - * - * The comparison depends on the key type, per the format's own rules: - * - `freemium` never expires; - * - `trial` and `subscription` have a hard stop: they keep working for `grace` days past the - * expiration date, compared against the current time; - * - `perpetual` has no grace period, and its maintenance end date is compared against the - * build's release date rather than the clock, so an air-gapped install with a wrong system - * clock is not affected. + * A non-negative integer count of days from a payload field, or `0` when it is absent or not one. * - * An unknown release date resolves to "not expired", matching what the legacy validator already - * does when `HT_RELEASE_DATE` is missing: a build that cannot tell its own age must not start - * rejecting keys that customers paid for. - * - * @param {TypedKeyData} data - the extracted key data + * @param {unknown} value - the raw payload value */ -function typedKeyValidity(data: TypedKeyData): {state: LicenseKeyValidityState, expiredOn?: Date} { - if (data.expiryTimestamp === null) { - return {state: LicenseKeyValidityState.VALID} - } - - const isPerpetual = data.keyType === 'perpetual' - const now = isPerpetual ? releaseDateTimestamp() : Date.now() +function readDays(value: unknown): number { + return typeof value === 'number' && isFinite(value) && value >= 0 ? Math.floor(value) : 0 +} - if (now === null) { - return {state: LicenseKeyValidityState.VALID} +/** + * The strings of a payload array field, ignoring anything that is not a non-empty string. + * + * @param {unknown} value - the raw payload value + */ +function readStrings(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] } - // The expiration date is INCLUSIVE of its last valid day, and a hard stop extends it by the - // grace period; a perpetual key has no grace period. - const deadline = data.expiryTimestamp + MILLISECONDS_PER_DAY - + (isPerpetual ? 0 : graceDaysOf(data) * MILLISECONDS_PER_DAY) - - return now < deadline - ? {state: LicenseKeyValidityState.VALID} - : {state: LicenseKeyValidityState.EXPIRED, expiredOn: new Date(data.expiryTimestamp)} + return (value as unknown[]).filter((item): item is string => typeof item === 'string' && item.length > 0) } /** - * The expiry descriptor of an entitlement built from an intact typed key. + * Reconciles the two payload shapes into one set of terms. * - * `noticeDays` is `0` because the typed key format carries no notice period — there is no - * `notice` field in the payload, and inventing a default here would put a product decision in - * the parser. If pre-expiry warnings are wanted, the number belongs in the payload schema (which - * marketing owns) or in an explicit HyperFormula constant, not in this function. + * `hyperformulaGrant` may be `undefined` — a key that licenses Handsontable alone still parses, + * and HyperFormula simply gets nothing beyond {@link CORE_TOKEN} from it. * * @param {TypedKeyData} data - the extracted key data */ -function expiryOf(data: TypedKeyData): LicenseExpiry { - if (data.expiryTimestamp === null) { - return {kind: 'none', date: null, noticeDays: 0, graceDays: 0} +function licenseTermsOf(data: TypedKeyData): LicenseTerms { + const hyperformulaGrant: TypedKeyProductGrant | undefined = data.payload.products[HYPERFORMULA_PRODUCT_NAME] + const licensedGrant: TypedKeyProductGrant = data.payload.products[data.licensedProductName] + const isRev5 = Array.isArray((hyperformulaGrant as {capabilities?: unknown} | undefined)?.capabilities) + + // CORE_TOKEN is always granted. Without it a key whose tier this version does not recognize + // would block every built-in function, whereas HF-307 decision D3 says such a key falls back + // to core and protected functions - not to nothing at all. + const capabilityTokens = [CORE_TOKEN] + + if (hyperformulaGrant !== undefined) { + if (isRev5) { + capabilityTokens.push(...readStrings((hyperformulaGrant as {capabilities?: unknown}).capabilities)) + } else { + if (typeof hyperformulaGrant.tier === 'string' && hyperformulaGrant.tier.length > 0) { + capabilityTokens.push(TIER_TO_CAPABILITY_TOKEN[hyperformulaGrant.tier] ?? hyperformulaGrant.tier) + } + capabilityTokens.push(...readStrings(hyperformulaGrant.addons)) + } } + // Expiry, grace and notice belong to the LICENSED product - for a key granting both products + // that is Handsontable, and HyperFormula's own entry carries none of them. + const rev5Terms = licensedGrant as {usage_until?: unknown, release_until?: unknown, notice?: unknown} | undefined + const usageUntil = readDate(rev5Terms?.usage_until) + const releaseUntil = readDate(rev5Terms?.release_until) + const comparedAgainstReleaseDate = releaseUntil !== null || (usageUntil === null && data.keyType === 'perpetual') + const expiryTimestamp = usageUntil ?? releaseUntil ?? data.expiryTimestamp + const flags = readStrings((hyperformulaGrant as {flags?: unknown} | undefined)?.flags) + // A perpetual licence has no grace period: its date is compared against a static build date, + // so there is no window to be inside of. + const graceDays = comparedAgainstReleaseDate ? 0 : readDays(licensedGrant?.grace) + return { - kind: data.keyType === 'perpetual' ? 'release' : 'usage', - // `expiryTimestamp` is UTC midnight by construction, so this round-trips the payload's own - // `YYYY-MM-DD` exactly. - date: new Date(data.expiryTimestamp).toISOString().slice(0, 10), - noticeDays: 0, - graceDays: data.keyType === 'perpetual' ? 0 : graceDaysOf(data), + capabilityTokens, + expiry: expiryTimestamp === null + ? {kind: 'none', date: null, noticeDays: 0, graceDays: 0} + : { + kind: comparedAgainstReleaseDate ? 'release' : 'usage', + // UTC midnight by construction, so this round-trips a payload's own `YYYY-MM-DD` exactly. + date: new Date(expiryTimestamp).toISOString().slice(0, 10), + noticeDays: readDays(rev5Terms?.notice), + graceDays, + }, + expiryTimestamp, + comparedAgainstReleaseDate, + graceDays, + isTrial: data.keyType === 'trial' || flags.indexOf('trial') !== -1, + // rev 5's `silent` flag, plus the §4.3 spelling of it. HF-307 decision D3 additionally makes + // a key carrying tokens this version does not know silent, further down. + silent: flags.indexOf('silent') !== -1 || flags.indexOf('silent-console') !== -1, } } /** - * The capability tokens HyperFormula's own entry of the payload grants: its tier plus its - * add-ons, both of which are only honoured when they are non-empty strings. + * Whether an intact typed key is still valid, and if not, the day it stopped being valid. + * + * A key with no expiry never expires. Otherwise the expiration date is INCLUSIVE of its last + * valid day, and a grace period extends it further. A date compared against the build's release + * date involves no clock at all, which is what keeps an air-gapped install with a wrong system + * clock working. * - * {@link CORE_TOKEN} is always included. Without it a key whose tier this version does not - * recognize would block every built-in function, and HF-307 decision D3 says such a key falls - * back to core and protected functions — not to nothing. + * An unknown release date resolves to "not expired", matching what the legacy validator already + * does when `HT_RELEASE_DATE` is missing: a build that cannot tell its own age must not start + * rejecting keys that customers paid for. * - * @param {TypedKeyProductGrant | undefined} grant - HyperFormula's entry, absent for a key that - * licenses another product only + * @param {LicenseTerms} terms - the reconciled terms of the key */ -function capabilityTokensOf(grant: TypedKeyProductGrant | undefined): string[] { - const tokens = [CORE_TOKEN] - - if (grant === undefined) { - return tokens - } - if (typeof grant.tier === 'string' && grant.tier.length > 0) { - tokens.push(grant.tier) +function validityOf(terms: LicenseTerms): {state: LicenseKeyValidityState, expiredOn?: Date} { + if (terms.expiryTimestamp === null) { + return {state: LicenseKeyValidityState.VALID} } - if (Array.isArray(grant.addons)) { - grant.addons.forEach((addon) => { - if (typeof addon === 'string' && addon.length > 0) { - tokens.push(addon) - } - }) + + const now = terms.comparedAgainstReleaseDate ? releaseDateTimestamp() : Date.now() + + if (now === null) { + return {state: LicenseKeyValidityState.VALID} } - return tokens + const deadline = terms.expiryTimestamp + MILLISECONDS_PER_DAY + (terms.graceDays * MILLISECONDS_PER_DAY) + + return now < deadline + ? {state: LicenseKeyValidityState.VALID} + : {state: LicenseKeyValidityState.EXPIRED, expiredOn: new Date(terms.expiryTimestamp)} } /** - * Turns an intact typed key into the entitlement it grants. + * Turns the reconciled terms of an intact, unexpired typed key into the entitlement it grants. * * Per HF-307 decision D3 this is fail-closed and silent: a token this version does not recognize * is recorded in `unrecognizedCapabilities` and grants nothing, without a warning, a message, or * anything public to read it back from. * - * @param {TypedKeyData} data - the extracted key data + * @param {LicenseTerms} terms - the reconciled terms of the key */ -function entitlementFromTypedKey(data: TypedKeyData): LicenseEntitlement { - const tokens = capabilityTokensOf(data.payload.products[HYPERFORMULA_PRODUCT_NAME]) - const unrecognizedCapabilities = tokens.filter((token) => !CAPABILITY_TABLE.has(token)) +function entitlementOf(terms: LicenseTerms): LicenseEntitlement { + const unrecognizedCapabilities = terms.capabilityTokens.filter((token) => !CAPABILITY_TABLE.has(token)) return { unrestricted: false, - capabilities: new Set(tokens), + capabilities: new Set(terms.capabilityTokens), unrecognizedCapabilities, - expiry: expiryOf(data), - silent: unrecognizedCapabilities.length > 0, - isTrial: data.keyType === 'trial', + expiry: terms.expiry, + silent: terms.silent || unrecognizedCapabilities.length > 0, + isTrial: terms.isTrial, } } @@ -207,14 +296,15 @@ export function resolveLicense(licenseKey: string): ResolvedLicense { } } - const {state, expiredOn} = typedKeyValidity(typedKeyData) + const terms = licenseTermsOf(typedKeyData) + const {state, expiredOn} = validityOf(terms) - notifyLicenseKeyState(state, expiredOn) + if (!terms.silent) { + notifyLicenseKeyState(state, expiredOn) + } return { validityState: state, - entitlement: state === LicenseKeyValidityState.VALID - ? entitlementFromTypedKey(typedKeyData) - : unrestrictedEntitlement(), + entitlement: state === LicenseKeyValidityState.VALID ? entitlementOf(terms) : unrestrictedEntitlement(), } } From 2c65499066ba0887def040b9bbeef31280962fcd Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 12 Aug 2026 00:12:53 +0000 Subject: [PATCH 4/9] HF-307 PR 3: fix review findings - two crashes, a gating hole and fail-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 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/license/CapabilityRegistry.ts | 5 +- src/license/LicenseEntitlement.ts | 15 ++-- src/license/capabilities.ts | 73 +++++++++++----- src/license/licenseResolution.ts | 139 ++++++++++++++++++++++-------- 4 files changed, 163 insertions(+), 69 deletions(-) diff --git a/src/license/CapabilityRegistry.ts b/src/license/CapabilityRegistry.ts index e631fc274..495c51cfb 100644 --- a/src/license/CapabilityRegistry.ts +++ b/src/license/CapabilityRegistry.ts @@ -4,7 +4,7 @@ */ import {FeatureId, LicenseEntitlement} from './LicenseEntitlement' -import {CAPABILITY_TABLE, CapabilityGrant, refreshDynamicGrants} from './capabilities' +import {CAPABILITY_TABLE, CapabilityGrant} from './capabilities' /** * The capabilities a resolved {@link LicenseEntitlement} grants, ready for gate B (the @@ -33,9 +33,6 @@ export class CapabilityRegistry { * suite does not depend on its placeholder content. */ constructor(table?: ReadonlyMap) { - if (table === undefined) { - refreshDynamicGrants() - } this.table = table ?? CAPABILITY_TABLE this.reverseIndex = CapabilityRegistry.buildReverseIndex(this.table) } diff --git a/src/license/LicenseEntitlement.ts b/src/license/LicenseEntitlement.ts index 9472d1350..299d11171 100644 --- a/src/license/LicenseEntitlement.ts +++ b/src/license/LicenseEntitlement.ts @@ -24,12 +24,15 @@ export const enum FeatureId { /** * Describes when a license entitlement stops being valid. * - * Per key-spec rev 3 §1.3, `date` is kept as a calendar string rather than an epoch, and is - * INCLUSIVE of its last valid day: - * - `kind === 'usage'`: `date` is compared against the client's LOCAL calendar date — deliberately - * not UTC, the date means the date, wherever the customer is. - * - `kind === 'release'`: `date` is compared LEXICOGRAPHICALLY, as text, against the library's - * build date; no clock is involved. + * `date` is kept as a calendar string rather than an epoch, and is INCLUSIVE of its last valid + * day: + * - `kind === 'usage'`: compared against the current instant in **UTC**. An earlier revision of + * the key spec called for the client's LOCAL calendar date; that was reversed, because the + * offline check and a future online check have to return the same verdict for the same key at + * the same instant, and any rule that reads a local clock breaks that parity. The practical + * cost is that a customer far west of UTC loses the tail of their last local day. + * - `kind === 'release'`: compared against the library's build date; no clock is involved, which + * is what keeps an air-gapped install with a wrong system clock working. * - `kind === 'none'`: the entitlement does not expire. */ export interface LicenseExpiry { diff --git a/src/license/capabilities.ts b/src/license/capabilities.ts index 34bb569ab..b25dcc05e 100644 --- a/src/license/capabilities.ts +++ b/src/license/capabilities.ts @@ -3,7 +3,6 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ -import {FunctionRegistry} from '../interpreter/FunctionRegistry' import {FeatureId} from './LicenseEntitlement' /** @@ -102,6 +101,45 @@ const SPREADSHEET_FUNCTIONS = [ 'VLOOKUP', 'WEEKDAY', 'WEEKNUM', 'WORKDAY', 'WORKDAY.INTL', 'XLOOKUP', 'YEARFRAC', ] +/** + * Added by the excel-simulator package, on top of {@link SPREADSHEET_FUNCTIONS} — the rest of the + * implemented catalog. + * + * Enumerated rather than taken from the function registry at run time, even though "all + * functions" would be the shorter way to say it. Reading the registry would sweep in functions + * registered through `HyperFormula.registerFunctionPlugin`, putting a user's OWN custom function + * into a paid package and returning `#LIC!` for it on a smaller licence — the opposite of HF-307 + * decision D1, which drops custom-function gating entirely. A function this table does not list + * is not gated at all, which is exactly the treatment a custom function should get. + */ +const EXCEL_SIMULATOR_FUNCTIONS = [ + 'ACOS', 'ACOSH', 'ACOT', 'ACOTH', 'ARABIC', 'ARRAYFORMULA', 'ARRAY_CONSTRAIN', 'ASIN', 'ASINH', 'ATAN', + 'ATAN2', 'ATANH', 'AVEDEV', 'AVERAGEA', 'BASE', 'BESSELI', 'BESSELJ', 'BESSELK', 'BESSELY', 'BETA.DIST', + 'BETA.INV', 'BIN2DEC', 'BIN2HEX', 'BIN2OCT', 'BINOM.DIST', 'BINOM.INV', 'BITAND', 'BITLSHIFT', 'BITOR', + 'BITRSHIFT', 'BITXOR', 'CEILING.MATH', 'CEILING.PRECISE', 'CHAR', 'CHISQ.DIST', 'CHISQ.DIST.RT', + 'CHISQ.INV', 'CHISQ.INV.RT', 'CHISQ.TEST', 'CLEAN', 'CODE', 'COMBIN', 'COMBINA', 'COMPLEX', + 'CONFIDENCE.NORM', 'CONFIDENCE.T', 'CORREL', 'COS', 'COSH', 'COT', 'COTH', 'COUNTA', 'COUNTBLANK', + 'COUNTUNIQUE', 'COVARIANCE.P', 'COVARIANCE.S', 'CSC', 'CSCH', 'CUMIPMT', 'CUMPRINC', 'DAVERAGE', 'DB', + 'DCOUNT', 'DCOUNTA', 'DDB', 'DEC2BIN', 'DEC2HEX', 'DEC2OCT', 'DECIMAL', 'DEGREES', 'DELTA', 'DEVSQ', + 'DGET', 'DMAX', 'DMIN', 'DOLLARDE', 'DOLLARFR', 'DPRODUCT', 'DSTDEV', 'DSTDEVP', 'DSUM', 'DVAR', 'DVARP', + 'EFFECT', 'ERF', 'ERFC', 'EVEN', 'EXACT', 'EXPON.DIST', 'F.DIST', 'F.DIST.RT', 'F.INV', 'F.INV.RT', + 'F.TEST', 'FACT', 'FACTDOUBLE', 'FISHER', 'FISHERINV', 'FLOOR.MATH', 'FLOOR.PRECISE', 'FVSCHEDULE', + 'GAMMA', 'GAMMA.DIST', 'GAMMA.INV', 'GAMMALN', 'GAUSS', 'GCD', 'GEOMEAN', 'HARMEAN', 'HEX2BIN', + 'HEX2DEC', 'HEX2OCT', 'HSTACK', 'HYPGEOM.DIST', 'IFS', 'IMABS', 'IMAGINARY', 'IMARGUMENT', 'IMCONJUGATE', + 'IMCOS', 'IMCOSH', 'IMCOT', 'IMCSC', 'IMCSCH', 'IMDIV', 'IMEXP', 'IMLN', 'IMLOG10', 'IMLOG2', 'IMPOWER', + 'IMPRODUCT', 'IMREAL', 'IMSEC', 'IMSECH', 'IMSIN', 'IMSINH', 'IMSQRT', 'IMSUB', 'IMSUM', 'IMTAN', 'INT', + 'ISPMT', 'LARGE', 'LCM', 'LOGNORM.DIST', 'LOGNORM.INV', 'MAXA', 'MAXPOOL', 'MEDIAN', 'MEDIANPOOL', + 'MINA', 'MIRR', 'MMULT', 'MROUND', 'MULTINOMIAL', 'N', 'NEGBINOM.DIST', 'NOMINAL', 'NORM.DIST', + 'NORM.INV', 'NORM.S.DIST', 'NORM.S.INV', 'OCT2BIN', 'OCT2DEC', 'OCT2HEX', 'ODD', 'PDURATION', 'PHI', + 'PI', 'POISSON.DIST', 'PROPER', 'QUARTILE.EXC', 'QUARTILE.INC', 'QUOTIENT', 'RADIANS', 'REPLACE', 'REPT', + 'ROMAN', 'RRI', 'RSQ', 'SEC', 'SECH', 'SEQUENCE', 'SERIESSUM', 'SHEET', 'SHEETS', 'SIGN', 'SIN', 'SINH', + 'SKEW', 'SKEW.P', 'SLN', 'SLOPE', 'SMALL', 'SPLIT', 'SQRTPI', 'STANDARDIZE', 'STEYX', 'SUBSTITUTE', + 'SUBTOTAL', 'SUMPRODUCT', 'SUMSQ', 'SUMX2MY2', 'SUMX2PY2', 'SUMXMY2', 'SWITCH', 'SYD', 'T', 'T.DIST', + 'T.DIST.2T', 'T.DIST.RT', 'T.INV', 'T.INV.2T', 'T.TEST', 'TAN', 'TANH', 'TBILLEQ', 'TBILLPRICE', + 'TBILLYIELD', 'TDIST', 'TRANSPOSE', 'UNICHAR', 'UNICODE', 'VSTACK', 'WEIBULL.DIST', 'XIRR', 'XNPV', + 'Z.TEST', +] + const coreGrant: CapabilityGrant = {functions: [...OPERATOR_FUNCTIONS], features: [...CORE_FEATURES]} const functions1Grant: CapabilityGrant = {functions: [...MATH_ENGINE_FUNCTIONS], features: []} const functions2Grant: CapabilityGrant = { @@ -110,7 +148,13 @@ const functions2Grant: CapabilityGrant = { const functions3Grant: CapabilityGrant = { functions: [...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS, ...SPREADSHEET_FUNCTIONS], features: [], } -const functions4Grant: CapabilityGrant = {functions: [], features: []} +const functions4Grant: CapabilityGrant = { + functions: [ + ...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS, ...SPREADSHEET_FUNCTIONS, + ...EXCEL_SIMULATOR_FUNCTIONS, + ], + features: [], +} /** * The production capability table. @@ -120,8 +164,11 @@ const functions4Grant: CapabilityGrant = {functions: [], features: []} * commercial nesting is expressed by a bigger licence simply listing more functions. The * cumulative spreads above keep the source DRY without putting that hierarchy into the runtime. * - * `functions_4` (the entire catalog) is filled by {@link refreshDynamicGrants} instead of being - * listed, so it keeps covering functions added after this file was written. + * Every grant is STATIC. Nothing here is derived from the function registry at run time, so a + * function registered by a user through `HyperFormula.registerFunctionPlugin` can never appear in + * a package and can never be gated — see {@link EXCEL_SIMULATOR_FUNCTIONS}. The cost is that a + * newly implemented built-in is ungated until it is added here, which the completeness invariant + * in `unit/license/capability-registry.spec.ts` fails on. * * The two add-on tokens are RESERVED: recognized, so an issued key carrying one is not reported * as unrecognized, but granting nothing. `spreadsheet` has no agreed content yet — the packaging @@ -139,21 +186,3 @@ export const CAPABILITY_TABLE: ReadonlyMap = new Map([ [IMPORT_EXPORT_ADDON_TOKEN, {functions: [], features: []}], ]) -/** - * Refreshes the grants that depend on what is currently registered — today only - * `functions_4`, the entire implemented catalog. - * - * Called from `CapabilityRegistry`'s constructor every time it is constructed without an explicit - * table, not just the first time: the static registry can change after the first engine is built - * (`HyperFormula.registerFunctionPlugin`/`unregisterFunctionPlugin` are public, documented APIs), - * and a one-time snapshot would silently go stale for every engine built afterwards. Cheap (a - * single array copy from an existing map's keys) and only ever runs once per `Config`/engine - * construction, never on the per-formula hot path. - * - * Reading the registry at module-load time instead would capture an empty one: `src/index.ts` - * registers the built-in plugins as a side effect of being imported, AFTER `Config` and - * `Interpreter` — and so this module — have been fully evaluated. - */ -export function refreshDynamicGrants(): void { - functions4Grant.functions = FunctionRegistry.getRegisteredFunctionIds() -} diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts index 4e65bd35d..cfc4391b5 100644 --- a/src/license/licenseResolution.ts +++ b/src/license/licenseResolution.ts @@ -19,6 +19,7 @@ import { import {LicenseEntitlement, LicenseExpiry, unrestrictedEntitlement} from './LicenseEntitlement' import {HYPERFORMULA_PRODUCT_NAME} from './vendor/defaultSchema' import {extractTypedKeyData, TypedKeyData, TypedKeyProductGrant} from './vendor/extractKeyData' +import {parseIsoDate} from './vendor/utils' /** Milliseconds in a day, used to turn a grace period in days into a deadline. */ const MILLISECONDS_PER_DAY = 86400000 @@ -30,6 +31,9 @@ const MILLISECONDS_PER_DAY = 86400000 */ const SECONDS_MILLISECONDS_THRESHOLD = 1e11 +/** The largest value `Date` can represent; beyond it `toISOString()` throws. */ +const MAX_TIMESTAMP = 8640000000000000 + /** * Commercial tier names (the shipped key format) mapped to the capability tokens the library * actually resolves. A tier this map does not know is passed through unchanged, so it surfaces @@ -42,6 +46,15 @@ const TIER_TO_CAPABILITY_TOKEN: Record = { excel_simulator: FUNCTIONS_4_TOKEN, } +/** The rev-5 fields, which the shipped payload shape does not have. */ +interface Rev5ProductGrant { + capabilities?: unknown, + usage_until?: unknown, + release_until?: unknown, + notice?: unknown, + flags?: unknown, +} + /** * Both halves of the license decision, resolved from one reading of the key. * @@ -61,9 +74,10 @@ export interface ResolvedLicense { * The engine reads TWO payload shapes on purpose: * * - the **shipped** shape of `handsontable/license-key` — `tier`, `addons`, `exp`, `grace`, with - * the contract type carried by the key's `[TRIAL]`/`[FREE]`/`[SUB]`/`[PERP]` tag; + * the contract type carried by the key's `[TRIAL]`/`[FREE]`/`[SUB]`/`[PERP]` tag, and with the + * expiry living on the LICENSED product entry (the first schema product present); * - the shape of key spec **rev 5** — `capabilities`, `usage_until` / `release_until`, `notice`, - * `grace`, `flags`, with no commercial vocabulary in the payload at all. + * `grace`, `flags`, where every product entry carries its own terms. * * The two disagree about nearly every field, rev 5 is still for review, and only the first can * be minted today. Reading both means an already-issued key keeps working whichever way that @@ -98,29 +112,35 @@ function releaseDateTimestamp(): number | null { /** * Reads a date that may arrive either as a `YYYY-MM-DD` string or as a numeric timestamp, and - * returns it as epoch milliseconds at UTC midnight; `null` when it is neither. + * returns it as epoch milliseconds at UTC midnight. Returns `null` when the value is present but + * cannot be read — the caller rejects the whole key in that case rather than treating it as + * "no expiry", which would silently turn a subscription into a perpetual licence. * * Both forms are accepted because key spec rev 5 contradicts itself about them: §1.2 mandates * "bare `YYYY-MM-DD` everywhere, no time component", while §2.1 types the same fields as - * `timestamp` and its example payload carries integers. Accepting both costs a few lines and - * removes the need to have guessed right. + * `timestamp` and its example payload carries integers. * - * @param {unknown} value - the raw payload value + * The string form goes through the vendored {@link parseIsoDate}, so it gets the same calendar + * round-trip check the shipped shape's `exp` gets: `2027-02-30` is rejected rather than rolling + * over into March and quietly granting two extra days. + * + * @param {unknown} value - the raw payload value, known not to be `undefined` */ function readDate(value: unknown): number | null { if (typeof value === 'string') { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) - - if (match === null) { + try { + return parseIsoDate(value, 'expiration').timestamp + } catch (error) { return null } - const timestamp = Date.UTC(parseInt(match[1], 10), parseInt(match[2], 10) - 1, parseInt(match[3], 10)) - - return isNaN(timestamp) ? null : timestamp } if (typeof value === 'number' && isFinite(value)) { const milliseconds = Math.abs(value) < SECONDS_MILLISECONDS_THRESHOLD ? value * 1000 : value + if (Math.abs(milliseconds) > MAX_TIMESTAMP) { + return null + } + // Normalize to UTC midnight so an inclusive last-licensed-DAY stays a day, not an instant. return Math.floor(milliseconds / MILLISECONDS_PER_DAY) * MILLISECONDS_PER_DAY } @@ -150,27 +170,35 @@ function readStrings(value: unknown): string[] { return (value as unknown[]).filter((item): item is string => typeof item === 'string' && item.length > 0) } +/** Whether a payload product entry is a usable object rather than `null`, an array or a scalar. */ +function isProductGrant(value: unknown): value is TypedKeyProductGrant & Rev5ProductGrant { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + /** - * Reconciles the two payload shapes into one set of terms. - * - * `hyperformulaGrant` may be `undefined` — a key that licenses Handsontable alone still parses, - * and HyperFormula simply gets nothing beyond {@link CORE_TOKEN} from it. + * Reconciles the two payload shapes into one set of terms, or `null` when the payload carries a + * term it cannot read — a date that is present but malformed, for instance. Returning `null` + * makes the key INVALID, which is what the shipped shape already does for a malformed `exp`; + * the alternative, treating an unreadable expiry as "never expires", would turn a minting typo + * into a permanent licence. * * @param {TypedKeyData} data - the extracted key data */ -function licenseTermsOf(data: TypedKeyData): LicenseTerms { - const hyperformulaGrant: TypedKeyProductGrant | undefined = data.payload.products[HYPERFORMULA_PRODUCT_NAME] - const licensedGrant: TypedKeyProductGrant = data.payload.products[data.licensedProductName] - const isRev5 = Array.isArray((hyperformulaGrant as {capabilities?: unknown} | undefined)?.capabilities) - - // CORE_TOKEN is always granted. Without it a key whose tier this version does not recognize - // would block every built-in function, whereas HF-307 decision D3 says such a key falls back - // to core and protected functions - not to nothing at all. +function licenseTermsOf(data: TypedKeyData): LicenseTerms | null { + const hyperformulaEntry: unknown = data.payload.products[HYPERFORMULA_PRODUCT_NAME] + const hyperformulaGrant = isProductGrant(hyperformulaEntry) ? hyperformulaEntry : undefined + const isRev5 = hyperformulaGrant !== undefined && Array.isArray(hyperformulaGrant.capabilities) + + // CORE_TOKEN is always granted, but note what it actually grants: the calculation operators, + // and the whole gated public API surface - NOT a usable set of functions. A key whose only + // tokens this build does not recognize therefore evaluates operators and protected built-ins + // and returns #LIC! for every function call, silently, per HF-307 decision D3. That cliff is + // deliberate but severe, and is flagged for review rather than softened here. const capabilityTokens = [CORE_TOKEN] if (hyperformulaGrant !== undefined) { if (isRev5) { - capabilityTokens.push(...readStrings((hyperformulaGrant as {capabilities?: unknown}).capabilities)) + capabilityTokens.push(...readStrings(hyperformulaGrant.capabilities)) } else { if (typeof hyperformulaGrant.tier === 'string' && hyperformulaGrant.tier.length > 0) { capabilityTokens.push(TIER_TO_CAPABILITY_TOKEN[hyperformulaGrant.tier] ?? hyperformulaGrant.tier) @@ -179,17 +207,42 @@ function licenseTermsOf(data: TypedKeyData): LicenseTerms { } } - // Expiry, grace and notice belong to the LICENSED product - for a key granting both products - // that is Handsontable, and HyperFormula's own entry carries none of them. - const rev5Terms = licensedGrant as {usage_until?: unknown, release_until?: unknown, notice?: unknown} | undefined - const usageUntil = readDate(rev5Terms?.usage_until) - const releaseUntil = readDate(rev5Terms?.release_until) - const comparedAgainstReleaseDate = releaseUntil !== null || (usageUntil === null && data.keyType === 'perpetual') + // WHERE the terms live differs by shape. Under rev 5 every product entry carries its own + // dates, notice, grace and flags, so HyperFormula reads its own. Under the shipped shape only + // the LICENSED product may carry `exp` and `grace`, so for a key granting both products those + // live on the Handsontable entry and HyperFormula's own entry has neither. + const licensedEntry: unknown = data.payload.products[data.licensedProductName] + const termsSource = isRev5 ? hyperformulaGrant : (isProductGrant(licensedEntry) ? licensedEntry : undefined) + + let usageUntil: number | null = null + let releaseUntil: number | null = null + + if (isRev5 && termsSource !== undefined) { + if (termsSource.usage_until !== undefined) { + usageUntil = readDate(termsSource.usage_until) + if (usageUntil === null) { + return null + } + } + if (termsSource.release_until !== undefined) { + releaseUntil = readDate(termsSource.release_until) + if (releaseUntil === null) { + return null + } + } + } + + // The two rev-5 date fields are specified as mutually exclusive, but a hand-built payload can + // carry both, and the date used and the axis it is compared against MUST come from the same + // field - otherwise a usage deadline would be checked against the build date, which either + // never expires or expires on the wrong axis. `usage_until` wins, and the axis follows it. + const comparedAgainstReleaseDate = usageUntil === null + && (releaseUntil !== null || data.keyType === 'perpetual') const expiryTimestamp = usageUntil ?? releaseUntil ?? data.expiryTimestamp - const flags = readStrings((hyperformulaGrant as {flags?: unknown} | undefined)?.flags) - // A perpetual licence has no grace period: its date is compared against a static build date, - // so there is no window to be inside of. - const graceDays = comparedAgainstReleaseDate ? 0 : readDays(licensedGrant?.grace) + const flags = readStrings(termsSource?.flags) + // A release-date comparison has no grace period: it is static, so there is no window to be + // inside of. + const graceDays = comparedAgainstReleaseDate ? 0 : readDays(termsSource?.grace) return { capabilityTokens, @@ -199,7 +252,7 @@ function licenseTermsOf(data: TypedKeyData): LicenseTerms { kind: comparedAgainstReleaseDate ? 'release' : 'usage', // UTC midnight by construction, so this round-trips a payload's own `YYYY-MM-DD` exactly. date: new Date(expiryTimestamp).toISOString().slice(0, 10), - noticeDays: readDays(rev5Terms?.notice), + noticeDays: readDays(termsSource?.notice), graceDays, }, expiryTimestamp, @@ -241,7 +294,9 @@ function validityOf(terms: LicenseTerms): {state: LicenseKeyValidityState, expir return now < deadline ? {state: LicenseKeyValidityState.VALID} - : {state: LicenseKeyValidityState.EXPIRED, expiredOn: new Date(terms.expiryTimestamp)} + // The reported day is the first day NOT covered, which is the convention the legacy validator + // already uses for the same message (it reports `keyValidityDays + 1`). + : {state: LicenseKeyValidityState.EXPIRED, expiredOn: new Date(terms.expiryTimestamp + MILLISECONDS_PER_DAY)} } /** @@ -284,6 +339,9 @@ function entitlementOf(terms: LicenseTerms): LicenseEntitlement { * otherwise valid key; it is not a rule about invalid keys, and conflating the two is exactly * the mistake this comment is here to prevent. * + * A checksum-valid key whose terms cannot be read is INVALID, not a crash and not a free pass: + * every payload field is untrusted, so nothing here may assume a shape. + * * @param {string} licenseKey - the raw `licenseKey` config value */ export function resolveLicense(licenseKey: string): ResolvedLicense { @@ -297,6 +355,13 @@ export function resolveLicense(licenseKey: string): ResolvedLicense { } const terms = licenseTermsOf(typedKeyData) + + if (terms === null) { + notifyLicenseKeyState(LicenseKeyValidityState.INVALID) + + return {validityState: LicenseKeyValidityState.INVALID, entitlement: unrestrictedEntitlement()} + } + const {state, expiredOn} = validityOf(terms) if (!terms.silent) { From 1aea95d881b118a7a174a4fba1baac8427d94f9c Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 12 Aug 2026 00:54:27 +0000 Subject: [PATCH 5/9] HF-307 PR 3: read the expiry date in UTC when formatting the warning 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 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/helpers/licenseKeyValidator.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/helpers/licenseKeyValidator.ts b/src/helpers/licenseKeyValidator.ts index 4a7454067..9d35d53e9 100644 --- a/src/helpers/licenseKeyValidator.ts +++ b/src/helpers/licenseKeyValidator.ts @@ -104,16 +104,21 @@ export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityS /** * Formats a Date instance to hard-coded format MMMM DD, YYYY. * - * @param {Date} date The date to format. - * @returns {string} + * Read in UTC, not local time. Every date reaching this function is built at UTC midnight — the + * legacy path from a whole number of days since the epoch, the typed-key path from a calendar + * date in the payload — so local getters shifted the day backwards for anyone west of UTC and + * printed an expiry one day earlier than the one the key actually carries. + * + * @param {Date} date The date to format, at UTC midnight. + * @returns {string} The date as `MMMM DD, YYYY`. */ function formatDate(date: Date): string { const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ] - const month = monthNames[date.getMonth()] - const day = date.getDate() - const year = date.getFullYear() + const month = monthNames[date.getUTCMonth()] + const day = date.getUTCDate() + const year = date.getUTCFullYear() return `${month} ${day}, ${year}` } From 9c22416c5dd953ebb46c3f10d88d0f372fb0cd60 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Wed, 12 Aug 2026 17:06:36 +0000 Subject: [PATCH 6/9] HF-307 PR 3: real feature gating and decoupled silence, per Kuba's answers 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 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/license/capabilities.ts | 56 +++++++++++++++++++++++--------- src/license/licenseResolution.ts | 33 ++++++++++++++----- 2 files changed, 65 insertions(+), 24 deletions(-) diff --git a/src/license/capabilities.ts b/src/license/capabilities.ts index b25dcc05e..6c640d444 100644 --- a/src/license/capabilities.ts +++ b/src/license/capabilities.ts @@ -8,11 +8,34 @@ import {FeatureId} from './LicenseEntitlement' /** * The always-granted token. Every entitlement built from a license key includes it. * - * It grants the calculation operators and the whole gated public API surface — see - * {@link CORE_FEATURES} for why the features live here rather than on a package. + * It grants the calculation operators — and nothing else. In particular it grants NO features: + * per Kuba's decision (task comment, 12.08) feature gating is real, and the gated API areas come + * from the `feat:*` tokens below. The always-on functionality the packaging design assigns to + * core (reads, serialization, teardown) is not behind `ensureCapability` at all. */ export const CORE_TOKEN = 'core' +/** Grants {@link FeatureId.Crud} — the mutating CRUD surface of the public API. */ +export const CRUD_FEATURE_TOKEN = 'feat:crud' +/** Grants {@link FeatureId.UndoRedo}. */ +export const UNDO_REDO_FEATURE_TOKEN = 'feat:undo_redo' +/** Grants {@link FeatureId.Clipboard}. */ +export const CLIPBOARD_FEATURE_TOKEN = 'feat:clipboard' +/** Grants {@link FeatureId.NamedExpressions}. */ +export const NAMED_EXPRESSIONS_FEATURE_TOKEN = 'feat:named_expressions' +/** Grants {@link FeatureId.Batching}. */ +export const BATCHING_FEATURE_TOKEN = 'feat:batching' + +/** + * Every feature token, in one list, for the shipped-shape adapter: the shipped key vocabulary + * predates feature tokens entirely, so a commercial tier is translated into its functions token + * PLUS all of these — see `licenseTermsOf` for the reasoning. + */ +export const ALL_FEATURE_TOKENS = [ + CRUD_FEATURE_TOKEN, UNDO_REDO_FEATURE_TOKEN, CLIPBOARD_FEATURE_TOKEN, + NAMED_EXPRESSIONS_FEATURE_TOKEN, BATCHING_FEATURE_TOKEN, +] + /** Math engine package — the free tier's function set. */ export const FUNCTIONS_1_TOKEN = 'functions_1' /** Calculated fields package. Cumulative: includes {@link FUNCTIONS_1_TOKEN}'s functions. */ @@ -47,18 +70,11 @@ const OPERATOR_FUNCTIONS = [ 'HF.MULTIPLY', 'HF.NE', 'HF.POW', 'HF.UMINUS', 'HF.UNARY_PERCENT', 'HF.UPLUS', ] -/** - * The features {@link CORE_TOKEN} grants — which is all of them. - * - * No package restricts the public API surface in this draft, deliberately. The packaging - * evidence covers FUNCTIONS only; nothing has decided whether, say, undo/redo or the clipboard - * belongs to a paid tier. Restricting one here would both invent a product decision and make - * PR 2's `ensureCapability` start throwing from the CRUD API for real keys, so the conservative - * choice is to grant them all until that decision exists. - */ -const CORE_FEATURES = [ - FeatureId.NamedExpressions, FeatureId.Clipboard, FeatureId.Crud, FeatureId.UndoRedo, FeatureId.Batching, -] +// 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. Kuba's call (task comment, 12.08): +// "Feature gating should work, but the legacy keys should grant all feat:* capabilities" — legacy +// keys already resolve to the unrestricted entitlement, so the carve-out costs nothing, and the +// five features moved onto their own `feat:*` tokens below. /** * Package membership, as the LOWEST package that includes each function. @@ -140,7 +156,7 @@ const EXCEL_SIMULATOR_FUNCTIONS = [ 'Z.TEST', ] -const coreGrant: CapabilityGrant = {functions: [...OPERATOR_FUNCTIONS], features: [...CORE_FEATURES]} +const coreGrant: CapabilityGrant = {functions: [...OPERATOR_FUNCTIONS], features: []} const functions1Grant: CapabilityGrant = {functions: [...MATH_ENGINE_FUNCTIONS], features: []} const functions2Grant: CapabilityGrant = { functions: [...MATH_ENGINE_FUNCTIONS, ...CALCULATED_FIELDS_FUNCTIONS], features: [], @@ -170,6 +186,11 @@ const functions4Grant: CapabilityGrant = { * newly implemented built-in is ungated until it is added here, which the completeness invariant * in `unit/license/capability-registry.spec.ts` fails on. * + * The five `feat:*` tokens carry the gated API areas, one feature each, spelled after the draft + * vocabulary in the task. A rev-5 key states them explicitly; the shipped-shape adapter grants + * all five alongside the tier (that vocabulary predates feature tokens); legacy keys resolve to + * the unrestricted entitlement and never consult this table. + * * The two add-on tokens are RESERVED: recognized, so an issued key carrying one is not reported * as unrecognized, but granting nothing. `spreadsheet` has no agreed content yet — the packaging * proposal names a *package* "Spreadsheet" and the pricing task names a "Spreadsheet Bundle" @@ -182,6 +203,11 @@ export const CAPABILITY_TABLE: ReadonlyMap = new Map([ [FUNCTIONS_2_TOKEN, functions2Grant], [FUNCTIONS_3_TOKEN, functions3Grant], [FUNCTIONS_4_TOKEN, functions4Grant], + [CRUD_FEATURE_TOKEN, {functions: [], features: [FeatureId.Crud]}], + [UNDO_REDO_FEATURE_TOKEN, {functions: [], features: [FeatureId.UndoRedo]}], + [CLIPBOARD_FEATURE_TOKEN, {functions: [], features: [FeatureId.Clipboard]}], + [NAMED_EXPRESSIONS_FEATURE_TOKEN, {functions: [], features: [FeatureId.NamedExpressions]}], + [BATCHING_FEATURE_TOKEN, {functions: [], features: [FeatureId.Batching]}], [SPREADSHEET_ADDON_TOKEN, {functions: [], features: []}], [IMPORT_EXPORT_ADDON_TOKEN, {functions: [], features: []}], ]) diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts index cfc4391b5..88dabb60e 100644 --- a/src/license/licenseResolution.ts +++ b/src/license/licenseResolution.ts @@ -9,6 +9,7 @@ import { notifyLicenseKeyState, } from '../helpers/licenseKeyValidator' import { + ALL_FEATURE_TOKENS, CAPABILITY_TABLE, CORE_TOKEN, FUNCTIONS_1_TOKEN, @@ -189,21 +190,30 @@ function licenseTermsOf(data: TypedKeyData): LicenseTerms | null { const hyperformulaGrant = isProductGrant(hyperformulaEntry) ? hyperformulaEntry : undefined const isRev5 = hyperformulaGrant !== undefined && Array.isArray(hyperformulaGrant.capabilities) - // CORE_TOKEN is always granted, but note what it actually grants: the calculation operators, - // and the whole gated public API surface - NOT a usable set of functions. A key whose only - // tokens this build does not recognize therefore evaluates operators and protected built-ins - // and returns #LIC! for every function call, silently, per HF-307 decision D3. That cliff is - // deliberate but severe, and is flagged for review rather than softened here. + // CORE_TOKEN is always granted, but note what it actually grants: the calculation operators - + // NOT a usable set of functions, and NO features. A key whose only tokens this build does not + // recognize therefore evaluates operators and protected built-ins, returns #LIC! for every + // function call, and throws from the gated API, silently, per HF-307 decision D3. Kuba + // ratified that cliff as-is on 12.08 (D6-A): "this situation should never happen. There is no + // point in issuing a key if empty capabilities." const capabilityTokens = [CORE_TOKEN] if (hyperformulaGrant !== undefined) { if (isRev5) { + // The rev-5 shape states its grants explicitly, feature tokens included - a key that + // carries no `feat:*` token gets no gated API area, which is what makes feature gating + // real (Kuba, 12.08: "Feature gating should work"). capabilityTokens.push(...readStrings(hyperformulaGrant.capabilities)) } else { if (typeof hyperformulaGrant.tier === 'string' && hyperformulaGrant.tier.length > 0) { capabilityTokens.push(TIER_TO_CAPABILITY_TOKEN[hyperformulaGrant.tier] ?? hyperformulaGrant.tier) } capabilityTokens.push(...readStrings(hyperformulaGrant.addons)) + // The shipped vocabulary predates feature tokens entirely, so a shipped-shape key CANNOT + // carry one - and its tiers are commercial products sold with the full API. Granting all + // five keeps every shipped-shape key's API behaviour identical to what it was before + // feature gating went live: the additive-safety rule, applied to features. + capabilityTokens.push(...ALL_FEATURE_TOKENS) } } @@ -259,8 +269,10 @@ function licenseTermsOf(data: TypedKeyData): LicenseTerms | null { comparedAgainstReleaseDate, graceDays, isTrial: data.keyType === 'trial' || flags.indexOf('trial') !== -1, - // rev 5's `silent` flag, plus the §4.3 spelling of it. HF-307 decision D3 additionally makes - // a key carrying tokens this version does not know silent, further down. + // rev 5's `silent` flag, plus the §4.3 spelling of it. This is the ONLY source of silence: + // an earlier revision also silenced any key carrying an unrecognized token, which suppressed + // strictly more than D3 asks for (it would have swallowed expiry notices too). Kuba confirmed + // it was an implementation error (12.08). silent: flags.indexOf('silent') !== -1 || flags.indexOf('silent-console') !== -1, } } @@ -304,7 +316,10 @@ function validityOf(terms: LicenseTerms): {state: LicenseKeyValidityState, expir * * Per HF-307 decision D3 this is fail-closed and silent: a token this version does not recognize * is recorded in `unrecognizedCapabilities` and grants nothing, without a warning, a message, or - * anything public to read it back from. + * anything public to read it back from. "Silent" there means the *grant* is silent — whether the + * key's console messages are suppressed is decided solely by its `flags` (`terms.silent`), never + * by the presence of an unrecognized token; coupling the two suppressed expiry notices as a side + * effect of a vocabulary mismatch, and was confirmed an implementation error (Kuba, 12.08). * * @param {LicenseTerms} terms - the reconciled terms of the key */ @@ -316,7 +331,7 @@ function entitlementOf(terms: LicenseTerms): LicenseEntitlement { capabilities: new Set(terms.capabilityTokens), unrecognizedCapabilities, expiry: terms.expiry, - silent: terms.silent || unrecognizedCapabilities.length > 0, + silent: terms.silent, isTrial: terms.isTrial, } } From 7c25bbc125fd965b86da0ead6e48f8defe0e425d Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 13 Aug 2026 08:49:21 +0000 Subject: [PATCH 7/9] HF-307 PR 3: feature tokens are opt-in, and every silent spelling is honoured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/helpers/licenseKeyValidator.ts | 15 ++++++ src/license/LicenseEntitlement.ts | 10 ++-- src/license/licenseResolution.ts | 73 ++++++++++++++++++++++-------- 3 files changed, 77 insertions(+), 21 deletions(-) diff --git a/src/helpers/licenseKeyValidator.ts b/src/helpers/licenseKeyValidator.ts index 9d35d53e9..c0a0bbed6 100644 --- a/src/helpers/licenseKeyValidator.ts +++ b/src/helpers/licenseKeyValidator.ts @@ -42,6 +42,21 @@ const consoleMessages: ConsoleMessages = { let _notified = false +/** + * Clears the once-per-page-load flag {@link notifyLicenseKeyState} keeps. + * + * Exists for tests only. The flag is module-level and never otherwise reset, so without this the + * whole console-message path is unobservable: the first spec to build any engine consumes the single + * warning and every later assertion sees silence regardless of what the code does. Making the reset + * explicit beats the alternatives — depending on spec-file order is flaky, and under Karma every + * spec shares one browser context, so order tricks do not work there at all. + * + * @internal + */ +export function resetLicenseKeyNotificationForTests(): void { + _notified = false +} + /** * Prints the console message for a non-valid license key state, at most once per page load. * diff --git a/src/license/LicenseEntitlement.ts b/src/license/LicenseEntitlement.ts index 299d11171..5b642f524 100644 --- a/src/license/LicenseEntitlement.ts +++ b/src/license/LicenseEntitlement.ts @@ -66,9 +66,13 @@ export interface LicenseEntitlement { expiry: LicenseExpiry, /** * When `true`, resolving this entitlement must not print a console message of any kind. - * HF-307 decision D3 (fail-closed, silent): a typed key with no recognized token resolves - * like an explicit `capabilities: []` — core and protected functions only, without a message, - * a warning, or a diagnostics getter. + * + * Set from the key's own flags ONLY — the key spec spells that flag three different ways across + * revisions and even within one revision, and all are honoured. An unrecognized token does NOT + * set it: HF-307 decision D3 makes the *grant* silent (an unknown token grants nothing, with no + * message and no diagnostics getter), which is a different thing from muting the key's console + * output. Coupling them suppressed expiry notices as a side effect of a vocabulary mismatch, and + * was confirmed an implementation error (Kuba, 12.08). */ silent: boolean, isTrial: boolean, diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts index 88dabb60e..678bd04ef 100644 --- a/src/license/licenseResolution.ts +++ b/src/license/licenseResolution.ts @@ -47,6 +47,25 @@ const TIER_TO_CAPABILITY_TOKEN: Record = { excel_simulator: FUNCTIONS_4_TOKEN, } +/** + * The prefix marking a capability token as granting a public-API feature area. + * + * Used to tell "this key names its feature grants" from "this key's vocabulary cannot express + * one" — see the opt-in rule in {@link licenseTermsOf}. + */ +const FEATURE_TOKEN_PREFIX = 'feat:' + +/** + * Flag spellings that suppress console output. + * + * Three, because the key spec is not self-consistent: its normative flags table and its example + * payload (rev 5 §2.3 and §2) say `no-console-warns`, while the runtime-behaviour sections of the + * same revision (§4.3, §5.2) say `silent-console`, and earlier revisions said plain `silent`. A key + * minted against any of those readings must be honoured — a SaaS deployment that asked for silence + * and got console warnings is the failure this list exists to prevent. + */ +const SILENT_CONSOLE_FLAGS = ['silent', 'silent-console', 'no-console-warns'] + /** The rev-5 fields, which the shipped payload shape does not have. */ interface Rev5ProductGrant { capabilities?: unknown, @@ -188,35 +207,53 @@ function isProductGrant(value: unknown): value is TypedKeyProductGrant & Rev5Pro function licenseTermsOf(data: TypedKeyData): LicenseTerms | null { const hyperformulaEntry: unknown = data.payload.products[HYPERFORMULA_PRODUCT_NAME] const hyperformulaGrant = isProductGrant(hyperformulaEntry) ? hyperformulaEntry : undefined + + // `capabilities` present but not an array is a term this code cannot read, so the whole key is + // rejected rather than quietly falling through to the shipped-shape branch. That fall-through was + // a free pass in both directions: the key gained every feature it never carried, and its rev-5 + // dates were never read at all, so an expired subscription resolved as perpetual. + if (hyperformulaGrant !== undefined + && hyperformulaGrant.capabilities !== undefined + && !Array.isArray(hyperformulaGrant.capabilities)) { + return null + } + const isRev5 = hyperformulaGrant !== undefined && Array.isArray(hyperformulaGrant.capabilities) // CORE_TOKEN is always granted, but note what it actually grants: the calculation operators - - // NOT a usable set of functions, and NO features. A key whose only tokens this build does not - // recognize therefore evaluates operators and protected built-ins, returns #LIC! for every - // function call, and throws from the gated API, silently, per HF-307 decision D3. Kuba - // ratified that cliff as-is on 12.08 (D6-A): "this situation should never happen. There is no - // point in issuing a key if empty capabilities." + // NOT a usable set of functions. A key whose only tokens this build does not recognize therefore + // evaluates operators and protected built-ins and returns #LIC! for every function call, + // silently, per HF-307 decision D3. Kuba ratified that cliff as-is on 12.08 (D6-A): "this + // situation should never happen. There is no point in issuing a key if empty capabilities." const capabilityTokens = [CORE_TOKEN] if (hyperformulaGrant !== undefined) { if (isRev5) { - // The rev-5 shape states its grants explicitly, feature tokens included - a key that - // carries no `feat:*` token gets no gated API area, which is what makes feature gating - // real (Kuba, 12.08: "Feature gating should work"). capabilityTokens.push(...readStrings(hyperformulaGrant.capabilities)) } else { if (typeof hyperformulaGrant.tier === 'string' && hyperformulaGrant.tier.length > 0) { capabilityTokens.push(TIER_TO_CAPABILITY_TOKEN[hyperformulaGrant.tier] ?? hyperformulaGrant.tier) } capabilityTokens.push(...readStrings(hyperformulaGrant.addons)) - // The shipped vocabulary predates feature tokens entirely, so a shipped-shape key CANNOT - // carry one - and its tiers are commercial products sold with the full API. Granting all - // five keeps every shipped-shape key's API behaviour identical to what it was before - // feature gating went live: the additive-safety rule, applied to features. - capabilityTokens.push(...ALL_FEATURE_TOKENS) } } + // Feature tokens are OPT-IN, never opt-out. A key carrying at least one `feat:*` token demonstrably + // speaks the feature vocabulary, so it gets exactly the areas it names - that is what makes feature + // gating real (Kuba, 12.08: "Feature gating should work"). A key carrying NONE cannot be saying + // "no features", because no vocabulary in circulation can express one: the shipped shape has no + // such field, and the key spec's current HyperFormula token list (rev 5 §2.2 - `functions_1..4`, + // `spreadsheet`, `import_export`) contains no `feat:*` entry at all. So absence means "this key + // does not talk about features", and the task's additive-safety rule - a grant may grow between + // versions, never shrink - makes the whole gated API the only safe reading. + // + // Reading absence as denial instead would hand a dead public API to every key myHOT can mint + // today, HyperFormula-only and Handsontable-only alike; both were verified doing exactly that + // before this rule existed. + if (!capabilityTokens.some((token) => token.indexOf(FEATURE_TOKEN_PREFIX) === 0)) { + capabilityTokens.push(...ALL_FEATURE_TOKENS) + } + // WHERE the terms live differs by shape. Under rev 5 every product entry carries its own // dates, notice, grace and flags, so HyperFormula reads its own. Under the shipped shape only // the LICENSED product may carry `exp` and `grace`, so for a key granting both products those @@ -269,11 +306,11 @@ function licenseTermsOf(data: TypedKeyData): LicenseTerms | null { comparedAgainstReleaseDate, graceDays, isTrial: data.keyType === 'trial' || flags.indexOf('trial') !== -1, - // rev 5's `silent` flag, plus the §4.3 spelling of it. This is the ONLY source of silence: - // an earlier revision also silenced any key carrying an unrecognized token, which suppressed - // strictly more than D3 asks for (it would have swallowed expiry notices too). Kuba confirmed - // it was an implementation error (12.08). - silent: flags.indexOf('silent') !== -1 || flags.indexOf('silent-console') !== -1, + // Every spelling the key spec uses for "suppress console output" - see SILENT_CONSOLE_FLAGS. + // The key's flags are the ONLY source of silence: an earlier revision also silenced any key + // carrying an unrecognized token, which suppressed strictly more than D3 asks for (it would + // have swallowed expiry notices too). Kuba confirmed that was an implementation error (12.08). + silent: flags.some((flag) => SILENT_CONSOLE_FLAGS.indexOf(flag) !== -1), } } From d236367fd78bccf226d3462b6d1c7ada8720853f Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 16 Aug 2026 06:46:08 +0000 Subject: [PATCH 8/9] HF-307 PR 3: a prototype-named tier must not crash the constructor `TIER_TO_CAPABILITY_TOKEN` was an object literal, and the tier it is looked up by comes from the payload - which is attacker-influenced, since a typed key's checksum is an unkeyed SHA-512 that anyone can compute. An object lookup also answers for every `Object.prototype` member, so `tier: "constructor"` resolved to a FUNCTION and `tier: "__proto__"` to an object. Either one landed in the capability token list, and the `feat:` scan added on 13.08 then called `.indexOf` on it: TypeError: token.indexOf is not a function at licenseResolution.ts (Array.some) -> licenseTermsOf -> resolveLicense -> new Config -> HyperFormula.buildFromArray The engine failed to CONSTRUCT. That breaks the rule this module documents and already honours elsewhere: a malformed key produces an `invalid` verdict, never a thrown exception. Worth being precise about the history - the unsafe lookup predates the 13.08 change, but before it a non-string token was merely ignored by a Map lookup; the opt-in scan is what turned it into a crash. Fixed by making the map a `Map`, which answers only for keys actually put in it and matches `CAPABILITY_TABLE`. It also makes the types honest: `Record` told TypeScript the lookup yields a string, which was the lie behind the crash, while `Map.get` returns `string | undefined`. Behaviour for such a key is now identical to any other unknown tier: VALID key, token passed through, recorded as unrecognized, grants nothing (D3). Tests: handsontable/hyperformula-tests#32 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/license/licenseResolution.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts index 678bd04ef..9fb1dea68 100644 --- a/src/license/licenseResolution.ts +++ b/src/license/licenseResolution.ts @@ -39,13 +39,20 @@ const MAX_TIMESTAMP = 8640000000000000 * Commercial tier names (the shipped key format) mapped to the capability tokens the library * actually resolves. A tier this map does not know is passed through unchanged, so it surfaces * as an unrecognized capability rather than being silently swallowed. + * + * A `Map`, not an object literal, and that matters for safety rather than style: the tier is an + * attacker-influenced string, and an object lookup also answers for every `Object.prototype` member, + * so `tier: "constructor"` would resolve to a FUNCTION and `tier: "__proto__"` to an object. Either + * put a non-string into the token list, which then crashed the scan that reads tokens as strings — + * a thrown `TypeError` escaping the `HyperFormula` constructor instead of an `invalid` verdict. + * A `Map` answers only for keys actually put in it, matching {@link CAPABILITY_TABLE}. */ -const TIER_TO_CAPABILITY_TOKEN: Record = { - freemium: FUNCTIONS_1_TOKEN, - crm: FUNCTIONS_2_TOKEN, - data_grid: FUNCTIONS_3_TOKEN, - excel_simulator: FUNCTIONS_4_TOKEN, -} +const TIER_TO_CAPABILITY_TOKEN: ReadonlyMap = new Map([ + ['freemium', FUNCTIONS_1_TOKEN], + ['crm', FUNCTIONS_2_TOKEN], + ['data_grid', FUNCTIONS_3_TOKEN], + ['excel_simulator', FUNCTIONS_4_TOKEN], +]) /** * The prefix marking a capability token as granting a public-API feature area. @@ -232,7 +239,7 @@ function licenseTermsOf(data: TypedKeyData): LicenseTerms | null { capabilityTokens.push(...readStrings(hyperformulaGrant.capabilities)) } else { if (typeof hyperformulaGrant.tier === 'string' && hyperformulaGrant.tier.length > 0) { - capabilityTokens.push(TIER_TO_CAPABILITY_TOKEN[hyperformulaGrant.tier] ?? hyperformulaGrant.tier) + capabilityTokens.push(TIER_TO_CAPABILITY_TOKEN.get(hyperformulaGrant.tier) ?? hyperformulaGrant.tier) } capabilityTokens.push(...readStrings(hyperformulaGrant.addons)) } From 05882e115d663c53df3cd589c36029b83b24b08a Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 16 Aug 2026 08:50:16 +0000 Subject: [PATCH 9/9] HF-307 PR 3: the release-date comment promised an agreement that does not hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `releaseDateTimestamp` said it reads HT_RELEASE_DATE "so a perpetual typed key and a legacy key agree on what 'this build' means". They do not, east of UTC. This function uses `Date.UTC`; the legacy validator parses the same env value with `new Date(month/day/year)`, which is LOCAL. Measured at process level: HT_RELEASE_DATE=10/08/2026 legacy (local) typed (UTC) TZ=UTC, TZ=America/Los_Angeles 20675 20675 agree TZ=Asia/Tokyo 20674 20675 differ TZ=Pacific/Kiritimati 20674 20675 differ Raised by Bugbot on 12.08 and left unanswered for four days while I reported the PR as review-clean off the check status - which it was not. The CODE is right and stays. UTC is required for a typed key: key spec rev 5 §1.2 makes offline/online parity a hard rule, and a local clock breaks it. Legacy keeps its local parse because legacy behaviour is frozen this release - switching it would move the expiry verdict of already-issued keys by a day for every customer east of UTC. So the fix is to stop the comment claiming the opposite, and to state the consequence plainly: two customers east of UTC, one on a legacy key and one on an equivalent typed key, can disagree by a day about whether this build is covered. Reconciling them is a product decision. No test accompanies this, deliberately. The property is not observable in this suite: assigning `process.env.TZ` mid-run has no effect once the runtime resolved its timezone (probed - UTC, Asia/Tokyo and Pacific/Kiritimati all returned an identical timestamp inside Jest), and CI runs in UTC where both parses agree. A test written that way passes whichever parse the source uses; I wrote one, mutation-checked it, found it vacuous, and removed it rather than ship an assertion that cannot fail. Pinning it needs a timezone-parameterised CI job. The reasoning sits next to the release-axis tests so the gap stays deliberate. Tests: handsontable/hyperformula-tests#32 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9 --- src/license/licenseResolution.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts index 9fb1dea68..42aaac584 100644 --- a/src/license/licenseResolution.ts +++ b/src/license/licenseResolution.ts @@ -127,8 +127,30 @@ interface LicenseTerms { * The build's release date as epoch milliseconds (UTC midnight), or `null` when it is unknown or * malformed. * - * Read from the same `HT_RELEASE_DATE` (`DD/MM/YYYY`) the legacy validator uses, so a perpetual - * typed key and a legacy key agree on what "this build" means. + * Read from the same `HT_RELEASE_DATE` (`DD/MM/YYYY`) the legacy validator uses, but **parsed + * differently on purpose**, and the difference is observable — so do not "simplify" either one to + * match the other without reading this. + * + * This function uses `Date.UTC`. The legacy validator builds the same value with + * `new Date(month/day/year)`, which is parsed in the host's LOCAL zone. East of UTC the two land on + * different day numbers for one and the same release date: + * + * ```text + * HT_RELEASE_DATE=10/08/2026 legacy (local) this function (UTC) + * TZ=UTC, TZ=America/Los_Angeles 20675 20675 agree + * TZ=Asia/Tokyo 20674 20675 differ by a day + * TZ=Pacific/Kiritimati 20674 20675 differ by a day + * ``` + * + * UTC is the required reading for a typed key: key spec rev 5 §1.2 makes offline/online parity a + * hard rule — the offline check and a future online check must return the same verdict for the same + * key at the same instant — and any rule reading a local clock breaks it. The legacy path keeps its + * local parse because legacy behaviour is frozen for this release; switching it would move the + * expiry verdict of already-issued legacy keys by a day for every customer east of UTC. + * + * The consequence, flagged rather than hidden: two customers east of UTC, one on a legacy key and + * one on an equivalent typed key, can disagree by a day about whether this build is covered. + * Reconciling them is a product decision, not a refactor. */ function releaseDateTimestamp(): number | null { const [day, month, year] = (process.env.HT_RELEASE_DATE ?? '').split('/')