Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand Down
140 changes: 140 additions & 0 deletions lib/management/engine.test.ts
Original file line number Diff line number Diff line change
@@ -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<Engine> = 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<Engine[]> = 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');
});
});
});
74 changes: 74 additions & 0 deletions lib/management/engine.ts
Original file line number Diff line number Diff line change
@@ -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<SdkResponse<Engine>> =>
transformResponse<SingleEngineResponse, Engine>(
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<SdkResponse<Engine>> =>
transformResponse<SingleEngineResponse, Engine>(
httpClient.post(apiPaths.engine.update, { id, name }),
(data) => data.engine,
),
/**
* Delete an engine.
* @param id Engine id to delete
*/
delete: (id: string): Promise<SdkResponse<never>> =>
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<SdkResponse<Engine>> =>
transformResponse<SingleEngineResponse, Engine>(
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<SdkResponse<Engine[]>> =>
transformResponse<MultipleEnginesResponse, Engine[]>(
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<SdkResponse<EngineSecretResponse>> =>
transformResponse<EngineSecretResponse, EngineSecretResponse>(
httpClient.post(apiPaths.engine.rotate, { id }),
(data) => data,
),
});

export default withEngine;
2 changes: 2 additions & 0 deletions lib/management/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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;
8 changes: 8 additions & 0 deletions lib/management/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
};
14 changes: 14 additions & 0 deletions lib/management/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Loading