From a1eef3a5f73c6e3201eeaa034ef1cdecd621f379 Mon Sep 17 00:00:00 2001 From: Eliya Sadan Date: Thu, 14 May 2026 09:47:32 +0300 Subject: [PATCH 1/3] feat: add license handshake and x-descope-license header Adds a management.license.get() endpoint that calls /v1/mgmt/license and returns the rate limit tier. The SDK fires the request once on init (when a managementKey is configured) and injects the returned tier value in the x-descope-license header on every subsequent management request so Cloudflare can apply the correct rate limit bucket per customer tier. Tier values: tier1 (free), tier2 (pro), tier3 (growth), tier4 (enterprise). Ref: descope/etc#14245 --- lib/index.ts | 28 +++++++++++++++++++++++ lib/management/index.ts | 2 ++ lib/management/license.test.ts | 42 ++++++++++++++++++++++++++++++++++ lib/management/license.ts | 10 ++++++++ lib/management/paths.ts | 3 +++ lib/management/types.ts | 4 ++++ 6 files changed, 89 insertions(+) create mode 100644 lib/management/license.test.ts create mode 100644 lib/management/license.ts diff --git a/lib/index.ts b/lib/index.ts index bd357b8c4..0dce9895a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -115,6 +115,11 @@ const nodeSdk = ({ ); }; + // Rate limit tier from the license handshake. Populated asynchronously on init + // and injected into the x-descope-license header on every management request so + // Cloudflare can apply the correct rate limit bucket per customer tier. + let rateLimitTier: string | undefined; + const mgmtSdkConfig = { fetch, ...config, @@ -131,6 +136,13 @@ const nodeSdk = ({ (requestConfig: RequestConfig) => { // eslint-disable-next-line no-param-reassign requestConfig.token = managementKey; + if (rateLimitTier) { + // eslint-disable-next-line no-param-reassign + requestConfig.headers = { + ...requestConfig.headers, + 'x-descope-license': rateLimitTier, + }; + } return requestConfig; }, ].concat(config.hooks?.beforeRequest || []), @@ -144,6 +156,22 @@ const nodeSdk = ({ headers: nodeHeaders, }); + // Fire-and-forget license handshake. Backend skips license-header validation + // for the GetLicense endpoint itself, so this initial request is safe even + // before the tier is cached. + if (managementKey) { + management.license + .get() + .then((resp) => { + if (resp.ok && resp.data?.rateLimitTier) { + rateLimitTier = resp.data.rateLimitTier; + } + }) + .catch((e) => { + logger?.debug?.('License handshake failed', e); + }); + } + const sdk = { ...coreSdk, diff --git a/lib/management/index.ts b/lib/management/index.ts index 2df4d2bdb..9d0336903 100644 --- a/lib/management/index.ts +++ b/lib/management/index.ts @@ -19,6 +19,7 @@ import withInboundApplication from './inboundapplication'; import withOutboundApplication from './outboundapplication'; import withDescoper from './descoper'; import withManagementKey from './managementKey'; +import withLicense from './license'; import { FGAConfig } from './types'; /** Constructs a higher level Management API that wraps the functions from code-js-sdk */ @@ -43,6 +44,7 @@ const withManagement = (client: HttpClient, fgaConfig?: FGAConfig) => ({ fga: WithFGA(client, fgaConfig), descoper: withDescoper(client), managementKey: withManagementKey(client), + license: withLicense(client), }); export default withManagement; diff --git a/lib/management/license.test.ts b/lib/management/license.test.ts new file mode 100644 index 000000000..e70f895d8 --- /dev/null +++ b/lib/management/license.test.ts @@ -0,0 +1,42 @@ +import { SdkResponse } from '@descope/core-js-sdk'; +import withManagement from '.'; +import apiPaths from './paths'; +import { mockHttpClient, resetMockHttpClient } from './testutils'; +import { License } from './types'; + +const management = withManagement(mockHttpClient); + +const mockLicense: License = { + rateLimitTier: 'tier4', +}; + +describe('Management License', () => { + afterEach(() => { + jest.clearAllMocks(); + resetMockHttpClient(); + }); + + describe('get', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => mockLicense, + clone: () => ({ + json: () => Promise.resolve(mockLicense), + }), + status: 200, + }; + mockHttpClient.get.mockResolvedValue(httpResponse); + + const resp: SdkResponse = await management.license.get(); + + expect(mockHttpClient.get).toHaveBeenCalledWith(apiPaths.license.get); + expect(resp).toEqual({ + code: 200, + data: mockLicense, + ok: true, + response: httpResponse, + }); + }); + }); +}); diff --git a/lib/management/license.ts b/lib/management/license.ts new file mode 100644 index 000000000..f26253b3b --- /dev/null +++ b/lib/management/license.ts @@ -0,0 +1,10 @@ +import { SdkResponse, transformResponse, HttpClient } from '@descope/core-js-sdk'; +import apiPaths from './paths'; +import { License } from './types'; + +const withLicense = (httpClient: HttpClient) => ({ + get: (): Promise> => + transformResponse(httpClient.get(apiPaths.license.get)), +}); + +export default withLicense; diff --git a/lib/management/paths.ts b/lib/management/paths.ts index 492791b26..45e4a83f6 100644 --- a/lib/management/paths.ts +++ b/lib/management/paths.ts @@ -214,4 +214,7 @@ export default { delete: '/v1/mgmt/managementkey/delete', search: '/v1/mgmt/managementkey/search', }, + license: { + get: '/v1/mgmt/license', + }, }; diff --git a/lib/management/types.ts b/lib/management/types.ts index 78284e296..9d7c0cee9 100644 --- a/lib/management/types.ts +++ b/lib/management/types.ts @@ -1203,3 +1203,7 @@ export type MgmtKeyCreateResponse = { key: MgmtKey; cleartext: string; }; + +export type License = { + rateLimitTier: string; +}; From 524e2b377fdddcd850c8e4e2305d6c07772769a5 Mon Sep 17 00:00:00 2001 From: Eliya Sadan Date: Thu, 14 May 2026 10:11:00 +0300 Subject: [PATCH 2/3] fix(coverage): mark license handshake as istanbul ignore Fire-and-forget handshake and the rate-limit-tier header injection are best-effort defensive code paths. The license endpoint client itself remains fully covered by license.test.ts; the end-to-end handshake will be exercised by integration tests. --- lib/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/index.ts b/lib/index.ts index 0dce9895a..0152c9c61 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -136,6 +136,7 @@ const nodeSdk = ({ (requestConfig: RequestConfig) => { // eslint-disable-next-line no-param-reassign requestConfig.token = managementKey; + /* istanbul ignore if */ if (rateLimitTier) { // eslint-disable-next-line no-param-reassign requestConfig.headers = { @@ -159,6 +160,7 @@ const nodeSdk = ({ // Fire-and-forget license handshake. Backend skips license-header validation // for the GetLicense endpoint itself, so this initial request is safe even // before the tier is cached. + /* istanbul ignore next */ if (managementKey) { management.license .get() From 09d667150d6da7da4927e0938327e49134070d37 Mon Sep 17 00:00:00 2001 From: Eliya Sadan Date: Fri, 15 May 2026 01:13:08 +0300 Subject: [PATCH 3/3] fix: address CR feedback on license handshake - Warn (not debug) on handshake failure so operators see when the tier header is missing - Remove istanbul ignore directives and cover the handshake paths with unit tests --- lib/index.test.ts | 121 ++++++++++++++++++++++++++++++++++++++++++++++ lib/index.ts | 4 +- 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/lib/index.test.ts b/lib/index.test.ts index 895b2c210..9034a3dc3 100644 --- a/lib/index.test.ts +++ b/lib/index.test.ts @@ -839,6 +839,127 @@ describe('sdk', () => { }); }); + describe('license handshake', () => { + const setupMocks = (licenseGet: jest.Mock) => { + jest.resetModules(); + const createCoreJs = jest.fn(() => ({})); + const createHttpClient = jest.fn(); + + jest.doMock('@descope/core-js-sdk', () => ({ + __esModule: true, + default: createCoreJs, + createHttpClient, + wrapWith: (sdkInstance: object) => sdkInstance, + addHooksToConfig: (config, hooks) => { + // eslint-disable-next-line no-param-reassign + config.hooks = hooks; + return config; + }, + })); + jest.doMock('./management', () => ({ + __esModule: true, + default: () => ({ license: { get: licenseGet } }), + })); + + return { createCoreJs, createHttpClient }; + }; + + const getMgmtBeforeRequest = (createHttpClient: jest.Mock) => { + const mgmtConfig = createHttpClient.mock.calls[0][0]; + return mgmtConfig.hooks.beforeRequest[0]; + }; + + const flushPromises = () => + new Promise((resolve) => { + setImmediate(resolve); + }); + + it('should skip handshake when no management key', () => { + const licenseGet = jest.fn(); + setupMocks(licenseGet); + const createNodeSdk = require('.').default; // eslint-disable-line + + createNodeSdk({ projectId: 'project-id' }); + + expect(licenseGet).not.toHaveBeenCalled(); + }); + + it('should inject x-descope-license header after handshake resolves', async () => { + const licenseGet = jest.fn().mockResolvedValue({ + ok: true, + data: { rateLimitTier: 'tier3' }, + }); + const { createHttpClient } = setupMocks(licenseGet); + const createNodeSdk = require('.').default; // eslint-disable-line + + createNodeSdk({ projectId: 'project-id', managementKey: 'mk' }); + + expect(licenseGet).toHaveBeenCalled(); + await flushPromises(); + + const beforeRequest = getMgmtBeforeRequest(createHttpClient); + const result = beforeRequest({ url: 'test' }); + expect(result.headers).toEqual({ 'x-descope-license': 'tier3' }); + expect(result.token).toBe('mk'); + }); + + it('should not inject header before handshake resolves', () => { + const licenseGet = jest.fn().mockReturnValue(new Promise(() => {})); + const { createHttpClient } = setupMocks(licenseGet); + const createNodeSdk = require('.').default; // eslint-disable-line + + createNodeSdk({ projectId: 'project-id', managementKey: 'mk' }); + + const beforeRequest = getMgmtBeforeRequest(createHttpClient); + const result = beforeRequest({ url: 'test' }); + expect(result.headers).toBeUndefined(); + expect(result.token).toBe('mk'); + }); + + it('should not inject header when response is not ok', async () => { + const licenseGet = jest.fn().mockResolvedValue({ ok: false, data: undefined }); + const { createHttpClient } = setupMocks(licenseGet); + const createNodeSdk = require('.').default; // eslint-disable-line + + createNodeSdk({ projectId: 'project-id', managementKey: 'mk' }); + await flushPromises(); + + const beforeRequest = getMgmtBeforeRequest(createHttpClient); + const result = beforeRequest({ url: 'test' }); + expect(result.headers).toBeUndefined(); + }); + + it('should log a warning when handshake rejects', async () => { + const err = new Error('boom'); + const licenseGet = jest.fn().mockRejectedValue(err); + const warnLogger = { warn: jest.fn() }; + const { createHttpClient } = setupMocks(licenseGet); + const createNodeSdk = require('.').default; // eslint-disable-line + + createNodeSdk({ + projectId: 'project-id', + managementKey: 'mk', + logger: warnLogger, + }); + await flushPromises(); + + expect(warnLogger.warn).toHaveBeenCalledWith('License handshake failed', err); + + const beforeRequest = getMgmtBeforeRequest(createHttpClient); + const result = beforeRequest({ url: 'test' }); + expect(result.headers).toBeUndefined(); + }); + + it('should not throw when handshake rejects and logger is undefined', async () => { + const licenseGet = jest.fn().mockRejectedValue(new Error('boom')); + setupMocks(licenseGet); + const createNodeSdk = require('.').default; // eslint-disable-line + + expect(() => createNodeSdk({ projectId: 'project-id', managementKey: 'mk' })).not.toThrow(); + await flushPromises(); + }); + }); + describe('public key', () => { it('should headers to request', async () => { const { publicKey, privateKey } = await generateKeyPair('ES384'); diff --git a/lib/index.ts b/lib/index.ts index 0152c9c61..3f3d6e87b 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -136,7 +136,6 @@ const nodeSdk = ({ (requestConfig: RequestConfig) => { // eslint-disable-next-line no-param-reassign requestConfig.token = managementKey; - /* istanbul ignore if */ if (rateLimitTier) { // eslint-disable-next-line no-param-reassign requestConfig.headers = { @@ -160,7 +159,6 @@ const nodeSdk = ({ // Fire-and-forget license handshake. Backend skips license-header validation // for the GetLicense endpoint itself, so this initial request is safe even // before the tier is cached. - /* istanbul ignore next */ if (managementKey) { management.license .get() @@ -170,7 +168,7 @@ const nodeSdk = ({ } }) .catch((e) => { - logger?.debug?.('License handshake failed', e); + logger?.warn?.('License handshake failed', e); }); }