@zerodev/wallet-core 0.0.5 → 0.0.7

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 (46) hide show
  1. package/dist/_cjs/client/transports/createTransport.js +1 -0
  2. package/dist/_cjs/client/transports/createTransport.js.map +1 -1
  3. package/dist/_cjs/client/transports/rest.js +2 -0
  4. package/dist/_cjs/client/transports/rest.js.map +1 -1
  5. package/dist/_cjs/core/createZeroDevWalletCore.js +76 -38
  6. package/dist/_cjs/core/createZeroDevWalletCore.js.map +1 -1
  7. package/dist/_cjs/stampers/indexedDbStamper.js +5 -1
  8. package/dist/_cjs/stampers/indexedDbStamper.js.map +1 -1
  9. package/dist/_cjs/utils/crossTabLock.js +10 -0
  10. package/dist/_cjs/utils/crossTabLock.js.map +1 -0
  11. package/dist/_cjs/version.js +5 -0
  12. package/dist/_cjs/version.js.map +1 -0
  13. package/dist/_esm/client/transports/createTransport.js +1 -0
  14. package/dist/_esm/client/transports/createTransport.js.map +1 -1
  15. package/dist/_esm/client/transports/rest.js +3 -0
  16. package/dist/_esm/client/transports/rest.js.map +1 -1
  17. package/dist/_esm/core/createZeroDevWalletCore.js +99 -43
  18. package/dist/_esm/core/createZeroDevWalletCore.js.map +1 -1
  19. package/dist/_esm/stampers/indexedDbStamper.js +8 -1
  20. package/dist/_esm/stampers/indexedDbStamper.js.map +1 -1
  21. package/dist/_esm/utils/crossTabLock.js +17 -0
  22. package/dist/_esm/utils/crossTabLock.js.map +1 -0
  23. package/dist/_esm/version.js +3 -0
  24. package/dist/_esm/version.js.map +1 -0
  25. package/dist/_types/client/transports/createTransport.d.ts +5 -0
  26. package/dist/_types/client/transports/createTransport.d.ts.map +1 -1
  27. package/dist/_types/client/transports/rest.d.ts +2 -0
  28. package/dist/_types/client/transports/rest.d.ts.map +1 -1
  29. package/dist/_types/core/createZeroDevWalletCore.d.ts +5 -0
  30. package/dist/_types/core/createZeroDevWalletCore.d.ts.map +1 -1
  31. package/dist/_types/stampers/indexedDbStamper.d.ts.map +1 -1
  32. package/dist/_types/stampers/types.d.ts +5 -0
  33. package/dist/_types/stampers/types.d.ts.map +1 -1
  34. package/dist/_types/utils/crossTabLock.d.ts +3 -0
  35. package/dist/_types/utils/crossTabLock.d.ts.map +1 -0
  36. package/dist/_types/version.d.ts +2 -0
  37. package/dist/_types/version.d.ts.map +1 -0
  38. package/dist/tsconfig.build.tsbuildinfo +1 -1
  39. package/package.json +1 -1
  40. package/src/client/transports/createTransport.ts +6 -0
  41. package/src/client/transports/rest.ts +5 -0
  42. package/src/core/createZeroDevWalletCore.ts +178 -94
  43. package/src/stampers/indexedDbStamper.ts +8 -1
  44. package/src/stampers/types.ts +5 -0
  45. package/src/utils/crossTabLock.ts +19 -0
  46. package/src/version.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerodev/wallet-core",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "ZeroDev Wallet SDK built on Turnkey",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,6 +12,11 @@ export type CreateTransportOptions = {
12
12
  name?: string
13
13
  /** Extra options merged into every fetch() call */
14
14
  fetchOptions?: Omit<RequestInit, 'body' | 'method' | 'signal'>
15
+ /**
16
+ * @internal The package reported in the `X-SDK-Version` header. Wrapper
17
+ * packages set their own name and version; apps should not set it.
18
+ */
19
+ sdkVersion?: string
15
20
  }
