Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
16fdd04
deprecate metadata updates via clerkClient.users.updateUser and user.…
brunol95 May 18, 2026
bf5b092
update api version in test
brunol95 May 19, 2026
aebfef5
user.update refresh user object on metadata only updates
brunol95 May 20, 2026
926d666
fix: isPlainObject rejects Date,Map, class interfaces
brunol95 May 20, 2026
c4ccc16
chore: fix changeset formatting
wobsoriano May 20, 2026
34c0df9
Merge branch 'main' into bruno/user-5312-deprecate-update-user-metada…
wobsoriano May 20, 2026
34c3fc8
address lint
brunol95 May 20, 2026
e0df27e
Merge branch 'bruno/user-5312-deprecate-update-user-metadata-fields' …
brunol95 May 20, 2026
4cf3a28
change supported version to latest for testing
brunol95 May 20, 2026
d63df08
remove unused import
brunol95 May 20, 2026
90e5e32
clerk-js: use console.warn instead of deprecated in user.update
brunol95 May 20, 2026
b30b0fa
chore: Use proper Clerk dev instance check
wobsoriano May 20, 2026
a6fc584
chore: Mock BaseResource.clerk
wobsoriano May 20, 2026
0e1a541
chore: test warning message
wobsoriano May 20, 2026
a458bb9
chore: lint fix
wobsoriano May 20, 2026
f4c1ea9
Merge branch 'main' into bruno/user-5312-deprecate-update-user-metada…
wobsoriano May 27, 2026
d0ab1d3
Merge branch 'main' into bruno/user-5312-deprecate-update-user-metada…
brunol95 Jun 9, 2026
d03eca9
update supported bapi and fapi versions to latest
brunol95 Jun 10, 2026
c8e9d9e
Merge branch 'main' into bruno/user-5312-deprecate-update-user-metada…
wobsoriano Jun 11, 2026
626bce4
Merge branch 'main' into bruno/user-5312-deprecate-update-user-metada…
brunol95 Jun 11, 2026
d7e6050
update bundle maxSize
brunol95 Jun 11, 2026
d582a57
Merge branch 'main' into bruno/user-5312-deprecate-update-user-metada…
wobsoriano Jun 11, 2026
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
5 changes: 5 additions & 0 deletions .changeset/deprecate-update-user-metadata.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's split this into separate changesets so each package changelog stays focused 👍🏼

  1. for backend
  2. for clerk-js and shared

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': minor
---

Add `clerkClient.users.replaceUserMetadata(userId, params)` for full-replacement metadata updates. Deprecate the `publicMetadata`, `privateMetadata`, and `unsafeMetadata` parameters on `clerkClient.users.updateUser` — use `updateUserMetadata` for partial updates (deep merge) or `replaceUserMetadata` for full replacement.
Comment thread
brunol95 marked this conversation as resolved.
Outdated
6 changes: 6 additions & 0 deletions .changeset/route-unsafe-metadata-to-merge-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
---

Deprecate `unsafeMetadata` on `user.update()`. Use `user.updateMetadata({ unsafeMetadata })` for partial updates (deep merge) instead. The parameter continues to work — metadata is now routed through `PATCH /v1/me/metadata`.
Comment thread
wobsoriano marked this conversation as resolved.
Outdated
101 changes: 101 additions & 0 deletions integration/tests/unsafeMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,105 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('unsafeMet

await fakeUser.deleteIfExists();
});

// Helper: sign up a user via the UI and return the BAPI user id once the
// client session is established. Mirrors the existing sign-up test flow so
// these specs share the same baseline (`unsafeMetadata: { position: 'goalie' }`).
const signUpAndGetUser = async ({ page, context }: { page: any; context: any }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
withPhoneNumber: true,
withUsername: true,
});

await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();

const bapiUser = await u.services.users.getUser({ email: fakeUser.email });
expect(bapiUser?.unsafeMetadata).toEqual({ position: 'goalie' });

