Skip to content

Commit 7443113

Browse files
dmoernerclaude
andauthored
feat(backend): add updateSession, mergeUser and the user.merged webhook (#10793)
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
1 parent eb24fb0 commit 7443113

9 files changed

Lines changed: 165 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': minor
3+
---
4+
5+
Add `sessions.updateSession()` to change when a single session expires, and `users.mergeUser()` to merge one user into another. The `user.merged` webhook event is now part of `UserWebhookEvent`, with a payload typed as `UserMergedJSON`.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expectTypeOf, test } from 'vitest';
2+
3+
import type { UserJSON, UserWebhookEvent, WebhookEventType } from '../index';
4+
import type { verifyWebhook } from '../webhooks';
5+
6+
type VerifiedEvent = Awaited<ReturnType<typeof verifyWebhook>>;
7+
8+
describe('user.merged webhook', () => {
9+
test('is a user webhook event type', () => {
10+
expectTypeOf<'user.merged'>().toExtend<WebhookEventType>();
11+
expectTypeOf<'user.merged'>().toExtend<UserWebhookEvent['type']>();
12+
});
13+
14+
test('narrows to the remaining user and the deleted user', () => {
15+
type Merged = Extract<VerifiedEvent, { type: 'user.merged' }>;
16+
expectTypeOf<Merged['data']['user']>().toEqualTypeOf<UserJSON>();
17+
expectTypeOf<Merged['data']['deleted_user']>().toEqualTypeOf<UserJSON>();
18+
});
19+
20+
test('preserves the common resource ID used by existing webhook handlers', () => {
21+
expectTypeOf<VerifiedEvent['data']['id']>().toEqualTypeOf<string | undefined>();
22+
});
23+
});

