Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to the Late Node.js SDK will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.519] - 2026-08-07

### Fixed
- **Security: API keys leaked between client instances.** The auth interceptor was registered on a module-global HTTP client, so every `new Zernio({ apiKey })` stacked another interceptor on the same shared client and the last-constructed key won for **all** in-flight requests. In a process serving more than one tenant (a server handling concurrent requests, a worker looping over accounts), one tenant's call could go out carrying another tenant's token and return their data. Each instance now owns its client, and `baseURL` and `defaultHeaders` are per-instance for the same reason. Upgrade if you construct more than one `Zernio` in a process.

Releases between 0.1.0 and 0.2.518 were automated regenerations from the OpenAPI spec and are not itemised here.

## [0.1.0] - 2025-01-16

### Added
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zernio/node",
"version": "0.2.518",
"version": "0.2.519",
"description": "The official Node.js library for the Zernio API",
"main": "dist/index.js",
"module": "dist/index.mjs",
Expand Down
65 changes: 47 additions & 18 deletions scripts/generate-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@ function generateClientCode(namespaces: Map<string, OperationInfo[]>): string {

// Generate imports
const imports = `import packageJson from '../package.json';
import { createClient, createConfig, type Client } from '@hey-api/client-fetch';
import {
client,
${allFunctions.map(f => ` ${f},`).join('\n')}
} from './generated/sdk.gen';

Expand Down Expand Up @@ -239,14 +239,14 @@ export interface ClientOptions {

// Add flat methods
for (const op of flat) {
connectCode += `\n ${op.methodName}: ${op.functionName},`;
connectCode += `\n ${op.methodName}: this._bind(${op.functionName}),`;
}

// Add nested namespaces
for (const [subName, subOps] of nested.entries()) {
connectCode += `\n ${subName}: {`;
for (const op of subOps) {
connectCode += `\n ${op.methodName}: ${op.functionName},`;
connectCode += `\n ${op.methodName}: this._bind(${op.functionName}),`;
}
connectCode += `\n },`;
}
Expand All @@ -262,7 +262,7 @@ export interface ClientOptions {
${namespace} = {`;

for (const op of operations) {
code += `\n ${op.methodName}: ${op.functionName},`;
code += `\n ${op.methodName}: this._bind(${op.functionName}),`;
}

code += `\n };`;
Expand Down Expand Up @@ -294,7 +294,7 @@ export interface ClientOptions {
// pointing at its exact new namespace.
for (const { ns, op } of adAliasEntries) {
adsCode += `\n /** @deprecated Use \`zernio.${ns}.${op.methodName}\` instead. */`;
adsCode += `\n ${op.methodName}: ${op.functionName},`;
adsCode += `\n ${op.methodName}: this._bind(${op.functionName}),`;
}
adsCode += `\n };`;
namespaceProps.push(adsCode);
Expand Down Expand Up @@ -331,6 +331,13 @@ export interface ClientOptions {
export class Zernio {
private _options: ClientOptions;

/**
* HTTP client owned by this instance. Every namespace method below is bound
* to it, so two Zernio instances in the same process never see each other's
* credentials.
*/
private _client!: Client;

/**
* API key used for authentication.
*/
Expand All @@ -341,6 +348,22 @@ export class Zernio {
*/
baseURL: string;

/**
* Routes a generated operation through this instance's client, preserving the
* operation's own signature. Reads \`_client\` when the method is called, not
* when it is bound, because class fields initialize before the constructor
* body runs.
*/
private _bind<F>(operation: F): F {
return ((options?: Record<string, unknown>) => {
const withClient = { ...options };
if (withClient['client'] === undefined) {
withClient['client'] = this._client;
}
return (operation as (opts: unknown) => unknown)(withClient);
}) as F;
}

${namespaceProps.join('\n\n')}

/**
Expand All @@ -364,26 +387,32 @@ ${namespaceProps.join('\n\n')}
this.baseURL = options.baseURL ?? 'https://zernio.com/api';
this._options = options;

// Configure the generated client. User-Agent and defaultHeaders are
// applied at config time (not via the interceptor) because Node 20's
// undici treats User-Agent as a forbidden header on already-constructed
// Request objects, silently dropping \`headers.set('User-Agent', …)\`.
client.setConfig({
baseUrl: this.baseURL,
headers: {
'User-Agent': \`zernio-node/\${packageJson.version}\`,
...(options.defaultHeaders ?? {}),
},
});
// One client per instance. Interceptors used to be registered on the
// module-global client, so every \`new Zernio()\` stacked another auth
// interceptor on the same shared client and the last-constructed key won
// for ALL in-flight requests — one tenant's call could carry another
// tenant's token. User-Agent and defaultHeaders are applied at config time
// (not via the interceptor) because Node 20's undici treats User-Agent as a
// forbidden header on already-constructed Request objects, silently
// dropping \`headers.set('User-Agent', …)\`.
this._client = createClient(
createConfig({
baseUrl: this.baseURL,
headers: {
'User-Agent': \`zernio-node/\${packageJson.version}\`,
...(options.defaultHeaders ?? {}),
},
})
);

// Add auth interceptor
client.interceptors.request.use((request) => {
this._client.interceptors.request.use((request) => {
request.headers.set('Authorization', \`Bearer \${this.apiKey}\`);
return request;
});

// Add error handling interceptor
client.interceptors.response.use(async (response) => {
this._client.interceptors.response.use(async (response) => {
if (!response.ok) {
let body: Record<string, unknown> | undefined;
try {
Expand Down
Loading
Loading