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..b2cbc57d6 --- /dev/null +++ b/lib/management/engine.test.ts @@ -0,0 +1,140 @@ +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); + +// 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', + secret: 's3cret', + 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..b870ba8df 100644 --- a/lib/management/types.ts +++ b/lib/management/types.ts @@ -1314,3 +1314,17 @@ 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. */ +export type Engine = { + id: string; + name: string; + secret?: string; + createdTime?: number; // epoch seconds +}; + +/** Response of an engine secret rotation. */ +export type EngineSecretResponse = { + secret: string; +};