Skip to content

fix: give each client its own API key instead of a shared global - #7

Merged
mikipalet merged 1 commit into
mainfrom
fix/per-instance-client
Aug 7, 2026
Merged

fix: give each client its own API key instead of a shared global#7
mikipalet merged 1 commit into
mainfrom
fix/per-instance-client

Conversation

@mikipalet

Copy link
Copy Markdown
Member

Why

The auth interceptor was registered on the module-global HTTP client that sdk.gen.ts exports, so every new Zernio({ apiKey }) stacked another interceptor onto the same shared client. All of them run on every request, each overwriting Authorization, so the last-constructed key won for all in-flight requests.

In a process serving more than one tenant, one tenant's call could go out carrying another tenant's token and return their data. Two concurrent requests in one instance is enough. Interceptors were also never removed, so they accumulated for the process lifetime.

const a = new Zernio({ apiKey: "sk_AAAA" });
const b = new Zernio({ apiKey: "sk_BBBB" });
await a.accounts.listAccounts({});
// before: Bearer sk_BBBB     after: Bearer sk_AAAA

Reported as zernio-dev/zernflow#18, found while fixing an unrelated issue there.

What

  • Each Zernio instance creates its own client via createClient(createConfig(...)). Every namespace method is routed through it by _bind, which reads _client at call time rather than bind time, because class fields initialize before the constructor body runs. _bind<F>(op: F): F preserves each operation's declared signature, so no consumer-visible type changes.
  • baseURL and defaultHeaders move onto that client for the same reason: they were global too.
  • scripts/generate-client.ts emits this shape. Verified byte-identical to the committed src/client.ts against the current live spec, so the next automated regeneration cannot silently revert it.
  • tests/isolation.test.ts pins the behaviour: per-instance keys, an earlier instance surviving a later construction, no interceptor stacking across 25 instances, per-instance baseURL and defaultHeaders, and errors still surfacing.

Compatibility

The raw operations and the global client are not exported from src/index.ts (it exports only Zernio, the error classes, and types.gen), so no documented consumer depended on the global being configured. npm run lint, typecheck and all 173 tests pass; 4 of the 6 new tests fail on the pre-fix client.

🤖 Generated with Claude Code

The auth interceptor was registered on the module-global HTTP client that
sdk.gen.ts exports, so every `new Zernio({ apiKey })` stacked another
interceptor onto the same shared client. All of them ran on every request, each
overwriting Authorization, so the last-constructed key won for ALL in-flight
requests.

In a process serving more than one tenant, that means one tenant's call could go
out carrying another tenant's token and return their data. Two concurrent
requests in the same instance are enough. Interceptors were also never removed,
so they accumulated for the lifetime of the process.

Each Zernio instance now creates its own client via createClient/createConfig
and every namespace method is routed through it by `_bind`, which reads
`_client` at call time (class fields initialize before the constructor body).
baseURL and defaultHeaders move to that client for the same reason.

The raw operations and the global client are not exported from src/index.ts, so
no documented consumer depended on the global being configured.

scripts/generate-client.ts emits this shape, verified byte-identical to the
committed src/client.ts against the current live spec, so the next automated
regeneration cannot revert it.

tests/isolation.test.ts pins the behaviour; 4 of its 6 cases fail on the
pre-fix client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant