jazz-tools 0.19.15 → 0.19.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.svelte-kit/__package__/tests/media/image.svelte.test.js +4 -1
  2. package/.turbo/turbo-build.log +60 -60
  3. package/CHANGELOG.md +19 -0
  4. package/dist/{chunk-R3KIZG4P.js → chunk-OH2GW5WP.js} +24 -8
  5. package/dist/{chunk-R3KIZG4P.js.map → chunk-OH2GW5WP.js.map} +1 -1
  6. package/dist/index.js +86 -54
  7. package/dist/index.js.map +1 -1
  8. package/dist/react-native/index.js +18 -17
  9. package/dist/react-native/index.js.map +1 -1
  10. package/dist/react-native-core/ReactNativeSessionProvider.d.ts.map +1 -1
  11. package/dist/react-native-core/index.js +18 -17
  12. package/dist/react-native-core/index.js.map +1 -1
  13. package/dist/svelte/tests/media/image.svelte.test.js +4 -1
  14. package/dist/testing.js +1 -1
  15. package/dist/tools/auth/clerk/getClerkUsername.d.ts +2 -2
  16. package/dist/tools/auth/clerk/getClerkUsername.d.ts.map +1 -1
  17. package/dist/tools/auth/clerk/index.d.ts +5 -4
  18. package/dist/tools/auth/clerk/index.d.ts.map +1 -1
  19. package/dist/tools/auth/clerk/tests/isClerkAuthStateEqual.test.d.ts +2 -0
  20. package/dist/tools/auth/clerk/tests/isClerkAuthStateEqual.test.d.ts.map +1 -0
  21. package/dist/tools/auth/clerk/tests/isClerkCredentials.test.d.ts +2 -0
  22. package/dist/tools/auth/clerk/tests/isClerkCredentials.test.d.ts.map +1 -0
  23. package/dist/tools/auth/clerk/types.d.ts +62 -19
  24. package/dist/tools/auth/clerk/types.d.ts.map +1 -1
  25. package/dist/tools/implementation/ContextManager.d.ts +10 -0
  26. package/dist/tools/implementation/ContextManager.d.ts.map +1 -1
  27. package/package.json +5 -6
  28. package/src/react/tests/media/image.test.tsx +5 -1
  29. package/src/react-native-core/ReactNativeSessionProvider.ts +24 -24
  30. package/src/svelte/tests/media/image.svelte.test.ts +5 -1
  31. package/src/tools/auth/clerk/getClerkUsername.ts +13 -20
  32. package/src/tools/auth/clerk/index.ts +35 -28
  33. package/src/tools/auth/clerk/tests/JazzClerkAuth.test.ts +105 -33
  34. package/src/tools/auth/clerk/tests/getClerkUsername.test.ts +25 -45
  35. package/src/tools/auth/clerk/tests/isClerkAuthStateEqual.test.ts +128 -0
  36. package/src/tools/auth/clerk/tests/{types.test.ts → isClerkCredentials.test.ts} +4 -2
  37. package/src/tools/auth/clerk/types.ts +66 -28
  38. package/src/tools/implementation/ContextManager.ts +28 -7
  39. package/src/tools/tests/ContextManager.test.ts +16 -0
  40. package/dist/tools/auth/clerk/tests/types.test.d.ts +0 -2
  41. package/dist/tools/auth/clerk/tests/types.test.d.ts.map +0 -1
@@ -6,7 +6,7 @@ import { Account, ID, InMemoryKVStore, KvStoreContext } from "jazz-tools";
6
6
  import { createJazzTestAccount } from "jazz-tools/testing";
7
7
  import { beforeEach, describe, expect, it, vi } from "vitest";
8
8
  import { JazzClerkAuth } from "../index";
9
- import type { MinimalClerkClient } from "../types";
9
+ import type { ClerkEventSchema, ClerkUser, MinimalClerkClient } from "../types";
10
10
 
11
11
  KvStoreContext.getInstance().initialize(new InMemoryKVStore());
12
12
  const authSecretStorage = new AuthSecretStorage();