return { u, fakeUser, bapiUser: bapiUser! };
};

test('user.update({ unsafeMetadata }) preserves replace semantics end-to-end', async ({ page, context }) => {
const { u, fakeUser, bapiUser } = await signUpAndGetUser({ page, context });

// Drive the deprecated path from the browser. The SDK should route
// metadata through PATCH /v1/me/metadata after computing a merge patch
// against the locally-cached value; the server-side outcome must match
// a true replace (the original `position` key is gone).
await page.evaluate(async () => {
await (window as any).Clerk.user.update({ unsafeMetadata: { city: 'Toronto' } });
Comment thread
brunol95 marked this conversation as resolved.
Outdated
});

const refreshed = await u.services.users.getUser({ id: bapiUser.id });
expect(refreshed?.unsafeMetadata).toEqual({ city: 'Toronto' });

await fakeUser.deleteIfExists();
});

test('user.updateMetadata({ unsafeMetadata }) deep-merges (recommended path)', async ({ page, context }) => {
const { u, fakeUser, bapiUser } = await signUpAndGetUser({ page, context });

// The recommended migration target. Unlike `update(...)`, this is a
// partial update — the original `position` key must survive.
await page.evaluate(async () => {
await (window as any).Clerk.user.updateMetadata({ unsafeMetadata: { city: 'Toronto' } });
});

const refreshed = await u.services.users.getUser({ id: bapiUser.id });
expect(refreshed?.unsafeMetadata).toEqual({ position: 'goalie', city: 'Toronto' });

await fakeUser.deleteIfExists();
});

test('user.update with metadata + non-metadata fields persists both', async ({ page, context }) => {
const { u, fakeUser, bapiUser } = await signUpAndGetUser({ page, context });

// Mixed call: PATCH /v1/me for the non-metadata field, then
// PATCH /v1/me/metadata for the computed patch. Both must land.
await page.evaluate(async () => {
await (window as any).Clerk.user.update({
firstName: 'Updated',
unsafeMetadata: { city: 'Toronto' },
});
});

const refreshed = await u.services.users.getUser({ id: bapiUser.id });
expect(refreshed?.firstName).toBe('Updated');
expect(refreshed?.unsafeMetadata).toEqual({ city: 'Toronto' });

await fakeUser.deleteIfExists();
});

test('user.update reloads before diffing so server-side mutations are not lost', async ({ page, context }) => {
const { u, fakeUser, bapiUser } = await signUpAndGetUser({ page, context });

// Simulate a server-side mutation made by *another* actor
// after the browser cached the user.
// The browser's local `unsafeMetadata` is now stale,
// missing the `adminAdded` key.
await u.services.clerk.users.updateUserMetadata(bapiUser.id, {
unsafeMetadata: { adminAdded: 'yes' },
});

// From the browser, call the deprecated path with replace intent.
// Without the pre-diff reload, the SDK would diff against stale `{ position: 'goalie' }`
// send `{ position: null, city: 'Toronto' }`, and the server-side `adminAdded` would silently survive violating replace semantics.
// The reload makes the SDK observe the fresh state and null-delete the server-added key too.
await page.evaluate(async () => {
await (window as any).Clerk.user.update({ unsafeMetadata: { city: 'Toronto' } });
});

const refreshed = await u.services.users.getUser({ id: bapiUser.id });
expect(refreshed?.unsafeMetadata).toEqual({ city: 'Toronto' });

await fakeUser.deleteIfExists();
});
});
179 changes: 179 additions & 0 deletions packages/backend/src/api/__tests__/UserApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it, vi } from 'vitest';

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

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

const mockUserResponse = {
object: 'user',
id: 'user_123',
public_metadata: {},
private_metadata: {},
unsafe_metadata: {},
};

