accounts
Version:
Tempo Accounts SDK
158 lines (145 loc) • 5.53 kB
text/typescript
import { Provider as core_Provider } from 'ox'
import { DeviceCode, Transport, Wata, deviceCode as core_deviceCode } from 'wata'
import type * as Adapter from '../../Adapter.js'
import type * as Keystore from '../../Keystore.js'
import { fromRequest } from '../internal/fromRequest.js'
/**
* Creates a device-code adapter that forwards wallet RPC through Wata.
*
* Each request runs one OAuth 2.0 Device Authorization Grant exchange
* (RFC 8628 with PKCE): the host issues a user code surfaced via
* `onPrompt`, the user approves on the host's verification page, and the
* adapter polls until the response arrives.
*/
export function deviceCode(options: deviceCode.Options): Adapter.Adapter {
const {
actions,
fetch,
icon,
keystores,
meta,
methods,
name,
onPrompt,
pollingInterval,
rdns,
timeout = 300_000,
url,
} = options
return fromRequest({
...(actions ? { actions } : {}),
...(icon ? { icon } : {}),
...(keystores ? { keystores } : {}),
name,
rdns,
async request(request) {
if (methods && !methods.includes(request.method))
throw new core_Provider.UnsupportedMethodError({
message: `\`${request.method}\` not supported by device-code adapter.`,
})
// Device code is single-exchange: one register + poll cycle carries one
// RPC request envelope, so a fresh Wata instance backs every request.
const wata = Wata.create({
transports: [
core_deviceCode({
...(fetch !== undefined ? { fetch } : {}),
...(meta ? { meta } : {}),
...(pollingInterval !== undefined ? { pollingInterval } : {}),
url,
}),
],
})
const session = wata.start()
// A failed prompt surface (e.g. browser open) fails the exchange — the
// user can never learn the code, so polling would only run out the clock.
session.onPrompt((prompt) => {
void (async () => onPrompt(prompt))().catch((error) => {
void session.close(new PromptError(prompt, error))
})
})
const timer = setTimeout(() => {
void session.close(new TimeoutError(session.prompt))
}, timeout)
try {
const { result } = await session.send({
method: request.method,
params: request.params ?? [],
...(request.context ? { context: request.context } : {}),
})
return result
} catch (error) {
if (error instanceof DeviceCode.UserRejectedError)
throw new core_Provider.UserRejectedRequestError({
message: 'User denied the device-code request.',
})
if (error instanceof Transport.ClosedError)
throw new Error('Device code expired before authorization completed.', { cause: error })
throw error
} finally {
clearTimeout(timer)
}
},
})
}
export declare namespace deviceCode {
/** Options for {@link deviceCode}. */
export type Options = {
/** Adapter-specific action overrides layered over the request-forwarding defaults. */
actions?: fromRequest.Options['actions'] | undefined
/** Override the transport `fetch` implementation. */
fetch?: typeof globalThis.fetch | undefined
/** Data URI of the provider icon, announced via EIP-6963. */
icon?: Adapter.Meta['icon'] | undefined
/** Keystores backing locally generated access keys (SDK default otherwise). */
keystores?: Keystore.Keystores | undefined
/** Consumer metadata shown on the host's approval page. */
meta?:
| {
name: string
description?: string | undefined
icon?: string | undefined
websiteUrl?: string | undefined
}
| undefined
/** Allowlist of RPC methods forwarded over the transport. Forwards all when omitted. */
methods?: readonly string[] | undefined
/** Provider display name. */
name: string
/** Surfaces the pairing prompt (user code + verification URL) to the user. */
onPrompt: (prompt: DeviceCode.Prompt) => Promise<void> | void
/** Poll cadence override in milliseconds. Defaults to the host-advertised interval. */
pollingInterval?: number | undefined
/** Reverse-DNS provider identifier. */
rdns: string
/** Overall wait for user approval in milliseconds. @default 300000 */
timeout?: number | undefined
/** Base URL of the host's device-code endpoints (derives `/register` and `/token`). */
url: string
}
}
/** Thrown when the `onPrompt` callback fails (e.g. no browser could be opened). */
export class PromptError extends Error {
override cause?: unknown | undefined
prompt: DeviceCode.Prompt
constructor(prompt: DeviceCode.Prompt, cause?: unknown) {
super(
`Failed to surface device code ${prompt.userCode}. Open ${prompt.verificationUriFull ?? prompt.verificationUri} manually.`,
)
this.name = 'PromptError'
this.cause = cause
this.prompt = prompt
}
}
/** Thrown when the user did not approve within {@link deviceCode.Options.timeout}. */
export class TimeoutError extends Error {
prompt: DeviceCode.Prompt | undefined
constructor(prompt: DeviceCode.Prompt | undefined) {
super(
prompt
? `Timed out waiting for device code ${prompt.userCode}. Continue at ${prompt.verificationUriFull ?? prompt.verificationUri}.`
: 'Timed out waiting for device code approval.',
)
this.name = 'TimeoutError'
this.prompt = prompt
}
}