16
21
 
17
22
  /**
@@ -37,6 +42,7 @@ export function zeroDevWalletTransport(
37
42
  apiKeyStamper,
38
43
  passkeyStamper,
39
44
  ...(options.fetchOptions && { fetchOptions: options.fetchOptions }),
45
+ ...(options.sdkVersion && { sdkVersion: options.sdkVersion }),
40
46
  })
41
47
 
42
48
  return {
@@ -2,6 +2,7 @@ import { canonicalizeEx } from 'json-canonicalize'
2
2
  import { RestRequestError, RestTimeoutError } from '../../errors/request.js'
3
3
  import type { Stamper } from '../../stampers/types.js'
4
4
  import type { StamperType } from '../../types/session.js'
5
+ import { version } from '../../version.js'
5
6
 
6
7
  export type RestRequestArgs = {
7
8
  path: string
@@ -42,6 +43,8 @@ export type RestTransportConfig = {
42
43
  name?: string
43
44
  apiKeyStamper: Stamper
44
45
  passkeyStamper: Stamper
46
+ /** `X-SDK-Version` value; defaults to this package. */
47
+ sdkVersion?: string
45
48
  }
46
49
 
47
50
  export function rest(url: string, cfg: RestTransportConfig): RestTransport {
@@ -60,6 +63,8 @@ export function rest(url: string, cfg: RestTransportConfig): RestTransport {
60
63
  ...((cfg.fetchOptions?.headers as Record<string, string>) ?? {}),
61
64
  ...(args.headers ?? {}),
62
65
  'content-type': 'application/json',
66
+ // Lets KMS report which SDK package and version each project runs.
67
+ 'X-SDK-Version': cfg.sdkVersion ?? `@zerodev/wallet-core@${version}`,
63
68
  }
64
69
 
65
70
  // Handle stamping if requested
@@ -17,9 +17,14 @@ import {
17
17
  } from '../storage/manager.js'
18
18
  import { SessionType, type ZeroDevWalletSession } from '../types/session.js'
19
19
  import { buildClientSignature } from '../utils/buildClientSignature.js'
20
+ import { withCrossTabLock } from '../utils/crossTabLock.js'
20
21
  import { encryptOtpAttempt } from '../utils/encryptOtpAttempt.js'
21
22
  import { createOrganizationIdResolver } from '../utils/resolveOrganizationId.js'
22
- import { humanReadableDateTime, parseSession } from '../utils/utils.js'
23
+ import {
24
+ humanReadableDateTime,
25
+ normalizeTimestamp,
26
+ parseSession,
27
+ } from '../utils/utils.js'
23
28
  export interface ZeroDevWalletConfigCore {
24
29
  organizationId?: string
25
30
  proxyBaseUrl?: string
@@ -29,6 +34,11 @@ export interface ZeroDevWalletConfigCore {
29
34
  apiKeyStamper: ApiKeyStamper
30
35
  passkeyStamper?: PasskeyStamper
31
36
  fetchOptions?: CreateTransportOptions['fetchOptions']
37
+ /**
38
+ * @internal The package reported in the `X-SDK-Version` header. Wrapper
39
+ * packages set their own name and version; apps should not set it.
40
+ */
41
+ sdkVersion?: string
32
42
  }
33
43
 
34
44
  export type { StorageAdapter, StorageManager } from '../storage/manager.js'
@@ -128,6 +138,7 @@ export async function createZeroDevWalletCore(
128
138
  transport: zeroDevWalletTransport({
129
139
  baseUrl: config.proxyBaseUrl || `${KMS_SERVER_URL}/api/v1`,
130
140
  ...(config.fetchOptions && { fetchOptions: config.fetchOptions }),
141
+ ...(config.sdkVersion && { sdkVersion: config.sdkVersion }),
131
142
  }),
132
143
  })
133
144
 
@@ -193,7 +204,8 @@ export async function createZeroDevWalletCore(
193
204
  }
194
205
  }