@@ -27,23 +27,16 @@ describe("JazzClerkAuth", () => {
27
27
 
28
28
  describe("onClerkUserChange", () => {
29
29
  it("should do nothing if no clerk user", async () => {
30
- const mockClerk = {
31
- user: null,
32
- } as MinimalClerkClient;
33
-
34
- await auth.onClerkUserChange(mockClerk);
30
+ await auth.onClerkUserChange(null);
35
31
  expect(mockAuthenticate).not.toHaveBeenCalled();
36
32
  });
37
33
 
38
34
  it("should throw if not authenticated locally", async () => {
39
- const mockClerk = {
40
- user: {
41
- unsafeMetadata: {},
42
- },
43
- signOut: vi.fn(),
44
- } as unknown as MinimalClerkClient;
35
+ const user = {
36
+ unsafeMetadata: {},
37
+ } as ClerkUser;
45
38
 
46
- await expect(auth.onClerkUserChange(mockClerk)).rejects.toThrow();
39
+ await expect(auth.onClerkUserChange(user)).rejects.toThrow();
47
40
  expect(mockAuthenticate).not.toHaveBeenCalled();
48
41
  });
49
42
 
@@ -61,11 +54,10 @@ describe("JazzClerkAuth", () => {
61
54
  fullName: "Guido",
62
55
  unsafeMetadata: {},
63
56
  update: vi.fn(),
64
- },
65
- signOut: vi.fn(),
66
- } as unknown as MinimalClerkClient;
57
+ } as ClerkUser,
58
+ };
67
59
 
68
- await auth.onClerkUserChange(mockClerk);
60
+ await auth.onClerkUserChange(mockClerk.user);
69
61
 
70
62
  expect(mockClerk.user?.update).toHaveBeenCalledWith({
71
63
  unsafeMetadata: {
@@ -106,11 +98,11 @@ describe("JazzClerkAuth", () => {
106
98
  jazzAccountSecret: "secret123",
107
99
  jazzAccountSeed: [1, 2, 3],
108
100
  },
109
- },
110
- signOut: vi.fn(),
111
- } as unknown as MinimalClerkClient;
101
+ update: vi.fn(),
102
+ } as ClerkUser,
103
+ };
112
104
 
113
- await auth.onClerkUserChange(mockClerk);
105
+ await auth.onClerkUserChange(mockClerk.user);
114
106
 
115
107
  expect(mockAuthenticate).toHaveBeenCalledWith({
116
108
  accountID: "test123",
@@ -137,13 +129,12 @@ describe("JazzClerkAuth", () => {
137
129
  jazzAccountSecret: "secret123",
138
130
  jazzAccountSeed: [1, 2, 3],
139
131
  },
140
- },
141
- signOut: vi.fn(),
142
- } as unknown as MinimalClerkClient;
143
-
144
- await auth.onClerkUserChange(mockClerk);
132
+ update: vi.fn(),
133
+ } as ClerkUser,
134
+ };
145
135
 
146
- await auth.onClerkUserChange({ user: null });
136
+ await auth.onClerkUserChange(mockClerk.user);
137
+ await auth.onClerkUserChange(null);
147
138
 
148
139
  expect(authSecretStorage.isAuthenticated).toBe(false);
149
140
  expect(mockLogOut).toHaveBeenCalled();
@@ -151,10 +142,8 @@ describe("JazzClerkAuth", () => {
151
142
  });
152
143
 
153
144
  describe("registerListener", () => {
154
- function setupMockClerk(user: MinimalClerkClient["user"]) {
155
- const listners = new Set<
156
- (clerkClient: Pick<MinimalClerkClient, "user">) => void
157
- >();
145
+ function setupMockClerk(user: ClerkUser | null) {
146
+ const listners = new Set<(clerkClient: ClerkEventSchema) => void>();
158
147
 
159
148
  return {
160
149
  client: {
@@ -166,9 +155,9 @@ describe("JazzClerkAuth", () => {
166
155
  };
167
156
  }),
168
157
  } as unknown as MinimalClerkClient,
169
- triggerUserChange: (user: unknown) => {
158
+ triggerUserChange: (user: ClerkUser | null | undefined) => {
170
159
  for (const listener of listners) {
171
- listener({ user } as Pick<MinimalClerkClient, "user">);
160
+ listener({ user });
172
161
  }
173
162
  },
174
163
  };
@@ -211,6 +200,7 @@ describe("JazzClerkAuth", () => {
211
200
  jazzAccountSecret: "secret123",
212
201
  jazzAccountSeed: [1, 2, 3],
213
202
  },
203
+ update: vi.fn(),
214
204
  });
215
205
 
216
206
  expect(onClerkUserChangeSpy).toHaveBeenCalledTimes(2);
@@ -251,6 +241,7 @@ describe("JazzClerkAuth", () => {
251
241
  jazzAccountSecret: "secret123",
252
242
  jazzAccountSeed: [1, 2, 3],
253
243
  },
244
+ update: vi.fn(),
254
245
  });
255
246
 
256
247
  triggerUserChange({
@@ -259,6 +250,7 @@ describe("JazzClerkAuth", () => {
259
250
  jazzAccountSecret: "secret123",
260
251
  jazzAccountSeed: [1, 2, 3],
261
252
  },
253
+ update: vi.fn(),
262
254
  });
263
255
 
264
256
  expect(onClerkUserChangeSpy).toHaveBeenCalledTimes(1);
@@ -282,6 +274,86 @@ describe("JazzClerkAuth", () => {
282
274
 
283
275
  expect(onClerkUserChangeSpy).toHaveBeenCalledTimes(1);
284
276
  });
277
+
278
+ it("should complete signup flow when new Clerk user is detected", async () => {
279
+ // 1. Setup local credentials (simulating anonymous user)
280
+ await authSecretStorage.set({
281
+ accountID: "test-account-id" as ID<Account>,
282
+ secretSeed: new Uint8Array([1, 2, 3]),
283
+ accountSecret: "test-secret" as AgentSecret,
284
+ provider: "anonymous",
285
+ });
286
+
287
+ const { client, triggerUserChange } = setupMockClerk(null);
288
+
289
+ const auth = new JazzClerkAuth(
290
+ mockAuthenticate,
291
+ mockLogOut,
292
+ authSecretStorage,
293
+ );
294
+
295
+ // 2. Register listener with null user (no one logged in yet)
296
+ auth.registerListener(client);
297
+
298
+ // Initial trigger with no user
299
+ triggerUserChange(null);
300
+
301
+ // 3. Trigger event with new Clerk user (no Jazz credentials yet)
302
+ const mockUserUpdate = vi.fn((data) => {
303
+ triggerUserChange({
304
+ ...data,
305
+ update: mockUserUpdate,
306
+ });
307
+ });
308
+
309
+ const signInSpy = vi.spyOn(auth, "signIn");
310
+ const logInSpy = vi.spyOn(auth, "logIn");
311
+
312
+ const newClerkUser = {
313
+ fullName: "Test User",
314
+ firstName: "Test",
315
+ lastName: "User",
316
+ username: "testuser",
317
+ id: "clerk-user-123",
318
+ primaryEmailAddress: { emailAddress: "test@example.com" },
319
+ unsafeMetadata: {}, // No Jazz credentials yet
320
+ update: mockUserUpdate,
321
+ };
322
+
323
+ triggerUserChange(newClerkUser);
324
+
325
+ // Wait for async operations to complete
326
+ await vi.waitFor(() => {
327
+ expect(mockUserUpdate).toHaveBeenCalled();
328
+ });
329
+
330
+ // 4. Verify credentials synced to Clerk
331
+ expect(mockUserUpdate).toHaveBeenCalledWith({
332
+ unsafeMetadata: {
333
+ jazzAccountID: "test-account-id",
334
+ jazzAccountSecret: "test-secret",
335
+ jazzAccountSeed: [1, 2, 3],
336
+ },
337
+ });
338
+
339
+ // Verify profile name was updated from Clerk username
340
+ const me = await Account.getMe().$jazz.ensureLoaded({
341
+ resolve: { profile: true },
342
+ });
343
+ expect(me.profile.name).toBe("Test User");
344
+
345
+ // Verify authSecretStorage is updated with provider "clerk"
346
+ const storedCredentials = await authSecretStorage.get();
347
+ expect(storedCredentials).toEqual({
348
+ accountID: "test-account-id",
349
+ accountSecret: "test-secret",
350
+ secretSeed: new Uint8Array([1, 2, 3]),
351
+ provider: "clerk",
352
+ });
353
+
354
+ expect(signInSpy).toHaveBeenCalled();
355
+ expect(logInSpy).not.toHaveBeenCalled();
356
+ });
285
357
  });
286
358
 
287
359
  describe("initializeAuth", () => {
@@ -1,81 +1,61 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { getClerkUsername } from "../getClerkUsername.js";
3
- import type { MinimalClerkClient } from "../types.js";
3
+ import type { ClerkUser } from "../types.js";
4
4
 
5
5
  describe("getClerkUsername", () => {
6
- it("should return null if no user", () => {
7
- const mockClerk = {
8
- user: null,
9
- } as MinimalClerkClient;
10
-
11
- expect(getClerkUsername(mockClerk)).toBe(null);
12
- });
13
-
14
6
  it("should return fullName if available", () => {
15
- const mockClerk = {
16
- user: {
7
+ expect(
8
+ getClerkUsername({
17
9
  fullName: "John Doe",
18
10
  firstName: "John",
19
11
  lastName: "Doe",
20
12
  username: "johndoe",
21
- },
22
- } as MinimalClerkClient;
23
-
24
- expect(getClerkUsername(mockClerk)).toBe("John Doe");
13
+ } as ClerkUser),
14
+ ).toBe("John Doe");
25
15
  });
26
16
 
27
17
  it("should return firstName + lastName if available and no fullName", () => {
28
- const mockClerk = {
29
- user: {
18
+ expect(
19
+ getClerkUsername({
30
20
  firstName: "John",
31
21
  lastName: "Doe",
32
22
  username: "johndoe",
33
- },
34
- } as MinimalClerkClient;
35
-
36
- expect(getClerkUsername(mockClerk)).toBe("John Doe");
23
+ } as ClerkUser),
24
+ ).toBe("John Doe");
37
25
  });
38
26
 
39
27
  it("should return firstName if available and no lastName or fullName", () => {
40
- const mockClerk = {
41
- user: {
28
+ expect(
29
+ getClerkUsername({
42
30
  firstName: "John",
43
31
  username: "johndoe",
44
- },
45
- } as MinimalClerkClient;
46
-
47
- expect(getClerkUsername(mockClerk)).toBe("John");
32
+ } as ClerkUser),
33
+ ).toBe("John");
48
34
  });
49
35
 
50
36
  it("should return username if available and no names", () => {
51
- const mockClerk = {
52
- user: {
37
+ expect(
38
+ getClerkUsername({
53
39
  username: "johndoe",
54
- },
55
- } as MinimalClerkClient;
56
-
57
- expect(getClerkUsername(mockClerk)).toBe("johndoe");
40
+ } as ClerkUser),
41
+ ).toBe("johndoe");
58
42
  });
59
43
 
60
44
  it("should return email username if available and no other identifiers", () => {
61
- const mockClerk = {
62
- user: {
45
+ expect(
46
+ getClerkUsername({
63
47
  primaryEmailAddress: {
64
48
  emailAddress: "john.doe@example.com",
65
49
  },
66
- },
67
- } as MinimalClerkClient;
68
-
69
- expect(getClerkUsername(mockClerk)).toBe("john.doe");
50
+ } as ClerkUser),
51
+ ).toBe("john.doe");
70
52
  });
71
53
 
72
54
  it("should return user id as last resort", () => {
73
- const mockClerk = {
74
- user: {
55
+ expect(
56
+ getClerkUsername({
75
57
  id: "user_123",
76
- },
77
- } as MinimalClerkClient;
78
-
79
- expect(getClerkUsername(mockClerk)).toBe("user_123");
58
+ } as ClerkUser),
59
+ ).toBe("user_123");
80
60
  });
81
61
  });
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ClerkUser, isClerkAuthStateEqual } from "../types";
3
+
4
+ describe("isClerkAuthStateEqual", () => {
5
+ const validCredentials = {
6
+ jazzAccountID: "account-123",
7
+ jazzAccountSecret: "secret-123",
8
+ jazzAccountSeed: [1, 2, 3],
9
+ };
10
+
11
+ const differentCredentials = {
12
+ jazzAccountID: "account-456",
13
+ jazzAccountSecret: "secret-456",
14
+ jazzAccountSeed: [4, 5, 6],
15
+ };
16
+
17
+ describe("both users null/undefined", () => {
18
+ it.each([
19
+ { previous: null, next: null, description: "both null" },
20
+ { previous: undefined, next: undefined, description: "both undefined" },
21
+ { previous: null, next: undefined, description: "null and undefined" },
22
+ { previous: undefined, next: null, description: "undefined and null" },
23
+ ])("returns true when $description", ({ previous, next }) => {
24
+ expect(isClerkAuthStateEqual(previous, next)).toBe(true);
25
+ });
26
+ });
27
+
28
+ describe("one user null, other exists", () => {
29
+ it.each([
30
+ {
31
+ previous: null,
32
+ next: { unsafeMetadata: validCredentials },
33
+ description: "previous null, next exists",
34
+ },
35
+ {
36
+ previous: { unsafeMetadata: validCredentials },
37
+ next: null,
38
+ description: "previous exists, next null",
39
+ },
40
+ {
41
+ previous: undefined,
42
+ next: { unsafeMetadata: validCredentials },
43
+ description: "previous undefined, next exists",
44
+ },
45
+ {
46
+ previous: { unsafeMetadata: validCredentials },
47
+ next: undefined,
48
+ description: "previous exists, next undefined",
49
+ },
50
+ ])("returns false when $description", ({ previous, next }) => {
51
+ expect(isClerkAuthStateEqual(previous, next)).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe("same jazzAccountID", () => {
56
+ it("returns true when both users have the same jazzAccountID", () => {
57
+ const previous = { unsafeMetadata: validCredentials };
58
+ const next = {
59
+ unsafeMetadata: {
60
+ ...validCredentials,
61
+ jazzAccountSecret: "different-secret",
62
+ },
63
+ };
64
+ expect(isClerkAuthStateEqual(previous, next)).toBe(true);
65
+ });
66
+ });
67
+
68
+ describe("different jazzAccountID", () => {
69
+ it("returns false when users have different jazzAccountID", () => {
70
+ const previous = { unsafeMetadata: validCredentials };
71
+ const next = { unsafeMetadata: differentCredentials };
72
+ expect(isClerkAuthStateEqual(previous, next)).toBe(false);
73
+ });
74
+ });
75
+
76
+ describe("neither user has valid credentials", () => {
77
+ it.each([
78
+ {
79
+ previous: { unsafeMetadata: {} },
80
+ next: { unsafeMetadata: {} },
81
+ description: "both have empty metadata",
82
+ },
83
+ {
84
+ previous: { unsafeMetadata: { someOtherField: "value" } },
85
+ next: { unsafeMetadata: { anotherField: "value" } },
86
+ description: "both have non-credential metadata",
87
+ },
88
+ {
89
+ previous: { unsafeMetadata: { jazzAccountID: "123" } },
90
+ next: { unsafeMetadata: { jazzAccountSecret: "456" } },
91
+ description: "both have incomplete credentials",
92
+ },
93
+ ])("returns true when $description", ({ previous, next }) => {
94
+ expect(
95
+ isClerkAuthStateEqual(previous as ClerkUser, next as ClerkUser),
96
+ ).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe("one has credentials, other doesn't", () => {
101
+ it.each([
102
+ {
103
+ previous: { unsafeMetadata: validCredentials },
104
+ next: { unsafeMetadata: {} },
105
+ description: "previous has credentials, next empty",
106
+ },
107
+ {
108
+ previous: { unsafeMetadata: {} },
109
+ next: { unsafeMetadata: validCredentials },
110
+ description: "previous empty, next has credentials",
111
+ },
112
+ {
113
+ previous: { unsafeMetadata: validCredentials },
114
+ next: { unsafeMetadata: { jazzAccountID: "123" } },
115
+ description: "previous has credentials, next has incomplete",
116
+ },
117
+ {
118
+ previous: { unsafeMetadata: { jazzAccountSecret: "456" } },
119
+ next: { unsafeMetadata: validCredentials },
120
+ description: "previous has incomplete, next has credentials",
121
+ },
122
+ ])("returns false when $description", ({ previous, next }) => {
123
+ expect(
124
+ isClerkAuthStateEqual(previous as ClerkUser, next as ClerkUser),
125
+ ).toBe(false);
126
+ });
127
+ });
128
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { isClerkCredentials } from "../types";
2
+ import { ClerkUser, isClerkCredentials } from "../types";
3
3
 
4
4
  describe("isClerkCredentials", () => {
5
5
  it.each([
@@ -44,6 +44,8 @@ describe("isClerkCredentials", () => {
44
44
  description: "missing jazzAccountSecret",
45
45
  },
46
46
  ])("fails for invalid credentials: $description", ({ metadata }) => {
47
- expect(isClerkCredentials(metadata)).toBe(false);
47
+ expect(isClerkCredentials(metadata as ClerkUser["unsafeMetadata"])).toBe(
48
+ false,
49
+ );
48
50
  });
49
51
  });
@@ -1,32 +1,57 @@
1
- import { AgentSecret } from "cojson";
2
- import { Account, ID } from "jazz-tools";
1
+ import { type AgentSecret } from "cojson";
2
+ import { z } from "zod/v4";
3
+
4
+ const ClerkJazzCredentialsSchema = z.object({
5
+ jazzAccountID: z.string(),
6
+ jazzAccountSecret: z.string(),
7
+ jazzAccountSeed: z.array(z.number()).optional(),
8
+ });
9
+
10
+ const ClerkUserSchema = z.object({
11
+ fullName: z.string().nullish(),
12
+ username: z.string().nullish(),
13
+ firstName: z.string().nullish(),
14
+ lastName: z.string().nullish(),
15
+ id: z.string().optional(),
16
+ primaryEmailAddress: z
17
+ .object({
18
+ emailAddress: z.string().nullable(),
19
+ })
20
+ .nullish(),
21
+ unsafeMetadata: z.union([z.object({}), ClerkJazzCredentialsSchema]),
22
+ update: z.function({
23
+ input: [
24
+ z.object({
25
+ unsafeMetadata: ClerkJazzCredentialsSchema,
26
+ }),
27
+ ],
28
+ output: z.promise(z.unknown()),
29
+ }),
30
+ });
31
+
32
+ export const ClerkEventSchema = z.object({
33
+ user: ClerkUserSchema.nullish(),
34
+ });
35
+ export type ClerkEventSchema = z.infer<typeof ClerkEventSchema>;
36
+
37
+ export type ClerkUser = z.infer<typeof ClerkUserSchema>;
38
+
39
+ // Need to provide a permissive type externally to accept
40
+ type PermissiveClerkUser = Omit<ClerkUser, "unsafeMetadata" | "update"> & {
41
+ unsafeMetadata: Record<string, unknown>;
42
+ update: (args: {
43
+ unsafeMetadata: Record<string, unknown>;
44
+ }) => Promise<unknown>;
45
+ };
3
46
 
4
47
  export type MinimalClerkClient = {
5
- user:
6
- | {
7
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
- unsafeMetadata: Record<string, any>;
9
- fullName: string | null;
10
- username: string | null;
11
- firstName: string | null;
12
- lastName: string | null;
13
- id: string;
14
- primaryEmailAddress: {
15
- emailAddress: string | null;
16
- } | null;
17
- update: (args: {
18
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
- unsafeMetadata: Record<string, any>;
20
- }) => Promise<unknown>;
21
- }
22
- | null
23
- | undefined;
48
+ user: PermissiveClerkUser | null | undefined;
24
49
  signOut: () => Promise<void>;
25
50
  addListener: (listener: (data: unknown) => void) => void;
26
51
  };
27
52
 
28
53
  export type ClerkCredentials = {
29
- jazzAccountID: ID<Account>;
54
+ jazzAccountID: string;
30
55
  jazzAccountSecret: AgentSecret;
31
56
  jazzAccountSeed?: number[];
32
57
  };
@@ -36,21 +61,34 @@ export type ClerkCredentials = {
36
61
  * **Note**: It does not validate the credentials, only checks if the necessary fields are present in the metadata object.
37
62
  */
38
63
  export function isClerkCredentials(
39
- data: NonNullable<MinimalClerkClient["user"]>["unsafeMetadata"] | undefined,
64
+ data: Record<string, unknown> | undefined,
40
65
  ): data is ClerkCredentials {
41
66
  return !!data && "jazzAccountID" in data && "jazzAccountSecret" in data;
42
67
  }
43
68
 
69
+ type ClerkUserWithUnsafeMetadata =
70
+ | Pick<ClerkUser, "unsafeMetadata">
71
+ | null
72
+ | undefined;
73
+
44
74
  export function isClerkAuthStateEqual(
45
- previousUser: MinimalClerkClient["user"] | null | undefined,
46
- newUser: MinimalClerkClient["user"] | null | undefined,
75
+ previousUser: ClerkUserWithUnsafeMetadata,
76
+ newUser: ClerkUserWithUnsafeMetadata,
47
77
  ) {
48
78
  if (Boolean(previousUser) !== Boolean(newUser)) {
49
79
  return false;
50
80
  }
51
81
 
52
- const previousCredentials = isClerkCredentials(previousUser?.unsafeMetadata);
53
- const newCredentials = isClerkCredentials(newUser?.unsafeMetadata);
82
+ const previousCredentials = isClerkCredentials(previousUser?.unsafeMetadata)
83
+ ? previousUser?.unsafeMetadata
84
+ : null;
85
+ const newCredentials = isClerkCredentials(newUser?.unsafeMetadata)
86
+ ? newUser?.unsafeMetadata
87
+ : null;
88
+
89
+ if (!previousCredentials || !newCredentials) {
90
+ return previousCredentials === newCredentials;
91
+ }
54
92
 
55
- return previousCredentials === newCredentials;
93
+ return previousCredentials.jazzAccountID === newCredentials.jazzAccountID;
56
94
  }
@@ -188,24 +188,45 @@ export class JazzContextManager<
188
188
  return this.subscriptionCache;
189
189
  }
190
190
 
191
+ /**
192
+ * Flag to indicate if a logout operation is currently in progress.
193
+ * Used to prevent concurrent logout attempts or double-logout issues.
194
+ * Set to true when logout starts, reset to false once all logout logic runs.
195
+ */
196
+ loggingOut = false;
197
+
198
+ /**
199
+ * Handles the logout process.
200
+ * Uses the loggingOut flag to ensure only one logout can happen at a time.
201
+ */
191
202
  logOut = async () => {
192
- if (!this.context || !this.props) {
203
+ if (!this.context || !this.props || this.loggingOut) {
193
204
  return;
194
205
  }
195
206
 
207
+ // Mark as logging out to prevent reentry
208
+ this.loggingOut = true;
209
+
196
210
  this.authenticatingAccountID = null;
197
211
 
198
212
  // Clear cache on logout to prevent subscription leaks across authentication boundaries
199
213
  this.subscriptionCache.clear();
200
214
 
201
- await this.props.onLogOut?.();
215
+ try {
216
+ await this.props.onLogOut?.();
202
217
 
203
- if (this.props.logOutReplacement) {
204
- await this.props.logOutReplacement();
205
- } else {
206
- await this.context.logOut();
207
- return this.createContext(this.props);
218
+ if (this.props.logOutReplacement) {
219
+ await this.props.logOutReplacement();
220
+ } else {
221
+ await this.context.logOut();
222
+ await this.createContext(this.props);
223
+ }
224
+ } catch (error) {
225
+ console.error("Error during logout", error);
208
226
  }
227
+
228
+ // Reset flag after standard logout finishes
229
+ this.loggingOut = false;
209
230
  };
210
231
 
211
232
  done = () => {
@@ -604,6 +604,22 @@ describe("ContextManager", () => {
604
604
  expect(onAnonymousAccountDiscarded).toHaveBeenCalledTimes(1);
605
605
  });
606
606
 
607
+ test("prevents concurrent logout attempts", async () => {
608
+ const onLogOut = vi.fn();
609
+ await manager.createContext({ onLogOut });
610
+
611
+ // Start multiple concurrent logout attempts
612
+ const promises = [];
613
+ for (let i = 0; i < 5; i++) {
614
+ promises.push(manager.logOut());
615
+ }
616
+
617
+ await Promise.all(promises);
618
+
619
+ // onLogOut should only be called once despite multiple logOut calls
620
+ expect(onLogOut).toHaveBeenCalledTimes(1);
621
+ });
622
+
607
623
  test("allows authentication after logout", async () => {
608
624
  const account = await createJazzTestAccount();
609
625
  const onAnonymousAccountDiscarded = vi.fn();
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=types.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"types.test.d.ts","sourceRoot":"","sources":["../../../../../src/tools/auth/clerk/tests/types.test.ts"],"names":[],"mappings":""}