From b909146bb6213a97a39ec2cc15ee5d3bc8e3f593 Mon Sep 17 00:00:00 2001 From: dorsha Date: Sun, 28 Jun 2026 18:09:39 +0300 Subject: [PATCH 1/2] feat(mgmt): add engine management API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add descopeClient.management.engine with create / update / delete / load / loadAll / rotateSecret, mirroring the other management resources. Engines are managed via the management API (e.g. by the Terraform provider); see descope/backend#1614 for the backend endpoints. The engine secret is returned only by create and rotateSecret — load/loadAll never include it. Engine numeric fields are typed as string because the management gateway serializes proto int64 values as JSON strings. Includes the engine module, paths, types, management wiring, README docs, and unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 31 ++++++++ lib/management/engine.test.ts | 142 ++++++++++++++++++++++++++++++++++ lib/management/engine.ts | 74 ++++++++++++++++++ lib/management/index.ts | 2 + lib/management/paths.ts | 8 ++ lib/management/types.ts | 21 +++++ 6 files changed, 278 insertions(+) create mode 100644 lib/management/engine.test.ts create mode 100644 lib/management/engine.ts diff --git a/README.md b/README.md index c2543e01a..808fe3f39 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Then, you can use that to work with the following functions: 15. [Manage SSO applications](#manage-sso-applications) 16. [Manage Management Keys](#manage-management-keys) 17. [Manage Descopers](#manage-descopers) +18. [Manage Engines](#manage-engines) If you wish to run any of our code samples and play with them, check out our [Code Examples](#code-examples) section. @@ -1780,6 +1781,36 @@ await descopeClient.management.descoper.update( await descopeClient.management.descoper.delete('descoper-id'); ``` +### Manage Engines + +You can create, update, delete, load engines, and rotate their secrets. The engine secret +is returned only on create and rotate — store it securely, as it cannot be retrieved again. + +```typescript +// Create an engine. The response includes the generated id and secret. +const createRes = await descopeClient.management.engine.create('My Engine'); +const { id, secret } = createRes.data; // save `secret` securely! + +// Update an engine's name (the response does not include the secret). +await descopeClient.management.engine.update(id, 'Updated Engine Name'); + +// Load a specific engine by id (the secret is always empty). +const engineRes = await descopeClient.management.engine.load(id); + +// Load all engines (secrets are always empty). +const enginesRes = await descopeClient.management.engine.loadAll(); +enginesRes.data.forEach((engine) => { + // do something +}); + +// Rotate an engine's secret. The previous secret is invalidated and the new one returned. +const rotateRes = await descopeClient.management.engine.rotateSecret(id); +const newSecret = rotateRes.data.secret; + +// Engine deletion cannot be undone. Use carefully. +await descopeClient.management.engine.delete(id); +``` + ### Utils for your end to end (e2e) tests and integration tests To ease your e2e tests, we exposed dedicated management methods, diff --git a/lib/management/engine.test.ts b/lib/management/engine.test.ts new file mode 100644 index 000000000..8260affe6 --- /dev/null +++ b/lib/management/engine.test.ts @@ -0,0 +1,142 @@ +import { SdkResponse } from '@descope/core-js-sdk'; +import withManagement from '.'; +import apiPaths from './paths'; +import { Engine, EngineSecretResponse } from './types'; +import { mockHttpClient, resetMockHttpClient } from './testutils'; + +const management = withManagement(mockHttpClient); + +// createdTime/version are int64 in the proto, so the management gateway returns them as +// JSON strings — the SDK types them as string and passes them through verbatim. +const mockEngine: Engine = { + id: 'eng1', + name: 'my-engine', + projectId: 'proj1', + secret: 's3cret', + version: '1', + createdTime: '1719571200', +}; + +describe('Management Engine', () => { + afterEach(() => { + jest.clearAllMocks(); + resetMockHttpClient(); + }); + + describe('create', () => { + it('should send the correct request and return the engine with its secret', async () => { + const httpResponse = { + ok: true, + json: () => ({ engine: mockEngine }), + clone: () => ({ json: () => Promise.resolve({ engine: mockEngine }) }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp: SdkResponse = await management.engine.create('my-engine'); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.engine.create, { + name: 'my-engine', + }); + expect(resp.data).toEqual(mockEngine); + expect(resp.data?.secret).toBe('s3cret'); + }); + }); + + describe('update', () => { + it('should send the correct request', async () => { + const updated = { id: 'eng1', name: 'renamed' }; + const httpResponse = { + ok: true, + json: () => ({ engine: updated }), + clone: () => ({ json: () => Promise.resolve({ engine: updated }) }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp = await management.engine.update('eng1', 'renamed'); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.engine.update, { + id: 'eng1', + name: 'renamed', + }); + expect(resp.data).toEqual(updated); + }); + }); + + describe('delete', () => { + it('should send the correct request', async () => { + const httpResponse = { + ok: true, + json: () => ({}), + clone: () => ({ json: () => Promise.resolve({}) }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + await management.engine.delete('eng1'); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.engine.delete, { + id: 'eng1', + }); + }); + }); + + describe('load', () => { + it('should send the correct request with the id query param', async () => { + const loaded = { id: 'eng1', name: 'my-engine' }; + const httpResponse = { + ok: true, + json: () => ({ engine: loaded }), + clone: () => ({ json: () => Promise.resolve({ engine: loaded }) }), + status: 200, + }; + mockHttpClient.get.mockResolvedValue(httpResponse); + + const resp = await management.engine.load('eng1'); + + expect(mockHttpClient.get).toHaveBeenCalledWith(apiPaths.engine.load, { + queryParams: { id: 'eng1' }, + }); + expect(resp.data).toEqual(loaded); + }); + }); + + describe('loadAll', () => { + it('should send the correct request and return all engines', async () => { + const engines = [{ id: 'eng1' }, { id: 'eng2' }]; + const httpResponse = { + ok: true, + json: () => ({ engines }), + clone: () => ({ json: () => Promise.resolve({ engines }) }), + status: 200, + }; + mockHttpClient.get.mockResolvedValue(httpResponse); + + const resp: SdkResponse = await management.engine.loadAll(); + + expect(mockHttpClient.get).toHaveBeenCalledWith(apiPaths.engine.loadAll, {}); + expect(resp.data).toEqual(engines); + }); + }); + + describe('rotateSecret', () => { + it('should send the correct request and return the new secret', async () => { + const secretResp: EngineSecretResponse = { secret: 'newS3cret' }; + const httpResponse = { + ok: true, + json: () => secretResp, + clone: () => ({ json: () => Promise.resolve(secretResp) }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp = await management.engine.rotateSecret('eng1'); + + expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.engine.rotate, { + id: 'eng1', + }); + expect(resp.data?.secret).toBe('newS3cret'); + }); + }); +}); diff --git a/lib/management/engine.ts b/lib/management/engine.ts new file mode 100644 index 000000000..81d446882 --- /dev/null +++ b/lib/management/engine.ts @@ -0,0 +1,74 @@ +import { SdkResponse, transformResponse, HttpClient } from '@descope/core-js-sdk'; +import apiPaths from './paths'; +import { Engine, EngineSecretResponse } from './types'; + +type SingleEngineResponse = { + engine: Engine; +}; + +type MultipleEnginesResponse = { + engines: Engine[]; +}; + +const withEngine = (httpClient: HttpClient) => ({ + /** + * Create a new engine. The returned engine includes its generated id and secret. + * The secret is returned only here (and on rotateSecret) — store it securely. + * @param name Engine name + * @returns The newly created engine, including its secret + */ + create: (name: string): Promise> => + transformResponse( + httpClient.post(apiPaths.engine.create, { name }), + (data) => data.engine, + ), + /** + * Update an engine's name. The returned engine does not include the secret. + * @param id Engine id to update + * @param name Updated engine name + * @returns The updated engine + */ + update: (id: string, name: string): Promise> => + transformResponse( + httpClient.post(apiPaths.engine.update, { id, name }), + (data) => data.engine, + ), + /** + * Delete an engine. + * @param id Engine id to delete + */ + delete: (id: string): Promise> => + transformResponse(httpClient.post(apiPaths.engine.delete, { id })), + /** + * Load a specific engine by id. The returned engine's secret is always empty. + * @param id Engine id to load + * @returns The loaded engine + */ + load: (id: string): Promise> => + transformResponse( + httpClient.get(apiPaths.engine.load, { queryParams: { id } }), + (data) => data.engine, + ), + /** + * Load all engines for the project. The returned engines' secrets are always empty. + * @returns An array of all engines + */ + loadAll: (): Promise> => + transformResponse( + httpClient.get(apiPaths.engine.loadAll, {}), + (data) => data.engines, + ), + /** + * Rotate an engine's secret, invalidating the previous one. The new secret is + * returned in cleartext — store it securely, as it cannot be retrieved again. + * @param id Engine id whose secret to rotate + * @returns The new secret + */ + rotateSecret: (id: string): Promise> => + transformResponse( + httpClient.post(apiPaths.engine.rotate, { id }), + (data) => data, + ), +}); + +export default withEngine; diff --git a/lib/management/index.ts b/lib/management/index.ts index 2df4d2bdb..c6c4073aa 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 withEngine from './engine'; 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), + engine: withEngine(client), }); export default withManagement; diff --git a/lib/management/paths.ts b/lib/management/paths.ts index 7566e0bdf..9e7c98b48 100644 --- a/lib/management/paths.ts +++ b/lib/management/paths.ts @@ -225,4 +225,12 @@ export default { license: { get: '/v1/mgmt/license', }, + engine: { + create: '/v1/mgmt/engine/create', + update: '/v1/mgmt/engine/update', + delete: '/v1/mgmt/engine/delete', + load: '/v1/mgmt/engine/load', + loadAll: '/v1/mgmt/engines/load', + rotate: '/v1/mgmt/engine/rotate', + }, }; diff --git a/lib/management/types.ts b/lib/management/types.ts index fb076cf39..3608bd5f8 100644 --- a/lib/management/types.ts +++ b/lib/management/types.ts @@ -1314,3 +1314,24 @@ export type MgmtKeyCreateResponse = { export type License = { rateLimitTier: string; }; + +/** Represents an engine in a project. `secret` is populated only on create and + * rotateSecret; it is always empty on load/loadAll. The numeric fields are typed as + * string because the management gateway serializes proto int64 values as JSON strings. */ +export type Engine = { + id: string; + name: string; + projectId?: string; + secret?: string; + imageVersion?: string; + contentVersion?: string; + version?: string; + createdTime?: string; + modifiedTime?: string; + lastSync?: string; +}; + +/** Response of an engine secret rotation. */ +export type EngineSecretResponse = { + secret: string; +}; From 50d5204e4e6febb64a630fbb3521d06936fddf46 Mon Sep 17 00:00:00 2001 From: dorsha Date: Sun, 28 Jun 2026 18:25:36 +0300 Subject: [PATCH 2/2] refactor(mgmt): slim down Engine type to id/name/secret/createdTime Match the dedicated management Engine message (descope/backend#1614): drop the EngineService-internal fields (projectId, version, modifiedTime, lastSync, imageVersion, contentVersion) that are no longer returned. createdTime is now a numeric epoch-seconds value. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/management/engine.test.ts | 8 +++----- lib/management/types.ts | 11 ++--------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/lib/management/engine.test.ts b/lib/management/engine.test.ts index 8260affe6..b2cbc57d6 100644 --- a/lib/management/engine.test.ts +++ b/lib/management/engine.test.ts @@ -6,15 +6,13 @@ import { mockHttpClient, resetMockHttpClient } from './testutils'; const management = withManagement(mockHttpClient); -// createdTime/version are int64 in the proto, so the management gateway returns them as -// JSON strings — the SDK types them as string and passes them through verbatim. +// The management Engine exposes only id/name/secret/createdTime; createdTime is an int32 +// epoch-seconds JSON number. const mockEngine: Engine = { id: 'eng1', name: 'my-engine', - projectId: 'proj1', secret: 's3cret', - version: '1', - createdTime: '1719571200', + createdTime: 1719571200, }; describe('Management Engine', () => { diff --git a/lib/management/types.ts b/lib/management/types.ts index 3608bd5f8..b870ba8df 100644 --- a/lib/management/types.ts +++ b/lib/management/types.ts @@ -1316,19 +1316,12 @@ export type License = { }; /** Represents an engine in a project. `secret` is populated only on create and - * rotateSecret; it is always empty on load/loadAll. The numeric fields are typed as - * string because the management gateway serializes proto int64 values as JSON strings. */ + * rotateSecret; it is always empty on load/loadAll. */ export type Engine = { id: string; name: string; - projectId?: string; secret?: string; - imageVersion?: string; - contentVersion?: string; - version?: string; - createdTime?: string; - modifiedTime?: string; - lastSync?: string; + createdTime?: number; // epoch seconds }; /** Response of an engine secret rotation. */