195
206
 
196
- const commitReplacementSession = async (
207
+ // Callers must hold the cross-tab lock; see commitReplacementSession.
208
+ const commitReplacementSessionLocked = async (
197
209
  session: ZeroDevWalletSession,
198
210
  targetPublicKey: string,
199
211
  ) => {
@@ -227,26 +239,41 @@ export async function createZeroDevWalletCore(
227
239
  if (!recovered) throw error
228
240
  }
229
241
  }
242
+ const commitReplacementSession = (
243
+ session: ZeroDevWalletSession,
244
+ targetPublicKey: string,
245
+ ) =>
246
+ withCrossTabLock(() =>
247
+ commitReplacementSessionLocked(session, targetPublicKey),
248
+ )
230
249
 
231
- await withKeyTransition(async () => {
232
- const activePublicKey = await client.apiKeyStamper.getPublicKey()
233
- await sessionStorageManager.recoverSessionTransition(activePublicKey)
234
- const restoredSession = await sessionStorageManager.getActiveSession()
235
- if (restoredSession) {
236
- try {
237
- const restoredPublicKey = parseSession(restoredSession.token).publicKey
238
- if (
239
- !activePublicKey ||
240
- !restoredPublicKey ||
241
- normalizeKey(activePublicKey) !== normalizeKey(restoredPublicKey)
242
- ) {
250
+ await withKeyTransition(() =>
251
+ withCrossTabLock(async () => {
252
+ // The stamper read the vault before this tab got the lock; another tab
253
+ // may have committed a new key since. A stale key here would look like a
254
+ // mismatch and clear that tab's valid session.
255
+ await client.apiKeyStamper.reload?.()
256
+ const activePublicKey = await client.apiKeyStamper.getPublicKey()
257
+ await sessionStorageManager.recoverSessionTransition(activePublicKey)
258
+ const restoredSession = await sessionStorageManager.getActiveSession()
259
+ if (restoredSession) {
260
+ try {
261
+ const restoredPublicKey = parseSession(
262
+ restoredSession.token,
263
+ ).publicKey
264
+ if (
265
+ !activePublicKey ||
266
+ !restoredPublicKey ||
267
+ normalizeKey(activePublicKey) !== normalizeKey(restoredPublicKey)
268
+ ) {
269
+ await sessionStorageManager.clearAllSessions()
270
+ }
271
+ } catch {
243
272
  await sessionStorageManager.clearAllSessions()
244
273
  }
245
- } catch {
246
- await sessionStorageManager.clearAllSessions()
247
274
  }
248
- }
249
- })
275
+ }),
276
+ )
250
277
 
251
278
  return {
252
279
  client,
@@ -291,56 +318,94 @@ export async function createZeroDevWalletCore(
291
318
  },
292
319
 
293
320
  async clearAllSessions() {
294
- if (await sessionStorageManager.getActiveSession()) {
295
- throw new Error(
296
- 'Refusing to clear the active session locally; use logout() to revoke its remote key first.',
297
- )
298
- }
299
- await sessionStorageManager.clearAllSessions()
321
+ // Under the lock, so it cannot remove another tab's in-flight journal.
322
+ return withKeyTransition(() =>
323
+ withCrossTabLock(async () => {
324
+ if (await sessionStorageManager.getActiveSession()) {
325
+ throw new Error(
326
+ 'Refusing to clear the active session locally; use logout() to revoke its remote key first.',
327
+ )
328
+ }
329
+ await sessionStorageManager.clearAllSessions()
330
+ }),
331
+ )
300
332
  },
301
333
 
302
334
  async refreshSession(sessionId?: string) {
303
- return withKeyTransition(async () => {
304
- const activeSessionKey =
305
- await sessionStorageManager.getActiveSessionKey()
306
- if (sessionId && sessionId !== activeSessionKey) {
307
- throw new Error(
308
- 'Refreshing an inactive session is not supported with a single active key vault',
309
- )
310
- }
311
- const activeSession = await sessionStorageManager.getActiveSession()
312
- if (!activeSession) {
313
- throw new Error('No active session')
314
- }
315
- if (activeSession.stamperType !== 'apiKey') {
316
- throw new Error('Invalid session type')
317
- }
335
+ // Without an id, refresh the session active when called, so a tab that
336
+ // then waits behind another tab's refresh adopts it instead of rotating
337
+ // again. Read now but awaited inside, so the call keeps its queue place.
338
+ const expected =
339
+ sessionId === undefined
340
+ ? sessionStorageManager.getActiveSessionKey()
341
+ : Promise.resolve(sessionId)
342
+ expected.catch(() => undefined)
343
+ return withKeyTransition(() =>
344
+ withCrossTabLock(async () => {
345
+ const expectedId = await expected
346
+ const activeSessionKey =
347
+ await sessionStorageManager.getActiveSessionKey()
348
+ const activeSession = await sessionStorageManager.getActiveSession()
349
+ if (!activeSession) {
350
+ throw new Error('No active session')
351
+ }
352
+ if (expectedId && expectedId !== activeSessionKey) {
353
+ // Another tab replaced the session while this one waited. Adopt it
354
+ // only when that tab also replaced the key under this one. Within
355
+ // one tab, a call without an id refreshes the current session and
356
+ // an inactive id stays an error. Callers should still compare the
357
+ // identity: the other tab may have signed in to a different account.
358
+ if (activeSession.stamperType === 'apiKey') {
359
+ const keyBefore = await client.apiKeyStamper.getPublicKey()
360
+ await client.apiKeyStamper.reload?.()
361
+ const activePublicKey = await client.apiKeyStamper.getPublicKey()
362
+ if (
363
+ activePublicKey &&
364
+ normalizeKey(activePublicKey) !==
365
+ normalizeKey(keyBefore ?? '') &&
366
+ normalizeKey(activePublicKey) ===
367
+ normalizeKey(activeSession.publicKey ?? '') &&
368
+ normalizeTimestamp(activeSession.expiry) > Date.now()
369
+ ) {
370
+ return activeSession
371
+ }
372
+ }
373
+ if (sessionId !== undefined) {
374
+ throw new Error(
375
+ 'Refreshing an inactive session is not supported with a single active key vault',
376
+ )
377
+ }
378
+ }
379
+ if (activeSession.stamperType !== 'apiKey') {
380
+ throw new Error('Invalid session type')
381
+ }
318
382
 
319
- const targetPublicKey = await prepareFreshKeyRotation()
320
- try {
321
- const data = await client.loginWithStamp({
322
- targetPublicKey,
323
- projectId,
324
- // Stamp-login is signed against the Turnkey parent org; the backend
325
- // resolves the sub-org from the stamped credential. Signing the
326
- // sub-org here makes the relayed payload's org mismatch the
327
- // signature → Turnkey SIGNATURE_INVALID.
328
- organizationId: await resolveOrganizationId(),
329
- stampWith: 'apiKey',
330
- })
331
- const session = createReplacementSession(
332
- data.session,
333
- targetPublicKey,
334
- 'indexedDb',
335
- SessionType.READ_WRITE,
336
- )
337
- await commitReplacementSession(session, targetPublicKey)
338
- return session
339
- } catch (error) {
340
- await client.apiKeyStamper.discardKeyRotation()
341
- throw error
342
- }
343
- })
383
+ const targetPublicKey = await prepareFreshKeyRotation()
384
+ try {
385
+ const data = await client.loginWithStamp({
386
+ targetPublicKey,
387
+ projectId,
388
+ // Stamp-login is signed against the Turnkey parent org; the backend
389
+ // resolves the sub-org from the stamped credential. Signing the
390
+ // sub-org here makes the relayed payload's org mismatch the
391
+ // signature → Turnkey SIGNATURE_INVALID.
392
+ organizationId: await resolveOrganizationId(),
393
+ stampWith: 'apiKey',
394
+ })
395
+ const session = createReplacementSession(
396
+ data.session,
397
+ targetPublicKey,
398
+ 'indexedDb',
399
+ SessionType.READ_WRITE,
400
+ )
401
+ await commitReplacementSessionLocked(session, targetPublicKey)
402
+ return session
403
+ } catch (error) {
404
+ await client.apiKeyStamper.discardKeyRotation()
405
+ throw error
406
+ }
407
+ }),
408
+ )
344
409
  },
345
410
 
346
411
  // [TODO] refactor to smaller utils/actions
@@ -408,24 +473,28 @@ export async function createZeroDevWalletCore(
408
473
  encodedPublicKey: registrationPublicKey,
409
474
  })
410
475
 
411
- // Registration proves the temporary key exists remotely. Make
412
- // it active so it can authorize the durable replacement key.
413
- await client.apiKeyStamper.commitKeyRotation()
414
- const targetPublicKey = await prepareFreshKeyRotation()
415
- const loginData = await client.loginWithStamp({
416
- projectId,
417
- targetPublicKey,
418
- // Sign against the parent org (see refreshSession note) — the
419
- // backend derives the sub-org from the stamped credential.
420
- organizationId: await resolveOrganizationId(),
476
+ // From here the shared vault changes, so hold the cross-tab
477
+ // lock; the passkey prompt above stays outside it.
478
+ return await withCrossTabLock(async () => {
479
+ // Registration proves the temporary key exists remotely. Make
480
+ // it active so it can authorize the durable replacement key.
481
+ await client.apiKeyStamper.commitKeyRotation()
482
+ const targetPublicKey = await prepareFreshKeyRotation()
483
+ const loginData = await client.loginWithStamp({
484
+ projectId,
485
+ targetPublicKey,
486
+ // Sign against the parent org (see refreshSession note) — the
487
+ // backend derives the sub-org from the stamped credential.
488
+ organizationId: await resolveOrganizationId(),
489
+ })
490
+ const session = createReplacementSession(
491
+ loginData.session,
492
+ targetPublicKey,
493
+ 'indexedDb',
494
+ )
495
+ await commitReplacementSessionLocked(session, targetPublicKey)
496
+ return data
421
497
  })
422
- const session = createReplacementSession(
423
- loginData.session,
424
- targetPublicKey,
425
- 'indexedDb',
426
- )
427
- await commitReplacementSession(session, targetPublicKey)
428
- return data
429
498
  } catch (error) {
430
499
  await client.apiKeyStamper.discardKeyRotation()
431
500
  throw error
@@ -619,19 +688,34 @@ export async function createZeroDevWalletCore(
619
688
  }
620
689
 
621
690
  preparedOAuthPublicKey = undefined
622
- let cleanupError: unknown
623
- try {
624
- await client.apiKeyStamper.clear()
625
- } catch (error) {
626
- cleanupError = error
627
- }
628
- try {
629
- await sessionStorageManager.clearAllSessions()
630
- } catch (error) {
631
- cleanupError ??= error
632
- }
633
- if (cleanupError) throw cleanupError
634
- return true
691
+ return withCrossTabLock(async () => {
692
+ // Another tab may have signed in while the revoke was in flight. Keep
693
+ // a session for a different account, or one that appeared after a
694
+ // signed-out start; a same-account refresh is still ours to clear.
695
+ const current = await sessionStorageManager.getActiveSession()
696
+ if (
697
+ current &&
698
+ (!session ||
699
+ current.organizationId !== session.organizationId ||
700
+ current.userId !== session.userId)
701
+ ) {
702
+ await client.apiKeyStamper.discardKeyRotation()
703
+ return true
704
+ }
705
+ let cleanupError: unknown
706
+ try {
707
+ await client.apiKeyStamper.clear()
708
+ } catch (error) {
709
+ cleanupError = error
710
+ }
711
+ try {
712
+ await sessionStorageManager.clearAllSessions()
713
+ } catch (error) {
714
+ cleanupError ??= error
715
+ }
716
+ if (cleanupError) throw cleanupError
717
+ return true
718
+ })
635
719
  })
636
720
  },
637
721
 
@@ -1,5 +1,6 @@
1
1
  import { p256 } from '@noble/curves/nist.js'
2
2
  import { IndexedDbStamper as TurnkeyIndexedDbStamper } from '@turnkey/indexed-db-stamper'
3
+ import { withCrossTabLock } from '../utils/crossTabLock.js'
3
4
  import { generateCompressedPublicKeyFromKeyPair } from '../utils/utils.js'
4
5
  import type { ApiKeyStamper } from './types.js'
5
6
 
@@ -28,7 +29,9 @@ function encodeStamp(publicKey: string, signature: string): string {
28
29
 
29
30
  export async function createIndexedDbStamper(): Promise<ApiKeyStamper> {
30
31
  const inner = new TurnkeyIndexedDbStamper()
31
- await inner.init()
32
+ // On an empty vault init() generates and stores a key; under the lock it
33
+ // cannot overwrite a key another tab is committing.
34
+ await withCrossTabLock(() => inner.init())
32
35
 
33
36
  let pendingKeyPair: CryptoKeyPair | null = null
34
37
 
@@ -88,5 +91,9 @@ export async function createIndexedDbStamper(): Promise<ApiKeyStamper> {
88
91
  async discardKeyRotation() {
89
92
  pendingKeyPair = null
90
93
  },
94
+ // Unlocked: its callers already hold the cross-tab lock.
95
+ async reload() {
96
+ await inner.init()
97
+ },
91
98
  }
92
99
  }
@@ -45,6 +45,11 @@ export type ApiKeyStamper = Stamper & {
45
45
  commitKeyRotation: () => Promise<void>
46
46
  /** Forget the prepared key while keeping the active key untouched. */
47
47
  discardKeyRotation: () => Promise<void>
48
+ /**
49
+ * Re-read the active key from the shared vault, which another tab may have
50
+ * rotated. Omit when each app has its own vault (React Native).
51
+ */
52
+ reload?: () => Promise<void>
48
53
  /**
49
54
  * Sign `payload` with the currently active key. Returns a hex-encoded
50
55
  * ECDSA-P256 / SHA-256 signature in ASN.1 DER form.
@@ -0,0 +1,19 @@
1
+ // Tabs of one origin share the key vault and session storage but not the
2
+ // in-process key-transition queue in createZeroDevWalletCore. A Web Lock spans
3
+ // tabs. It covers a whole refresh, so every tab's timer firing at the same
4
+ // instant sends one stamp login (Turnkey fails concurrent ones for a sub-org,
5
+ // DPL-798), and every write of the shared vault and session, including the
6
+ // stamper's first key on an empty vault, so one tab cannot overwrite another's
7
+ // key or consume its transition journal. It never spans a passkey prompt or
8
+ // OAuth popup: an abandoned prompt would block every other tab. It is not
9
+ // re-entrant, so code running under it must not take it again. Without Web
10
+ // Locks (React Native, very old browsers) there is one instance per app or the
11
+ // in-process queue is all we have.
12
+ export const KEY_TRANSITION_LOCK = '@zerodev/key_transition'
13
+
14
+ export const withCrossTabLock = async <T>(
15
+ task: () => Promise<T>,
16
+ ): Promise<T> => {
17
+ const locks = typeof navigator === 'undefined' ? undefined : navigator.locks
18
+ return locks ? await locks.request(KEY_TRANSITION_LOCK, task) : task()
19
+ }
package/src/version.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Synced from package.json by scripts/sync-version.mjs in changeset:version.
2
+ export const version = '0.0.7'