Skip to content

Commit e0bc769

Browse files
dorshaclaude
andauthored
feat(mgmt): add engine management API (#757)
* feat(mgmt): add engine management API 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 20892d4 commit e0bc769

6 files changed

Lines changed: 269 additions & 0 deletions

File tree

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Then, you can use that to work with the following functions:
7878
15. [Manage SSO applications](#manage-sso-applications)
7979
16. [Manage Management Keys](#manage-management-keys)
8080
17. [Manage Descopers](#manage-descopers)
81+
18. [Manage Engines](#manage-engines)
8182

8283
If you wish to run any of our code samples and play with them, check out our [Code Examples](#code-examples) section.
8384

@@ -1780,6 +1781,36 @@ await descopeClient.management.descoper.update(
17801781
await descopeClient.management.descoper.delete('descoper-id');
17811782
```
17821783

1784+
### Manage Engines
1785+
1786+
You can create, update, delete, load engines, and rotate their secrets. The engine secret
1787+
is returned only on create and rotate — store it securely, as it cannot be retrieved again.
1788+
1789+
```typescript
1790+
// Create an engine. The response includes the generated id and secret.
1791+
const createRes = await descopeClient.management.engine.create('My Engine');
1792+
const { id, secret } = createRes.data; // save `secret` securely!
1793+
1794+
// Update an engine's name (the response does not include the secret).
1795+
await descopeClient.management.engine.update(id, 'Updated Engine Name');
1796+
1797+
// Load a specific engine by id (the secret is always empty).
1798+
const engineRes = await descopeClient.management.engine.load(id);
1799+
1800+
// Load all engines (secrets are always empty).
1801+
const enginesRes = await descopeClient.management.engine.loadAll();
1802+
enginesRes.data.forEach((engine) => {
1803+
// do something
1804+
});
1805+
1806+
// Rotate an engine's secret. The previous secret is invalidated and the new one returned.
1807+
const rotateRes = await descopeClient.management.engine.rotateSecret(id);
1808+
const newSecret = rotateRes.data.secret;
1809+
1810+
// Engine deletion cannot be undone. Use carefully.
1811+
await descopeClient.management.engine.delete(id);
1812+
```
1813+
17831814
### Utils for your end to end (e2e) tests and integration tests
17841815

17851816
To ease your e2e tests, we exposed dedicated management methods,

lib/management/engine.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { SdkResponse } from '@descope/core-js-sdk';
2+
import withManagement from '.';
3+
import apiPaths from './paths';
4+
import { Engine, EngineSecretResponse } from './types';
5+
import { mockHttpClient, resetMockHttpClient } from './testutils';
6+
7+
const management = withManagement(mockHttpClient);
8+
9+
// The management Engine exposes only id/name/secret/createdTime; createdTime is an int32
10+
// epoch-seconds JSON number.
11+
const mockEngine: Engine = {
12+
id: 'eng1',
13+
name: 'my-engine',
14+
secret: 's3cret',
15+
createdTime: 1719571200,
16+
};
17+
18+
describe('Management Engine', () => {
19+
afterEach(() => {
20+
jest.clearAllMocks();
21+
resetMockHttpClient();
22+
});
23+
24+
describe('create', () => {
25+
it('should send the correct request and return the engine with its secret', async () => {
26+
const httpResponse = {
27+
ok: true,
28+
json: () => ({ engine: mockEngine }),
29+
clone: () => ({ json: () => Promise.resolve({ engine: mockEngine }) }),
30+
status: 200,
31+
};
32+
mockHttpClient.post.mockResolvedValue(httpResponse);
33+
34+
const resp: SdkResponse<Engine> = await management.engine.create('my-engine');
35+
36+
expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.engine.create, {
37+
name: 'my-engine',
38+
});
39+
expect(resp.data).toEqual(mockEngine);
40+
expect(resp.data?.secret).toBe('s3cret');
41+
});
42+
});
43+
44+
describe('update', () => {
45+
it('should send the correct request', async () => {
46+
const updated = { id: 'eng1', name: 'renamed' };
47+
const httpResponse = {
48+
ok: true,
49+
json: () => ({ engine: updated }),
50+
clone: () => ({ json: () => Promise.resolve({ engine: updated }) }),
51+
status: 200,
52+
};
53+
mockHttpClient.post.mockResolvedValue(httpResponse);
54+
55+
const resp = await management.engine.update('eng1', 'renamed');
56+
57+
expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.engine.update, {
58+
id: 'eng1',
59+
name: 'renamed',
60+
});
61+
expect(resp.data).toEqual(updated);
62+
});
63+
});
64+
65+
describe('delete', () => {
66+
it('should send the correct request', async () => {
67+
const httpResponse = {
68+
ok: true,
69+
json: () => ({}),
70+
clone: () => ({ json: () => Promise.resolve({}) }),
71+
status: 200,
72+
};
73+
mockHttpClient.post.mockResolvedValue(httpResponse);
74+
75+
await management.engine.delete('eng1');
76+
77+
expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.engine.delete, {
78+
id: 'eng1',
79+
});
80+
});
81+
});
82+
83+
describe('load', () => {
84+
it('should send the correct request with the id query param', async () => {
85+
const loaded = { id: 'eng1', name: 'my-engine' };
86+
const httpResponse = {
87+
ok: true,
88+
json: () => ({ engine: loaded }),
89+
clone: () => ({ json: () => Promise.resolve({ engine: loaded }) }),
90+
status: 200,
91+
};
92+
mockHttpClient.get.mockResolvedValue(httpResponse);
93+
94+
const resp = await management.engine.load('eng1');
95+
96+
expect(mockHttpClient.get).toHaveBeenCalledWith(apiPaths.engine.load, {
97+
queryParams: { id: 'eng1' },
98+
});
99+
expect(resp.data).toEqual(loaded);
100+
});
101+
});
102+
103+
describe('loadAll', () => {
104+
it('should send the correct request and return all engines', async () => {
105+
const engines = [{ id: 'eng1' }, { id: 'eng2' }];
106+
const httpResponse = {
107+
ok: true,
108+
json: () => ({ engines }),
109+
clone: () => ({ json: () => Promise.resolve({ engines }) }),
110+
status: 200,
111+
};
112+
mockHttpClient.get.mockResolvedValue(httpResponse);
113+
114+
const resp: SdkResponse<Engine[]> = await management.engine.loadAll();
115+
116+
expect(mockHttpClient.get).toHaveBeenCalledWith(apiPaths.engine.loadAll, {});
117+
expect(resp.data).toEqual(engines);
118+
});
119+
});
120+
121+
describe('rotateSecret', () => {
122+
it('should send the correct request and return the new secret', async () => {
123+
const secretResp: EngineSecretResponse = { secret: 'newS3cret' };
124+
const httpResponse = {
125+
ok: true,
126+
json: () => secretResp,
127+
clone: () => ({ json: () => Promise.resolve(secretResp) }),
128+
status: 200,
129+
};
130+
mockHttpClient.post.mockResolvedValue(httpResponse);
131+
132+
const resp = await management.engine.rotateSecret('eng1');
133+
134+
expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.engine.rotate, {
135+
id: 'eng1',
136+
});
137+
expect(resp.data?.secret).toBe('newS3cret');
138+
});
139+
});
140+
});

lib/management/engine.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { SdkResponse, transformResponse, HttpClient } from '@descope/core-js-sdk';
2+
import apiPaths from './paths';
3+
import { Engine, EngineSecretResponse } from './types';
4+
5+
type SingleEngineResponse = {
6+
engine: Engine;
7+
};
8+
9+
type MultipleEnginesResponse = {
10+
engines: Engine[];
11+
};
12+
13+
const withEngine = (httpClient: HttpClient) => ({
14+
/**
15+
* Create a new engine. The returned engine includes its generated id and secret.
16+
* The secret is returned only here (and on rotateSecret) — store it securely.
17+
* @param name Engine name
18+
* @returns The newly created engine, including its secret
19+
*/
20+
create: (name: string): Promise<SdkResponse<Engine>> =>
21+
transformResponse<SingleEngineResponse, Engine>(
22+
httpClient.post(apiPaths.engine.create, { name }),
23+
(data) => data.engine,
24+
),
25+
/**
26+
* Update an engine's name. The returned engine does not include the secret.
27+
* @param id Engine id to update
28+
* @param name Updated engine name
29+
* @returns The updated engine
30+
*/
31+
update: (id: string, name: string): Promise<SdkResponse<Engine>> =>
32+
transformResponse<SingleEngineResponse, Engine>(
33+
httpClient.post(apiPaths.engine.update, { id, name }),
34+
(data) => data.engine,
35+
),
36+
/**
37+
* Delete an engine.
38+
* @param id Engine id to delete
39+
*/
40+
delete: (id: string): Promise<SdkResponse<never>> =>
41+
transformResponse(httpClient.post(apiPaths.engine.delete, { id })),
42+
/**
43+
* Load a specific engine by id. The returned engine's secret is always empty.
44+
* @param id Engine id to load
45+
* @returns The loaded engine
46+
*/
47+
load: (id: string): Promise<SdkResponse<Engine>> =>
48+
transformResponse<SingleEngineResponse, Engine>(
49+
httpClient.get(apiPaths.engine.load, { queryParams: { id } }),
50+
(data) => data.engine,
51+
),
52+
/**
53+
* Load all engines for the project. The returned engines' secrets are always empty.
54+
* @returns An array of all engines
55+
*/
56+
loadAll: (): Promise<SdkResponse<Engine[]>> =>
57+
transformResponse<MultipleEnginesResponse, Engine[]>(
58+
httpClient.get(apiPaths.engine.loadAll, {}),
59+
(data) => data.engines,
60+
),
61+
/**
62+
* Rotate an engine's secret, invalidating the previous one. The new secret is
63+
* returned in cleartext — store it securely, as it cannot be retrieved again.
64+
* @param id Engine id whose secret to rotate
65+
* @returns The new secret
66+
*/
67+
rotateSecret: (id: string): Promise<SdkResponse<EngineSecretResponse>> =>
68+
transformResponse<EngineSecretResponse, EngineSecretResponse>(
69+
httpClient.post(apiPaths.engine.rotate, { id }),
70+
(data) => data,
71+
),
72+
});
73+
74+
export default withEngine;

lib/management/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import withInboundApplication from './inboundapplication';
1919
import withOutboundApplication from './outboundapplication';
2020
import withDescoper from './descoper';
2121
import withManagementKey from './managementKey';
22+
import withEngine from './engine';
2223
import { FGAConfig } from './types';
2324

2425
/** Constructs a higher level Management API that wraps the functions from code-js-sdk */
@@ -43,6 +44,7 @@ const withManagement = (client: HttpClient, fgaConfig?: FGAConfig) => ({
4344
fga: WithFGA(client, fgaConfig),
4445
descoper: withDescoper(client),
4546
managementKey: withManagementKey(client),
47+
engine: withEngine(client),
4648
});
4749

4850
export default withManagement;

lib/management/paths.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,4 +225,12 @@ export default {
225225
license: {
226226
get: '/v1/mgmt/license',
227227
},
228+
engine: {
229+
create: '/v1/mgmt/engine/create',
230+
update: '/v1/mgmt/engine/update',
231+
delete: '/v1/mgmt/engine/delete',
232+
load: '/v1/mgmt/engine/load',
233+
loadAll: '/v1/mgmt/engines/load',
234+
rotate: '/v1/mgmt/engine/rotate',
235+
},
228236
};

lib/management/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,3 +1314,17 @@ export type MgmtKeyCreateResponse = {
13141314
export type License = {
13151315
rateLimitTier: string;
13161316
};
1317+
1318+
/** Represents an engine in a project. `secret` is populated only on create and
1319+
* rotateSecret; it is always empty on load/loadAll. */
1320+
export type Engine = {
1321+
id: string;
1322+
name: string;
1323+
secret?: string;
1324+
createdTime?: number; // epoch seconds
1325+
};
1326+
1327+
/** Response of an engine secret rotation. */
1328+
export type EngineSecretResponse = {
1329+
secret: string;
1330+
};

0 commit comments

Comments
 (0)