accounts 0.16.2 → 0.17.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 (65) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +3 -4
  3. package/dist/cli/Provider.d.ts +1 -1
  4. package/dist/cli/Provider.d.ts.map +1 -1
  5. package/dist/cli/Provider.js +3 -3
  6. package/dist/cli/Provider.js.map +1 -1
  7. package/dist/cli/adapter.d.ts +13 -8
  8. package/dist/cli/adapter.d.ts.map +1 -1
  9. package/dist/cli/adapter.js +92 -303
  10. package/dist/cli/adapter.js.map +1 -1
  11. package/dist/core/adapters/deviceCode/deviceCode.d.ts +59 -0
  12. package/dist/core/adapters/deviceCode/deviceCode.d.ts.map +1 -0
  13. package/dist/core/adapters/deviceCode/deviceCode.js +93 -0
  14. package/dist/core/adapters/deviceCode/deviceCode.js.map +1 -0
  15. package/dist/core/adapters/deviceCode/index.d.ts +3 -0
  16. package/dist/core/adapters/deviceCode/index.d.ts.map +1 -0
  17. package/dist/core/adapters/deviceCode/index.js +3 -0
  18. package/dist/core/adapters/deviceCode/index.js.map +1 -0
  19. package/dist/core/adapters/deviceCode/tempoWallet.d.ts +9 -0
  20. package/dist/core/adapters/deviceCode/tempoWallet.d.ts.map +1 -0
  21. package/dist/core/adapters/deviceCode/tempoWallet.js +11 -0
  22. package/dist/core/adapters/deviceCode/tempoWallet.js.map +1 -0
  23. package/dist/core/adapters/internal/fromRequest.d.ts +10 -0
  24. package/dist/core/adapters/internal/fromRequest.d.ts.map +1 -1
  25. package/dist/core/adapters/internal/fromRequest.js +4 -2
  26. package/dist/core/adapters/internal/fromRequest.js.map +1 -1
  27. package/dist/server/CliAuth.d.ts +69 -1
  28. package/dist/server/CliAuth.d.ts.map +1 -1
  29. package/dist/server/CliAuth.js +6 -1
  30. package/dist/server/CliAuth.js.map +1 -1
  31. package/dist/server/Handler.d.ts +2 -1
  32. package/dist/server/Handler.d.ts.map +1 -1
  33. package/dist/server/Handler.js +2 -1
  34. package/dist/server/Handler.js.map +1 -1
  35. package/dist/server/index.d.ts +1 -0
  36. package/dist/server/index.d.ts.map +1 -1
  37. package/dist/server/index.js +1 -0
  38. package/dist/server/index.js.map +1 -1
  39. package/dist/server/internal/handlers/codeAuth.d.ts +1 -0
  40. package/dist/server/internal/handlers/codeAuth.d.ts.map +1 -1
  41. package/dist/server/internal/handlers/codeAuth.js +1 -0
  42. package/dist/server/internal/handlers/codeAuth.js.map +1 -1
  43. package/dist/server/internal/handlers/deviceCode.d.ts +60 -0
  44. package/dist/server/internal/handlers/deviceCode.d.ts.map +1 -0
  45. package/dist/server/internal/handlers/deviceCode.js +218 -0
  46. package/dist/server/internal/handlers/deviceCode.js.map +1 -0
  47. package/package.json +7 -1
  48. package/src/cli/Provider.localnet.test.ts +414 -470
  49. package/src/cli/Provider.test-d.ts +4 -3
  50. package/src/cli/Provider.ts +6 -6
  51. package/src/cli/adapter.ts +111 -380
  52. package/src/core/adapters/deviceCode/deviceCode.localnet.test.ts +226 -0
  53. package/src/core/adapters/deviceCode/deviceCode.test-d.ts +19 -0
  54. package/src/core/adapters/deviceCode/deviceCode.ts +157 -0
  55. package/src/core/adapters/deviceCode/index.ts +2 -0
  56. package/src/core/adapters/deviceCode/tempoWallet.ts +17 -0
  57. package/src/core/adapters/internal/fromRequest.ts +115 -88
  58. package/src/server/CliAuth.test.ts +29 -18
  59. package/src/server/CliAuth.ts +10 -2
  60. package/src/server/Handler.test-d.ts +19 -33
  61. package/src/server/Handler.ts +2 -1
  62. package/src/server/index.ts +1 -0
  63. package/src/server/internal/handlers/codeAuth.ts +1 -0
  64. package/src/server/internal/handlers/deviceCode.test.ts +140 -0
  65. package/src/server/internal/handlers/deviceCode.ts +323 -0
