From b7f69e84f4a054e9af0e107e2c104464658941ef Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Wed, 26 Aug 2026 23:37:39 -0700 Subject: [PATCH 1/5] feat(self-serve-ds): add DirectorySync resource and Organization contract Adds DirectorySync/DirectorySyncUser types, connection-scoped Directory Sync methods on the Organization contract and resource (hitting .../enterprise_connections/{id}/scim_directory), and the self_serve_directory_sync user-settings flag (absent on older backends, defaulting to false). --- .../src/core/resources/DirectorySync.ts | 105 +++++++++++++ .../src/core/resources/Organization.ts | 98 ++++++++++++ .../src/core/resources/UserSettings.ts | 6 +- .../resources/__tests__/Organization.test.ts | 143 ++++++++++++++++++ .../resources/__tests__/UserSettings.test.ts | 8 + .../clerk-js/src/core/resources/internal.ts | 1 + packages/clerk-js/src/test/fixture-helpers.ts | 2 +- packages/shared/src/types/directorySync.ts | 108 +++++++++++++ packages/shared/src/types/index.ts | 1 + packages/shared/src/types/organization.ts | 45 ++++++ packages/shared/src/types/userSettings.ts | 2 + 11 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 packages/clerk-js/src/core/resources/DirectorySync.ts create mode 100644 packages/shared/src/types/directorySync.ts diff --git a/packages/clerk-js/src/core/resources/DirectorySync.ts b/packages/clerk-js/src/core/resources/DirectorySync.ts new file mode 100644 index 00000000000..8f97adbe7e8 --- /dev/null +++ b/packages/clerk-js/src/core/resources/DirectorySync.ts @@ -0,0 +1,105 @@ +import type { + DirectorySyncJSON, + DirectorySyncJSONSnapshot, + DirectorySyncProvider, + DirectorySyncResource, + DirectorySyncUserJSON, + DirectorySyncUserResource, +} from '@clerk/shared/types'; + +import { unixEpochToDate } from '../../utils/date'; +import { BaseResource } from './Base'; + +export class DirectorySync extends BaseResource implements DirectorySyncResource { + id!: string; + name!: string; + enterpriseConnectionId: string | null = null; + endpointUrl!: string; + provider!: DirectorySyncProvider; + enabled!: boolean; + groupRoleMappingEnabled!: boolean; + attributeMapping: Record = {}; + apiKey: string | null = null; + createdAt: Date | null = null; + updatedAt: Date | null = null; + + constructor(data: DirectorySyncJSON | DirectorySyncJSONSnapshot | null) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: DirectorySyncJSON | DirectorySyncJSONSnapshot | null): this { + if (!data) { + return this; + } + + this.id = data.id; + this.name = data.name; + this.enterpriseConnectionId = data.enterprise_connection_id ?? null; + this.endpointUrl = data.endpoint_url; + this.provider = data.provider; + this.enabled = data.enabled; + this.groupRoleMappingEnabled = data.group_role_mapping_enabled; + this.attributeMapping = data.attribute_mapping ?? {}; + this.apiKey = data.api_key ?? null; + this.createdAt = unixEpochToDate(data.created_at); + this.updatedAt = unixEpochToDate(data.updated_at); + + return this; + } + + public __internal_toSnapshot(): DirectorySyncJSONSnapshot { + return { + object: 'directory', + id: this.id, + name: this.name, + enterprise_connection_id: this.enterpriseConnectionId, + endpoint_url: this.endpointUrl, + provider: this.provider, + enabled: this.enabled, + group_role_mapping_enabled: this.groupRoleMappingEnabled, + attribute_mapping: this.attributeMapping, + // The bearer token is deliberately absent: snapshots may be persisted + // and the secret must never outlive the response it arrived on. + created_at: this.createdAt?.getTime() ?? 0, + updated_at: this.updatedAt?.getTime() ?? 0, + }; + } +} + +export class DirectorySyncUser extends BaseResource implements DirectorySyncUserResource { + id!: string; + userId!: string; + firstName: string | null = null; + lastName: string | null = null; + identifier: string | null = null; + imageUrl!: string; + hasImage!: boolean; + active!: boolean; + provisionedAt: Date | null = null; + updatedAt: Date | null = null; + + constructor(data: DirectorySyncUserJSON | null) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: DirectorySyncUserJSON | null): this { + if (!data) { + return this; + } + + this.id = data.id; + this.userId = data.user_id; + this.firstName = data.first_name; + this.lastName = data.last_name; + this.identifier = data.identifier; + this.imageUrl = data.image_url; + this.hasImage = data.has_image; + this.active = data.active; + this.provisionedAt = unixEpochToDate(data.provisioned_at); + this.updatedAt = unixEpochToDate(data.updated_at); + + return this; + } +} diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index f8da6e8bdf7..b044ac48525 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -2,11 +2,16 @@ import type { AddMemberParams, ClerkPaginatedResponse, ClerkResourceReloadParams, + CreateDirectorySyncParams, CreateOrganizationDomainParams, CreateOrganizationEnterpriseConnectionParams, CreateOrganizationParams, DeletedObjectJSON, DeletedObjectResource, + DirectorySyncJSON, + DirectorySyncResource, + DirectorySyncUserJSON, + DirectorySyncUserResource, EnterpriseConnectionJSON, EnterpriseConnectionResource, EnterpriseConnectionTestRunInitJSON, @@ -14,6 +19,7 @@ import type { EnterpriseConnectionTestRunJSON, EnterpriseConnectionTestRunResource, EnterpriseConnectionTestRunsPaginatedJSON, + GetDirectorySyncUsersParams, GetDomainsParams, GetEnterpriseConnectionsParams, GetEnterpriseConnectionTestRunsParams, @@ -37,6 +43,7 @@ import type { OrganizationResource, RoleJSON, SetOrganizationLogoParams, + UpdateDirectorySyncParams, UpdateMembershipParams, UpdateOrganizationEnterpriseConnectionParams, UpdateOrganizationParams, @@ -49,6 +56,8 @@ import { addPaymentMethod, getPaymentMethods, initializePaymentMethod } from '.. import { BaseResource, DeletedObject, + DirectorySync, + DirectorySyncUser, EnterpriseConnection, EnterpriseConnectionTestRun, OrganizationInvitation, @@ -274,6 +283,95 @@ export class Organization extends BaseResource implements OrganizationResource { }; }; + getDirectorySync = async (enterpriseConnectionId: string): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory`, + method: 'GET', + }) + )?.response as unknown as DirectorySyncJSON; + + return new DirectorySync(json); + }; + + createDirectorySync = async ( + enterpriseConnectionId: string, + params?: CreateDirectorySyncParams, + ): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory`, + method: 'POST', + body: (params?.name ? { name: params.name } : {}) as any, + }) + )?.response as unknown as DirectorySyncJSON; + + return new DirectorySync(json); + }; + + updateDirectorySync = async ( + enterpriseConnectionId: string, + params: UpdateDirectorySyncParams, + ): Promise => { + const body: Record = {}; + if (params.enabled !== undefined) { + body.enabled = params.enabled; + } + if (params.attributeMapping !== undefined) { + body.attribute_mapping = JSON.stringify(params.attributeMapping); + } + + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory`, + method: 'PATCH', + body: body as any, + }) + )?.response as unknown as DirectorySyncJSON; + + return new DirectorySync(json); + }; + + rotateDirectorySyncToken = async (enterpriseConnectionId: string): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory/rotate_api_key`, + method: 'POST', + }) + )?.response as unknown as DirectorySyncJSON; + + return new DirectorySync(json); + }; + + deleteDirectorySync = async (enterpriseConnectionId: string): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory`, + method: 'DELETE', + }) + )?.response as unknown as DeletedObjectJSON; + + return new DeletedObject(json); + }; + + getDirectorySyncUsers = async ( + enterpriseConnectionId: string, + params?: GetDirectorySyncUsersParams, + ): Promise> => { + const res = await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory/users`, + method: 'GET', + search: convertPageToOffsetSearchParams(params), + }); + + const payload = res?.response as unknown as ClerkPaginatedResponse | undefined; + + return { + total_count: payload?.total_count ?? 0, + data: (payload?.data ?? []).map(row => new DirectorySyncUser(row)), + }; + }; + getMembershipRequests = async ( getRequestParam?: GetMembershipRequestParams, ): Promise> => { diff --git a/packages/clerk-js/src/core/resources/UserSettings.ts b/packages/clerk-js/src/core/resources/UserSettings.ts index 86c928f6d74..7aa0faf6394 100644 --- a/packages/clerk-js/src/core/resources/UserSettings.ts +++ b/packages/clerk-js/src/core/resources/UserSettings.ts @@ -108,6 +108,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource { enterpriseSSO: EnterpriseSSOSettings = { enabled: false, self_serve_sso: false, + self_serve_directory_sync: false, }; passkeySettings: PasskeySettingsData = { allow_autofill: false, @@ -225,7 +226,10 @@ export class UserSettings extends BaseResource implements UserSettingsResource { this.attackProtection.enumeration_protection.enabled, }, }; - this.enterpriseSSO = this.withDefault(data.enterprise_sso, this.enterpriseSSO); + this.enterpriseSSO = { + ...this.withDefault(data.enterprise_sso, this.enterpriseSSO), + self_serve_directory_sync: data.enterprise_sso?.self_serve_directory_sync ?? false, + }; this.passkeySettings = this.withDefault(data.passkey_settings, this.passkeySettings); this.passwordSettings = data.password_settings ? { diff --git a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts index 351629f9f6c..ad1762a1f72 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts @@ -344,4 +344,147 @@ describe('Organization', () => { expect(result.data[0].connectionType).toBe('saml'); }); }); + + describe('directory sync', () => { + const DIRECTORY_PATH = `/organizations/${ORG_ID}/enterprise_connections/ec_123/directory`; + + const directoryJSON = { + object: 'directory' as const, + id: 'scimdir_1', + name: 'Acme Okta', + enterprise_connection_id: 'ec_123', + endpoint_url: 'https://api.example.com/scim/v2', + provider: 'okta' as const, + enabled: false, + group_role_mapping_enabled: false, + attribute_mapping: { 'name.givenName': 'first_name' }, + created_at: 1700000000000, + updated_at: 1700000000000, + }; + + it('fetches the directory from the connection-scoped path', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: directoryJSON })); + + const organization = createOrganization(); + const result = await organization.getDirectorySync('ec_123'); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'GET', path: DIRECTORY_PATH }); + expect(result.id).toBe('scimdir_1'); + expect(result.endpointUrl).toBe('https://api.example.com/scim/v2'); + expect(result.provider).toBe('okta'); + expect(result.attributeMapping).toEqual({ 'name.givenName': 'first_name' }); + expect(result.apiKey).toBeNull(); + }); + + it('creates the directory and exposes the show-once token', async () => { + // @ts-ignore + BaseResource._fetch = vi + .fn() + .mockReturnValue(Promise.resolve({ response: { ...directoryJSON, api_key: 'ak_secret' } })); + + const organization = createOrganization(); + const result = await organization.createDirectorySync('ec_123', { name: 'Acme Okta' }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'POST', + path: DIRECTORY_PATH, + body: { name: 'Acme Okta' }, + }); + expect(result.apiKey).toBe('ak_secret'); + expect(result.__internal_toSnapshot()).not.toHaveProperty('api_key'); + }); + + it('updates the directory, serializing the attribute mapping as JSON', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: { ...directoryJSON, enabled: true } })); + + const organization = createOrganization(); + const result = await organization.updateDirectorySync('ec_123', { + enabled: true, + attributeMapping: { 'name.familyName': 'last_name', 'name.givenName': null }, + }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'PATCH', + path: DIRECTORY_PATH, + body: { + enabled: true, + attribute_mapping: JSON.stringify({ 'name.familyName': 'last_name', 'name.givenName': null }), + }, + }); + expect(result.enabled).toBe(true); + }); + + it('rotates the bearer token', async () => { + // @ts-ignore + BaseResource._fetch = vi + .fn() + .mockReturnValue(Promise.resolve({ response: { ...directoryJSON, api_key: 'ak_new' } })); + + const organization = createOrganization(); + const result = await organization.rotateDirectorySyncToken('ec_123'); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'POST', path: `${DIRECTORY_PATH}/rotate_api_key` }); + expect(result.apiKey).toBe('ak_new'); + }); + + it('deletes the directory', async () => { + // @ts-ignore + BaseResource._fetch = vi + .fn() + .mockReturnValue(Promise.resolve({ response: { object: 'directory', id: 'scimdir_1', deleted: true } })); + + const organization = createOrganization(); + const result = await organization.deleteDirectorySync('ec_123'); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'DELETE', path: DIRECTORY_PATH }); + expect(result.id).toBe('scimdir_1'); + expect(result.deleted).toBe(true); + }); + + it('lists provisioned directory users with pagination', async () => { + const paginated = { + data: [ + { + object: 'directory_user' as const, + id: 'scimdu_1', + user_id: 'user_1', + first_name: 'Ada', + last_name: 'Lovelace', + identifier: 'ada@example.com', + image_url: '', + has_image: false, + active: true, + provisioned_at: 1700000000000, + updated_at: 1700000000000, + }, + ], + total_count: 1, + }; + + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: paginated })); + + const organization = createOrganization(); + const result = await organization.getDirectorySyncUsers('ec_123', { initialPage: 2, pageSize: 10 }); + + // @ts-ignore + const call = BaseResource._fetch.mock.calls[0][0]; + expect(call.method).toBe('GET'); + expect(call.path).toBe(`${DIRECTORY_PATH}/users`); + expect(call.search.get('limit')).toBe('10'); + expect(call.search.get('offset')).toBe('10'); + + expect(result.total_count).toBe(1); + expect(result.data[0].userId).toBe('user_1'); + expect(result.data[0].identifier).toBe('ada@example.com'); + expect(result.data[0].active).toBe(true); + }); + }); }); diff --git a/packages/clerk-js/src/core/resources/__tests__/UserSettings.test.ts b/packages/clerk-js/src/core/resources/__tests__/UserSettings.test.ts index e87df8e5028..5e8f372b441 100644 --- a/packages/clerk-js/src/core/resources/__tests__/UserSettings.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/UserSettings.test.ts @@ -25,6 +25,14 @@ describe('UserSettings', () => { }); }); + it('treats an absent self_serve_directory_sync as disabled', function () { + const sut = new UserSettings({ + enterprise_sso: { enabled: true, self_serve_sso: true }, + } as any); + + expect(sut.enterpriseSSO).toEqual({ enabled: true, self_serve_sso: true, self_serve_directory_sync: false }); + }); + it('returns enabled web3 first factors', function () { const sut = new UserSettings({ attributes: { diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index fc1232779e6..2228a4f5829 100644 --- a/packages/clerk-js/src/core/resources/internal.ts +++ b/packages/clerk-js/src/core/resources/internal.ts @@ -18,6 +18,7 @@ export * from './DeletedObject'; export * from './DisplayConfig'; export * from './EmailAddress'; export * from './EnterpriseAccount'; +export * from './DirectorySync'; export * from './EnterpriseConnection'; export * from './EnterpriseConnectionTestRun'; export * from './Environment'; diff --git a/packages/clerk-js/src/test/fixture-helpers.ts b/packages/clerk-js/src/test/fixture-helpers.ts index f3498850197..c1b4df2708d 100644 --- a/packages/clerk-js/src/test/fixture-helpers.ts +++ b/packages/clerk-js/src/test/fixture-helpers.ts @@ -536,7 +536,7 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { const withEnterpriseSso = () => { us.saml = { enabled: true }; - us.enterprise_sso = { enabled: true, self_serve_sso: false }; + us.enterprise_sso = { enabled: true, self_serve_sso: false, self_serve_directory_sync: false }; }; const withBackupCode = (opts?: Partial) => { diff --git a/packages/shared/src/types/directorySync.ts b/packages/shared/src/types/directorySync.ts new file mode 100644 index 00000000000..bb54cb88460 --- /dev/null +++ b/packages/shared/src/types/directorySync.ts @@ -0,0 +1,108 @@ +import type { ClerkResourceJSON } from './json'; +import type { ClerkResource } from './resource'; + +/** + * The SCIM provider backing a Directory Sync directory. Derived server-side + * from the linked enterprise connection's identity provider. + */ +export type DirectorySyncProvider = 'okta' | 'entra' | 'custom' | 'google'; + +export interface DirectorySyncJSON extends ClerkResourceJSON { + object: 'directory'; + name: string; + enterprise_connection_id: string | null; + endpoint_url: string; + provider: DirectorySyncProvider; + enabled: boolean; + group_role_mapping_enabled: boolean; + attribute_mapping: Record; + /** + * The SCIM bearer token. Only present on create and rotate responses; it + * cannot be retrieved again afterwards. + */ + api_key?: string | null; + created_at: number; + updated_at: number; +} + +export type DirectorySyncJSONSnapshot = DirectorySyncJSON; + +export interface DirectorySyncResource extends ClerkResource { + /** The directory ID. */ + id: string; + /** The display name of the directory. */ + name: string; + /** The ID of the enterprise connection the directory provisions through. */ + enterpriseConnectionId: string | null; + /** The SCIM 2.0 endpoint URL the identity provider pushes to. */ + endpointUrl: string; + /** The SCIM provider, derived from the linked enterprise connection. */ + provider: DirectorySyncProvider; + /** Whether provisioning is active. */ + enabled: boolean; + /** Whether directory groups are mapped to organization roles. */ + groupRoleMappingEnabled: boolean; + /** The SCIM attribute paths mapped onto Clerk user attributes. */ + attributeMapping: Record; + /** + * The SCIM bearer token. Only populated on the resource returned by + * `createDirectorySync` and `rotateDirectorySyncToken`; `null` everywhere + * else — generate a new token if it was lost. + */ + apiKey: string | null; + /** The date when the directory was created. */ + createdAt: Date | null; + /** The date when the directory was last updated. */ + updatedAt: Date | null; + __internal_toSnapshot: () => DirectorySyncJSONSnapshot; +} + +export interface DirectorySyncUserJSON extends ClerkResourceJSON { + object: 'directory_user'; + user_id: string; + first_name: string | null; + last_name: string | null; + identifier: string | null; + image_url: string; + has_image: boolean; + active: boolean; + provisioned_at: number; + updated_at: number; +} + +/** + * A user the identity provider has provisioned into the directory, in + * public-user-data shape. + */ +export interface DirectorySyncUserResource extends ClerkResource { + id: string; + userId: string; + firstName: string | null; + lastName: string | null; + /** The user's primary email address. */ + identifier: string | null; + imageUrl: string; + hasImage: boolean; + /** `false` once the identity provider has deprovisioned the user. */ + active: boolean; + /** The date the user was provisioned into the directory. */ + provisionedAt: Date | null; + updatedAt: Date | null; +} + +export type UpdateDirectorySyncParams = { + /** Activates (`true`) or deactivates (`false`) provisioning. */ + enabled?: boolean; + /** Partial attribute mapping to merge into the stored one; `null` values remove keys. */ + attributeMapping?: Record; +}; + +export type CreateDirectorySyncParams = { + /** Optional display name; defaults to the enterprise connection's name. */ + name?: string; +}; + +export type GetDirectorySyncUsersParams = { + initialPage?: number; + pageSize?: number; +}; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 577a38ab18d..f9241426bdf 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -16,6 +16,7 @@ export type * from './displayConfig'; export type * from './elementIds'; export type * from './emailAddress'; export type * from './enterpriseAccount'; +export type * from './directorySync'; export type * from './enterpriseConnection'; export type * from './enterpriseConnectionTestRun'; export type * from './environment'; diff --git a/packages/shared/src/types/organization.ts b/packages/shared/src/types/organization.ts index 53ec35142e1..9bc901e48f7 100644 --- a/packages/shared/src/types/organization.ts +++ b/packages/shared/src/types/organization.ts @@ -1,5 +1,12 @@ import type { BillingPayerMethods } from './billing'; import type { DeletedObjectResource } from './deletedObject'; +import type { + CreateDirectorySyncParams, + DirectorySyncResource, + DirectorySyncUserResource, + GetDirectorySyncUsersParams, + UpdateDirectorySyncParams, +} from './directorySync'; import type { CreateOrganizationEnterpriseConnectionParams, EnterpriseConnectionResource, @@ -221,6 +228,44 @@ export interface OrganizationResource extends ClerkResource, BillingPayerMethods enterpriseConnectionId: string, params?: GetEnterpriseConnectionTestRunsParams, ) => Promise>; + /** + * Gets the Directory Sync directory bound to the given enterprise connection. The returned resource never carries + * the SCIM bearer token. + */ + getDirectorySync: (enterpriseConnectionId: string) => Promise; + /** + * Provisions Directory Sync for the given enterprise connection. The returned resource is the only place the SCIM + * bearer token (`apiKey`) is ever available; rotate it to obtain a new one. + */ + createDirectorySync: ( + enterpriseConnectionId: string, + params?: CreateDirectorySyncParams, + ) => Promise; + /** + * Updates the Directory Sync directory bound to the given enterprise connection, e.g. to activate or deactivate + * provisioning. + */ + updateDirectorySync: ( + enterpriseConnectionId: string, + params: UpdateDirectorySyncParams, + ) => Promise; + /** + * Mints a new SCIM bearer token for the directory, expiring the previous one after a short grace period. The + * returned resource is the only place the new token is available. + */ + rotateDirectorySyncToken: (enterpriseConnectionId: string) => Promise; + /** + * Deletes the connection's directory and stops provisioning. Previously provisioned members keep their + * memberships. + */ + deleteDirectorySync: (enterpriseConnectionId: string) => Promise; + /** + * Gets the users the identity provider has provisioned into the connection's directory. + */ + getDirectorySyncUsers: ( + enterpriseConnectionId: string, + params?: GetDirectorySyncUsersParams, + ) => Promise>; /** * Deletes the Organization. Only administrators can delete an Organization. * diff --git a/packages/shared/src/types/userSettings.ts b/packages/shared/src/types/userSettings.ts index ec5a599a2f6..98ba391e9c5 100644 --- a/packages/shared/src/types/userSettings.ts +++ b/packages/shared/src/types/userSettings.ts @@ -99,6 +99,8 @@ export type OAuthProviders = { export type EnterpriseSSOSettings = { enabled: boolean; self_serve_sso: boolean; + /** Whether end-users may manage Directory Sync for their enterprise connections. Absent from older backends, which means `false`. */ + self_serve_directory_sync: boolean; }; export type AttributesJSON = { From a71fc26743102c779aa3f872bf1c84d61fb5618f Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Fri, 28 Aug 2026 18:16:40 -0700 Subject: [PATCH 2/5] feat(self-serve-ds): move Directory Sync mutations onto the DirectorySync resource Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U54pszNFtqsBNpQhXaGvaa --- .../src/core/resources/DirectorySync.ts | 78 ++++++++++- .../src/core/resources/Organization.ts | 72 +--------- .../resources/__tests__/DirectorySync.test.ts | 123 ++++++++++++++++++ .../resources/__tests__/Organization.test.ts | 90 ------------- packages/shared/src/types/directorySync.ts | 27 +++- packages/shared/src/types/organization.ts | 33 +---- 6 files changed, 225 insertions(+), 198 deletions(-) create mode 100644 packages/clerk-js/src/core/resources/__tests__/DirectorySync.test.ts diff --git a/packages/clerk-js/src/core/resources/DirectorySync.ts b/packages/clerk-js/src/core/resources/DirectorySync.ts index 8f97adbe7e8..bb2165428b8 100644 --- a/packages/clerk-js/src/core/resources/DirectorySync.ts +++ b/packages/clerk-js/src/core/resources/DirectorySync.ts @@ -1,19 +1,27 @@ import type { + ClerkPaginatedResponse, + DeletedObjectJSON, + DeletedObjectResource, DirectorySyncJSON, DirectorySyncJSONSnapshot, DirectorySyncProvider, DirectorySyncResource, DirectorySyncUserJSON, DirectorySyncUserResource, + GetDirectorySyncUsersParams, + UpdateDirectorySyncParams, } from '@clerk/shared/types'; +import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffsetSearchParams'; import { unixEpochToDate } from '../../utils/date'; import { BaseResource } from './Base'; +import { DeletedObject } from './DeletedObject'; export class DirectorySync extends BaseResource implements DirectorySyncResource { id!: string; name!: string; - enterpriseConnectionId: string | null = null; + organizationId!: string; + enterpriseConnectionId!: string; endpointUrl!: string; provider!: DirectorySyncProvider; enabled!: boolean; @@ -23,11 +31,75 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource createdAt: Date | null = null; updatedAt: Date | null = null; - constructor(data: DirectorySyncJSON | DirectorySyncJSONSnapshot | null) { + constructor(data: DirectorySyncJSON | DirectorySyncJSONSnapshot | null, organizationId: string) { super(); + this.organizationId = organizationId; this.fromJSON(data); } + private get directoryPath(): string { + return `/organizations/${this.organizationId}/enterprise_connections/${this.enterpriseConnectionId}/directory`; + } + + update = async (params: UpdateDirectorySyncParams): Promise => { + const body: Record = {}; + if (params.enabled !== undefined) { + body.enabled = params.enabled; + } + if (params.attributeMapping !== undefined) { + body.attribute_mapping = JSON.stringify(params.attributeMapping); + } + + const json = ( + await BaseResource._fetch({ + path: this.directoryPath, + method: 'PATCH', + body: body as any, + }) + )?.response as unknown as DirectorySyncJSON; + + return new DirectorySync(json, this.organizationId); + }; + + rotateToken = async (): Promise => { + const json = ( + await BaseResource._fetch({ + path: `${this.directoryPath}/rotate_api_key`, + method: 'POST', + }) + )?.response as unknown as DirectorySyncJSON; + + return new DirectorySync(json, this.organizationId); + }; + + delete = async (): Promise => { + const json = ( + await BaseResource._fetch({ + path: this.directoryPath, + method: 'DELETE', + }) + )?.response as unknown as DeletedObjectJSON; + + return new DeletedObject(json); + }; + + getUsers = async ( + params?: GetDirectorySyncUsersParams, + ): Promise> => { + const res = await BaseResource._fetch({ + path: `${this.directoryPath}/users`, + method: 'GET', + search: convertPageToOffsetSearchParams(params), + }); + + const payload = res?.response as unknown as ClerkPaginatedResponse | undefined; + + return { + total_count: payload?.total_count ?? 0, + data: (payload?.data ?? []).map(row => new DirectorySyncUser(row)), + }; + }; + protected fromJSON(data: DirectorySyncJSON | DirectorySyncJSONSnapshot | null): this { if (!data) { return this; @@ -35,7 +107,7 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource this.id = data.id; this.name = data.name; - this.enterpriseConnectionId = data.enterprise_connection_id ?? null; + this.enterpriseConnectionId = data.enterprise_connection_id; this.endpointUrl = data.endpoint_url; this.provider = data.provider; this.enabled = data.enabled; diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index b044ac48525..8d984e27b13 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -10,8 +10,6 @@ import type { DeletedObjectResource, DirectorySyncJSON, DirectorySyncResource, - DirectorySyncUserJSON, - DirectorySyncUserResource, EnterpriseConnectionJSON, EnterpriseConnectionResource, EnterpriseConnectionTestRunInitJSON, @@ -19,7 +17,6 @@ import type { EnterpriseConnectionTestRunJSON, EnterpriseConnectionTestRunResource, EnterpriseConnectionTestRunsPaginatedJSON, - GetDirectorySyncUsersParams, GetDomainsParams, GetEnterpriseConnectionsParams, GetEnterpriseConnectionTestRunsParams, @@ -43,7 +40,6 @@ import type { OrganizationResource, RoleJSON, SetOrganizationLogoParams, - UpdateDirectorySyncParams, UpdateMembershipParams, UpdateOrganizationEnterpriseConnectionParams, UpdateOrganizationParams, @@ -57,7 +53,6 @@ import { BaseResource, DeletedObject, DirectorySync, - DirectorySyncUser, EnterpriseConnection, EnterpriseConnectionTestRun, OrganizationInvitation, @@ -291,7 +286,7 @@ export class Organization extends BaseResource implements OrganizationResource { }) )?.response as unknown as DirectorySyncJSON; - return new DirectorySync(json); + return new DirectorySync(json, this.id); }; createDirectorySync = async ( @@ -306,70 +301,7 @@ export class Organization extends BaseResource implements OrganizationResource { }) )?.response as unknown as DirectorySyncJSON; - return new DirectorySync(json); - }; - - updateDirectorySync = async ( - enterpriseConnectionId: string, - params: UpdateDirectorySyncParams, - ): Promise => { - const body: Record = {}; - if (params.enabled !== undefined) { - body.enabled = params.enabled; - } - if (params.attributeMapping !== undefined) { - body.attribute_mapping = JSON.stringify(params.attributeMapping); - } - - const json = ( - await BaseResource._fetch({ - path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory`, - method: 'PATCH', - body: body as any, - }) - )?.response as unknown as DirectorySyncJSON; - - return new DirectorySync(json); - }; - - rotateDirectorySyncToken = async (enterpriseConnectionId: string): Promise => { - const json = ( - await BaseResource._fetch({ - path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory/rotate_api_key`, - method: 'POST', - }) - )?.response as unknown as DirectorySyncJSON; - - return new DirectorySync(json); - }; - - deleteDirectorySync = async (enterpriseConnectionId: string): Promise => { - const json = ( - await BaseResource._fetch({ - path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory`, - method: 'DELETE', - }) - )?.response as unknown as DeletedObjectJSON; - - return new DeletedObject(json); - }; - - getDirectorySyncUsers = async ( - enterpriseConnectionId: string, - params?: GetDirectorySyncUsersParams, - ): Promise> => { - const res = await BaseResource._fetch({ - path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory/users`, - method: 'GET', - search: convertPageToOffsetSearchParams(params), - }); - - const payload = res?.response as unknown as ClerkPaginatedResponse | undefined; - - return { - total_count: payload?.total_count ?? 0, - data: (payload?.data ?? []).map(row => new DirectorySyncUser(row)), - }; + return new DirectorySync(json, this.id); }; getMembershipRequests = async ( diff --git a/packages/clerk-js/src/core/resources/__tests__/DirectorySync.test.ts b/packages/clerk-js/src/core/resources/__tests__/DirectorySync.test.ts new file mode 100644 index 00000000000..81f3210e743 --- /dev/null +++ b/packages/clerk-js/src/core/resources/__tests__/DirectorySync.test.ts @@ -0,0 +1,123 @@ +import type { DirectorySyncJSON } from '@clerk/shared/types'; +import { describe, expect, it, vi } from 'vitest'; + +import { BaseResource, DirectorySync } from '../internal'; + +const ORG_ID = 'org_123'; +const DIRECTORY_PATH = `/organizations/${ORG_ID}/enterprise_connections/ec_123/directory`; + +const directoryJSON: DirectorySyncJSON = { + object: 'directory', + id: 'scimdir_1', + name: 'Acme Okta', + enterprise_connection_id: 'ec_123', + endpoint_url: 'https://api.example.com/scim/v2', + provider: 'okta', + enabled: false, + group_role_mapping_enabled: false, + attribute_mapping: { 'name.givenName': 'first_name' }, + created_at: 1700000000000, + updated_at: 1700000000000, +}; + +function createDirectorySync(): DirectorySync { + return new DirectorySync(directoryJSON, ORG_ID); +} + +describe('DirectorySync', () => { + it('scopes itself to the owning organization and connection', () => { + const directory = createDirectorySync(); + + expect(directory.organizationId).toBe(ORG_ID); + expect(directory.enterpriseConnectionId).toBe('ec_123'); + expect(directory.apiKey).toBeNull(); + expect(directory.__internal_toSnapshot()).not.toHaveProperty('api_key'); + }); + + it('updates the directory, serializing the attribute mapping as JSON', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: { ...directoryJSON, enabled: true } })); + + const result = await createDirectorySync().update({ + enabled: true, + attributeMapping: { 'name.familyName': 'last_name', 'name.givenName': null }, + }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'PATCH', + path: DIRECTORY_PATH, + body: { + enabled: true, + attribute_mapping: JSON.stringify({ 'name.familyName': 'last_name', 'name.givenName': null }), + }, + }); + expect(result.enabled).toBe(true); + expect(result.organizationId).toBe(ORG_ID); + }); + + it('rotates the bearer token', async () => { + // @ts-ignore + BaseResource._fetch = vi + .fn() + .mockReturnValue(Promise.resolve({ response: { ...directoryJSON, api_key: 'ak_new' } })); + + const result = await createDirectorySync().rotateToken(); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'POST', path: `${DIRECTORY_PATH}/rotate_api_key` }); + expect(result.apiKey).toBe('ak_new'); + }); + + it('deletes the directory', async () => { + // @ts-ignore + BaseResource._fetch = vi + .fn() + .mockReturnValue(Promise.resolve({ response: { object: 'directory', id: 'scimdir_1', deleted: true } })); + + const result = await createDirectorySync().delete(); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'DELETE', path: DIRECTORY_PATH }); + expect(result.id).toBe('scimdir_1'); + expect(result.deleted).toBe(true); + }); + + it('lists provisioned directory users with pagination', async () => { + const paginated = { + data: [ + { + object: 'directory_user' as const, + id: 'scimdu_1', + user_id: 'user_1', + first_name: 'Ada', + last_name: 'Lovelace', + identifier: 'ada@example.com', + image_url: '', + has_image: false, + active: true, + provisioned_at: 1700000000000, + updated_at: 1700000000000, + }, + ], + total_count: 1, + }; + + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: paginated })); + + const result = await createDirectorySync().getUsers({ initialPage: 2, pageSize: 10 }); + + // @ts-ignore + const call = BaseResource._fetch.mock.calls[0][0]; + expect(call.method).toBe('GET'); + expect(call.path).toBe(`${DIRECTORY_PATH}/users`); + expect(call.search.get('limit')).toBe('10'); + expect(call.search.get('offset')).toBe('10'); + + expect(result.total_count).toBe(1); + expect(result.data[0].userId).toBe('user_1'); + expect(result.data[0].identifier).toBe('ada@example.com'); + expect(result.data[0].active).toBe(true); + }); +}); diff --git a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts index ad1762a1f72..400683ba0cb 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts @@ -396,95 +396,5 @@ describe('Organization', () => { expect(result.apiKey).toBe('ak_secret'); expect(result.__internal_toSnapshot()).not.toHaveProperty('api_key'); }); - - it('updates the directory, serializing the attribute mapping as JSON', async () => { - // @ts-ignore - BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: { ...directoryJSON, enabled: true } })); - - const organization = createOrganization(); - const result = await organization.updateDirectorySync('ec_123', { - enabled: true, - attributeMapping: { 'name.familyName': 'last_name', 'name.givenName': null }, - }); - - // @ts-ignore - expect(BaseResource._fetch).toHaveBeenCalledWith({ - method: 'PATCH', - path: DIRECTORY_PATH, - body: { - enabled: true, - attribute_mapping: JSON.stringify({ 'name.familyName': 'last_name', 'name.givenName': null }), - }, - }); - expect(result.enabled).toBe(true); - }); - - it('rotates the bearer token', async () => { - // @ts-ignore - BaseResource._fetch = vi - .fn() - .mockReturnValue(Promise.resolve({ response: { ...directoryJSON, api_key: 'ak_new' } })); - - const organization = createOrganization(); - const result = await organization.rotateDirectorySyncToken('ec_123'); - - // @ts-ignore - expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'POST', path: `${DIRECTORY_PATH}/rotate_api_key` }); - expect(result.apiKey).toBe('ak_new'); - }); - - it('deletes the directory', async () => { - // @ts-ignore - BaseResource._fetch = vi - .fn() - .mockReturnValue(Promise.resolve({ response: { object: 'directory', id: 'scimdir_1', deleted: true } })); - - const organization = createOrganization(); - const result = await organization.deleteDirectorySync('ec_123'); - - // @ts-ignore - expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'DELETE', path: DIRECTORY_PATH }); - expect(result.id).toBe('scimdir_1'); - expect(result.deleted).toBe(true); - }); - - it('lists provisioned directory users with pagination', async () => { - const paginated = { - data: [ - { - object: 'directory_user' as const, - id: 'scimdu_1', - user_id: 'user_1', - first_name: 'Ada', - last_name: 'Lovelace', - identifier: 'ada@example.com', - image_url: '', - has_image: false, - active: true, - provisioned_at: 1700000000000, - updated_at: 1700000000000, - }, - ], - total_count: 1, - }; - - // @ts-ignore - BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: paginated })); - - const organization = createOrganization(); - const result = await organization.getDirectorySyncUsers('ec_123', { initialPage: 2, pageSize: 10 }); - - // @ts-ignore - const call = BaseResource._fetch.mock.calls[0][0]; - expect(call.method).toBe('GET'); - expect(call.path).toBe(`${DIRECTORY_PATH}/users`); - expect(call.search.get('limit')).toBe('10'); - expect(call.search.get('offset')).toBe('10'); - - expect(result.total_count).toBe(1); - expect(result.data[0].userId).toBe('user_1'); - expect(result.data[0].identifier).toBe('ada@example.com'); - expect(result.data[0].active).toBe(true); - }); }); }); diff --git a/packages/shared/src/types/directorySync.ts b/packages/shared/src/types/directorySync.ts index bb54cb88460..151ad6587b6 100644 --- a/packages/shared/src/types/directorySync.ts +++ b/packages/shared/src/types/directorySync.ts @@ -1,4 +1,6 @@ +import type { DeletedObjectResource } from './deletedObject'; import type { ClerkResourceJSON } from './json'; +import type { ClerkPaginatedResponse } from './pagination'; import type { ClerkResource } from './resource'; /** @@ -10,7 +12,7 @@ export type DirectorySyncProvider = 'okta' | 'entra' | 'custom' | 'google'; export interface DirectorySyncJSON extends ClerkResourceJSON { object: 'directory'; name: string; - enterprise_connection_id: string | null; + enterprise_connection_id: string; endpoint_url: string; provider: DirectorySyncProvider; enabled: boolean; @@ -32,8 +34,10 @@ export interface DirectorySyncResource extends ClerkResource { id: string; /** The display name of the directory. */ name: string; + /** The ID of the organization that owns the directory. */ + organizationId: string; /** The ID of the enterprise connection the directory provisions through. */ - enterpriseConnectionId: string | null; + enterpriseConnectionId: string; /** The SCIM 2.0 endpoint URL the identity provider pushes to. */ endpointUrl: string; /** The SCIM provider, derived from the linked enterprise connection. */ @@ -46,7 +50,7 @@ export interface DirectorySyncResource extends ClerkResource { attributeMapping: Record; /** * The SCIM bearer token. Only populated on the resource returned by - * `createDirectorySync` and `rotateDirectorySyncToken`; `null` everywhere + * `Organization.createDirectorySync` and `rotateToken`; `null` everywhere * else — generate a new token if it was lost. */ apiKey: string | null; @@ -54,6 +58,23 @@ export interface DirectorySyncResource extends ClerkResource { createdAt: Date | null; /** The date when the directory was last updated. */ updatedAt: Date | null; + /** + * Updates the directory, e.g. to activate or deactivate provisioning. + */ + update: (params: UpdateDirectorySyncParams) => Promise; + /** + * Mints a new SCIM bearer token, expiring the previous one after a short grace period. The returned resource is + * the only place the new token is available. + */ + rotateToken: () => Promise; + /** + * Deletes the directory and stops provisioning. Previously provisioned members keep their memberships. + */ + delete: () => Promise; + /** + * Gets the users the identity provider has provisioned into the directory. + */ + getUsers: (params?: GetDirectorySyncUsersParams) => Promise>; __internal_toSnapshot: () => DirectorySyncJSONSnapshot; } diff --git a/packages/shared/src/types/organization.ts b/packages/shared/src/types/organization.ts index 9bc901e48f7..ba15ed72ed3 100644 --- a/packages/shared/src/types/organization.ts +++ b/packages/shared/src/types/organization.ts @@ -1,12 +1,6 @@ import type { BillingPayerMethods } from './billing'; import type { DeletedObjectResource } from './deletedObject'; -import type { - CreateDirectorySyncParams, - DirectorySyncResource, - DirectorySyncUserResource, - GetDirectorySyncUsersParams, - UpdateDirectorySyncParams, -} from './directorySync'; +import type { CreateDirectorySyncParams, DirectorySyncResource } from './directorySync'; import type { CreateOrganizationEnterpriseConnectionParams, EnterpriseConnectionResource, @@ -241,31 +235,6 @@ export interface OrganizationResource extends ClerkResource, BillingPayerMethods enterpriseConnectionId: string, params?: CreateDirectorySyncParams, ) => Promise; - /** - * Updates the Directory Sync directory bound to the given enterprise connection, e.g. to activate or deactivate - * provisioning. - */ - updateDirectorySync: ( - enterpriseConnectionId: string, - params: UpdateDirectorySyncParams, - ) => Promise; - /** - * Mints a new SCIM bearer token for the directory, expiring the previous one after a short grace period. The - * returned resource is the only place the new token is available. - */ - rotateDirectorySyncToken: (enterpriseConnectionId: string) => Promise; - /** - * Deletes the connection's directory and stops provisioning. Previously provisioned members keep their - * memberships. - */ - deleteDirectorySync: (enterpriseConnectionId: string) => Promise; - /** - * Gets the users the identity provider has provisioned into the connection's directory. - */ - getDirectorySyncUsers: ( - enterpriseConnectionId: string, - params?: GetDirectorySyncUsersParams, - ) => Promise>; /** * Deletes the Organization. Only administrators can delete an Organization. * From 2d92bc60388476255c671faadaa66d91e18952f2 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Mon, 31 Aug 2026 09:45:37 -0700 Subject: [PATCH 3/5] feat(self-serve-ds): add changeset for Directory Sync Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U54pszNFtqsBNpQhXaGvaa --- .changeset/dir-sync-self-serve-wiring.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/dir-sync-self-serve-wiring.md diff --git a/.changeset/dir-sync-self-serve-wiring.md b/.changeset/dir-sync-self-serve-wiring.md new file mode 100644 index 00000000000..7154d681b95 --- /dev/null +++ b/.changeset/dir-sync-self-serve-wiring.md @@ -0,0 +1,8 @@ +--- +'@clerk/clerk-js': minor +'@clerk/localizations': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Add self-serve Directory Sync (SCIM) setup. The `Organization` resource gains `getDirectorySync()` and `createDirectorySync()` for the directory bound to an enterprise connection, and the returned `DirectorySync` resource exposes `update()`, `rotateToken()`, `delete()`, and `getUsers()`; the SCIM bearer token is only returned by `createDirectorySync()` and `rotateToken()`. The `OrganizationProfile` Security page gains a Directory Sync section, and the internal `ConfigureDirectorySync` component walks through the setup. Both are only shown when the instance has self-serve Directory Sync enabled. From 35275184ece55e712661112259b22c5f289fe40d Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Tue, 1 Sep 2026 13:36:41 -0700 Subject: [PATCH 4/5] Simplify body parameter --- .../clerk-js/src/core/resources/Organization.ts | 2 +- .../core/resources/__tests__/Organization.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index 8d984e27b13..28d1d9b696f 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -297,7 +297,7 @@ export class Organization extends BaseResource implements OrganizationResource { await BaseResource._fetch({ path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory`, method: 'POST', - body: (params?.name ? { name: params.name } : {}) as any, + body: { name: params?.name } as any, }) )?.response as unknown as DirectorySyncJSON; diff --git a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts index 400683ba0cb..e1ba593abe7 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts @@ -396,5 +396,20 @@ describe('Organization', () => { expect(result.apiKey).toBe('ak_secret'); expect(result.__internal_toSnapshot()).not.toHaveProperty('api_key'); }); + + it('creates the directory without a name, leaving it to the FAPI client to drop the undefined field', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: directoryJSON })); + + const organization = createOrganization(); + await organization.createDirectorySync('ec_123'); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'POST', + path: DIRECTORY_PATH, + body: { name: undefined }, + }); + }); }); }); From 490bd714c5c62fea8a5b884bc97aa7aba3eeb6d6 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Tue, 1 Sep 2026 13:48:55 -0700 Subject: [PATCH 5/5] chore(js): bump bundlewatch limits for DirectorySync resource Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YLdfJcha2UZyPxBv6TEq8w --- packages/clerk-js/bundlewatch.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index a9b53bd6c93..0c841d72c11 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "552KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "79KB" }, + { "path": "./dist/clerk.js", "maxSize": "553KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "79.5KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "122KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "320KB" }, { "path": "./dist/clerk.native.js", "maxSize": "79KB" },