accounts 0.3.0 → 0.4.1

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 (168) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +97 -0
  4. package/dist/core/AccessKey.d.ts +55 -0
  5. package/dist/core/AccessKey.d.ts.map +1 -0
  6. package/dist/core/AccessKey.js +69 -0
  7. package/dist/core/AccessKey.js.map +1 -0
  8. package/dist/core/Account.d.ts +91 -0
  9. package/dist/core/Account.d.ts.map +1 -0
  10. package/dist/core/Account.js +64 -0
  11. package/dist/core/Account.js.map +1 -0
  12. package/dist/core/Adapter.d.ts +187 -0
  13. package/dist/core/Adapter.d.ts.map +1 -0
  14. package/dist/core/Adapter.js +7 -0
  15. package/dist/core/Adapter.js.map +1 -0
  16. package/dist/core/Ceremony.d.ts +109 -0
  17. package/dist/core/Ceremony.d.ts.map +1 -0
  18. package/dist/core/Ceremony.js +104 -0
  19. package/dist/core/Ceremony.js.map +1 -0
  20. package/dist/core/Client.d.ts +16 -0
  21. package/dist/core/Client.d.ts.map +1 -0
  22. package/dist/core/Client.js +18 -0
  23. package/dist/core/Client.js.map +1 -0
  24. package/dist/core/Dialog.d.ts +52 -0
  25. package/dist/core/Dialog.d.ts.map +1 -0
  26. package/dist/core/Dialog.js +342 -0
  27. package/dist/core/Dialog.js.map +1 -0
  28. package/dist/core/Expiry.d.ts +15 -0
  29. package/dist/core/Expiry.d.ts.map +1 -0
  30. package/dist/core/Expiry.js +29 -0
  31. package/dist/core/Expiry.js.map +1 -0
  32. package/dist/core/Messenger.d.ts +86 -0
  33. package/dist/core/Messenger.d.ts.map +1 -0
  34. package/dist/core/Messenger.js +127 -0
  35. package/dist/core/Messenger.js.map +1 -0
  36. package/dist/core/Provider.d.ts +69 -0
  37. package/dist/core/Provider.d.ts.map +1 -0
  38. package/dist/core/Provider.js +401 -0
  39. package/dist/core/Provider.js.map +1 -0
  40. package/dist/core/Remote.d.ts +114 -0
  41. package/dist/core/Remote.d.ts.map +1 -0
  42. package/dist/core/Remote.js +116 -0
  43. package/dist/core/Remote.js.map +1 -0
  44. package/dist/core/Schema.d.ts +805 -0
  45. package/dist/core/Schema.d.ts.map +1 -0
  46. package/dist/core/Schema.js +43 -0
  47. package/dist/core/Schema.js.map +1 -0
  48. package/dist/core/Storage.d.ts +42 -0
  49. package/dist/core/Storage.d.ts.map +1 -0
  50. package/dist/core/Storage.js +173 -0
  51. package/dist/core/Storage.js.map +1 -0
  52. package/dist/core/Store.d.ts +58 -0
  53. package/dist/core/Store.d.ts.map +1 -0
  54. package/dist/core/Store.js +58 -0
  55. package/dist/core/Store.js.map +1 -0
  56. package/dist/core/adapters/dangerous_secp256k1.d.ts +30 -0
  57. package/dist/core/adapters/dangerous_secp256k1.d.ts.map +1 -0
  58. package/dist/core/adapters/dangerous_secp256k1.js +39 -0
  59. package/dist/core/adapters/dangerous_secp256k1.js.map +1 -0
  60. package/dist/core/adapters/dialog.d.ts +31 -0
  61. package/dist/core/adapters/dialog.d.ts.map +1 -0
  62. package/dist/core/adapters/dialog.js +306 -0
  63. package/dist/core/adapters/dialog.js.map +1 -0
  64. package/dist/core/adapters/local.d.ts +33 -0
  65. package/dist/core/adapters/local.d.ts.map +1 -0
  66. package/dist/core/adapters/local.js +227 -0
  67. package/dist/core/adapters/local.js.map +1 -0
  68. package/dist/core/adapters/webAuthn.d.ts +36 -0
  69. package/dist/core/adapters/webAuthn.d.ts.map +1 -0
  70. package/dist/core/adapters/webAuthn.js +93 -0
  71. package/dist/core/adapters/webAuthn.js.map +1 -0
  72. package/dist/core/internal/withDedupe.d.ts +12 -0
  73. package/dist/core/internal/withDedupe.d.ts.map +1 -0
  74. package/dist/core/internal/withDedupe.js +12 -0
  75. package/dist/core/internal/withDedupe.js.map +1 -0
  76. package/dist/core/zod/request.d.ts +31 -0
  77. package/dist/core/zod/request.d.ts.map +1 -0
  78. package/dist/core/zod/request.js +41 -0
  79. package/dist/core/zod/request.js.map +1 -0
  80. package/dist/core/zod/rpc.d.ts +603 -0
  81. package/dist/core/zod/rpc.d.ts.map +1 -0
  82. package/dist/core/zod/rpc.js +293 -0
  83. package/dist/core/zod/rpc.js.map +1 -0
  84. package/dist/core/zod/utils.d.ts +18 -0
  85. package/dist/core/zod/utils.d.ts.map +1 -0
  86. package/dist/core/zod/utils.js +21 -0
  87. package/dist/core/zod/utils.js.map +1 -0
  88. package/dist/index.d.ts +15 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +15 -0
  91. package/dist/index.js.map +1 -0
  92. package/dist/internal/types.d.ts +284 -0
  93. package/dist/internal/types.d.ts.map +1 -0
  94. package/dist/internal/types.js +2 -0
  95. package/dist/internal/types.js.map +1 -0
  96. package/dist/server/Handler.d.ts +257 -0
  97. package/dist/server/Handler.d.ts.map +1 -0
  98. package/dist/server/Handler.js +433 -0
  99. package/dist/server/Handler.js.map +1 -0
  100. package/dist/server/Kv.d.ts +16 -0
  101. package/dist/server/Kv.d.ts.map +1 -0
  102. package/dist/server/Kv.js +30 -0
  103. package/dist/server/Kv.js.map +1 -0
  104. package/dist/server/index.d.ts +3 -0
  105. package/dist/server/index.d.ts.map +1 -0
  106. package/dist/server/index.js +3 -0
  107. package/dist/server/index.js.map +1 -0
  108. package/dist/server/internal/requestListener.d.ts +124 -0
  109. package/dist/server/internal/requestListener.d.ts.map +1 -0
  110. package/dist/server/internal/requestListener.js +173 -0
  111. package/dist/server/internal/requestListener.js.map +1 -0
  112. package/dist/wagmi/Connector.d.ts +93 -0
  113. package/dist/wagmi/Connector.d.ts.map +1 -0
  114. package/dist/wagmi/Connector.js +238 -0
  115. package/dist/wagmi/Connector.js.map +1 -0
  116. package/dist/wagmi/index.d.ts +3 -0
  117. package/dist/wagmi/index.d.ts.map +1 -0
  118. package/dist/wagmi/index.js +3 -0
  119. package/dist/wagmi/index.js.map +1 -0
  120. package/package.json +56 -2
  121. package/src/core/AccessKey.test.ts +257 -0
  122. package/src/core/AccessKey.ts +123 -0
  123. package/src/core/Account.test.ts +309 -0
  124. package/src/core/Account.ts +152 -0
  125. package/src/core/Adapter.ts +238 -0
  126. package/src/core/Ceremony.browser.test.ts +239 -0
  127. package/src/core/Ceremony.test.ts +151 -0
  128. package/src/core/Ceremony.ts +203 -0
  129. package/src/core/Client.ts +36 -0
  130. package/src/core/Dialog.browser.test.ts +309 -0
  131. package/src/core/Dialog.test-d.ts +19 -0
  132. package/src/core/Dialog.ts +442 -0
  133. package/src/core/Expiry.ts +34 -0
  134. package/src/core/Messenger.ts +206 -0
  135. package/src/core/Provider.browser.test.ts +774 -0
  136. package/src/core/Provider.connect.browser.test.ts +415 -0
  137. package/src/core/Provider.test-d.ts +53 -0
  138. package/src/core/Provider.test.ts +1566 -0
  139. package/src/core/Provider.ts +559 -0
  140. package/src/core/Remote.ts +262 -0
  141. package/src/core/Schema.test-d.ts +211 -0
  142. package/src/core/Schema.ts +143 -0
  143. package/src/core/Storage.ts +213 -0
  144. package/src/core/Store.test.ts +287 -0
  145. package/src/core/Store.ts +129 -0
  146. package/src/core/adapters/dangerous_secp256k1.ts +53 -0
  147. package/src/core/adapters/dialog.ts +379 -0
  148. package/src/core/adapters/local.test.ts +97 -0
  149. package/src/core/adapters/local.ts +277 -0
  150. package/src/core/adapters/webAuthn.ts +129 -0
  151. package/src/core/internal/withDedupe.test.ts +116 -0
  152. package/src/core/internal/withDedupe.ts +20 -0
  153. package/src/core/mppx.test.ts +83 -0
  154. package/src/core/zod/request.test.ts +121 -0
  155. package/src/core/zod/request.ts +70 -0
  156. package/src/core/zod/rpc.ts +374 -0
  157. package/src/core/zod/utils.test.ts +69 -0
  158. package/src/core/zod/utils.ts +40 -0
  159. package/src/index.ts +14 -0
  160. package/src/internal/types.ts +378 -0
  161. package/src/server/Handler.test.ts +1014 -0
  162. package/src/server/Handler.ts +605 -0
  163. package/src/server/Kv.ts +46 -0
  164. package/src/server/index.ts +2 -0
  165. package/src/server/internal/requestListener.ts +273 -0
  166. package/src/tsconfig.json +9 -0
  167. package/src/wagmi/Connector.ts +287 -0
  168. package/src/wagmi/index.ts +2 -0
