diff --git a/.changeset/five-jokes-clap.md b/.changeset/five-jokes-clap.md new file mode 100644 index 00000000000..6c4bd83e42a --- /dev/null +++ b/.changeset/five-jokes-clap.md @@ -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') +``` diff --git a/packages/backend/src/api/__tests__/MachineApi.test.ts b/packages/backend/src/api/__tests__/MachineApi.test.ts new file mode 100644 index 00000000000..9b721206211 --- /dev/null +++ b/packages/backend/src/api/__tests__/MachineApi.test.ts @@ -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); + }); +}); diff --git a/packages/backend/src/api/endpoints/MachineApi.ts b/packages/backend/src/api/endpoints/MachineApi.ts index aec9a1bdc8c..74e51ad9204 100644 --- a/packages/backend/src/api/endpoints/MachineApi.ts +++ b/packages/backend/src/api/endpoints/MachineApi.ts @@ -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 = { @@ -62,4 +85,43 @@ export class MachineApi extends AbstractAPI { path: joinPaths(basePath, machineId), }); } + + async getSecretKey(machineId: string) { + this.requireId(machineId); + return this.request({ + 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({ + 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({ + method: 'DELETE', + path: joinPaths(basePath, machineId, 'scopes', otherMachineId), + }); + } } diff --git a/packages/backend/src/api/resources/Deserializer.ts b/packages/backend/src/api/resources/Deserializer.ts index 2db6e993609..d18bf76c039 100644 --- a/packages/backend/src/api/resources/Deserializer.ts +++ b/packages/backend/src/api/resources/Deserializer.ts @@ -16,6 +16,8 @@ import { Invitation, JwtTemplate, Machine, + MachineScope, + MachineSecretKey, MachineToken, OauthAccessToken, OAuthApplication, @@ -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: diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index e48b8361fe9..a1e216f328d 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -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', @@ -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 { diff --git a/packages/backend/src/api/resources/Machine.ts b/packages/backend/src/api/resources/Machine.ts index 16b2f9b010f..8a096e35276 100644 --- a/packages/backend/src/api/resources/Machine.ts +++ b/packages/backend/src/api/resources/Machine.ts @@ -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, + ), + ), + data.default_token_ttl, + ); } } diff --git a/packages/backend/src/api/resources/MachineScope.ts b/packages/backend/src/api/resources/MachineScope.ts new file mode 100644 index 00000000000..9a7440b1109 --- /dev/null +++ b/packages/backend/src/api/resources/MachineScope.ts @@ -0,0 +1,14 @@ +import type { MachineScopeJSON } from './JSON'; + +export class MachineScope { + constructor( + readonly fromMachineId: string, + readonly toMachineId: string, + readonly createdAt?: number, + readonly deleted?: boolean, + ) {} + + static fromJSON(data: MachineScopeJSON): MachineScope { + return new MachineScope(data.from_machine_id, data.to_machine_id, data.created_at, data.deleted); + } +} diff --git a/packages/backend/src/api/resources/MachineSecretKey.ts b/packages/backend/src/api/resources/MachineSecretKey.ts new file mode 100644 index 00000000000..ba6245efe55 --- /dev/null +++ b/packages/backend/src/api/resources/MachineSecretKey.ts @@ -0,0 +1,9 @@ +import type { MachineSecretKeyJSON } from './JSON'; + +export class MachineSecretKey { + constructor(readonly secret: string) {} + + static fromJSON(data: MachineSecretKeyJSON): MachineSecretKey { + return new MachineSecretKey(data.secret); + } +} diff --git a/packages/backend/src/api/resources/index.ts b/packages/backend/src/api/resources/index.ts index 034ab10fd3e..0e7cb401791 100644 --- a/packages/backend/src/api/resources/index.ts +++ b/packages/backend/src/api/resources/index.ts @@ -31,6 +31,8 @@ export * from './InstanceSettings'; export * from './Invitation'; export * from './JSON'; export * from './Machine'; +export * from './MachineScope'; +export * from './MachineSecretKey'; export * from './MachineToken'; export * from './JwtTemplate'; export * from './OauthAccessToken';