Skip to content

Commit 2cd2d16

Browse files
fix(mosaic): preserve active device state and restore retries
1 parent 1ab18cb commit 2cd2d16

15 files changed

Lines changed: 744 additions & 280 deletions

‎.changeset/quiet-managed-password.md‎

Lines changed: 0 additions & 2 deletions
This file was deleted.

‎.changeset/quiet-password-messages.md‎

Lines changed: 0 additions & 2 deletions
This file was deleted.

‎packages/clerk-js/src/core/resources/SessionWithActivities.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
ActClaim,
3+
GetSessionsParams,
34
SessionActivity,
45
SessionActivityJSON,
56
SessionWithActivitiesJSON,
@@ -36,10 +37,10 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc
3637
this.fromJSON(data);
3738
}
3839

39-
static retrieve(): Promise<SessionWithActivities[]> {
40+
static retrieve(params?: Pick<GetSessionsParams, 'throwOnError'>): Promise<SessionWithActivities[]> {
4041
const sessionId = BaseResource.clerk.session?.id;
4142

42-
return this.clerk
43+
const request = this.clerk
4344
.getFapiClient()
4445
.request<SessionWithActivitiesJSON[]>({
4546
method: 'GET',
@@ -50,8 +51,8 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc
5051
// https://www.notion.so/clerkdev/Align-SessionWithActivities-retrieval-with-the-rest-of-Client-API-a043f72f6b9d4344bd2f21dc1d3f79de
5152
const sessionWithActivitiesJSON = res.payload as unknown as SessionWithActivitiesJSON[];
5253
return sessionWithActivitiesJSON.map(sa => new SessionWithActivities(sa, '/me/sessions'));
53-
})
54-
.catch(() => []);
54+
});
55+
return params?.throwOnError ? request : request.catch(() => []);
5556
}
5657

5758
revoke(): Promise<this> {

‎packages/clerk-js/src/core/resources/User.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
ExternalAccountResource,
1818
GetEnterpriseConnectionsParams,
1919
GetOrganizationMemberships,
20+
GetSessionsParams,
2021
GetUserOrganizationInvitationsParams,
2122
GetUserOrganizationSuggestionsParams,
2223
ImageResource,
@@ -305,11 +306,11 @@ export class User extends BaseResource implements UserResource {
305306
});
306307
};
307308