describe('updateUser', () => {
it('calls PATCH /users/{id} when no metadata fields are provided', async () => {
const patchHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({ first_name: 'Jane' });
return HttpResponse.json(mockUserResponse);
});

server.use(http.patch('https://api.clerk.test/v1/users/user_123', validateHeaders(patchHandler)));

const response = await apiClient.users.updateUser('user_123', { firstName: 'Jane' });

expect(patchHandler).toHaveBeenCalledTimes(1);
expect(response.id).toBe('user_123');
});

it('routes metadata to PUT /users/{id}/metadata when only metadata is provided', async () => {
const patchHandler = vi.fn(() => HttpResponse.json(mockUserResponse));
const putHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({
public_metadata: { foo: 'bar' },
});
return HttpResponse.json({
...mockUserResponse,
public_metadata: { foo: 'bar' },
});
});

server.use(
http.patch('https://api.clerk.test/v1/users/user_123', validateHeaders(patchHandler)),
http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)),
);

const response = await apiClient.users.updateUser('user_123', {
publicMetadata: { foo: 'bar' },
});

expect(patchHandler).not.toHaveBeenCalled();
expect(putHandler).toHaveBeenCalledTimes(1);
expect(response.publicMetadata).toEqual({ foo: 'bar' });
});

it('splits mixed calls: PATCH for non-metadata, then PUT for metadata', async () => {
const calls: string[] = [];

const patchHandler = vi.fn(async ({ request }: { request: Request }) => {
calls.push('patch');
const body = await request.json();
expect(body).toEqual({ first_name: 'Jane' });
return HttpResponse.json(mockUserResponse);
});

const putHandler = vi.fn(async ({ request }: { request: Request }) => {
calls.push('put');
const body = await request.json();
expect(body).toEqual({
public_metadata: { plan: 'pro' },
private_metadata: { invoice: 'inv_1' },
});
return HttpResponse.json({
...mockUserResponse,
first_name: 'Jane',
public_metadata: { plan: 'pro' },
private_metadata: { invoice: 'inv_1' },
});
});

server.use(
http.patch('https://api.clerk.test/v1/users/user_123', validateHeaders(patchHandler)),
http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)),
);

const response = await apiClient.users.updateUser('user_123', {
firstName: 'Jane',
publicMetadata: { plan: 'pro' },
privateMetadata: { invoice: 'inv_1' },
});

expect(patchHandler).toHaveBeenCalledTimes(1);
expect(putHandler).toHaveBeenCalledTimes(1);
// PATCH must run before PUT so the user state from PUT is the latest.
expect(calls).toEqual(['patch', 'put']);
expect(response.firstName).toBe('Jane');
expect(response.publicMetadata).toEqual({ plan: 'pro' });
});

it('passes only metadata fields that were explicitly provided to PUT', async () => {
const putHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = (await request.json()) as Record<string, unknown>;
// Only unsafe_metadata was provided. The other two should be undefined,
// which serializes to "field omitted" on the wire — leaving those
// columns untouched server-side.
expect(body.unsafe_metadata).toEqual({ device: 'mobile' });
expect(body).not.toHaveProperty('public_metadata');
expect(body).not.toHaveProperty('private_metadata');
return HttpResponse.json({
...mockUserResponse,
unsafe_metadata: { device: 'mobile' },
});
});

server.use(http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)));

await apiClient.users.updateUser('user_123', {
unsafeMetadata: { device: 'mobile' },
});

expect(putHandler).toHaveBeenCalledTimes(1);
});
});

describe('updateUserMetadata', () => {
it('still hits PATCH /users/{id}/metadata (unchanged)', async () => {
const patchHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({
public_metadata: { merge: true },
});
return HttpResponse.json({
...mockUserResponse,
public_metadata: { merge: true },
});
});

server.use(http.patch('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(patchHandler)));

await apiClient.users.updateUserMetadata('user_123', {
publicMetadata: { merge: true },
});

expect(patchHandler).toHaveBeenCalledTimes(1);
});
});

