diff --git a/.gitignore b/.gitignore index 2c127bf..e7485f3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ test-results/ playwright-report/ playwright/.cache/ coverage/ + +# Claude Code +.claude/settings.local.json diff --git a/README.md b/README.md index 66b7774..548d3a1 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,22 @@ cd glow-web npm install ``` +### Set up environment variables + +1. Copy the example environment file: + +```bash +cp example.env .env.local +``` + +2. Edit `.env.local` and add your Breez API key (required): + +``` +VITE_BREEZ_API_KEY="your_breez_api_key_here" +``` + +See `example.env` for all available configuration options. + ### Start the development server ```bash diff --git a/example.env b/example.env new file mode 100644 index 0000000..cd33ee2 --- /dev/null +++ b/example.env @@ -0,0 +1,48 @@ +# Glow Web Environment Variables +# Copy this file to .env.local and fill in your values + +# ============================================================================= +# REQUIRED +# ============================================================================= + +# Breez API key for SDK initialization +# Get your key at: https://breez.technology/sdk/ +VITE_BREEZ_API_KEY=your_breez_api_key_here + +# ============================================================================= +# OPTIONAL +# ============================================================================= + +# Staging environment password (Preview deployments only) +# When set, users must enter this password to access the app +# VITE_STAGING_PASSWORD= + +# Console logging override +# "true" = always enabled, "false" = always disabled +# Default: enabled in dev mode, disabled in production +# VITE_CONSOLE_LOGGING=true + +# Passkey Relying Party ID for cross-app passkey sharing +# Enables passkeys created in Glow to work across other apps +# Requires server-side .well-known/webauthn configuration at the rpID domain +# Default: current hostname (passkeys only work on this domain) +# VITE_PASSKEY_RP_ID=keys.breez.technology + +# Base path for subpath deployment +# Must include leading and trailing slashes +# Default: / (root) +# VITE_BASE_PATH=/glow/ + +# ============================================================================= +# SERVER +# ============================================================================= + +# Server host binding +# Use "0.0.0.0" to allow LAN access (e.g., for testing on mobile devices) +# Default: localhost +# VITE_SERVER_HOST=0.0.0.0 + +# Allowed hosts for the server (comma-separated) +# Required when accessing via IP or custom hostname +# Example: 192.168.1.100,my-dev-machine.local +# VITE_SERVER_ALLOWED_HOSTS= diff --git a/package-lock.json b/package-lock.json index 7ff5a6c..ce47b26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "wasm-example-app", "version": "0.1.0", "dependencies": { - "@breeztech/breez-sdk-spark": "0.10.0", + "@breeztech/breez-sdk-spark": "^0.11.0-dev3", "@headlessui/react": "^1.7.17", "@zxing/browser": "^0.1.5", "@zxing/library": "^0.21.3", @@ -407,15 +407,16 @@ } }, "node_modules/@breeztech/breez-sdk-spark": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.10.0.tgz", - "integrity": "sha512-eBsh0oX2B8uGuWfCMmtH3SNXmSkED5du/CiWQKh1Ei1r0LsO6jlVnUmh94j7R5W4siIi7M6CC7ywll3FQ47rYQ==", + "version": "0.11.0-dev3", + "resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.11.0-dev3.tgz", + "integrity": "sha512-Caoyz2EBmipuCgg+sNSLGyPwtnbDzeEnBqMD8+tk3n0hsp90ttYr66jGcnkJsbXjGjhlO7QB0UK5PvW5bp7RvQ==", "license": "MIT", "engines": { "node": ">=22" }, "optionalDependencies": { - "better-sqlite3": "^12.2.0" + "better-sqlite3": "^12.2.0", + "pg": "^8.18.0" } }, "node_modules/@cspotcode/source-map-support": { @@ -8274,6 +8275,102 @@ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "optional": true, + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "optional": true, + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "optional": true, + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "optional": true, + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8530,6 +8627,49 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -9456,6 +9596,16 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">= 10.x" + } + }, "node_modules/srvx": { "version": "0.8.9", "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.8.9.tgz", @@ -11892,7 +12042,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.4" diff --git a/package.json b/package.json index 76c4945..197226a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test:e2e:debug": "playwright test --debug" }, "dependencies": { - "@breeztech/breez-sdk-spark": "0.10.0", + "@breeztech/breez-sdk-spark": "^0.11.0-dev3", "@headlessui/react": "^1.7.17", "@zxing/browser": "^0.1.5", "@zxing/library": "^0.21.3", diff --git a/src/App.tsx b/src/App.tsx index 436f69d..368dad4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,11 +14,13 @@ import RestorePage from './pages/RestorePage'; import GeneratePage from './pages/GeneratePage'; import GetRefundPage from './pages/GetRefundPage'; import BackupPage from './pages/BackupPage'; +import PasskeyPage from './pages/PasskeyPage'; import SettingsPage from './pages/SettingsPage'; import FiatCurrenciesPage from './pages/FiatCurrenciesPage'; import { useIOSViewportFix } from './hooks/useIOSViewportFix'; +import type { Seed } from '@breeztech/breez-sdk-spark'; -type Screen = 'home' | 'restore' | 'generate' | 'wallet' | 'getRefund' | 'settings' | 'backup' | 'fiatCurrencies'; +type Screen = 'home' | 'restore' | 'generate' | 'wallet' | 'getRefund' | 'settings' | 'backup' | 'fiatCurrencies' | 'passkey'; const AppContent: React.FC = () => { const [currentScreen, setCurrentScreen] = useState('home'); @@ -38,13 +40,19 @@ const AppContent: React.FC = () => { // Navigate to wallet after successful connect const handleConnect = async (mnemonic: string, restore: boolean) => { - await sdk.connectWallet(mnemonic, restore); + await sdk.connectWallet({ type: 'mnemonic', mnemonic }, restore); + setCurrentScreen('wallet'); + }; + + // Navigate to wallet after passkey connect + const handlePasskeyConnect = async (seed: Seed, walletName: string) => { + await sdk.connectWallet(seed, false, walletName); setCurrentScreen('wallet'); }; const handleLogout = async () => { - await sdk.handleLogout(); setCurrentScreen('home'); + await sdk.handleLogout(); }; // Render screens @@ -63,6 +71,16 @@ const AppContent: React.FC = () => { setCurrentScreen('restore')} onCreateNewWallet={() => setCurrentScreen('generate')} + onUsePasskey={() => setCurrentScreen('passkey')} + prfAvailable={sdk.prfAvailable} + /> + ); + + case 'passkey': + return ( + setCurrentScreen('home')} /> ); @@ -114,6 +132,16 @@ const AppContent: React.FC = () => { ); case 'wallet': + if (!sdk.isConnected) { + return ( + setCurrentScreen('restore')} + onCreateNewWallet={() => setCurrentScreen('generate')} + onUsePasskey={() => setCurrentScreen('passkey')} + prfAvailable={sdk.prfAvailable} + /> + ); + } return ( = ({ className = '', size = 'md' } // MISC ICONS // ============================================ +export const UploadIcon: React.FC = ({ className = '', size = 'md' }) => ( + + + +); + +export const EyeIcon: React.FC = ({ className = '', size = 'md' }) => ( + + + + +); + +export const FingerprintIcon: React.FC = ({ className = '', size = 'md' }) => ( + + + +); + export const CurrencyIcon: React.FC = ({ className = '', size = 'md' }) => ( diff --git a/src/components/SideMenu.tsx b/src/components/SideMenu.tsx index 25b1635..96b25fe 100644 --- a/src/components/SideMenu.tsx +++ b/src/components/SideMenu.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useLayoutEffect, useState, useRef } from 'react'; import { createPortal } from 'react-dom'; import { Transition } from '@headlessui/react'; +import { isPasskeyMode } from '@/services/passkeyService'; // Star positions around the logo (relative to center, in pixels) const STARS = [ { x: -28, y: -20, size: 3 }, @@ -29,6 +30,8 @@ const SideMenu: React.FC = ({ isOpen, onClose, onLogout, onOpenSe const [starsAnimating, setStarsAnimating] = useState(false); const prevIsOpenRef = useRef(false); + const isPasskey = isPasskeyMode(); + // Trigger star animation when sidebar opens useEffect(() => { if (isOpen && !prevIsOpenRef.current) { @@ -245,7 +248,9 @@ const SideMenu: React.FC = ({ isOpen, onClose, onLogout, onOpenSe Logout Warning

