Releases: keylight-dev/keylight-js
Release list
v0.4.2
Fixed
refreshAfterUpgradecould squeeze in one extra poll past its timeout.
The capped final sleep was followed by a fresh clock read, and a timer that
fires a hair early relative toperformance.now()bought one more
validate()call. The last wait is now decided up front, so no network call
starts aftertimeout. This is the only change since 0.4.1, which was
tagged but never reached npm; install 0.4.2.
v0.4.1
Fixed
refreshAfterUpgradenow honours itsAbortSignaland itstimeoutwhile
asleep. The wait between polls was a baresetTimeout: the signal was only
read between iterations, so an abort landed up to a fullpollIntervallate,
and apollIntervallonger than what remained oftimeoutran the method past
its own deadline. The sleep now wakes the moment the signal fires, the final
wait is capped to the time left, and no poll starts once the deadline has
passed. An already-aborted signal makes zero network calls rather than one
lastvalidate(). The timer is alsounref'd on Node/Bun, so a pending poll
no longer keeps a one-shot script alive.- README documents
requireSignedConfig. The 0.4.0 flag was in the
changelog and the types but not in the README, whose quick start fetches the
keyset at runtime withfetchKeyset(...)— the one thing you must not rely on
once the flag is on. The Configuration Reference now covers the flag (off by
default; enable only for a product with a trial length configured in the
dashboard; unverified settings are never cached and the seed is kept) and
states that enabling it means pinningtrustedKeysat build time, because a
keyset fetched over the same connection as the settings can be forged by the
same attacker. TheKeylightOptionsdoc comment says the same thing instead
of claiming the SDK never fetches a keyset.
v0.4.0
Added
-
requireSignedConfig— Ed25519 verification of server-owned product
settings. The Keylight worker has signed the trial length and free-tier flag
on every route that delivers them since 2026-09-06; nothing in this SDK
checked those signatures.verifyConfignow does, over the payload format
shared byte-for-byte with the Swift, C#, C++ and Rust SDKs.Off by default, and it should stay off unless you know your product is
signed. The worker signs a product's settings only once that product has a
trial length configured in the dashboard; every other product is served
unsigned, and enabling this against one of those would reject legitimate
responses and pin the install to the seed you shipped with.When enabled, settings that do not verify are never cached — the SDK keeps
your seed rather than trusting what the server claimed. The check lives at the
single point where settings are merged, so no route can write settings around
it:/config,validateand the keyless beacon all pass through it.readConfigFieldsnow carries the four signature fields it previously
dropped. Without that every route looked permanently unsigned.Trust is rooted in the
trustedKeysyou ship with your app. The SDK does not
fetch a keyset at runtime, on purpose: keys fetched over the same connection
that serves the settings would let anyone able to forge one forge the other.
The trade-off is that rotating to a new key id leaves builds already in the
wild on their last known settings until they update — they freeze, they do not
break.Verification is pinned by a golden vector captured from the live worker, the
same one the other SDKs pin. -
refreshAfterUpgrade(timeout?, pollInterval?, signal?)— brief poll-revalidate
right after an upgrade. Payment webhooks (Stripe/Polar/etc.) can lag a few seconds
behind the checkout redirect, so a naive singlevalidate()right after upgrade can
still see the old entitlements. This re-validates everypollInterval(default 2s, in
milliseconds — this SDK's convention, unlike the Swift SDK's seconds) up to
timeout(default 30s), and resolvestrueas soon as the license's entitlements or
state change from what they were when it was called — including a definitive rejection
landing mid-poll, sincevalidate()reconciles that into a state change on its own.
Resolvesfalseon timeout, on an abortedAbortSignal, or immediately (no network
calls) when there is no stored license. Mirrors the Swift SDK'srefreshAfterUpgrade.
v0.3.0
Added
- The trial length is the server's, not the build's. A tenant could set a
trial length in the dashboard and nothing happened to their JS app:
trialDurationDaysandfreeTierEnabledwere read straight off the
normalized config wherever a trial decision was made, so the value passed to
new Keylight({...})was the only one that ever applied. New
effectiveTrialDurationDays()andeffectiveFreeTierEnabled()resolve
server value → local seed → 0, andcheckTrial(),state()and
productFreeTierEnabled()now read those. The option stays a seed,
deliberately: a brand-new install genuinely has nothing else, and removing it
would make first-launch behaviour depend on the network. - The settings ride on calls already being made, so launch-time network I/O
stays at zero.validatecovers every licensed install and the keyless beacon
covers every unlicensed one; both responses now carrytrial_duration_days
andfree_tier_enabled, and both are absorbed.fetchConfig()reads the
dedicatedGET /{tenant}/{product}/configroute for hosts that want an
explicit refresh — it is not on the launch path, and a test asserts that
resolving state never touches it.fetchConfig()never throws. sdk_trial_duration_dayson activate and validate — the length the build
was configured with, not the effective one, since echoing the server's own
number back diagnoses nothing. It catches the ordinary mistake of a 30-day
build against a 14-day dashboard setting. Diagnostic only: the server must
never gate on it, because a patched client sends whatever its author wants.ACCOUNT.PRODUCT_CONFIG("product_config"), matching the Rustaccount
module, and theCachedProductConfig/ProductConfigFieldstypes.
Fixed
0is a real setting and absence is not zero. The cached pair is stored
as optional fields and merged one at a time, so a worker that sends neither
field leaves what the install already learned alone, a server0survives a
relaunch as0rather than falling back to the seed, and a server
free_tier_enabled: falseis not mistaken for "never heard".startTrial()stamps the clock even at a zero duration. Once the duration
is server-owned,0is indistinguishable from "the config has not arrived
yet", so bailing out left no start timestamp for a later-arriving duration to
measure and the user never got the trial their tenant enabled. The stamp
grants nothing on its own. An existing stamp is never overwritten, so enabling
a trial 60 days after an install does not hand it a fresh window.
Notes
- No breaking change: object literals are name-based, so nothing in
KeylightOptionsmoved or was added. - 157 → 173 tests.
- Ports the contract shipped in
keylight-cpp0.2.0/0.2.1; see that repo's
docs/superpowers/specs/2026-09-05-trial-parity-handoff.md.
v0.2.0
[0.2.0] - 2026-09-04
Added
-
A keyless heartbeat, on by default.
reportKeylessStateused to go out
only when the app called it, which in practice is once at startup — so an
Electron app or a resident Node service reported itself once and then looked
dead to the dashboard for as long as it ran:last_seennever moved past
first_seen, and the reported app version froze at whatever shipped that day.checkOnLaunch()now starts a cadence (keylessHeartbeatMs, default 6h to
match the Worker's server-side gate on keyless writes);startKeylessHeartbeat()
is public for apps that never callcheckOnLaunch. Each tick beacons only
when the device is keyless — a licensed device sends nothing and reports
liveness through/validate— and the timer keeps running across that
boundary so a lapsed license resumes on its own.Nothing to change in your app.
reportKeylessStatestill debounces to one
request per 24h, so the cadence costs nothing extra on the wire. The timer is
unref'd on Node/Bun so a six-hour interval can't keep a one-shot script
alive, andstopKeylessHeartbeat()disposes it. Pass
keylessHeartbeatMs: nullto opt out. -
Device dimensions on the telemetry fields:
archandos_version.
Requests that already carry telemetry (activate,validate, the keyless
beacon) now also report the CPU architecture as a canonical token (arm64/
x86_64; anything else is omitted) and the host OS's release version reduced
to its dotted-numeric form (a Linux kernel release like6.8.0-45-genericis
sent as6.8.0; on macOS it is the marketing version fromsw_vers, e.g.
26.1, matching what the Swift and Rust SDKs report — not the Darwin kernel
version, which would place the same macOS release in a different bucket. The
sw_verscall runs at most once per process, and where it cannot run the
version is omitted rather than reported in the other vocabulary). Both fields
are optional on the wire and only sent where a host OS exists to ask: under
Node, Bun, Electron and Deno — on Deno the macOS version needs--allow-run,
and is omitted rather than prompting for it. Browser pages and edge isolates
send neither — there
is no reliable source there that isn't also a fingerprinting surface, and
this SDK keeps its stance of not reading one.device_classis never sent;
the backend derives it from the OS. No app code changes. -
Coarse device capacity on the same telemetry fields:
cpu_coresand
memory. Requests that already carry telemetry report which bucket the
machine falls in —1-2,3-4,5-8,9-16,17+cores, and<4GB,
4-8GB,8-16GB,16-32GB,32-64GB,64GB+of RAM. The exact core count
and the exact byte figure never leave the machine: a licensing SDK reporting
precise hardware specs reads as fingerprinting, so only the bucket is sent.
Bucket edges match the other SDKs exactly, so one machine population never
splits across two buckets. Both fields are optional on the wire and only sent
where a host OS exists to ask (Node, Bun, Electron, Deno). Browser pages and
edge isolates send neither:navigator.hardwareConcurrencyand
navigator.deviceMemoryare documented fingerprinting surfaces and this SDK
declines to read them, the same stance it takes onarch. No app code
changes.
v0.1.6
[0.1.6] - 2026-08-01
Changed
-
platformnow reports the operating system, not the JavaScript runtime.
It previously sentnode/deno/bun/web, which meant every Electron
app reportednodeno matter which OS it ran on — so a dashboard could not
tell a Windows install base from a macOS one, and the breakdown was not
comparable with the Swift, Rust, C++ and C# SDKs, all of which report the OS.
It now sends the same canonical tokens they do:macos,windows,linux
(and an unmappedprocess.platformvalue verbatim, e.g.freebsd).Where there is genuinely no host OS to report — a page in a browser, an
isolate on the edge — the runtime token is still the honest answer and is
kept:web,workers,unknown. The SDK deliberately does not read
navigator.userAgentData.platformto guess a browser's OS; it is
Chromium-only and a fingerprinting surface, and this SDK avoids the
User-Agent for the same reason it avoids it ininstance_name.No app code changes. If you have been reading the platform breakdown, expect
nodeto stop growing andmacos/windows/linuxto start; historical
rows keep their old token.
Added
sdkfield identifying this SDK on activate/validate/keyless calls.
Sendsjs. Keylight used to work out which SDK a device ran from the shape
of itsplatformtoken — feasible only while each SDK had its own
vocabulary, which the change above ends. Exported asSDK_ID. Requires no
action from you; older SDK versions keep working, and the server falls back to
the previous inference when the field is absent.
Added
activeRevalidate()— forced, 60 s-debounced revalidation for active use.
Call it when the user brings the app forward (window focus, popover open,
route change) so a dashboard revoke lands within a minute instead of waiting
for the next launch. It bypassesrefreshIfNeeded's staleness gates
(5 min / 6 h / 24 h) and sharescheckOnLaunch's reconciliation: a definitive
server rejection downgrades immediately, a network blip never downgrades a
live session, and it never throws. The debounce is held in memory only, so a
process restart or page reload always revalidates. Mirrors the Swift SDK's
activeRevalidate().
Fixed
- The
activeRevalidate()debounce no longer follows the wall clock. It now
measures elapsed time withperformance.now(). Because the debounce
suppresses revalidation, a system clock moved backwards previously
suppressed revocation enforcement for the size of the jump — indefinitely, if
the clock stayed back. Falls back to the previous behaviour only where
performanceis unavailable.
v0.1.5
[0.1.5] - 2026-07-29
Added
activeRevalidate()— forced, 60 s-debounced revalidation for active use.
Call it when the user brings the app forward (window focus, popover open,
route change) so a dashboard revoke lands within a minute instead of waiting
for the next launch. It bypassesrefreshIfNeeded's staleness gates
(5 min / 6 h / 24 h) and sharescheckOnLaunch's reconciliation: a definitive
server rejection downgrades immediately, a network blip never downgrades a
live session, and it never throws. The debounce is held in memory only, so a
process restart or page reload always revalidates. Mirrors the Swift SDK's
activeRevalidate().
Fixed
- The
activeRevalidate()debounce no longer follows the wall clock. It now
measures elapsed time withperformance.now(). Because the debounce
suppresses revalidation, a system clock moved backwards previously
suppressed revocation enforcement for the size of the jump — indefinitely, if
the clock stayed back. Falls back to the previous behaviour only where
performanceis unavailable.
v0.1.4
[0.1.4] - 2026-07-17
Added
stableDeviceIdoption for browser-like environments. When no
OS/hardware machine id is available (browser, Deno, Workers), a host app can
now supply its own stable identifier — typically a user/account id — as a
string or (async) function. It is never sent raw: it is hashed with the same
tenant/product-scoped material as the hardware machine id, and a hardware id
(Node/Bun) always takes precedence. A null/empty value behaves as unset.
Note: changing the supplied value changes the device identity server-side.machine_hashon activate and validate. The same cross-SDK device hash
the keyless beacon sends now accompaniesactivateandvalidate, so a
device that converts from keyless to licensed (or keeps validating) counts
as one daily-active device instead of two.- Durable browser storage fallbacks:
IndexedDbStoreandCookieStore.
makeDefaultStorenow layers localStorage → fs (Node/Bun) → IndexedDB
(probed with a real open) → cookies (probed with a write round-trip) →
memory, so one browser profile keeps one stablefree_tier_instance_id
across page loads even where localStorage is unavailable (sandboxed iframes,
some privacy modes). Both stores are also exported for explicit use.
Changed
reportKeylessState(andreportFreeTier) now return a boolean —true
when the state is considered reported (HTTP 200, or a still-fresh <24h
debounce for an unchanged state),falsewhen the send failed. They still
never throw, and the debounce state is persisted only on a successful 200,
so a transient failure no longer suppresses re-sends for a day.
v0.1.3
[0.1.3] - 2026-07-09
Added
- Privacy-safe machine identity on keyless beacons. The keyless/free-tier
heartbeat now sends a one-waymachine_hashderived from a stable hardware
identifier —/etc/machine-id(Linux),IOPlatformUUID(macOS), or
MachineGuid(Windows) — namespaced to your tenant and product, so the
dashboard counts one device per physical machine instead of per install (a
reinstall updates the same free-tier row instead of creating a duplicate).
Read lazily on Node/Bun only, with no new dependency; omitted in the browser,
Deno, and Workers, where the SDK keeps using its per-install id. Only the
SHA-256 hash is transmitted — the raw identifier never leaves the machine.
Byte-for-byte identical to the Swift and Rust SDKs for the same inputs. Inject
a custom resolver via themachineIdoption for tests.
v0.1.1
[0.1.1] - 2026-06-12
- Documentation and release-tooling fixes.