Five packaging defects, all pre-existing:
1. dist/esm/index.js held ESM syntax while the package is not
"type": "module", so Node classified it as CommonJS. It failed
outright on Node 18 ("Unexpected token 'export'") and only worked on
Node >= 22 because Node re-parses after guessing the module type,
paying that cost on every import. Bundles now carry explicit
extensions: .mjs for ES output, .cjs/.js for CommonJS. The browser
build gained a real CommonJS output too — the exports map previously
pointed the browser "require" condition at an ES module.
The exports map now also leads with "types" and ends with a "default"
fallback for resolvers matching neither "node" nor "browser".
2. files: ["native"] published the local build directory: a 94.6 kB
Linux-x64 .node binary, a 148 kB object file and generated Makefiles.
node-gyp-build checks build/Release before prebuilds, so every
consumer on every platform would have found this machine's binary,
skipped compiling, and failed to load it. It fails safe (native/
index.js catches and returns null), but the addon could never work
for anyone else. Narrowed to the four source files.
3. binding.gyp resolves node-addon-api at build time, but nothing
declared it: it was a devDependency of the root, absent from
native/package.json. The build only succeeded here because a dev
install populates the root node_modules. Declared as a dependency of
the native package, where it is actually needed.
4. No clean step, so stale output shipped — the tarball carried both
dist/types/core/** and a dist/types/src/** tree left over from before
rootDir was set. build now runs clean first.
5. test:browser ran `karma start` with no karma.conf.js anywhere in the
repo, and tests/browser is an empty directory. Removed the script and
the karma devDependency rather than leave a script that cannot run.
Verified: CommonJS require and ESM import both resolve on Node 18.19.1
and 24.18.0; TypeScript resolves types under both bundler and node16;
npm pack now produces 35 files / 103.8 kB with no build artefacts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the parity work (step 4).
Request timeout now defaults to 900 s, matching Python, instead of 60 s.
Encrypted inference cannot stream, so an entire completion arrives in one
response; a long generation on a busy backend legitimately takes minutes
and was timing out here while succeeding in the Python client.
Error types now distinguish malformed data from integrity failures.
Python raises ValueError for a bad package, a non-200 or unparseable
/pki/public_key, and plaintext that will not parse, reserving
SecurityError for crypto failures. This port wrapped nearly all of it in
SecurityError — so a server sending malformed JSON was reported as an
authentication failure, pointing debugging in exactly the wrong
direction. Malformed data is now a plain Error (the JS equivalent of
ValueError), carried past the deliberately opaque catch-all by a symbol
marker rather than a new exported class. Genuine crypto failures still
report a single vague message so they cannot serve as a decryption
oracle.
Also adds the missing guard Python has: decrypting without a private key
now says so, instead of failing later and being reported as an integrity
failure.
doc/attestation.md ports the Python attestation guide to the JS API, and
documents the two deliberate divergences: no verify_ssl escape hatch, and
jose injection instead of a runtime dynamic import.
Version 0.1.0 -> 0.3.0 to match the Python client's feature level, now
that the two are at parity.
Not ported: Python's warning when secure_memory=True but the SecureMemory
module is unavailable. There is no JS equivalent — zeroing is always
available, and the weaker case (mlock unavailable) is already reported
honestly by getProtectionInfo().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Memory protection (step 3 of the Python parity work):
NodeSecureMemory has implemented lockMemory/unlockMemory since the
native addon landed, but nothing ever called them — mlock was dead code
while getProtectionInfo() reported method: 'mlock' and canLock: true.
The library claimed a protection it was not applying.
- SecureByteContext now locks on entry and unlocks on exit, mirroring
Python's secure_bytearray(lock=True). Zeroing happens *before*
unlocking, so cleartext cannot reach swap in between.
- lockMemory/unlockMemory are part of the SecureMemory interface, so
the browser implementation must answer for them explicitly (false).
- Locking is best-effort throughout: a refused or throwing lock
degrades to zeroing only, which still has value.
getProtectionInfo() now probes rather than assumes. Having the addon
loaded is not the same as being allowed to lock: mlock is routinely
refused by RLIMIT_MEMLOCK, which is small by default and 0 in some
containers. canLock reflects a real mlock attempt, and 'mlock' is only
claimed when locking genuinely works — otherwise it reports zero-only
and says why. ProtectionInfo also carries platform, hasSecureZeroing
and pageSize, closer to Python's get_protection_info().
SAST (ts.code_exec.new_function, ERROR):
JwtQuoteVerifier loaded ESM-only jose via new Function('s', 'return
import(s)'). No user input reached it, so it was not code injection —
but new Function is blocked by any CSP without 'unsafe-eval', and this
package ships a browser bundle, so the failure would land in the
attestation path. Removed in favour of injection: pass the module as
options.jose when require('jose') cannot work. The error message says
so. No eval-equivalent remains in src/.
.nyx/triage.json records the two native/src/mlock.cc cfg-resource-leak
warnings as false positives, scoped to that file rather than the rule,
so a genuine leak in future C++ still surfaces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BREAKING CHANGE: keyDir now defaults to null (ephemeral) instead of
'client_keys'. Clients that relied on keys persisting across restarts
must now pass keyDir explicitly.
The Python SDK defaults to key_dir=None: a key pair is generated in
memory for the session and never written to disk. The JS port defaulted
to 'client_keys' and always persisted, so merely constructing a client
wrote an RSA private key into the working directory. That is a weaker
default than the client it ports, and one users never asked for.
- keyDir?: string | null, defaulting to undefined. null and undefined
both mean ephemeral, matching Python's None.
- Persistent mode is unchanged when keyDir is set: load the existing
pair from that directory, otherwise generate and save one there.
- Browsers are always ephemeral; they have no filesystem.
Key rotation follows the same rule. It previously hardcoded
'client_keys' as its fallback directory, so an ephemeral client would
have started writing private keys to disk on the first rotation tick.
Rotated keys are now persisted only where keyDir or keyRotationDir is
explicitly configured.
Tests assert the intent (that saveKeys is never called) rather than
probing the filesystem, since a leftover client_keys/ from the old
default would otherwise make them pass or fail for the wrong reason.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three independent breakages, all present on main before this branch:
1. `npm ci` always exited non-zero. The root package.json carried
"install": "node-gyp-build", but binding.gyp lives in native/, so the
script ran in a directory with nothing to build:
gyp: binding.gyp not found (cwd: <repo root>)
native/package.json already declares that same install script in the
right place, alongside its binding.gyp and "gypfile": true, so the
root copy was a duplicate in the wrong package. Removing it also
clears the now-inaccurate hasInstallScript flag from the lockfile.
`npm ci` exits 0 again; the addon still builds from native/.
2. `npm run build` failed at the first step. @rollup/plugin-typescript
requires tslib as a peer, and nothing depended on it directly — it was
only present transitively as an optional dev dep, so a clean install
could omit it entirely. Declared explicitly.
3. rollup.config.js used ESM syntax while package.json has no
"type": "module", so Node parsed it as CommonJS and threw
"Cannot use import statement outside a module". Node 24 recovers by
reparsing (with a warning); Node 18 fails outright. Renamed to
rollup.config.mjs, which is unambiguous on both. Setting
"type": "module" instead would have broken jest.config.js, which is
CommonJS.
Verified on Node 18.19.1 and Node 24.18.0: npm ci exits 0, npm run build
produces all three bundles plus declarations, and 76/76 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dependency bumps on main left `npm test` and `npm run build:types`
broken independently of any feature work; both fail on a clean checkout
of origin/main.
TypeScript 6 no longer auto-includes @types packages the way node10
resolution did, so every test suite failed to compile with "Cannot find
name 'describe'/'expect'". Declare the needed @types explicitly instead:
- tsconfig.json: types: ["node"]
- jest.config.js: types: ["jest", "node"]
TypeScript 6 also errors on two settings this config relied on:
- moduleResolution "node" (node10) is deprecated -> "bundler", which
matches how the package is actually consumed (rollup-bundled, with
"module": "ESNext")
- an implicit rootDir is now an error when outDir/declarationDir are
set -> rootDir: "./src"
Drop three unused imports that noUnusedLocals turns into hard errors,
failing --emitDeclarationOnly.
Finally, NodeSecureMemory logged to stdout unconditionally on
construction. This was dormant while the native addon failed to load;
once it loads, it broke the "no console.log when debug=false" test. A
library must not write to stdout uninvited, and the client already
reports this via getProtectionInfo() behind its own debug flag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>