accounts
Version:
Tempo Accounts SDK
647 lines (559 loc) • 20.5 kB
text/typescript
import * as IO from './IntersectionObserver.js'
import * as Messenger from './Messenger.js'
import type * as Store from './Store.js'
import * as TrustedHosts from './TrustedHosts.js'
/** Dialog interface — manages the iframe/popup lifecycle for cross-origin auth. */
export type Dialog = SetupFn & Meta
/** Static metadata attached to a dialog function. */
export type Meta = {
/** Identifier for the dialog type (e.g. `'iframe'`, `'popup'`). */
name?: string | undefined
}
export type Instance = {
/** Close the dialog (hide iframe / close popup). */
close: () => void
/** Destroy the dialog (remove DOM elements, clean up). */
destroy: () => void
/** Open the dialog (show iframe / open popup). */
open: () => void
/** Sync the pending request queue to the remote auth app. */
syncRequests: (requests: readonly Store.QueuedRequest[]) => Promise<void>
/** Update the visual theme at runtime. */
syncTheme: (theme: Theme | undefined) => void
}
/** The setup function a dialog must implement. */
export type SetupFn = (parameters: SetupFn.Parameters) => Instance
export declare namespace SetupFn {
type Parameters = {
/** URL of the Tempo Wallet app. */
host: string
/** Reactive state store. */
store: Store.Store
/** Visual theme overrides applied to the embed. */
theme?: Theme | undefined
}
}
/** Visual theme configuration for the dialog embed. */
export type Theme = {
/** Accent color — a theme preset name or a CSS color value (e.g. `'#6366f1'`). */
accent?: 'neutral' | 'blue' | 'red' | 'amber' | 'green' | 'purple' | (string & {}) | undefined
/** Border radius preset. */
radius?: 'none' | 'small' | 'medium' | 'large' | 'full' | undefined
/** Color scheme — controls light/dark appearance. Defaults to `'light dark'` (follows OS). */
scheme?: 'light' | 'dark' | undefined
}
/** Serializes theme options onto a URL's search params. */
function applyThemeParams(url: URL, theme: Theme | undefined) {
if (!theme) return
if (theme.accent) url.searchParams.set('accent', theme.accent)
if (theme.radius) url.searchParams.set('radius', theme.radius)
if (theme.scheme) url.searchParams.set('scheme', theme.scheme)
}
export const defaultSize = { height: 440, width: 360 }
/** Creates a dialog from metadata and a setup function. */
export function define(meta: Meta, fn: SetupFn): Dialog {
const { name, ...rest } = meta
Object.defineProperty(fn, 'name', { value: name, configurable: true })
return Object.assign(fn, rest) as Dialog
}
/** Detects an insecure context (e.g. HTTP) where iframes lack WebAuthn support. */
export function isInsecureContext(): boolean {
if (typeof window === 'undefined') return false
// `http://localhost` is a secure context but WebAuthn still requires HTTPS.
if (window.location.protocol === 'http:') return true
return !window.isSecureContext
}
/** Detects Safari (which does not support WebAuthn in cross-origin iframes). */
export function isSafari(): boolean {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent.toLowerCase()
return ua.includes('safari') && !ua.includes('chrome')
}
/** Cached iframe singleton — keyed by host, reused across setup calls. */
let cached: { host: string; instance: Instance } | undefined
/** Mutable refs swapped on re-entry so the singleton always uses the latest caller's state. */
let store: Store.Store | undefined
let fallback: Instance | undefined
/** Previous stores kept alive so in-flight responses find their matching request. */
let previousStores: Store.Store[] = []
/** Creates an iframe dialog that embeds the auth app in a `<dialog>` element. */
export function iframe(): Dialog {
if (typeof window === 'undefined') return noop()
return define({ name: 'iframe' }, (parameters) => {
const { host } = parameters
// Reuse existing iframe if the host matches — just swap the store/fallback refs.
if (cached && cached.host === host) {
const oldStore = store
store = parameters.store
// Keep the old store so in-flight responses can find their matching request.
if (oldStore && oldStore !== store && !previousStores.includes(oldStore))
previousStores.push(oldStore)
fallback?.destroy()
fallback = popup()(parameters)
cached.instance.syncTheme(parameters.theme)
return cached.instance
}
// Different host — tear down old iframe and create fresh.
cached?.instance.destroy()
store = parameters.store
fallback = popup()(parameters)
let open = false
const referrer = getReferrer()
const hostUrl = new URL(host)
hostUrl.searchParams.set('chainId', String(store.getState().chainId))
hostUrl.searchParams.set('mode', 'iframe')
if (referrer.icon) {
if (typeof referrer.icon === 'string') hostUrl.searchParams.set('icon', referrer.icon)
else {
hostUrl.searchParams.set('icon', referrer.icon.light)
hostUrl.searchParams.set('iconDark', referrer.icon.dark)
}
}
applyThemeParams(hostUrl, parameters.theme)
const root = document.createElement('dialog')
root.dataset.tempoWallet = ''
root.setAttribute('role', 'dialog')
root.setAttribute('aria-closed', 'true')
root.setAttribute('aria-label', 'Tempo Wallet')
root.setAttribute('hidden', 'until-found')
Object.assign(root.style, {
background: 'transparent',
border: '0',
outline: '0',
padding: '0',
position: 'fixed',
})
const frame = document.createElement('iframe')
frame.dataset.testid = 'tempo-wallet'
frame.setAttribute(
'allow',
[
`publickey-credentials-get ${hostUrl.origin}`,
`publickey-credentials-create ${hostUrl.origin}`,
'clipboard-write',
'payment',
].join('; '),
)
frame.setAttribute('allowtransparency', 'true')
frame.setAttribute('tabindex', '0')
frame.setAttribute('title', 'Tempo Wallet')
frame.src = hostUrl.toString()
Object.assign(frame.style, {
backgroundColor: 'transparent',
border: '0',
colorScheme: parameters.theme?.scheme ?? 'light dark',
height: '100%',
left: '0',
position: 'fixed',
top: '0',
width: '100%',
})
const style = document.createElement('style')
style.innerHTML = `
dialog[data-tempo-wallet]::backdrop {
background: transparent!important;
}
`
root.appendChild(style)
root.appendChild(frame)
let readyResult: Messenger.ReadyOptions | undefined
let switchedToPopup = false
function createMessenger() {
readyResult = undefined
const m = Messenger.bridge({
from: Messenger.fromWindow(window, { targetOrigin: hostUrl.origin }),
to: Messenger.fromWindow(frame.contentWindow!, {
targetOrigin: hostUrl.origin,
}),
waitForReady: true,
})
m.on('rpc-response', (response) => {
const targetStore = findStoreForResponse(store!, previousStores, response.id)
handleResponse(targetStore, response)
})
m.waitForReady().then((result) => {
readyResult = result
if (result.colorScheme) frame.style.colorScheme = result.colorScheme
// Ask the wallet to verify the SDK's stored accounts are still valid.
syncAccounts(m)
})
m.on('sync', ({ valid }) => {
if (valid === false) store?.setState({ accessKeys: [], accounts: [], activeAccount: 0 })
})
m.on('switch-mode', () => {
hideDialog()
activatePage()
open = false
switchedToPopup = true
const pending = store
?.getState()
.requestQueue.filter(
(x): x is Store.QueuedRequest & { status: 'pending' } => x.status === 'pending',
)
if (pending && pending.length > 0) fallback?.syncRequests(pending)
})
return m
}
document.body.appendChild(root)
let messenger = createMessenger()
// Re-mount if removed (e.g. React hydration clears non-server-rendered elements).
// The iframe reloads on re-append, so the messenger must be re-established.
new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.removedNodes) {
if (node !== root) continue
document.body.appendChild(root)
messenger.destroy()
messenger = createMessenger()
return
}
}
}).observe(document.body, { childList: true })
let savedOverflow = ''
let opener: HTMLElement | null = null
const onBlur = () => handleBlur(store!)
// 1Password extension adds `inert` attribute to `dialog` rendering it unusable.
const inertObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type !== 'attributes') continue
if (mutation.attributeName !== 'inert') continue
root.removeAttribute('inert')
}
})
inertObserver.observe(root, { attributeOldValue: true, attributes: true })
// dialog/page interactivity (no visibility change)
let dialogActive = false
const activatePage = () => {
if (!dialogActive) return
dialogActive = false
root.removeEventListener('cancel', onBlur)
root.removeEventListener('click', onBlur)
root.style.pointerEvents = 'none'
opener?.focus()
opener = null
document.body.style.overflow = savedOverflow
}
const activateDialog = () => {
if (dialogActive) return
dialogActive = true
root.addEventListener('cancel', onBlur)
root.addEventListener('click', onBlur)
frame.focus()
root.style.pointerEvents = 'auto'
savedOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
}
// dialog visibility
let visible = false
const showDialog = () => {
if (visible) return
visible = true
if (document.activeElement instanceof HTMLElement) opener = document.activeElement
root.removeAttribute('hidden')
root.removeAttribute('aria-closed')
root.showModal()
}
const hideDialog = () => {
if (!visible) return
visible = false
root.setAttribute('hidden', 'true')
root.setAttribute('aria-closed', 'true')
root.close()
// 1Password extension sometimes adds `inert` to dialog siblings
// and does not clean up when dialog closes.
for (const sibling of root.parentNode ? Array.from(root.parentNode.children) : []) {
if (sibling === root) continue
if (!sibling.hasAttribute('inert')) continue
sibling.removeAttribute('inert')
}
}
const instance: Instance = {
close() {
fallback!.close()
open = false
hideDialog()
activatePage()
},
destroy() {
if (cached?.instance === instance) cached = undefined
fallback?.close()
open = false
activatePage()
hideDialog()
fallback?.destroy()
messenger.destroy()
root.remove()
inertObserver.disconnect()
store = undefined
fallback = undefined
previousStores = []
},
open() {
if (open) return
open = true
showDialog()
activateDialog()
},
async syncRequests(requests) {
if (switchedToPopup) {
fallback!.syncRequests(requests)
return
}
const { trustedHosts } = readyResult ?? (await messenger.waitForReady())
// Safari does not support WebAuthn credential creation in iframes.
if (
isSafari() &&
requests.some((x) => ['wallet_connect', 'eth_requestAccounts'].includes(x.request.method))
) {
fallback!.syncRequests(requests)
return
}
const ioSupported = IO.supported()
const hostname = window.location.hostname.replace(/^www\./, '')
const trusted = Boolean(
trustedHosts && TrustedHosts.match(trustedHosts, hostname, hostUrl.hostname),
)
const secure = ioSupported || trusted
if (!secure) {
console.warn(
[
`[accounts] Browser does not support IntersectionObserver v2 and "${window.location.hostname}" is not a trusted host.`,
'Falling back to popup dialog.',
'',
'To enable the iframe dialog, add your hostname to the trusted hosts list.',
].join('\n'),
)
fallback!.syncRequests(requests)
} else {
const requiresConfirm = requests.some((x) => x.status === 'pending')
if (!open && requiresConfirm) this.open()
messenger.send('rpc-requests', {
account: getAccount(store!),
chainId: store!.getState().chainId,
requests,
})
}
},
syncTheme(theme) {
frame.style.colorScheme = theme?.scheme ?? 'light dark'
messenger.send('theme', theme ?? {})
},
}
cached = { host, instance }
return instance
})
}
/** Opens the auth app in a new browser window. */
export function popup(options: popup.Options = {}): Dialog {
if (typeof window === 'undefined') return noop()
const { size = defaultSize } = options
return define({ name: 'popup' }, (parameters) => {
const { host, store } = parameters
let win: Window | null = null
const offDetectClosed = (() => {
const timer = setInterval(() => {
if (win?.closed) handleBlur(store)
}, 100)
return () => clearInterval(timer)
})()
let messenger: Messenger.Bridge | undefined
const overlay = document.createElement('div')
Object.assign(overlay.style, {
alignItems: 'center',
background: 'rgba(0, 0, 0, 0.5)',
color: 'white',
display: 'none',
flexDirection: 'column',
fontFamily: 'system-ui, sans-serif',
fontSize: '16px',
gap: '12px',
inset: '0',
justifyContent: 'center',
position: 'fixed',
zIndex: '2147483647',
})
const overlayMessage = document.createElement('p')
Object.assign(overlayMessage.style, { margin: '0' })
overlayMessage.textContent = 'Continue in the popup window'
const overlayClose = document.createElement('button')
Object.assign(overlayClose.style, {
background: 'none',
border: 'none',
color: 'white',
cursor: 'pointer',
font: 'inherit',
padding: '0',
textDecoration: 'underline',
})
overlayClose.textContent = 'Close'
overlayClose.addEventListener('click', () => handleBlur(store))
overlay.appendChild(overlayMessage)
overlay.appendChild(overlayClose)
document.body.appendChild(overlay)
return {
close() {
overlay.style.display = 'none'
if (!win) return
win.close()
win = null
},
destroy() {
this.close()
messenger?.destroy()
offDetectClosed()
overlay.remove()
},
open() {
messenger?.destroy()
win?.close()
const referrer = getReferrer()
const hostUrl = new URL(host)
hostUrl.searchParams.set('chainId', String(store.getState().chainId))
hostUrl.searchParams.set('mode', 'popup')
if (referrer.icon) {
if (typeof referrer.icon === 'string') hostUrl.searchParams.set('icon', referrer.icon)
else {
hostUrl.searchParams.set('icon', referrer.icon.light)
hostUrl.searchParams.set('iconDark', referrer.icon.dark)
}
}
applyThemeParams(hostUrl, parameters.theme)
const left = (window.innerWidth - size.width) / 2 + window.screenX
const top = window.screenY + 100
win = window.open(
hostUrl.toString(),
'_blank',
`width=${size.width},height=${size.height},left=${left},top=${top}`,
)
if (!win) throw new Error('Failed to open popup')
messenger = Messenger.bridge({
from: Messenger.fromWindow(window, { targetOrigin: hostUrl.origin }),
to: Messenger.fromWindow(win, { targetOrigin: hostUrl.origin }),
waitForReady: true,
})
messenger.on('rpc-response', (response) => handleResponse(store, response))
overlay.style.display = 'flex'
},
async syncRequests(requests) {
const requiresConfirm = requests.some((x) => x.status === 'pending')
if (requiresConfirm) {
if (!win || win.closed) this.open()
else win.focus()
}
messenger?.send('rpc-requests', {
account: getAccount(store),
chainId: store.getState().chainId,
requests,
})
},
syncTheme() {},
}
})
}
export declare namespace popup {
type Options = {
/** Popup window dimensions. @default `{ width: 360, height: 440 }` */
size?: { width: number; height: number } | undefined
}
}
/** Returns a no-op dialog for SSR environments. */
export function noop(): Dialog {
return define({ name: 'noop' }, () => ({
open() {},
close() {},
destroy() {},
async syncRequests() {},
syncTheme() {},
}))
}
/** Finds the store that owns the request matching the given response id. */
function findStoreForResponse(
current: Store.Store,
previous: Store.Store[],
id: number,
): Store.Store {
if (current.getState().requestQueue.some((q) => q.request.id === id)) return current
for (const s of previous) {
if (s.getState().requestQueue.some((q) => q.request.id === id)) return s
}
return current
}
/** Updates the store with an RPC response from the remote auth app. */
function handleResponse(
store: Store.Store,
response: { id: number; result?: unknown; error?: { code: number; message: string } | undefined },
) {
store.setState((x) => ({
...x,
requestQueue: x.requestQueue.map((queued) => {
if (queued.request.id !== response.id) return queued
if (response.error)
return {
request: queued.request,
error: response.error,
status: 'error' as const,
}
return {
request: queued.request,
result: response.result,
status: 'success' as const,
}
}),
}))
}
/** Marks all pending requests as rejected (user closed the dialog). */
function handleBlur(store: Store.Store) {
store.setState((x) => ({
...x,
requestQueue: x.requestQueue.map((queued) =>
queued.status === 'pending'
? {
request: queued.request,
error: { code: 4001, message: 'User rejected the request.' },
status: 'error' as const,
}
: queued,
),
}))
}
/** Sends stored account addresses to the wallet for session validation. */
function syncAccounts(messenger: Messenger.Bridge) {
if (!store) return
const { accounts } = store.getState()
if (accounts.length === 0) return
messenger.send('sync', { addresses: accounts.map((a) => a.address) })
}
/** Returns the active account from the store, or `undefined` if none. */
function getAccount(store: Store.Store): { address: string } | undefined {
const { accounts, activeAccount } = store.getState()
const account = accounts[activeAccount]
if (!account) return undefined
return { address: account.address }
}
/**
* Extracts referrer metadata from the host page.
* Must be called in the host page context (where `document` is accessible).
*/
function getReferrer(): getReferrer.ReturnType {
const icon = (() => {
const dark = document.querySelector(
'link[rel~="icon"][media="(prefers-color-scheme: dark)"]',
) as HTMLLinkElement | null
const light = (document.querySelector(
'link[rel~="icon"][media="(prefers-color-scheme: light)"]',
) ?? document.querySelector('link[rel~="icon"]')) as HTMLLinkElement | null
if (dark?.href && light?.href && dark.href !== light.href)
return { dark: dark.href, light: light.href }
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
return (isDark ? dark?.href : light?.href) ?? light?.href
})()
return { icon, title: document.title }
}
declare namespace getReferrer {
type ReturnType = {
/** Favicon URL, or separate light/dark URLs. */
icon: string | { light: string; dark: string } | undefined
/** Document title of the host page. */
title: string
}
}