‎packages/backend/src/api/__tests__/SessionApi.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,31 @@ describe('SessionAPI', () => {
1616
jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.token',
1717
};
1818

19+
describe('updateSession', () => {
20+
it('calls PATCH /sessions/{id} with expire_at as an RFC 3339 timestamp', async () => {
21+
const expireAt = new Date('2030-10-20T10:00:27.645Z');
22+
server.use(
23+
http.patch(
24+
`https://api.clerk.test/v1/sessions/${sessionId}`,
25+
validateHeaders(async ({ request }) => {
26+
expect(await request.json()).toEqual({ expire_at: '2030-10-20T10:00:27.645Z' });
27+
return HttpResponse.json({ object: 'session', id: sessionId, expire_at: expireAt.getTime() });
28+
}),
29+
),
30+
);
31+
32+
const response = await apiClient.sessions.updateSession(sessionId, { expireAt });
33+
expect(response.id).toBe(sessionId);
34+
expect(response.expireAt).toBe(expireAt.getTime());
35+
});
36+
37+
it('requires a session ID', async () => {
38+
await expect(apiClient.sessions.updateSession('', { expireAt: new Date() })).rejects.toThrow(
39+
'A valid resource ID is required.',
40+
);
41+
});
42+
});
43+
1944
describe('getToken', () => {
2045
it('creates a session token without template', async () => {
2146
server.use(

‎packages/backend/src/api/__tests__/UserApi.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,34 @@ describe('UserAPI', () => {
210210
});
211211
});
212212

213+
describe('mergeUser', () => {
214+
it('calls POST /users/{id}/merge and returns both users', async () => {
215+
const postHandler = vi.fn(async ({ request }: { request: Request }) => {
216+
expect(await request.json()).toEqual({ from_user_id: 'user_456', dry_run: true });
217+
return HttpResponse.json({
218+
user: { ...mockUserResponse, first_name: 'Remaining' },
219+
deleted_user: { ...mockUserResponse, id: 'user_456', first_name: 'Deleted' },
220+
});
221+
});
222+
223+
server.use(http.post('https://api.clerk.test/v1/users/user_123/merge', validateHeaders(postHandler)));
224+
225+
const response = await apiClient.users.mergeUser('user_123', { fromUserId: 'user_456', dryRun: true });
226+
227+
expect(postHandler).toHaveBeenCalledTimes(1);
228+
expect(response.user.id).toBe('user_123');
229+
expect(response.user.firstName).toBe('Remaining');
230+
expect(response.deletedUser.id).toBe('user_456');
231+
expect(response.deletedUser.firstName).toBe('Deleted');
232+
});
233+
234+
it('requires a user ID', async () => {
235+
await expect(apiClient.users.mergeUser('', { fromUserId: 'user_456' })).rejects.toThrow(
236+
'A valid resource ID is required.',
237+
);
238+
});
239+
});
240+
213241
describe('getUserOauthAccessToken', () => {
214242
it('exposes the provider error from a failed token retrieval', async () => {
215243
server.use(

‎packages/backend/src/api/endpoints/SessionApi.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export type CreateSessionParams = {
4343
userId: string;
4444
};
4545

46+
/** @generateWithEmptyComment */
47+
export type UpdateSessionParams = {
48+
/** When the session should expire. Must be at least 5 minutes and at most 10 years from now. */
49+
expireAt: Date;
50+
};
51+
4652
/** @generateWithEmptyComment */
4753
export class SessionAPI extends AbstractAPI {
4854
/**
@@ -81,6 +87,23 @@ export class SessionAPI extends AbstractAPI {
8187
});
8288
}
8389

90+
/**
91+
* Changes when the given session expires. The new expiry applies to this session only and can exceed the instance's session lifetime. Only active and pending sessions can be updated.
92+
*
93+
* Take the session ID from the `sid` claim of a verified session token, never from an unverified client request.
94+
* @param sessionId - The ID of the session to update.
95+
* @param params - The parameters to update the session with.
96+
* @returns The updated [`Session`](https://clerk.com/docs/reference/backend/types/backend-session).
97+
*/
98+
public async updateSession(sessionId: string, params: UpdateSessionParams) {
99+
this.requireId(sessionId);
100+
return this.request<Session>({
101+
method: 'PATCH',
102+
path: joinPaths(basePath, sessionId),
103+
bodyParams: params,
104+
});
105+
}
106+
84107
/**
85108
* Revokes the given session. The user will be signed out from the client the session is associated with.
86109
* @param sessionId - The ID of the session to revoke.

‎packages/backend/src/api/endpoints/UserApi.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import type {
1010
OrganizationInvitation,
1111
OrganizationMembership,
1212
PhoneNumber,
13-
User,
1413
} from '../resources';
1514
import type { PaginatedResourceResponse } from '../resources/Deserializer';
15+
import type { UserMergedJSON } from '../resources/JSON';
16+
import { User } from '../resources/User';
1617
import { AbstractAPI } from './AbstractApi';
1718
import type { WithSign } from './util-types';
1819

@@ -425,6 +426,22 @@ export type SetPasswordCompromisedParams = {
425426
revokeAllSessions?: boolean;
426427
};
427428

429+
/** @generateWithEmptyComment */
430+
export type MergeUserParams = {
431+
/** The ID of the user to merge into the other user. This user is deleted. */
432+
fromUserId: string;
433+
/** Whether to return the result of the merge without changing anything. Defaults to `false`. */
434+
dryRun?: boolean;
435+
};
436+
437+
/** @generateWithEmptyComment */
438+
export type UserMergeResult = {
439+
/** The user that remains, as it is after the merge. */
440+
user: User;
441+
/** The merged user as it was before it was deleted. */
442+
deletedUser: User;
443+
};
444+
428445
type ReplaceUserEmailAddressParams = {
429446
/** The new email address. Must adhere to the RFC 5322 specification for email address format. */
430447
emailAddress: string;
@@ -930,4 +947,22 @@ export class UserAPI extends AbstractAPI {
930947
path: joinPaths(basePath, userId, 'password', 'unset_compromised'),
931948
});
932949
}
950+
951+
/**
952+
* Merges the `fromUserId` user into the given user: moves its identifications, connected accounts, second factors, organization memberships, and metadata onto the given user, then deletes the `fromUserId` user. Where both users have a value, the given user keeps its own.
953+
*
954+
* A merge cannot be undone. Use `dryRun` to preview one.
955+
* @param userId - The ID of the user that remains after the merge.
956+
* @param params - The parameters for the merge.
957+
* @returns The remaining [`User`](https://clerk.com/docs/reference/backend/types/backend-user) after the merge, and the deleted user as it was before the merge.
958+
*/
959+
public async mergeUser(userId: string, params: MergeUserParams): Promise<UserMergeResult> {
960+
this.requireId(userId);
961+
const result = await this.request<UserMergedJSON>({
962+
method: 'POST',
963+
path: joinPaths(basePath, userId, 'merge'),
964+
bodyParams: params,
965+
});
966+
return { user: User.fromJSON(result.user), deletedUser: User.fromJSON(result.deleted_user) };
967+
}
933968
}

‎packages/backend/src/api/resources/JSON.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,21 @@ export interface UserDeletedJSON extends DeletedObjectJSON {
763763
external_id?: string;
764764
}
765765

766+
/**
767+
* The result of merging one user into another.
768+
* Used for `user.merged` webhook events and the response of the merge user endpoint.
769+
*/
770+
export interface UserMergedJSON {
771+
/**
772+
* The user that remains, as it is after the merge.
773+
*/
774+
user: UserJSON;
775+
/**
776+
* The merged user as it was before it was deleted.
777+
*/
778+
deleted_user: UserJSON;
779+
}
780+
766781
/**
767782
* Directory group webhook event payload: a group as the identity provider reported it to a directory.
768783
* Used for `directoryGroup.created`, `directoryGroup.updated`, `directoryGroup.synced` and `directoryGroup.deleted` webhook events.

‎packages/backend/src/api/resources/Webhooks.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
SMSMessageJSON,
1919
UserDeletedJSON,
2020
UserJSON,
21+
UserMergedJSON,
2122
WaitlistEntryJSON,
2223
} from './JSON';
2324

@@ -37,9 +38,16 @@ type Webhook<EvtType, Data> = {
3738
instance_id: string;
3839
};
3940

41+
/**
42+
* `user.merged` carries two users instead of one resource, so its data has no `id` of its own.
43+
* Declaring `id` as absent keeps `event.data.id` readable on a `WebhookEvent` that has not been narrowed by `type`.
44+
*/
45+
type UserMergedWebhookEventJSON = UserMergedJSON & { id?: never };
46+
4047
export type UserWebhookEvent =
4148
| Webhook<'user.created' | 'user.updated', UserJSON>
42-
| Webhook<'user.deleted', UserDeletedJSON>;
49+
| Webhook<'user.deleted', UserDeletedJSON>
50+
| Webhook<'user.merged', UserMergedWebhookEventJSON>;
4351

4452
export type EmailWebhookEvent = Webhook<'email.created', EmailJSON>;
4553

‎packages/backend/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export type {
103103
SMSMessageJSON,
104104
UserJSON,
105105
UserDeletedJSON,
106+
UserMergedJSON,
106107
DirectoryGroupWebhookEventJSON,
107108
DirectoryGroupMembershipWebhookEventJSON,
108109
DeletedDirectoryGroupMembershipWebhookEventJSON,

0 commit comments

Comments
 (0)