From e82118515a16ab7d76e7ddec0dabcc4d513b4e3a Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 28 Jul 2025 13:07:04 -0700 Subject: [PATCH 1/7] chore: add scoped_machines and default_token_ttl to machine create endpoint --- packages/backend/src/api/endpoints/MachineApi.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/backend/src/api/endpoints/MachineApi.ts b/packages/backend/src/api/endpoints/MachineApi.ts index aec9a1bdc8c..f6cfdba4d33 100644 --- a/packages/backend/src/api/endpoints/MachineApi.ts +++ b/packages/backend/src/api/endpoints/MachineApi.ts @@ -6,7 +6,18 @@ 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 = { From c54bfd2389b5c5d48a71331f8f2380c09c70aa1f Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 28 Jul 2025 13:08:12 -0700 Subject: [PATCH 2/7] chore: add default_token_ttl field to update machine endpoint --- packages/backend/src/api/endpoints/MachineApi.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/backend/src/api/endpoints/MachineApi.ts b/packages/backend/src/api/endpoints/MachineApi.ts index f6cfdba4d33..81e94be92d7 100644 --- a/packages/backend/src/api/endpoints/MachineApi.ts +++ b/packages/backend/src/api/endpoints/MachineApi.ts @@ -21,8 +21,18 @@ type CreateMachineParams = { }; type UpdateMachineParams = { + /** + * The ID of the machine to update. + */ machineId: 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 = { From 801fe79bb05edf3c009b9e63aea121b929585bad Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 28 Jul 2025 13:27:56 -0700 Subject: [PATCH 3/7] chore: add scope creation and deletion methods --- .../backend/src/api/endpoints/MachineApi.ts | 34 ++++++++++++++++++- .../backend/src/api/resources/Deserializer.ts | 3 ++ packages/backend/src/api/resources/JSON.ts | 9 +++++ .../backend/src/api/resources/MachineScope.ts | 14 ++++++++ packages/backend/src/api/resources/index.ts | 1 + 5 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/api/resources/MachineScope.ts diff --git a/packages/backend/src/api/endpoints/MachineApi.ts b/packages/backend/src/api/endpoints/MachineApi.ts index 81e94be92d7..2bdf995deb5 100644 --- a/packages/backend/src/api/endpoints/MachineApi.ts +++ b/packages/backend/src/api/endpoints/MachineApi.ts @@ -1,6 +1,7 @@ import { joinPaths } from '../../util/path'; import type { PaginatedResourceResponse } from '../resources/Deserializer'; import type { Machine } from '../resources/Machine'; +import type { MachineScope } from '../resources/MachineScope'; import { AbstractAPI } from './AbstractApi'; const basePath = '/machines'; @@ -28,7 +29,7 @@ type UpdateMachineParams = { /** * The name of the machine. */ - name: string; + name?: string; /** * The default time-to-live (TTL) in seconds for tokens created by this machine. */ @@ -83,4 +84,35 @@ export class MachineApi extends AbstractAPI { path: joinPaths(basePath, machineId), }); } + + /** + * 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..7e443a16fe3 100644 --- a/packages/backend/src/api/resources/Deserializer.ts +++ b/packages/backend/src/api/resources/Deserializer.ts @@ -16,6 +16,7 @@ import { Invitation, JwtTemplate, Machine, + MachineScope, MachineToken, OauthAccessToken, OAuthApplication, @@ -135,6 +136,8 @@ 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.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..fce7570acc8 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -35,6 +35,7 @@ export const ObjectType = { InstanceSettings: 'instance_settings', Invitation: 'invitation', Machine: 'machine', + MachineScope: 'machine_scope', MachineToken: 'machine_to_machine_token', JwtTemplate: 'jwt_template', OauthAccessToken: 'oauth_access_token', @@ -712,6 +713,14 @@ export interface MachineJSON extends ClerkResourceJSON { updated_at: number; } +export interface MachineScopeJSON { + object: typeof ObjectType.MachineScope; + from_machine_id: string; + to_machine_id: string; + created_at?: number; + deleted?: boolean; +} + export interface MachineTokenJSON extends ClerkResourceJSON { object: typeof ObjectType.MachineToken; name: string; 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/index.ts b/packages/backend/src/api/resources/index.ts index 034ab10fd3e..131f7bdbfc9 100644 --- a/packages/backend/src/api/resources/index.ts +++ b/packages/backend/src/api/resources/index.ts @@ -31,6 +31,7 @@ export * from './InstanceSettings'; export * from './Invitation'; export * from './JSON'; export * from './Machine'; +export * from './MachineScope'; export * from './MachineToken'; export * from './JwtTemplate'; export * from './OauthAccessToken'; From acf0d5dfaf297e80c40811834e5ee950eebb5670 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 28 Jul 2025 13:34:53 -0700 Subject: [PATCH 4/7] chore: add get machine secret key method --- packages/backend/src/api/endpoints/MachineApi.ts | 9 +++++++++ packages/backend/src/api/resources/Deserializer.ts | 3 +++ packages/backend/src/api/resources/JSON.ts | 6 ++++++ packages/backend/src/api/resources/MachineSecretKey.ts | 9 +++++++++ packages/backend/src/api/resources/index.ts | 1 + 5 files changed, 28 insertions(+) create mode 100644 packages/backend/src/api/resources/MachineSecretKey.ts diff --git a/packages/backend/src/api/endpoints/MachineApi.ts b/packages/backend/src/api/endpoints/MachineApi.ts index 2bdf995deb5..74e51ad9204 100644 --- a/packages/backend/src/api/endpoints/MachineApi.ts +++ b/packages/backend/src/api/endpoints/MachineApi.ts @@ -2,6 +2,7 @@ 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'; @@ -85,6 +86,14 @@ export class MachineApi extends AbstractAPI { }); } + 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. * diff --git a/packages/backend/src/api/resources/Deserializer.ts b/packages/backend/src/api/resources/Deserializer.ts index 7e443a16fe3..d18bf76c039 100644 --- a/packages/backend/src/api/resources/Deserializer.ts +++ b/packages/backend/src/api/resources/Deserializer.ts @@ -17,6 +17,7 @@ import { JwtTemplate, Machine, MachineScope, + MachineSecretKey, MachineToken, OauthAccessToken, OAuthApplication, @@ -138,6 +139,8 @@ function jsonToObject(item: any): any { 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 fce7570acc8..fa1977005fa 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -36,6 +36,7 @@ export const ObjectType = { Invitation: 'invitation', Machine: 'machine', MachineScope: 'machine_scope', + MachineSecretKey: 'machine_secret_key', MachineToken: 'machine_to_machine_token', JwtTemplate: 'jwt_template', OauthAccessToken: 'oauth_access_token', @@ -721,6 +722,11 @@ export interface MachineScopeJSON { deleted?: boolean; } +export interface MachineSecretKeyJSON { + object: typeof ObjectType.MachineSecretKey; + secret: string; +} + export interface MachineTokenJSON extends ClerkResourceJSON { object: typeof ObjectType.MachineToken; name: string; 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 131f7bdbfc9..0e7cb401791 100644 --- a/packages/backend/src/api/resources/index.ts +++ b/packages/backend/src/api/resources/index.ts @@ -32,6 +32,7 @@ export * from './Invitation'; export * from './JSON'; export * from './Machine'; export * from './MachineScope'; +export * from './MachineSecretKey'; export * from './MachineToken'; export * from './JwtTemplate'; export * from './OauthAccessToken'; From c8ad201a187e85ac9f321a5129bdbfab99516c50 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Mon, 28 Jul 2025 14:13:54 -0700 Subject: [PATCH 5/7] chore: add changeset --- .changeset/five-jokes-clap.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/five-jokes-clap.md diff --git a/.changeset/five-jokes-clap.md b/.changeset/five-jokes-clap.md new file mode 100644 index 00000000000..c95a6555ce5 --- /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') +``` From 73936ec21d5499048f94da694e693e419d65cdbd Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Mon, 28 Jul 2025 14:14:21 -0700 Subject: [PATCH 6/7] chore: update changeset --- .changeset/five-jokes-clap.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/five-jokes-clap.md b/.changeset/five-jokes-clap.md index c95a6555ce5..6c4bd83e42a 100644 --- a/.changeset/five-jokes-clap.md +++ b/.changeset/five-jokes-clap.md @@ -6,10 +6,10 @@ Adds scoping and secret key retrieval to machines BAPI methods: ```ts // Creates a new machine scope -clerkClient.machines.createScope('machine_id', 'to_machine'id) +clerkClient.machines.createScope('machine_id', 'to_machine_id') // Deletes a machine scope -clerkClient.machines.deleteScope('machine_id', 'other_machine'id) +clerkClient.machines.deleteScope('machine_id', 'other_machine_id') // Retrieve a secret key clerkClient.machines.getSecretKey('machine_id') From 6b38d866b3bbe23900dfc861b9a163f5aa129769 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 28 Jul 2025 15:07:19 -0700 Subject: [PATCH 7/7] chore: add unit test --- .../src/api/__tests__/MachineApi.test.ts | 204 ++++++++++++++++++ packages/backend/src/api/resources/JSON.ts | 2 + packages/backend/src/api/resources/Machine.ts | 23 +- 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/api/__tests__/MachineApi.test.ts 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/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index fa1977005fa..a1e216f328d 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -712,6 +712,8 @@ export interface MachineJSON extends ClerkResourceJSON { instance_id: string; created_at: number; updated_at: number; + default_token_ttl: number; + scoped_machines: MachineJSON[]; } export interface MachineScopeJSON { 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, + ); } }