From 523316e5db3596f12af08e1623b9eceef317aad1 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 20 Aug 2026 08:06:00 +0000 Subject: [PATCH] HF-329: re-port the license key reader to the entitlement key format Upstream handsontable/license-key 4.0.0 (DEV-2512) deleted src/typed-key/ and replaced the tagged key format with the entitlement key format: , blank line, []. The tagged format was never issued to anyone (its 3.5.0 carrier was never released), so the old reader is removed rather than kept alongside. Re-vendored from src/entitlement-key/ at tag 4.0.0: detect-format and extract-key-data are new ports; sha512 and utils are byte-identical upstream and carry over. The reader is schema-free by upstream design, so default-schema is no longer vendored and TIER_TO_CAPABILITY_TOKEN (the tagged format's tier adapter) is gone with the format that fed it. Resolution reads HyperFormula's own product entry only: capabilities verbatim, exactly one of usage_until/release_until (the reader enforces the shape), notice/grace, flags (trial + the three silent spellings). Legacy 25-character keys and the literals are untouched; the invariant stands - only a VALID entitlement key may restrict the entitlement. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019pxNP45obT2LZfjitaCv9o --- CHANGELOG.md | 1 + docs/guide/license-key.md | 26 +- src/Config.ts | 2 +- src/helpers/licenseKeyValidator.ts | 29 ++- src/license/LicenseEntitlement.ts | 2 +- src/license/capabilities.ts | 14 +- src/license/licenseResolution.ts | 343 +++++++++------------------ src/license/vendor/PROVENANCE.md | 86 ++++--- src/license/vendor/constants.ts | 23 +- src/license/vendor/defaultSchema.ts | 168 ------------- src/license/vendor/detectFormat.ts | 70 ++++++ src/license/vendor/extractKeyData.ts | 309 ++++++++++++++---------- src/license/vendor/sha512.ts | 2 +- src/license/vendor/utils.ts | 2 +- 14 files changed, 476 insertions(+), 601 deletions(-) delete mode 100644 src/license/vendor/defaultSchema.ts create mode 100644 src/license/vendor/detectFormat.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c159af421..3870698ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed - Changed `getAvailableFunctions()` and `getFunctionDetails()` to describe only the functions the instance's license key includes, so they no longer advertise a function that would evaluate to a `#LIC!` error. A missing, invalid, or expired key does not shorten the list. [#1731](https://github.com/handsontable/hyperformula/pull/1731) +- Changed the parser for the new proprietary license keys to the entitlement key format (a human-readable text ending with a machine-readable block in square brackets), following its upstream specification. This replaces the tagged key format, which was never issued to anyone. Classic 25-character license keys and `gpl-v3` are unaffected. [#1740](https://github.com/handsontable/hyperformula/pull/1740) ## [3.4.0] - 2026-08-10 diff --git a/docs/guide/license-key.md b/docs/guide/license-key.md index 34b6aa5f2..bab722092 100644 --- a/docs/guide/license-key.md +++ b/docs/guide/license-key.md @@ -29,6 +29,18 @@ const options = { } ``` +### Proprietary license key formats + +Your proprietary license key is in one of two formats, and both work the same way: + +* A classic key: 25 characters in five dash-separated groups, for example + `1a2b3-4c5d6-7e8f9-0a1b2-3c4d5`. +* An entitlement key: a short, human-readable license text that ends with a machine-readable + block in square brackets. Assign the whole text to the `licenseKey` option, or just the + bracketed block — the block is the only part HyperFormula reads, so both work. The text around + the block may be re-wrapped on its way to you (for example, by an email client) without + affecting the key; the block itself has to arrive character for character. + ### Proprietary license key validation ::: tip @@ -36,10 +48,12 @@ HyperFormula doesn't use an internet connection to validate your proprietary lic ::: To determine whether a user is still entitled to use a particular -version of the software, HyperFormula compares the time between -two dates: -* The HyperFormula build date -* The date in your proprietary license key +version of the software, HyperFormula compares the date in your +proprietary license key against one of two references, depending on +the license you purchased: +* The HyperFormula build date, when the key ends maintenance on a set + date (versions released before that date keep working indefinitely) +* The current date (in UTC), when the key ends usage on a set date This process doesn't require any connection to the server. @@ -96,6 +110,10 @@ Arithmetic keeps working: operators such as `=A1+B1` are not function calls and are `VERSION()` and `OFFSET()`, which sit outside the licence system entirely. So a sheet with a key problem does not go blank — it keeps producing values wherever no function is called. +A **valid** key can print one notification too: if it expires on a set date and that date is +within the notice period your license carries, the console names the last day the key covers. It +is a heads-up only — nothing is restricted while a key is valid, and the message appears once. + ## License key support If you have any issues with your license key, [contact our team](contact.md). \ No newline at end of file diff --git a/src/Config.ts b/src/Config.ts index 4587eb36d..e17b9fd07 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -341,7 +341,7 @@ export class Config implements ConfigParams, ParserConfig { /** * Whether gate B (the entitlement check in the interpreter) needs to run at all for this - * config. `false` — the common case, for `gpl-v3`, legacy keys, and an unrestricted typed + * config. `false` — the common case, for `gpl-v3`, legacy keys, and an unrestricted entitlement * key — is a single boolean read, cheaper than the string-enum comparison it replaces. * * @internal diff --git a/src/helpers/licenseKeyValidator.ts b/src/helpers/licenseKeyValidator.ts index 868512d72..74c02ec4d 100644 --- a/src/helpers/licenseKeyValidator.ts +++ b/src/helpers/licenseKeyValidator.ts @@ -3,6 +3,7 @@ * Copyright (c) 2025 Handsoncode. All rights reserved. */ +import {ENTITLEMENT_KEY_CHECKSUM_LENGTH} from '../license/vendor/constants' import {checkKeySchema, extractTime} from './licenseKeyHelper' /** @@ -75,7 +76,7 @@ export function resetLicenseKeyNotificationForTests(): void { /** * 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 + * Extracted so the entitlement-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. @@ -96,8 +97,8 @@ export function notifyLicenseKeyState(state: LicenseKeyValidityState, keyValidit } /** - * Prints a one-time notice that a VALID typed key's usage-until expiry is approaching, at most - * once per distinct license key. + * Prints a one-time notice that a VALID entitlement key's usage-until expiry is approaching, at + * most once per distinct license key. * * Called from `src/license/licenseResolution.ts`'s `resolveLicense`, alongside * {@link notifyLicenseKeyState} — see that function's doc for why the two share this module @@ -125,17 +126,21 @@ export function notifyLicenseKeyNotice(licenseKey: string, expiryDate: Date): vo } /** - * The warn-once identity of a key: its trailing 128 characters — for an intact typed key, the - * sha512 checksum, unique per distinct key content — after trimming. + * The warn-once identity of a key: its trailing 129 characters, after trimming — for an intact + * entitlement key, the sha512 checksum plus the closing bracket that ends the machine-readable + * block, unique per distinct key content. * - * Trimmed because `extractTypedKeyData` trims before validating, so `'KEY'` and `'KEY\n'` are one - * license to the validator and must be one identity here too. Truncated because the set retains - * its entries for the life of the process: a multi-tenant server building one engine per - * customer-supplied key would otherwise accumulate every full key string it has ever warned - * about; 128 characters per entry bounds that to the checksum alone. + * Trimmed because the reader ignores trailing whitespace (it looks for the block, not for the end + * of the string), so `'KEY'` and `'KEY\n'` are one license and must be one identity here too. + * Reading from the END rather than the start also makes the whole artifact and its bare `[...]` + * block — which the format says are equally valid spellings of the same license — one identity. + * + * Truncated because the set retains its entries for the life of the process: a multi-tenant server + * building one engine per customer-supplied key would otherwise accumulate every full key string + * it has ever warned about; 129 characters per entry bounds that to the checksum alone. */ function noticeIdentityOf(licenseKey: string): string { - return licenseKey.trim().slice(-128) + return licenseKey.trim().slice(-(ENTITLEMENT_KEY_CHECKSUM_LENGTH + 1)) } /** @@ -178,7 +183,7 @@ export function checkLicenseKeyValidity(licenseKey: string): LicenseKeyValidityS * Formats a Date instance to hard-coded format MMMM DD, YYYY. * * 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 + * legacy path from a whole number of days since the epoch, the entitlement-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. * diff --git a/src/license/LicenseEntitlement.ts b/src/license/LicenseEntitlement.ts index 2ede884ba..7e929f3f5 100644 --- a/src/license/LicenseEntitlement.ts +++ b/src/license/LicenseEntitlement.ts @@ -84,7 +84,7 @@ export interface LicenseEntitlement { /** * The unrestricted entitlement: legacy keys and `gpl-v3` resolve to this today. * - * HF-307 decision D3 (fail-closed, silent) means a typed key whose tokens this library version + * HF-307 decision D3 (fail-closed, silent) means an entitlement key whose tokens this library version * does not recognize at all no longer maps here — it resolves to an entitlement with an empty, * silent capability set instead of falling back to unrestricted access. Do not reuse this * function for that case. diff --git a/src/license/capabilities.ts b/src/license/capabilities.ts index 5bbe2ebb6..fb2dc0e03 100644 --- a/src/license/capabilities.ts +++ b/src/license/capabilities.ts @@ -27,9 +27,9 @@ export const NAMED_EXPRESSIONS_FEATURE_TOKEN = 'feat:named_expressions' 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. + * Every feature token, in one list, for the opt-in rule in `licenseTermsOf`: a key naming no + * `feat:*` token at all is granted all of these, because no key vocabulary in circulation can + * express "no features" — see that function for the reasoning. */ export const ALL_FEATURE_TOKENS = [ CRUD_FEATURE_TOKEN, UNDO_REDO_FEATURE_TOKEN, CLIPBOARD_FEATURE_TOKEN, @@ -79,7 +79,7 @@ const OPERATOR_FUNCTIONS = [ ] // 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): +// by construction: no restricted 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. @@ -201,9 +201,9 @@ const functions4Grant: CapabilityGrant = { * 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. + * vocabulary in the task. A key may state them explicitly; a key naming none is granted all five + * (the opt-in rule in `licenseTermsOf`); legacy keys resolve to the unrestricted entitlement and + * never consult this table. * * The two add-on tokens, wired per the 2026-08-12 packages meeting: `spreadsheet` backs the * 'Spreadsheet Bundle' add-on and grants {@link FeatureId.Crud}, {@link FeatureId.UndoRedo}, diff --git a/src/license/licenseResolution.ts b/src/license/licenseResolution.ts index 505794015..04e77bc75 100644 --- a/src/license/licenseResolution.ts +++ b/src/license/licenseResolution.ts @@ -9,51 +9,22 @@ import { notifyLicenseKeyNotice, notifyLicenseKeyState, } from '../helpers/licenseKeyValidator' -import { - ALL_FEATURE_TOKENS, - CAPABILITY_TABLE, - CORE_TOKEN, - FUNCTIONS_1_TOKEN, - FUNCTIONS_2_TOKEN, - FUNCTIONS_3_TOKEN, - FUNCTIONS_4_TOKEN, -} from './capabilities' +import {ALL_FEATURE_TOKENS, 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' +import {detectLicenseKeyFormat} from './vendor/detectFormat' +import {EntitlementKeyData, EntitlementProductGrant, extractEntitlementKeyData} 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 /** - * 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. + * The name of HyperFormula's own product entry in an entitlement key payload. Every product + * entry carries its own capabilities, dates and windows, so this is the only entry this library + * reads — a key granting other products alongside (or instead of) HyperFormula is a valid key + * whose other entries are simply not for us. */ -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 - * 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: ReadonlyMap = new Map([ - ['freemium', FUNCTIONS_1_TOKEN], - ['crm', FUNCTIONS_2_TOKEN], - ['data_grid', FUNCTIONS_3_TOKEN], - ['excel_simulator', FUNCTIONS_4_TOKEN], -]) +export const HYPERFORMULA_PRODUCT_NAME = 'hyperformula' /** * The prefix marking a capability token as granting a public-API feature area. @@ -67,22 +38,13 @@ 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 + * payload (rev 6 §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, - usage_until?: unknown, - release_until?: unknown, - notice?: unknown, - flags?: unknown, -} - /** * Both halves of the license decision, resolved from one reading of the key. * @@ -97,20 +59,13 @@ export interface ResolvedLicense { } /** - * 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, 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`, where every product entry carries its own terms. + * What HyperFormula needs from an entitlement key, read from its own product entry. * - * 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. + * The entry's shape is guaranteed by the vendored reader ({@link extractEntitlementKeyData} + * returns `null` for anything malformed), so unlike the typed-key adapter this replaces, nothing + * here re-checks field types or reconciles competing payload shapes: the entitlement format is + * the only shape there is, and a key granting HyperFormula nothing is simply a key with no + * `hyperformula` entry. */ interface LicenseTerms { capabilityTokens: string[], @@ -143,14 +98,15 @@ interface LicenseTerms { * 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. + * UTC is the required reading for an entitlement key: key spec rev 6 §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. + * one on an equivalent entitlement 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 { @@ -161,94 +117,20 @@ 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. 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. + * Reads HyperFormula's terms out of an intact entitlement key payload. * - * 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. + * Total on purpose: the vendored reader has already rejected every malformed shape, so every + * field read here is exactly what {@link EntitlementProductGrant} promises. A payload without a + * `hyperformula` entry — including `products: {}` — is a VALID key that grants this library + * nothing and never expires for it; per HF-307 decision D6-A that cliff is silent. Note this + * differs from the typed-key format this replaces, where a key licensed to another product + * carried the expiry HyperFormula was checked against: an entitlement key's product entries each + * carry their own terms, so another product's dates are not ours to read. * - * 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` + * @param {EntitlementKeyData} data - the extracted key data */ -function readDate(value: unknown): number | null { - if (typeof value === 'string') { - try { - return parseIsoDate(value, 'expiration').timestamp - } catch (error) { - return null - } - } - 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 - } - - return null -} - -/** - * A non-negative integer count of days from a payload field, or `0` when it is absent or not one. - * - * @param {unknown} value - the raw payload value - */ -function readDays(value: unknown): number { - return typeof value === 'number' && isFinite(value) && value >= 0 ? Math.floor(value) : 0 -} - -/** - * 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 [] - } - - 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, 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 | 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) +function licenseTermsOf(data: EntitlementKeyData): LicenseTerms { + const grant: EntitlementProductGrant | undefined = data.products[HYPERFORMULA_PRODUCT_NAME] // CORE_TOKEN is always granted, but note what it actually grants: the calculation operators - // NOT a usable set of functions. A key whose only tokens this build does not recognize therefore @@ -257,89 +139,77 @@ function licenseTermsOf(data: TypedKeyData): LicenseTerms | null { // situation should never happen. There is no point in issuing a key if empty capabilities." const capabilityTokens = [CORE_TOKEN] - if (hyperformulaGrant !== undefined) { - if (isRev5) { - capabilityTokens.push(...readStrings(hyperformulaGrant.capabilities)) - } else { - if (typeof hyperformulaGrant.tier === 'string' && hyperformulaGrant.tier.length > 0) { - capabilityTokens.push(TIER_TO_CAPABILITY_TOKEN.get(hyperformulaGrant.tier) ?? hyperformulaGrant.tier) - } - capabilityTokens.push(...readStrings(hyperformulaGrant.addons)) - } + if (grant !== undefined) { + // Appended one by one rather than with `push(...grant.capabilities)`. The array comes from an + // attacker-influenced payload and the format sets no size limit (the spec addendum lists + // "payload size" as an open question on its own page), and spreading an array into a call puts + // one argument per stack slot: measured, a checksum-valid key carrying 125 000 tokens threw + // `RangeError: Maximum call stack size exceeded` out of `HyperFormula.buildFromArray` instead + // of resolving to a verdict. A malformed or hostile key must produce INVALID, never a throw. + grant.capabilities.forEach((token) => capabilityTokens.push(token)) } // 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. + // "no features", because no vocabulary in circulation can express one: the key spec's current + // HyperFormula token list (rev 6 §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)) { + // The trigger is a feature token this version RECOGNIZES, not merely one that looks like a + // feature token. An unrecognized `feat:*` token has to be inert (D3: "unrecognized token should + // not grant the capability (silently ignored)"), and a purely syntactic prefix test makes it the + // opposite of inert - it suppresses the fallback, so the key ends up with ZERO of the five areas. + // Measured before this guard existed: a key carrying `functions_1` plus a single unknown + // `feat:teleport` had CRUD, undo, clipboard, named expressions and batching all throwing, while + // the same key without that token had all five. That is the additive-safety rule inverted - an + // older build meeting a key minted by a newer generator, or a one-character typo at issuing time, + // would revoke the whole gated API rather than ignore a word it does not know. + const namesAKnownFeature = capabilityTokens.some( + (token) => token.indexOf(FEATURE_TOKEN_PREFIX) === 0 && CAPABILITY_TABLE.has(token) + ) + + if (!namesAKnownFeature) { 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 - // 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(termsSource?.flags) + // Exactly one of the two date fields is present on an intact entry (the reader enforces it), + // and the date used and the axis it is compared against come from that same field. The date is + // carried as the payload's own `YYYY-MM-DD` string, never routed through `Date` formatting - + // the key spec's fixture J11 exists because `toISOString()` shortens every licence issued east + // of UTC by a day. + const expiryDate = grant === undefined ? undefined : (grant.usage_until ?? grant.release_until) + const comparedAgainstReleaseDate = grant !== undefined && grant.release_until !== undefined + const expiryTimestamp = expiryDate === undefined ? null : parseIsoDate(expiryDate, 'expiration').timestamp + const flags = grant === undefined ? [] : grant.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) + const graceDays = comparedAgainstReleaseDate || grant === undefined ? 0 : grant.grace return { capabilityTokens, - expiry: expiryTimestamp === null + expiry: expiryDate === undefined || 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), - // Gated on the shape, not just on the field's presence: `notice` is rev-5 vocabulary - // (§2.1), and the shipped shape reads its terms off the LICENSED product's entry — for a - // dual-product key that is the Handsontable entry, so an ungated read would let a field - // another product added for its own purposes switch HyperFormula's console output on. - noticeDays: isRev5 ? readDays(termsSource?.notice) : 0, + date: expiryDate, + // Read off HyperFormula's OWN entry, which is what makes the shape gate structural here: + // the tagged format took its terms from the LICENSED product's entry, so a `notice` field + // another product added for its own purposes could switch HyperFormula's console output + // on (fixed under gate in the previous PR). An entitlement key carries per-entry terms, so + // another product's `notice` is not reachable from here at all. + noticeDays: grant === undefined ? 0 : grant.notice, graceDays, }, expiryTimestamp, comparedAgainstReleaseDate, graceDays, - isTrial: data.keyType === 'trial' || flags.indexOf('trial') !== -1, + isTrial: flags.indexOf('trial') !== -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 @@ -349,7 +219,7 @@ function licenseTermsOf(data: TypedKeyData): LicenseTerms | null { } /** - * Whether an intact typed key is still valid, and if not, the day it stopped being valid. + * Whether an intact entitlement 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 @@ -360,7 +230,7 @@ function licenseTermsOf(data: TypedKeyData): LicenseTerms | null { * 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 {LicenseTerms} terms - the reconciled terms of the key + * @param {LicenseTerms} terms - the terms of the key */ function validityOf(terms: LicenseTerms): {state: LicenseKeyValidityState, expiredOn?: Date} { if (terms.expiryTimestamp === null) { @@ -385,23 +255,22 @@ function validityOf(terms: LicenseTerms): {state: LicenseKeyValidityState, expir /** * The day a VALID key's usage-until expiry falls on, if the current UTC instant is within its * notice window — `null` otherwise, which covers "no notice window configured" (`noticeDays` is - * `0` for every non-rev-5 entry, enforced where the terms are read) just as much as "not close + * `0`, which is also what a key with no HyperFormula entry resolves to) just as much as "not close * enough yet" or "already past its usage-until day". * * Deliberately blind to `graceDays`: notice is about the usage_until axis itself, not about the - * grace extension past it. Key spec rev 5 §4.1 sequences notice, then a soft-stop window, then the + * grace extension past it. Key spec rev 6 §4.1 sequences notice, then a soft-stop window, then the * hard-stop this build already enforces; only the hard stop and this notice are built for 3.5.0 * (Kuba's decision D5-A), so the window checked here ends exactly where the soft-stop phase would * begin, rather than reaching into grace and printing a notice for a key already past its expiry. * - * `release_until`-axis keys never reach here with a non-`null` result — `kind` is `'release'` for - * them (see {@link licenseTermsOf}) — matching rev 5's rule that notice and grace have no effect - * on that axis. The converse does NOT hold: `kind === 'usage'` also covers a rev-5 entry with no - * date of its own, whose `expiryTimestamp` fell through to the key envelope's `exp`, so such an - * entry carrying `notice` notices against that envelope date. Accepted for this PR standing - * alone — the entitlement-envelope re-port (#1740) removes the fallback entirely. + * `release_until`-axis keys never reach here with a non-`null` result — `kind` is `'usage'` only + * when the date came from `usage_until` (see {@link licenseTermsOf}) — matching the spec's rule + * that notice and grace have no effect on that axis. The converse holds too now: the tagged + * format let an entry with no date of its own fall through to the key envelope's `exp`, so + * `'usage'` did not imply `usage_until`; an entitlement key has no envelope date to fall back to. * - * @param {LicenseTerms} terms - the reconciled terms of the key + * @param {LicenseTerms} terms - the terms of the key */ function expiryWithinNoticeWindow(terms: LicenseTerms): Date | null { if (terms.expiry.kind !== 'usage' || terms.expiry.noticeDays <= 0 || terms.expiryTimestamp === null) { @@ -418,7 +287,7 @@ function expiryWithinNoticeWindow(terms: LicenseTerms): Date | null { } /** - * Turns the reconciled terms of an intact, unexpired typed key into the entitlement it grants. + * Turns the terms of an intact, unexpired entitlement 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 @@ -427,7 +296,7 @@ function expiryWithinNoticeWindow(terms: LicenseTerms): Date | null { * 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 + * @param {LicenseTerms} terms - the terms of the key */ function entitlementOf(terms: LicenseTerms): LicenseEntitlement { const unrecognizedCapabilities = terms.capabilityTokens.filter((token) => !CAPABILITY_TABLE.has(token)) @@ -445,13 +314,17 @@ function entitlementOf(terms: LicenseTerms): LicenseEntitlement { /** * 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. + * Routing follows the vendored {@link detectLicenseKeyFormat}, whose test order is normative + * (key spec addendum, T12): the literals, then the trailing bracketed block that marks an + * entitlement key, then the legacy 25-character shape. Everything that is not an entitlement key + * — `gpl-v3`, a legacy key, an empty string — falls through to {@link checkLicenseKeyValidity} + * completely unchanged, which is what keeps this from touching existing behaviour. A string that + * carries a bracketed block routes here even when the block is garbage: such a key is INVALID, + * not a legacy key that happens to contain brackets. * - * **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 + * **The invariant this function exists to protect.** Only a VALID entitlement key resolves to a + * restricted entitlement. Every other outcome — missing, invalid, or expired, for an entitlement + * 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, @@ -460,8 +333,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. + * A checksum-valid key whose payload shape 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 the vendored reader + * has not verified. * * @param {string} licenseKey - the raw `licenseKey` config value * @param {boolean} notifyConsole - pass `false` for a resolution whose result exists only to be @@ -471,18 +345,16 @@ function entitlementOf(terms: LicenseTerms): LicenseEntitlement { * once-per-page-load flag, so they cannot double-print regardless of this parameter. */ export function resolveLicense(licenseKey: string, notifyConsole: boolean = true): ResolvedLicense { - const typedKeyData = extractTypedKeyData(licenseKey) - - if (typedKeyData === null) { + if (detectLicenseKeyFormat(licenseKey) !== 'entitlement') { return { validityState: checkLicenseKeyValidity(licenseKey), entitlement: unrestrictedEntitlement(), } } - const terms = licenseTermsOf(typedKeyData) + const data = extractEntitlementKeyData(licenseKey) - if (terms === null) { + if (data === null) { if (notifyConsole) { notifyLicenseKeyState(LicenseKeyValidityState.INVALID) } @@ -490,6 +362,7 @@ export function resolveLicense(licenseKey: string, notifyConsole: boolean = true return {validityState: LicenseKeyValidityState.INVALID, entitlement: unrestrictedEntitlement()} } + const terms = licenseTermsOf(data) const {state, expiredOn} = validityOf(terms) if (notifyConsole && !terms.silent) { diff --git a/src/license/vendor/PROVENANCE.md b/src/license/vendor/PROVENANCE.md index 4908e8292..4618ebc31 100644 --- a/src/license/vendor/PROVENANCE.md +++ b/src/license/vendor/PROVENANCE.md @@ -1,4 +1,4 @@ -# Vendored typed-key reader — provenance and drift control +# Vendored entitlement-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, @@ -10,23 +10,32 @@ parser rejects genuine customer keys. | | | |---|---| | 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 | +| Tag | `4.0.0` | +| Commit | `c50ef40a6` (the `4.0.0` release commit; on `develop` as `1acddafa8`) | +| Ported on | 2026-08-20 | +| Reference docs | the format and design notes kept alongside the upstream sources; the byte-level rules are also specified in the key spec's "Technical implementation" addendum (T1–T14) | + +This replaces the earlier port of `src/typed-key/` at `7553d0d1` (2026-08-11). Upstream 4.0.0 +(DEV-2512) **deleted** that directory and replaced the tagged key format with the entitlement key +format; the tagged format never reached customers, so the old reader was removed here rather than +kept alongside. ## Files -Hashes are of the **upstream** `.js` sources at the commit above, so drift is detectable without +Hashes are of the **upstream** `.js` sources at the tag above, so drift is detectable without storing a copy of them here. -| This directory | Upstream `src/typed-key/` | Upstream sha256 | +| This directory | Upstream `src/entitlement-key/` | Upstream sha256 | |---|---|---| -| `constants.ts` | `constants.js` | `2f987427ba3d012917c5972714b964b26f877b2e91a57790b37249928c72f5b6` | -| `defaultSchema.ts` | `default-schema.js` | `f905f1a0a6fef9b0c247a0fdb0642d5018d30fc915314ac8b10976cec8be9fc8` | +| `constants.ts` | `constants.js` | `6e2ad68d1a316abdec3f89bf04260a2cc4098f76525427d919f22cb25fb077d6` | +| `detectFormat.ts` | `detect-format.js` | `7dc037fd70e7c64078a0fe29b42cb33ecf25e8f4d8963ae9bfb16b69479d267f` | +| `extractKeyData.ts` | `extract-key-data.js` | `afd0858768879764ea016d2bc4fca692a0cd12214c1dfda6932d7ed9e4f32e45` | | `utils.ts` | `utils.js` | `135a8396bb22f424160fc651e899931d4be807df9b94c6dd24bb1cf6526e0541` | | `sha512.ts` | `sha512.js` | `668dd1109160b92965a1f9a9c5fb78dfdc1e5b7e93f635a147ae8a6bb2a5d837` | -| `extractKeyData.ts` | `extract-key-data.js` | `e6f854f10c6679136d382afe0bcf4fb1d4f9709416c68247a9cdb2236b7eec23` | + +`utils.js` and `sha512.js` are byte-identical between `src/typed-key/` at the old pin and +`src/entitlement-key/` at `4.0.0` (same hashes as the previous revision of this table), so their +ports carried over unchanged apart from this file's path references. ### Checking for drift @@ -35,8 +44,8 @@ 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 +cd license-key/src/entitlement-key +sha256sum constants.js detect-format.js extract-key-data.js utils.js sha512.js ``` Any hash that differs from the table means upstream moved. Re-read the changed file and re-port @@ -44,49 +53,48 @@ it, then update this table together with the code in the same commit. ## Not vendored, on purpose +The entitlement reader is deliberately schema-free upstream (unknown products, tokens and flags +are tolerated, so nothing about *reading* a key depends on the vocabulary), which keeps the +vendored surface small: everything schema- and generation-side stays out. + | 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. | +| `generate-key.js`, `build-payload.js`, `build-prose.js` | Mint keys. HyperFormula only ever reads them. | +| `default-schema.js` | The generator's vocabulary (packages, add-ons, wordings, templates). The reader needs no schema; the only name this library reads is its own product entry, kept as `HYPERFORMULA_PRODUCT_NAME` in `src/license/licenseResolution.ts`. | +| `create-engine.js`, `resolve-schema.js`, `validate-schema.js`, `validate-record.js` | Bind and verify a caller's schema/record at generation time — generator-side. | +| `validate-key.js` | A two-line boolean wrapper over `extractEntitlementKeyData`; 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 +1. **`detectFormat.ts` keeps its literals in a `Map`,** where upstream uses an object literal + behind a `hasOwnProperty` guard. Same behaviour for every input (including `constructor` and + `__proto__`); the `Map` is this repository's idiom for lookups keyed by untrusted strings. +2. **`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. +3. **The normalized product entry is typed** (`EntitlementProductGrant`), which upstream's plain + JavaScript does not do. The types state what the reader CHECKS, and the checks are upstream's: + `capabilities` and `flags` are verified element by element, `notice` and `grace` are verified as + non-negative integers, and the date field is verified only by matching `String(value)` against + `YYYY-MM-DD` — so a payload whose `usage_until` is a single-element array of the right string + passes, and the declared `string` type is then wider than the value. Faithful to upstream, which + stringifies the same way; noted here because the declaration alone reads stronger than the check. + Everything the reader does not verify — unknown fields are preserved on purpose — sits behind an + `unknown`-valued index signature, so consumers must narrow before use. 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. +- `src/helpers/licenseKeyHelper.ts` — the validator for the legacy 25-character key format, + untouched here (upstream 4.0.0 still exports it too). +- `src/license/licenseResolution.ts` — the consumer: routes on `detectLicenseKeyFormat` and turns + the extracted payload into an entitlement. +- `src/license/capabilities.ts` — the capability table the payload's tokens are resolved against. diff --git a/src/license/vendor/constants.ts b/src/license/vendor/constants.ts index f5fd9aac7..dd9f5c4ec 100644 --- a/src/license/vendor/constants.ts +++ b/src/license/vendor/constants.ts @@ -4,20 +4,25 @@ */ /** - * Vendored from `handsontable/license-key`, `src/typed-key/constants.js`. + * Vendored from `handsontable/license-key`, `src/entitlement-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. + * The length of the checksum (SHA-512 as hex) which postfixes the payload inside the + * machine-readable block of every entitlement license key. */ -export const TYPED_KEY_SUPPORTED_VERSIONS: number[] = [1] +export const ENTITLEMENT_KEY_CHECKSUM_LENGTH = 128 /** - * The length of the checksum (SHA-512 as hex) which postfixes every typed license key. + * The two mutually exclusive date fields of a product entry. Exactly one of them has to be + * present: + * + * - `usage_until` — the last licensed day (inclusive, compared in UTC), + * - `release_until` — builds released on or before that day may be used forever (compared + * against the build release date as text, no clock involved). + * + * The pair replaces the contract type — nothing in the payload says "subscription" or + * "perpetual". */ -export const TYPED_KEY_CHECKSUM_LENGTH = 128 +export const DATE_FIELDS: readonly string[] = ['usage_until', 'release_until'] diff --git a/src/license/vendor/defaultSchema.ts b/src/license/vendor/defaultSchema.ts deleted file mode 100644 index 7cb6d0ca0..000000000 --- a/src/license/vendor/defaultSchema.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * @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/detectFormat.ts b/src/license/vendor/detectFormat.ts new file mode 100644 index 000000000..41fde4bef --- /dev/null +++ b/src/license/vendor/detectFormat.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright (c) 2025 Handsoncode. All rights reserved. + */ + +/** + * Vendored from `handsontable/license-key`, `src/entitlement-key/detect-format.js`. + * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. + */ + +/** + * The name of each license key format {@link detectLicenseKeyFormat} can answer with. + */ +export type LicenseKeyFormat = + | 'entitlement' + | 'legacy' + | 'non-commercial-and-evaluation' + | 'gpl-v3' + | 'unknown' + +/** + * The literal keys that stand for a licence rather than encode one. + */ +const LITERAL_KEYS: ReadonlyMap = new Map([ + ['non-commercial-and-evaluation', 'non-commercial-and-evaluation'], + ['gpl-v3', 'gpl-v3'], +]) + +/** + * The classic 25-character key, once its dashes are stripped. + */ +const LEGACY_KEY = /^[0-9a-fA-F]{25}$/ + +/** + * Tells which license key format a string is in, without validating it. + * + * The entitlement key format removed the leading type tag, so a key no longer announces itself + * in its first characters — it now ends with the bracketed machine-readable block instead. + * Products that accept several formats need one place that makes the distinction, and this is it. + * + * The answer is about SHAPE only. A returned `'entitlement'` means "route this to the + * entitlement validator", not "this key is valid". + * + * @param {unknown} licenseKey - the license key to inspect + */ +export function detectLicenseKeyFormat(licenseKey: unknown): LicenseKeyFormat { + if (typeof licenseKey !== 'string') { + return 'unknown' + } + + const key = licenseKey.trim() + const literal = LITERAL_KEYS.get(key.toLowerCase()) + + if (literal !== undefined) { + return literal + } + + // The bracketed block closes an entitlement key. Its presence is what separates the new format + // from everything else, so it is checked before the shape-based ones. + const blockStart = key.lastIndexOf('[') + + if (blockStart !== -1 && key.indexOf(']', blockStart) !== -1) { + return 'entitlement' + } + if (LEGACY_KEY.test(key.replace(/-/g, ''))) { + return 'legacy' + } + + return 'unknown' +} diff --git a/src/license/vendor/extractKeyData.ts b/src/license/vendor/extractKeyData.ts index 0a95cd753..408ed3c1d 100644 --- a/src/license/vendor/extractKeyData.ts +++ b/src/license/vendor/extractKeyData.ts @@ -4,212 +4,275 @@ */ /** - * Vendored from `handsontable/license-key`, `src/typed-key/extract-key-data.js`. + * Vendored from `handsontable/license-key`, `src/entitlement-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. + * Unlike the typed-key reader this file replaces, the entitlement reader is deliberately + * SCHEMA-FREE upstream: unknown products, capabilities and flags are all tolerated, so nothing + * about reading a key depends on the vocabulary — which is what lets a product vendor this + * parser on its own. */ -import {TYPED_KEY_CHECKSUM_LENGTH, TYPED_KEY_SUPPORTED_VERSIONS} from './constants' -import {DEFAULT_TYPED_KEY_SCHEMA} from './defaultSchema' +import {DATE_FIELDS, ENTITLEMENT_KEY_CHECKSUM_LENGTH} from './constants' import {sha512} from './sha512' import {base64ToString, parseIsoDate, stringToUtf8Bytes} from './utils' /** - * One product entry of a typed key payload. + * The alphabet of the encoded payload — URL-safe base64 without padding. The checksum (lowercase + * hex) is a subset of it, which is what lets the two be split by a fixed length from the right. + */ +const ENCODED_PAYLOAD = /^[A-Za-z0-9\-_]+$/ +const CHECKSUM = /^[0-9a-f]+$/ + +/** + * One normalized product entry of an entitlement 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. + * The named fields are guaranteed by {@link normalizeProductEntry}: `capabilities` and `flags` + * are arrays of strings (`flags` normalized to `[]` when absent), `notice` and `grace` are + * non-negative integers, and exactly one of `usage_until` / `release_until` is present and is a + * real `YYYY-MM-DD` calendar date. Any OTHER field the entry carries is preserved verbatim under + * its own name — a field added to the format later must reach an application running an older + * vendored parser — which is what the index signature is for. */ -export interface TypedKeyProductGrant { - readonly tier?: unknown, - readonly mode?: unknown, - readonly addons?: unknown, - readonly exp?: unknown, - readonly grace?: unknown, +export interface EntitlementProductGrant { + readonly capabilities: readonly string[], + readonly usage_until?: string, + readonly release_until?: string, + readonly notice: number, + readonly grace: number, + readonly flags: readonly string[], + readonly [field: string]: 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`. + * The machine-readable content of an intact entitlement license key: the granted products, each + * with its capabilities, its single date (`usage_until` or `release_until`), its `notice` and + * `grace` windows in days, and its `flags`. */ -export interface TypedKeyPayload { - readonly v: number, - readonly products: Readonly>, - readonly ref?: unknown, - readonly holder?: unknown, - readonly iss?: unknown, +export interface EntitlementKeyData { + readonly products: Readonly>, } /** - * The machine-readable content of an intact typed license key. + * Returns `true` when the value is a plain object. + * + * @param {unknown} value - the value to check */ -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, +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) } /** - * The licensed product of a payload, together with the expiration time derived from it. + * Returns `true` when the value is a non-negative integer. + * + * @param {unknown} value - the value to check */ -interface LicensedProduct { - readonly name: string, - readonly expiryTimestamp: number | null, +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && Math.floor(value) === value && value >= 0 } /** - * 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. + * Returns `true` when the value is an array of strings. * - * 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 {unknown} value - the value to check + */ +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string') +} + +/** + * Returns `true` when the value is a real calendar date in the `YYYY-MM-DD` format. A time + * component, an offset, a numeric timestamp and a date that does not exist are all rejected — + * the format is the whole contract, and a validator that accepted two spellings would hide a + * timezone bug at generation instead of surfacing it. * - * @param {TypedKeyPayload} payload - the key payload + * @param {unknown} value - the value to check */ -function resolveLicensedProduct(payload: TypedKeyPayload): LicensedProduct | null { - const {products} = payload +function isIsoDate(value: unknown): boolean { + try { + // The cast mirrors upstream, where the call is untyped: `parseIsoDate` stringifies its + // argument before matching it against the `YYYY-MM-DD` shape, so a non-string value is + // rejected by the shape check rather than by a type guard here. + parseIsoDate(value as string, 'license') - if (products === null || typeof products !== 'object' || Array.isArray(products)) { - return null + return true + } catch (error) { + return false } +} - const schemaProductNames = DEFAULT_TYPED_KEY_SCHEMA.products.map((schemaProduct) => schemaProduct.name) +/** + * Adds an own, ordinary property. + * + * Both the product names and the field names of a product entry come from JSON, so `__proto__` + * is a name an attacker can put in a key. A plain assignment would go through the + * `Object.prototype` setter: the value would vanish from `Object.keys` while still resolving + * through the chain. + * + * @param {object} target - the object to add the property to + * @param {string} key - the property name + * @param {unknown} value - the property value + */ +function defineOwn(target: object, key: string, value: unknown): void { + Object.defineProperty(target, key, { + value, enumerable: true, writable: true, configurable: true, + }) +} - // 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)) { +/** + * Verifies and normalizes one product entry. + * + * Strict about SHAPE: exactly one of the two dates, a real date, and the two window sizes. A key + * that gets this wrong is malformed, not merely unknown, and reading it would mean guessing what + * was licensed. + * + * Lenient about VOCABULARY: an unrecognised capability token, an unrecognised flag and an + * unrecognised extra field are all kept and ignored. Without that leniency every token added on + * the issuing side would break every library version already deployed in the field. + * + * Returns `null` when the entry is malformed. + * + * @param {unknown} entry - the product entry of the payload + */ +function normalizeProductEntry(entry: unknown): EntitlementProductGrant | null { + if (!isPlainObject(entry)) { + return null + } + if (!isStringArray(entry.capabilities)) { 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] + const presentDateFields = DATE_FIELDS.filter((field) => entry[field] !== undefined) - if (licensedProductName === undefined || licensedProduct === undefined || licensedProduct === null - || typeof licensedProduct !== 'object' || Array.isArray(licensedProduct)) { + // Exactly one date per product. "Both" and "neither" are each a different commercial shape + // that the format cannot express, so neither may be silently resolved by whichever field the + // parser happens to read first. + if (presentDateFields.length !== 1) { return null } - if (licensedProduct.exp === undefined) { - return {name: licensedProductName, expiryTimestamp: null} + if (!isIsoDate(entry[presentDateFields[0]])) { + return 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. + if (!isNonNegativeInteger(entry.notice) || !isNonNegativeInteger(entry.grace)) { + return null + } + if (entry.flags !== undefined && !isStringArray(entry.flags)) { return null } + + // Start from everything the entry carries, so a field this version does not know survives + // into the result instead of being silently dropped. A field added to the format later is + // exactly the case an already-vendored parser has to survive, and a reader that quietly + // discards it makes the field invisible to the application on top. + const normalized = {} + + Object.keys(entry).forEach((field) => defineOwn(normalized, field, entry[field])) + + defineOwn(normalized, 'capabilities', entry.capabilities.slice()) + defineOwn(normalized, 'notice', entry.notice) + defineOwn(normalized, 'grace', entry.grace) + // An absent array and an empty one mean the same thing. Normalizing here keeps + // `flags.indexOf('trial')` safe at every call site. + defineOwn(normalized, 'flags', entry.flags === undefined ? [] : entry.flags.slice()) + defineOwn(normalized, presentDateFields[0], entry[presentDateFields[0]]) + + return normalized as EntitlementProductGrant } /** - * 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. + * Extracts the machine-readable data from an entitlement license key. + * + * The checksum is verified first, so the returned data is guaranteed to belong to an intact + * block. For a malformed or tampered key `null` is returned — reporting an invalid key is the + * caller's job, not this function's. + * + * Only the bracketed block matters. The prose in front of it is neither parsed nor covered by + * the checksum, so the caller may pass the whole artifact or just the `[...]` block, and + * rewrapped or re-pasted text still validates. * - * 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). + * No schema is needed. Unknown products, capabilities and flags are all tolerated, so nothing + * about reading a key depends on the vocabulary — which is what lets a product vendor this + * parser on its own. * * @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) { +export function extractEntitlementKeyData(licenseKey: string): EntitlementKeyData | null { + if (typeof licenseKey !== 'string') { return null } - if (key.length <= TYPED_KEY_CHECKSUM_LENGTH) { + + // The machine-readable block closes the key. Searching backwards means a bracket inside the + // prose cannot shadow it. + const blockStart = licenseKey.lastIndexOf('[') + + if (blockStart === -1) { return null } - const keyBody = key.slice(0, -TYPED_KEY_CHECKSUM_LENGTH) - const checksum = key.slice(-TYPED_KEY_CHECKSUM_LENGTH) + const blockEnd = licenseKey.indexOf(']', blockStart) - if (!/^[0-9a-f]+$/.test(checksum)) { + if (blockEnd === -1) { return null } - if (sha512(stringToUtf8Bytes(keyBody)) !== checksum) { + + const content = licenseKey.slice(blockStart + 1, blockEnd) + + if (content.length <= ENTITLEMENT_KEY_CHECKSUM_LENGTH) { 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('____') + const encodedPayload = content.slice(0, -ENTITLEMENT_KEY_CHECKSUM_LENGTH) + const checksum = content.slice(-ENTITLEMENT_KEY_CHECKSUM_LENGTH) - if (separatorIndex === -1) { + if (!ENCODED_PAYLOAD.test(encodedPayload) || !CHECKSUM.test(checksum)) { + return null + } + if (sha512(stringToUtf8Bytes(encodedPayload)) !== checksum) { return null } - // The machine-readable part is the payload encoded as URL-safe base64. - const payloadJson = base64ToString(keyBody.slice(separatorIndex + 4)) + const payloadJson = base64ToString(encodedPayload) if (payloadJson === null) { return null } - let parsed: unknown + let payload: unknown try { - parsed = JSON.parse(payloadJson) + payload = JSON.parse(payloadJson) } catch (error) { return null } - if (parsed === null || typeof parsed !== 'object') { + if (!isPlainObject(payload)) { return null } - const payload = parsed as TypedKeyPayload + const rawProducts = payload.products - // 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) { + if (!isPlainObject(rawProducts)) { return null } - const licensedProduct = resolveLicensedProduct(payload) + const products = {} + let malformed = false + + Object.keys(rawProducts).forEach((name) => { + const entry = normalizeProductEntry(rawProducts[name]) + + if (entry === null) { + malformed = true - if (licensedProduct === null) { + return + } + + defineOwn(products, name, entry) + }) + + if (malformed) { return null } - return { - keyType, - payload, - expiryTimestamp: licensedProduct.expiryTimestamp, - licensedProductName: licensedProduct.name, - } + return {products} } diff --git a/src/license/vendor/sha512.ts b/src/license/vendor/sha512.ts index 91eab847b..24f87b6b0 100644 --- a/src/license/vendor/sha512.ts +++ b/src/license/vendor/sha512.ts @@ -4,7 +4,7 @@ */ /** - * Vendored from `handsontable/license-key`, `src/typed-key/sha512.js`. + * Vendored from `handsontable/license-key`, `src/entitlement-key/sha512.js`. * See `src/license/vendor/PROVENANCE.md` before editing — this file is a port, not original code. */ diff --git a/src/license/vendor/utils.ts b/src/license/vendor/utils.ts index 71e16285a..6bce9c17d 100644 --- a/src/license/vendor/utils.ts +++ b/src/license/vendor/utils.ts @@ -4,7 +4,7 @@ */ /** - * Vendored from `handsontable/license-key`, `src/typed-key/utils.js`. + * Vendored from `handsontable/license-key`, `src/entitlement-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