accounts 0.12.2 → 0.13.0

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 (99) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +6 -5
  3. package/dist/cli/adapter.d.ts.map +1 -1
  4. package/dist/cli/adapter.js +53 -67
  5. package/dist/cli/adapter.js.map +1 -1
  6. package/dist/core/AccessKey.d.ts +89 -106
  7. package/dist/core/AccessKey.d.ts.map +1 -1
  8. package/dist/core/AccessKey.js +226 -210
  9. package/dist/core/AccessKey.js.map +1 -1
  10. package/dist/core/Account.d.ts +0 -9
  11. package/dist/core/Account.d.ts.map +1 -1
  12. package/dist/core/Account.js +1 -13
  13. package/dist/core/Account.js.map +1 -1
  14. package/dist/core/Adapter.d.ts +10 -2
  15. package/dist/core/Adapter.d.ts.map +1 -1
  16. package/dist/core/Provider.d.ts +4 -5
  17. package/dist/core/Provider.d.ts.map +1 -1
  18. package/dist/core/Provider.js +66 -76
  19. package/dist/core/Provider.js.map +1 -1
  20. package/dist/core/Schema.d.ts +7 -7
  21. package/dist/core/adapters/dialog.d.ts.map +1 -1
  22. package/dist/core/adapters/dialog.js +114 -124
  23. package/dist/core/adapters/dialog.js.map +1 -1
  24. package/dist/core/adapters/local.d.ts.map +1 -1
  25. package/dist/core/adapters/local.js +92 -105
  26. package/dist/core/adapters/local.js.map +1 -1
  27. package/dist/core/adapters/privy.d.ts +164 -0
  28. package/dist/core/adapters/privy.d.ts.map +1 -0
  29. package/dist/core/adapters/privy.js +574 -0
  30. package/dist/core/adapters/privy.js.map +1 -0
  31. package/dist/core/adapters/turnkey.d.ts.map +1 -1
  32. package/dist/core/adapters/turnkey.js +136 -133
  33. package/dist/core/adapters/turnkey.js.map +1 -1
  34. package/dist/core/internal/AccessKeyTransaction.d.ts +59 -0
  35. package/dist/core/internal/AccessKeyTransaction.d.ts.map +1 -0
  36. package/dist/core/internal/AccessKeyTransaction.js +155 -0
  37. package/dist/core/internal/AccessKeyTransaction.js.map +1 -0
  38. package/dist/core/zod/rpc.d.ts +9 -4
  39. package/dist/core/zod/rpc.d.ts.map +1 -1
  40. package/dist/core/zod/rpc.js +9 -4
  41. package/dist/core/zod/rpc.js.map +1 -1
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +1 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/react-native/adapter.d.ts.map +1 -1
  47. package/dist/react-native/adapter.js +61 -72
  48. package/dist/react-native/adapter.js.map +1 -1
  49. package/dist/server/Kv.d.ts +19 -9
  50. package/dist/server/Kv.d.ts.map +1 -1
  51. package/dist/server/Kv.js +32 -9
  52. package/dist/server/Kv.js.map +1 -1
  53. package/dist/server/internal/handlers/auth.d.ts.map +1 -1
  54. package/dist/server/internal/handlers/auth.js +2 -0
  55. package/dist/server/internal/handlers/auth.js.map +1 -1
  56. package/dist/server/internal/handlers/relay.d.ts +18 -12
  57. package/dist/server/internal/handlers/relay.d.ts.map +1 -1
  58. package/dist/server/internal/handlers/relay.js +115 -2
  59. package/dist/server/internal/handlers/relay.js.map +1 -1
  60. package/dist/server/internal/handlers/webAuthn.d.ts +6 -1
  61. package/dist/server/internal/handlers/webAuthn.d.ts.map +1 -1
  62. package/dist/server/internal/handlers/webAuthn.js +16 -9
  63. package/dist/server/internal/handlers/webAuthn.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/cli/adapter.ts +69 -74
  66. package/src/core/AccessKey.test.ts +525 -559
  67. package/src/core/AccessKey.ts +323 -327
  68. package/src/core/Account.test.ts +5 -50
  69. package/src/core/Account.ts +1 -19
  70. package/src/core/Adapter.ts +6 -2
  71. package/src/core/{Provider.test.ts → Provider.localnet.test.ts} +97 -31
  72. package/src/core/Provider.ts +110 -110
  73. package/src/core/Schema.test-d.ts +18 -0
  74. package/src/core/adapters/dialog.test.ts +137 -13
  75. package/src/core/adapters/dialog.ts +109 -121
  76. package/src/core/adapters/local.ts +95 -119
  77. package/src/core/adapters/privy.test-d.ts +128 -0
  78. package/src/core/adapters/privy.test.ts +634 -0
  79. package/src/core/adapters/privy.ts +805 -0
  80. package/src/core/adapters/turnkey.test.ts +117 -13
  81. package/src/core/adapters/turnkey.ts +132 -149
  82. package/src/core/internal/AccessKeyTransaction.ts +243 -0
  83. package/src/core/{mppx.test.ts → mppx.localnet.test.ts} +32 -2
  84. package/src/core/zod/request.test.ts +36 -0
  85. package/src/core/zod/rpc.ts +17 -10
  86. package/src/index.ts +1 -1
  87. package/src/react-native/adapter.ts +77 -84
  88. package/src/server/Kv.test.ts +51 -0
  89. package/src/server/Kv.ts +43 -9
  90. package/src/server/internal/handlers/auth.test.ts +10 -5
  91. package/src/server/internal/handlers/auth.ts +3 -0
  92. package/src/server/internal/handlers/{relay.test.ts → relay.localnet.test.ts} +3 -0
  93. package/src/server/internal/handlers/relay.ts +138 -13
  94. package/src/server/internal/handlers/session.test.ts +1 -1
  95. package/src/server/internal/handlers/webAuthn.test.ts +20 -0
  96. package/src/server/internal/handlers/webAuthn.ts +24 -12
  97. /package/src/cli/{Provider.test.ts → Provider.localnet.test.ts} +0 -0
  98. /package/src/react-native/{Provider.test.ts → Provider.localnet.test.ts} +0 -0
  99. /package/src/server/internal/handlers/{exchange.test.ts → exchange.localnet.test.ts} +0 -0
