accounts 0.4.0 → 0.4.2

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 (76) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +38 -7
  3. package/dist/cli/Provider.d.ts +12 -0
  4. package/dist/cli/Provider.d.ts.map +1 -0
  5. package/dist/cli/Provider.js +19 -0
  6. package/dist/cli/Provider.js.map +1 -0
  7. package/dist/cli/adapter.d.ts +24 -0
  8. package/dist/cli/adapter.d.ts.map +1 -0
  9. package/dist/cli/adapter.js +173 -0
  10. package/dist/cli/adapter.js.map +1 -0
  11. package/dist/cli/index.d.ts +3 -0
  12. package/dist/cli/index.d.ts.map +1 -0
  13. package/dist/cli/index.js +3 -0
  14. package/dist/cli/index.js.map +1 -0
  15. package/dist/core/Dialog.d.ts.map +1 -1
  16. package/dist/core/Dialog.js +25 -1
  17. package/dist/core/Dialog.js.map +1 -1
  18. package/dist/core/IntersectionObserver.d.ts +3 -0
  19. package/dist/core/IntersectionObserver.d.ts.map +1 -0
  20. package/dist/core/IntersectionObserver.js +6 -0
  21. package/dist/core/IntersectionObserver.js.map +1 -0
  22. package/dist/core/Messenger.d.ts +14 -3
  23. package/dist/core/Messenger.d.ts.map +1 -1
  24. package/dist/core/Messenger.js +4 -4
  25. package/dist/core/Messenger.js.map +1 -1
  26. package/dist/core/Remote.d.ts +6 -3
  27. package/dist/core/Remote.d.ts.map +1 -1
  28. package/dist/core/Remote.js +3 -6
  29. package/dist/core/Remote.js.map +1 -1
  30. package/dist/core/adapters/local.d.ts.map +1 -1
  31. package/dist/core/adapters/local.js +2 -2
  32. package/dist/core/adapters/local.js.map +1 -1
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/react/Remote.d.ts +21 -0
  38. package/dist/react/Remote.d.ts.map +1 -0
  39. package/dist/react/Remote.js +51 -0
  40. package/dist/react/Remote.js.map +1 -0
  41. package/dist/react/index.d.ts +2 -0
  42. package/dist/react/index.d.ts.map +1 -0
  43. package/dist/react/index.js +2 -0
  44. package/dist/react/index.js.map +1 -0
  45. package/dist/server/CliAuth.d.ts +553 -0
  46. package/dist/server/CliAuth.d.ts.map +1 -0
  47. package/dist/server/CliAuth.js +446 -0
  48. package/dist/server/CliAuth.js.map +1 -0
  49. package/dist/server/Handler.d.ts +36 -2
  50. package/dist/server/Handler.d.ts.map +1 -1
  51. package/dist/server/Handler.js +84 -0
  52. package/dist/server/Handler.js.map +1 -1
  53. package/dist/server/index.d.ts +1 -0
  54. package/dist/server/index.d.ts.map +1 -1
  55. package/dist/server/index.js +1 -0
  56. package/dist/server/index.js.map +1 -1
  57. package/package.json +16 -54
  58. package/src/cli/Provider.test-d.ts +28 -0
  59. package/src/cli/Provider.test.ts +235 -0
  60. package/src/cli/Provider.ts +26 -0
  61. package/src/cli/adapter.ts +229 -0
  62. package/src/cli/index.ts +2 -0
  63. package/src/core/Dialog.ts +31 -1
  64. package/src/core/IntersectionObserver.ts +6 -0
  65. package/src/core/Messenger.ts +18 -8
  66. package/src/core/Provider.test.ts +12 -2
  67. package/src/core/Remote.ts +9 -10
  68. package/src/core/adapters/local.ts +7 -2
  69. package/src/index.ts +1 -0
  70. package/src/react/Remote.ts +94 -0
  71. package/src/react/index.ts +1 -0
  72. package/src/server/CliAuth.test-d.ts +56 -0
  73. package/src/server/CliAuth.test.ts +800 -0
  74. package/src/server/CliAuth.ts +634 -0
  75. package/src/server/Handler.ts +123 -1
  76. package/src/server/index.ts +1 -0
