UNPKG

accounts

Version:

Tempo Accounts SDK

647 lines (559 loc) 20.5 kB
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 } }