@@ -0,0 +1,605 @@
1
+ import {
2
+ createRouter,
3
+ type Middleware,
4
+ type Router,
5
+ type RouterOptions,
6
+ } from '@remix-run/fetch-router'
7
+ import { Base64, Bytes, Hex, RpcRequest, RpcResponse } from 'ox'
8
+ import { Credential } from 'ox/webauthn'
9
+ import { type Chain, type Client, createClient, http, type Transport } from 'viem'
10
+ import type { LocalAccount } from 'viem/accounts'
11
+ import { signTransaction } from 'viem/actions'
12
+ import { tempo, tempoModerato } from 'viem/chains'
13
+ import { Transaction } from 'viem/tempo'
14
+ import {
15
+ Authentication,
16
+ Registration,
17
+ type Registration as Registration_Types,
18
+ } from 'webauthx/server'
19
+
20
+ import * as RequestListener from './internal/requestListener.js'
21
+ import type { Kv } from './Kv.js'
22
+
23
+ export type Handler = Omit<Router, 'fetch'> & {
24
+ fetch: (input: string | URL | Request, ...args: any[]) => Promise<Response>
25
+ listener: (req: any, res: any) => void
26
+ }
27
+
28
+ export function compose(handlers: Handler[], options: compose.Options = {}): Handler {
29
+ const path = options.path ?? '/'
30
+
31
+ return from({
32
+ ...options,
33
+ async defaultHandler(context) {
34
+ const url = new URL(context.request.url)
35
+ if (!url.pathname.startsWith(path)) return new Response('Not Found', { status: 404 })
36
+
37
+ url.pathname = url.pathname.replace(path, '')
38
+ for (const handler of handlers) {
39
+ const request = new Request(url, context.request.clone() as RequestInit)
40
+ const response = await handler.fetch(request)
41
+ if (response.status !== 404) return response
42
+ }
43
+ return new Response('Not Found', { status: 404 })
44
+ },
45
+ })
46
+ }
47
+
48
+ export declare namespace compose {
49
+ export type Options = from.Options & {
50
+ /** The path to use for the handler. */
51
+ path?: string | undefined
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Instantiates a new request handler.
57
+ *
58
+ * @param options - constructor options
59
+ * @returns Handler instance
60
+ */
61
+ export function from(options: from.Options = {}): Handler {
62
+ const corsHeaders = corsToHeaders(options.cors)
63
+ const mergedHeaders = new Headers(corsHeaders)
64
+ for (const [key, value] of normalizeHeaders(options.headers).entries())
65
+ mergedHeaders.set(key, value)
66
+
67
+ const router = createRouter({
68
+ ...options,
69
+ middleware: [headers(mergedHeaders), preflight(mergedHeaders)],
70
+ })
71
+
72
+ return {
73
+ ...router,
74
+ listener: RequestListener.fromFetchHandler((request) => {
75
+ return router.fetch(request)
76
+ }),
77
+ }
78
+ }
79
+
80
+ export declare namespace from {
81
+ export type Options = RouterOptions & {
82
+ /**
83
+ * CORS configuration.
84
+ * - `true` (default): Allow all origins with default methods/headers
85
+ * - `false`: Disable CORS headers
86
+ * - Object: Custom CORS configuration
87
+ */
88
+ cors?: boolean | Cors | undefined
89
+ /** Headers to add to the response. */
90
+ headers?: Headers | Record<string, string> | undefined
91
+ }
92
+
93
+ export type Cors = {
94
+ /** Allowed origins. Defaults to `'*'`. */
95
+ origin?: string | string[] | undefined
96
+ /** Allowed methods. Defaults to `'GET, POST, PUT, DELETE, OPTIONS'`. */
97
+ methods?: string | undefined
98
+ /** Allowed headers. Defaults to `'Content-Type'`. */
99
+ headers?: string | undefined
100
+ /** Whether to allow credentials. */
101
+ credentials?: boolean | undefined
102
+ /** Headers to expose to the browser. */
103
+ exposeHeaders?: string | undefined
104
+ /** Max age for preflight cache in seconds. */
105
+ maxAge?: number | undefined
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Instantiates a fee payer service request handler that can be used to
111
+ * sponsor the fee for user transactions.
112
+ *
113
+ * @example
114
+ * ### Cloudflare Worker
115
+ *
116
+ * ```ts
117
+ * import { privateKeyToAccount } from 'viem/accounts'
118
+ * import { Handler } from 'accounts/server'
119
+ *
120
+ * export default {
121
+ * fetch(request) {
122
+ * return Handler.feePayer({
123
+ * account: privateKeyToAccount('0x...'),
124
+ * }).fetch(request)
125
+ * }
126
+ * }
127
+ * ```
128
+ *
129
+ * @example
130
+ * ### Next.js
131
+ *
132
+ * ```ts
133
+ * import { privateKeyToAccount } from 'viem/accounts'
134
+ * import { Handler } from 'accounts/server'
135
+ *
136
+ * const handler = Handler.feePayer({
137
+ * account: privateKeyToAccount('0x...'),
138
+ * })
139
+ *
140
+ * export GET = handler.fetch
141
+ * export POST = handler.fetch
142
+ * ```
143
+ *
144
+ * @example
145
+ * ### Hono
146
+ *
147
+ * ```ts
148
+ * import { privateKeyToAccount } from 'viem/accounts'
149
+ * import { Handler } from 'accounts/server'
150
+ *
151
+ * const handler = Handler.feePayer({
152
+ * account: privateKeyToAccount('0x...'),
153
+ * })
154
+ *
155
+ * const app = new Hono()
156
+ * app.all('*', handler)
157
+ *
158
+ * export default app
159
+ * ```
160
+ *
161
+ * @example
162
+ * ### Node.js
163
+ *
164
+ * ```ts
165
+ * import { privateKeyToAccount } from 'viem/accounts'
166
+ * import { Handler } from 'accounts/server'
167
+ *
168
+ * const handler = Handler.feePayer({
169
+ * account: privateKeyToAccount('0x...'),
170
+ * })
171
+ *
172
+ * const server = createServer(handler.listener)
173
+ * server.listen(3000)
174
+ * ```
175
+ *
176
+ * @example
177
+ * ### Bun
178
+ *
179
+ * ```ts
180
+ * import { privateKeyToAccount } from 'viem/accounts'
181
+ * import { Handler } from 'accounts/server'
182
+ *
183
+ * const handler = Handler.feePayer({
184
+ * account: privateKeyToAccount('0x...'),
185
+ * })
186
+ *
187
+ * Bun.serve(handler)
188
+ * ```
189
+ *
190
+ * @example
191
+ * ### Deno
192
+ *
193
+ * ```ts
194
+ * import { privateKeyToAccount } from 'viem/accounts'
195
+ * import { Handler } from 'accounts/server'
196
+ *
197
+ * const handler = Handler.feePayer({
198
+ * account: privateKeyToAccount('0x...'),
199
+ * })
200
+ *
201
+ * Deno.serve(handler)
202
+ * ```
203
+ *
204
+ * @example
205
+ * ### Express
206
+ *
207
+ * ```ts
208
+ * import { privateKeyToAccount } from 'viem/accounts'
209
+ * import { Handler } from 'accounts/server'
210
+ *
211
+ * const handler = Handler.feePayer({
212
+ * account: privateKeyToAccount('0x...'),
213
+ * })
214
+ *
215
+ * const app = express()
216
+ * app.use(handler.listener)
217
+ * app.listen(3000)
218
+ * ```
219
+ *
220
+ * @example
221
+ * ### Custom chains & transports
222
+ *
223
+ * ```ts
224
+ * import { http } from 'viem'
225
+ * import { privateKeyToAccount } from 'viem/accounts'
226
+ * import { tempo, tempoModerato } from 'viem/chains'
227
+ * import { Handler } from 'accounts/server'
228
+ *
229
+ * const handler = Handler.feePayer({
230
+ * account: privateKeyToAccount('0x...'),
231
+ * chains: [tempo, tempoModerato],
232
+ * transports: {
233
+ * [tempo.id]: http('https://rpc.tempo.xyz'),
234
+ * [tempoModerato.id]: http('https://rpc.moderato.tempo.xyz'),
235
+ * },
236
+ * })
237
+ * ```
238
+ *
239
+ * @param options - Options.
240
+ * @returns Request handler.
241
+ */
242
+ export function feePayer(options: feePayer.Options) {
243
+ const {
244
+ account,
245
+ chains = [tempo, tempoModerato],
246
+ onRequest,
247
+ path = '/',
248
+ transports = {},
249
+ } = options
250
+
251
+ const clients = new Map<number, Client>()
252
+ for (const chain of chains) {
253
+ const transport = transports[chain.id] ?? http()
254
+ clients.set(chain.id, createClient({ chain, transport }))
255
+ }
256
+
257
+ function getClient(chainId?: number): Client {
258
+ if (chainId) {
259
+ const client = clients.get(chainId)
260
+ if (!client) throw new Error(`Chain ${chainId} not configured`)
261
+ return client
262
+ }
263
+ return clients.get(chains[0]!.id)!
264
+ }
265
+
266
+ const router = from(options)
267
+
268
+ router.post(path, async ({ request: req }) => {
269
+ const request = RpcRequest.from((await req.json()) as any)
270
+
271
+ try {
272
+ await onRequest?.(request)
273
+
274
+ const method = request.method as string
275
+ if (
276
+ method !== 'eth_signRawTransaction' &&
277
+ method !== 'eth_sendRawTransaction' &&
278
+ method !== 'eth_sendRawTransactionSync'
279
+ )
280
+ return Response.json(
281
+ RpcResponse.from(
282
+ {
283
+ error: new RpcResponse.MethodNotSupportedError({
284
+ message: `Method not supported: ${request.method}`,
285
+ }),
286
+ },
287
+ { request },
288
+ ),
289
+ )
290
+
291
+ const serialized = request.params?.[0] as `0x76${string}`
292
+
293
+ if (!serialized?.startsWith('0x76') && !serialized?.startsWith('0x78'))
294
+ throw new RpcResponse.InvalidParamsError({
295
+ message: 'Only Tempo (0x76/0x78) transactions are supported.',
296
+ })
297
+
298
+ const transaction = Transaction.deserialize(serialized) as any
299
+
300
+ if (!transaction.signature || !transaction.from)
301
+ throw new RpcResponse.InvalidParamsError({
302
+ message: 'Transaction must be signed by the sender before fee payer signing.',
303
+ })
304
+
305
+ const client = getClient(transaction.chainId)
306
+ const serializedTransaction = await signTransaction(client, {
307
+ ...transaction,
308
+ account,
309
+ feePayer: account,
310
+ })
311
+
312
+ if (method === 'eth_signRawTransaction')
313
+ return Response.json(RpcResponse.from({ result: serializedTransaction }, { request }))
314
+
315
+ const result = await (client as any).request({
316
+ method,
317
+ params: [serializedTransaction],
318
+ })
319
+
320
+ return Response.json(RpcResponse.from({ result }, { request }))
321
+ } catch (error) {
322
+ return Response.json(
323
+ RpcResponse.from(
324
+ {
325
+ error: new RpcResponse.InternalError({
326
+ message: (error as Error).message,
327
+ }),
328
+ },
329
+ { request },
330
+ ),
331
+ )
332
+ }
333
+ })
334
+
335
+ return router
336
+ }
337
+
338
+ export declare namespace feePayer {
339
+ export type Options = from.Options & {
340
+ /** Account to use as the fee payer. */
341
+ account: LocalAccount
342
+ /**
343
+ * Supported chains. The handler resolves the client based on the
344
+ * `chainId` in the incoming transaction.
345
+ * @default [tempo, tempoModerato]
346
+ */
347
+ chains?: readonly [Chain, ...Chain[]] | undefined
348
+ /** Function to call before handling the request. */
349
+ onRequest?: (request: RpcRequest.RpcRequest) => Promise<void>
350
+ /** Path to use for the handler. */
351
+ path?: string | undefined
352
+ /** Transports keyed by chain ID. Defaults to `http()` for each chain. */
353
+ transports?: Record<number, Transport> | undefined
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Instantiates a WebAuthn ceremony handler that manages registration and
359
+ * authentication flows server-side.
360
+ *
361
+ * Exposes 4 POST endpoints following the webauthx convention:
362
+ * - `POST /register/options` — generate credential creation options
363
+ * - `POST /register` — verify registration and store credential
364
+ * - `POST /login/options` — generate credential request options
365
+ * - `POST /login` — verify authentication
366
+ *
367
+ * @example
368
+ * ```ts
369
+ * import { Handler, Kv } from 'accounts/server'
370
+ *
371
+ * const handler = Handler.webauthn({
372
+ * kv: Kv.memory(),
373
+ * origin: 'https://example.com',
374
+ * rpId: 'example.com',
375
+ * })
376
+ *
377
+ * export default handler
378
+ * ```
379
+ *
380
+ * @param options - Options.
381
+ * @returns Request handler.
382
+ */
383
+ export function webauthn(options: webauthn.Options): Handler {
384
+ const { challengeTtl = 300, kv, onAuthenticate, onRegister, path = '', rpId, ...rest } = options
385
+ const origin = options.origin as string | string[]
386
+
387
+ const router = from(rest)
388
+
389
+ router.post(`${path}/register/options`, async ({ request: req }) => {
390
+ try {
391
+ const body = await req.json()
392
+ const { excludeCredentialIds, name, userId } = body as {
393
+ excludeCredentialIds?: string[]
394
+ name: string
395
+ userId?: string
396
+ }
397
+
398
+ const { challenge, options } = Registration.getOptions({
399
+ excludeCredentialIds,
400
+ name,
401
+ rp: { id: rpId, name: rpId },
402
+ ...(userId ? { user: { id: new TextEncoder().encode(userId), name } } : undefined),
403
+ })
404
+
405
+ await kv.set(`challenge:${challenge}`, Date.now())
406
+
407
+ return Response.json({ options })
408
+ } catch (error) {
409
+ return Response.json({ error: (error as Error).message }, { status: 400 })
410
+ }
411
+ })
412
+
413
+ router.post(`${path}/register`, async ({ request: req }) => {
414
+ try {
415
+ const credential = (await req.json()) as Registration_Types.Credential
416
+ const deserialized = Credential.deserialize(credential)
417
+
418
+ const clientData = JSON.parse(
419
+ Bytes.toString(new Uint8Array(deserialized.clientDataJSON)),
420
+ ) as { challenge: string }
421
+ const challenge = Hex.fromBytes(Base64.toBytes(clientData.challenge))
422
+ const stored = await kv.get<number>(`challenge:${challenge}`)
423
+ if (!stored || Date.now() - stored > challengeTtl * 1_000)
424
+ throw new Error('Missing or expired challenge')
425
+ await kv.delete(`challenge:${challenge}`)
426
+
427
+ const result = Registration.verify(credential, {
428
+ challenge,
429
+ origin,
430
+ rpId,
431
+ })
432
+
433
+ const { publicKey } = result.credential
434
+ const credentialId = credential.id
435
+
436
+ await kv.set(`credential:${credentialId}`, { publicKey })
437
+
438
+ const json = { credentialId, publicKey }
439
+ const hook = await onRegister?.({ credentialId, publicKey, request: req })
440
+ return mergeResponse(json, hook)
441
+ } catch (error) {
442
+ return Response.json({ error: (error as Error).message }, { status: 400 })
443
+ }
444
+ })
445
+
446
+ router.post(`${path}/login/options`, async ({ request: req }) => {
447
+ try {
448
+ const body = await req.json()
449
+ const {
450
+ allowCredentialIds,
451
+ challenge: requestChallenge,
452
+ credentialId,
453
+ mediation,
454
+ } = body as {
455
+ allowCredentialIds?: string[]
456
+ challenge?: Hex.Hex
457
+ credentialId?: string
458
+ mediation?: string
459
+ }
460
+
461
+ const { challenge, options: authOptions } = Authentication.getOptions({
462
+ challenge: requestChallenge,
463
+ credentialId: allowCredentialIds ?? credentialId,
464
+ rpId,
465
+ })
466
+ const options = mediation ? { ...authOptions, mediation } : authOptions
467
+
468
+ await kv.set(`challenge:${challenge}`, Date.now())
469
+
470
+ return Response.json({ options })
471
+ } catch (error) {
472
+ return Response.json({ error: (error as Error).message }, { status: 400 })
473
+ }
474
+ })
475
+
476
+ router.post(`${path}/login`, async ({ request: req }) => {
477
+ try {
478
+ const response = (await req.json()) as Authentication.Response
479
+
480
+ const clientData = JSON.parse(response.metadata.clientDataJSON) as {
481
+ challenge: string
482
+ }
483
+ const challenge = Hex.fromBytes(Base64.toBytes(clientData.challenge))
484
+ const stored = await kv.get<number>(`challenge:${challenge}`)
485
+ if (!stored || Date.now() - stored > challengeTtl * 1_000)
486
+ throw new Error('Missing or expired challenge')
487
+ await kv.delete(`challenge:${challenge}`)
488
+
489
+ const credentialData = await kv.get<{ publicKey: string }>(`credential:${response.id}`)
490
+ if (!credentialData) throw new Error('Unknown credential')
491
+
492
+ const valid = Authentication.verify(response, {
493
+ challenge,
494
+ origin,
495
+ publicKey: credentialData.publicKey as `0x${string}`,
496
+ rpId,
497
+ })
498
+ if (!valid) throw new Error('Authentication failed')
499
+
500
+ const rawResponse = response.raw?.response as unknown as Record<string, string> | undefined
501
+ const userHandle = rawResponse?.userHandle
502
+
503
+ const json = {
504
+ credentialId: response.id,
505
+ publicKey: credentialData.publicKey,
506
+ ...(userHandle && userHandle.length > 0 ? { userId: userHandle } : undefined),
507
+ }
508
+ const hook = await onAuthenticate?.({ ...json, request: req })
509
+ return mergeResponse(json, hook)
510
+ } catch (error) {
511
+ return Response.json({ error: (error as Error).message }, { status: 400 })
512
+ }
513
+ })
514
+
515
+ return router
516
+ }
517
+
518
+ export declare namespace webauthn {
519
+ type Options = from.Options & {
520
+ /** Maximum age of a challenge in seconds before it expires. @default 300 */
521
+ challengeTtl?: number | undefined
522
+ /** Key-value store for challenges and credentials. */
523
+ kv: Kv
524
+ /** Called after a successful registration. The returned response is merged onto the default JSON response. */
525
+ onRegister?: (parameters: {
526
+ credentialId: string
527
+ publicKey: string
528
+ request: Request
529
+ }) => Response | Promise<Response> | void | Promise<void>
530
+ /** Called after a successful authentication. The returned response is merged onto the default JSON response. */
531
+ onAuthenticate?: (parameters: {
532
+ credentialId: string
533
+ publicKey: string
534
+ userId?: string | undefined
535
+ request: Request
536
+ }) => Response | Promise<Response> | void | Promise<void>
537
+ /** Expected origin(s) (e.g. `"https://example.com"` or `["https://a.com", "https://b.com"]`). */
538
+ origin: string | readonly string[]
539
+ /** Path prefix for the WebAuthn endpoints (e.g. `"/webauthn"`). @default "" */
540
+ path?: string | undefined
541
+ /** Relying Party ID (e.g. `"example.com"`). */
542
+ rpId: string
543
+ }
544
+ }
545
+
546
+ /** @internal */
547
+ async function mergeResponse(
548
+ json: Record<string, unknown>,
549
+ hook?: Response | void,
550
+ ): Promise<Response> {
551
+ if (!hook) return Response.json(json)
552
+ const extra = (await hook.json().catch(() => ({}))) as Record<string, unknown>
553
+ const headers = new Headers(hook.headers)
554
+ headers.set('content-type', 'application/json')
555
+ return new Response(JSON.stringify({ ...json, ...extra }), {
556
+ headers,
557
+ status: hook.status,
558
+ })
559
+ }
560
+
561
+ /** @internal */
562
+ function normalizeHeaders(headers?: Headers | Record<string, string>): Headers {
563
+ if (!headers) return new Headers()
564
+ if (headers instanceof Headers) return headers
565
+ return new Headers(headers)
566
+ }
567
+
568
+ /** @internal */
569
+ function corsToHeaders(cors?: boolean | from.Cors): Headers {
570
+ if (cors === false) return new Headers()
571
+
572
+ const config = cors === true || cors === undefined ? {} : cors
573
+
574
+ const headers = new Headers()
575
+ const origin = Array.isArray(config.origin) ? config.origin.join(', ') : (config.origin ?? '*')
576
+ headers.set('Access-Control-Allow-Origin', origin)
577
+ headers.set('Access-Control-Allow-Methods', config.methods ?? 'GET, POST, PUT, DELETE, OPTIONS')
578
+ headers.set('Access-Control-Allow-Headers', config.headers ?? 'Content-Type')
579
+ if (config.credentials) headers.set('Access-Control-Allow-Credentials', 'true')
580
+ if (config.exposeHeaders) headers.set('Access-Control-Expose-Headers', config.exposeHeaders)
581
+ if (config.maxAge !== undefined) headers.set('Access-Control-Max-Age', String(config.maxAge))
582
+
583
+ return headers
584
+ }
585
+
586
+ /** @internal */
587
+ function headers(headers: Headers): Middleware {
588
+ return async (_, next) => {
589
+ const response = await next()
590
+ const responseHeaders = new Headers(response.headers)
591
+ for (const [key, value] of headers.entries()) responseHeaders.set(key, value)
592
+ return new Response(response.body, {
593
+ headers: responseHeaders,
594
+ status: response.status,
595
+ statusText: response.statusText,
596
+ })
597
+ }
598
+ }
599
+
600
+ /** @internal */
601
+ function preflight(headers: Headers): Middleware {
602
+ return async (context) => {
603
+ if (context.request.method === 'OPTIONS') return new Response(null, { headers })
604
+ }
605
+ }
@@ -0,0 +1,46 @@
1
+ import { Json } from 'ox'
2
+
3
+ export type Kv = {
4
+ get: <value = unknown>(key: string) => Promise<value>
5
+ set: (key: string, value: unknown) => Promise<void>
6
+ delete: (key: string) => Promise<void>
7
+ }
8
+
9
+ export function from<kv extends Kv>(kv: kv): kv {
10
+ return kv
11
+ }
12
+
13
+ export function cloudflare(kv: cloudflare.Parameters): Kv {
14
+ return from({
15
+ delete: kv.delete.bind(kv),
16
+ async get(key) {
17
+ return kv.get(key, 'json')
18
+ },
19
+ async set(key, value) {
20
+ return kv.put(key, Json.stringify(value))
21
+ },
22
+ })
23
+ }
24
+
25
+ export declare namespace cloudflare {
26
+ export type Parameters = {
27
+ get: <value = unknown>(key: string, format: 'json') => Promise<value>
28
+ put: (key: string, value: string) => Promise<void>
29
+ delete: (key: string) => Promise<void>
30
+ }
31
+ }
32
+
33
+ export function memory(): Kv {
34
+ const store = new Map<string, unknown>()
35
+ return from({
36
+ async delete(key) {
37
+ Promise.resolve(store.delete(key))
38
+ },
39
+ async get(key) {
40
+ return store.get(key) as any
41
+ },
42
+ async set(key, value) {
43
+ store.set(key, value)
44
+ },
45
+ })
46
+ }
@@ -0,0 +1,2 @@
1
+ export * as Handler from './Handler.js'
2
+ export * as Kv from './Kv.js'