@@ -47,6 +47,36 @@ describe('memory', () => {
47
47
  expect(await kv.get('a')).toMatchInlineSnapshot(`"v2"`)
48
48
  })
49
49
 
50
+ test('create: writes only when key is absent', async () => {
51
+ const kv = Kv.memory()
52
+
53
+ expect(await kv.create!('a', 'v1')).toMatchInlineSnapshot(`true`)
54
+ expect(await kv.create!('a', 'v2')).toMatchInlineSnapshot(`false`)
55
+ expect(await kv.get('a')).toMatchInlineSnapshot(`"v1"`)
56
+ })
57
+
58
+ test('create: replaces expired entries', async () => {
59
+ let now = 1_000_000
60
+ const kv = Kv.memory({ now: () => now })
61
+
62
+ await kv.set('a', 'v1', { ttl: 1 })
63
+ now += 2_000
64
+ expect(await kv.create!('a', 'v2')).toMatchInlineSnapshot(`true`)
65
+ expect(await kv.get('a')).toMatchInlineSnapshot(`"v2"`)
66
+ })
67
+
68
+ test('create: concurrent callers — only one writes', async () => {
69
+ const kv = Kv.memory()
70
+
71
+ const results = await Promise.all([kv.create!('a', 'v1'), kv.create!('a', 'v2')])
72
+ expect(results.filter(Boolean)).toMatchInlineSnapshot(`
73
+ [
74
+ true,
75
+ ]
76
+ `)
77
+ expect(await kv.get('a')).toMatchInlineSnapshot(`"v1"`)
78
+ })
79
+
50
80
  test('take: returns the value and removes the entry', async () => {
51
81
  const kv = Kv.memory()
52
82
 
@@ -163,6 +193,18 @@ describe('durableObject + NonceStorage', () => {
163
193
  `)
164
194
  })
165
195
 
196
+ test('create: concurrent callers — only one wins', async () => {
197
+ const kv = Kv.durableObject(fakeDurableObject())
198
+
199
+ const results = await Promise.all([kv.create!('a', 'v1'), kv.create!('a', 'v2')])
200
+ expect(results.filter(Boolean)).toMatchInlineSnapshot(`
201
+ [
202
+ true,
203
+ ]
204
+ `)
205
+ expect(await kv.get('a')).toMatchInlineSnapshot(`"v1"`)
206
+ })
207
+
166
208
  test('take: missing key returns undefined', async () => {
167
209
  const kv = Kv.durableObject(fakeDurableObject())
168
210
  expect(await kv.take!('missing')).toMatchInlineSnapshot(`undefined`)
@@ -226,6 +268,15 @@ describe('cloudflare', () => {
226
268
  expect(kv.take).toBeUndefined()
227
269
  })
228
270
 
271
+ test('create: NOT implemented (CF KV is not linearizable)', () => {
272
+ const kv = Kv.cloudflare({
273
+ get: async () => null,
274
+ put: async () => {},
275
+ delete: async () => {},
276
+ })
277
+ expect(kv.create).toBeUndefined()
278
+ })
279
+
229
280
  test('ttl: passes expirationTtl seconds to underlying put', async () => {
230
281
  const puts: { key: string; value: string; options: unknown }[] = []
231
282
  const fakeKv = {
package/src/server/Kv.ts CHANGED
@@ -16,6 +16,16 @@ export type Kv = {
16
16
  set: (key: string, value: unknown, options?: set.Options | undefined) => Promise<void>
17
17
  /** Delete a value by key. */
18
18
  delete: (key: string) => Promise<void>
19
+ /**
20
+ * Atomic create-if-absent. Returns `true` when the value was written,
21
+ * `false` when a non-expired value already exists.
22
+ *
23
+ * Optional. Backends with a linearizable create primitive should
24
+ * implement this so consumers can atomically reject duplicates.
25
+ * Consumers may fall back to `get` then `set` when strict atomicity is
26
+ * not required.
27
+ */
28
+ create?: (key: string, value: unknown, options?: set.Options | undefined) => Promise<boolean>
19
29
  /**
20
30
  * Atomic read-and-delete. Returns the value if present, `undefined` if
21
31
  * missing or expired. Across concurrent callers, exactly one observer
@@ -51,11 +61,10 @@ export function from<kv extends Kv>(kv: kv): kv {
51
61
  * Cloudflare KV's minimum TTL is 60 seconds; the platform enforces its own
52
62
  * minimum independent of what's passed here.
53
63
  *
54
- * **Not safe for one-time-consume semantics.** Cloudflare KV is eventually
55
- * consistent across data centers concurrent read+delete races can let
56
- * the same key be "consumed" twice. `take` is intentionally NOT
57
- * implemented. Use a Durable Object (or another linearizable backend)
58
- * for the SIWE challenge nonce store.
64
+ * **Not safe for one-time-consume or unique-key semantics.**
65
+ * Cloudflare KV is eventually consistent across data centers, so `take`
66
+ * and `create` are intentionally NOT implemented. Use a Durable Object
67
+ * (or another linearizable backend) for the SIWE challenge nonce store.
59
68
  */
60
69
  export function cloudflare(kv: cloudflare.Parameters): Kv {
61
70
  return from({
@@ -84,10 +93,11 @@ export declare namespace cloudflare {
84
93
 
85
94
  /**
86
95
  * Adapt a Cloudflare Durable Object namespace into a `Kv` with atomic
87
- * `take`. Unlike `Kv.cloudflare`, a Durable Object's storage is
88
- * single-actor and linearizable `take` (read+delete) is guaranteed
89
- * atomic across concurrent callers, which makes this the recommended
90
- * backend for SIWE challenge nonce storage on Cloudflare Workers.
96
+ * `take` and `create`. Unlike `Kv.cloudflare`, a Durable Object's
97
+ * storage is single-actor and linearizable, so `take` (read+delete) and
98
+ * `create` (create-if-absent) are guaranteed atomic across concurrent
99
+ * callers. This makes it the recommended backend for SIWE challenge
100
+ * nonce storage on Cloudflare Workers.
91
101
  *
92
102
  * Pair with `Kv.NonceStorage` (or your own DO class implementing the
93
103
  * same fetch protocol).
@@ -147,6 +157,12 @@ export function durableObject(
147
157
  async set(key, value, options) {
148
158
  await rpc('set', key, { value, ttl: options?.ttl })
149
159
  },
160
+ async create(key, value, options) {
161
+ const { created } = (await rpc('create', key, { value, ttl: options?.ttl })) as {
162
+ created: boolean
163
+ }
164
+ return created
165
+ },
150
166
  async delete(key) {
151
167
  await rpc('delete', key)
152
168
  },
@@ -228,6 +244,17 @@ export class NonceStorage {
228
244
  await this.state.storage.put(key, entry)
229
245
  return Response.json({})
230
246
  }
247
+ if (op === 'create') {
248
+ const current = await this.state.storage.get<NonceStorage.Entry>(key)
249
+ if (current && !isExpired(current)) return Response.json({ created: false })
250
+
251
+ const body = (await request.json()) as { value: unknown; ttl?: number }
252
+ const entry: NonceStorage.Entry = body.ttl
253
+ ? { value: body.value, expiresAt: Date.now() + body.ttl * 1000 }
254
+ : { value: body.value }
255
+ await this.state.storage.put(key, entry)
256
+ return Response.json({ created: true })
257
+ }
231
258
  if (op === 'delete') {
232
259
  await this.state.storage.delete(key)
233
260
  return Response.json({})
@@ -280,6 +307,13 @@ export function memory(options: memory.Options = {}): Kv {
280
307
  const expiresAt = options?.ttl ? now() + options.ttl * 1000 : undefined
281
308
  store.set(key, expiresAt !== undefined ? { value, expiresAt } : { value })
282
309
  },
310
+ async create(key, value, options) {
311
+ const entry = store.get(key)
312
+ if (entry && !isExpired(entry)) return false
313
+ const expiresAt = options?.ttl ? now() + options.ttl * 1000 : undefined
314
+ store.set(key, expiresAt !== undefined ? { value, expiresAt } : { value })
315
+ return true
316
+ },
283
317
  // Atomic in-process: the synchronous `Map.get` + `Map.delete` runs
284
318
  // in a single microtask, so concurrent `take(key)` callers (within
285
319
  // the same Node/Bun/Worker process) cannot both observe the value.
@@ -15,6 +15,12 @@ const otherAccount = privateKeyToAccount(
15
15
  )
16
16
 
17
17
  describe('challenge', () => {
18
+ test('error: requires pinned origin or domain', () => {
19
+ expect(() => auth()).toThrowErrorMatchingInlineSnapshot(
20
+ `[Error: \`auth()\` requires \`origin\` or \`domain\` to pin SIWE domain binding.]`,
21
+ )
22
+ })
23
+
18
24
  test('returns challenge message with chainId, nonce, zero-address placeholder', async () => {
19
25
  const { app } = setup()
20
26
 
@@ -689,7 +695,7 @@ describe('getSession', () => {
689
695
 
690
696
  describe('store: atomic `take` preferred, non-atomic fallback', () => {
691
697
  test('Kv.memory() (has `take`) is accepted', () => {
692
- expect(() => auth({ store: Kv.memory() })).not.toThrow()
698
+ expect(() => auth({ domain: 'wallet.example', store: Kv.memory() })).not.toThrow()
693
699
  })
694
700
 
695
701
  test('store without `take` falls back to non-atomic get + delete', async () => {
@@ -726,8 +732,7 @@ describe('store: atomic `take` preferred, non-atomic fallback', () => {
726
732
 
727
733
  describe('origin / trustProxy', () => {
728
734
  test('default: ignores `x-forwarded-host` and `x-forwarded-proto`', async () => {
729
- // No domain pin relies on host header. trustProxy defaults to false.
730
- const handler = auth()
735
+ const handler = auth({ domain: 'real.example' })
731
736
  const app = new Hono()
732
737
  app.route('/', handler)
733
738
 
@@ -747,7 +752,7 @@ describe('origin / trustProxy', () => {
747
752
  })
748
753
 
749
754
  test('trustProxy: true → honors `x-forwarded-host` and `x-forwarded-proto`', async () => {
750
- const handler = auth({ trustProxy: true })
755
+ const handler = auth({ domain: 'app.example', trustProxy: true })
751
756
  const app = new Hono()
752
757
  app.route('/', handler)
753
758
 
@@ -805,7 +810,7 @@ describe('origin / trustProxy', () => {
805
810
  configurable: true,
806
811
  })
807
812
  try {
808
- const handler = auth()
813
+ const handler = auth({ domain: 'app.example' })
809
814
  const app = new Hono()
810
815
  app.route('/', handler)
811
816
 
@@ -138,6 +138,9 @@ export function auth(options: auth.Options = {}): auth.ReturnType {
138
138
  ...rest
139
139
  } = options
140
140
 
141
+ if (!origin_option && !domain)
142
+ throw new Error('`auth()` requires `origin` or `domain` to pin SIWE domain binding.')
143
+
141
144
  async function take(key: string): Promise<ChallengePayload | undefined> {
142
145
  if (store.take) return store.take<ChallengePayload>(key)
143
146
  const value = await store.get<ChallengePayload>(key)
@@ -414,6 +414,7 @@ describe('behavior: with app-provided feePayer URL', () => {
414
414
  walletServer = await createServer(
415
415
  relay({
416
416
  chains: [chain],
417
+ internal_allowUnsafeUrls: true,
417
418
  transports: { [chain.id]: http() },
418
419
  }).listener,
419
420
  )
@@ -504,6 +505,7 @@ describe('behavior: with app-provided feePayer URL + autoSwap', () => {
504
505
  relay({
505
506
  chains: [chain],
506
507
  features: 'all',
508
+ internal_allowUnsafeUrls: true,
507
509
  transports: { [chain.id]: http() },
508
510
  }).listener,
509
511
  )
@@ -625,6 +627,7 @@ describe('behavior: app-provided feePayer URL bypasses wallet validate', () => {
625
627
  relay({
626
628
  chains: [chain],
627
629
  features: 'all',
630
+ internal_allowUnsafeUrls: true,
628
631
  transports: { [chain.id]: http() },
629
632
  feePayer: {
630
633
  account: feePayerAccount,
@@ -74,6 +74,7 @@ export function relay(options: relay.Options = {}): Handler {
74
74
  const {
75
75
  cacheTtl = defaultCacheTtl,
76
76
  chains = [tempo, tempoModerato, tempoDevnet],
77
+ internal_allowUnsafeUrls = false,
77
78
  kv = Kv.memory(),
78
79
  onRequest,
79
80
  path = '/',
@@ -150,7 +151,11 @@ export function relay(options: relay.Options = {}): Handler {
150
151
  const requestFeeToken =
151
152
  typeof parameters.feeToken === 'string' ? (parameters.feeToken as Address) : undefined
152
153
  const externalFeePayerUrl =
153
- typeof parameters.feePayer === 'string' ? parameters.feePayer : undefined
154
+ typeof parameters.feePayer === 'string'
155
+ ? normalizeExternalFeePayerUrl(parameters.feePayer, {
156
+ allowUnsafe: internal_allowUnsafeUrls,
157
+ })
158
+ : undefined
154
159
  const requestsSponsorship =
155
160
  (!!feePayerOptions || !!externalFeePayerUrl) && parameters.feePayer !== false
156
161
  // Default to `true`. Dapps that don't render diffs can pass
@@ -639,6 +644,20 @@ export namespace relay {
639
644
  url?: string | undefined
640
645
  }
641
646
  | undefined
647
+ /**
648
+ * Relay features.
649
+ *
650
+ * - `'all'` — enables fee token resolution, auto-swap,
651
+ * fee payer, and simulation (balance diffs + fee breakdown).
652
+ * - `undefined` (default) — only fee payers.
653
+ */
654
+ features?: 'all' | undefined
655
+ /**
656
+ * Allows app-provided external fee payer URLs to use non-HTTPS protocols
657
+ * or local/private hosts. Intended only for trusted development setups.
658
+ * @default false
659
+ */
660
+ internal_allowUnsafeUrls?: boolean | undefined
642
661
  /**
643
662
  * Kv store used to cache `resolveTokens` results across requests.
644
663
  * Provide `Kv.cloudflare(env.KV)` for cross-instance caching, or omit
@@ -646,6 +665,10 @@ export namespace relay {
646
665
  * @default Kv.memory()
647
666
  */
648
667
  kv?: Kv.Kv | undefined
668
+ /** Function to call before handling the request. */
669
+ onRequest?: ((request: RpcRequest.RpcRequest) => Promise<void>) | undefined
670
+ /** Path to use for the handler. @default "/" */
671
+ path?: string | undefined
649
672
  /**
650
673
  * Resolves the list of known tokens for a chain. The relay checks
651
674
  * `balanceOf` for each token and picks the one with the highest balance
@@ -655,18 +678,6 @@ export namespace relay {
655
678
  resolveTokens?:
656
679
  | ((chainId: number) => readonly Tokenlist.Token[] | Promise<readonly Tokenlist.Token[]>)
657
680
  | undefined
658
- /**
659
- * Relay features.
660
- *
661
- * - `'all'` — enables fee token resolution, auto-swap,
662
- * fee payer, and simulation (balance diffs + fee breakdown).
663
- * - `undefined` (default) — only fee payers.
664
- */
665
- features?: 'all' | undefined
666
- /** Function to call before handling the request. */
667
- onRequest?: ((request: RpcRequest.RpcRequest) => Promise<void>) | undefined
668
- /** Path to use for the handler. @default "/" */
669
- path?: string | undefined
670
681
  /** Transports keyed by chain ID. Defaults to `http()` for each chain. */
671
682
  transports?: Record<number, Transport> | undefined
672
683
  }
@@ -1406,6 +1417,120 @@ function mergeCallsFromRequest(
1406
1417
  return merged
1407
1418
  }
1408
1419
 
1420
+ /** Normalizes external fee-payer relay URLs before forwarding fill payloads. */
1421
+ function normalizeExternalFeePayerUrl(
1422
+ value: string,
1423
+ options: normalizeExternalFeePayerUrl.Options = {},
1424
+ ): string {
1425
+ let url: URL
1426
+ try {
1427
+ url = new URL(value)
1428
+ } catch {
1429
+ throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL.' })
1430
+ }
1431
+
1432
+ const allowUnsafe = options.allowUnsafe === true
1433
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
1434
+ throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL protocol.' })
1435
+ if (!allowUnsafe && url.protocol !== 'https:')
1436
+ throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL protocol.' })
1437
+ if (!allowUnsafe && isUnsafeHostname(url.hostname))
1438
+ throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL host.' })
1439
+
1440
+ url.username = ''
1441
+ url.password = ''
1442
+ url.hash = ''
1443
+ return url.href
1444
+ }
1445
+
1446
+ declare namespace normalizeExternalFeePayerUrl {
1447
+ type Options = {
1448
+ /** Whether to allow non-HTTPS or local/private external fee payer URLs. */
1449
+ allowUnsafe?: boolean | undefined
1450
+ }
1451
+ }
1452
+
1453
+ function isUnsafeHostname(hostname: string): boolean {
1454
+ const host = normalizeHostname(hostname)
1455
+ if (host === 'localhost' || host.endsWith('.localhost')) return true
1456
+ const ipv4 = parseIpv4(host)
1457
+ if (ipv4) return isUnsafeIpv4(ipv4)
1458
+ if (!host.includes(':')) return false
1459
+ const ipv6 = parseIpv6(host)
1460
+ if (!ipv6) return true
1461
+ return isUnsafeIpv6(ipv6)
1462
+ }
1463
+
1464
+ function normalizeHostname(hostname: string): string {
1465
+ return hostname
1466
+ .toLowerCase()
1467
+ .replace(/^\[|\]$/g, '')
1468
+ .split('%')[0]!
1469
+ }
1470
+
1471
+ function parseIpv4(value: string): [number, number, number, number] | undefined {
1472
+ const parts = value.split('.')
1473
+ if (parts.length !== 4 || parts.some((part) => !/^\d+$/.test(part))) return undefined
1474
+ const numbers = parts.map((part) => Number(part))
1475
+ if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return undefined
1476
+ return numbers as [number, number, number, number]
1477
+ }
1478
+
1479
+ function isUnsafeIpv4(parts: [number, number, number, number]): boolean {
1480
+ const [a, b] = parts
1481
+ if (a === 0 || a === 10 || a === 127) return true
1482
+ if (a === 100 && b >= 64 && b <= 127) return true
1483
+ if (a === 169 && b === 254) return true
1484
+ if (a === 172 && b >= 16 && b <= 31) return true
1485
+ if (a === 192 && b === 168) return true
1486
+ if (a >= 224) return true
1487
+ return false
1488
+ }
1489
+
1490
+ function parseIpv6(
1491
+ value: string,
1492
+ ): [number, number, number, number, number, number, number, number] | undefined {
1493
+ const halves = value.split('::')
1494
+ if (halves.length > 2) return undefined
1495
+
1496
+ const left = halves[0] ? halves[0].split(':') : []
1497
+ const right = halves[1] ? halves[1].split(':') : []
1498
+ const missing = halves.length === 2 ? 8 - left.length - right.length : 0
1499
+ if (missing < 0) return undefined
1500
+
1501
+ const parts = [...left, ...Array(missing).fill('0'), ...right]
1502
+ if (parts.length !== 8) return undefined
1503
+
1504
+ const numbers = parts.map((part) =>
1505
+ /^[\da-f]{1,4}$/i.test(part) ? Number.parseInt(part, 16) : NaN,
1506
+ )
1507
+ if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) return undefined
1508
+ return numbers as [number, number, number, number, number, number, number, number]
1509
+ }
1510
+
1511
+ function isUnsafeIpv6(parts: [number, number, number, number, number, number, number, number]) {
1512
+ const [a] = parts
1513
+ if (parts.every((part) => part === 0)) return true
1514
+ if (parts.slice(0, 7).every((part) => part === 0) && parts[7] === 1) return true
1515
+ if ((a & 0xfe00) === 0xfc00) return true
1516
+ if ((a & 0xffc0) === 0xfe80) return true
1517
+ if ((a & 0xffc0) === 0xfec0) return true
1518
+ if ((a & 0xff00) === 0xff00) return true
1519
+
1520
+ const ipv4 = ipv4FromIpv6(parts)
1521
+ if (ipv4) return isUnsafeIpv4(ipv4)
1522
+ return false
1523
+ }
1524
+
1525
+ function ipv4FromIpv6(
1526
+ parts: [number, number, number, number, number, number, number, number],
1527
+ ): [number, number, number, number] | undefined {
1528
+ const mapped = parts.slice(0, 5).every((part) => part === 0) && parts[5] === 0xffff
1529
+ const compatible = parts.slice(0, 6).every((part) => part === 0)
1530
+ if (!mapped && !compatible) return undefined
1531
+ return [parts[6]! >> 8, parts[6]! & 0xff, parts[7]! >> 8, parts[7]! & 0xff]
1532
+ }
1533
+
1409
1534
  /**
1410
1535
  * Reconstructs a `swap` shape (matching the inner autoSwap branch's return
1411
1536
  * value) from an upstream relay's `capabilities.autoSwap`. Used so a wallet
@@ -143,7 +143,7 @@ describe('tokenFromRequest (Node.js headers)', () => {
143
143
 
144
144
  describe('getSession with http.IncomingMessage', () => {
145
145
  const store = Kv.memory()
146
- const handler = auth({ store, cookie: false })
146
+ const handler = auth({ store, cookie: false, domain: 'localhost' })
147
147
 
148
148
  let authServer: Http.Server
149
149
  let authUrl: string
@@ -67,6 +67,26 @@ describe('POST /register', () => {
67
67
  })
68
68
  })
69
69
 
70
+ describe('kv', () => {
71
+ test('store without atomic create is accepted', () => {
72
+ const kv: Kv.Kv = {
73
+ async get() {
74
+ return undefined
75
+ },
76
+ async set() {},
77
+ async delete() {},
78
+ }
79
+
80
+ expect(() =>
81
+ webAuthn({
82
+ kv,
83
+ origin: 'http://localhost',
84
+ rpId: 'localhost',
85
+ }),
86
+ ).not.toThrow()
87
+ })
88
+ })
89
+
70
90
  describe('POST /login', () => {
71
91
  test('error: unknown credential → 400', async () => {
72
92
  const response = await fetch(`${server.url}/login`, {
@@ -20,6 +20,17 @@ const defaults = {
20
20
 
21
21
  const sessionKey = (token: string) => `session:${token}`
22
22
 
23
+ async function createCredential(
24
+ kv: Kv.Kv,
25
+ key: string,
26
+ value: { publicKey: string; userId?: string | undefined },
27
+ ) {
28
+ if (kv.create) return await kv.create(key, value)
29
+ if (await kv.get(key)) return false
30
+ await kv.set(key, value)
31
+ return true
32
+ }
33
+
23
34
  /**
24
35
  * Session payload persisted in the session store and surfaced via
25
36
  * `getSession`. Mirrors the shape of the WebAuthn login response so
@@ -149,9 +160,13 @@ export function webAuthn(options: webAuthn.Options): webAuthn.ReturnType {
149
160
  const userId = stored.userId
150
161
  ? Base64.fromBytes(Bytes.fromString(stored.userId), { pad: false, url: true })
151
162
  : undefined
163
+ const created = await createCredential(kv, `credential:${credentialId}`, {
164
+ publicKey,
165
+ ...(userId ? { userId } : {}),
166
+ })
167
+ if (!created) throw new Error('Credential already exists')
152
168
 
153
- const [, hook] = await Promise.all([
154
- kv.set(`credential:${credentialId}`, { publicKey, ...(userId ? { userId } : {}) }),
169
+ const [hook] = await Promise.all([
155
170
  onRegister?.({
156
171
  credentialId,
157
172
  name: stored.name,
@@ -263,17 +278,9 @@ export function webAuthn(options: webAuthn.Options): webAuthn.ReturnType {
263
278
  })
264
279
  if (!valid) throw new Error('Authentication failed')
265
280
 
266
- const rawResponse = response.raw?.response as unknown as Record<string, string> | undefined
267
- const userHandle = rawResponse?.userHandle
268
-
269
281
  const credentialId = response.id
270
282
  const publicKey = credentialData.publicKey
271
- // Surface the authenticator-emitted `userHandle` verbatim
272
- // (base64url-encoded user id). Fall back to the base64-encoded
273
- // userId we stashed during register, so callers see the same
274
- // identifier shape across register and login.
275
- const userId =
276
- userHandle && userHandle.length > 0 ? userHandle : (credentialData.userId ?? undefined)
283
+ const userId = credentialData.userId
277
284
 
278
285
  // Hook for side effects (user provisioning, analytics, allow/deny).
279
286
  // The legacy contract — return a `Response` to merge fields onto
@@ -400,7 +407,12 @@ export declare namespace webAuthn {
400
407
  cookie?: boolean | undefined
401
408
  /** Cookie name for the session token. @default "accounts_webauthn" */
402
409
  cookieName?: string | undefined
403
- /** Key-value store for challenges, credentials, and sessions. */
410
+ /**
411
+ * Key-value store for challenges, credentials, and sessions. When
412
+ * `create` is available, credential registration uses it to reject
413
+ * duplicates atomically. Otherwise, registration falls back to
414
+ * best-effort `get` then `set` storage.
415
+ */
404
416
  kv: Kv.Kv
405
417
  /** Called after a successful registration. The returned response is merged onto the default JSON response. */
406
418
  onRegister?: (parameters: {