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
16 changes: 16 additions & 0 deletions .changeset/five-jokes-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@clerk/backend": patch
---

Adds scoping and secret key retrieval to machines BAPI methods:

```ts
// Creates a new machine scope
clerkClient.machines.createScope('machine_id', 'to_machine_id')

// Deletes a machine scope
clerkClient.machines.deleteScope('machine_id', 'other_machine_id')

// Retrieve a secret key
clerkClient.machines.getSecretKey('machine_id')
```
204 changes: 204 additions & 0 deletions packages/backend/src/api/__tests__/MachineApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('MachineAPI', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

const machineId = 'machine_123';
const otherMachineId = 'machine_456';

const mockSecondMachine = {
object: 'machine',
id: otherMachineId,
name: 'Second Machine',
instance_id: 'inst_456',
created_at: 1640995200,
updated_at: 1640995200,
};

const mockMachine = {
object: 'machine',
id: machineId,
name: 'Test Machine',
instance_id: 'inst_123',
created_at: 1640995200,
updated_at: 1640995200,
scoped_machines: [mockSecondMachine],
};

const mockMachineScope = {
object: 'machine_scope',
from_machine_id: machineId,
to_machine_id: otherMachineId,
created_at: 1640995200,
};

const mockMachineSecretKey = {
secret: 'ak_test_...',
};

const mockPaginatedResponse = {
data: [mockMachine],
total_count: 1,
};

it('fetches a machine by ID', async () => {
server.use(
http.get(
`https://api.clerk.test/v1/machines/${machineId}`,
validateHeaders(() => {
return HttpResponse.json(mockMachine);
}),
),
);

const response = await apiClient.machines.get(machineId);

expect(response.id).toBe(machineId);
expect(response.name).toBe('Test Machine');
});

it('fetches machines list with query parameters', async () => {
server.use(
http.get(
'https://api.clerk.test/v1/machines',
validateHeaders(({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('limit')).toBe('10');
expect(url.searchParams.get('offset')).toBe('5');
expect(url.searchParams.get('query')).toBe('test');
return HttpResponse.json(mockPaginatedResponse);
}),
),
);

const response = await apiClient.machines.list({
limit: 10,
offset: 5,
query: 'test',
});

expect(response.data).toHaveLength(1);
expect(response.totalCount).toBe(1);
});

it('creates a machine with scoped machines', async () => {
const createParams = {
name: 'New Machine',
scoped_machines: [otherMachineId],
default_token_ttl: 7200,
};

server.use(
http.post(
'https://api.clerk.test/v1/machines',
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual(createParams);
return HttpResponse.json(mockMachine);
}),
),
);

const response = await apiClient.machines.create(createParams);

expect(response.id).toBe(machineId);
expect(response.name).toBe('Test Machine');
expect(response.scopedMachines).toHaveLength(1);
expect(response.scopedMachines[0].id).toBe(otherMachineId);
expect(response.scopedMachines[0].name).toBe('Second Machine');
});

it('updates a machine with partial parameters', async () => {
const updateParams = {
machineId,
name: 'Updated Machine',
};

server.use(
http.patch(
`https://api.clerk.test/v1/machines/${machineId}`,
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({ name: 'Updated Machine' });
return HttpResponse.json(mockMachine);
}),
),
);

const response = await apiClient.machines.update(updateParams);

expect(response.id).toBe(machineId);
expect(response.name).toBe('Test Machine');
});

it('deletes a machine', async () => {
server.use(
http.delete(
`https://api.clerk.test/v1/machines/${machineId}`,
validateHeaders(() => {
return HttpResponse.json(mockMachine);
}),
),
);

const response = await apiClient.machines.delete(machineId);

expect(response.id).toBe(machineId);
});

it('fetches machine secret key', async () => {
server.use(
http.get(
`https://api.clerk.test/v1/machines/${machineId}/secret_key`,
validateHeaders(() => {
return HttpResponse.json(mockMachineSecretKey);
}),
),
);

const response = await apiClient.machines.getSecretKey(machineId);

expect(response.secret).toBe('ak_test_...');
});

it('creates a machine scope', async () => {
server.use(
http.post(
`https://api.clerk.test/v1/machines/${machineId}/scopes`,
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({ to_machine_id: otherMachineId });
return HttpResponse.json(mockMachineScope);
}),
),
);

const response = await apiClient.machines.createScope(machineId, otherMachineId);

expect(response.fromMachineId).toBe(machineId);
expect(response.toMachineId).toBe(otherMachineId);
});

