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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,34 @@ const relations = await descopeClient.management.fga.check([
]);
```

Response times of repeated FGA `check` calls, especially in high volume scenarios, can be reduced to sub-millisecond scales by re-directing the calls to a Descope FGA Cache Proxy running in the same backend cluster as your application.

After setting up the proxy server via the Descope provided Docker image, set the `fgaCacheUrl` parameter to be equal to the proxy URL to enable its use in the SDK, as shown in the example below:

> **Note:** Both `fgaCacheUrl` and `managementKey` must be provided for the cache proxy to be used. If only `fgaCacheUrl` is configured without `managementKey`, requests will use the standard Descope API.

```typescript
import DescopeClient from '@descope/node-sdk';

// Initialize client with FGA cache URL
const descopeClient = DescopeClient({
projectId: '<Project ID>',
managementKey: '<Management Key>', // Required for cache proxy
fgaCacheUrl: 'https://10.0.0.4', // example FGA Cache Proxy URL, running inside the same backend cluster
});
```

When the `fgaCacheUrl` is configured, the following FGA methods will automatically use the cache proxy instead of the default Descope API:

- `saveSchema`
- `createRelations`
- `deleteRelations`
- `check`

If the cache proxy is unreachable or returns an error, the SDK will automatically fall back to the standard Descope API.

Other FGA operations like `loadResourcesDetails` and `saveResourcesDetails` will continue to use the standard Descope API endpoints.

### Manage Outbound Applications

You can create, update, delete or load outbound applications:
Expand Down
16 changes: 14 additions & 2 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,16 @@ type NodeSdkArgs = Parameters<typeof createSdk>[0] & {
managementKey?: string;
authManagementKey?: string;
publicKey?: string;
fgaCacheUrl?: string;
};

const nodeSdk = ({ authManagementKey, managementKey, publicKey, ...config }: NodeSdkArgs) => {
const nodeSdk = ({
authManagementKey,
managementKey,
publicKey,
fgaCacheUrl,
...config
}: NodeSdkArgs) => {
const nodeHeaders = {
'x-descope-sdk-name': 'nodejs',
'x-descope-sdk-node-version': process?.versions?.node || '',
Expand Down Expand Up @@ -128,7 +135,12 @@ const nodeSdk = ({ authManagementKey, managementKey, publicKey, ...config }: Nod
},
};
const mgmtHttpClient = createHttpClient(mgmtSdkConfig);
const management = withManagement(mgmtHttpClient);
const management = withManagement(mgmtHttpClient, {
fgaCacheUrl,
managementKey,
projectId,
headers: nodeHeaders,
});

const sdk = {
...coreSdk,
Expand Down
147 changes: 147 additions & 0 deletions lib/management/fga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import apiPaths from './paths';
import { mockHttpClient, resetMockHttpClient } from './testutils';
import { FGAResourceIdentifier, FGAResourceDetails } from './types';

jest.mock('../fetch-polyfill', () => jest.fn());

const emptySuccessResponse = {
code: 200,
data: { body: 'body' },
Expand Down Expand Up @@ -41,6 +43,13 @@ const mockCheckResponse = {
};

describe('Management FGA', () => {
let fetchMock: jest.Mock;

beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
fetchMock = require('../fetch-polyfill') as jest.Mock;
});

afterEach(() => {
jest.clearAllMocks();
resetMockHttpClient();
Expand Down Expand Up @@ -167,4 +176,142 @@ describe('Management FGA', () => {
await expect(WithFGA(mockHttpClient).saveResourcesDetails(details)).rejects.toThrow();
});
});

describe('FGA Cache URL support', () => {
const fgaCacheUrl = 'https://my-fga-cache.example.com';
const projectId = 'test-project-id';
const managementKey = 'test-management-key';
const headers = {
'x-descope-sdk-name': 'nodejs',
'x-descope-sdk-node-version': '18.0.0',
'x-descope-sdk-version': '1.0.0',
};

const fgaConfig = {
fgaCacheUrl,
managementKey,
projectId,
headers,
};

it('should use cache URL for saveSchema when configured', async () => {
const schema = { dsl: 'model AuthZ 1.0' };
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({}),
clone: () => ({ json: async () => ({}) }),
status: 200,
});

await WithFGA(mockHttpClient, fgaConfig).saveSchema(schema);

expect(fetchMock).toHaveBeenCalledWith(
`${fgaCacheUrl}${apiPaths.fga.schema}`,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
Authorization: `Bearer ${projectId}:${managementKey}`,
'x-descope-project-id': projectId,
}),
body: JSON.stringify(schema),
}),
);
});

it('should use cache URL for createRelations when configured', async () => {
const relations = [relation1];
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({}),
clone: () => ({ json: async () => ({}) }),
status: 200,
});

await WithFGA(mockHttpClient, fgaConfig).createRelations(relations);

expect(fetchMock).toHaveBeenCalledWith(
`${fgaCacheUrl}${apiPaths.fga.relations}`,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
Authorization: `Bearer ${projectId}:${managementKey}`,
'x-descope-project-id': projectId,
}),
body: JSON.stringify({ tuples: relations }),
}),
);
});

it('should use cache URL for deleteRelations when configured', async () => {
const relations = [relation1];
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({}),
clone: () => ({ json: async () => ({}) }),
status: 200,
});

await WithFGA(mockHttpClient, fgaConfig).deleteRelations(relations);

expect(fetchMock).toHaveBeenCalledWith(
`${fgaCacheUrl}${apiPaths.fga.deleteRelations}`,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
Authorization: `Bearer ${projectId}:${managementKey}`,
'x-descope-project-id': projectId,
}),
body: JSON.stringify({ tuples: relations }),
}),
);
});

it('should use cache URL for check when configured', async () => {
const relations = [relation1, relation2];
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ tuples: mockCheckResponseRelations }),
clone: () => ({ json: async () => ({ tuples: mockCheckResponseRelations }) }),
status: 200,
headers: new Map(),
});

const result = await WithFGA(mockHttpClient, fgaConfig).check(relations);

expect(fetchMock).toHaveBeenCalledWith(
`${fgaCacheUrl}${apiPaths.fga.check}`,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
Authorization: `Bearer ${projectId}:${managementKey}`,
'x-descope-project-id': projectId,
}),
body: JSON.stringify({ tuples: relations }),
}),
);
expect(result.data).toEqual(mockCheckResponseRelations);
});

it('should use default httpClient when cache URL is not configured', async () => {
const schema = { dsl: 'model AuthZ 1.0' };
await WithFGA(mockHttpClient).saveSchema(schema);

expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.fga.schema, schema);
expect(fetchMock).not.toHaveBeenCalled();
});

it('should fallback to httpClient when cache URL fetch fails', async () => {
const schema = { dsl: 'model AuthZ 1.0' };
fetchMock.mockRejectedValue(new Error('Network error'));

await WithFGA(mockHttpClient, fgaConfig).saveSchema(schema);

expect(fetchMock).toHaveBeenCalled();
expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.fga.schema, schema);
});
});
});
Loading