@@ -1,4 +1,4 @@
1
- import { Base64 } from 'ox'
1
+ import { Base64, Hex } from 'ox'
2
2
  import { KeyAuthorization } from 'ox/tempo'
3
3
  import { createClient, custom, encodeErrorResult, encodeFunctionResult } from 'viem'
4
4
  import { Abis, Account as TempoAccount } from 'viem/tempo'
@@ -23,6 +23,22 @@ const limits = [
23
23
  },
24
24
  ] as const
25
25
 
26
+ describe('keyAuthorization', () => {
27
+ test('behavior: preserves spending limit periods', () => {
28
+ const authorization = z.decode(CliAuth.keyAuthorization, {
29
+ address: accessKey.address,
30
+ chainId: '0xa5bf',
31
+ expiry: Hex.fromNumber(expiry),
32
+ keyId: accessKey.address,
33
+ keyType: accessKey.keyType,
34
+ limits: [{ limit: '0x3e8', period: Hex.fromNumber(86_400), token }],
35
+ signature: { r: '0x1', s: '0x2', type: 'secp256k1', yParity: '0x0' },
36
+ })
37
+
38
+ expect(authorization.limits).toEqual([{ limit, period: 86_400, token }])
39
+ })
40
+ })
41
+
26
42
  function createMissingAccessKeyClient() {
27
43
  return createClient({
28
44
  chain,
@@ -744,23 +760,18 @@ Received 2 bytes.]`)
744
760
  limits: Array.from({ length: 11 }, () => item),
745
761
  })
746
762
 
747
- expect(result).toMatchInlineSnapshot(`
748
- {
749
- "error": [$ZodError: [
750
- {
751
- "origin": "array",
752
- "code": "too_big",
753
- "maximum": 10,
754
- "inclusive": true,
755
- "path": [
756
- "limits"
757
- ],
758
- "message": "Invalid input"
759
- }
760
- ]],
761
- "success": false,
762
- }
763
- `)
763
+ expect(result.success).toBe(false)
764
+ if (result.success) return
765
+ expect(result.error.issues).toEqual([
766
+ {
767
+ code: 'too_big',
768
+ inclusive: true,
769
+ maximum: 10,
770
+ message: 'Invalid input',
771
+ origin: 'array',
772
+ path: ['limits'],
773
+ },
774
+ ])
764
775
  })
765
776
 
766
777
  test('behavior: rejects malformed limit tokens', async () => {
@@ -12,7 +12,11 @@ import type { MaybePromise } from '../internal/types.js'
12
12
  import type { Kv } from './Kv.js'
13
13
 
14
14
  const maxLimits = 10
15
- const limit = z.object({ token: u.address(), limit: u.bigint() })
15
+ const limit = z.object({
16
+ token: u.address(),
17
+ limit: u.bigint(),
18
+ period: z.optional(u.number()),
19
+ })
16
20
  const limits = z.readonly(z.array(limit).check(z.maxLength(maxLimits)))
17
21
  const showDeposit = z.optional(
18
22
  z.union([
@@ -249,7 +253,10 @@ export const entry = u.oneOf([
249
253
  }),
250
254
  ])
251
255
 
252
- /** Shared CLI auth helper with pre-bound defaults and cached clients. */
256
+ /**
257
+ * Shared CLI auth helper with pre-bound defaults and cached clients.
258
+ * @deprecated Use `Handler.deviceCode` with the Wata device-code transport.
259
+ */
253
260
  export type CliAuth = {
254
261
  /** Creates and stores a new device code. */
255
262
  createDeviceCode: (options: createDeviceCode.Parameters) => Promise<createDeviceCode.ReturnType>
@@ -622,6 +629,7 @@ export const RateLimit = {
622
629
  * Instantiates a CLI auth helper with shared defaults and cached clients.
623
630
  *
624
631
  *
632
+ * @deprecated Use `Handler.deviceCode` with the Wata device-code transport.
625
633
  * @param {from.Options} options - Shared CLI auth defaults.
626
634
  * @returns {CliAuth} CLI auth helper.
627
635
  *
@@ -1,42 +1,28 @@
1
1
  import { hc } from 'hono/client'
2
2
  import type { ExtractSchema } from 'hono/types'
3
- import { http, type Chain, type Transport } from 'viem'
4
- import { tempo, tempoModerato } from 'viem/tempo/chains'
5
3
  import { describe, expectTypeOf, test } from 'vp/test'
4
+ import { Store } from 'wata/host'
6
5
 
7
- import * as CliAuth from './CliAuth.js'
8
6
  import * as Handler from './Handler.js'
9
7
 
10
- describe('codeAuth options', () => {
11
- test('supports chain-agnostic chains/transports configuration', () => {
12
- expectTypeOf<Handler.codeAuth.Options>().toMatchTypeOf<{
13
- chains?: readonly [Chain, ...Chain[]] | undefined
14
- transports?: Record<number, Transport> | undefined
15
- }>()
16
- })
17
-
18
- test('accepts derived clients from chains/transports', () => {
19
- void Handler.codeAuth({
20
- chains: [tempo, tempoModerato],
21
- transports: {
22
- [tempo.id]: http('https://rpc.tempo.xyz'),
23
- [tempoModerato.id]: http('https://rpc.moderato.tempo.xyz'),
8
+ describe('deviceCode', () => {
9
+ test('accepts host deployment options', () => {
10
+ const handler = Handler.deviceCode({
11
+ baseUrl: (request) => new URL(request.url).origin,
12
+ html: {
13
+ render: () => new Response('verify'),
24
14
  },
25
- })
26
- })
27
-
28
- test('accepts a shared rate limiter', () => {
29
- void Handler.codeAuth({
30
- rateLimit: CliAuth.RateLimit.memory({ max: 120, windowMs: 60_000 }),
31
- })
32
- void Handler.codeAuth({
33
- rateLimit: false,
34
- })
35
- void Handler.codeAuth({
36
- rateLimitKey(request) {
37
- return request.headers.get('x-forwarded-for') ?? 'unknown'
15
+ store: Store.memory(),
16
+ validate(options) {
17
+ expectTypeOf(options.record).toEqualTypeOf<import('wata/host').DeviceCode.PendingRecord>()
18
+ expectTypeOf(options.request.id).toEqualTypeOf<string | number>()
19
+ expectTypeOf(options.result).toBeUnknown()
20
+ expectTypeOf(options.userCode).toBeString()
21
+ return undefined
38
22
  },
39
23
  })
24
+
25
+ expectTypeOf(handler).toMatchTypeOf<Handler.Handler>()
40
26
  })
41
27
  })
42
28
 
@@ -125,11 +111,11 @@ describe('compose', () => {
125
111
  })
126
112
 
127
113
  test('handlers without a route schema (legacy `Handler`) compose without errors', () => {
128
- // `Handler.codeAuth` returns plain `Handler` (default `Hono`), no route
114
+ // `Handler.from` returns plain `Handler` (default `Hono`), no route
129
115
  // schema. Mixing it with typed handlers must not break inference for the
130
116
  // typed siblings.
131
- const codeAuth = Handler.codeAuth() as unknown as Handler.Handler
132
- const composed = Handler.compose([codeAuth, makeAlpha()], { path: '/api' })
117
+ const plain = Handler.from() as unknown as Handler.Handler
118
+ const composed = Handler.compose([plain, makeAlpha()], { path: '/api' })
133
119
  const client = hc<typeof composed>('http://localhost')
134
120
 
135
121
  expectTypeOf(client.api.alpha.ping.$post).toBeFunction()
@@ -7,6 +7,7 @@ import * as RequestListener from './internal/requestListener.js'
7
7
  export { auth } from './internal/handlers/auth.js'
8
8
  export { type SessionRequest } from './internal/handlers/session.js'
9
9
  export { codeAuth } from './internal/handlers/codeAuth.js'
10
+ export { deviceCode } from './internal/handlers/deviceCode.js'
10
11
  export { exchange } from './internal/handlers/exchange.js'
11
12
  export { oidcProvider } from './internal/handlers/oidcProvider.js'
12
13
  export { relay } from './internal/handlers/relay.js'
@@ -51,7 +52,7 @@ export type ComposedSchema<subs extends readonly unknown[], path extends string>
51
52
  * import { hc } from 'hono/client'
52
53
  *
53
54
  * const app = Handler.compose(
54
- * [Handler.exchange(), Handler.codeAuth({ store })],
55
+ * [Handler.exchange(), Handler.relay()],
55
56
  * { path: '/api' },
56
57
  * )
57
58
  * type App = typeof app
@@ -1,5 +1,6 @@
1
1
  export { hc } from 'hono/client'
2
2
 
3
+ /** @deprecated Use `Handler.deviceCode` with the Wata device-code transport. */
3
4
  export * as CliAuth from './CliAuth.js'
4
5
  export * as Handler from './Handler.js'
5
6
  export * as Identity from './Identity.js'
@@ -15,6 +15,7 @@ import { type Handler, from } from '../../Handler.js'
15
15
  * - `POST /auth/pkce/poll/:code`
16
16
  * - `POST /auth/pkce`
17
17
  *
18
+ * @deprecated Use `Handler.deviceCode` with the Wata device-code transport.
18
19
  * @param {codeAuth.Options} options - Options.
19
20
  * @returns {Handler} Request handler.
20
21
  */
@@ -0,0 +1,140 @@
1
+ import { Base64, Bytes, Hash } from 'ox'
2
+ import { describe, expect, test, vi } from 'vp/test'
3
+
4
+ import { deviceCode } from './deviceCode.js'
5
+
6
+ const verifier = 'test-device-code-verifier-0123456789'
7
+
8
+ function createHandler(validate = vi.fn(() => undefined)) {
9
+ return {
10
+ handler: deviceCode({
11
+ html: { render: () => new Response('verify') },
12
+ pollingInterval: 1,
13
+ validate,
14
+ }),
15
+ validate,
16
+ }
17
+ }
18
+
19
+ async function register(handler: ReturnType<typeof deviceCode>) {
20
+ const response = await handler.fetch(
21
+ new Request('https://wallet.example.com/auth/device/register', {
22
+ body: JSON.stringify({
23
+ code_challenge: Base64.fromBytes(Hash.sha256(Bytes.fromString(verifier), { as: 'Bytes' }), {
24
+ pad: false,
25
+ url: true,
26
+ }),
27
+ code_challenge_method: 'S256',
28
+ message: {
29
+ payload: [{ id: 1, jsonrpc: '2.0', method: 'wallet_connect', params: [] }],
30
+ type: 'rpc-requests',
31
+ },
32
+ meta: { name: 'Test CLI' },
33
+ }),
34
+ headers: { 'content-type': 'application/json' },
35
+ method: 'POST',
36
+ }),
37
+ )
38
+ return (await response.json()) as { device_code: string; user_code: string }
39
+ }
40
+
41
+ async function verify(
42
+ handler: ReturnType<typeof deviceCode>,
43
+ userCode: string,
44
+ response:
45
+ | { error: { code: number; data?: unknown; message: string }; id: number }
46
+ | {
47
+ id: number
48
+ result: unknown
49
+ },
50
+ ) {
51
+ return await handler.fetch(
52
+ new Request('https://wallet.example.com/auth/device/verify', {
53
+ body: JSON.stringify({ action: 'approve', results: [response], user_code: userCode }),
54
+ headers: { 'content-type': 'application/json' },
55
+ method: 'POST',
56
+ }),
57
+ )
58
+ }
59
+
60
+ async function token(handler: ReturnType<typeof deviceCode>, code: string) {
61
+ const response = await handler.fetch(
62
+ new Request('https://wallet.example.com/auth/device/token', {
63
+ body: JSON.stringify({
64
+ code_verifier: verifier,
65
+ device_code: code,
66
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
67
+ }),
68
+ headers: { 'content-type': 'application/json' },
69
+ method: 'POST',
70
+ }),
71
+ )
72
+ return { body: await response.json(), status: response.status }
73
+ }
74
+
75
+ describe('deviceCode', () => {
76
+ test('default: validates approved results before relaying them', async () => {
77
+ const { handler, validate } = createHandler()
78
+ const registered = await register(handler)
79
+
80
+ const approved = await verify(handler, registered.user_code, { id: 1, result: '0x1' })
81
+
82
+ expect(approved.status).toMatchInlineSnapshot(`200`)
83
+ expect(validate).toHaveBeenCalledWith(
84
+ expect.objectContaining({
85
+ request: expect.objectContaining({ id: 1, method: 'wallet_connect' }),
86
+ result: '0x1',
87
+ userCode: registered.user_code,
88
+ }),
89
+ )
90
+ await expect(token(handler, registered.device_code)).resolves.toMatchInlineSnapshot(`
91
+ {
92
+ "body": {
93
+ "payload": [
94
+ {
95
+ "id": 1,
96
+ "jsonrpc": "2.0",
97
+ "result": "0x1",
98
+ },
99
+ ],
100
+ "type": "rpc-responses",
101
+ },
102
+ "status": 200,
103
+ }
104
+ `)
105
+ })
106
+
107
+ test('behavior: relays submitted RPC errors', async () => {
108
+ const { handler, validate } = createHandler()
109
+ const registered = await register(handler)
110
+
111
+ const approved = await verify(handler, registered.user_code, {
112
+ error: { code: 4001, data: { reason: 'cancelled' }, message: 'Request rejected.' },
113
+ id: 1,
114
+ })
115
+
116
+ expect(approved.status).toMatchInlineSnapshot(`200`)
117
+ expect(validate).not.toHaveBeenCalled()
118
+ await expect(token(handler, registered.device_code)).resolves.toMatchInlineSnapshot(`
119
+ {
120
+ "body": {
121
+ "payload": [
122
+ {
123
+ "error": {
124
+ "code": 4001,
125
+ "data": {
126
+ "reason": "cancelled",
127
+ },
128
+ "message": "Request rejected.",
129
+ },
130
+ "id": 1,
131
+ "jsonrpc": "2.0",
132
+ },
133
+ ],
134
+ "type": "rpc-responses",
135
+ },
136
+ "status": 200,
137
+ }
138
+ `)
139
+ })
140
+ })
@@ -0,0 +1,323 @@
1
+ import { type DeviceCode, Store, Wata, deviceCode as core_deviceCode } from 'wata/host'
2
+ import * as z from 'zod/mini'
3
+
4
+ import { type Handler, from } from '../../Handler.js'
5
+
6
+ const maxVerifyBodyBytes = 65_536
7
+
8
+ const verifyRequest = z.object({
9
+ action: z.union([z.literal('approve'), z.literal('deny')]),
10
+ results: z.optional(
11
+ z.array(
12
+ z.union([
13
+ z.object({
14
+ error: z.object({
15
+ code: z.number(),
16
+ data: z.optional(z.json()),
17
+ message: z.string(),
18
+ }),
19
+ id: z.union([z.string(), z.number()]),
20
+ }),
21
+ z.object({ id: z.union([z.string(), z.number()]), result: z.json() }),
22
+ ]),
23
+ ),
24
+ ),
25
+ user_code: z.string(),
26
+ })
27
+
28
+ type PendingRequest = {
29
+ context?: { account?: string | undefined; chainId?: number | undefined } | undefined
30
+ id: string | number
31
+ method: string
32
+ params?: unknown
33
+ }
34
+
35
+ /**
36
+ * Instantiates a Wata device-code handler for wallet RPC requests.
37
+ *
38
+ * The handler implements the OAuth 2.0 Device Authorization Grant endpoints,
39
+ * accepts browser-submitted RPC results, and returns them to the polling
40
+ * consumer. The consuming host is responsible for deciding which methods
41
+ * its approval UI supports.
42
+ *
43
+ * @param options - Options.
44
+ * @returns Request handler.
45
+ */
46
+ export function deviceCode(options: deviceCode.Options): Handler {
47
+ const {
48
+ baseUrl,
49
+ expiresIn,
50
+ fetch,
51
+ html,
52
+ maxBodyBytes = maxVerifyBodyBytes,
53
+ path = '/auth/device',
54
+ pollingInterval,
55
+ store = Store.memory(),
56
+ validate,
57
+ ...rest
58
+ } = options
59
+
60
+ const router = from(rest)
61
+ router.all(`${path}/*`, async (c) => {
62
+ const request = c.req.raw
63
+ const origin = typeof baseUrl === 'function' ? baseUrl(request) : baseUrl
64
+ const results = new Map<
65
+ string | number,
66
+ { result: unknown } | { error: { code: number; data?: unknown; message: string } }
67
+ >()
68
+ let settle: ((error?: Error) => void) | undefined
69
+ const responded = new Promise<void>((resolve, reject) => {
70
+ settle = (error) => (error ? reject(error) : resolve())
71
+ })
72
+
73
+ const wata = Wata.create({
74
+ transports: [
75
+ core_deviceCode({
76
+ ...(origin ? { baseUrl: origin } : {}),
77
+ ...(expiresIn !== undefined ? { expiresIn } : {}),
78
+ ...(fetch !== undefined ? { fetch } : {}),
79
+ html: {
80
+ async authenticate({ actions, request }) {
81
+ const body = await readVerifyBody(request, maxBodyBytes)
82
+ if (!body.ok) return body.response
83
+ const { action, results: submitted, user_code } = body.value
84
+
85
+ const userCode = normalizeUserCode(user_code)
86
+ const record = await actions.get(userCode)
87
+ if (!record)
88
+ return Response.json(
89
+ { error: 'unknown_code', error_description: 'Unknown or expired device code.' },
90
+ { status: 404 },
91
+ )
92
+ if (record.status !== 'pending')
93
+ return Response.json(
94
+ { error: 'not_pending', error_description: 'Device code already completed.' },
95
+ { status: 409 },
96
+ )
97
+
98
+ if (action === 'deny') {
99
+ await actions.deny(userCode)
100
+ return Response.json({ status: 'denied' })
101
+ }
102
+
103
+ const requests = pendingRequests(record)
104
+ const [pending] = requests
105
+ if (requests.length !== 1 || !pending)
106
+ return Response.json(
107
+ {
108
+ error: 'invalid_request',
109
+ error_description: 'Expected one pending request.',
110
+ },
111
+ { status: 400 },
112
+ )
113
+
114
+ const response = submitted?.find((entry) => entry.id === pending.id)
115
+ if (!response)
116
+ return Response.json(
117
+ {
118
+ error: 'invalid_request',
119
+ error_description: 'Missing response for pending request.',
120
+ },
121
+ { status: 400 },
122
+ )
123
+
124
+ if ('result' in response) {
125
+ const validation = await validate({
126
+ record,
127
+ request: pending,
128
+ result: response.result,
129
+ userCode,
130
+ })
131
+ if (validation) return validation
132
+ }
133
+
134
+ results.set(pending.id, response)
135
+ await actions.approve(userCode)
136
+ await responded
137
+ return Response.json({ status: 'approved' })
138
+ },
139
+ render: html.render,
140
+ },
141
+ path,
142
+ ...(pollingInterval !== undefined ? { pollingInterval } : {}),
143
+ store: normalizeStore(store),
144
+ }),
145
+ ],
146
+ })
147
+
148
+ const session = wata.start()
149
+ session.onRequest(async (event) => {
150
+ const response = results.get(event.id)
151
+ try {
152
+ if (!response)
153
+ await event.reject({ code: -32603, message: 'No result supplied for request.' })
154
+ else if ('error' in response) await event.reject(response.error)
155
+ else await event.respond(response.result)
156
+ settle?.()
157
+ } catch (error) {
158
+ settle?.(error as Error)
159
+ }
160
+ })
161
+
162
+ return await normalizeRegisterResponse(await wata.fetch(request), request, path)
163
+ })
164
+
165
+ return router
166
+ }
167
+
168
+ export declare namespace deviceCode {
169
+ /** Options for {@link deviceCode}. */
170
+ export type Options = from.Options & {
171
+ /** Public host origin or a request-based origin resolver. */
172
+ baseUrl?: string | ((request: Request) => string) | undefined
173
+ /** Authorization intent lifetime in seconds. @default 600 */
174
+ expiresIn?: number | undefined
175
+ /** Discovery fetch implementation. */
176
+ fetch?: typeof globalThis.fetch | undefined
177
+ /** Approval page hooks. */
178
+ html: {
179
+ /** Renders or redirects the approval page. */
180
+ render: DeviceCode.html.Hooks['render']
181
+ }
182
+ /** Maximum approval request body size in bytes. @default 65536 */
183
+ maxBodyBytes?: number | undefined
184
+ /** Device-code endpoint path. @default "/auth/device" */
185
+ path?: string | undefined
186
+ /** Suggested polling interval in milliseconds. */
187
+ pollingInterval?: number | undefined
188
+ /** Device-code persistence. @default in-memory */
189
+ store?: Store.Store | undefined
190
+ /** Host policy applied before an approved result is relayed to the consumer. */
191
+ validate: (options: {
192
+ /** Pending device-code record. */
193
+ record: DeviceCode.PendingRecord
194
+ /** Pending JSON-RPC request matched to the submitted result. */
195
+ request: PendingRequest
196
+ /** Browser-submitted result. */
197
+ result: unknown
198
+ /** Normalized raw user code. */
199
+ userCode: string
200
+ }) => Promise<Response | undefined> | Response | undefined
201
+ }
202
+ }
203
+
204
+ function pendingRequests(record: DeviceCode.PendingRecord): PendingRequest[] {
205
+ if (record.message.type !== 'rpc-requests') return []
206
+ return record.message.payload.filter(
207
+ (message) => typeof message === 'object' && message !== null && 'id' in message,
208
+ ) as unknown as PendingRequest[]
209
+ }
210
+
211
+ function normalizeUserCode(value: string): string {
212
+ return value.replace(/[\s-]/g, '').toUpperCase()
213
+ }
214
+
215
+ function normalizeStore(store: Store.Store): Store.Store {
216
+ function key(value: string) {
217
+ if (!value.startsWith('user:')) return value
218
+ return `user:${normalizeUserCode(value.slice('user:'.length))}`
219
+ }
220
+
221
+ return Store.from({
222
+ delete: (value) => store.delete(key(value)),
223
+ get: <value = unknown>(name: string) => store.get<value>(key(name)),
224
+ set: (name, value, options) => store.set(key(name), value, options),
225
+ ...(store.take
226
+ ? { take: <value = unknown>(name: string) => store.take!<value>(key(name)) }
227
+ : {}),
228
+ })
229
+ }
230
+
231
+ async function normalizeRegisterResponse(response: Response, request: Request, path: string) {
232
+ if (!response.ok || new URL(request.url).pathname !== `${path}/register`) return response
233
+
234
+ const body = (await response.clone().json()) as {
235
+ user_code?: unknown
236
+ verification_uri_complete?: unknown
237
+ }
238
+ if (typeof body.user_code !== 'string') return response
239
+
240
+ const userCode = normalizeUserCode(body.user_code)
241
+ const verificationUriComplete =
242
+ typeof body.verification_uri_complete === 'string'
243
+ ? (() => {
244
+ const url = new URL(body.verification_uri_complete)
245
+ url.searchParams.set('user_code', userCode)
246
+ return url.toString()
247
+ })()
248
+ : body.verification_uri_complete
249
+ const headers = new Headers(response.headers)
250
+ headers.delete('content-length')
251
+ return Response.json(
252
+ {
253
+ ...body,
254
+ user_code: userCode,
255
+ ...(verificationUriComplete ? { verification_uri_complete: verificationUriComplete } : {}),
256
+ },
257
+ { headers, status: response.status },
258
+ )
259
+ }
260
+
261
+ async function readVerifyBody(
262
+ request: Request,
263
+ maxBodyBytes: number,
264
+ ): Promise<
265
+ { ok: true; value: z.output<typeof verifyRequest> } | { ok: false; response: Response }
266
+ > {
267
+ const invalid = (description: string) => ({
268
+ ok: false as const,
269
+ response: Response.json(
270
+ { error: 'invalid_request', error_description: description },
271
+ { status: 400 },
272
+ ),
273
+ })
274
+
275
+ let json: unknown
276
+ try {
277
+ const body = await readBody(request, maxBodyBytes)
278
+ if (!body.ok) return invalid('Request body is too large.')
279
+ json = JSON.parse(body.text)
280
+ } catch {
281
+ return invalid('Expected a JSON body.')
282
+ }
283
+
284
+ const parsed = z.safeParse(verifyRequest, json)
285
+ if (!parsed.success) return invalid('Malformed verify request.')
286
+ return { ok: true, value: parsed.data }
287
+ }
288
+
289
+ async function readBody(
290
+ request: Request,
291
+ maxBodyBytes: number,
292
+ ): Promise<{ ok: true; text: string } | { ok: false }> {
293
+ const length = request.headers.get('content-length')
294
+ if (length && Number(length) > maxBodyBytes) return { ok: false }
295
+ if (!request.body) return { ok: true, text: '' }
296
+
297
+ const reader = request.body.getReader()
298
+ const chunks: Uint8Array[] = []
299
+ let size = 0
300
+
301
+ try {
302
+ while (true) {
303
+ const { done, value } = await reader.read()
304
+ if (done) break
305
+ size += value.byteLength
306
+ if (size > maxBodyBytes) {
307
+ await reader.cancel().catch(() => undefined)
308
+ return { ok: false }
309
+ }
310
+ chunks.push(value)
311
+ }
312
+ } finally {
313
+ reader.releaseLock()
314
+ }
315
+
316
+ const bytes = new Uint8Array(size)
317
+ let offset = 0
318
+ for (const chunk of chunks) {
319
+ bytes.set(chunk, offset)
320
+ offset += chunk.byteLength
321
+ }
322
+ return { ok: true, text: new TextDecoder().decode(bytes) }
323
+ }