- Make sure you've saved your recovery phrase before logging out. You'll need it to access your funds again. + {isPasskey + ? "You'll need to authenticate with the same passkey to access your funds again." + : "Make sure you've saved your recovery phrase before logging out. You'll need it to access your funds again."}

diff --git a/src/hooks/useBreezSdk.ts b/src/hooks/useBreezSdk.ts index e0e86bd..5959c3d 100644 --- a/src/hooks/useBreezSdk.ts +++ b/src/hooks/useBreezSdk.ts @@ -3,13 +3,13 @@ import type { BreezSdk, Config, GetInfoResponse, - Network, Payment, SdkEvent, Rate, FiatCurrency, DepositInfo, LogEntry, + Seed, } from '@breeztech/breez-sdk-spark'; import { connect, initLogging } from '@breeztech/breez-sdk-spark'; import { useLatest } from './useLatest'; @@ -18,6 +18,14 @@ import { logger, LogCategory, logSdkMessage } from '../services/logger'; import { formatError } from '../utils/formatError'; import { isDepositRejected } from '../services/depositState'; import { hideSplash } from '../main'; +import { + isPrfAvailable, + isPasskeyMode, + setPasskeyMode, + clearPasskeyMode, + releasePasskey, + getWallet, +} from '../services/passkeyService'; // ============================================ // SDK logging (initialized once) @@ -58,10 +66,11 @@ export interface BreezSdkState { error: string | null; hasRejectedDeposits: boolean; celebrationAmount: number | null; + prfAvailable: boolean; } export interface BreezSdkActions { - connectWallet: (mnemonic: string, restore: boolean, overrideNetwork?: Network) => Promise; + connectWallet: (seed: Seed, restore: boolean, passkeyWalletName?: string) => Promise; refreshWalletData: (showLoading?: boolean) => Promise; fetchUnclaimedDeposits: () => Promise; handleLogout: () => Promise; @@ -91,6 +100,7 @@ export function useBreezSdk( const [config, setConfig] = useState(null); const [hasRejectedDeposits, setHasRejectedDeposits] = useState(false); const [celebrationAmount, setCelebrationAmount] = useState(null); + const [prfAvailable, setPrfAvailable] = useState(false); // Refs const isInitialLoadRef = useRef(true); @@ -202,7 +212,7 @@ export function useBreezSdk( // Connection lifecycle // ---------------------------------------- - const connectWallet = useCallback(async (mnemonic: string, restore: boolean, overrideNetwork?: Network) => { + const connectWallet = useCallback(async (seed: Seed, restore: boolean, passkeyWalletName?: string) => { let connectedSdk: BreezSdk | undefined; try { logger.info(LogCategory.SDK, 'Initiating wallet connection', { restore }); @@ -217,24 +227,31 @@ export function useBreezSdk( if (!import.meta.env.VITE_BREEZ_API_KEY) { showToast('error', 'Missing API Key', 'Please add VITE_BREEZ_API_KEY to your .env file'); + setIsLoading(false); + return; } initSdkLogging(); - const cfg = buildConnectConfig(overrideNetwork); + const cfg = buildConnectConfig(); setConfig(cfg); connectedSdk = await connect({ config: cfg, - seed: { type: 'mnemonic', mnemonic }, + seed, storageDir: 'spark-wallet-example', }); setSdk(connectedSdk); logger.sdkInitialized(); - logger.authSuccess('mnemonic'); + logger.authSuccess(seed.type); logger.info(LogCategory.SDK, 'Wallet connected successfully'); - saveMnemonic(mnemonic); + + if (passkeyWalletName != null) { + setPasskeyMode(passkeyWalletName); + } else if (seed.type === 'mnemonic') { + saveMnemonic(seed.mnemonic); + } const [info, txns] = await Promise.all([ connectedSdk.getInfo({}), @@ -245,7 +262,6 @@ export function useBreezSdk( setIsConnected(true); - // Fetch unclaimed deposits using the new SDK instance directly try { const result = await connectedSdk.listUnclaimedDeposits({}); const deposits = result.deposits; @@ -259,7 +275,7 @@ export function useBreezSdk( } catch (e) { const errorMsg = formatError(e); logger.error(LogCategory.SDK, 'Error connecting wallet', { error: errorMsg }); - logger.authFailure('mnemonic', errorMsg); + logger.authFailure(seed.type, errorMsg); // If SDK connected but a subsequent step failed, disconnect to avoid leaked instance if (connectedSdk) { @@ -267,7 +283,7 @@ export function useBreezSdk( setSdk(null); } - setError('Failed to connect wallet. Please check your mnemonic and try again.'); + setError('Failed to connect wallet. Please try again.'); setIsSyncing(false); setIsLoading(false); setConfig(null); @@ -293,6 +309,8 @@ export function useBreezSdk( // Always reset all state — even if disconnect threw setSdk(null); clearMnemonic(); + clearPasskeyMode(); + releasePasskey(); shownPaymentIdsRef.current.clear(); setIsConnected(false); setIsSyncing(false); @@ -331,6 +349,11 @@ export function useBreezSdk( return () => { document.body.setAttribute('data-lnurl-enabled', 'false'); }; }, [config?.lnurlDomain]); + // Check PRF availability on mount + useEffect(() => { + isPrfAvailable().then(setPrfAvailable).catch(() => setPrfAvailable(false)); + }, []); + // Auto-reconnect on mount useEffect(() => { logger.initSession().catch((e) => { @@ -342,13 +365,23 @@ export function useBreezSdk( if (savedMnemonic) { try { setIsLoading(true); - await connectWallet(savedMnemonic, false); + await connectWallet({ type: 'mnemonic', mnemonic: savedMnemonic }, false); } catch (e) { logger.error(LogCategory.SDK, 'Failed to connect with saved mnemonic', { error: formatError(e) }); setError('Failed to connect with saved mnemonic. Please try again.'); clearMnemonic(); setIsLoading(false); } + } else if (isPasskeyMode()) { + try { + setIsLoading(true); + const wallet = await getWallet(); + await connectWallet(wallet.seed, false, wallet.name); + } catch (e) { + logger.error(LogCategory.SDK, 'Failed to reconnect with passkey', { error: formatError(e) }); + setError('Failed to authenticate with passkey. Please try again.'); + setIsLoading(false); + } } else { setIsLoading(false); } @@ -411,6 +444,7 @@ export function useBreezSdk( error, hasRejectedDeposits, celebrationAmount, + prfAvailable, // Actions connectWallet, refreshWalletData, diff --git a/src/hooks/useSecretTap.ts b/src/hooks/useSecretTap.ts new file mode 100644 index 0000000..114843e --- /dev/null +++ b/src/hooks/useSecretTap.ts @@ -0,0 +1,32 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; + +export function useSecretTap(threshold = 5, timeoutMs = 2000, initialActivated: boolean | (() => boolean) = false) { + const [tapCount, setTapCount] = useState(0); + const [activated, setActivated] = useState(initialActivated); + const timerRef = useRef>(); + + const handleTap = useCallback(() => { + setTapCount(prev => { + const next = prev + 1; + if (next >= threshold) { + setActivated(a => !a); + return 0; + } + return next; + }); + + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setTapCount(0), timeoutMs); + }, [threshold, timeoutMs]); + + useEffect(() => { + return () => clearTimeout(timerRef.current); + }, []); + + const reset = useCallback(() => { + setTapCount(0); + setActivated(false); + }, []); + + return { handleTap, activated, tapCount, threshold, reset }; +} diff --git a/src/index.css b/src/index.css index 1e17307..11f3d2c 100644 --- a/src/index.css +++ b/src/index.css @@ -687,6 +687,27 @@ select { } } +@keyframes popover-in { + 0% { + opacity: 0; + transform: translateY(-8px) scale(0.9); + } + + 60% { + opacity: 1; + transform: translateY(2px) scale(1.02); + } + + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.animate-popover-in { + animation: popover-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + /* ============================================ UTILITY CLASSES ============================================ */ diff --git a/src/pages/BackupPage.tsx b/src/pages/BackupPage.tsx index 6af9fe2..16bfcde 100644 --- a/src/pages/BackupPage.tsx +++ b/src/pages/BackupPage.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; -import { WarningIcon } from '../components/Icons'; +import { WarningIcon, SpinnerIcon, EyeIcon, FingerprintIcon } from '../components/Icons'; import SlideInPage from '../components/layout/SlideInPage'; +import { isPasskeyMode, getWallet } from '@/services/passkeyService'; import { logger, LogCategory } from '@/services/logger'; interface BackupPageProps { @@ -11,10 +12,37 @@ const BackupPage: React.FC = ({ onBack }) => { const [mnemonic, setMnemonic] = useState(null); const [copied, setCopied] = useState(false); const [isRevealed, setIsRevealed] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const isPasskey = isPasskeyMode(); useEffect(() => { - setMnemonic(localStorage.getItem('walletMnemonic')); - }, []); + if (!isPasskey) { + setMnemonic(localStorage.getItem('walletMnemonic')); + } + }, [isPasskey]); + + const handleRevealPasskey = async () => { + setIsLoading(true); + setError(null); + try { + const w = await getWallet(); + if (w.seed.type === 'mnemonic' && w.seed.mnemonic) { + setMnemonic(w.seed.mnemonic); + setIsRevealed(true); + } else { + setError('Could not derive recovery phrase'); + } + } catch (e) { + logger.error(LogCategory.AUTH, 'Failed to derive mnemonic from passkey', { + error: e instanceof Error ? e.message : String(e), + }); + setError(e instanceof Error ? e.message : 'Failed to authenticate'); + } finally { + setIsLoading(false); + } + }; const handleCopy = async () => { if (!mnemonic) return; @@ -29,37 +57,71 @@ const BackupPage: React.FC = ({ onBack }) => { } }; + const handleHide = () => { + setIsRevealed(false); + if (isPasskey) { + setMnemonic(null); + } + }; + const words = mnemonic ? mnemonic.split(' ') : []; return (
- {/* Reveal toggle */} - {!isRevealed && mnemonic && ( + {/* Reveal button — passkey mode */} + {isPasskey && !isRevealed && !mnemonic && ( + + )} + + {/* Reveal button — mnemonic mode */} + {!isPasskey && !isRevealed && mnemonic && ( )} - {/* Mnemonic display */} + {/* Error message (passkey only) */} + {error && ( +
+

{error}

+
+ )} + + {/* Mnemonic word grid (shared) */} {isRevealed && mnemonic && (
Recovery Phrase
)} - {/* No mnemonic state */} - {!mnemonic && ( + {/* Passkey info card */} + {isPasskey && ( +
+
+
+ +
+
+

Passkey Protected

+

+ Your recovery phrase is derived from your passkey. To restore on another device, use your passkey or the recovery phrase above. +

+
+
+
+ )} + + {/* No backup found (mnemonic mode only) */} + {!isPasskey && !mnemonic && (
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 77159b7..fdb0cfc 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { useSecretTap } from '@/hooks/useSecretTap'; // Star positions around the logo (relative to center, in pixels) - larger radius for bigger logo const STARS = [ @@ -15,10 +16,19 @@ const STARS = [ interface HomePageProps { onRestoreWallet: () => void; onCreateNewWallet: () => void; + onUsePasskey: () => void; + prfAvailable: boolean; } -const HomePage: React.FC = ({ onRestoreWallet, onCreateNewWallet }) => { +const HomePage: React.FC = ({ + onRestoreWallet, + onCreateNewWallet, + onUsePasskey, + prfAvailable, +}) => { const [starsAnimating, setStarsAnimating] = useState(false); + const [showMnemonicOptions, setShowMnemonicOptions] = useState(false); + const { handleTap: handleLogoTap, activated: showPasskeyOptions } = useSecretTap(5, 2000); // Trigger star animation on mount useEffect(() => { @@ -84,6 +94,7 @@ const HomePage: React.FC = ({ onRestoreWallet, onCreateNewWallet src="/assets/Glow_Logo.png" alt="Glow" className="w-full h-full object-contain" + onClick={handleLogoTap} /> {/* Twinkling stars */} {STARS.map((star, i) => ( @@ -116,23 +127,55 @@ const HomePage: React.FC = ({ onRestoreWallet, onCreateNewWallet {/* CTA Buttons */}
- {/* Primary CTA */} - - - {/* Secondary CTA */} - + {prfAvailable && showPasskeyOptions && !showMnemonicOptions ? ( + <> + {/* Primary: Use Passkey */} + + + {/* Toggle to mnemonic options */} + + + ) : ( + <> + {/* Mnemonic flow */} + + + + + {/* Toggle back to passkey if PRF available */} + {prfAvailable && showPasskeyOptions && ( + + )} + + )}
diff --git a/src/pages/PasskeyPage.tsx b/src/pages/PasskeyPage.tsx new file mode 100644 index 0000000..a208751 --- /dev/null +++ b/src/pages/PasskeyPage.tsx @@ -0,0 +1,241 @@ +import React, { useEffect, useState } from 'react'; +import { Seed } from '@breeztech/breez-sdk-spark'; +import { PrimaryButton, SecondaryButton } from '../components/ui'; +import LoadingSpinner from '../components/LoadingSpinner'; +import PageLayout from '../components/layout/PageLayout'; +import { AlertCard } from '../components/AlertCard'; +import { UploadIcon, CheckIcon } from '../components/Icons'; +import { getWallet, listWalletNames, storeWalletName } from '@/services/passkeyService'; +import { logger, LogCategory } from '@/services/logger'; + +interface PasskeyPageProps { + onWalletRestored: (seed: Seed, walletName: string) => void; + onBack: () => void; +} + +const PasskeyPage: React.FC = ({ + onWalletRestored, + onBack, +}) => { + const [isLoading, setIsLoading] = useState(true); + const [isConnecting, setIsConnecting] = useState(false); + const [walletNames, setWalletNames] = useState([]); + const [selectedWalletName, setSelectedWalletName] = useState(null); + const [error, setError] = useState(null); + const [manualWalletName, setManualWalletName] = useState(''); + const [showManualInput, setShowManualInput] = useState(false); + + + // Fetch wallet names on mount + useEffect(() => { + const autoCreate = async () => { + setIsConnecting(true); + try { + const w = await getWallet(); + storeWalletName(w.name).catch((e) => + logger.warn(LogCategory.AUTH, 'Failed to store wallet name', { + error: e instanceof Error ? e.message : String(e), + }), + ); + onWalletRestored(w.seed, w.name); + } catch (e) { + setError('Failed to set up wallet'); + logger.error(LogCategory.AUTH, 'Auto-create wallet failed', { + error: e instanceof Error ? e.message : String(e), + }); + setIsConnecting(false); + } + }; + + const fetchWalletNames = async () => { + setIsLoading(true); + try { + const names = await listWalletNames(); + setWalletNames(names); + + if (names.length === 0) { + // No wallets — auto-create default + setIsLoading(false); + await autoCreate(); + return; + } + + // Pre-select "Default" if present, otherwise first + const defaultIdx = names.indexOf('Default'); + setSelectedWalletName(defaultIdx !== -1 ? names[defaultIdx] : names[0]); + } catch (e) { + setError('Failed to discover wallets'); + logger.error(LogCategory.AUTH, 'Failed to list wallet names', { + error: e instanceof Error ? e.message : String(e), + }); + } finally { + setIsLoading(false); + } + }; + + fetchWalletNames(); + }, [onWalletRestored]); + + const handleConnect = async () => { + const manualName = manualWalletName.trim(); + const nameToUse = manualName || selectedWalletName; + if (!nameToUse) return; + + setIsConnecting(true); + setError(null); + + try { + if (manualName) { + storeWalletName(nameToUse).catch((e) => + logger.warn(LogCategory.AUTH, 'Failed to store wallet name', { + error: e instanceof Error ? e.message : String(e), + }), + ); + } + const w = await getWallet(nameToUse); + logger.info(LogCategory.AUTH, 'Passkey wallet derived'); + onWalletRestored(w.seed, w.name); + } catch (e) { + setError('Failed to connect'); + logger.error(LogCategory.AUTH, 'Passkey wallet restore failed', { + error: e instanceof Error ? e.message : String(e), + }); + setIsConnecting(false); + } + }; + + if (isLoading) { + return ( + } title="Passkey"> +
+ +
+
+ ); + } + + if (isConnecting) { + return ( + } title="Passkey"> +
+ +
+
+ ); + } + + // 1+ wallets — unified selection list + create option + const canConnect = !!(manualWalletName.trim() || selectedWalletName); + const footer = ( +
+ + Connect + + + Go Back + +
+ ); + + return ( + +
+
+
+ +
+
+ +
+

+ Select Wallet +

+
+ +
+ {walletNames.map((name) => ( + + ))} + + {/* Create new wallet */} + {!showManualInput ? ( + + ) : ( +
+
+ + Create a new wallet + +
+ {manualWalletName.trim() && ( + + )} +
+
+ setManualWalletName(e.target.value)} + placeholder="Wallet name" + className="w-full bg-spark-surface border border-spark-border rounded-xl px-3 py-2 text-spark-text-primary placeholder:text-spark-text-muted focus:outline-none focus:ring-2 focus:ring-spark-primary/50 focus:border-spark-primary text-sm" + autoFocus + /> +
+ )} +
+ + {error && ( + +

Please ensure your device supports passkeys and is the correct device

+
+ )} +
+
+ ); +}; + +export default PasskeyPage; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6996dc7..3780406 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -7,8 +7,8 @@ import { CurrencyIcon, ChevronRightIcon, DownloadIcon } from '../components/Icon import SlideInPage from '../components/layout/SlideInPage'; import { logger, LogCategory } from '@/services/logger'; import { shareOrDownloadLogs } from '@/services/logExport'; +import { useSecretTap } from '@/hooks/useSecretTap'; -const DEV_MODE_TAP_COUNT = 5; const DEV_MODE_STORAGE_KEY = 'spark-dev-mode'; interface SettingsPageProps { @@ -19,8 +19,15 @@ interface SettingsPageProps { const SettingsPage: React.FC = ({ onBack, config, onOpenFiatCurrencies }) => { const wallet = useWallet(); - const [isDevMode, setIsDevMode] = useState(false); - const [devTapCount, setDevTapCount] = useState(0); + const { + handleTap: devTap, + activated: isDevMode, + tapCount: devTapCount, + threshold: devTapThreshold, + } = useSecretTap(5, 2000, () => + new URLSearchParams(window.location.search).get('dev') === 'true' + || localStorage.getItem(DEV_MODE_STORAGE_KEY) === 'true' + ); const [selectedNetwork, setSelectedNetwork] = useState('mainnet'); const [feeType, setFeeType] = useState<'fixed' | 'rate' | 'networkRecommended'>('fixed'); const [feeValue, setFeeValue] = useState('1'); @@ -34,11 +41,6 @@ const SettingsPage: React.FC = ({ onBack, config, onOpenFiatC useEffect(() => { const params = new URLSearchParams(window.location.search); - // Check URL param or localStorage for dev mode - const urlDevMode = params.get('dev') === 'true'; - const storedDevMode = localStorage.getItem(DEV_MODE_STORAGE_KEY) === 'true'; - setIsDevMode(urlDevMode || storedDevMode); - // Get current network from URL const network = (params.get('network') || 'mainnet') as Network; setSelectedNetwork(network); @@ -88,25 +90,10 @@ const SettingsPage: React.FC = ({ onBack, config, onOpenFiatC })(); }, [config, wallet]); - const handleVersionTap = () => { - setDevTapCount(prev => { - const newCount = prev + 1; - - if (newCount >= DEV_MODE_TAP_COUNT) { - setIsDevMode(current => { - const newDevMode = !current; - localStorage.setItem(DEV_MODE_STORAGE_KEY, String(newDevMode)); - return newDevMode; - }); - return 0; - } - - return newCount; - }); - - // Reset tap count after 2 seconds of inactivity - setTimeout(() => setDevTapCount(0), 2000); - }; + // Persist dev mode to localStorage when toggled via secret tap + useEffect(() => { + localStorage.setItem(DEV_MODE_STORAGE_KEY, String(isDevMode)); + }, [isDevMode]); const handleNetworkChange = (network: Network) => { setSelectedNetwork(network); @@ -331,15 +318,15 @@ const SettingsPage: React.FC = ({ onBack, config, onOpenFiatC {/* Version / Dev Mode Toggle */}
- {devTapCount > 0 && devTapCount < DEV_MODE_TAP_COUNT && ( + {devTapCount > 0 && devTapCount < devTapThreshold && (

- {DEV_MODE_TAP_COUNT - devTapCount} more taps to {isDevMode ? 'disable' : 'enable'} dev mode + {devTapThreshold - devTapCount} more taps to {isDevMode ? 'disable' : 'enable'} dev mode

)}
diff --git a/src/services/passkeyPrfProvider.ts b/src/services/passkeyPrfProvider.ts new file mode 100644 index 0000000..5ba2d45 --- /dev/null +++ b/src/services/passkeyPrfProvider.ts @@ -0,0 +1,176 @@ +/** + * WebAuthn PRF Provider for passkey-based wallet operations. + * + * Implements the PasskeyPrfProvider interface from the Breez SDK + * using the browser's WebAuthn API with PRF extension. + */ + +import { PasskeyPrfProvider } from '@breeztech/breez-sdk-spark'; +import { logger, LogCategory } from './logger'; + +// RP (Relying Party) configuration +const RP_NAME = 'Glow'; +// Configurable rpID for cross-app passkey sharing (requires server-side .well-known/webauthn) +const RP_ID = import.meta.env.VITE_PASSKEY_RP_ID || window.location.hostname; +logger.info(LogCategory.AUTH, 'Passkey RP_ID configured', { rpId: RP_ID }); + +/** + * Browser implementation of PasskeyPrfProvider using WebAuthn PRF extension. + * + * Uses discoverable credentials (resident keys) so no credential ID storage is needed. + */ +class BrowserPasskeyPrfProvider implements PasskeyPrfProvider { + /** + * Check if PRF-capable passkey is available on this device. + */ + async isPrfAvailable(): Promise { + // Check basic WebAuthn support + if (typeof window === 'undefined' || !window.PublicKeyCredential) { + logger.debug(LogCategory.AUTH, 'WebAuthn not supported'); + return false; + } + + try { + // Check if platform authenticator is available + const platformAvailable = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + if (!platformAvailable) { + logger.debug(LogCategory.AUTH, 'Platform authenticator not available'); + return false; + } + + // Note: getClientCapabilities() doesn't report PRF extension support + // (it reports client capabilities like hybridTransport, conditionalGet, etc.) + // PRF support is an authenticator/extension feature confirmed during credential creation. + // + // We assume PRF is available if platform authenticator exists. + // If PRF isn't actually supported, we'll get an error during create. + logger.debug(LogCategory.AUTH, 'Platform authenticator available, assuming PRF supported'); + return true; + } catch (e) { + logger.warn(LogCategory.AUTH, 'Error checking PRF availability', { + error: e instanceof Error ? e.message : String(e), + }); + return false; + } + } + + /** + * Derive a 32-byte seed from passkey PRF with the given salt. + * + * This will prompt the user for authentication (biometric, PIN, etc.) + * and then evaluate the PRF extension with the provided salt. + * + * Flow per Yubico PRF guide: + * 1. Registration ceremony (create) must happen first + * 2. Authentication ceremony (get) with PRF eval + */ + async derivePrfSeed(salt: string): Promise { + logger.info(LogCategory.AUTH, 'Deriving PRF seed'); + + // Try get() first to show the passkey picker (includes cross-device + // passkeys like iCloud Keychain). If no existing passkey is found, the + // browser requires the user to cancel the get() prompt before we can call + // create() to register a new passkey — this is a browser limitation, not + // a user error, so NotAllowedError is the expected path for first-time users. + try { + return await this.derivePrfSeedWithExistingPasskey(salt); + } catch (e) { + logger.info(LogCategory.AUTH, 'No existing passkey found, creating new one'); + return this.createAndDerivePrfSeed(salt); + } + } + + /** + * Create a new passkey and derive PRF seed. + * Called when no existing passkey is found. + */ + private async createAndDerivePrfSeed(salt: string): Promise { + logger.info(LogCategory.AUTH, 'Creating new passkey with PRF'); + + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const userId = crypto.getRandomValues(new Uint8Array(16)); + + // Create a new passkey with PRF enabled + const credential = await navigator.credentials.create({ + publicKey: { + challenge, + rp: { + name: RP_NAME, + id: RP_ID, + }, + user: { + id: userId, + name: RP_NAME, + displayName: RP_NAME, + }, + pubKeyCredParams: [ + { alg: -7, type: 'public-key' }, // ES256 + { alg: -257, type: 'public-key' }, // RS256 + ], + authenticatorSelection: { + userVerification: 'required', + residentKey: 'required', + }, + extensions: { + prf: {}, + }, + }, + }) as PublicKeyCredential; + + // Check if PRF was enabled + const extResults = credential.getClientExtensionResults() as { + prf?: { enabled?: boolean }; + }; + + if (!extResults.prf?.enabled) { + logger.error(LogCategory.AUTH, 'PRF extension not supported by authenticator'); + throw new Error('PRF extension not supported by this authenticator'); + } + + logger.info(LogCategory.AUTH, 'Passkey created with PRF support'); + + // Now derive the seed using the newly created passkey + return this.derivePrfSeedWithExistingPasskey(salt); + } + + /** + * Derive PRF seed using an existing passkey. + */ + private async derivePrfSeedWithExistingPasskey(salt: string): Promise { + const saltBytes = new TextEncoder().encode(salt); + const challenge = crypto.getRandomValues(new Uint8Array(32)); + + const credential = await navigator.credentials.get({ + publicKey: { + challenge, + rpId: RP_ID, + allowCredentials: [], + userVerification: 'required', + extensions: { + prf: { + eval: { + first: saltBytes, + }, + }, + }, + }, + }) as PublicKeyCredential; + + const extResults = credential.getClientExtensionResults() as { + prf?: { results?: { first?: ArrayBuffer } }; + }; + + if (!extResults.prf?.results?.first) { + throw new Error('PRF evaluation failed after passkey creation'); + } + + logger.info(LogCategory.AUTH, 'PRF seed derived after passkey creation'); + return new Uint8Array(extResults.prf.results.first); + } +} + +// Export singleton instance +export const passkeyPrfProvider = new BrowserPasskeyPrfProvider(); + +// Export class for testing +export { BrowserPasskeyPrfProvider }; diff --git a/src/services/passkeyService.ts b/src/services/passkeyService.ts new file mode 100644 index 0000000..ee4f8d5 --- /dev/null +++ b/src/services/passkeyService.ts @@ -0,0 +1,124 @@ +/** + * Passkey Service. + * + * Wraps the Breez SDK's Passkey class to provide + * passkey-based wallet creation and restoration functionality. + * + * Uses a singleton Passkey instance so that multiple operations + * (list, store, getWallet) share a single PRF auth session. + */ + +import { Passkey, Wallet, NostrRelayConfig } from '@breeztech/breez-sdk-spark'; +import { passkeyPrfProvider } from './passkeyPrfProvider'; +import { logger, LogCategory } from './logger'; + +// Storage key — presence signals passkey mode +const PASSKEY_WALLET_NAME_KEY = 'passkeyWalletName'; + +// Singleton Passkey instance +let passkeyInstance: Passkey | null = null; + +/** + * Get or create the singleton Passkey instance. + */ +function getOrCreatePasskey(): Passkey { + if (!passkeyInstance) { + const breezApiKey = import.meta.env.VITE_BREEZ_API_KEY; + const relayConfig: NostrRelayConfig | undefined = breezApiKey + ? { breezApiKey } + : undefined; + passkeyInstance = new Passkey(passkeyPrfProvider, relayConfig ?? null); + } + return passkeyInstance; +} + +/** + * Release the singleton Passkey instance. + * Nulls the reference so a fresh instance is created next time. + * WASM memory is reclaimed via FinalizationRegistry when GC runs. + */ +export function releasePasskey(): void { + passkeyInstance = null; +} + +/** + * Check if PRF (passkey) authentication is available on this device. + */ +export async function isPrfAvailable(): Promise { + // Firefox's PRF support is still unreliable — disable until stable + const ua = navigator.userAgent; + if (/Firefox\//i.test(ua) && !/Seamonkey\//i.test(ua)) { + return false; + } + + const passkey = getOrCreatePasskey(); + return await passkey.isAvailable(); +} + +/** + * Check if the app is in passkey mode. + * Passkey mode is signalled by a stored wallet name. + */ +export function isPasskeyMode(): boolean { + return localStorage.getItem(PASSKEY_WALLET_NAME_KEY) !== null; +} + +/** + * Set passkey mode by storing the wallet name. + */ +export function setPasskeyMode(walletName?: string): void { + localStorage.setItem(PASSKEY_WALLET_NAME_KEY, walletName ?? 'Default'); +} + +/** + * Clear passkey mode. + * Does NOT clear the persistent "passkey registered" flag — the passkey + * still exists on the device and should be reused on next login. + */ +export function clearPasskeyMode(): void { + localStorage.removeItem(PASSKEY_WALLET_NAME_KEY); +} + +/** + * List available wallet names from nostr relays. + */ +export async function listWalletNames(): Promise { + logger.info(LogCategory.AUTH, 'Listing wallet names from nostr relays'); + const passkey = getOrCreatePasskey(); + return await passkey.listWalletNames(); +} + +/** + * Store a wallet name to nostr relays so it can be discovered later. + */ +export async function storeWalletName(walletName: string): Promise { + logger.info(LogCategory.AUTH, 'Storing wallet name to nostr relays'); + const passkey = getOrCreatePasskey(); + await passkey.storeWalletName(walletName); +} + +/** + * Derive a Wallet using passkey authentication. + * + * Falls back to saved wallet name from localStorage when no name arg provided. + * + * @param walletName - Optional wallet name. If omitted, uses saved name or SDK default. + * @returns The derived Wallet object containing seed and name. + */ +export async function getWallet(walletName?: string): Promise { + const effectiveName = walletName ?? localStorage.getItem(PASSKEY_WALLET_NAME_KEY) ?? undefined; + + logger.info(LogCategory.AUTH, 'Deriving wallet via passkey'); + + const passkey = getOrCreatePasskey(); + try { + const wallet = await passkey.getWallet(effectiveName); + logger.info(LogCategory.AUTH, 'Passkey wallet derived successfully'); + return wallet; + } catch (e) { + logger.error(LogCategory.AUTH, 'Failed to derive passkey wallet', { + error: e instanceof Error ? e.message : String(e), + }); + throw e; + } +} diff --git a/vite.config.ts b/vite.config.ts index eb3487a..df088ce 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,47 +1,56 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; import wasm from 'vite-plugin-wasm'; import topLevelAwait from 'vite-plugin-top-level-await'; import { nodePolyfills } from 'vite-plugin-node-polyfills' // https://vitejs.dev/config/ -export default defineConfig({ - plugins: [ - react(), - wasm(), - topLevelAwait(), - nodePolyfills() - ], - server: { - headers: { - 'Cross-Origin-Embedder-Policy': 'require-corp', - 'Cross-Origin-Opener-Policy': 'same-origin', - }, - fs: { - // Allow serving files from project root and node_modules - allow: ['..'], +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + + return { + base: env.VITE_BASE_PATH || '/', + plugins: [ + react(), + wasm(), + topLevelAwait(), + nodePolyfills() + ], + server: { + host: env.VITE_SERVER_HOST || 'localhost', + allowedHosts: env.VITE_SERVER_ALLOWED_HOSTS + ? env.VITE_SERVER_ALLOWED_HOSTS.split(',') + : [], + headers: { + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Opener-Policy': 'same-origin', + }, + fs: { + // Allow serving files from project root and node_modules + allow: ['..'], + }, }, - }, - resolve: { - alias: { - '@': '/src', + resolve: { + alias: { + '@': '/src', + }, }, - }, - build: { - target: 'esnext', - outDir: 'dist', - assetsDir: 'assets', - sourcemap: true, - chunkSizeWarningLimit: 1700, - rollupOptions: { - output: { - manualChunks: { - polyfills: ['buffer', 'process', 'events', 'stream-browserify'], + build: { + target: 'esnext', + outDir: 'dist', + assetsDir: 'assets', + sourcemap: true, + chunkSizeWarningLimit: 1700, + rollupOptions: { + output: { + manualChunks: { + polyfills: ['buffer', 'process', 'events', 'stream-browserify'], + }, }, }, }, - }, - optimizeDeps: { - exclude: ['@breeztech/breez-sdk-spark'], - } + optimizeDeps: { + exclude: ['@breeztech/breez-sdk-spark'], + } + }; });