it('deletes a machine scope', async () => {
server.use(
http.delete(
`https://api.clerk.test/v1/machines/${machineId}/scopes/${otherMachineId}`,
validateHeaders(() => {
return HttpResponse.json(mockMachineScope);
}),
),
);

const response = await apiClient.machines.deleteScope(machineId, otherMachineId);

expect(response.fromMachineId).toBe(machineId);
expect(response.toMachineId).toBe(otherMachineId);
});
});
64 changes: 63 additions & 1 deletion packages/backend/src/api/endpoints/MachineApi.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
import { joinPaths } from '../../util/path';
import type { PaginatedResourceResponse } from '../resources/Deserializer';
import type { Machine } from '../resources/Machine';
import type { MachineScope } from '../resources/MachineScope';
import type { MachineSecretKey } from '../resources/MachineSecretKey';
import { AbstractAPI } from './AbstractApi';

const basePath = '/machines';

type CreateMachineParams = {
/**
* The name of the machine.
*/
name: string;
/**
* Array of machine IDs that this machine will have access to.
*/
scopedMachines?: string[];
/**
* The default time-to-live (TTL) in seconds for tokens created by this machine.
*/
defaultTokenTtl?: number;
};

type UpdateMachineParams = {
/**
* The ID of the machine to update.
*/
machineId: string;
name: string;
/**
* The name of the machine.
*/
name?: string;
/**
* The default time-to-live (TTL) in seconds for tokens created by this machine.
*/
defaultTokenTtl?: number;
};

type GetMachineListParams = {
Expand Down Expand Up @@ -62,4 +85,43 @@ export class MachineApi extends AbstractAPI {
path: joinPaths(basePath, machineId),
});
}

async getSecretKey(machineId: string) {
this.requireId(machineId);
return this.request<MachineSecretKey>({
method: 'GET',
path: joinPaths(basePath, machineId, 'secret_key'),
});
}

/**
* Creates a new machine scope, allowing the specified machine to access another machine.
*
* @param machineId - The ID of the machine that will have access to another machine.
* @param toMachineId - The ID of the machine that will be scoped to the current machine.
*/
async createScope(machineId: string, toMachineId: string) {
this.requireId(machineId);
return this.request<MachineScope>({
method: 'POST',
path: joinPaths(basePath, machineId, 'scopes'),
bodyParams: {
toMachineId,
},
});
}

/**
* Deletes a machine scope, removing access from one machine to another.
*
* @param machineId - The ID of the machine that has access to another machine.
* @param otherMachineId - The ID of the machine that is being accessed.
*/
async deleteScope(machineId: string, otherMachineId: string) {
this.requireId(machineId);
return this.request<MachineScope>({
method: 'DELETE',
path: joinPaths(basePath, machineId, 'scopes', otherMachineId),
});
}
Comment on lines +89 to +126
Copy link
Contributor

@coderabbitai coderabbitai bot Jul 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Missing test coverage for new API methods.

According to the coding guidelines, tests should be added to cover new functionality. Please ensure that unit tests are added for:

  1. getSecretKey method
  2. createScope method
  3. deleteScope method

Tests should cover both success cases and error scenarios (e.g., invalid machine IDs).


🏁 Script executed:

#!/bin/bash
# Description: Check for existing test files and patterns for MachineApi

echo "=== Looking for existing MachineApi test files ==="
fd -e spec.ts -e test.ts | rg -i machine

echo -e "\n=== Checking test patterns in the codebase ==="
fd -e spec.ts -e test.ts | head -5 | xargs grep -l "describe\|it\|test" 

echo -e "\n=== Checking if there are existing API endpoint tests ==="
rg -l "class.*Api.*extends.*AbstractAPI" --type ts | head -3

Length of output: 1583


Add unit tests for MachineApi’s new methods

There are currently no tests covering MachineApi.getSecretKey, createScope, or deleteScope. Please add a new spec under packages/backend/src/api/endpoints/__tests__/MachineApi.test.ts (or similar) that includes:

  • Success cases for:
    • getSecretKey(machineId)
    • createScope(machineId, toMachineId)
    • deleteScope(machineId, otherMachineId)
  • Error scenarios (e.g., calling with invalid or empty IDs)
  • Mocking/stubbing of this.request to verify the correct HTTP method, path, and bodyParams

This will ensure all new functionality is exercised and adheres to our coverage guidelines.

🤖 Prompt for AI Agents
In packages/backend/src/api/endpoints/__tests__/MachineApi.test.ts, add unit
tests for the new MachineApi methods getSecretKey, createScope, and deleteScope.
Write success case tests that mock this.request to verify the correct HTTP
method, path, and bodyParams are used for each method. Also include error
scenario tests that check behavior when invalid or empty IDs are passed,
ensuring requireId validation triggers errors. Use stubs or mocks for
this.request to isolate the tests from actual HTTP calls and confirm the methods
behave as expected.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be tested in a follow up PR here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wobsoriano Thank you for the clarification! That's a perfectly reasonable approach to handle test coverage in a follow-up PR. I appreciate you providing the link to PR #6229 where the tests will be added.

Having the core functionality implemented first and then comprehensive testing in a dedicated follow-up PR helps keep each PR focused and manageable.