@@ -0,0 +1,229 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { setTimeout as sleep } from 'node:timers/promises'
3
+ import { Base64, Hash, Hex, Provider as core_Provider, RpcResponse } from 'ox'
4
+ import * as z from 'zod/mini'
5
+
6
+ import * as Adapter from '../core/Adapter.js'
7
+ import * as CliAuth from '../server/CliAuth.js'
8
+
9
+ /**
10
+ * Creates a CLI bootstrap adapter backed by the device-code protocol.
11
+ *
12
+ * Only `wallet_connect` is supported in v1.
13
+ */
14
+ export function cli(options: cli.Options): Adapter.Adapter {
15
+ const { name = 'Tempo CLI', rdns = 'xyz.tempo.cli' } = options
16
+
17
+ return Adapter.define({ name, rdns }, () => ({
18
+ actions: {
19
+ async createAccount(params, request) {
20
+ return this.loadAccounts(params, request)
21
+ },
22
+ async loadAccounts(parameters) {
23
+ const {
24
+ host,
25
+ open = defaultOpen,
26
+ pollIntervalMs = 2_000,
27
+ timeoutMs = 5 * 60 * 1_000,
28
+ } = options
29
+ const authorizeAccessKey = parameters?.authorizeAccessKey
30
+
31
+ if (!authorizeAccessKey?.publicKey)
32
+ throw new RpcResponse.InvalidParamsError({
33
+ message:
34
+ '`wallet_connect` on the CLI adapter requires `capabilities.authorizeAccessKey.publicKey`.',
35
+ })
36
+ if (parameters?.digest)
37
+ throw unsupported('`wallet_connect` digest signing not supported by CLI adapter.')
38
+
39
+ const codeVerifier = createCodeVerifier()
40
+ const codeChallenge = createCodeChallenge(codeVerifier)
41
+ const created = await post({
42
+ body: {
43
+ codeChallenge,
44
+ ...(typeof authorizeAccessKey.expiry !== 'undefined'
45
+ ? { expiry: authorizeAccessKey.expiry }
46
+ : {}),
47
+ ...(authorizeAccessKey.keyType ? { keyType: authorizeAccessKey.keyType } : {}),
48
+ ...(authorizeAccessKey.limits ? { limits: authorizeAccessKey.limits } : {}),
49
+ pubKey: authorizeAccessKey.publicKey,
50
+ } satisfies z.output<typeof CliAuth.createRequest>,
51
+ request: CliAuth.createRequest,
52
+ response: CliAuth.createResponse,
53
+ url: getApiUrl(host, 'code'),
54
+ })
55
+ const url = getBrowserUrl(host, created.code)
56
+
57
+ try {
58
+ await open(url)
59
+ } catch (error) {
60
+ throw new OpenError(url, created.code, error)
61
+ }
62
+
63
+ const startedAt = Date.now()
64
+
65
+ while (Date.now() - startedAt < timeoutMs) {
66
+ const result = await post({
67
+ body: {
68
+ codeVerifier,
69
+ } satisfies z.output<typeof CliAuth.pollRequest>,
70
+ request: CliAuth.pollRequest,
71
+ response: CliAuth.pollResponse,
72
+ url: getApiUrl(host, `poll/${created.code}`),
73
+ })
74
+
75
+ if (result.status === 'pending') {
76
+ await sleep(pollIntervalMs)
77
+ continue
78
+ }
79
+ if (result.status === 'expired')
80
+ throw new Error('Device code expired before authorization completed.')
81
+
82
+ return {
83
+ accounts: [
84
+ {
85
+ address: result.accountAddress,
86
+ capabilities: {},
87
+ },
88
+ ],
89
+ keyAuthorization: z.encode(CliAuth.keyAuthorization, result.keyAuthorization),
90
+ }
91
+ }
92
+
93
+ throw new TimeoutError(url, created.code)
94
+ },
95
+ async revokeAccessKey() {
96
+ throw unsupported('`wallet_revokeAccessKey` not supported by CLI adapter.')
97
+ },
98
+ async sendTransaction() {
99
+ throw unsupported('`eth_sendTransaction` not supported by CLI adapter.')
100
+ },
101
+ async sendTransactionSync() {
102
+ throw unsupported('`eth_sendTransactionSync` not supported by CLI adapter.')
103
+ },
104
+ async signPersonalMessage() {
105
+ throw unsupported('`personal_sign` not supported by CLI adapter.')
106
+ },
107
+ async signTransaction() {
108
+ throw unsupported('`eth_signTransaction` not supported by CLI adapter.')
109
+ },
110
+ async signTypedData() {
111
+ throw unsupported('`eth_signTypedData_v4` not supported by CLI adapter.')
112
+ },
113
+ },
114
+ }))
115
+ }
116
+
117
+ export declare namespace cli {
118
+ export type Options = {
119
+ /** Host URL for the device-code flow. API calls are made under the same base path. */
120
+ host: string
121
+ /** Provider display name. @default "Tempo CLI" */
122
+ name?: string | undefined
123
+ /** Browser opener override. */
124
+ open?: ((url: string) => Promise<void> | void) | undefined
125
+ /** Poll interval in milliseconds. @default 2000 */
126
+ pollIntervalMs?: number | undefined
127
+ /** Reverse-DNS provider identifier. @default "xyz.tempo.cli" */
128
+ rdns?: string | undefined
129
+ /** Poll timeout in milliseconds. @default 300000 */
130
+ timeoutMs?: number | undefined
131
+ }
132
+ }
133
+
134
+ class OpenError extends Error {
135
+ code: string
136
+ cause?: unknown | undefined
137
+ url: string
138
+
139
+ constructor(url: string, code: string, cause?: unknown) {
140
+ super(`Failed to open browser for device code ${formatCode(code)}. Open ${url} manually.`)
141
+ this.name = 'OpenError'
142
+ this.code = code
143
+ this.cause = cause
144
+ this.url = url
145
+ }
146
+ }
147
+
148
+ class TimeoutError extends Error {
149
+ code: string
150
+ url: string
151
+
152
+ constructor(url: string, code: string) {
153
+ super(`Timed out waiting for device code ${formatCode(code)}. Continue at ${url}.`)
154
+ this.name = 'TimeoutError'
155
+ this.code = code
156
+ this.url = url
157
+ }
158
+ }
159
+
160
+ function createCodeChallenge(codeVerifier: string) {
161
+ return Base64.fromBytes(Hash.sha256(Hex.fromString(codeVerifier), { as: 'Bytes' }), {
162
+ pad: false,
163
+ url: true,
164
+ })
165
+ }
166
+
167
+ function createCodeVerifier() {
168
+ return Base64.fromBytes(Hex.toBytes(Hex.random(32)), { pad: false, url: true })
169
+ }
170
+
171
+ function formatCode(code: string) {
172
+ return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code
173
+ }
174
+
175
+ function defaultOpen(url: string) {
176
+ const command =
177
+ process.platform === 'darwin'
178
+ ? { command: 'open', args: [url] }
179
+ : process.platform === 'win32'
180
+ ? { command: 'cmd', args: ['/c', 'start', '', url] }
181
+ : { command: 'xdg-open', args: [url] }
182
+
183
+ const child = spawn(command.command, command.args, {
184
+ detached: true,
185
+ stdio: 'ignore',
186
+ })
187
+ child.unref()
188
+ }
189
+
190
+ function getApiUrl(serviceUrl: string, path: string) {
191
+ const url = new URL(serviceUrl)
192
+ url.pathname = `${url.pathname.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
193
+ url.search = ''
194
+ return url.toString()
195
+ }
196
+
197
+ function getBrowserUrl(serviceUrl: string, code: string) {
198
+ const url = new URL(serviceUrl)
199
+ url.searchParams.set('code', code)
200
+ return url.toString()
201
+ }
202
+
203
+ async function post<
204
+ const request extends z.ZodMiniType,
205
+ const response extends z.ZodMiniType,
206
+ >(options: {
207
+ body: z.output<request>
208
+ request: request
209
+ response: response
210
+ url: string
211
+ }): Promise<z.output<response>> {
212
+ const result = await fetch(options.url, {
213
+ body: JSON.stringify(z.encode(options.request, options.body)),
214
+ headers: { 'content-type': 'application/json' },
215
+ method: 'POST',
216
+ })
217
+ const json = (await result.json().catch(() => ({}))) as z.input<response>
218
+
219
+ if (!result.ok) {
220
+ const error = (json as { error?: unknown }).error
221
+ throw new Error(typeof error === 'string' ? error : `Request failed: ${result.status}`)
222
+ }
223
+
224
+ return z.decode(options.response, json)
225
+ }
226
+
227
+ function unsupported(message: string) {
228
+ return new core_Provider.UnsupportedMethodError({ message })
229
+ }
@@ -0,0 +1,2 @@
1
+ export * as Provider from './Provider.js'
2
+ export { cli } from './adapter.js'
@@ -1,3 +1,4 @@
1
+ import * as IO from './IntersectionObserver.js'
1
2
  import * as Messenger from './Messenger.js'
2
3
  import type * as Store from './Store.js'
3
4
 
@@ -207,6 +208,19 @@ export function iframe(): Dialog {
207
208
  }
208
209
  }
209
210
 
211
+ messenger.on('switch-mode', () => {
212
+ hideDialog()
213
+ activatePage()
214
+ open = false
215
+
216
+ const pending = store
217
+ .getState()
218
+ .requestQueue.filter(
219
+ (x): x is Store.QueuedRequest & { status: 'pending' } => x.status === 'pending',
220
+ )
221
+ if (pending.length > 0) fallback.syncRequests(pending)
222
+ })
223
+
210
224
  return {
211
225
  close() {
212
226
  fallback.close()
@@ -241,7 +255,23 @@ export function iframe(): Dialog {
241
255
  isSafari() &&
242
256
  requests.some((x) => ['wallet_connect', 'eth_requestAccounts'].includes(x.request.method))
243
257
 
244
- if (unsupported) {
258
+ const secure = await (async () => {
259
+ const { trustedHosts } = await messenger.waitForReady()
260
+ const ioSupported = IO.supported()
261
+ const trusted = Boolean(trustedHosts?.includes(window.location.hostname))
262
+ return ioSupported || trusted
263
+ })()
264
+
265
+ if (unsupported || !secure) {
266
+ if (!secure)
267
+ console.warn(
268
+ [
269
+ `[accounts] Browser does not support IntersectionObserver v2 and "${window.location.hostname}" is not a trusted host.`,
270
+ 'Falling back to popup dialog.',
271
+ '',
272
+ 'To enable the iframe dialog, add your hostname to the trusted hosts list.',
273
+ ].join('\n'),
274
+ )
245
275
  fallback.syncRequests(requests)
246
276
  } else {
247
277
  const requiresConfirm = requests.some((x) => x.status === 'pending')
@@ -0,0 +1,6 @@
1
+ /** Whether IntersectionObserver v2 (with `isVisible`) is supported. */
2
+ export const supported = () =>
3
+ 'IntersectionObserver' in window &&
4
+ 'IntersectionObserverEntry' in window &&
5
+ 'intersectionRatio' in IntersectionObserverEntry.prototype &&
6
+ 'isVisible' in IntersectionObserverEntry.prototype
@@ -20,19 +20,25 @@ export type Messenger = {
20
20
  ) => Promise<{ id: string; topic: topic; payload: Payload<topic> }>
21
21
  }
22
22
 
23
+ /** Options sent with the `ready` signal from the remote frame. */
24
+ export type ReadyOptions = {
25
+ /** Hostnames trusted by the remote embed to render in an iframe. */
26
+ trustedHosts?: string[] | undefined
27
+ }
28
+
23
29
  /** Bridge messenger that waits for a `ready` signal from the remote frame. */
24
30
  export type Bridge = Messenger & {
25
31
  /** Signal readiness (called by the remote frame). */
26
- ready: () => void
32
+ ready: (options?: ReadyOptions | undefined) => void
27
33
  /** Promise that resolves when the remote frame signals ready. */
28
- waitForReady: () => Promise<void>
34
+ waitForReady: () => Promise<ReadyOptions>
29
35
  }
30
36
 
31
37
  /** Message schema for cross-frame communication. */
32
38
  export type Schema = [
33
39
  {
34
40
  topic: 'ready'
35
- payload: undefined
41
+ payload: ReadyOptions
36
42
  },
37
43
  {
38
44
  topic: 'rpc-requests'
@@ -52,6 +58,10 @@ export type Schema = [
52
58
  topic: 'close'
53
59
  payload: undefined
54
60
  },
61
+ {
62
+ topic: 'switch-mode'
63
+ payload: { mode: 'popup' }
64
+ },
55
65
  ]
56
66
 
57
67
  /** Union of all topic strings. */
@@ -116,8 +126,8 @@ export function bridge(parameters: bridge.Parameters): Bridge {
116
126
 
117
127
  let pending = false
118
128
 
119
- const ready = withResolvers<void>()
120
- from_.on('ready', ready.resolve)
129
+ const ready = withResolvers<ReadyOptions>()
130
+ from_.on('ready', (payload) => ready.resolve(payload ?? {}))
121
131
 
122
132
  const messenger = from({
123
133
  destroy() {
@@ -137,8 +147,8 @@ export function bridge(parameters: bridge.Parameters): Bridge {
137
147
 
138
148
  return {
139
149
  ...messenger,
140
- ready() {
141
- void messenger.send('ready', undefined)
150
+ ready(options) {
151
+ void messenger.send('ready', options ?? {})
142
152
  },
143
153
  waitForReady() {
144
154
  return ready.promise
@@ -169,7 +179,7 @@ export function noop(): Bridge {
169
179
  },
170
180
  ready() {},
171
181
  waitForReady() {
172
- return Promise.resolve()
182
+ return Promise.resolve({})
173
183
  },
174
184
  }
175
185
  }
@@ -1482,10 +1482,15 @@ describe.each(adapters)('$name', ({ adapter }: (typeof adapters)[number]) => {
1482
1482
 
1483
1483
  const connected = await connect(provider)
1484
1484
  await fund(connected)
1485
+ const syncTransferCall = Actions.token.transfer.call({
1486
+ to: '0x0000000000000000000000000000000000000001',
1487
+ token: Addresses.pathUsd,
1488
+ amount: parseUnits('2', 6),
1489
+ })
1485
1490
 
1486
1491
  const receipt = await provider.request({
1487
1492
  method: 'eth_sendTransactionSync',
1488
- params: [{ calls: [transferCall], feePayer: server.url }],
1493
+ params: [{ calls: [syncTransferCall], feePayer: server.url }],
1489
1494
  })
1490
1495
 
1491
1496
  expect(receipt.feePayer).toBe(feePayerAccount.address.toLowerCase())
@@ -1536,10 +1541,15 @@ describe.each(adapters)('$name', ({ adapter }: (typeof adapters)[number]) => {
1536
1541
 
1537
1542
  const connected = await connect(provider)
1538
1543
  await fund(connected)
1544
+ const syncTransferCall = Actions.token.transfer.call({
1545
+ to: '0x0000000000000000000000000000000000000001',
1546
+ token: Addresses.pathUsd,
1547
+ amount: parseUnits('3', 6),
1548
+ })
1539
1549
 
1540
1550
  const receipt = await provider.request({
1541
1551
  method: 'eth_sendTransactionSync',
1542
- params: [{ calls: [transferCall], feePayer: true }],
1552
+ params: [{ calls: [syncTransferCall], feePayer: true }],
1543
1553
  })
1544
1554
 
1545
1555
  expect(receipt.feePayer).toBe(feePayerAccount.address.toLowerCase())
@@ -1,7 +1,6 @@
1
1
  import { Hex } from 'ox'
2
2
  import * as Provider from 'ox/Provider'
3
3
  import * as RpcResponse from 'ox/RpcResponse'
4
- import { useStore } from 'zustand'
5
4
  import type { StoreApi } from 'zustand/vanilla'
6
5
  import { createStore } from 'zustand/vanilla'
7
6
 
@@ -35,6 +34,10 @@ export type Remote = {
35
34
  * Remote context store.
36
35
  */
37
36
  store: StoreApi<State>
37
+ /**
38
+ * Hostnames trusted to render the embed in an iframe.
39
+ */
40
+ trustedHosts: readonly string[]
38
41
  /**
39
42
  * Subscribes to user-facing RPC requests from the parent context.
40
43
  *
@@ -109,7 +112,7 @@ export declare namespace respond {
109
112
 
110
113
  /** Creates a remote context for the dialog app. */
111
114
  export function create(options: create.Options): Remote {
112
- const { messenger, provider } = options
115
+ const { messenger, provider, trustedHosts } = options
113
116
  const ready =
114
117
  typeof window !== 'undefined' && !new URLSearchParams(window.location.search).get('mode')
115
118
  const store = createStore<State>(() => ({
@@ -123,6 +126,7 @@ export function create(options: create.Options): Remote {
123
126
  messenger,
124
127
  provider,
125
128
  store,
129
+ trustedHosts: trustedHosts ?? [],
126
130
 
127
131
  onUserRequest(cb) {
128
132
  return this.onRequests(async (requests, event, { account }) => {
@@ -171,7 +175,7 @@ export function create(options: create.Options): Remote {
171
175
  },
172
176
 
173
177
  ready() {
174
- messenger.ready()
178
+ messenger.ready({ trustedHosts })
175
179
 
176
180
  if (typeof window !== 'undefined') {
177
181
  const params = new URLSearchParams(window.location.search)
@@ -251,12 +255,7 @@ export declare namespace create {
251
255
  messenger: Messenger.Bridge
252
256
  /** Provider to execute RPC requests against. */
253
257
  provider: CoreProvider.Provider
258
+ /** Hostnames trusted to render the embed in an iframe. */
259
+ trustedHosts?: string[] | undefined
254
260
  }
255
261
  }
256
-
257
- /** React hook to select state from a remote context's store. */
258
- export function useState(remote: Remote): State
259
- export function useState<selected>(remote: Remote, selector: (state: State) => selected): selected
260
- export function useState(remote: Remote, selector?: (state: State) => unknown) {
261
- return useStore(remote.store, selector as never)
262
- }
@@ -207,8 +207,13 @@ export function local(options: local.Options): Adapter.Adapter {
207
207
  },
208
208
  async signTypedData({ data, address }) {
209
209
  const account = getAccount({ address, signable: true })
210
- const { domain, types, primaryType, message } = JSON.parse(data)
211
- return await account.signTypedData({ domain, types, primaryType, message })
210
+ const parsed = JSON.parse(data) as {
211
+ domain: Record<string, unknown>
212
+ message: Record<string, unknown>
213
+ primaryType: string
214
+ types: Record<string, unknown>
215
+ }
216
+ return await account.signTypedData(parsed)
212
217
  },
213
218
  async sendTransaction(parameters) {
214
219
  const { feePayer, ...rest } = parameters
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * as Ceremony from './core/Ceremony.js'
2
+ export * as IntersectionObserver from './core/IntersectionObserver.js'
2
3
  export * as Dialog from './core/Dialog.js'
3
4
  export * as Messenger from './core/Messenger.js'
4
5
  export * as Schema from './core/Schema.js'
@@ -0,0 +1,94 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState as react_useState } from 'react'
2
+ import { useStore } from 'zustand'
3
+
4
+ import * as IO from '../core/IntersectionObserver.js'
5
+ import type * as CoreRemote from '../core/Remote.js'
6
+
7
+ /** Monitors element visibility using IntersectionObserver v2. */
8
+ export function useEnsureVisibility(
9
+ remote: CoreRemote.Remote,
10
+ options: useEnsureVisibility.Options = {},
11
+ ): useEnsureVisibility.ReturnType {
12
+ const { enabled = true } = options
13
+
14
+ const origin = useState(remote, (s) => s.origin)
15
+
16
+ const trusted = useMemo(() => {
17
+ if (!origin) return false
18
+ try {
19
+ const hostname = new URL(origin).hostname
20
+ return remote.trustedHosts.includes(hostname)
21
+ } catch {
22
+ return false
23
+ }
24
+ }, [origin, remote.trustedHosts])
25
+
26
+ const active = enabled && !trusted
27
+
28
+ const ref = useRef<HTMLDivElement>(null)
29
+ const [visible, setVisible] = react_useState(true)
30
+
31
+ useEffect(() => {
32
+ if (!active) return
33
+ if (!ref.current) return
34
+
35
+ if (!IO.supported()) {
36
+ setVisible(false)
37
+ return
38
+ }
39
+
40
+ const observer = new IntersectionObserver(
41
+ (entries) => {
42
+ const entry = entries[0]
43
+ if (!entry) return
44
+ const isVisible =
45
+ (entry as unknown as { isVisible: boolean | undefined }).isVisible || false
46
+ setVisible(isVisible)
47
+ },
48
+ {
49
+ delay: 100,
50
+ threshold: [0.99],
51
+ trackVisibility: true,
52
+ } as IntersectionObserverInit,
53
+ )
54
+
55
+ observer.observe(ref.current)
56
+ return () => observer.disconnect()
57
+ }, [active])
58
+
59
+ const invokePopup = useCallback(
60
+ () => remote.messenger.send('switch-mode', { mode: 'popup' }),
61
+ [remote],
62
+ )
63
+
64
+ return { invokePopup, ref, visible }
65
+ }
66
+
67
+ /** React hook to select state from a remote context's store. */
68
+ export function useState(remote: CoreRemote.Remote): CoreRemote.State
69
+ export function useState<selected>(
70
+ remote: CoreRemote.Remote,
71
+ selector: (state: CoreRemote.State) => selected,
72
+ ): selected
73
+ export function useState(
74
+ remote: CoreRemote.Remote,
75
+ selector?: (state: CoreRemote.State) => unknown,
76
+ ) {
77
+ return useStore(remote.store, selector as never)
78
+ }
79
+
80
+ export declare namespace useEnsureVisibility {
81
+ type Options = {
82
+ /** Whether visibility monitoring is enabled. @default true */
83
+ enabled?: boolean | undefined
84
+ }
85
+
86
+ type ReturnType = {
87
+ /** Requests the host switch to a popup dialog. */
88
+ invokePopup: () => void
89
+ /** Ref to attach to the element being monitored. */
90
+ ref: React.RefObject<HTMLDivElement | null>
91
+ /** Whether the element is currently visible. */
92
+ visible: boolean
93
+ }
94
+ }
@@ -0,0 +1 @@
1
+ export * as Remote from './Remote.js'
@@ -0,0 +1,56 @@
1
+ import type { Hex } from 'viem'
2
+ import { describe, expectTypeOf, test } from 'vp/test'
3
+ import * as z from 'zod/mini'
4
+
5
+ import * as CliAuth from './CliAuth.js'
6
+
7
+ describe('createRequest', () => {
8
+ test('includes the v1 device-code request fields', () => {
9
+ expectTypeOf<z.output<typeof CliAuth.createRequest>>().toMatchTypeOf<{
10
+ account?: Hex | undefined
11
+ codeChallenge: string
12
+ expiry?: number | undefined
13
+ keyType?: 'secp256k1' | 'p256' | 'webAuthn' | undefined
14
+ limits?: readonly { token: Hex; limit: bigint }[] | undefined
15
+ pubKey: Hex
16
+ }>()
17
+ })
18
+
19
+ test('does not include scopes in v1', () => {
20
+ type Request = z.output<typeof CliAuth.createRequest>
21
+ expectTypeOf<Request>().not.toHaveProperty('scopes')
22
+ })
23
+ })
24
+
25
+ describe('pollResponse', () => {
26
+ test('authorized responses carry the normal keyAuthorization shape', () => {
27
+ type Response = Extract<z.output<typeof CliAuth.pollResponse>, { status: 'authorized' }>
28
+ expectTypeOf<Response>().toMatchTypeOf<{
29
+ accountAddress: Hex
30
+ keyAuthorization: z.output<typeof CliAuth.keyAuthorization>
31
+ status: 'authorized'
32
+ }>()
33
+ })
34
+ })
35
+
36
+ describe('pendingResponse', () => {
37
+ test('pending responses expose the browser approval payload', () => {
38
+ expectTypeOf<z.output<typeof CliAuth.pendingResponse>>().toMatchTypeOf<{
39
+ accessKeyAddress: Hex
40
+ account?: Hex | undefined
41
+ chainId: bigint
42
+ code: string
43
+ expiry: number
44
+ keyType: 'secp256k1' | 'p256' | 'webAuthn'
45
+ limits?: readonly { token: Hex; limit: bigint }[] | undefined
46
+ pubKey: Hex
47
+ status: 'pending'
48
+ }>()
49
+ })
50
+ })
51
+
52
+ describe('Store', () => {
53
+ test('memory helper satisfies the shared store contract', () => {
54
+ expectTypeOf(CliAuth.Store.memory).returns.toMatchTypeOf<CliAuth.Store>()
55
+ })
56
+ })