308-
getSessions = async (): Promise<SessionWithActivities[]> => {
309-
if (this.cachedSessionsWithActivities) {
309+
getSessions = async (params?: GetSessionsParams): Promise<SessionWithActivities[]> => {
310+
if (!params?.forceRefresh && this.cachedSessionsWithActivities) {
310311
return this.cachedSessionsWithActivities;
311312
}
312-
const res = await SessionWithActivities.retrieve();
313+
const res = await SessionWithActivities.retrieve(params);
313314
this.cachedSessionsWithActivities = res;
314315
return res;
315316
};
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { createSession, createUser } from '@/test/core-fixtures';
4+
5+
import { Clerk } from '../../clerk';
6+
import { BaseResource } from '../internal';
7+
import { User } from '../User';
8+
9+
function response(id: string) {
10+
const { user: _user, ...session } = createSession({ id, status: 'active' });
11+
return Object.assign(new Response(), {
12+
payload: [{ ...session, user: null, latest_activity: { object: 'session_activity', id: `activity_${id}` } }],
13+
});
14+
}
15+
16+
describe('User session retrieval', () => {
17+
const clerk = new Clerk('pk_test_Y2xlcmsuZXhhbXBsZS5jb20k');
18+
const previousClerk = BaseResource.clerk;
19+
20+
beforeEach(() => {
21+
BaseResource.clerk = clerk;
22+
});
23+
24+
afterEach(() => {
25+
vi.restoreAllMocks();
26+
BaseResource.clerk = previousClerk;
27+
});
28+
29+
it('preserves cached successes for callers without options', async () => {
30+
const request = vi.spyOn(clerk.getFapiClient(), 'request').mockResolvedValue(response('sess_first'));
31+
const user = new User(createUser());
32+
expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_first']);
33+
request.mockResolvedValue(response('sess_next'));
34+
expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_first']);
35+
expect(request).toHaveBeenCalledOnce();
36+
});
37+
38+
it('preserves the cached empty result after failure for callers without options', async () => {
39+
const request = vi.spyOn(clerk.getFapiClient(), 'request').mockRejectedValue(new Error('Network failure'));
40+
const user = new User(createUser());
41+
await expect(user.getSessions()).resolves.toEqual([]);
42+
request.mockResolvedValue(response('sess_next'));
43+
await expect(user.getSessions()).resolves.toEqual([]);
44+
expect(request).toHaveBeenCalledOnce();
45+
});
46+
47+
it('makes a fresh read after a legacy caller cached a failed request', async () => {
48+
const request = vi.spyOn(clerk.getFapiClient(), 'request').mockRejectedValue(new Error('Network failure'));
49+
const user = new User(createUser());
50+
await expect(user.getSessions()).resolves.toEqual([]);
51+
request.mockResolvedValue(response('sess_next'));
52+
const sessions = await user.getSessions({ forceRefresh: true, throwOnError: true });
53+
expect(sessions.map(session => session.id)).toEqual(['sess_next']);
54+
expect(request).toHaveBeenCalledTimes(2);
55+
});
56+
57+
it('propagates a strict failure without overwriting a successful cache', async () => {
58+
const request = vi.spyOn(clerk.getFapiClient(), 'request').mockResolvedValue(response('sess_first'));
59+
const user = new User(createUser());
60+
await user.getSessions();
61+
const failure = new Error('Network failure');
62+
request.mockRejectedValue(failure);
63+
await expect(user.getSessions({ forceRefresh: true, throwOnError: true })).rejects.toBe(failure);
64+
expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_first']);
65+
});
66+
67+
it('retries a strict failed request and refreshes the cached sessions on success', async () => {
68+
const request = vi.spyOn(clerk.getFapiClient(), 'request').mockRejectedValue(new Error('Network failure'));
69+
const user = new User(createUser());
70+
await expect(user.getSessions({ forceRefresh: true, throwOnError: true })).rejects.toThrow('Network failure');
71+
request.mockResolvedValue(response('sess_next'));
72+
expect((await user.getSessions({ forceRefresh: true, throwOnError: true })).map(session => session.id)).toEqual([
73+
'sess_next',
74+
]);
75+
expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_next']);
76+
expect(request).toHaveBeenCalledTimes(2);
77+
});
78+
});

‎packages/mosaic/src/__tests__/feature/fake-fapi.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
OrganizationMembershipJSON,
44
OrganizationSuggestionJSON,
55
SessionJSON,
6+
SessionWithActivitiesJSON,
67
UserOrganizationInvitationJSON,
78
} from '@clerk/shared/types';
89
import { http, HttpResponse, type JsonBodyType } from 'msw';
@@ -37,6 +38,16 @@ export interface FakeFapiState {
3738
suggestions: OrganizationSuggestionJSON[];
3839
verification: FakeVerificationState;
3940
passwordUpdates: URLSearchParams[];
41+
activeDevices?: ActiveDeviceRecord[];
42+
deviceTrackingEnabled: boolean;
43+
}
44+
45+
export interface ActiveDeviceRecord extends SessionWithActivitiesJSON {
46+
ownerUserId: string;
47+
replacementSessionId?: string;
48+
inactivityTimeoutSeconds?: number;
49+
touchedAt?: number;
50+
tokenIssuedAt?: number;
4051
}
4152

4253
export type FakeFapiSeed = Partial<Omit<FakeFapiState, 'verification'>> & {
@@ -81,10 +92,40 @@ function findSession(state: FakeFapiState, id: unknown): SessionJSON | undefined
8192
return state.client.sessions.find(session => session.id === id);
8293
}
8394

95+
function requestingSession(state: FakeFapiState, request: Request): SessionJSON | undefined {
96+
const sessionId = new URL(request.url).searchParams.get('_clerk_session_id') ?? state.client.last_active_session_id;
97+
return findSession(state, sessionId);
98+
}
99+
84100
function missing() {
85101
return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
86102
}
87103

104+
function canRevokeDevice(record: ActiveDeviceRecord): boolean {
105+
const now = Date.now();
106+
const lastActivity = Math.max(record.touchedAt ?? record.last_active_at, record.tokenIssuedAt ?? 0);
107+
return (
108+
record.status === 'active' &&
109+
!record.replacementSessionId &&
110+
record.expire_at > now &&
111+
record.abandon_at > now &&
112+
(!record.inactivityTimeoutSeconds || now - lastActivity <= record.inactivityTimeoutSeconds * 1000)
113+
);
114+
}
115+
116+
function deviceSessionPayload(record: ActiveDeviceRecord) {
117+
const {
118+
ownerUserId: _ownerUserId,
119+
replacementSessionId: _replacementSessionId,
120+
inactivityTimeoutSeconds: _inactivityTimeoutSeconds,
121+
touchedAt: _touchedAt,
122+
tokenIssuedAt: _tokenIssuedAt,
123+
latest_activity: _latestActivity,
124+
...session
125+
} = record;
126+
return session;
127+
}
128+
88129
export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
89130
const { verification, ...rest } = seed;
90131
const state: FakeFapiState = {
@@ -94,6 +135,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
94135
invitations: [],
95136
suggestions: [],
96137
passwordUpdates: [],
138+
deviceTrackingEnabled: true,
97139
...rest,
98140
verification: createVerificationState(verification),
99141
};
@@ -138,6 +180,53 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
138180
state.client = { ...state.client, sessions, last_active_session_id: sessions[0]?.id ?? null };
139181
return envelope({ ...session, status: 'removed' }, state.client);
140182
}),
183+
http.get(fapiUrl('/v1/me/sessions/active'), ({ request }) => {
184+
if (!state.activeDevices) {
185+
return undefined;
186+
}
187+
const requester = requestingSession(state, request);
188+
if (!requester) {
189+
return missing();
190+
}
191+
if (!state.deviceTrackingEnabled) {
192+
const { user: _user, ...session } = requester;
193+
return HttpResponse.json([{ ...session, user: null }]);
194+
}
195+
const records = state.activeDevices.filter(
196+
item => item.ownerUserId === requester.user.id && canRevokeDevice(item) && (!item.actor || requester.actor),
197+
);
198+
return HttpResponse.json(
199+
records.map(item => ({
200+
...deviceSessionPayload(item),
201+
status: item.tasks?.length ? 'pending' : item.status,
202+
latest_activity: item.latest_activity,
203+
})),
204+
);
205+
}),
206+
http.post(fapiUrl('/v1/me/sessions/:id/revoke'), ({ params, request }) => {
207+
if (!state.activeDevices) {
208+
return undefined;
209+
}
210+
const requester = requestingSession(state, request);
211+
if (!requester) {
212+
return missing();
213+
}
214+
const target = state.activeDevices.find(item => item.id === params.id);
215+
if (target?.id === requester.id) {
216+
const code = 'invalid_action_for_session';
217+
return HttpResponse.json({ errors: [{ code, message: code }] }, { status: 400 });
218+
}
219+
if (!target || target.ownerUserId !== requester.user.id) {
220+
const code = 'action_for_session_not_authorized';
221+
return HttpResponse.json({ errors: [{ code, message: code }] }, { status: 401 });
222+
}
223+
if (!canRevokeDevice(target)) {
224+
const code = 'invalid_action_for_session';
225+
return HttpResponse.json({ errors: [{ code, message: code }] }, { status: 400 });
226+
}
227+
target.status = 'revoked';
228+
return envelope({ ...deviceSessionPayload(target), user: requester.user, status: 'revoked' }, state.client);
229+
}),
141230
http.post(fapiUrl('/v1/me/change_password'), async ({ request }) => {
142231
const session = findSession(state, state.client.last_active_session_id);
143232
if (!session) {

0 commit comments

Comments
 (0)