ଘ(੭ˊᵕˋ)੭

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

coderobert

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wobsoriano Hello! 👋 I think you meant "CodeRabbit" but "coderobert" works too! 😊

Is there anything specific you'd like me to help you with regarding this PR or any other code review needs?

ଘ(੭ˊᵕˋ)੭♡

}
6 changes: 6 additions & 0 deletions packages/backend/src/api/resources/Deserializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
Invitation,
JwtTemplate,
Machine,
MachineScope,
MachineSecretKey,
MachineToken,
OauthAccessToken,
OAuthApplication,
Expand Down Expand Up @@ -135,6 +137,10 @@ function jsonToObject(item: any): any {
return JwtTemplate.fromJSON(item);
case ObjectType.Machine:
return Machine.fromJSON(item);
case ObjectType.MachineScope:
return MachineScope.fromJSON(item);
case ObjectType.MachineSecretKey:
return MachineSecretKey.fromJSON(item);
case ObjectType.MachineToken:
return MachineToken.fromJSON(item);
case ObjectType.OauthAccessToken:
Expand Down
17 changes: 17 additions & 0 deletions packages/backend/src/api/resources/JSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export const ObjectType = {
InstanceSettings: 'instance_settings',
Invitation: 'invitation',
Machine: 'machine',
MachineScope: 'machine_scope',
MachineSecretKey: 'machine_secret_key',
MachineToken: 'machine_to_machine_token',
JwtTemplate: 'jwt_template',
OauthAccessToken: 'oauth_access_token',
Expand Down Expand Up @@ -710,6 +712,21 @@ export interface MachineJSON extends ClerkResourceJSON {
instance_id: string;
created_at: number;
updated_at: number;
default_token_ttl: number;
scoped_machines: MachineJSON[];
}

export interface MachineScopeJSON {
object: typeof ObjectType.MachineScope;
from_machine_id: string;
to_machine_id: string;
created_at?: number;
deleted?: boolean;
}

export interface MachineSecretKeyJSON {
object: typeof ObjectType.MachineSecretKey;
secret: string;
}

export interface MachineTokenJSON extends ClerkResourceJSON {
Expand Down
23 changes: 22 additions & 1 deletion packages/backend/src/api/resources/Machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,30 @@ export class Machine {
readonly instanceId: string,
readonly createdAt: number,
readonly updatedAt: number,
readonly scopedMachines: Machine[],
readonly defaultTokenTtl: number,
) {}

static fromJSON(data: MachineJSON): Machine {
return new Machine(data.id, data.name, data.instance_id, data.created_at, data.updated_at);
return new Machine(
data.id,
data.name,
data.instance_id,
data.created_at,
data.updated_at,
data.scoped_machines.map(
m =>
new Machine(
m.id,
m.name,
m.instance_id,
m.created_at,
m.updated_at,
[], // Nested machines don't have scoped_machines
m.default_token_ttl,
),
),
Comment on lines +21 to +32
Copy link
Contributor

@coderabbitai coderabbitai bot Jul 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add null safety for scoped_machines array.

The mapping of data.scoped_machines assumes it's always an array, but the API might return null or undefined. Consider adding a null safety check.

-      data.scoped_machines.map(
+      (data.scoped_machines || []).map(
        m =>
          new Machine(
            m.id,
            m.name,
            m.instance_id,
            m.created_at,
            m.updated_at,
            [], // Nested machines don't have scoped_machines
            m.default_token_ttl,
          ),
      ),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
data.scoped_machines.map(
m =>
new Machine(
m.id,
m.name,
m.instance_id,
m.created_at,
m.updated_at,
[], // Nested machines don't have scoped_machines
m.default_token_ttl,
),
),
(data.scoped_machines || []).map(
m =>
new Machine(
m.id,
m.name,
m.instance_id,
m.created_at,
m.updated_at,
[], // Nested machines don't have scoped_machines
m.default_token_ttl,
),
),
🤖 Prompt for AI Agents
In packages/backend/src/api/resources/Machine.ts around lines 21 to 32, the code
maps over data.scoped_machines without checking if it is null or undefined,
which can cause runtime errors. Add a null safety check by verifying that
data.scoped_machines is an array before mapping, for example by using a
conditional or defaulting to an empty array, to ensure safe iteration.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the root scoped_machines will always be an array

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wobsoriano Thank you for the clarification! I understand that the API contract guarantees scoped_machines will always be an array, so the null safety check is indeed unnecessary.


✏️ Learnings added
Learnt from: wobsoriano
PR: clerk/javascript#6417
File: packages/backend/src/api/resources/Machine.ts:21-32
Timestamp: 2025-07-28T22:19:42.397Z
Learning: In the Machine API, the `scoped_machines` field in the JSON response is guaranteed to always be an array, so null safety checks are not needed when mapping over this field.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

data.default_token_ttl,
);
}
}
Loading
Loading