describe('replaceUserMetadata', () => {
it('hits PUT /users/{id}/metadata', async () => {
const putHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({
public_metadata: { replaced: true },
});
return HttpResponse.json({
...mockUserResponse,
public_metadata: { replaced: true },
});
});

server.use(http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)));

const response = await apiClient.users.replaceUserMetadata('user_123', {
publicMetadata: { replaced: true },
});

expect(putHandler).toHaveBeenCalledTimes(1);
expect(response.publicMetadata).toEqual({ replaced: true });
});
});
});
79 changes: 74 additions & 5 deletions packages/backend/src/api/endpoints/UserApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,34 @@ type UpdateUserParams = {

/** The maximum number of Organizations the user can create. 0 means unlimited. */
createOrganizationsLimit?: number;
} & UserMetadataParams &
(UserPasswordHashingParams | object);

/**
* Metadata visible to your Frontend and Backend APIs.
*
* @deprecated Updating metadata via `updateUser` is deprecated. Use
* `updateUserMetadata` for partial updates (deep merge) or
* `replaceUserMetadata` for full replacement.
*/
publicMetadata?: UserPublicMetadata;

/**
* Metadata visible only to your Backend API.
*
* @deprecated Updating metadata via `updateUser` is deprecated. Use
* `updateUserMetadata` for partial updates (deep merge) or
* `replaceUserMetadata` for full replacement.
*/
privateMetadata?: UserPrivateMetadata;

/**
* Metadata writeable from both the Frontend and Backend APIs.
*
* @deprecated Updating metadata via `updateUser` is deprecated. Use
* `updateUserMetadata` for partial updates (deep merge) or
* `replaceUserMetadata` for full replacement.
*/
unsafeMetadata?: UserUnsafeMetadata;
} & (UserPasswordHashingParams | object);

type GetOrganizationMembershipListParams = ClerkPaginationRequest<{
userId: string;
Expand Down Expand Up @@ -252,10 +278,38 @@ export class UserAPI extends AbstractAPI {
public async updateUser(userId: string, params: UpdateUserParams = {}) {
this.requireId(userId);

const { publicMetadata, privateMetadata, unsafeMetadata, ...rest } = params as UpdateUserParams &
UserMetadataParams;
const hasMetadata = publicMetadata !== undefined || privateMetadata !== undefined || unsafeMetadata !== undefined;
const hasRest = Object.keys(rest).length > 0;

if (hasMetadata) {
deprecated(
'updateUser(userId, { publicMetadata | privateMetadata | unsafeMetadata })',
'Use updateUserMetadata for partial updates (merge) or replaceUserMetadata for full replacement.',
);
}

if (!hasMetadata) {
return this.request<User>({
method: 'PATCH',
path: joinPaths(basePath, userId),
bodyParams: rest,
});
}

if (hasRest) {
await this.request<User>({
method: 'PATCH',
path: joinPaths(basePath, userId),
bodyParams: rest,
});
}

return this.request<User>({
method: 'PATCH',
path: joinPaths(basePath, userId),
bodyParams: params,
method: 'PUT',
path: joinPaths(basePath, userId, 'metadata'),
bodyParams: { publicMetadata, privateMetadata, unsafeMetadata },
});
}

Expand All @@ -282,6 +336,21 @@ export class UserAPI extends AbstractAPI {
});
}

/**
* Replace a user's metadata. Supplied fields are overwritten in full; fields
* omitted from `params` are left unchanged. Prefer `updateUserMetadata` for
* partial updates with deep-merge semantics.
*/
public async replaceUserMetadata(userId: string, params: UserMetadataParams) {
this.requireId(userId);

return this.request<User>({
method: 'PUT',
path: joinPaths(basePath, userId, 'metadata'),
bodyParams: params,
});
}

public async deleteUser(userId: string) {
this.requireId(userId);
return this.request<User>({
Expand Down
Loading
Loading