diff --git a/apps/web-wallet/app/entry.client.tsx b/apps/web-wallet/app/entry.client.tsx index d75a4f689..d060ee856 100644 --- a/apps/web-wallet/app/entry.client.tsx +++ b/apps/web-wallet/app/entry.client.tsx @@ -1,4 +1,3 @@ -import { configure } from '@agicash/opensecret'; import { configureFeatureFlags, ensureBreezWasm, @@ -15,6 +14,9 @@ import { HydratedRouter } from 'react-router/dom'; import { getEnvironment, isServedLocally } from './environment'; import { agicashDbClient } from './features/agicash-db/database.client'; import { loadFeatureFlags } from './features/shared/feature-flags'; +// Importing the module constructs the SDK, which configures Open Secret as an +// import-evaluation side effect — before any body code below runs. +import './features/shared/sdk.client'; import { registerMoneyDevToolsFormatter } from './lib/money-devtools-formatter'; import { getTracesSampleRate, sanitizeUrl } from './tracing-utils'; @@ -23,21 +25,6 @@ if (process.env.NODE_ENV === 'development') { registerMoneyDevToolsFormatter(); } -const openSecretApiUrl = import.meta.env.VITE_OPEN_SECRET_API_URL ?? ''; -if (!openSecretApiUrl) { - throw new Error('VITE_OPEN_SECRET_API_URL is not set'); -} - -const openSecretClientId = import.meta.env.VITE_OPEN_SECRET_CLIENT_ID ?? ''; -if (!openSecretClientId) { - throw new Error('VITE_OPEN_SECRET_CLIENT_ID is not set'); -} - -configure({ - apiUrl: openSecretApiUrl, - clientId: openSecretClientId, -}); - // Start Breez WASM fetch/compile as early as possible so it overlaps with // hydration, Sentry init, and the auth query — by the time the _protected // middleware awaits it, init is often already done. diff --git a/apps/web-wallet/app/features/agicash-db/database.client.ts b/apps/web-wallet/app/features/agicash-db/database.client.ts index ae18659f7..8f87bd3c8 100644 --- a/apps/web-wallet/app/features/agicash-db/database.client.ts +++ b/apps/web-wallet/app/features/agicash-db/database.client.ts @@ -24,9 +24,9 @@ const getSupabaseUrl = () => { return supabaseUrl; }; -const supabaseUrl = getSupabaseUrl(); +export const supabaseUrl = getSupabaseUrl(); -const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? ''; +export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? ''; if (!supabaseAnonKey) { throw new Error('VITE_SUPABASE_ANON_KEY is not set'); } diff --git a/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts b/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts index beccc2f19..ad7f9276d 100644 --- a/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts +++ b/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts @@ -5,6 +5,11 @@ import { sumProofs, } from '@agicash/cashu'; import type { Money } from '@agicash/money'; +import { + type LongTimeout, + clearLongTimeout, + setLongTimeout, +} from '@agicash/utils'; import type { CashuAccount, CashuReceiveQuote, @@ -34,11 +39,6 @@ import { import { useCallback, useEffect, useMemo, useState } from 'react'; import { useOnMeltQuoteStateChange } from '~/lib/cashu/melt-quote-subscription'; import { MintQuoteSubscriptionManager } from '~/lib/cashu/mint-quote-subscription-manager'; -import { - type LongTimeout, - clearLongTimeout, - setLongTimeout, -} from '~/lib/timeout'; import { useLatest } from '~/lib/use-latest'; import { withRetry } from '~/lib/with-retry'; import { diff --git a/apps/web-wallet/app/features/shared/auth.ts b/apps/web-wallet/app/features/shared/auth.ts index 69f52f6ae..64ecf0404 100644 --- a/apps/web-wallet/app/features/shared/auth.ts +++ b/apps/web-wallet/app/features/shared/auth.ts @@ -1,12 +1,19 @@ -import { jwtDecode } from 'jwt-decode'; +import { safeJwtDecode } from '@agicash/utils'; -/** Check if the user is logged in by verifying localStorage tokens. */ +/** + * Check if the user is logged in by verifying localStorage tokens. A corrupt + * stored token counts as logged out — this feeds the DB client's token + * getter, so it must never throw into every query. + * + * Temporary leak: reads Open Secret's storage keys directly until the late + * SDK-migration steps retire the web's own DB client. + */ export const isLoggedIn = (): boolean => { const accessToken = window.localStorage.getItem('access_token'); const refreshToken = window.localStorage.getItem('refresh_token'); if (!accessToken || !refreshToken) { return false; } - const decoded = jwtDecode(refreshToken); - return !!decoded.exp && decoded.exp * 1000 > Date.now(); + const decoded = safeJwtDecode(refreshToken); + return !!decoded?.exp && decoded.exp * 1000 > Date.now(); }; diff --git a/apps/web-wallet/app/features/shared/sdk.client.ts b/apps/web-wallet/app/features/shared/sdk.client.ts new file mode 100644 index 000000000..491029edd --- /dev/null +++ b/apps/web-wallet/app/features/shared/sdk.client.ts @@ -0,0 +1,57 @@ +import { browserStorage } from '@agicash/opensecret'; +import { AgicashSdk } from '@agicash/wallet-sdk'; +import { + supabaseAnonKey, + supabaseUrl, +} from '~/features/agicash-db/database.client'; +import { breezApiKey } from '~/lib/breez'; + +const openSecretApiUrl = import.meta.env.VITE_OPEN_SECRET_API_URL ?? ''; +if (!openSecretApiUrl) { + throw new Error('VITE_OPEN_SECRET_API_URL is not set'); +} + +const openSecretClientId = import.meta.env.VITE_OPEN_SECRET_CLIENT_ID ?? ''; +if (!openSecretClientId) { + throw new Error('VITE_OPEN_SECRET_CLIENT_ID is not set'); +} + +const consoleLogger = { + debug: (message: string, meta?: unknown) => + meta === undefined ? console.debug(message) : console.debug(message, meta), + info: (message: string, meta?: unknown) => + meta === undefined ? console.info(message) : console.info(message, meta), + warn: (message: string, meta?: unknown) => + meta === undefined ? console.warn(message) : console.warn(message, meta), + error: (message: string, meta?: unknown) => + meta === undefined ? console.error(message) : console.error(message, meta), +}; + +export const sdk = AgicashSdk.create({ + db: { + url: supabaseUrl, + anonKey: supabaseAnonKey, + }, + auth: { + apiUrl: openSecretApiUrl, + clientId: openSecretClientId, + storage: browserStorage, + // e2e bridge: the Playwright fixture arms window.getMockPassword; in + // production it's absent, so this resolves null and the SDK generates. + generateGuestPassword: async () => + (await window.getMockPassword?.()) ?? null, + }, + spark: { + breezApiKey, + network: 'MAINNET', + }, + lightningAddressDomain: window.location.host, + logger: consoleLogger, +}); + +if (import.meta.hot) { + // A hot reload of this module constructs a second SDK; dispose the old one + // so its expiry timer doesn't leak. Returning the promise makes Vite await + // the teardown before evaluating the replacement module. + import.meta.hot.dispose(() => sdk.dispose()); +} diff --git a/apps/web-wallet/app/features/signup/verify-email.ts b/apps/web-wallet/app/features/signup/verify-email.ts index c619e8a4a..a48559078 100644 --- a/apps/web-wallet/app/features/signup/verify-email.ts +++ b/apps/web-wallet/app/features/signup/verify-email.ts @@ -1,8 +1,8 @@ -import { verifyEmail as osVerifyEmail } from '@agicash/opensecret'; import type { FullUser } from '@agicash/wallet-sdk'; import { shouldVerifyEmail } from '@agicash/wallet-sdk'; import { useState } from 'react'; import { createContext, redirect } from 'react-router'; +import { sdk } from '~/features/shared/sdk.client'; import { useToast } from '~/hooks/use-toast'; import type { Route } from '../../routes/+types/_protected.verify-email.($code)'; import { invalidateAuthQueries } from '../user/auth'; @@ -37,7 +37,7 @@ export const verifyEmail = async ( code: string, ): Promise<{ verified: true } | { verified: false; error: Error }> => { try { - await osVerifyEmail(code); + await sdk.auth.verifyEmail(code); await invalidateAuthQueries(); return { verified: true }; } catch (e) { diff --git a/apps/web-wallet/app/features/user/auth.ts b/apps/web-wallet/app/features/user/auth.ts index dee12f0dd..9fdc266c8 100644 --- a/apps/web-wallet/app/features/user/auth.ts +++ b/apps/web-wallet/app/features/user/auth.ts @@ -1,19 +1,5 @@ -import { - type UserResponse, - fetchUser, - convertGuestToUserAccount as osConvertGuestToFullAccount, - initiateGoogleAuth as osInitiateGoogleAuth, - signIn as osSignIn, - signInGuest as osSignInGuest, - signOut as osSignOut, - signUp as osSignUp, - signUpGuest as osSignUpGuest, - verifyEmail as osVerifyEmail, -} from '@agicash/opensecret'; -import { - clearAgicashMintAuthToken, - clearSparkWallets, -} from '@agicash/wallet-sdk/temporary'; +import { safeJwtDecode } from '@agicash/utils'; +import type { AuthUser } from '@agicash/wallet-sdk'; import * as Sentry from '@sentry/react-router'; import { decodeURLSafe, encodeURLSafe } from '@stablelib/base64'; import { @@ -21,26 +7,26 @@ import { useQueryClient, useSuspenseQuery, } from '@tanstack/react-query'; -import { jwtDecode } from 'jwt-decode'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useNavigate, useRevalidator } from 'react-router'; import { loadFeatureFlags, resetFeatureFlags, } from '~/features/shared/feature-flags'; import { getQueryClient } from '~/features/shared/query-client'; -import { useLongTimeout } from '~/hooks/use-long-timeout'; -import { generateRandomPassword } from '~/lib/password-generator'; -import { guestAccountStorage } from './guest-account-storage'; +import { sdk } from '~/features/shared/sdk.client'; +import { useLatest } from '~/lib/use-latest'; import { oauthLoginSessionStorage } from './oauth-login-session-storage'; import { sessionHintCookie } from './session-hint-cookie'; -export type AuthUser = UserResponse['user']; +export type { AuthUser }; type AuthState = | { isLoggedIn: true; user: AuthUser; + /** Unix seconds, captured at fetch time; drives the hint-cookie lifetime and query staleness. */ + refreshTokenExpiresAt: number | null; } | { isLoggedIn: false; @@ -49,43 +35,83 @@ type AuthState = export const authStateQueryKey = 'auth-state'; +// The stored token may be corrupt — safeJwtDecode keeps it from throwing out +// of the queryFn or staleTime callback (that would error-page every route, +// /login included). +// Temporary leak: reads Open Secret's storage keys directly until step 18 +// exposes the refresh-token expiry on the SDK session. +const getRefreshTokenExpiry = (): number | null => { + const refreshToken = window.localStorage.getItem('refresh_token'); + if (!refreshToken) { + return null; + } + return safeJwtDecode(refreshToken)?.exp ?? null; +}; + export const authQueryOptions = () => queryOptions({ queryKey: [authStateQueryKey], - queryFn: async () => { - const access_token = window.localStorage.getItem('access_token'); - const refresh_token = window.localStorage.getItem('refresh_token'); - if (!access_token || !refresh_token) { - sessionHintCookie.clear(); - return { isLoggedIn: false } as const; + queryFn: async (): Promise => { + // Associate Sentry events with the user as early as possible, before + // session restore completes. + // Temporary leak: reads Open Secret's storage keys directly until + // step 18 exposes a pre-restore session hint on the SDK. + const accessToken = window.localStorage.getItem('access_token'); + const sub = accessToken ? safeJwtDecode(accessToken)?.sub : undefined; + if (sub) { + Sentry.setUser({ id: sub }); } try { - // We want to set Sentry user id here to make sure that Sentry events are associated with the user as soon as possible. - const { sub } = jwtDecode(access_token); - Sentry.setUser({ id: sub }); + await sdk.init(); + } catch (error) { + // Restore failed with tokens present (e.g. a network blip at boot). + // Boot anonymous; init()'s rejection is not memoized, so a later + // invalidateAuthQueries() retries the restore. + console.error('Failed to restore session', { cause: error }); + Sentry.setUser(null); + sessionHintCookie.clear(); + return { isLoggedIn: false }; + } + const session = sdk.auth.getSession(); - const response = await fetchUser(); + if (!session.isLoggedIn) { + Sentry.setUser(null); + sessionHintCookie.clear(); + return { isLoggedIn: false }; + } - // Set Sentry user again to include the isGuest flag - Sentry.setUser({ id: response.user.id, isGuest: !response.user.email }); + Sentry.setUser({ id: session.user.id, isGuest: !session.user.email }); - // Mirror auth state into a hint cookie so the server can short-circuit - // SSR for unauthenticated visits. Lifetime matches the refresh token - // so we don't leave a stale "logged in" hint after the session - // genuinely expires. - const { exp } = jwtDecode(refresh_token); + // Mirror auth state into a hint cookie so the server can short-circuit + // SSR for unauthenticated visits. Lifetime matches the refresh token + // so we don't leave a stale "logged in" hint after the session + // genuinely expires. + const exp = getRefreshTokenExpiry(); + if (exp) { sessionHintCookie.set(exp - Math.floor(Date.now() / 1000)); + } - return { isLoggedIn: true, user: response.user } as const; - } catch (error) { - console.error('Failed to fetch user', { cause: error }); - Sentry.setUser(null); - sessionHintCookie.clear(); - return { isLoggedIn: false } as const; + return { ...session, refreshTokenExpiresAt: exp }; + }, + // Logged-in state is fresh until the refresh token expires; a refetch + // after that point re-reads the (SDK-extended or ended) session and + // re-syncs the hint cookie. Anonymous state only changes through explicit + // invalidation. Staleness is pinned to the expiry captured AT FETCH TIME + // (not re-read from storage), so an SDK-internal guest extension can't + // slide freshness forward and postpone the cookie re-sync forever. + staleTime: ({ state: { data, dataUpdatedAt } }) => { + if (!data?.isLoggedIn) { + return Number.POSITIVE_INFINITY; } + if (!data.refreshTokenExpiresAt) { + return 0; + } + return Math.max( + (data.refreshTokenExpiresAt - 5) * 1000 - dataUpdatedAt, + 0, + ); }, - staleTime: Number.POSITIVE_INFINITY, }); /** @@ -108,6 +134,36 @@ export const useAuthState = (): AuthState => { return data; }; +/** + * The web-side counterpart of the SDK's session end: forgets all + * session-derived web state after a session ends (sign-out or expiry). + * Ordering is load-bearing: the flags reset first, so the previous user's + * flags are gone even if the anonymous re-fetch fails and can't clobber its + * result; queryClient.clear() runs last, once navigation/revalidation has + * settled, so the still-mounted protected tree doesn't lose its suspended + * query data mid-transition. + */ +const useSessionEndCleanup = () => { + const queryClient = useQueryClient(); + const { revalidate } = useRevalidator(); + const navigate = useNavigate(); + + return useCallback( + async ({ redirectTo }: { redirectTo?: string } = {}) => { + resetFeatureFlags(); + await invalidateAuthQueries(); + if (redirectTo) { + await navigate(redirectTo); + } else { + await revalidate(); + } + Sentry.setUser(null); + queryClient.clear(); + }, + [navigate, revalidate, queryClient], + ); +}; + type SignOutOptions = { /** * The URL to redirect to after signing out. If not provided, the user will be redirected to the singup page by the protected layout. @@ -116,69 +172,24 @@ type SignOutOptions = { }; type AuthActions = { - /** - * Creates a new full user account. Automatically signs in the user after sign up. - * @param email - * @param password - */ signUp: (email: string, password: string) => Promise; - - /** - * Creates a new guest user account. If the user has already signed up as a guest on the same device before, the sign - * in to that account will be performed instead. Automatically signs in the user after sign up. - */ signUpGuest: () => Promise; - - /** - * Signs in the existing user - * @param email - * @param password - */ signIn: (email: string, password: string) => Promise; - - /** - * Signs out the current user - * @param options Options for the sign out - */ signOut: (options?: SignOutOptions) => Promise; - - /** - * Initiates a Google authentication flow - * Returns the auth URL to redirect the user to - */ - initiateGoogleAuth: () => Promise<{ - /** - * The auth URL to redirect the user to to perform the Google authentication flow - */ - authUrl: string; - }>; - - /** - * Verifies the email address - * @param code The code from the email verification - */ + initiateGoogleAuth: () => Promise<{ authUrl: string }>; verifyEmail: (code: string) => Promise; - - /** - * Converts a guest account to a full account - * @param email The email address of the user - * @param password The password of the user - */ convertGuestToFullAccount: (email: string, password: string) => Promise; }; /** - * A hook that provides authentication actions by wrapping functionalities from the OpenSecret SDK. - * The actions include user signing up, signing in, and signing out. - * References for these actions are memoized to ensure consistent references across renders, - * improving performance and preventing unnecessary re-renders or function evaluations. - * - * @returns {AuthActions} + * Authentication actions backed by the wallet SDK, wrapped with the web + * concerns the SDK doesn't own: query invalidation, navigation, Sentry user + * tracking, and the OAuth deep-link session. */ export const useAuthActions = (): AuthActions => { - const queryClient = useQueryClient(); const { revalidate } = useRevalidator(); const navigate = useNavigate(); + const endSessionCleanup = useSessionEndCleanup(); const refreshSession = useCallback( async (redirectTo?: string) => { @@ -194,7 +205,7 @@ export const useAuthActions = (): AuthActions => { const signUp = useCallback( async (email: string, password: string) => { - await osSignUp(email, password, ''); + await sdk.auth.signUp(email, password); await refreshSession(); }, [refreshSession], @@ -202,42 +213,37 @@ export const useAuthActions = (): AuthActions => { const signIn = useCallback( async (email: string, password: string) => { - await osSignIn(email, password); + await sdk.auth.signIn(email, password); await refreshSession(); }, [refreshSession], ); - const signInGuest = useCallback( - async (id: string, password: string) => { - await osSignInGuest(id, password); - await refreshSession(); - }, - [refreshSession], - ); + const signUpGuest = useCallback(async () => { + await sdk.auth.signUpGuest(); + await refreshSession(); + }, [refreshSession]); const signOut = useCallback( async (options: SignOutOptions = {}) => { - await osSignOut(); - // Before the refresh below so the previous user's flags are gone even if - // the anon re-fetch fails, and so its result isn't clobbered afterwards. - resetFeatureFlags(); - await refreshSession(options.redirectTo); - Sentry.setUser(null); - queryClient.clear(); - // The SDK's module-level memos (spark wallet connections, agicash-mint - // CAT) live outside the query cache, so clear them alongside it so the - // next session starts fresh. - clearSparkWallets(); - clearAgicashMintAuthToken(); + try { + await sdk.auth.signOut(); + } finally { + // The SDK ends the local session even when the sign-out throws, so + // the web state must be reset regardless — otherwise a dead session + // keeps a live-looking wallet on screen. + await endSessionCleanup({ redirectTo: options.redirectTo }); + } }, - [refreshSession, queryClient], + [endSessionCleanup], ); const initiateGoogleAuth = useCallback(async () => { - const response = await osInitiateGoogleAuth(''); + const { authUrl } = await sdk.auth.initiateGoogleAuth(); - const authLocation = new URL(response.auth_url); + // Stash the current location under a session id and thread it through the + // OAuth state param, so the callback route can restore the deep link. + const authLocation = new URL(authUrl); const stateParam = authLocation.searchParams.get('state'); const state = stateParam ? JSON.parse(new TextDecoder().decode(decodeURLSafe(stateParam))) @@ -257,28 +263,9 @@ export const useAuthActions = (): AuthActions => { return { authUrl: authLocation.href }; }, []); - const signUpGuest = useCallback(async () => { - const existingGuestAccount = guestAccountStorage.get(); - if (existingGuestAccount) { - return signInGuest( - existingGuestAccount.id, - existingGuestAccount.password, - ); - } - - const createGuestAccount = async () => { - const password = await generateRandomPassword(32); - const guestAccount = await osSignUpGuest(password, ''); - guestAccountStorage.store({ id: guestAccount.id, password }); - await refreshSession(); - }; - - await createGuestAccount(); - }, [signInGuest, refreshSession]); - const verifyEmail = useCallback( async (code: string) => { - await osVerifyEmail(code); + await sdk.auth.verifyEmail(code); await refreshSession(); }, [refreshSession], @@ -286,7 +273,7 @@ export const useAuthActions = (): AuthActions => { const convertGuestToFullAccount = useCallback( async (email: string, password: string) => { - await osConvertGuestToFullAccount(email, password); + await sdk.auth.convertGuestToFullAccount(email, password); await refreshSession(); }, [refreshSession], @@ -309,100 +296,80 @@ export const useSignOut = () => { const handleSignOut = async () => { setLoading(true); - await signOut({ redirectTo: '/home' }); - setLoading(false); + try { + await signOut({ redirectTo: '/home' }); + } finally { + setLoading(false); + } }; return { isSigningOut: loading, signOut: handleSignOut }; }; -type OpenSecretJwt = { - /** - * Token expiration time. It's a unix timestamp in seconds - */ - exp: number; - - /** - * Time when the token was issues. It's a unix timestamp in seconds - */ - iat: number; - - /** - * ID of the logged-in user - */ - sub: string; - - /** - * Audience - */ - aud: 'access' | 'refresh'; -}; - -const accessTokenKey = 'access_token'; -const refreshTokenKey = 'refresh_token'; - -const getJwt = (key: string): OpenSecretJwt | null => { - const jwt = localStorage.getItem(key); - if (!jwt) { - return null; - } - return jwtDecode(jwt); -}; - -const removeKeys = () => { - localStorage.removeItem(accessTokenKey); - localStorage.removeItem(refreshTokenKey); - sessionHintCookie.clear(); -}; - -const getRefreshToken = () => getJwt(refreshTokenKey); - -const getRemainingSessionTimeInMs = ( - token: OpenSecretJwt | null, -): number | null => { - if (!token) { - return null; - } - // We are treating the session as expired 5 seconds before the actual expiry just in case - const fiveSecondsBeforeExpiry = token.exp - 5; - const fiveSecondsBeforeExpiryInMs = fiveSecondsBeforeExpiry * 1000; - const remainingTime = fiveSecondsBeforeExpiryInMs - Date.now(); - return Math.max(remainingTime, 0); -}; +/** + * Reacts to SDK-initiated session transitions the host didn't trigger. + * Expiry (refresh-token death with failed/impossible extension): notifies the + * user and resets the web session state. Refresh (guest auto-extension): + * re-runs the auth query so the session-hint cookie picks up the new expiry, + * matching master's extend-through-invalidation behavior. + */ +// Guards the expiry hard-fallback below: at most one reload per window, so a +// persistently failing cleanup can't reload-loop the tab. +const expiryReloadAtKey = 'agicash.session-expiry-reload-at'; -type HandleSessionExpiryProps = { - isGuestAccount: boolean; - onLogout: () => void; -}; +export const useHandleSessionEvents = (onSessionExpired: () => void) => { + const queryClient = useQueryClient(); + const endSessionCleanup = useSessionEndCleanup(); + const onSessionExpiredRef = useLatest(onSessionExpired); + + useEffect(() => { + const handleSessionExpired = () => { + onSessionExpiredRef.current(); + void endSessionCleanup().catch((error) => { + // Hard fallback: the SDK already ended the session, so a reload + // boots anonymous even when the soft reset above fails mid-flight. + console.error('Failed to handle session expiry', { cause: error }); + const lastReloadAt = Number( + window.sessionStorage.getItem(expiryReloadAtKey) ?? 0, + ); + if (Date.now() - lastReloadAt < 30_000) { + return; + } + window.sessionStorage.setItem(expiryReloadAtKey, String(Date.now())); + window.location.reload(); + }); + }; -export const useHandleSessionExpiry = ({ - isGuestAccount, - onLogout, -}: HandleSessionExpiryProps) => { - const { signUpGuest: extendGuestSession, signOut } = useAuthActions(); - const refreshToken = getRefreshToken(); - const remainingSessionTime = getRemainingSessionTimeInMs(refreshToken); + const unsubscribeExpired = sdk.events.on( + 'auth.session-expired', + handleSessionExpired, + ); + const unsubscribeRefreshed = sdk.events.on('auth.session-refreshed', () => { + void invalidateAuthQueries(); + }); - const handleSessionExpiry = async () => { - try { - if (isGuestAccount) { - // Extend guest session will get new extended access and refresh token from Open Secret. The OS code can be seen - // here https://github.com/OpenSecretCloud/OpenSecret-SDK/blob/master/src/lib/main.tsx#L441. Because setState is - // called after this method is executed the new render will be triggered and useHandleSessionExpiry will be - // executed again which will result in new session expiry timeout being set. - await extendGuestSession(); - } else { - onLogout(); - // Open secret is already handling potential errors in signOut method and removes the keys from the storage so - // in that case our catch should never be triggered, which is fine. We are leaving it there for the guest use - // case and just in case. - await signOut(); + // The SDK arms its expiry timer during init(), before this subscription + // exists; an expiry firing in that window emitted to no subscribers. + // This hook mounts only under an authenticated layout, so an already-dead + // SDK session here means exactly that missed event — handle it now. + if (!sdk.auth.getSession().isLoggedIn) { + handleSessionExpired(); + } else { + // The mirror gap for guests: an auto-extension during init() missed its + // auth.session-refreshed the same way. If the stored expiry moved past + // what the auth query captured, re-sync the query (and with it the + // session-hint cookie) now. + const authState = queryClient.getQueryData(authQueryOptions().queryKey); + if ( + authState?.isLoggedIn && + authState.refreshTokenExpiresAt !== getRefreshTokenExpiry() + ) { + void invalidateAuthQueries(); } - } catch (e) { - console.error('Failed to handle session expiry', { cause: e }); - removeKeys(); - window.location.reload(); } - }; - useLongTimeout(handleSessionExpiry, remainingSessionTime); + return () => { + unsubscribeExpired(); + unsubscribeRefreshed(); + }; + }, [endSessionCleanup, queryClient]); }; diff --git a/apps/web-wallet/app/features/user/guest-account-storage.ts b/apps/web-wallet/app/features/user/guest-account-storage.ts deleted file mode 100644 index b856f5c09..000000000 --- a/apps/web-wallet/app/features/user/guest-account-storage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { safeJsonParse } from '@agicash/utils'; -import { z } from 'zod/mini'; - -const storageKey = 'guestAccount'; - -const GuestAccountDetailsSchema = z.object({ - id: z.string(), - password: z.string(), -}); - -type GuestAccountDetails = z.infer; - -const getGuestAccount = (): GuestAccountDetails | null => { - const dataString = localStorage.getItem(storageKey); - if (!dataString) { - return null; - } - const parseResult = safeJsonParse(dataString); - if (!parseResult.success) { - return null; - } - const validationResult = GuestAccountDetailsSchema.safeParse( - parseResult.data, - ); - if (!validationResult.success) { - console.warn('Invalid guest account data found in the storage'); - return null; - } - return validationResult.data; -}; - -const storeGuestAccount = (data: GuestAccountDetails) => { - localStorage.setItem(storageKey, JSON.stringify(data)); -}; - -const removeGuestAccount = () => { - localStorage.removeItem(storageKey); -}; - -export const guestAccountStorage = { - get: getGuestAccount, - store: storeGuestAccount, - clear: removeGuestAccount, -}; diff --git a/apps/web-wallet/app/features/user/user-hooks.tsx b/apps/web-wallet/app/features/user/user-hooks.tsx index 16edb1c30..9ce16d670 100644 --- a/apps/web-wallet/app/features/user/user-hooks.tsx +++ b/apps/web-wallet/app/features/user/user-hooks.tsx @@ -1,7 +1,6 @@ import type { Currency } from '@agicash/money'; -import { requestNewVerificationCode } from '@agicash/opensecret'; import type { Account, User } from '@agicash/wallet-sdk'; -import type { AgicashDbUser, UpdateUser } from '@agicash/wallet-sdk/temporary'; +import type { AgicashDbUser } from '@agicash/wallet-sdk/temporary'; import { ReadUserRepository } from '@agicash/wallet-sdk/temporary'; import { type QueryClient, @@ -11,14 +10,9 @@ import { import { useQueryClient } from '@tanstack/react-query'; import { useCallback, useMemo } from 'react'; import { getQueryClient } from '~/features/shared/query-client'; +import { sdk } from '~/features/shared/sdk.client'; import { useAuthActions, useAuthState } from '~/features/user/auth'; import { useLatest } from '~/lib/use-latest'; -import { guestAccountStorage } from './guest-account-storage'; -import { - useReadUserRepository, - useWriteUserRepository, -} from './user-repository-hooks'; -import { useUserService } from './user-service-hooks'; export class UserCache { public static Key = 'user'; @@ -71,16 +65,12 @@ export const getUserFromCacheOrThrow = () => { }; const userQueryOptions = ({ - userId, - userRepository, select, }: { - userId: string; - userRepository: ReadUserRepository; select?: (data: User) => TData; }) => ({ queryKey: [UserCache.Key], - queryFn: () => userRepository.get(userId), + queryFn: () => sdk.user.get(), select, }); @@ -93,16 +83,11 @@ export const useUser = ( select?: (data: User) => TData, ): TData => { const authState = useAuthState(); - const authUser = authState.user; - if (!authUser) { + if (!authState.user) { throw new Error('Cannot use useUser hook in anonymous context'); } - const userRepository = useReadUserRepository(); - - const { data } = useSuspenseQuery( - userQueryOptions({ userId: authUser.id, userRepository, select }), - ); + const { data } = useSuspenseQuery(userQueryOptions({ select })); return data; }; @@ -164,12 +149,7 @@ export const useUpgradeGuestToFullAccount = (): (( throw new Error('User already has a full account'); } - return convertGuestToFullAccount( - variables.email, - variables.password, - ).then(() => { - guestAccountStorage.clear(); - }); + return convertGuestToFullAccount(variables.email, variables.password); }, scope: { id: 'upgrade-guest-to-full-account', @@ -195,7 +175,7 @@ export const useRequestNewEmailVerificationCode = (): (() => Promise) => { throw new Error('Email is already verified'); } - return requestNewVerificationCode(); + return sdk.auth.requestNewVerificationCode(); }, scope: { id: 'request-new-email-verification-code', @@ -228,13 +208,13 @@ export const useVerifyEmail = (): ((code: string) => Promise) => { return mutateAsync; }; -const useUpdateUser = () => { +const useUserUpdatingMutation = ( + mutationFn: (variables: TVariables) => Promise, +) => { const queryClient = useQueryClient(); - const userId = useUser((user) => user.id); - const userRepository = useWriteUserRepository(); return useMutation({ - mutationFn: (updates: UpdateUser) => userRepository.update(userId, updates), + mutationFn, onSuccess: (data) => { queryClient.setQueryData([UserCache.Key], data); }, @@ -242,53 +222,34 @@ const useUpdateUser = () => { }; export const useSetDefaultCurrency = () => { - const { mutateAsync: updateUser } = useUpdateUser(); - - return useCallback( - (currency: Currency) => updateUser({ defaultCurrency: currency }), - [updateUser], + const { mutateAsync } = useUserUpdatingMutation((currency: Currency) => + sdk.user.setDefaultCurrency({ currency }), ); + + return mutateAsync; }; export const useSetDefaultAccount = () => { - const userService = useUserService(); - const user = useUserRef(); - const queryClient = useQueryClient(); - - const { mutateAsync } = useMutation({ - mutationFn: (account: Account) => - userService.setDefaultAccount(user.current, account), - onSuccess: (data) => { - queryClient.setQueryData([UserCache.Key], data); - }, - }); + const { mutateAsync } = useUserUpdatingMutation((account: Account) => + sdk.user.setDefaultAccount({ accountId: account.id }), + ); return mutateAsync; }; export const useUpdateUsername = () => { - const { mutateAsync: updateUser } = useUpdateUser(); - - return useCallback( - (username: string) => updateUser({ username }), - [updateUser], + const { mutateAsync } = useUserUpdatingMutation((username: string) => + sdk.user.updateUsername(username), ); + + return mutateAsync; }; export const useAcceptTerms = () => { - const { mutateAsync: updateUser } = useUpdateUser(); - - return useCallback( - ({ - walletTerms, - giftCardTerms, - }: { walletTerms?: boolean; giftCardTerms?: boolean }) => { - const now = new Date().toISOString(); - const updates: UpdateUser = {}; - if (walletTerms) updates.termsAcceptedAt = now; - if (giftCardTerms) updates.giftCardMintTermsAcceptedAt = now; - return updateUser(updates); - }, - [updateUser], + const { mutateAsync } = useUserUpdatingMutation( + (params: { walletTerms?: boolean; giftCardTerms?: boolean }) => + sdk.user.acceptTerms(params), ); + + return mutateAsync; }; diff --git a/apps/web-wallet/app/features/user/user-repository-hooks.ts b/apps/web-wallet/app/features/user/user-repository-hooks.ts deleted file mode 100644 index 157793783..000000000 --- a/apps/web-wallet/app/features/user/user-repository-hooks.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { - ReadUserRepository, - WriteUserRepository, -} from '@agicash/wallet-sdk/temporary'; -import { useAccountRepository } from '~/features/accounts/account-repository-hooks'; -import { agicashDbClient } from '~/features/agicash-db/database.client'; - -export function useReadUserRepository() { - return new ReadUserRepository(agicashDbClient); -} - -export function useWriteUserRepository() { - const accountRepository = useAccountRepository(); - return new WriteUserRepository(agicashDbClient, accountRepository); -} diff --git a/apps/web-wallet/app/features/user/user-service-hooks.ts b/apps/web-wallet/app/features/user/user-service-hooks.ts deleted file mode 100644 index 192d9a080..000000000 --- a/apps/web-wallet/app/features/user/user-service-hooks.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { UserService } from '@agicash/wallet-sdk/temporary'; -import { useWriteUserRepository } from './user-repository-hooks'; - -export function useUserService() { - const userRepository = useWriteUserRepository(); - return new UserService(userRepository); -} diff --git a/apps/web-wallet/app/features/wallet/wallet.tsx b/apps/web-wallet/app/features/wallet/wallet.tsx index a76549083..e867b6fa3 100644 --- a/apps/web-wallet/app/features/wallet/wallet.tsx +++ b/apps/web-wallet/app/features/wallet/wallet.tsx @@ -4,7 +4,7 @@ import { useToast } from '~/hooks/use-toast'; import { useSupabaseRealtimeActivityTracking } from '~/lib/supabase'; import { agicashRealtimeClient } from '../agicash-db/database.client'; import { useTheme } from '../theme'; -import { useHandleSessionExpiry } from '../user/auth'; +import { useHandleSessionEvents } from '../user/auth'; import { useUser } from '../user/user-hooks'; import { TaskProcessor, useTakeTaskProcessingLead } from './task-processing'; import { useTrackAndUpdateSparkAccountBalances } from './use-track-spark-account-balances'; @@ -38,15 +38,12 @@ export const Wallet = ({ children }: PropsWithChildren) => { // Logout handles clearing Sentry user on actual logout. }, [user]); - useHandleSessionExpiry({ - isGuestAccount: user.isGuest, - onLogout: () => { - toast({ - title: 'Session expired', - description: - 'The session has expired. You will be redirected to the login page.', - }); - }, + useHandleSessionEvents(() => { + toast({ + title: 'Session expired', + description: + 'The session has expired. You will be redirected to the login page.', + }); }); useSyncThemeWithDefaultCurrency(); diff --git a/apps/web-wallet/app/hooks/use-long-timeout.ts b/apps/web-wallet/app/hooks/use-long-timeout.ts deleted file mode 100644 index 9b15a5f53..000000000 --- a/apps/web-wallet/app/hooks/use-long-timeout.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useEffect } from 'react'; -import { clearLongTimeout, setLongTimeout } from '~/lib/timeout'; -import { useLatest } from '~/lib/use-latest'; - -/** - * Custom hook that handles long timeouts in React components using the `setLongTimeout API`. - * The code of useLongTimeout is copied from [`usehooks-ts` lib](https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useTimeout/useTimeout.ts) - * and calls to setTimeout and clearTimeout were replaced with long timeout alternatives. - * Support arbitrary delays. - * @param {() => void} callback - The function to be executed when the timeout elapses. - * @param {number | null} delay - The duration (in milliseconds) for the timeout. Set to `null` to clear the timeout. - * @returns {void} This hook does not return anything. - * @public - * @example - * ```tsx - * // Usage of useLongTimeout hook - * useLongTimeout(() => { - * // Code to be executed after the specified delay - * }, 1000); // Set a timeout of 1000 milliseconds (1 second) - * ``` - */ -export function useLongTimeout( - callback: () => void, - delay: number | null, -): void { - // Remember the latest callback if it changes. - const savedCallback = useLatest(callback); - - // Set up the timeout. - useEffect(() => { - // Don't schedule if no delay is specified. - // Note: 0 is a valid value for delay. - if (!delay && delay !== 0) { - return; - } - - const id = setLongTimeout(() => { - savedCallback.current(); - }, delay); - - return () => { - clearLongTimeout(id); - }; - }, [delay]); -} diff --git a/apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts b/apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts index 03764794b..823009d3e 100644 --- a/apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts +++ b/apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts @@ -1,9 +1,13 @@ import type { ExtendedCashuWallet } from '@agicash/cashu'; import type { Currency } from '@agicash/money'; +import { + type LongTimeout, + clearLongTimeout, + setLongTimeout, +} from '@agicash/utils'; import { type MeltQuoteBolt11Response, MeltQuoteState } from '@cashu/cashu-ts'; import { useMutation } from '@tanstack/react-query'; import { useCallback, useEffect, useState } from 'react'; -import { type LongTimeout, clearLongTimeout, setLongTimeout } from '../timeout'; import { useLatest } from '../use-latest'; import { MeltQuoteSubscriptionManager } from './melt-quote-subscription-manager'; diff --git a/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx b/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx index 9dbf0a371..16f1a8614 100644 --- a/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx +++ b/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx @@ -1,7 +1,7 @@ -import { handleGoogleCallback } from '@agicash/opensecret'; import { decodeURLSafe } from '@stablelib/base64'; import { redirect } from 'react-router'; import { LoadingScreen } from '~/features/loading/LoadingScreen'; +import { sdk } from '~/features/shared/sdk.client'; import { invalidateAuthQueries } from '~/features/user/auth'; import { oauthLoginSessionStorage } from '~/features/user/oauth-login-session-storage'; import { toast } from '~/hooks/use-toast'; @@ -38,7 +38,7 @@ export async function clientLoader({ try { switch (provider) { case 'google': - await handleGoogleCallback(code, state, ''); + await sdk.auth.completeGoogleAuth({ code, state }); break; default: { throw new UnsupportedOAuthProviderError( diff --git a/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx index 6d4e35295..30b83bdab 100644 --- a/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx +++ b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx @@ -13,7 +13,6 @@ import { SparkReceiveQuoteRepository, SparkReceiveQuoteService, UserService, - WriteUserRepository, decodeCashuToken, getEncryption, } from '@agicash/wallet-sdk/temporary'; @@ -39,6 +38,7 @@ import { encryptionPublicKeyQueryOptions, } from '~/features/shared/encryption-hooks'; import { getQueryClient } from '~/features/shared/query-client'; +import { sdk } from '~/features/shared/sdk.client'; import { sparkMnemonicQueryOptions } from '~/features/shared/spark-query-options'; import { UserCache, getUserFromCacheOrThrow } from '~/features/user/user-hooks'; import { getExchangeRate } from '~/hooks/use-exchange-rate'; @@ -89,12 +89,6 @@ const getServices = async () => { cashuReceiveQuoteService, sparkReceiveQuoteService, ); - const userRepository = new WriteUserRepository( - agicashDbClient, - accountRepository, - ); - const userService = new UserService(userRepository); - const claimCashuTokenService = new ClaimCashuTokenService( accountService, receiveSwapService, @@ -105,7 +99,7 @@ const getServices = async () => { (ticker) => getExchangeRate(queryClient, ticker), ); - return { claimCashuTokenService, accountRepository, userService }; + return { claimCashuTokenService, accountRepository }; }; /** @@ -115,7 +109,6 @@ const getServices = async () => { * UX, so it lives here rather than in the claim service. */ async function trySetReceiveAccountAsDefault( - userService: UserService, queryClient: QueryClient, user: User, account: Account, @@ -127,7 +120,8 @@ async function trySetReceiveAccountAsDefault( return; } try { - const updatedUser = await userService.setDefaultAccount(user, account, { + const updatedUser = await sdk.user.setDefaultAccount({ + accountId: account.id, setDefaultCurrency: true, }); new UserCache(queryClient).set(updatedUser); @@ -173,8 +167,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { if (claimTo) { const user = getUserFromCacheOrThrow(); - const { claimCashuTokenService, accountRepository, userService } = - await getServices(); + const { claimCashuTokenService, accountRepository } = await getServices(); const queryClient = getQueryClient(); const accounts = await queryClient.fetchQuery( accountsQueryOptions({ userId: user.id, accountRepository }), @@ -195,7 +188,6 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { accountsCache.upsert(account); } await trySetReceiveAccountAsDefault( - userService, queryClient, user, result.receiveAccount, diff --git a/apps/web-wallet/app/routes/_protected.tsx b/apps/web-wallet/app/routes/_protected.tsx index d0214f41c..4b743885c 100644 --- a/apps/web-wallet/app/routes/_protected.tsx +++ b/apps/web-wallet/app/routes/_protected.tsx @@ -1,5 +1,6 @@ import type { User } from '@agicash/wallet-sdk'; import { shouldAcceptTerms } from '@agicash/wallet-sdk'; +import type { AuthUser } from '@agicash/wallet-sdk'; import { AccountRepository, BASE_CASHU_LOCKING_DERIVATION_PATH, @@ -27,11 +28,7 @@ import { sparkIdentityPublicKeyQueryOptions, sparkMnemonicQueryOptions, } from '~/features/shared/spark-query-options'; -import { - type AuthUser, - authQueryOptions, - useAuthState, -} from '~/features/user/auth'; +import { authQueryOptions, useAuthState } from '~/features/user/auth'; import { pendingGiftCardMintTermsStorage, pendingWalletTermsStorage, @@ -121,24 +118,24 @@ const ensureUserData = async ( getSparkWalletMnemonic, { storageDir: './.spark-data', apiKey: breezApiKey }, ); - const writeUserRepository = new WriteUserRepository( - agicashDbClient, - accountRepository, - ); + const writeUserRepository = new WriteUserRepository(agicashDbClient); const { user: upsertedUser, accounts } = await withRetry({ fn: () => - writeUserRepository.upsert({ - id: authUser.id, - email: authUser.email, - emailVerified: authUser.email_verified, - accounts: [...defaultAccounts], - cashuLockingXpub, - encryptionPublicKey, - sparkIdentityPublicKey, - termsAcceptedAt, - giftCardMintTermsAcceptedAt, - }), + writeUserRepository.upsert( + { + id: authUser.id, + email: authUser.email, + emailVerified: authUser.email_verified, + accounts: [...defaultAccounts], + cashuLockingXpub, + encryptionPublicKey, + sparkIdentityPublicKey, + termsAcceptedAt, + giftCardMintTermsAcceptedAt, + }, + accountRepository, + ), retry: (attemptIndex, error) => { if (error instanceof core.$ZodError) { return false; diff --git a/apps/web-wallet/package.json b/apps/web-wallet/package.json index 5a96869ea..b9aba0625 100644 --- a/apps/web-wallet/package.json +++ b/apps/web-wallet/package.json @@ -63,7 +63,7 @@ "embla-carousel-react": "8.5.2", "express": "5.2.1", "isbot": "5.1.34", - "jwt-decode": "4.0.0", + "jwt-decode": "catalog:", "ky": "catalog:", "lucide-react": "0.468.0", "qrcode.react": "4.2.0", diff --git a/bun.lock b/bun.lock index 061a01f8e..b16b90afe 100644 --- a/bun.lock +++ b/bun.lock @@ -60,7 +60,7 @@ "embla-carousel-react": "8.5.2", "express": "5.2.1", "isbot": "5.1.34", - "jwt-decode": "4.0.0", + "jwt-decode": "catalog:", "ky": "catalog:", "lucide-react": "0.468.0", "qrcode.react": "4.2.0", @@ -180,6 +180,7 @@ "dependencies": { "@noble/ciphers": "catalog:", "@noble/hashes": "catalog:", + "jwt-decode": "catalog:", "type-fest": "catalog:", "zod": "catalog:", }, @@ -207,7 +208,7 @@ "@stablelib/base64": "catalog:", "@supabase/supabase-js": "2.95.2", "big.js": "catalog:", - "jwt-decode": "4.0.0", + "jwt-decode": "catalog:", "ky": "catalog:", "type-fest": "catalog:", "zod": "catalog:", @@ -224,7 +225,7 @@ "@sentry/core@10.42.0": "patches/@sentry%2Fcore@10.42.0.patch", }, "catalog": { - "@agicash/opensecret": "0.1.0", + "@agicash/opensecret": "1.0.0-rc.0", "@cashu/cashu-ts": "3.6.1", "@noble/ciphers": "1.3.0", "@noble/curves": "1.9.7", @@ -236,6 +237,7 @@ "@types/bun": "1.3.11", "big.js": "7.0.1", "dotenv": "16.4.7", + "jwt-decode": "4.0.0", "jwt-encode": "1.0.1", "ky": "1.14.3", "type-fest": "5.4.3", @@ -257,7 +259,7 @@ "@agicash/money": ["@agicash/money@workspace:packages/money"], - "@agicash/opensecret": ["@agicash/opensecret@0.1.0", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-5cbr7hgKlrY3aLm2RPrk9+xfserhEgG60V+zxtABcD0ZG7Gw6AgeMUuijWyp8mAqZ7G+bZr4Us0ZvGq63ISzog=="], + "@agicash/opensecret": ["@agicash/opensecret@1.0.0-rc.0", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" } }, "sha512-BbXdFQp1NTug5jA7SHcZDP0a9qRmwl36pF+bgWv69ohAZMdYH7k4WiEbLgRBLoYXGj2VyyUEene0n3GIbDmHMQ=="], "@agicash/qr-scanner": ["@agicash/qr-scanner@0.1.2", "", { "dependencies": { "zxing-wasm": "2.2.4" } }, "sha512-7qNJoDRqBbHSm12qZ1l0O2JDof0sXNiooebsCXGZOsJpCLBYF9bAJXuy/4Q/jFwbiH0knFZq8myRI5v6IFqpEQ=="], diff --git a/docs/superpowers/plans/2026-07-09-wallet-sdk-auth-slice.md b/docs/superpowers/plans/2026-07-09-wallet-sdk-auth-slice.md new file mode 100644 index 000000000..ea5fccfb9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-wallet-sdk-auth-slice.md @@ -0,0 +1,2740 @@ +# Wallet SDK Auth & User Slice (Step 5) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement step 5 of the no-cache SDK migration: the first runtime `Sdk` class (`AgicashSdk.create(config)`) with working `auth`, `user`, and `events` namespaces, adopt the React-agnostic `@agicash/opensecret@1.0.0-rc.0`, settle the step-5 contract placeholders, and flip the web app's auth + user imports from `/temporary` to `sdk.*`. + +**Architecture:** The SDK configures Open Secret itself inside `create(config)` (host passes `apiUrl`/`clientId`/storage adapter), builds its own Supabase client with an internal Open Secret → Supabase token getter, and keeps an in-memory `AuthSession` snapshot maintained by `AuthService` (restore on `init()`, refresh after every auth verb, refresh-token expiry timer with guest auto-extend and `auth.session-expired` emission). The web keeps thin glue: TanStack `authQueryOptions` wrapping `sdk.init()` + `sdk.auth.getSession()`, plus Sentry/session-hint-cookie/navigation concerns. Specs: `docs/superpowers/specs/2026-06-24-wallet-sdk-no-cache-production-design.md` (parent) and `docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md` (contract). + +**Tech Stack:** TypeScript (bun workspace monorepo), React Router v7, TanStack Query v5 (web only), `@agicash/opensecret@1.0.0-rc.0`, `@supabase/supabase-js`, `jwt-decode`, `zod/mini`, `bun test` for SDK unit tests. + +## Global Constraints + +- **SDK is React-agnostic and headless-safe.** No file under `packages/wallet-sdk/` may import `react`, `@tanstack/react-query`, or read `window`/`document`/`localStorage`/`sessionStorage`/cookies. Host state enters only via `create(config)` ports (`config.auth.storage`). +- **`@agicash/opensecret` is pinned exact** in the root `workspaces.catalog`: `"1.0.0-rc.0"` after Task 1. Verified: the RC's storage keys are unchanged (`access_token`, `refresh_token`), `browserStorage` maps `persistent`→`localStorage` and `session`→`sessionStorage` lazily, and the package has **no React peer deps**. Existing user sessions survive the upgrade. +- **The contract types in `packages/wallet-sdk/sdk.ts` are the source of truth.** `AuthStorage` binds *verbatim* to the RC's `StorageProvider` shape (scopes `persistent`/`session`, methods `getItem`/`setItem`/`removeItem`, sync-or-async returns) so the SDK ships no adapter over it. +- **`create()` is sync with no I/O; `init()` is memoized single-flight.** In this slice `init()` = session restore only. The web keeps calling `ensureBreezWasm()` from `/temporary` (entry.client + `_protected` middleware) — WASM folds into `init()` when the first Spark namespace lands (decision, see Decision Record). +- **Web-only concerns stay in the web glue:** Sentry user tracking, session-hint cookie, TanStack invalidation, navigation/revalidation, feature-flag load/reset, OAuth deep-link restore (`oauthLoginSessionStorage`), pending-terms storage. +- **Package manager is `bun`; never npm/npx/yarn/pnpm.** In this environment `bun` is NOT on PATH — prefix commands with `export PATH="$PWD/.devenv/profile/bin:$PATH"` (run from the repo root). +- **Branch `sdk/auth-slice` off `master`.** Conventional commits (`feat(wallet-sdk):`, `refactor(web-wallet):`, …). Run `bun run fix:all && bun run typecheck` before every commit — `fix:all` is biome lint/format ONLY (it does not typecheck, despite CLAUDE.md's description); `typecheck` runs each package's `tsc`. +- **Behavior parity is the review bar.** Every auth flow (email login/signup, guest signup + re-signin, Google OAuth, verify email, convert guest, sign out, session expiry) must behave as on `master`; deltas are listed in "Accepted behavior deltas" below and nothing else may change. + +## Decision Record (resolved with maintainer, 2026-07-09) + +| # | Decision | +|---|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| A1 | **`ensureUserData` (user + default-accounts upsert in `_protected.tsx`) stays in the web via `/temporary` this slice.** It constructs `AccountRepository` (accounts domain); the accounts slice (step 6) gives it a contract home. Step 5 flips every other user-domain consumer. | +| A2 | **Session-expiry machinery moves into the SDK now.** `AuthService` arms a long-timeout at refresh-token expiry−5s: guests are auto-re-signed-in internally; full accounts get the session cleared + `auth.session-expired` emitted. This requires the minimal typed event emitter now (`auth.session-expired` is explicitly not realtime-backed). Web's `useHandleSessionExpiry` is replaced by an event subscription. | +| A3 | **`init()` = session restore only** this slice (see Global Constraints). Rationale: gating `authQueryOptions` on a combined restore+WASM `init()` would newly break login pages under WASM-unavailable (iOS Lockdown Mode) — a regression. No Spark ops exist on the contract until steps 11/15. | +| A4 | **Guest password generation: SDK default + optional host override.** `SdkConfig.auth.generateGuestPassword?: () => Promise` — resolving null falls through to the SDK's internal CSPRNG generator, which is the ONLY generator (the web's `~/lib/password-generator.ts` is deleted). The web passes a two-line bridge to `window.getMockPassword`, preserving the e2e seam with zero e2e changes. *(Refined 2026-07-09 from a full-replacement port, removing the duplicated generator body.)* | +| A5 | **`AuthUser` settles to Open Secret's user shape verbatim:** `export type AuthUser = UserResponse['user']` (id, name, email?, email_verified, login_method, created_at, updated_at). The web already consumes exactly these fields (`_protected.tsx`, `_auth.tsx`). | +| A6 | **The SDK builds its own Supabase client** (contract decision #1 from #1164) with an internal memoized third-party-token getter. Two Supabase clients coexist during migration: web's (`database.client.ts` — unmigrated domains + realtime) and the SDK's (auth/user namespaces). Accepted transitional cost; each caches its own token. | +| A7 | **`guestAccountStorage` moves into the SDK** behind `config.auth.storage.persistent` (same `guestAccount` localStorage key → existing guest creds survive). `oauth-login-session-storage`, `pending-terms-storage`, `session-hint-cookie`, and `shared/auth.ts` (`isLoggedIn` for the web's own DB client) **stay in the web**. | +| A8 | **`setLongTimeout`/`clearLongTimeout` move from `~/lib/timeout` to `@agicash/utils`** (needed by both web and SDK). | +| A9 | **`WriteUserRepository` drops `accountRepository` from its constructor**; `upsert()` takes it as a parameter (only `upsert` uses it). Lets the SDK construct the user namespace without the accounts graph. `UserService.setDefaultAccount` loosens `account` to `Pick`. | +| A10 | Step-5 params settle as: `AcceptTermsParams = { walletTerms?: boolean; giftCardTerms?: boolean }`, `SetDefaultAccountParams = { accountId: string; setDefaultCurrency?: boolean }`, `SetDefaultCurrencyParams = { currency: Currency }`. | +| A11 | The `_protected.receive.cashu_.token.tsx` default-account write flips to `sdk.user.setDefaultAccount({ accountId, setDefaultCurrency: true })` now (it is user-domain surface). `UserService`/`ReadUserRepository` remain exported from `/temporary` for their **static** helpers (`isDefaultAccount`, `getExtendedAccounts`, `toUser` for the realtime row mapper) until steps 6/18. | +| A12 | `sdk.featureFlags` namespace is **not** wired this slice (flags stay web-configured via `configureFeatureFlags(agicashDbClient)`); it needs no auth work and has no assigned step — it lands alongside a later slice. | +| A13 | **`auth.session-refreshed` added to the event map** (2026-07-09, maintainer-approved): fires only on SDK-initiated session refreshes — today exactly the guest auto-extension; host-initiated verbs never fire it (same principle as `auth.session-expired`'s "the host knows its own logout"). Rationale trail: PR #1164 floated `auth.changed` and landed the narrower `auth.session-expired`, but the recorded reasoning covered sign-out, not SDK-internal extension, and the hint-cookie consequence was never discussed there — an unconsidered gap, and the contract states adding events is non-breaking. The web's session-events hook invalidates the auth query on it, restoring master's cookie freshness after a guest auto-extend. | + +## Accepted behavior deltas (everything else is parity) + +1. **Guest extend failure:** master does `removeKeys()` + `window.location.reload()`; now the SDK falls through to the death path (session cleared + `auth.session-expired`) and the web shows the toast + redirects. Strictly better UX, same terminal state. +2. **Session-hint cookie refresh after guest auto-extend:** restored to master behavior via `auth.session-refreshed` (A13) — the web's session-events hook invalidates the auth query, whose refetch re-sets the cookie with the extended expiry, exactly like master's extend-through-invalidation. Residual: on pages where the hook isn't mounted (public/marketing — where master never armed an extension timer at all, see delta 6), the `staleTime` pinned to fetch-time expiry re-syncs the cookie on the next focus/mount refetch after the old expiry; a background tab there can still hit one cold-load `/home` bounce. Auth itself is unaffected (the cookie is a non-authoritative SSR hint). +3. **Sentry `setUser` timing:** master sets `{id: sub}` from the raw JWT before `fetchUser`; the glue still does this, then upgrades to `{id, isGuest}` after restore — unchanged ordering, but the fetch itself now happens inside `sdk.init()`. +4. **Sign-out memo-clear ordering:** master clears the SDK module memos (`clearSparkWallets`, `clearAgicashMintAuthToken`) last, after `queryClient.clear()`; now they clear at session end inside `sdk.auth.signOut()`. Post-clear repopulation by an in-flight request remains possible for the spark-wallet and mint-CAT memos **under either ordering** (master's clear-last only narrowed the window) but is harmless by construction: the Supabase token cache is generation-fenced (cannot cache post-reset), spark-wallet entries are keyed by the user's mnemonic (never served cross-user), and the mint CAT is wiped again when a *different* user's session begins (the `lastUserId` guard in `applySessionFromServer`). Residual: a same-user memo staying warm across their own sign-out/sign-in (benign), and one theoretical sliver — a CAT fetch from the previous session resolving *after* the next user's sign-in — tracked in Deferred. +5. **`authQueryOptions` is snapshot-driven:** master's queryFn called `fetchUser()` on every refetch; now a refetch awaits the memoized `sdk.init()` and reads the in-memory session snapshot. Every existing invalidation site is preceded by an SDK auth verb that already refreshed the snapshot, so reads stay fresh — but future code calling `invalidateAuthQueries()` with no preceding auth verb reads the snapshot, not the server. (A session-ending failure clears the restore memo, so the next invalidation re-restores from storage — a transient post-login `fetchUser` blip recovers on the glue's own invalidation, like master.) +6. **Expiry-timer scope:** master armed the refresh-expiry timer only under the protected layout's `` (which wraps every protected page, verify-email and accept-terms included); the SDK arms it whenever a session exists — now also on public/marketing pages that read auth state. A full-account expiry there ends the session in place without a toast (the subscriber lives in `Wallet`); master had no timer on those pages and logged out on the next protected navigation. Same terminal state. +7. **`setDefaultAccount` reads the account row (and writes column-minimally):** master passed the cached user + account objects and wrote all three default columns, echoing unchanged fields from the cache — which could revert a concurrent change from another device. Now the update writes only the changed columns (no user read, no echo, lost-update-proof) and reads just the account row by PK to derive the per-currency column server-truthfully. One extra lightweight read on the settings and token-claim paths; strictly safer writes. + +## Deferred (tracked, out of scope) + +- `ensureUserData` bootstrap → step 6 (A1). `_protected.tsx` keeps `/temporary` imports for it. +- `getEncryption` + the encryption/cryptography key plumbing (`encryption-hooks.ts`, `cryptography-hooks.ts`, `/temporary`'s `getEncryption`): the contract's migration mapping assigns these "internal (auth slice)", but their remaining web consumers are the not-yet-migrated domains and `ensureUserData` — they go SDK-internal with the accounts slice (step 6, alongside A1) at the earliest. +- `ReadUserDefaultAccountRepository` has NO web consumers (its only caller is the SDK-internal `lightning-address-service`); it stays on `/temporary`'s export list untouched and gets dropped there in step 17/19. +- `ReadUserRepository.toUser` in `useUserChangeHandlers` (realtime row mapping) → step 18. +- Breez WASM into `init()` → first Spark slice (A3). +- `sdk.featureFlags` wiring (A12). +- Deleting `UserService`/user repos from `/temporary` → step 6/18 once the statics find contract homes. +- Exposing refresh-token expiry on `getSession()` (so the web glue stops reading Open Secret's storage keys for the hint cookie and `staleTime`) → revisit with the step-18 session/events work. +- SDK-thrown error typing: the user repos throw plain `Error` today, so `sdk.user.*` doesn't yet honor the contract's "everything the SDK throws extends `SdkError`". Wrapping repo errors is a cross-domain pass — tracked for step 17/19. (Web behavior is unaffected: unknown errors already get the generic destructive toast.) +- Generation-fencing the mint-CAT memo (like the Supabase token getter): closes the last theoretical repopulation sliver — a CAT fetch from the previous session resolving after the next user's sign-in (i.e., surviving both the sign-out and the sign-in). Pre-existing on master with the same window; sub-second across two user actions. (Cancellation is not an alternative: the fetch happens inside `@agicash/opensecret`'s encrypted tunnel, which exposes no AbortSignal — and an abort landing after the response is queued can't prevent the write anyway; the fence is the correctness mechanism.) +- Session-scoped AbortController for SDK-internal reads: created per session, aborted on `endSession()`, signal threaded into the namespaces' Supabase queries (the repos already accept `abortSignal`), so in-flight namespace promises reject at session end instead of resolving into a dead session. Implements the contract's `dispose()` teardown semantics ("still-pending namespace promises reject with a typed `SdkError`") → step-18/dispose work. + +--- + +## File Structure + +**Created (packages/):** +- `packages/wallet-sdk/lib/events.ts` — `WalletEventEmitter` (typed `on`/`emit`) +- `packages/wallet-sdk/lib/events.test.ts` +- `packages/wallet-sdk/lib/password.ts` — CSPRNG `generateRandomPassword` +- `packages/wallet-sdk/domain/user/guest-account-storage.ts` — port-backed guest creds +- `packages/wallet-sdk/domain/user/guest-account-storage.test.ts` +- `packages/wallet-sdk/domain/user/auth-service.ts` — `AuthService implements AuthApi` +- `packages/wallet-sdk/domain/user/auth-service.test.ts` +- `packages/wallet-sdk/domain/user/user-api.ts` — `createUserApi(deps): UserApi` +- `packages/wallet-sdk/db/client.ts` — `createAgicashDbClient` +- `packages/wallet-sdk/db/supabase-session.ts` — memoized third-party-token getter +- `packages/wallet-sdk/db/supabase-session.test.ts` +- `packages/wallet-sdk/agicash-sdk.ts` — `class AgicashSdk` +- `packages/utils/src/timeout.ts` (moved from web) + +**Modified (SDK):** +- `packages/wallet-sdk/sdk.ts` — settle `AuthStorage`, `AuthUser`, 3 param types; add `generateGuestPassword` port +- `packages/wallet-sdk/index.ts` — export `AgicashSdk` +- `packages/wallet-sdk/lib/error.ts` — add internal `NoSessionError` +- `packages/wallet-sdk/domain/user/user-repository.ts` — A9 refactor +- `packages/wallet-sdk/domain/user/user-service.ts` — A9 param loosening +- Root `package.json` — catalog bump to `1.0.0-rc.0` + +**Created (web):** +- `apps/web-wallet/app/features/shared/sdk.client.ts` — config assembly + `sdk` singleton + +**Modified (web):** +- `apps/web-wallet/app/entry.client.tsx` — drop `configure()`, import `sdk` +- `apps/web-wallet/app/features/agicash-db/database.client.ts` — export `supabaseUrl`/`supabaseAnonKey` +- `apps/web-wallet/app/features/user/auth.ts` — rewrite as thin glue over `sdk.auth` +- `apps/web-wallet/app/features/user/user-hooks.tsx` — flip to `sdk.user`/`sdk.auth` +- `apps/web-wallet/app/features/wallet/wallet.tsx` — expiry hook swap +- `apps/web-wallet/app/routes/_protected.tsx` — `AuthUser` import + A9 call-site +- `apps/web-wallet/app/routes/_auth.oauth.$provider.tsx` — `completeGoogleAuth` +- `apps/web-wallet/app/features/signup/verify-email.ts` — `sdk.auth.verifyEmail` +- `apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx` — A11 +- `apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts`, `apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts`, `apps/web-wallet/app/hooks/use-long-timeout.ts` — timeout import repoint (the hook is later deleted in Task 11) + +**Deleted (web):** +- `apps/web-wallet/app/features/user/guest-account-storage.ts` (→ SDK) +- `apps/web-wallet/app/features/user/user-repository-hooks.ts` +- `apps/web-wallet/app/features/user/user-service-hooks.ts` +- `apps/web-wallet/app/lib/timeout.ts` (→ `@agicash/utils`) +- `apps/web-wallet/app/hooks/use-long-timeout.ts` (its only consumer is the old `useHandleSessionExpiry`; dead after Task 11) +- `apps/web-wallet/app/lib/password-generator.ts` (→ SDK `lib/password.ts` as the only generator; the web keeps just the `window.getMockPassword` bridge in `sdk.client.ts` — A4) + +--- + +### Task 1: Adopt `@agicash/opensecret@1.0.0-rc.0` + +**Files:** +- Modify: `package.json` (repo root, `workspaces.catalog`) +- Modify: `apps/web-wallet/package.json`, `packages/wallet-sdk/package.json` (`jwt-decode` → `catalog:`) +- Modify: `apps/web-wallet/app/entry.client.tsx:36-39` (temporary `storage` arg to stay green) + +**Interfaces:** +- Produces: the RC API for all later tasks — `configure({ apiUrl, clientId, storage })`, `browserStorage`, `StorageProvider`/`KeyValueStore` types, and the unchanged fn set (`signIn`, `signUp`, `signUpGuest`, `signInGuest`, `signOut`, `fetchUser`, `verifyEmail`, `requestNewVerificationCode`, `convertGuestToUserAccount`, `initiateGoogleAuth`, `handleGoogleCallback`, `generateThirdPartyToken`, `getPrivateKey`, `getPrivateKeyBytes`, `getPublicKey`, `signMessage`). + +- [ ] **Step 1: Bump the catalog version (+ fold `jwt-decode` into the catalog)** + +In root `package.json`, change: + +```json +"@agicash/opensecret": "0.1.0", +``` + +to: + +```json +"@agicash/opensecret": "1.0.0-rc.0", +``` + +and add `"jwt-decode": "4.0.0"` to the same `workspaces.catalog` block, switching the two consumers (`apps/web-wallet/package.json`, `packages/wallet-sdk/package.json`) from `"jwt-decode": "4.0.0"` to `"jwt-decode": "catalog:"` — repo rule: a dep shared by ≥2 packages lives in the catalog. + +- [ ] **Step 2: Install** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun install` +Expected: lockfile updates; no peer-dep warnings (RC has no React peer deps). + +- [ ] **Step 3: Satisfy the now-required `storage` option in `entry.client.tsx`** + +The RC's `configure()` requires `storage`. In `apps/web-wallet/app/entry.client.tsx`, change: + +```ts +import { configure } from '@agicash/opensecret'; +``` +```ts +configure({ + apiUrl: openSecretApiUrl, + clientId: openSecretClientId, +}); +``` + +to: + +```ts +import { browserStorage, configure } from '@agicash/opensecret'; +``` +```ts +configure({ + apiUrl: openSecretApiUrl, + clientId: openSecretClientId, + storage: browserStorage, +}); +``` + +(This is transitional; Task 10 moves `configure()` into `AgicashSdk.create()` and reverts this file further.) + +- [ ] **Step 4: Verify types + boot** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` +Expected: PASS (all used fn signatures are unchanged in the RC). + +Smoke: `bun run dev`, open `http://127.0.0.1:3000`, log in (or guest signup), confirm wallet loads. A pre-existing session in localStorage must still be logged in (keys unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add package.json bun.lock apps/web-wallet/package.json packages/wallet-sdk/package.json apps/web-wallet/app/entry.client.tsx +git commit -m "feat(deps): adopt React-agnostic @agicash/opensecret 1.0.0-rc.0" +``` + +--- + +### Task 2: Move the long-timeout util to `@agicash/utils` + +**Files:** +- Create: `packages/utils/src/timeout.ts` (content = current `apps/web-wallet/app/lib/timeout.ts`, verbatim) +- Modify: `packages/utils/src/index.ts` (add `export * from './timeout';`) +- Delete: `apps/web-wallet/app/lib/timeout.ts` +- Modify: `apps/web-wallet/app/hooks/use-long-timeout.ts` (import `{ clearLongTimeout, setLongTimeout }` from `@agicash/utils`) +- Modify: `apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts:~41` (same repoint) +- Modify: `apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts:6` (imports the util RELATIVELY: `from '../timeout'` → `from '@agicash/utils'`) + +**Interfaces:** +- Produces: `setLongTimeout(callback: () => void, delay: number): LongTimeout`, `clearLongTimeout(timeout: LongTimeout): void`, `type LongTimeout` from `@agicash/utils` — consumed by Task 6's `AuthService`. + +- [ ] **Step 1: Move the file** — `git mv` semantics: create `packages/utils/src/timeout.ts` with the exact current content of `apps/web-wallet/app/lib/timeout.ts`, delete the web file, add the barrel export. + +- [ ] **Step 2: Repoint the three web importers** — `use-long-timeout.ts` and `cashu-receive-quote-hooks.ts` import via the `~/lib/timeout` alias; `lib/cashu/melt-quote-subscription.ts:6` imports via the RELATIVE specifier `'../timeout'`. Replace all three with `@agicash/utils`. Keep imported names identical (`type LongTimeout`, `clearLongTimeout`, `setLongTimeout`). + +- [ ] **Step 3: Verify** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` +Expected: PASS. `grep -rn "from '.*timeout'" apps/web-wallet/app | grep -v '@agicash/utils'` returns nothing (catches alias AND relative specifiers). + +- [ ] **Step 4: Commit** + +```bash +git add packages/utils apps/web-wallet +git commit -m "refactor(utils): move setLongTimeout/clearLongTimeout to @agicash/utils" +``` + +--- + +### Task 3: Settle the step-5 contract types in `sdk.ts` + +**Files:** +- Modify: `packages/wallet-sdk/sdk.ts` + +**Interfaces:** +- Produces (consumed by every later task): + - `AuthKeyValueStore` = `{ getItem(key): string | null | Promise; setItem(key, value): void | Promise; removeItem(key): void | Promise }` + - `AuthStorage` = `{ persistent: AuthKeyValueStore; session: AuthKeyValueStore }` + - `SdkConfig['auth']` gains `generateGuestPassword?: () => Promise` + - `AuthUser = UserResponse['user']` (from `@agicash/opensecret`) + - `AcceptTermsParams`, `SetDefaultAccountParams`, `SetDefaultCurrencyParams` per A10. + +- [ ] **Step 1: Replace the `AuthStorage` placeholder** (`sdk.ts:60-65`) with the RC-verbatim shape: + +```ts +/** + * Minimal key/value store — the Web Storage API subset the SDK persists auth + * state through. Methods may be sync (window.localStorage) or async (React + * Native AsyncStorage, SQLite); the SDK always awaits results. Matches the + * @agicash/opensecret StorageProvider interface verbatim, so one host object + * backs both. + */ +export type AuthKeyValueStore = { + getItem(key: string): string | null | Promise; + setItem(key: string, value: string): void | Promise; + removeItem(key: string): void | Promise; +}; + +/** + * Host-backed session persistence. `persistent` must survive restarts (auth + * tokens, guest credentials); `session` is per-app-session (attestation + * handshake material). Browser hosts map them to localStorage/sessionStorage. + */ +export type AuthStorage = { + persistent: AuthKeyValueStore; + session: AuthKeyValueStore; +}; +``` + +- [ ] **Step 2: Add the guest-password port** to `SdkConfig.auth`: + +```ts + auth: { + apiUrl: string; + clientId: string; + storage: AuthStorage; + /** + * Host override for guest credential generation; resolve null to use the + * SDK's CSPRNG generator. Test seam (the web bridges its e2e password + * mock through it). + */ + generateGuestPassword?: () => Promise; + }; +``` + +- [ ] **Step 3: Settle `AuthUser`** — replace `export type AuthUser = unknown; // settles in step 5 (auth & user)` with: + +```ts +import type { UserResponse } from '@agicash/opensecret'; +// … +export type AuthUser = UserResponse['user']; +``` + +(import goes at the top with the other imports). + +- [ ] **Step 4: Settle the three param placeholders** — replace the `unknown` lines at the bottom: + +```ts +export type AcceptTermsParams = { + walletTerms?: boolean; + giftCardTerms?: boolean; +}; + +export type SetDefaultAccountParams = { + accountId: string; + /** Also switch the user's default currency to the account's currency. */ + setDefaultCurrency?: boolean; +}; + +export type SetDefaultCurrencyParams = { + currency: Currency; +}; +``` + +Add `Currency` to the existing `@agicash/money` type import (`import type { Currency, Money } from '@agicash/money';`). + +- [ ] **Step 5: Add `auth.session-refreshed` to `WalletEventMap`** (contract addition per A13 — adding events is non-breaking per the map's own invariant). Insert directly after the `'auth.session-expired'` entry in `sdk.ts`: + +```ts + /** + * The SDK refreshed the session without a host-initiated verb — today: + * guest auto-extension at refresh-token expiry. Host-initiated verbs never + * fire it (the host knows its own actions). Hosts re-sync session-derived + * state from it (the web: auth query + session-hint cookie). + */ + 'auth.session-refreshed': Record; +``` + +Append the same entry with a one-line A13 note to the event map in `docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md`, so the prose contract stays in sync with `sdk.ts`. + +- [ ] **Step 6: Annotate `init()`'s contract JSDoc for the migration window** — the doc-comment on `Sdk['init']` promises session restore AND the Breez WASM load, but step 5 ships restore-only (A3). Append one line to that JSDoc so contract readers aren't misled mid-migration: + +```ts + * Migration note: until the first Spark slice lands, `init()` performs + * session restore only — the WASM load still runs host-side. +``` + +- [ ] **Step 7: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. + +```bash +git add packages/wallet-sdk/sdk.ts +git commit -m "feat(wallet-sdk): settle the step-5 contract types (auth storage, AuthUser, user params)" +``` + +--- + +### Task 4: Typed event emitter (`lib/events.ts`) + +**Files:** +- Create: `packages/wallet-sdk/lib/events.ts` +- Test: `packages/wallet-sdk/lib/events.test.ts` + +**Interfaces:** +- Consumes: `WalletEventMap`, `WalletEvents`, `Logger` types from `../sdk`. +- Produces: `class WalletEventEmitter implements WalletEvents` with `on(event, handler): () => void` and `emit(event, payload): void` — consumed by Tasks 6 and 8. + +- [ ] **Step 1: Write the failing tests** + +```ts +// packages/wallet-sdk/lib/events.test.ts +import { describe, expect, it } from 'bun:test'; +import { WalletEventEmitter } from './events'; + +describe('WalletEventEmitter', () => { + it('delivers payloads to subscribed handlers', () => { + const emitter = new WalletEventEmitter(); + const received: unknown[] = []; + emitter.on('auth.session-expired', (payload) => received.push(payload)); + + emitter.emit('auth.session-expired', {}); + + expect(received).toEqual([{}]); + }); + + it('stops delivering after unsubscribe', () => { + const emitter = new WalletEventEmitter(); + let calls = 0; + const unsubscribe = emitter.on('auth.session-expired', () => { + calls += 1; + }); + + unsubscribe(); + emitter.emit('auth.session-expired', {}); + + expect(calls).toBe(0); + }); + + it('isolates a throwing handler and reports it to the logger', () => { + const errors: string[] = []; + const emitter = new WalletEventEmitter({ + debug: () => {}, + info: () => {}, + warn: () => {}, + error: (message) => { + errors.push(message); + }, + }); + let secondHandlerRan = false; + emitter.on('auth.session-expired', () => { + throw new Error('boom'); + }); + emitter.on('auth.session-expired', () => { + secondHandlerRan = true; + }); + + emitter.emit('auth.session-expired', {}); + + expect(secondHandlerRan).toBe(true); + expect(errors).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && cd packages/wallet-sdk && bun test lib/events.test.ts` +Expected: FAIL — `events.ts` does not exist. + +- [ ] **Step 3: Implement** + +```ts +// packages/wallet-sdk/lib/events.ts +import type { Logger, WalletEventMap, WalletEvents } from '../sdk'; + +type Handler = (payload: never) => void; + +export class WalletEventEmitter implements WalletEvents { + private readonly handlers = new Map>(); + + constructor(private readonly logger?: Logger) {} + + on( + event: K, + handler: (payload: WalletEventMap[K]) => void, + ): () => void { + const set = this.handlers.get(event) ?? new Set(); + set.add(handler as Handler); + this.handlers.set(event, set); + return () => { + set.delete(handler as Handler); + }; + } + + emit( + event: K, + payload: WalletEventMap[K], + ): void { + const set = this.handlers.get(event); + if (!set) { + return; + } + for (const handler of set) { + try { + (handler as (payload: WalletEventMap[K]) => void)(payload); + } catch (error) { + this.logger?.error(`Event handler for ${event} threw`, error); + } + } + } +} +``` + +- [ ] **Step 4: Run tests** — same command, expected: 3 pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/wallet-sdk/lib/events.ts packages/wallet-sdk/lib/events.test.ts +git commit -m "feat(wallet-sdk): add the typed wallet event emitter" +``` + +--- + +### Task 5: SDK password generator + guest-account storage + +**Files:** +- Create: `packages/wallet-sdk/lib/password.ts` +- Create: `packages/wallet-sdk/domain/user/guest-account-storage.ts` +- Test: `packages/wallet-sdk/domain/user/guest-account-storage.test.ts` + +**Interfaces:** +- Consumes: `AuthKeyValueStore`, `Logger` from `../../sdk`; `safeJsonParse` from `@agicash/utils`. +- Produces: + - `generateRandomPassword(length?: number): Promise` (CSPRNG, no window access) + - `type GuestAccountDetails = { id: string; password: string }` + - `createGuestAccountStorage(store: AuthKeyValueStore, logger?: Logger): GuestAccountStorage` where `GuestAccountStorage = { get(): Promise; store(details): Promise; clear(): Promise }` — consumed by Task 6. + +- [ ] **Step 1: Write the failing storage tests** + +```ts +// packages/wallet-sdk/domain/user/guest-account-storage.test.ts +import { describe, expect, it } from 'bun:test'; +import type { AuthKeyValueStore } from '../../sdk'; +import { createGuestAccountStorage } from './guest-account-storage'; + +const createMemoryStore = (): AuthKeyValueStore & { data: Map } => { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +describe('guestAccountStorage', () => { + it('round-trips guest account details under the legacy key', async () => { + const store = createMemoryStore(); + const storage = createGuestAccountStorage(store); + + await storage.store({ id: 'guest-1', password: 'pw' }); + + expect(store.data.has('guestAccount')).toBe(true); + expect(await storage.get()).toEqual({ id: 'guest-1', password: 'pw' }); + }); + + it('returns null when nothing is stored', async () => { + const storage = createGuestAccountStorage(createMemoryStore()); + expect(await storage.get()).toBeNull(); + }); + + it('returns null for corrupt or invalid data', async () => { + const store = createMemoryStore(); + store.data.set('guestAccount', 'not-json'); + const storage = createGuestAccountStorage(store); + expect(await storage.get()).toBeNull(); + + store.data.set('guestAccount', JSON.stringify({ id: 42 })); + expect(await storage.get()).toBeNull(); + }); + + it('clear removes the stored account', async () => { + const store = createMemoryStore(); + const storage = createGuestAccountStorage(store); + await storage.store({ id: 'guest-1', password: 'pw' }); + + await storage.clear(); + + expect(await storage.get()).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `cd packages/wallet-sdk && bun test domain/user/guest-account-storage.test.ts` → FAIL (module missing). + +- [ ] **Step 3: Implement storage** + +```ts +// packages/wallet-sdk/domain/user/guest-account-storage.ts +import { safeJsonParse } from '@agicash/utils'; +import { z } from 'zod/mini'; +import type { AuthKeyValueStore, Logger } from '../../sdk'; + +// Key predates the SDK move — existing devices have guest credentials stored +// under it, so it must not change. +const storageKey = 'guestAccount'; + +const GuestAccountDetailsSchema = z.object({ + id: z.string(), + password: z.string(), +}); + +export type GuestAccountDetails = z.infer; + +export type GuestAccountStorage = { + get(): Promise; + store(details: GuestAccountDetails): Promise; + clear(): Promise; +}; + +export function createGuestAccountStorage( + store: AuthKeyValueStore, + logger?: Logger, +): GuestAccountStorage { + return { + async get() { + const dataString = await store.getItem(storageKey); + if (!dataString) { + return null; + } + const parseResult = safeJsonParse(dataString); + if (!parseResult.success) { + return null; + } + const validationResult = GuestAccountDetailsSchema.safeParse( + parseResult.data, + ); + if (!validationResult.success) { + logger?.warn('Invalid guest account data found in the storage'); + return null; + } + return validationResult.data; + }, + async store(details) { + await store.setItem(storageKey, JSON.stringify(details)); + }, + async clear() { + await store.removeItem(storageKey); + }, + }; +} +``` + +- [ ] **Step 4: Implement the password generator** (moved from `apps/web-wallet/app/lib/password-generator.ts`, minus the `window.getMockPassword` hook and `window.` prefixes — this becomes the ONLY generator; the web file is deleted in Task 11 once its last importer, the old `auth.ts`, is rewritten): + +```ts +// packages/wallet-sdk/lib/password.ts +type PasswordOptions = { + letters?: boolean; + numbers?: boolean; + special?: boolean; +}; + +export async function generateRandomPassword( + length = 24, + options: PasswordOptions = { letters: true, numbers: true, special: true }, +): Promise { + let charset = ''; + + if (options.letters) + charset += 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if (options.numbers) charset += '0123456789'; + if (options.special) charset += '!@#$%^&*()_+~'; + + if (!charset) { + throw new Error( + 'At least one character set (letters, numbers, special) must be selected.', + ); + } + + const password: string[] = []; + + for (let i = 0; i < length; i++) { + const randomIndex = + globalThis.crypto.getRandomValues(new Uint32Array(1))[0] % charset.length; + password.push(charset[randomIndex]); + } + + return password.join(''); +} +``` + +- [ ] **Step 5: Run tests** — storage tests pass; `bun run fix:all && bun run typecheck` passes. + +- [ ] **Step 6: Commit** + +```bash +git add packages/wallet-sdk/lib/password.ts packages/wallet-sdk/domain/user +git commit -m "feat(wallet-sdk): add port-backed guest-account storage and CSPRNG password generator" +``` + +---### Task 6: `AuthService` — session state, verbs, expiry machinery + +**Files:** +- Create: `packages/wallet-sdk/domain/user/auth-service.ts` +- Test: `packages/wallet-sdk/domain/user/auth-service.test.ts` +- Modify: `packages/wallet-sdk/lib/error.ts` (add `NoSessionError`) + +**Interfaces:** +- Consumes: `AuthApi`, `AuthSession`, `AuthStorage`, `Logger` from `../../sdk`; `WalletEventEmitter` from `../../lib/events`; `GuestAccountStorage` from `./guest-account-storage`; `setLongTimeout`/`clearLongTimeout`/`LongTimeout` from `@agicash/utils`; `jwtDecode` from `jwt-decode`. +- Produces: `class AuthService implements AuthApi` with constructor `new AuthService(deps: AuthServiceDeps)`, plus `restoreSession(): Promise` and `teardown(): void` beyond the contract surface. `type OpenSecretAuthApi` naming the 11 Open Secret fns it uses (so `import * as openSecret` satisfies it structurally). Consumed by Task 8. + +- [ ] **Step 1: Add the internal error class** to `packages/wallet-sdk/lib/error.ts`: + +```ts +/** Thrown when a namespace method requiring an authenticated session runs without one. */ +export class NoSessionError extends SdkError { + constructor() { + super('No authenticated session'); + this.name = 'NoSessionError'; + } +} +``` + +(Not added to `index.ts`/`temporary.ts` — hosts catch it via the exported `SdkError` base.) + +- [ ] **Step 2: Write the failing tests.** Use fake deps throughout — no module mocking. Helper to mint JWTs with a chosen `exp` so timer math is real: + +```ts +// packages/wallet-sdk/domain/user/auth-service.test.ts +import { describe, expect, it } from 'bun:test'; +import type { AuthKeyValueStore, AuthStorage } from '../../sdk'; +import { WalletEventEmitter } from '../../lib/events'; +import { AuthService, type OpenSecretAuthApi } from './auth-service'; +import { createGuestAccountStorage } from './guest-account-storage'; + +const createMemoryStore = (): AuthKeyValueStore & { data: Map } => { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +const createStorage = (): AuthStorage & { persistent: ReturnType } => ({ + persistent: createMemoryStore(), + session: createMemoryStore(), +}); + +const toBase64Url = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + +const createJwt = (expSecondsFromNow: number, sub = 'user-1') => + `${toBase64Url({ alg: 'none' })}.${toBase64Url({ + sub, + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + })}.sig`; + +const fullUser = { + id: 'user-1', + name: null, + email: 'a@b.c', + email_verified: true, + login_method: 'email', + created_at: '2026-01-01', + updated_at: '2026-01-01', +}; + +const guestUser = { ...fullUser, email: undefined }; + +const createOsFake = ( + tokenStore: ReturnType, + overrides: Partial = {}, +) => { + const calls: string[] = []; + // The real Open Secret SDK persists fresh tokens on every login path; the + // fakes mirror that so a timer re-arm after login reads a live refresh token. + const login = (id: string) => { + tokenStore.data.set('access_token', createJwt(600, id)); + tokenStore.data.set('refresh_token', createJwt(3600, id)); + return { id, access_token: 'a', refresh_token: 'r' }; + }; + const os: OpenSecretAuthApi = { + fetchUser: async () => ({ user: fullUser }), + signIn: async () => login('user-1'), + signUp: async () => login('user-1'), + signUpGuest: async () => login('guest-1'), + signInGuest: async () => login('guest-1'), + signOut: async () => {}, + verifyEmail: async () => {}, + requestNewVerificationCode: async () => {}, + convertGuestToUserAccount: async () => {}, + initiateGoogleAuth: async () => ({ auth_url: 'https://accounts.google/x', csrf_token: 'c' }), + handleGoogleCallback: async () => login('user-1'), + ...overrides, + }; + // wrap every fn to record invocation order + for (const key of Object.keys(os) as (keyof OpenSecretAuthApi)[]) { + const original = os[key] as (...args: unknown[]) => unknown; + // biome-ignore lint/suspicious/noExplicitAny: test instrumentation + (os as any)[key] = (...args: unknown[]) => { + calls.push(key); + return original(...args); + }; + } + return { os, calls }; +}; + +const createService = (options: { + os?: Partial; + storage?: ReturnType; + onSessionEnded?: () => void; +} = {}) => { + const storage = options.storage ?? createStorage(); + const { os, calls } = createOsFake(storage.persistent, options.os); + const events = new WalletEventEmitter(); + const service = new AuthService({ + os, + storage, + guestAccountStorage: createGuestAccountStorage(storage.persistent), + generateGuestPassword: async () => 'generated-pw', + events, + onSessionEnded: options.onSessionEnded, + }); + return { service, storage, calls, events }; +}; + +describe('AuthService', () => { + it('starts anonymous', () => { + const { service } = createService(); + expect(service.getSession()).toEqual({ isLoggedIn: false }); + }); + + describe('restoreSession', () => { + it('stays anonymous without stored tokens and does not call fetchUser', async () => { + const { service, calls } = createService(); + await service.restoreSession(); + expect(service.getSession().isLoggedIn).toBe(false); + expect(calls).not.toContain('fetchUser'); + }); + + it('restores a session from stored tokens', async () => { + const { service, storage } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await service.restoreSession(); + + expect(service.getSession()).toEqual({ isLoggedIn: true, user: fullUser }); + service.teardown(); + }); + + it('rejects when tokens exist but the user fetch fails, then recovers on retry', async () => { + let sessionEnded = false; + let failFetch = true; + const { service, storage } = createService({ + os: { + fetchUser: async () => { + if (failFetch) { + throw new Error('network'); + } + return { user: fullUser }; + }, + }, + onSessionEnded: () => { + sessionEnded = true; + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await expect(service.restoreSession()).rejects.toThrow('network'); + // per-session caches were torn down with the failed restore + expect(sessionEnded).toBe(true); + expect(service.getSession().isLoggedIn).toBe(false); + + // the rejection is not memoized — a retry can succeed + failFetch = false; + await service.restoreSession(); + + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('is single-flight', async () => { + const { service, storage, calls } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await Promise.all([service.restoreSession(), service.restoreSession()]); + + expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); + service.teardown(); + }); + }); + + describe('signUpGuest', () => { + it('creates a new guest account and stores the credentials', async () => { + const { service, storage, calls } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + + await service.signUpGuest(); + + expect(calls).toContain('signUpGuest'); + expect(JSON.parse(storage.persistent.data.get('guestAccount') ?? '')).toEqual({ + id: 'guest-1', + password: 'generated-pw', + }); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('re-signs-in the stored guest account instead of creating a new one', async () => { + const { service, storage, calls } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'stored-pw' }), + ); + + await service.signUpGuest(); + + expect(calls).toContain('signInGuest'); + expect(calls).not.toContain('signUpGuest'); + service.teardown(); + }); + }); + + describe('signOut', () => { + it('clears the session, keeps guest credentials, and runs onSessionEnded', async () => { + let sessionEnded = false; + const { service, storage } = createService({ + onSessionEnded: () => { + sessionEnded = true; + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + await service.restoreSession(); + + await service.signOut(); + + expect(service.getSession().isLoggedIn).toBe(false); + expect(sessionEnded).toBe(true); + expect(storage.persistent.data.has('guestAccount')).toBe(true); + }); + + it('wipes per-session caches again when a different user signs in after sign-out', async () => { + let sessionEndedCount = 0; + const userIds = ['user-a', 'user-b']; + let fetchCalls = 0; + const { service } = createService({ + os: { + fetchUser: async () => ({ + user: { ...fullUser, id: userIds[Math.min(fetchCalls++, 1)] }, + }), + }, + onSessionEnded: () => { + sessionEndedCount += 1; + }, + }); + + await service.signIn('a@b.c', 'pw'); + await service.signOut(); // ends user-a's session → 1 + await service.signIn('b@b.c', 'pw'); // different user → wiped again → 2 + + expect(sessionEndedCount).toBe(2); + service.teardown(); + }); + }); + + describe('convertGuestToFullAccount', () => { + it('clears the stored guest credentials', async () => { + const { service, storage } = createService(); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await service.convertGuestToFullAccount('a@b.c', 'pw2'); + + expect(storage.persistent.data.has('guestAccount')).toBe(false); + service.teardown(); + }); + }); + + describe('session expiry', () => { + it('emits auth.session-expired and ends the session for a full account', async () => { + let sessionEnded = false; + const { service, storage, events } = createService({ + onSessionEnded: () => { + sessionEnded = true; + }, + }); + // refresh token expiring "now" (exp-5s already past) → timer fires immediately + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // the 1ms-floored timer fires on the next macrotask + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(expired).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(false); + expect(sessionEnded).toBe(true); + }); + + it('auto-extends a guest session instead of expiring it', async () => { + const { service, storage, calls, events } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + const refreshed: unknown[] = []; + events.on('auth.session-refreshed', (payload) => refreshed.push(payload)); + + await service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(calls).toContain('signInGuest'); + expect(expired).toHaveLength(0); + // the host is told about the refresh it didn't initiate + expect(refreshed).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('re-arms instead of expiring when the stored refresh token was rotated forward', async () => { + const { service, storage, events } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + // (exp - 5s) is ~100ms away, so the timer fires shortly after restore + storage.persistent.data.set('refresh_token', createJwt(5.1)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // the Open Secret SDK rotates the refresh token during its internal + // refresh flow; simulate a rotation landing before the timer fires + storage.persistent.data.set('refresh_token', createJwt(3600)); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(expired).toHaveLength(0); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('ends the session when the extension cannot restore the guest user', async () => { + let fetchCalls = 0; + const { service, storage, events } = createService({ + os: { + fetchUser: async () => { + fetchCalls += 1; + // restore succeeds; the post-extend fetch fails + if (fetchCalls > 1) { + throw new Error('network'); + } + return { user: guestUser }; + }, + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // no wedged half-session: the death path ran and told the host + expect(expired).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(false); + }); + }); + + it('initiateGoogleAuth returns the raw auth url', async () => { + const { service } = createService(); + expect(await service.initiateGoogleAuth()).toEqual({ + authUrl: 'https://accounts.google/x', + }); + }); +}); +``` + +Note: the login fakes MUST write fresh tokens into the store (mirroring the real Open Secret SDK, which persists tokens on every login path) — otherwise the guest-extend test would end the session instead of extending it: the post-extend expiry check would see an unmoved expiry and fall through to the death path. (In production that check plus the 1ms timer floor is what guards against a hot extend loop when a returned token is already expired.) Bun runs all test files in one process, so every test that leaves a session (and therefore a live expiry timer) ends with `service.teardown()` to avoid leaking timers into the rest of the suite. + +- [ ] **Step 3: Run to verify failure** — `cd packages/wallet-sdk && bun test domain/user/auth-service.test.ts` → FAIL (module missing). + +- [ ] **Step 4: Implement `AuthService`** + +```ts +// packages/wallet-sdk/domain/user/auth-service.ts +import { + type LongTimeout, + clearLongTimeout, + setLongTimeout, +} from '@agicash/utils'; +import type { + GoogleAuthResponse, + LoginResponse, + UserResponse, +} from '@agicash/opensecret'; +import { jwtDecode } from 'jwt-decode'; +import type { WalletEventEmitter } from '../../lib/events'; +import type { AuthApi, AuthSession, AuthStorage, Logger } from '../../sdk'; +import type { GuestAccountStorage } from './guest-account-storage'; + +// Keys are owned by @agicash/opensecret's token persistence; the service reads +// them (never writes) for session detection and expiry math. +const accessTokenKey = 'access_token'; +const refreshTokenKey = 'refresh_token'; + +// A corrupt stored token must degrade (no timer / expiry path), never throw +// from a timer callback or a query fn. +const decodeJwt = (token: string): { exp?: number } | null => { + try { + return jwtDecode(token); + } catch { + return null; + } +}; + +/** The subset of @agicash/opensecret the auth service drives. `import * as openSecret` satisfies it. */ +export type OpenSecretAuthApi = { + fetchUser(): Promise; + signIn(email: string, password: string): Promise; + signUp( + email: string, + password: string, + inviteCode: string, + name?: string | null, + ): Promise; + signUpGuest(password: string, inviteCode: string): Promise; + signInGuest(id: string, password: string): Promise; + signOut(): Promise; + verifyEmail(code: string): Promise; + requestNewVerificationCode(): Promise; + convertGuestToUserAccount( + email: string, + password: string, + name?: string | null, + ): Promise; + initiateGoogleAuth(inviteCode?: string): Promise; + handleGoogleCallback( + code: string, + state: string, + inviteCode: string, + ): Promise; +}; + +type AuthServiceDeps = { + os: OpenSecretAuthApi; + storage: AuthStorage; + guestAccountStorage: GuestAccountStorage; + generateGuestPassword: () => Promise; + events: WalletEventEmitter; + /** Instance-memo cleanup on any session end (sign-out or expiry). */ + onSessionEnded?: () => void; + logger?: Logger; +}; + +export class AuthService implements AuthApi { + private session: AuthSession = { isLoggedIn: false }; + private restorePromise: Promise | undefined; + private expiryTimeout: LongTimeout | undefined; + // Survives endSession() deliberately — see applySessionFromServer. + private lastUserId: string | undefined; + + constructor(private readonly deps: AuthServiceDeps) {} + + getSession(): AuthSession { + return this.session; + } + + /** + * Idempotent session restore from the storage port; resolving anonymous is + * a state, not a failure. A rejection (unreadable storage) is not memoized, + * so the host's query retries can recover. + */ + restoreSession(): Promise { + this.restorePromise ??= this.doRestore().catch((error) => { + this.restorePromise = undefined; + throw error; + }); + return this.restorePromise; + } + + private async doRestore(): Promise { + const [accessToken, refreshToken] = await Promise.all([ + this.deps.storage.persistent.getItem(accessTokenKey), + this.deps.storage.persistent.getItem(refreshTokenKey), + ]); + if (!accessToken || !refreshToken) { + return; + } + try { + await this.applySessionFromServer(); + } catch (error) { + if (this.session.isLoggedIn) { + // An auth verb established a session while this restore was in + // flight; the restore result is moot. + return; + } + // Contract: init() rejects on refresh errors (tokens exist but can't + // be validated). endSession keeps the instance consistent; the + // rejection is un-memoized by restoreSession, so a retry can succeed. + this.endSession(); + throw error; + } + } + + async signUp(email: string, password: string): Promise { + await this.deps.os.signUp(email, password, ''); + await this.refreshSessionSnapshot('sign up'); + } + + async signUpGuest(): Promise { + const existingGuestAccount = await this.deps.guestAccountStorage.get(); + if (existingGuestAccount) { + await this.deps.os.signInGuest( + existingGuestAccount.id, + existingGuestAccount.password, + ); + } else { + const password = await this.deps.generateGuestPassword(); + const guestAccount = await this.deps.os.signUpGuest(password, ''); + await this.deps.guestAccountStorage.store({ + id: guestAccount.id, + password, + }); + } + await this.refreshSessionSnapshot('guest sign up'); + } + + async signIn(email: string, password: string): Promise { + await this.deps.os.signIn(email, password); + await this.refreshSessionSnapshot('sign in'); + } + + async signOut(): Promise { + try { + await this.deps.os.signOut(); + } finally { + this.endSession(); + } + } + + async verifyEmail(code: string): Promise { + await this.deps.os.verifyEmail(code); + await this.refreshSessionSnapshot('email verification'); + } + + requestNewVerificationCode(): Promise { + return this.deps.os.requestNewVerificationCode(); + } + + async convertGuestToFullAccount( + email: string, + password: string, + ): Promise { + await this.deps.os.convertGuestToUserAccount(email, password); + await this.deps.guestAccountStorage.clear(); + await this.refreshSessionSnapshot('guest conversion'); + } + + async initiateGoogleAuth(): Promise<{ authUrl: string }> { + const response = await this.deps.os.initiateGoogleAuth(''); + return { authUrl: response.auth_url }; + } + + async completeGoogleAuth(params: { + code: string; + state: string; + }): Promise { + await this.deps.os.handleGoogleCallback(params.code, params.state, ''); + await this.refreshSessionSnapshot('google auth'); + } + + /** Cancels the expiry timer; the instance stays usable. */ + teardown(): void { + this.disarmExpiryTimer(); + } + + private async refreshSessionSnapshot(context: string): Promise { + try { + await this.applySessionFromServer(); + } catch (error) { + // Swallowed for parity: a verb whose fetchUser fails leaves an + // anonymous session the host discovers on its next read, like the old + // web glue. endSession (not a bare snapshot clear) so the per-session + // caches die with the session — the Supabase token cache in particular + // must never outlive it. + this.deps.logger?.error(`Failed to fetch user (${context})`, error); + this.endSession(); + } + } + + private async applySessionFromServer(): Promise { + const response = await this.deps.os.fetchUser(); + // Compared against the last seen user rather than the live session: a + // memo repopulated by a request that resolved after sign-out must still + // be wiped when a DIFFERENT user's session begins, and by then the + // session is anonymous. Same-user re-login keeps its memos warm. + if (this.lastUserId && this.lastUserId !== response.user.id) { + this.deps.onSessionEnded?.(); + } + this.lastUserId = response.user.id; + this.session = { isLoggedIn: true, user: response.user }; + await this.armExpiryTimer(); + } + + private endSession(): void { + this.session = { isLoggedIn: false }; + this.disarmExpiryTimer(); + // Un-memoize the restore so the next init() re-evaluates from storage — + // a verb whose post-login fetchUser failed leaves tokens behind, and the + // next invalidation can then recover the session like the old glue did. + this.restorePromise = undefined; + this.deps.onSessionEnded?.(); + } + + private async armExpiryTimer(): Promise { + const remaining = await this.getRemainingSessionTimeMs(); + // Disarm only after the await, so disarm+assign form one synchronous + // block — two overlapping arms can't interleave and orphan a timer. + this.disarmExpiryTimer(); + if (remaining === null) { + return; + } + // Floor of 1ms: setLongTimeout fires synchronously at delay 0, which + // would recurse into handleSessionExpiry from inside a login verb. + this.expiryTimeout = setLongTimeout( + () => { + void this.handleSessionExpiry(); + }, + Math.max(remaining, 1), + ); + } + + /** + * Milliseconds until the stored refresh token is treated as expired (5s + * before actual expiry, matching the previous web behavior), floored at 0. + * Null when the token is absent or undecodable. + */ + private async getRemainingSessionTimeMs(): Promise { + const refreshToken = + await this.deps.storage.persistent.getItem(refreshTokenKey); + if (!refreshToken) { + return null; + } + const decoded = decodeJwt(refreshToken); + if (!decoded?.exp) { + return null; + } + return Math.max((decoded.exp - 5) * 1000 - Date.now(), 0); + } + + private disarmExpiryTimer(): void { + if (this.expiryTimeout) { + clearLongTimeout(this.expiryTimeout); + this.expiryTimeout = undefined; + } + } + + private async handleSessionExpiry(): Promise { + const session = this.session; + if (!session.isLoggedIn) { + return; + } + // The Open Secret SDK rotates the refresh token during its internal + // refresh flow, so the expiry this timer was armed for may have moved. + // Re-check the stored token and re-arm instead of expiring a live session. + const remaining = await this.getRemainingSessionTimeMs(); + if (remaining !== null && remaining > 0) { + await this.armExpiryTimer(); + return; + } + const isGuest = !session.user.email; + if (isGuest) { + try { + // Re-signing-in the stored guest account gets fresh tokens and re-arms + // the timer; the host never observes the expiry. + await this.signUpGuest(); + const extendedRemaining = await this.getRemainingSessionTimeMs(); + if ( + extendedRemaining !== null && + extendedRemaining > 0 && + this.session.isLoggedIn + ) { + // The host didn't initiate this refresh, so it must be told — + // the web re-syncs its auth query + session-hint cookie from it. + this.deps.events.emit('auth.session-refreshed', {}); + return; + } + // Falls through when the extension produced no live session (already- + // expired token — also guards a hot extend loop — or a failed + // post-extend user fetch), so the death path emits the event instead + // of leaving a wedged half-session. + this.deps.logger?.warn( + 'Guest session extension did not produce a live session; ending it', + ); + } catch (error) { + this.deps.logger?.error('Failed to extend guest session', error); + } + } + try { + await this.deps.os.signOut(); + } catch (error) { + this.deps.logger?.warn('Sign out during session expiry failed', error); + } + this.endSession(); + this.deps.events.emit('auth.session-expired', {}); + } +} +``` + +- [ ] **Step 5: Run tests** — all `auth-service.test.ts` tests pass; run `bun test` (whole SDK) and `bun run fix:all && bun run typecheck` → PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/wallet-sdk/domain/user/auth-service.ts packages/wallet-sdk/domain/user/auth-service.test.ts packages/wallet-sdk/lib/error.ts +git commit -m "feat(wallet-sdk): add AuthService with session snapshot and expiry machinery" +``` + +--- + +### Task 7: SDK Supabase client + internal session-token getter + +**Files:** +- Create: `packages/wallet-sdk/db/client.ts` +- Create: `packages/wallet-sdk/db/supabase-session.ts` +- Test: `packages/wallet-sdk/db/supabase-session.test.ts` + +**Interfaces:** +- Consumes: `Database`, `AgicashDb` types from `./database`; `generateThirdPartyToken` from `@agicash/opensecret`. +- Produces: + - `createSupabaseSessionTokenGetter(deps: { isLoggedIn: () => boolean; generateToken?: () => Promise<{ token: string }> }): SupabaseSessionTokenSource` where `SupabaseSessionTokenSource = { getToken: () => Promise; reset: () => void }`. `reset` MUST be invoked on session end (Task 9 wires it into `onSessionEnded`) — the cache is otherwise only re-validated by expiry, and a token minted for one user must never survive into another user's session (sign out → sign in as a different user would otherwise query with the old JWT until it expires). + - `createAgicashDbClient(config: { url: string; anonKey: string; accessToken: () => Promise }): AgicashDb` — consumed by Task 9. + +- [ ] **Step 1: Write the failing token-getter tests** + +```ts +// packages/wallet-sdk/db/supabase-session.test.ts +import { describe, expect, it } from 'bun:test'; +import { createSupabaseSessionTokenGetter } from './supabase-session'; + +const toBase64Url = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + +const createToken = (expSecondsFromNow: number) => + `${toBase64Url({ alg: 'none' })}.${toBase64Url({ + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + })}.sig`; + +describe('createSupabaseSessionTokenGetter', () => { + it('returns null and skips token generation when logged out', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => false, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + expect(await getToken()).toBeNull(); + expect(generated).toBe(0); + }); + + it('memoizes the token until close to expiry', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + const first = await getToken(); + const second = await getToken(); + + expect(first).toBe(second as string); + expect(generated).toBe(1); + }); + + it('re-generates once the cached token is within 5s of expiry', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + // expires in 3s → refreshAt is already in the past + return { token: createToken(3) }; + }, + }); + + await getToken(); + await getToken(); + + expect(generated).toBe(2); + }); + + it('shares one in-flight request between concurrent callers', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + await new Promise((resolve) => setTimeout(resolve, 5)); + return { token: createToken(3600) }; + }, + }); + + await Promise.all([getToken(), getToken(), getToken()]); + + expect(generated).toBe(1); + }); + + it('drops the cache when the session ends', async () => { + let loggedIn = true; + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => loggedIn, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + await getToken(); + loggedIn = false; + expect(await getToken()).toBeNull(); + loggedIn = true; + await getToken(); + + expect(generated).toBe(2); + }); + + it('reset drops the cached token so the next session cannot reuse it', async () => { + let generated = 0; + const source = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + await source.getToken(); + source.reset(); + await source.getToken(); + + expect(generated).toBe(2); + }); + + it('does not cache a token that resolves after reset', async () => { + let generated = 0; + let release: (() => void) | undefined; + const source = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + if (generated === 1) { + await new Promise((resolve) => { + release = resolve; + }); + } + return { token: createToken(3600) }; + }, + }); + + const firstCall = source.getToken(); + // session ends while the first exchange is still in flight + source.reset(); + release?.(); + await firstCall; + + await source.getToken(); + + // the stale in-flight token was not cached; the new session exchanged fresh + expect(generated).toBe(2); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `cd packages/wallet-sdk && bun test db/supabase-session.test.ts` → FAIL. + +- [ ] **Step 3: Implement** + +```ts +// packages/wallet-sdk/db/supabase-session.ts +import { generateThirdPartyToken } from '@agicash/opensecret'; +import { jwtDecode } from 'jwt-decode'; + +type Deps = { + isLoggedIn: () => boolean; + /** Test seam; defaults to Open Secret's generateThirdPartyToken. */ + generateToken?: () => Promise<{ token: string }>; +}; + +export type SupabaseSessionTokenSource = { + /** Supabase `accessToken` callback; null selects the anon key. */ + getToken: () => Promise; + /** + * Drops the cached token. Must be called when the session ends — the cache + * is otherwise only re-validated by expiry, and a token minted for one user + * must never survive into another user's session. + */ + reset: () => void; +}; + +/** + * Builds the Supabase `accessToken` source: exchanges the Open Secret JWT + * for a Supabase third-party token and memoizes it until 5 seconds before its + * expiry. Concurrent callers share one in-flight exchange. Returns null when + * no session exists (the client then uses the anon key). + */ +export function createSupabaseSessionTokenGetter( + deps: Deps, +): SupabaseSessionTokenSource { + const generateToken = deps.generateToken ?? (() => generateThirdPartyToken()); + let cached: { token: string; refreshAtMs: number } | undefined; + let inFlight: Promise | undefined; + // Incremented by reset(); an exchange started under an older generation + // must not populate the cache — its token belongs to the ended session. + let generation = 0; + + const invalidate = () => { + generation += 1; + cached = undefined; + inFlight = undefined; + }; + + return { + reset: invalidate, + getToken: async () => { + if (!deps.isLoggedIn()) { + // Same full invalidation as reset(): an exchange in flight when the + // session ended must not populate the cache either. + invalidate(); + return null; + } + if (cached && Date.now() < cached.refreshAtMs) { + return cached.token; + } + if (!inFlight) { + const startedGeneration = generation; + inFlight = (async () => { + try { + const { token } = await generateToken(); + if (generation === startedGeneration) { + const { exp } = jwtDecode(token); + cached = { token, refreshAtMs: exp ? (exp - 5) * 1000 : 0 }; + } + return token; + } finally { + if (generation === startedGeneration) { + inFlight = undefined; + } + } + })(); + } + return inFlight; + }, + }; +} +``` + +```ts +// packages/wallet-sdk/db/client.ts +import { createClient } from '@supabase/supabase-js'; +import type { AgicashDb, Database } from './database'; + +type AgicashDbClientConfig = { + url: string; + anonKey: string; + /** Resolves the Supabase session JWT; null selects the anon key. */ + accessToken: () => Promise; +}; + +/** Builds the SDK's own Supabase client (wallet schema). */ +export function createAgicashDbClient(config: AgicashDbClientConfig): AgicashDb { + return createClient(config.url, config.anonKey, { + accessToken: config.accessToken, + db: { + schema: 'wallet', + }, + }); +} +``` + +If `AgicashDb` in `db/database.ts` is not exactly the return type of this `createClient` call, type the function's return as `AgicashDb` only if it assigns cleanly — otherwise return the inferred type and alias it; check `db/database.ts`'s `AgicashDb` definition first and match it. + +- [ ] **Step 4: Run tests** — token tests pass; `bun run fix:all && bun run typecheck` passes. + +- [ ] **Step 5: Commit** + +```bash +git add packages/wallet-sdk/db +git commit -m "feat(wallet-sdk): add the SDK-internal Supabase client and session token getter" +``` + +--- + +### Task 8: `WriteUserRepository`/`UserService` refactor (A9) + `createUserApi` + +**Files:** +- Modify: `packages/wallet-sdk/domain/user/user-repository.ts:56-60,107-201` +- Modify: `packages/wallet-sdk/domain/user/user-service.ts:49-73` +- Modify: `apps/web-wallet/app/routes/_protected.tsx:124-127` (call-site) +- Modify: `apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx:92-95` (call-site — there are THREE `new WriteUserRepository(...)` sites, not two) +- Modify: `apps/web-wallet/app/features/user/user-repository-hooks.ts` (drop accountRepository arg) +- Create: `packages/wallet-sdk/domain/user/user-api.ts` + +**Interfaces:** +- Consumes: `ReadUserRepository`, `WriteUserRepository`, `UserService` (existing), `NoSessionError` (Task 6), `AgicashDb`, contract types from `../../sdk`. +- Produces: + - `new WriteUserRepository(db)` (constructor loses `accountRepository`); `upsert(user, accountRepository: AccountRepository, options?)` gains it as a param. + - `UserService.setDefaultAccount(user: Pick, account: Pick, options?)` — column-minimal update; no field echo. + - `createUserApi(deps: { db: AgicashDb; getSession: () => AuthSession }): UserApi` — consumed by Task 9. + +- [ ] **Step 1: Refactor `WriteUserRepository`** + +```ts +export class WriteUserRepository { + constructor(private readonly db: AgicashDb) {} +``` + +and change `upsert`'s signature (body unchanged except `this.accountRepository` → `accountRepository`): + +```ts + async upsert( + user: { + // ... existing param docs unchanged ... + }, + accountRepository: AccountRepository, + options?: Options, + ): Promise<{ user: User; accounts: Account[] }> { +``` + +- [ ] **Step 2: Loosen `UserService.setDefaultAccount`'s params and make the update column-minimal** + +```ts + async setDefaultAccount( + user: Pick, + account: Pick, + options: SetDefaultAccountOptions = { + setDefaultCurrency: false, + }, + ): Promise { + if (!['BTC', 'USD'].includes(account.currency)) { + throw new Error('Unsupported currency'); + } + + return this.userRepository.update( + user.id, + { + ...(account.currency === 'BTC' + ? { defaultBtcAccountId: account.id } + : { defaultUsdAccountId: account.id }), + ...(options.setDefaultCurrency + ? { defaultCurrency: account.currency } + : {}), + }, + { abortSignal: options.abortSignal }, + ); + } +``` + +Only the changed columns are written — `WriteUserRepository.update` passes `undefined` for the omitted fields and supabase-js drops them (behavior the `acceptTerms` path already relies on in production), so the untouched defaults can't be clobbered by stale caller state under concurrency. `user` narrows to `Pick` because the echo of unchanged fields is gone — the service no longer needs the rest of the user. (Existing callers pass full `User`/`Account` objects — assignable, no further changes.) + +- [ ] **Step 3: Update the three web call-sites** + +`apps/web-wallet/app/routes/_protected.tsx` (`ensureUserData`): + +```ts + const writeUserRepository = new WriteUserRepository(agicashDbClient); + + const { user: upsertedUser, accounts } = await withRetry({ + fn: () => + writeUserRepository.upsert( + { + id: authUser.id, + email: authUser.email, + emailVerified: authUser.email_verified, + accounts: [...defaultAccounts], + cashuLockingXpub, + encryptionPublicKey, + sparkIdentityPublicKey, + termsAcceptedAt, + giftCardMintTermsAcceptedAt, + }, + accountRepository, + ), +``` + +`apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx:92-95` — the route never calls `upsert`, so the `accountRepository` arg simply goes: + +```ts + const userRepository = new WriteUserRepository(agicashDbClient); +``` + +`apps/web-wallet/app/features/user/user-repository-hooks.ts`: + +```ts +export function useWriteUserRepository() { + return new WriteUserRepository(agicashDbClient); +} +``` + +(and drop the now-unused `useAccountRepository` import; the whole file is deleted in Task 12.) + +- [ ] **Step 4: Create `createUserApi`** + +```ts +// packages/wallet-sdk/domain/user/user-api.ts +import type { Currency } from '@agicash/money'; +import type { AgicashDb } from '../../db/database'; +import { NoSessionError } from '../../lib/error'; +import type { AuthSession, UserApi } from '../../sdk'; +import { ReadUserRepository, WriteUserRepository } from './user-repository'; +import { UserService } from './user-service'; + +type Deps = { + db: AgicashDb; + getSession: () => AuthSession; +}; + +export function createUserApi(deps: Deps): UserApi { + const readRepository = new ReadUserRepository(deps.db); + const writeRepository = new WriteUserRepository(deps.db); + const userService = new UserService(writeRepository); + + const requireUserId = (): string => { + const session = deps.getSession(); + if (!session.isLoggedIn) { + throw new NoSessionError(); + } + return session.user.id; + }; + + const getAccountRef = async ( + accountId: string, + ): Promise<{ id: string; currency: Currency }> => { + const { data, error } = await deps.db + .from('accounts') + .select('id, currency') + .eq('id', accountId) + // RLS already scopes rows to the user; this is defense-in-depth per the + // "userId implicit from session" convention. + .eq('user_id', requireUserId()) + .single(); + if (error) { + throw new Error('Failed to get account', { cause: error }); + } + return data; + }; + + // Methods are async so a missing session surfaces as a rejection, matching + // the Promise-returning contract, not a synchronous throw. + return { + get: async () => readRepository.get(requireUserId()), + updateUsername: async (username) => + writeRepository.update(requireUserId(), { username }), + acceptTerms: async (params) => { + const now = new Date().toISOString(); + return writeRepository.update(requireUserId(), { + termsAcceptedAt: params.walletTerms ? now : undefined, + giftCardMintTermsAcceptedAt: params.giftCardTerms ? now : undefined, + }); + }, + setDefaultCurrency: async (params) => + writeRepository.update(requireUserId(), { + defaultCurrency: params.currency, + }), + setDefaultAccount: async (params) => { + // One read, not two: the account row is fetched to derive the + // per-currency column server-truthfully; the user row isn't needed + // because the update only writes the changed columns. + const account = await getAccountRef(params.accountId); + return userService.setDefaultAccount({ id: requireUserId() }, account, { + setDefaultCurrency: params.setDefaultCurrency, + }); + }, + }; +} +``` + +(`accounts.currency` resolves to the `wallet` schema's currency enum, `'BTC' | 'USD'`, which is exactly `Currency` — `data` assigns cleanly.) + +- [ ] **Step 5: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. + +```bash +git add packages/wallet-sdk/domain/user apps/web-wallet/app/routes/_protected.tsx "apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx" apps/web-wallet/app/features/user/user-repository-hooks.ts +git commit -m "refactor(wallet-sdk): free WriteUserRepository from the accounts graph; add createUserApi" +``` + +--- + +### Task 9: `AgicashSdk` class + +**Files:** +- Create: `packages/wallet-sdk/agicash-sdk.ts` +- Modify: `packages/wallet-sdk/index.ts` (export it) + +**Interfaces:** +- Consumes: everything produced by Tasks 3–8; `configure` + module fns from `@agicash/opensecret`; `clearSparkWallets` from `./lib/spark/wallet`; `clearAgicashMintAuthToken` from `./lib/agicash-mint-auth-provider`. +- Produces: `class AgicashSdk` with `static create(config: SdkConfig): AgicashSdk`, `readonly auth: AuthApi`, `readonly user: UserApi`, `readonly events: WalletEvents`, `init(): Promise`, `dispose(): Promise` — consumed by the web in Task 10. Exported from `@agicash/wallet-sdk`. + +- [ ] **Step 1: Implement** + +```ts +// packages/wallet-sdk/agicash-sdk.ts +import * as openSecret from '@agicash/opensecret'; +import { createAgicashDbClient } from './db/client'; +import { createSupabaseSessionTokenGetter } from './db/supabase-session'; +import { AuthService } from './domain/user/auth-service'; +import { createGuestAccountStorage } from './domain/user/guest-account-storage'; +import { createUserApi } from './domain/user/user-api'; +import { clearAgicashMintAuthToken } from './lib/agicash-mint-auth-provider'; +import { WalletEventEmitter } from './lib/events'; +import { generateRandomPassword } from './lib/password'; +import { clearSparkWallets } from './lib/spark/wallet'; +import type { AuthApi, SdkConfig, UserApi, WalletEvents } from './sdk'; + +/** + * Runtime implementation of the SDK contract, filled namespace-by-namespace + * as the migration slices land (auth/user/events since step 5). It will + * declare `implements Sdk` once every namespace exists. + */ +export class AgicashSdk { + readonly auth: AuthApi; + readonly user: UserApi; + readonly events: WalletEvents; + + private readonly authService: AuthService; + + private constructor(config: SdkConfig) { + // The Open Secret client is module-scoped in @agicash/opensecret, so auth + // configuration is process-global: a second AgicashSdk instance would + // re-configure it. One instance per process until the library ships an + // instance API. + openSecret.configure({ + apiUrl: config.auth.apiUrl, + clientId: config.auth.clientId, + storage: config.auth.storage, + }); + + const events = new WalletEventEmitter(config.logger); + + // Created before authService — the isLoggedIn closure dereferences it + // lazily at request time, after the constructor has assigned it. + const sessionToken = createSupabaseSessionTokenGetter({ + isLoggedIn: () => this.authService.getSession().isLoggedIn, + }); + + this.authService = new AuthService({ + os: openSecret, + storage: config.auth.storage, + guestAccountStorage: createGuestAccountStorage( + config.auth.storage.persistent, + config.logger, + ), + generateGuestPassword: async () => + (await config.auth.generateGuestPassword?.()) ?? + generateRandomPassword(32), + events, + onSessionEnded: () => { + // The token cache must die with the session: a token minted for one + // user must never serve the next login's queries. + sessionToken.reset(); + clearSparkWallets(); + clearAgicashMintAuthToken(); + }, + logger: config.logger, + }); + + const db = createAgicashDbClient({ + url: config.db.url, + anonKey: config.db.anonKey, + accessToken: sessionToken.getToken, + }); + + this.auth = this.authService; + this.user = createUserApi({ + db, + getSession: () => this.authService.getSession(), + }); + this.events = events; + } + + /** Sync; no I/O. */ + static create(config: SdkConfig): AgicashSdk { + return new AgicashSdk(config); + } + + /** + * Session restore only for now — the Breez WASM load folds in when the + * first Spark namespace lands. Resolves when no session exists. Delegates + * to the auth service, which is single-flight and memoizes success but + * clears a rejection, so the host's query retries can recover. + */ + init(): Promise { + return this.authService.restoreSession(); + } + + async dispose(): Promise { + this.authService.teardown(); + } +} +``` + +Check the actual export site of `clearSparkWallets`: it lives in `lib/spark/wallet.ts:150`; import from `'./lib/spark'` if the barrel re-exports it (temporary.ts does `export * from './lib/spark'`), otherwise from `'./lib/spark/wallet'`. + +- [ ] **Step 2: Export from `index.ts`** — add below the `export * from './sdk';` line: + +```ts +export { AgicashSdk } from './agicash-sdk'; +``` + +(The root export is the contract-mandated surface ("Runtime public surface = the `Sdk` class"). `agicash-sdk.ts` has no top-level side effects, so bundles that never touch the class tree-shake it; its spark/supabase graph is already server-import-safe via `/temporary`'s use in `_protected.tsx`.) + +- [ ] **Step 3: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck && cd packages/wallet-sdk && bun test` → PASS. +Then from the repo root: `bun run build` → PASS. (`fix:all` won't catch an SSR-bundle break; this is the first task that changes what the root export pulls into consumers, so exercise the real client + server build now, not only in Task 13.) + +```bash +git add packages/wallet-sdk/agicash-sdk.ts packages/wallet-sdk/index.ts +git commit -m "feat(wallet-sdk): add the AgicashSdk runtime with auth, user, and events namespaces" +``` + +--- + +### Task 10: Web config assembly (`sdk.client.ts`) + entry wiring + +**Files:** +- Create: `apps/web-wallet/app/features/shared/sdk.client.ts` +- Modify: `apps/web-wallet/app/features/agicash-db/database.client.ts` (export url/key consts) +- Modify: `apps/web-wallet/app/entry.client.tsx` (drop `configure`, import `sdk`) + +**Interfaces:** +- Consumes: `AgicashSdk` from `@agicash/wallet-sdk`; `browserStorage` from `@agicash/opensecret`; `breezApiKey` from `~/lib/breez`; the `window.getMockPassword` global (declared in `vite-env.d.ts`, armed only by the Playwright fixture). +- Produces: `export const sdk: AgicashSdk` — the web's singleton, imported by Tasks 11–12. (`.client.ts` suffix keeps it out of the server module graph, like `database.client.ts`.) + +- [ ] **Step 1: Export the resolved Supabase config from `database.client.ts`** — change the two consts to exported: + +```ts +export const supabaseUrl = getSupabaseUrl(); +``` +```ts +export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? ''; +``` + +(only the `const` declarations gain `export`; everything else unchanged). + +- [ ] **Step 2: Create `sdk.client.ts`** + +```ts +// apps/web-wallet/app/features/shared/sdk.client.ts +import { browserStorage } from '@agicash/opensecret'; +import { AgicashSdk } from '@agicash/wallet-sdk'; +import { + supabaseAnonKey, + supabaseUrl, +} from '~/features/agicash-db/database.client'; +import { breezApiKey } from '~/lib/breez'; + +const openSecretApiUrl = import.meta.env.VITE_OPEN_SECRET_API_URL ?? ''; +if (!openSecretApiUrl) { + throw new Error('VITE_OPEN_SECRET_API_URL is not set'); +} + +const openSecretClientId = import.meta.env.VITE_OPEN_SECRET_CLIENT_ID ?? ''; +if (!openSecretClientId) { + throw new Error('VITE_OPEN_SECRET_CLIENT_ID is not set'); +} + +const consoleLogger = { + debug: (message: string, meta?: unknown) => + meta === undefined ? console.debug(message) : console.debug(message, meta), + info: (message: string, meta?: unknown) => + meta === undefined ? console.info(message) : console.info(message, meta), + warn: (message: string, meta?: unknown) => + meta === undefined ? console.warn(message) : console.warn(message, meta), + error: (message: string, meta?: unknown) => + meta === undefined ? console.error(message) : console.error(message, meta), +}; + +export const sdk = AgicashSdk.create({ + db: { + url: supabaseUrl, + anonKey: supabaseAnonKey, + }, + auth: { + apiUrl: openSecretApiUrl, + clientId: openSecretClientId, + storage: browserStorage, + // e2e bridge: the Playwright fixture arms window.getMockPassword; in + // production it's absent, so this resolves null and the SDK generates. + generateGuestPassword: async () => + (await window.getMockPassword?.()) ?? null, + }, + spark: { + breezApiKey, + network: 'MAINNET', + }, + lightningAddressDomain: window.location.host, + logger: consoleLogger, +}); + +if (import.meta.hot) { + // A hot reload of this module constructs a second SDK; dispose the old one + // so its expiry timer doesn't leak. + import.meta.hot.dispose(() => void sdk.dispose()); +} +``` + +(`window.location.host` includes the port, matching what `useLocationData` derives for the settings screen today; nothing consumes the value until the contacts/receive slices.) + +- [ ] **Step 3: Rewire `entry.client.tsx`** — remove the `configure` import and call (added in Task 1), remove the now-unused env reads, and import the sdk module first so Open Secret is configured before any consumer runs: + +```ts +import { + configureFeatureFlags, + ensureBreezWasm, +} from '@agicash/wallet-sdk/temporary'; +// ... existing imports ... +import { agicashDbClient } from './features/agicash-db/database.client'; +// Importing the module constructs the SDK, which configures Open Secret as an +// import-evaluation side effect — before any body code below runs. +import './features/shared/sdk.client'; +``` + +Delete these lines: the `import { browserStorage, configure } from '@agicash/opensecret';`, the `openSecretApiUrl`/`openSecretClientId` env-read blocks, and the `configure({ ... })` call. Everything else (Breez WASM kickoff, feature flags, Sentry) stays. + +- [ ] **Step 4: Verify** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. +Smoke: `bun run dev` → app boots; existing session still logged in; network tab shows Open Secret calls working (attestation/session handshake unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web-wallet/app/features/shared/sdk.client.ts apps/web-wallet/app/features/agicash-db/database.client.ts apps/web-wallet/app/entry.client.tsx +git commit -m "feat(web-wallet): construct the wallet SDK instance and move Open Secret config into it" +``` + +--- + +### Task 11: Web auth glue flip + +**Files:** +- Modify: `apps/web-wallet/app/features/user/auth.ts` (full rewrite below) +- Modify: `apps/web-wallet/app/features/wallet/wallet.tsx` +- Modify: `apps/web-wallet/app/routes/_protected.tsx:30-34` (`AuthUser` import) +- Modify: `apps/web-wallet/app/routes/_auth.oauth.$provider.tsx` +- Modify: `apps/web-wallet/app/features/signup/verify-email.ts` +- Delete: `apps/web-wallet/app/hooks/use-long-timeout.ts` (its only consumer is the old `useHandleSessionExpiry`, which this task removes) +- Delete: `apps/web-wallet/app/lib/password-generator.ts` (its last importer is the old `auth.ts`; the SDK's `lib/password.ts` is now the only generator — A4) + +**Interfaces:** +- Consumes: `sdk` from `~/features/shared/sdk.client`; `AuthSession`, `AuthUser` from `@agicash/wallet-sdk`. +- Produces (same names as today so forms/pages don't change): `authQueryOptions`, `authStateQueryKey`, `invalidateAuthQueries`, `useAuthState`, `useAuthActions` (same verb signatures incl. `signOut(options?: { redirectTo?: string })`), `useSignOut`, `type AuthUser`; NEW `useHandleSessionEvents(onSessionExpired: () => void)` replacing `useHandleSessionExpiry` (subscribes to both `auth.session-expired` and `auth.session-refreshed`). + +- [ ] **Step 1: Rewrite `features/user/auth.ts`** + +```ts +import type { AuthUser } from '@agicash/wallet-sdk'; +import * as Sentry from '@sentry/react-router'; +import { decodeURLSafe, encodeURLSafe } from '@stablelib/base64'; +import { + queryOptions, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import { jwtDecode } from 'jwt-decode'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useRevalidator } from 'react-router'; +import { + loadFeatureFlags, + resetFeatureFlags, +} from '~/features/shared/feature-flags'; +import { getQueryClient } from '~/features/shared/query-client'; +import { sdk } from '~/features/shared/sdk.client'; +import { useLatest } from '~/lib/use-latest'; +import { oauthLoginSessionStorage } from './oauth-login-session-storage'; +import { sessionHintCookie } from './session-hint-cookie'; + +export type { AuthUser }; + +type AuthState = + | { + isLoggedIn: true; + user: AuthUser; + /** Unix seconds, captured at fetch time; drives the hint-cookie lifetime and query staleness. */ + refreshTokenExpiresAt: number | null; + } + | { + isLoggedIn: false; + user?: undefined; + }; + +export const authStateQueryKey = 'auth-state'; + +// A corrupt stored token must degrade to "no value", never throw from a query +// fn or staleTime callback (that would error-page every route, /login included). +const safeJwtDecode = ( + token: string, +): { exp?: number; sub?: string } | null => { + try { + return jwtDecode(token); + } catch { + return null; + } +}; + +const getRefreshTokenExpiry = (): number | null => { + const refreshToken = window.localStorage.getItem('refresh_token'); + if (!refreshToken) { + return null; + } + return safeJwtDecode(refreshToken)?.exp ?? null; +}; + +export const authQueryOptions = () => + queryOptions({ + queryKey: [authStateQueryKey], + queryFn: async (): Promise => { + // Associate Sentry events with the user as early as possible, before + // session restore completes. + const accessToken = window.localStorage.getItem('access_token'); + const sub = accessToken ? safeJwtDecode(accessToken)?.sub : undefined; + if (sub) { + Sentry.setUser({ id: sub }); + } + + try { + await sdk.init(); + } catch (error) { + // Restore failed with tokens present (e.g. a network blip at boot). + // Boot anonymous; init()'s rejection is not memoized, so a later + // invalidateAuthQueries() retries the restore. + console.error('Failed to restore session', { cause: error }); + Sentry.setUser(null); + sessionHintCookie.clear(); + return { isLoggedIn: false }; + } + const session = sdk.auth.getSession(); + + if (!session.isLoggedIn) { + Sentry.setUser(null); + sessionHintCookie.clear(); + return { isLoggedIn: false }; + } + + Sentry.setUser({ id: session.user.id, isGuest: !session.user.email }); + + // Mirror auth state into a hint cookie so the server can short-circuit + // SSR for unauthenticated visits. Lifetime matches the refresh token + // so we don't leave a stale "logged in" hint after the session + // genuinely expires. + const exp = getRefreshTokenExpiry(); + if (exp) { + sessionHintCookie.set(exp - Math.floor(Date.now() / 1000)); + } + + return { ...session, refreshTokenExpiresAt: exp }; + }, + // Logged-in state is fresh until the refresh token expires; a refetch + // after that point re-reads the (SDK-extended or ended) session and + // re-syncs the hint cookie. Anonymous state only changes through explicit + // invalidation. Staleness is pinned to the expiry captured AT FETCH TIME + // (not re-read from storage), so an SDK-internal guest extension can't + // slide freshness forward and postpone the cookie re-sync forever. + staleTime: ({ state: { data, dataUpdatedAt } }) => { + if (!data?.isLoggedIn) { + return Number.POSITIVE_INFINITY; + } + if (!data.refreshTokenExpiresAt) { + return 0; + } + return Math.max( + (data.refreshTokenExpiresAt - 5) * 1000 - dataUpdatedAt, + 0, + ); + }, + }); + +/** + * Invalidates all queries that depend on the current auth session. + * Call after any auth state change (login, logout, email verification, etc.) + */ +export const invalidateAuthQueries = async () => { + const queryClient = getQueryClient(); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [authStateQueryKey], + refetchType: 'all', + }), + loadFeatureFlags(), + ]); +}; + +export const useAuthState = (): AuthState => { + const { data } = useSuspenseQuery(authQueryOptions()); + return data; +}; + +type SignOutOptions = { + /** + * The URL to redirect to after signing out. If not provided, the user will be redirected to the singup page by the protected layout. + */ + redirectTo?: string; +}; + +type AuthActions = { + signUp: (email: string, password: string) => Promise; + signUpGuest: () => Promise; + signIn: (email: string, password: string) => Promise; + signOut: (options?: SignOutOptions) => Promise; + initiateGoogleAuth: () => Promise<{ authUrl: string }>; + verifyEmail: (code: string) => Promise; + convertGuestToFullAccount: (email: string, password: string) => Promise; +}; + +/** + * Authentication actions backed by the wallet SDK, wrapped with the web + * concerns the SDK doesn't own: query invalidation, navigation, Sentry user + * tracking, and the OAuth deep-link session. + */ +export const useAuthActions = (): AuthActions => { + const queryClient = useQueryClient(); + const { revalidate } = useRevalidator(); + const navigate = useNavigate(); + + const refreshSession = useCallback( + async (redirectTo?: string) => { + await invalidateAuthQueries(); + if (redirectTo) { + await navigate(redirectTo); + } else { + await revalidate(); + } + }, + [navigate, revalidate], + ); + + const signUp = useCallback( + async (email: string, password: string) => { + await sdk.auth.signUp(email, password); + await refreshSession(); + }, + [refreshSession], + ); + + const signIn = useCallback( + async (email: string, password: string) => { + await sdk.auth.signIn(email, password); + await refreshSession(); + }, + [refreshSession], + ); + + const signUpGuest = useCallback(async () => { + await sdk.auth.signUpGuest(); + await refreshSession(); + }, [refreshSession]); + + const signOut = useCallback( + async (options: SignOutOptions = {}) => { + await sdk.auth.signOut(); + // Before the refresh below so the previous user's flags are gone even if + // the anon re-fetch fails, and so its result isn't clobbered afterwards. + resetFeatureFlags(); + await refreshSession(options.redirectTo); + Sentry.setUser(null); + queryClient.clear(); + }, + [refreshSession, queryClient], + ); + + const initiateGoogleAuth = useCallback(async () => { + const { authUrl } = await sdk.auth.initiateGoogleAuth(); + + // Stash the current location under a session id and thread it through the + // OAuth state param, so the callback route can restore the deep link. + const authLocation = new URL(authUrl); + const stateParam = authLocation.searchParams.get('state'); + const state = stateParam + ? JSON.parse(new TextDecoder().decode(decodeURLSafe(stateParam))) + : {}; + + const oauthLoginSession = oauthLoginSessionStorage.create({ + search: location.search, + hash: location.hash, + }); + state.sessionId = oauthLoginSession.sessionId; + + const stateEncoded = encodeURLSafe( + new TextEncoder().encode(JSON.stringify(state)), + ); + authLocation.searchParams.set('state', stateEncoded); + + return { authUrl: authLocation.href }; + }, []); + + const verifyEmail = useCallback( + async (code: string) => { + await sdk.auth.verifyEmail(code); + await refreshSession(); + }, + [refreshSession], + ); + + const convertGuestToFullAccount = useCallback( + async (email: string, password: string) => { + await sdk.auth.convertGuestToFullAccount(email, password); + await refreshSession(); + }, + [refreshSession], + ); + + return { + signUp, + signUpGuest, + signIn, + signOut, + initiateGoogleAuth, + verifyEmail, + convertGuestToFullAccount, + }; +}; + +export const useSignOut = () => { + const { signOut } = useAuthActions(); + const [loading, setLoading] = useState(false); + + const handleSignOut = async () => { + setLoading(true); + await signOut({ redirectTo: '/home' }); + setLoading(false); + }; + return { isSigningOut: loading, signOut: handleSignOut }; +}; + +/** + * Reacts to SDK-initiated session transitions the host didn't trigger. + * Expiry (refresh-token death with failed/impossible extension): notifies the + * user and resets the web session state. Refresh (guest auto-extension): + * re-runs the auth query so the session-hint cookie picks up the new expiry, + * matching master's extend-through-invalidation behavior. + */ +export const useHandleSessionEvents = (onSessionExpired: () => void) => { + const queryClient = useQueryClient(); + const { revalidate } = useRevalidator(); + const onSessionExpiredRef = useLatest(onSessionExpired); + + useEffect(() => { + const unsubscribeExpired = sdk.events.on('auth.session-expired', () => { + void (async () => { + onSessionExpiredRef.current(); + resetFeatureFlags(); + await invalidateAuthQueries(); + await revalidate(); + Sentry.setUser(null); + queryClient.clear(); + })(); + }); + const unsubscribeRefreshed = sdk.events.on('auth.session-refreshed', () => { + void invalidateAuthQueries(); + }); + return () => { + unsubscribeExpired(); + unsubscribeRefreshed(); + }; + }, [queryClient, revalidate, onSessionExpiredRef]); +}; +``` + +Deleted vs master: `useHandleSessionExpiry`, the `OpenSecretJwt` helpers (`getJwt`, `removeKeys`, `getRefreshToken`, `getRemainingSessionTimeInMs`), the direct `@agicash/opensecret` + `/temporary` imports, `guestAccountStorage` usage, `generateRandomPassword` usage (now the SDK's concern via the config port). + +- [ ] **Step 2: Update `wallet.tsx`** — replace the `useHandleSessionExpiry` import + call: + +```ts +import { useHandleSessionEvents } from '../user/auth'; +``` +```ts + useHandleSessionEvents(() => { + toast({ + title: 'Session expired', + description: + 'The session has expired. You will be redirected to the login page.', + }); + }); +``` + +(the `isGuestAccount` prop disappears — guests are auto-extended inside the SDK). + +- [ ] **Step 3: Repoint `AuthUser` in `_protected.tsx`** + +```ts +import type { AuthUser } from '@agicash/wallet-sdk'; +import { authQueryOptions, useAuthState } from '~/features/user/auth'; +``` + +- [ ] **Step 4: Flip the OAuth callback route** (`_auth.oauth.$provider.tsx`) — replace the `handleGoogleCallback` import with the sdk and change the call: + +```ts +import { sdk } from '~/features/shared/sdk.client'; +``` +```ts + switch (provider) { + case 'google': + await sdk.auth.completeGoogleAuth({ code, state }); + break; +``` + +- [ ] **Step 5: Flip `features/signup/verify-email.ts`** — replace `import { verifyEmail as osVerifyEmail } from '@agicash/opensecret';` with the sdk import and change the call: + +```ts +import { sdk } from '~/features/shared/sdk.client'; +``` +```ts + await sdk.auth.verifyEmail(code); + await invalidateAuthQueries(); +``` + +- [ ] **Step 6: Delete `apps/web-wallet/app/hooks/use-long-timeout.ts` and `apps/web-wallet/app/lib/password-generator.ts`** — their only consumers were the removed `useHandleSessionExpiry` and the old guest-signup path (the SDK's generator + the `window.getMockPassword` bridge in `sdk.client.ts` replace the latter). Verify: `grep -rn "useLongTimeout\|password-generator" apps/web-wallet/app` returns nothing. + +- [ ] **Step 7: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. + +```bash +git add apps/web-wallet +git commit -m "refactor(web-wallet): route auth flows through sdk.auth" +``` + +--- + +### Task 12: Web user-domain flip + +**Files:** +- Modify: `apps/web-wallet/app/features/user/user-hooks.tsx` +- Delete: `apps/web-wallet/app/features/user/user-repository-hooks.ts` +- Delete: `apps/web-wallet/app/features/user/user-service-hooks.ts` +- Delete: `apps/web-wallet/app/features/user/guest-account-storage.ts` (moved into the SDK in Task 5; `user-hooks.tsx` is its last importer and stops using it in Step 2) +- Modify: `apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx` + +**Interfaces:** +- Consumes: `sdk.user`, `sdk.auth` from `~/features/shared/sdk.client`. +- Produces: unchanged hook names/signatures (`useUser`, `useUserRef`, `useUpgradeGuestToFullAccount`, `useRequestNewEmailVerificationCode`, `useVerifyEmail`, `useSetDefaultCurrency`, `useSetDefaultAccount`, `useUpdateUsername`, `useAcceptTerms`, `UserCache`, `useUserCache`, `useUserChangeHandlers`, `getUserFromCache`, `getUserFromCacheOrThrow`, `defaultAccounts`). + +- [ ] **Step 1: Flip `useUser`** — drop the repository plumbing: + +```ts +const userQueryOptions = ({ + select, +}: { + select?: (data: User) => TData; +}) => ({ + queryKey: [UserCache.Key], + queryFn: () => sdk.user.get(), + select, +}); + +export const useUser = ( + select?: (data: User) => TData, +): TData => { + const authState = useAuthState(); + if (!authState.user) { + throw new Error('Cannot use useUser hook in anonymous context'); + } + + const { data } = useSuspenseQuery(userQueryOptions({ select })); + + return data; +}; +``` + +(imports: add `import { sdk } from '~/features/shared/sdk.client';`, drop `ReadUserRepository`, `useReadUserRepository`, `useWriteUserRepository`, `useUserService`, `requestNewVerificationCode`, `guestAccountStorage`; keep the `ReadUserRepository` import **only** via `@agicash/wallet-sdk/temporary` for `useUserChangeHandlers`'s static `toUser` — that usage stays.) + +- [ ] **Step 2: Flip the mutations** + +```ts +export const useUpgradeGuestToFullAccount = (): (( + email: string, + password: string, +) => Promise) => { + const userRef = useUserRef(); + const { convertGuestToFullAccount } = useAuthActions(); + + const { mutateAsync } = useMutation({ + mutationKey: ['upgrade-guest-to-full-account'], + mutationFn: (variables: { email: string; password: string }) => { + if (!userRef.current.isGuest) { + throw new Error('User already has a full account'); + } + + return convertGuestToFullAccount(variables.email, variables.password); + }, + scope: { + id: 'upgrade-guest-to-full-account', + }, + }); + + return useCallback( + (email: string, password: string) => mutateAsync({ email, password }), + [mutateAsync], + ); +}; +``` + +(the `guestAccountStorage.clear()` follow-up is gone — the SDK clears it inside `convertGuestToFullAccount`). + +```ts +export const useRequestNewEmailVerificationCode = (): (() => Promise) => { + const userRef = useUserRef(); + + const { mutateAsync } = useMutation({ + mutationKey: ['request-new-email-verification-code'], + mutationFn: () => { + if (userRef.current.isGuest) { + throw new Error('Cannot request email verification for guest account'); + } + if (userRef.current.emailVerified) { + throw new Error('Email is already verified'); + } + + return sdk.auth.requestNewVerificationCode(); + }, + scope: { + id: 'request-new-email-verification-code', + }, + }); + + return mutateAsync; +}; +``` + +Replace `useUpdateUser` + the three wrappers with direct sdk calls (the `UpdateUser` type import from `/temporary` goes away): + +```ts +const useUserUpdatingMutation = ( + mutationFn: (variables: TVariables) => Promise, +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn, + onSuccess: (data) => { + queryClient.setQueryData([UserCache.Key], data); + }, + }); +}; + +export const useSetDefaultCurrency = () => { + const { mutateAsync } = useUserUpdatingMutation((currency: Currency) => + sdk.user.setDefaultCurrency({ currency }), + ); + return mutateAsync; +}; + +export const useSetDefaultAccount = () => { + const { mutateAsync } = useUserUpdatingMutation((account: Account) => + sdk.user.setDefaultAccount({ accountId: account.id }), + ); + return mutateAsync; +}; + +export const useUpdateUsername = () => { + const { mutateAsync } = useUserUpdatingMutation((username: string) => + sdk.user.updateUsername(username), + ); + return mutateAsync; +}; + +export const useAcceptTerms = () => { + const { mutateAsync } = useUserUpdatingMutation( + (params: { walletTerms?: boolean; giftCardTerms?: boolean }) => + sdk.user.acceptTerms(params), + ); + return mutateAsync; +}; +``` + +(`useVerifyEmail` is unchanged — it already goes through `useAuthActions`.) + +- [ ] **Step 3: Delete `user-repository-hooks.ts`, `user-service-hooks.ts`, and `guest-account-storage.ts`.** Verify no importers remain: `grep -rn "user-repository-hooks\|user-service-hooks\|user/guest-account-storage" apps/` → empty. + +- [ ] **Step 4: Flip the receive-token route** (`_protected.receive.cashu_.token.tsx`) — in `trySetReceiveAccountAsDefault` (line 117), replace the `userService.setDefaultAccount(user, account, { setDefaultCurrency: true })` call with: + +```ts + const updatedUser = await sdk.user.setDefaultAccount({ + accountId: account.id, + setDefaultCurrency: true, + }); + new UserCache(queryClient).set(updatedUser); +``` + +Concretely, five removals or the file won't compile clean: (1) drop `userService` and the `userRepository` construction feeding it from `getServices()` (construction + return object); (2) remove `userService` from the destructure at ~line 176; (3) remove the `userService: UserService` parameter from `trySetReceiveAccountAsDefault` (line ~117-122); (4) remove the corresponding argument at its call site (~line 197); (5) drop the now-unused `WriteUserRepository` import. Keep the `UserService.isDefaultAccount` static usage (its `/temporary` import stays). Add the `sdk` import. + +- [ ] **Step 5: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. +Confirm the only remaining web importers of user-domain classes from `/temporary` are: `_protected.tsx` (`WriteUserRepository` for `ensureUserData`, A1), `user-hooks.tsx` (`ReadUserRepository.toUser` static), the receive route + any accounts/transactions files (`UserService` statics, `ReadUserDefaultAccountRepository`) — run `grep -rn "UserRepository\|UserService" apps/web-wallet/app --include='*.ts*'` and check the list matches the Deferred section. + +```bash +git add apps/web-wallet +git commit -m "refactor(web-wallet): route user reads and writes through sdk.user" +``` + +--- + +### Task 13: Full verification + +- [ ] **Step 1: Static + unit + production build** + +```bash +export PATH="$PWD/.devenv/profile/bin:$PATH" +bun run fix:all && bun run typecheck +bun run test +bun run build +``` +Expected: all PASS. The build step exercises the real client + server bundles (the dev server alone doesn't), confirming the root `AgicashSdk` export is server-bundle-safe. + +- [ ] **Step 2: Browser smoke (dev server + Chrome MCP or manual)** — `bun run dev`, then walk: + +1. `/home` (marketing, anonymous) → Sign Up → **Create wallet as Guest** → wallet home renders (dev auto-creates Testnut accounts). +2. Reload → still signed in (session restore through `sdk.init()`). +3. Settings → Sign Out → back at signup; localStorage keeps `guestAccount`. +4. **Create wallet as Guest** again → re-signs into the SAME guest account (no new account). +5. Settings → edit username → persists after reload (`sdk.user.updateUsername` + cache). +6. Switch default account/currency in settings → theme flips (USD/BTC), persists. +7. Google login page renders and the Google button redirects to an accounts.google.com URL with a `state` containing `sessionId` (don't complete externally). +8. DevTools: no console errors; `wallet.users` reads go through the SDK client (two Supabase token exchanges are expected — web + SDK clients). + +- [ ] **Step 3: E2E (ask the user before running)** + +```bash +cd apps/web-wallet-e2e && bun run test:e2e -- --grep "signup|login|verify" +``` +(Run from the package dir — arg forwarding through the root script's `bun --filter` isn't guaranteed to reach playwright, and a silently-unfiltered full run is the failure mode.) +Expected: signup.spec, login.spec, verify-email.spec pass unchanged (the RC talks to the same endpoints; the password mock still applies through the config port). + +- [ ] **Step 4: Existing-session upgrade check** — with a session created on `master` (localStorage tokens present), switch to the branch, reload: still logged in. + +- [ ] **Step 5: Commit any fixes; then push and open the PR** + +```bash +git push -u origin sdk/auth-slice +``` + +PR: base `master`, title `feat(wallet-sdk): auth & user slice (step 5)`, description listing: contract placeholders settled, opensecret RC adoption, AgicashSdk runtime, web flips, Decision Record A1–A12, deferred items. + +--- + +## Self-Review Checklist (run after Task 13) + +1. **Spec coverage:** step-5 line items — contract methods wrapped (AuthApi ✓ Task 6/9, UserApi ✓ Task 8/9), web imports flipped (✓ Tasks 11–12 minus documented deferrals), storage-adapter port settled (✓ Task 3), React-agnostic opensecret adopted (✓ Task 1), port shapes settled (✓ Tasks 3–5). +2. **Placeholder scan:** no TBDs; every step has code or exact commands. +3. **Type consistency:** `AuthKeyValueStore`/`AuthStorage` (Task 3) = what `guest-account-storage.ts` (Task 5), `AuthService` (Task 6), and `browserStorage` (Task 10) consume; `OpenSecretAuthApi` fn names = RC exports; `SetDefaultAccountParams.setDefaultCurrency` used in Task 12 Step 4. +4. **Parity scan:** every master behavior either preserved or listed under "Accepted behavior deltas". diff --git a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md index 9e020ceff..19af95815 100644 --- a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +++ b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md @@ -44,7 +44,10 @@ type SdkConfig = { storageDir?: string; // node hosts; browser default applies }; lightningAddressDomain: string; // lud16 domain for contacts/display - logger?: Logger; // diagnostic sink; MCP stdio hosts route to stderr + logger: Logger; // diagnostic sink; MCP stdio hosts route to stderr. + // Required; hosts that want no logging pass the + // exported `nullLogger` (explicit choice over a + // silently-absent default). }; // Illustrative shape — binds to the React-agnostic @agicash/opensecret release's @@ -332,6 +335,7 @@ only an id — the asymmetry is intentional, not an oversight. ```ts type WalletEventMap = { 'auth.session-expired': Record; // session died without signOut() (expiry / failed refresh) + 'auth.session-refreshed': Record; // SDK-initiated refresh (guest auto-extend); host verbs never fire it — added by the auth slice (step-5 plan, A13) 'user.updated': { user: User }; 'account.created' | 'account.updated': { account: Account }; 'account.balance-changed': { accountId: string; balance: Money }; // both rails; no version @@ -459,6 +463,8 @@ helpers the web consumes that need no instance state: WebAssembly is unavailable; web `instanceof`-checks it for the fallback UI). Subclass semantics are contract: `DomainError.message` is the only user-displayable message; `ConcurrencyError` always means retry. +- logging: `nullLogger` — the no-op `Logger` hosts pass when they want no + diagnostics (the `logger` config port is required) - exchange rate: `exchangeRate` — provider fallback chain (mempool → coingecko → coinbase); holds no instance state or ports, so a rate lookup needs no `Sdk`. diff --git a/package.json b/package.json index 20b311b4c..aa1f87aac 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "workspaces": { "packages": ["apps/*", "packages/*"], "catalog": { - "@agicash/opensecret": "0.1.0", + "@agicash/opensecret": "1.0.0-rc.0", "@cashu/cashu-ts": "3.6.1", "@noble/ciphers": "1.3.0", "@noble/curves": "1.9.7", @@ -17,6 +17,7 @@ "@types/bun": "1.3.11", "big.js": "7.0.1", "dotenv": "16.4.7", + "jwt-decode": "4.0.0", "jwt-encode": "1.0.1", "ky": "1.14.3", "type-fest": "5.4.3", diff --git a/packages/utils/package.json b/packages/utils/package.json index 14b06b138..b75e69879 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -12,6 +12,7 @@ "dependencies": { "@noble/ciphers": "catalog:", "@noble/hashes": "catalog:", + "jwt-decode": "catalog:", "type-fest": "catalog:", "zod": "catalog:" }, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 5fae18a36..73992e90b 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,6 +1,8 @@ export * from './collections'; export * from './json'; +export * from './jwt'; export * from './zod'; export * from './type-utils'; export * from './sha256'; +export * from './timeout'; export * from './xchacha20poly1305'; diff --git a/packages/utils/src/jwt.ts b/packages/utils/src/jwt.ts new file mode 100644 index 000000000..ecbdf1f59 --- /dev/null +++ b/packages/utils/src/jwt.ts @@ -0,0 +1,15 @@ +import { type JwtPayload, jwtDecode } from 'jwt-decode'; + +/** + * Decodes a JWT's payload, returning null for an undecodable token instead of + * throwing. For tokens read from storage, which may be corrupt. A token just + * minted by a server should be decoded with `jwtDecode` directly, so a + * malformed one fails its operation loudly instead of passing as absent. + */ +export const safeJwtDecode = (token: string): JwtPayload | null => { + try { + return jwtDecode(token); + } catch { + return null; + } +}; diff --git a/apps/web-wallet/app/lib/timeout.ts b/packages/utils/src/timeout.ts similarity index 100% rename from apps/web-wallet/app/lib/timeout.ts rename to packages/utils/src/timeout.ts diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index 0814fa583..efe3145de 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -4,7 +4,7 @@ "exclude": ["node_modules"], "compilerOptions": { "lib": ["ES2022"], - "types": [], + "types": ["bun"], "noEmit": true } } diff --git a/packages/wallet-sdk/agicash-sdk.test.ts b/packages/wallet-sdk/agicash-sdk.test.ts new file mode 100644 index 000000000..c30a12ae1 --- /dev/null +++ b/packages/wallet-sdk/agicash-sdk.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'bun:test'; +import { AgicashSdk } from './agicash-sdk'; +import { nullLogger } from './lib/logger'; +import type { AuthKeyValueStore, SdkConfig } from './sdk'; + +const createMemoryStore = (): AuthKeyValueStore => { + const data = new Map(); + return { + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +const createConfig = (): SdkConfig => ({ + db: { url: 'http://localhost:54321', anonKey: 'anon-key' }, + auth: { + apiUrl: 'http://localhost:3100', + clientId: '00000000-0000-0000-0000-000000000000', + storage: { persistent: createMemoryStore(), session: createMemoryStore() }, + }, + spark: { breezApiKey: 'key', network: 'MAINNET' }, + lightningAddressDomain: 'localhost', + logger: nullLogger, +}); + +describe('AgicashSdk.create', () => { + it('refuses a second instance until the first is disposed', async () => { + const sdk = AgicashSdk.create(createConfig()); + try { + expect(() => AgicashSdk.create(createConfig())).toThrow( + /dispose\(\) the previous instance/, + ); + } finally { + await sdk.dispose(); + } + + const next = AgicashSdk.create(createConfig()); + await next.dispose(); + }); +}); diff --git a/packages/wallet-sdk/agicash-sdk.ts b/packages/wallet-sdk/agicash-sdk.ts new file mode 100644 index 000000000..54ec1781f --- /dev/null +++ b/packages/wallet-sdk/agicash-sdk.ts @@ -0,0 +1,113 @@ +import * as openSecret from '@agicash/opensecret'; +import { createAgicashDbClient } from './db/client'; +import { createSupabaseSessionTokenGetter } from './db/supabase-session'; +import { AuthService } from './domain/user/auth-service'; +import { createGuestAccountStorage } from './domain/user/guest-account-storage'; +import { createUserApi } from './domain/user/user-api'; +import { clearAgicashMintAuthToken } from './lib/agicash-mint-auth-provider'; +import { WalletEventEmitter } from './lib/events'; +import { generateRandomPassword } from './lib/password'; +import { clearSparkWallets } from './lib/spark/wallet'; +import type { AuthApi, Sdk, SdkConfig, UserApi, WalletEvents } from './sdk'; + +// Makes the one-instance-per-process constraint (see the constructor note) +// self-enforcing: create() refuses to run while an undisposed instance holds +// the module-global Open Secret configuration. +let liveInstance: AgicashSdk | undefined; + +/** + * Runtime implementation of the SDK contract, filled namespace-by-namespace + * as the migration slices land (auth/user/events since step 5). Each slice + * adds its namespace to the `Pick` until it collapses to the full `Sdk`. + */ +export class AgicashSdk + implements Pick +{ + readonly auth: AuthApi; + readonly user: UserApi; + readonly events: WalletEvents; + + private readonly authService: AuthService; + + private constructor(config: SdkConfig) { + // The Open Secret client is module-scoped in @agicash/opensecret, so auth + // configuration is process-global: a second AgicashSdk instance would + // re-configure it. One instance per process until the library ships an + // instance API. + openSecret.configure({ + apiUrl: config.auth.apiUrl, + clientId: config.auth.clientId, + storage: config.auth.storage, + }); + + const events = new WalletEventEmitter(config.logger); + + // Created before authService — the isLoggedIn closure dereferences it + // lazily at request time, after the constructor has assigned it. + const sessionToken = createSupabaseSessionTokenGetter({ + isLoggedIn: () => this.authService.getSession().isLoggedIn, + }); + + this.authService = new AuthService({ + os: openSecret, + storage: config.auth.storage, + guestAccountStorage: createGuestAccountStorage( + config.auth.storage.persistent, + config.logger, + ), + generateGuestPassword: async () => + (await config.auth.generateGuestPassword?.()) ?? + generateRandomPassword(32), + events, + onSessionEnded: () => { + // The token cache must die with the session: a token minted for one + // user must never serve the next login's queries. + sessionToken.reset(); + clearSparkWallets(); + clearAgicashMintAuthToken(); + }, + logger: config.logger, + }); + + const db = createAgicashDbClient({ + url: config.db.url, + anonKey: config.db.anonKey, + accessToken: sessionToken.getToken, + }); + + this.auth = this.authService; + this.user = createUserApi({ + db, + getSession: () => this.authService.getSession(), + }); + this.events = events; + } + + /** Sync; no I/O. Throws when an undisposed instance already exists (see the constructor note). */ + static create(config: SdkConfig): AgicashSdk { + if (liveInstance) { + throw new Error( + 'An AgicashSdk instance already exists in this process. @agicash/opensecret holds module-global auth state, so dispose() the previous instance before creating another.', + ); + } + liveInstance = new AgicashSdk(config); + return liveInstance; + } + + /** + * Session restore only for now — the Breez WASM load folds in when the + * first Spark slice lands. Resolves when no session exists. Delegates + * to the auth service, which is single-flight and memoizes success but + * clears a rejection, so the host's query retries can recover. + */ + init(): Promise { + return this.authService.restoreSession(); + } + + async dispose(): Promise { + this.authService.teardown(); + if (liveInstance === this) { + liveInstance = undefined; + } + } +} diff --git a/packages/wallet-sdk/db/client.ts b/packages/wallet-sdk/db/client.ts new file mode 100644 index 000000000..32e9f750a --- /dev/null +++ b/packages/wallet-sdk/db/client.ts @@ -0,0 +1,21 @@ +import { createClient } from '@supabase/supabase-js'; +import type { AgicashDb, Database } from './database'; + +type AgicashDbClientConfig = { + url: string; + anonKey: string; + /** Resolves the Supabase session JWT; null selects the anon key. */ + accessToken: () => Promise; +}; + +/** Builds the SDK's own Supabase client (wallet schema). */ +export function createAgicashDbClient( + config: AgicashDbClientConfig, +): AgicashDb { + return createClient(config.url, config.anonKey, { + accessToken: config.accessToken, + db: { + schema: 'wallet', + }, + }); +} diff --git a/packages/wallet-sdk/db/supabase-session.test.ts b/packages/wallet-sdk/db/supabase-session.test.ts new file mode 100644 index 000000000..1d7eddbe1 --- /dev/null +++ b/packages/wallet-sdk/db/supabase-session.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'bun:test'; +import { createSupabaseSessionTokenGetter } from './supabase-session'; + +const toBase64Url = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + +const createToken = (expSecondsFromNow: number) => + `${toBase64Url({ alg: 'none' })}.${toBase64Url({ + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + })}.sig`; + +describe('createSupabaseSessionTokenGetter', () => { + it('returns null and skips token generation when logged out', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => false, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + expect(await getToken()).toBeNull(); + expect(generated).toBe(0); + }); + + it('memoizes the token until close to expiry', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + const first = await getToken(); + const second = await getToken(); + + expect(first).toBe(second as string); + expect(generated).toBe(1); + }); + + it('re-generates once the cached token is within 5s of expiry', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + // expires in 3s → refreshAt is already in the past + return { token: createToken(3) }; + }, + }); + + await getToken(); + await getToken(); + + expect(generated).toBe(2); + }); + + it('shares one in-flight request between concurrent callers', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + await new Promise((resolve) => setTimeout(resolve, 5)); + return { token: createToken(3600) }; + }, + }); + + await Promise.all([getToken(), getToken(), getToken()]); + + expect(generated).toBe(1); + }); + + it('drops the cache when the session ends', async () => { + let loggedIn = true; + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => loggedIn, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + await getToken(); + loggedIn = false; + expect(await getToken()).toBeNull(); + loggedIn = true; + await getToken(); + + expect(generated).toBe(2); + }); + + it('reset drops the cached token so the next session cannot reuse it', async () => { + let generated = 0; + const source = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + await source.getToken(); + source.reset(); + await source.getToken(); + + expect(generated).toBe(2); + }); + + it('does not cache a token that resolves after reset', async () => { + let generated = 0; + let release: (() => void) | undefined; + const source = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + if (generated === 1) { + await new Promise((resolve) => { + release = resolve; + }); + } + return { token: createToken(3600) }; + }, + }); + + const firstCall = source.getToken(); + // session ends while the first exchange is still in flight + source.reset(); + release?.(); + await firstCall; + + await source.getToken(); + + // the stale in-flight token was not cached; the new session exchanged fresh + expect(generated).toBe(2); + }); +}); diff --git a/packages/wallet-sdk/db/supabase-session.ts b/packages/wallet-sdk/db/supabase-session.ts new file mode 100644 index 000000000..f9ac33887 --- /dev/null +++ b/packages/wallet-sdk/db/supabase-session.ts @@ -0,0 +1,76 @@ +import { generateThirdPartyToken } from '@agicash/opensecret'; +import { jwtDecode } from 'jwt-decode'; + +type Deps = { + isLoggedIn: () => boolean; + /** Test seam; defaults to Open Secret's generateThirdPartyToken. */ + generateToken?: () => Promise<{ token: string }>; +}; + +export type SupabaseSessionTokenSource = { + /** Supabase `accessToken` callback; null selects the anon key. */ + getToken: () => Promise; + /** + * Drops the cached token. Must be called when the session ends — the cache + * is otherwise only re-validated by expiry, and a token minted for one user + * must never survive into another user's session. + */ + reset: () => void; +}; + +/** + * Builds the Supabase `accessToken` source: exchanges the Open Secret JWT + * for a Supabase third-party token and memoizes it until 5 seconds before its + * expiry. Concurrent callers share one in-flight exchange. Returns null when + * no session exists (the client then uses the anon key). + */ +export function createSupabaseSessionTokenGetter( + deps: Deps, +): SupabaseSessionTokenSource { + const generateToken = deps.generateToken ?? (() => generateThirdPartyToken()); + let cached: { token: string; refreshAtMs: number } | undefined; + let inFlight: Promise | undefined; + // Incremented on invalidation; an exchange started under an older + // generation must not populate the cache — its token belongs to the ended + // session. + let generation = 0; + + const invalidate = () => { + generation += 1; + cached = undefined; + inFlight = undefined; + }; + + return { + reset: invalidate, + getToken: async () => { + if (!deps.isLoggedIn()) { + // Same full invalidation as reset(): an exchange in flight when the + // session ended must not populate the cache either. + invalidate(); + return null; + } + if (cached && Date.now() < cached.refreshAtMs) { + return cached.token; + } + if (!inFlight) { + const startedGeneration = generation; + inFlight = (async () => { + try { + const { token } = await generateToken(); + if (generation === startedGeneration) { + const { exp } = jwtDecode(token); + cached = { token, refreshAtMs: exp ? (exp - 5) * 1000 : 0 }; + } + return token; + } finally { + if (generation === startedGeneration) { + inFlight = undefined; + } + } + })(); + } + return inFlight; + }, + }; +} diff --git a/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts b/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts index e9c3d0b6e..7b2ebad10 100644 --- a/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts +++ b/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts @@ -138,22 +138,35 @@ const CashuReceiveQuoteFailedStateSchema = z.object({ failureReason: z.string(), }); +const CashuReceiveQuoteTypeSchema = z.union([ + CashuReceiveQuoteLightningTypeSchema, + CashuReceiveQuoteCashuTokenTypeSchema, +]); + +const CashuReceiveQuoteStateSchema = z.union([ + CashuReceiveQuoteUnpaidExpiredStateSchema, + CashuReceiveQuotePaidCompletedStateSchema, + CashuReceiveQuoteFailedStateSchema, +]); + /** * Schema for cashu receive quote. */ export const CashuReceiveQuoteSchema = z.intersection( CashuReceiveQuoteBaseSchema, - z.intersection( - z.union([ - CashuReceiveQuoteLightningTypeSchema, - CashuReceiveQuoteCashuTokenTypeSchema, - ]), - z.union([ - CashuReceiveQuoteUnpaidExpiredStateSchema, - CashuReceiveQuotePaidCompletedStateSchema, - CashuReceiveQuoteFailedStateSchema, - ]), - ), + z.intersection(CashuReceiveQuoteTypeSchema, CashuReceiveQuoteStateSchema), ); export type CashuReceiveQuote = z.infer; + +/** + * The variant unions on their own, for projections that rebuild the + * intersection — a bare `Omit` over the full type collapses each union to its + * shared keys. + */ +export type CashuReceiveQuoteTypeVariant = z.infer< + typeof CashuReceiveQuoteTypeSchema +>; +export type CashuReceiveQuoteStateVariant = z.infer< + typeof CashuReceiveQuoteStateSchema +>; diff --git a/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts b/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts index c2ac3e3dc..abc59b9c2 100644 --- a/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts +++ b/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts @@ -102,16 +102,27 @@ const CashuReceiveSwapFailedStateSchema = z.object({ failureReason: z.string(), }); +const CashuReceiveSwapStateSchema = z.union([ + CashuReceiveSwapPendingStateSchema, + CashuReceiveSwapCompletedStateSchema, + CashuReceiveSwapFailedStateSchema, +]); + /** * Schema for cashu receive swap. */ export const CashuReceiveSwapSchema = z.intersection( CashuReceiveSwapBaseSchema, - z.union([ - CashuReceiveSwapPendingStateSchema, - CashuReceiveSwapCompletedStateSchema, - CashuReceiveSwapFailedStateSchema, - ]), + CashuReceiveSwapStateSchema, ); export type CashuReceiveSwap = z.infer; + +/** + * The state variant union on its own, for projections that rebuild the + * intersection — a bare `Omit` over the full type collapses the union to its + * shared keys. + */ +export type CashuReceiveSwapStateVariant = z.infer< + typeof CashuReceiveSwapStateSchema +>; diff --git a/packages/wallet-sdk/domain/receive/spark-receive-quote.ts b/packages/wallet-sdk/domain/receive/spark-receive-quote.ts index f3c9efe36..ac3a6ab7c 100644 --- a/packages/wallet-sdk/domain/receive/spark-receive-quote.ts +++ b/packages/wallet-sdk/domain/receive/spark-receive-quote.ts @@ -125,22 +125,35 @@ const SparkReceiveQuoteFailedStateSchema = z.object({ failureReason: z.string(), }); +const SparkReceiveQuoteTypeSchema = z.union([ + SparkReceiveQuoteLightningTypeSchema, + SparkReceiveQuoteCashuTokenTypeSchema, +]); + +const SparkReceiveQuoteStateSchema = z.union([ + SparkReceiveQuoteUnpaidExpiredStateSchema, + SparkReceiveQuotePaidStateSchema, + SparkReceiveQuoteFailedStateSchema, +]); + /** * Schema for Spark receive quote. */ export const SparkReceiveQuoteSchema = z.intersection( SparkReceiveQuoteBaseSchema, - z.intersection( - z.union([ - SparkReceiveQuoteLightningTypeSchema, - SparkReceiveQuoteCashuTokenTypeSchema, - ]), - z.union([ - SparkReceiveQuoteUnpaidExpiredStateSchema, - SparkReceiveQuotePaidStateSchema, - SparkReceiveQuoteFailedStateSchema, - ]), - ), + z.intersection(SparkReceiveQuoteTypeSchema, SparkReceiveQuoteStateSchema), ); export type SparkReceiveQuote = z.infer; + +/** + * The variant unions on their own, for projections that rebuild the + * intersection — a bare `Omit` over the full type collapses each union to its + * shared keys. + */ +export type SparkReceiveQuoteTypeVariant = z.infer< + typeof SparkReceiveQuoteTypeSchema +>; +export type SparkReceiveQuoteStateVariant = z.infer< + typeof SparkReceiveQuoteStateSchema +>; diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts new file mode 100644 index 000000000..51d832a11 --- /dev/null +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -0,0 +1,676 @@ +import { describe, expect, it } from 'bun:test'; +import { WalletEventEmitter } from '../../lib/events'; +import { nullLogger } from '../../lib/logger'; +import type { AuthKeyValueStore, AuthStorage } from '../../sdk'; +import { AuthService, type OpenSecretAuthApi } from './auth-service'; +import { createGuestAccountStorage } from './guest-account-storage'; + +const createMemoryStore = (): AuthKeyValueStore & { + data: Map; +} => { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +const createStorage = (): AuthStorage & { + persistent: ReturnType; +} => ({ + persistent: createMemoryStore(), + session: createMemoryStore(), +}); + +const toBase64Url = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + +const createJwt = (expSecondsFromNow: number, sub = 'user-1') => + `${toBase64Url({ alg: 'none' })}.${toBase64Url({ + sub, + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + })}.sig`; + +const fullUser = { + id: 'user-1', + name: null, + email: 'a@b.c', + email_verified: true, + login_method: 'email', + created_at: '2026-01-01', + updated_at: '2026-01-01', +}; + +const guestUser = { ...fullUser, email: undefined }; + +const createOsFake = ( + tokenStore: ReturnType, + overrides: Partial = {}, +) => { + const calls: string[] = []; + // The real Open Secret SDK persists fresh tokens on every login path; the + // fakes mirror that so a timer re-arm after login reads a live refresh token. + const login = (id: string) => { + tokenStore.data.set('access_token', createJwt(600, id)); + tokenStore.data.set('refresh_token', createJwt(3600, id)); + return { id, access_token: 'a', refresh_token: 'r' }; + }; + const os: OpenSecretAuthApi = { + fetchUser: async () => ({ user: fullUser }), + signIn: async () => login('user-1'), + signUp: async () => login('user-1'), + signUpGuest: async () => login('guest-1'), + signInGuest: async () => login('guest-1'), + // The real SDK swallows the network logout failure and clears its token + // keys unconditionally. + signOut: async () => { + tokenStore.data.delete('access_token'); + tokenStore.data.delete('refresh_token'); + }, + verifyEmail: async () => undefined, + requestNewVerificationCode: async () => undefined, + convertGuestToUserAccount: async () => undefined, + initiateGoogleAuth: async () => ({ + auth_url: 'https://accounts.google/x', + csrf_token: 'c', + }), + handleGoogleCallback: async () => login('user-1'), + ...overrides, + }; + // wrap every fn to record invocation order + for (const key of Object.keys(os) as (keyof OpenSecretAuthApi)[]) { + const original = os[key] as (...args: unknown[]) => unknown; + // biome-ignore lint/suspicious/noExplicitAny: test instrumentation + (os as any)[key] = (...args: unknown[]) => { + calls.push(key); + return original(...args); + }; + } + return { os, calls }; +}; + +const createService = ( + options: { + os?: Partial; + storage?: ReturnType; + onSessionEnded?: () => void; + } = {}, +) => { + const storage = options.storage ?? createStorage(); + const { os, calls } = createOsFake(storage.persistent, options.os); + const events = new WalletEventEmitter(nullLogger); + const service = new AuthService({ + os, + storage, + guestAccountStorage: createGuestAccountStorage( + storage.persistent, + nullLogger, + ), + generateGuestPassword: async () => 'generated-pw', + events, + onSessionEnded: options.onSessionEnded, + logger: nullLogger, + }); + return { service, storage, calls, events }; +}; + +describe('AuthService', () => { + it('starts anonymous', () => { + const { service } = createService(); + expect(service.getSession()).toEqual({ isLoggedIn: false }); + }); + + describe('restoreSession', () => { + it('stays anonymous without stored tokens and does not call fetchUser', async () => { + const { service, calls } = createService(); + await service.restoreSession(); + expect(service.getSession().isLoggedIn).toBe(false); + expect(calls).not.toContain('fetchUser'); + }); + + it('restores a session from stored tokens', async () => { + const { service, storage } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await service.restoreSession(); + + expect(service.getSession()).toEqual({ + isLoggedIn: true, + user: fullUser, + }); + service.teardown(); + }); + + it('rejects when tokens exist but the user fetch fails, then recovers on retry', async () => { + let sessionEnded = false; + let failFetch = true; + const { service, storage } = createService({ + os: { + fetchUser: async () => { + if (failFetch) { + throw new Error('network'); + } + return { user: fullUser }; + }, + }, + onSessionEnded: () => { + sessionEnded = true; + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await expect(service.restoreSession()).rejects.toThrow('network'); + // per-session caches were torn down with the failed restore + expect(sessionEnded).toBe(true); + expect(service.getSession().isLoggedIn).toBe(false); + + // the rejection is not memoized — a retry can succeed + failFetch = false; + await service.restoreSession(); + + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('stays anonymous when the stored refresh token is undecodable', async () => { + const { service, storage, calls } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', 'not-a-jwt'); + + await service.restoreSession(); + + expect(service.getSession().isLoggedIn).toBe(false); + expect(calls).not.toContain('fetchUser'); + }); + + it('does not clobber a session a verb established while the restore fetch was in flight', async () => { + let releaseRestoreFetch = (): void => undefined; + const restoreFetchGate = new Promise((resolve) => { + releaseRestoreFetch = resolve; + }); + const verbUser = { ...fullUser, id: 'user-verb' }; + let fetchCalls = 0; + const { service, storage } = createService({ + os: { + fetchUser: async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + await restoreFetchGate; + return { user: fullUser }; + } + return { user: verbUser }; + }, + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + const restore = service.restoreSession(); + // Flush microtasks so the restore's gated fetchUser is in flight first. + await new Promise((resolve) => setTimeout(resolve, 0)); + await service.signIn('verb@b.c', 'pw'); + releaseRestoreFetch(); + await restore; + + expect(service.getSession()).toEqual({ + isLoggedIn: true, + user: verbUser, + }); + service.teardown(); + }); + + it('does not repeat the user fetch when a verb already established the session', async () => { + const { service, calls } = createService(); + + await service.signIn('a@b.c', 'pw'); + await service.restoreSession(); + + expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); + service.teardown(); + }); + + it('does not fence out a newer restore when an older one fails late', async () => { + let fetchCalls = 0; + let releaseFirstFetch = (): void => undefined; + const firstFetchGate = new Promise((resolve) => { + releaseFirstFetch = resolve; + }); + let releaseThirdFetch = (): void => undefined; + const thirdFetchGate = new Promise((resolve) => { + releaseThirdFetch = resolve; + }); + const { service, storage } = createService({ + os: { + fetchUser: async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + // restore A: fails only after restore B is in flight + await firstFetchGate; + throw new Error('slow network failure'); + } + if (fetchCalls === 2) { + // the verb's snapshot refresh: ends the session, un-memoizes A + throw new Error('verb fetch failed'); + } + // restore B + await thirdFetchGate; + return { user: fullUser }; + }, + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + const restoreA = service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await service.signIn('a@b.c', 'pw'); + const restoreB = service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 0)); + releaseFirstFetch(); + await expect(restoreA).rejects.toThrow('slow network failure'); + releaseThirdFetch(); + await restoreB; + + // A's late failure neither bumped the generation (which would fence + // B's apply out) nor clobbered B's memo + expect(service.getSession().isLoggedIn).toBe(true); + await service.restoreSession(); + expect(fetchCalls).toBe(3); + service.teardown(); + }); + + it('is single-flight', async () => { + const { service, storage, calls } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await Promise.all([service.restoreSession(), service.restoreSession()]); + + expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); + service.teardown(); + }); + + it('does not re-arm the expiry timer when the restore resolves after teardown', async () => { + let releaseRestoreFetch = (): void => undefined; + const restoreFetchGate = new Promise((resolve) => { + releaseRestoreFetch = resolve; + }); + const { service, storage, events } = createService({ + os: { + fetchUser: async () => { + await restoreFetchGate; + return { user: fullUser }; + }, + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + // Refresh token already inside the exp-5s window: a timer armed by the + // late-resolving restore would fire immediately and expire the session. + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + const restore = service.restoreSession(); + // Flush microtasks so the gated fetchUser is in flight before teardown. + await new Promise((resolve) => setTimeout(resolve, 0)); + service.teardown(); + releaseRestoreFetch(); + await restore; + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(expired).toHaveLength(0); + // The stale apply still lands (teardown is not logout); only the + // expiry machinery stays off. + expect(service.getSession().isLoggedIn).toBe(true); + }); + }); + + describe('signUpGuest', () => { + it('creates a new guest account and stores the credentials', async () => { + const { service, storage, calls } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + + await service.signUpGuest(); + + expect(calls).toContain('signUpGuest'); + expect( + JSON.parse(storage.persistent.data.get('guestAccount') ?? ''), + ).toEqual({ + id: 'guest-1', + password: 'generated-pw', + }); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('undoes the guest sign-up when persisting the credentials fails', async () => { + const storage = createStorage(); + const write = storage.persistent.setItem; + storage.persistent.setItem = (key, value) => { + if (key === 'guestAccount') { + throw new Error('quota exceeded'); + } + write(key, value); + }; + const { service, calls } = createService({ + storage, + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + + await expect(service.signUpGuest()).rejects.toThrow('quota exceeded'); + + // no live session on unpersistable credentials — it would strand the + // account (and any funds) at its first expiry + expect(calls).toContain('signOut'); + expect(service.getSession().isLoggedIn).toBe(false); + expect(storage.persistent.data.has('refresh_token')).toBe(false); + }); + + it('re-signs-in the stored guest account instead of creating a new one', async () => { + const { service, storage, calls } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'stored-pw' }), + ); + + await service.signUpGuest(); + + expect(calls).toContain('signInGuest'); + expect(calls).not.toContain('signUpGuest'); + service.teardown(); + }); + }); + + describe('signOut', () => { + it('clears the session, keeps guest credentials, and runs onSessionEnded', async () => { + let sessionEnded = false; + const { service, storage } = createService({ + onSessionEnded: () => { + sessionEnded = true; + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + await service.restoreSession(); + + await service.signOut(); + + expect(service.getSession().isLoggedIn).toBe(false); + expect(sessionEnded).toBe(true); + expect(storage.persistent.data.has('guestAccount')).toBe(true); + }); + + it('wipes per-session caches again when a different user signs in after sign-out', async () => { + let sessionEndedCount = 0; + const userIds = ['user-a', 'user-b']; + let fetchCalls = 0; + const { service } = createService({ + os: { + fetchUser: async () => ({ + user: { ...fullUser, id: userIds[Math.min(fetchCalls++, 1)] }, + }), + }, + onSessionEnded: () => { + sessionEndedCount += 1; + }, + }); + + await service.signIn('a@b.c', 'pw'); + await service.signOut(); // ends user-a's session → 1 + await service.signIn('b@b.c', 'pw'); // different user → wiped again → 2 + + expect(sessionEndedCount).toBe(2); + service.teardown(); + }); + }); + + describe('convertGuestToFullAccount', () => { + it('clears the stored guest credentials', async () => { + const { service, storage } = createService(); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await service.convertGuestToFullAccount('a@b.c', 'pw2'); + + expect(storage.persistent.data.has('guestAccount')).toBe(false); + service.teardown(); + }); + }); + + describe('session expiry', () => { + it('emits auth.session-expired and ends the session for a full account', async () => { + let sessionEnded = false; + const { service, storage, events } = createService({ + onSessionEnded: () => { + sessionEnded = true; + }, + }); + // refresh token expiring "now" (exp-5s already past) → timer fires immediately + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // the 1ms-floored timer fires on the next macrotask + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(expired).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(false); + expect(sessionEnded).toBe(true); + }); + + it('auto-extends a guest session instead of expiring it', async () => { + const { service, storage, calls, events } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + const refreshed: unknown[] = []; + events.on('auth.session-refreshed', (payload) => refreshed.push(payload)); + + await service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(calls).toContain('signInGuest'); + expect(expired).toHaveLength(0); + // the host is told about the refresh it didn't initiate + expect(refreshed).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('re-arms instead of expiring when the stored refresh token was rotated forward', async () => { + const { service, storage, events } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + // (exp - 5s) is ~100ms away, so the timer fires shortly after restore + storage.persistent.data.set('refresh_token', createJwt(5.1)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // the Open Secret SDK rotates the refresh token during its internal + // refresh flow; simulate a rotation landing before the timer fires + storage.persistent.data.set('refresh_token', createJwt(3600)); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(expired).toHaveLength(0); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + // Parked (it.todo) as the spec for the planned command-lane + // serialization of session transitions: the unfenced handler inherited + // from master fails this — see the KNOWN ISSUE note on + // handleSessionExpiry in auth-service.ts (PR #1166 review, finding 1). + it.todo( + 'does not resurrect a session that was signed out while a guest extension was in flight', + async () => { + let releaseSignInGuest = (): void => undefined; + const signInGuestGate = new Promise((resolve) => { + releaseSignInGuest = resolve; + }); + const storage = createStorage(); + const { service, calls, events } = createService({ + storage, + os: { + fetchUser: async () => ({ user: guestUser }), + signInGuest: async () => { + // The extension's fresh tokens land only once the gate opens — + // i.e. after the concurrent sign-out already cleared storage. + await signInGuestGate; + storage.persistent.data.set( + 'access_token', + createJwt(600, 'guest-1'), + ); + storage.persistent.data.set( + 'refresh_token', + createJwt(3600, 'guest-1'), + ); + return { id: 'guest-1', access_token: 'a', refresh_token: 'r' }; + }, + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const refreshed: unknown[] = []; + events.on('auth.session-refreshed', (payload) => + refreshed.push(payload), + ); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // let the expiry timer fire and park the extension on the gated sign-in + await new Promise((resolve) => setTimeout(resolve, 10)); + await service.signOut(); + releaseSignInGuest(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(service.getSession().isLoggedIn).toBe(false); + expect(refreshed).toHaveLength(0); + expect(expired).toHaveLength(0); + // the extension's freshly written tokens were cleared again instead of + // being left to resurrect the signed-out session on the next boot + expect(storage.persistent.data.has('refresh_token')).toBe(false); + expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); + }, + ); + + // Parked (it.todo) with the test above — the SDK-lifecycle variant of + // the same known issue, solved by the same command-lane serialization + // (dispose drains the lane). + it.todo( + 'stops an in-flight expiry handler at teardown without clearing tokens', + async () => { + let gateReads = false; + let releaseTokenRead = (): void => undefined; + const readGate = new Promise((resolve) => { + releaseTokenRead = resolve; + }); + const storage = createStorage(); + const readValue = storage.persistent.getItem; + storage.persistent.getItem = async (key) => { + if (gateReads) { + await readGate; + } + return readValue(key); + }; + const { service, calls, events } = createService({ storage }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // gate the handler's token re-read so teardown lands mid-handler + gateReads = true; + await new Promise((resolve) => setTimeout(resolve, 10)); + service.teardown(); + releaseTokenRead(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(calls).not.toContain('signOut'); + expect(expired).toHaveLength(0); + // teardown is not logout: tokens survive for a successor instance + expect(storage.persistent.data.has('refresh_token')).toBe(true); + }, + ); + + it('ends the session when the extension cannot restore the guest user', async () => { + let fetchCalls = 0; + const { service, storage, events } = createService({ + os: { + fetchUser: async () => { + fetchCalls += 1; + // restore succeeds; the post-extend fetch fails + if (fetchCalls > 1) { + throw new Error('network'); + } + return { user: guestUser }; + }, + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // no wedged half-session: the death path ran and told the host + expect(expired).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(false); + }); + }); + + it('initiateGoogleAuth returns the raw auth url', async () => { + const { service } = createService(); + expect(await service.initiateGoogleAuth()).toEqual({ + authUrl: 'https://accounts.google/x', + }); + }); + + it('completeGoogleAuth establishes the session', async () => { + const { service, calls } = createService(); + + await service.completeGoogleAuth({ code: 'auth-code', state: 'state' }); + + expect(calls).toContain('handleGoogleCallback'); + expect(service.getSession()).toEqual({ isLoggedIn: true, user: fullUser }); + service.teardown(); + }); +}); diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts new file mode 100644 index 000000000..d8ded9f3b --- /dev/null +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -0,0 +1,398 @@ +import type { + GoogleAuthResponse, + LoginResponse, + UserResponse, +} from '@agicash/opensecret'; +import { + type LongTimeout, + clearLongTimeout, + safeJwtDecode, + setLongTimeout, +} from '@agicash/utils'; +import type { WalletEventEmitter } from '../../lib/events'; +import type { AuthApi, AuthSession, AuthStorage, Logger } from '../../sdk'; +import type { GuestAccountStorage } from './guest-account-storage'; + +// Keys are owned by @agicash/opensecret's token persistence; the service reads +// them (never writes) for session detection and expiry math. +const accessTokenKey = 'access_token'; +const refreshTokenKey = 'refresh_token'; + +/** The subset of @agicash/opensecret the auth service drives. `import * as openSecret` satisfies it. */ +export type OpenSecretAuthApi = { + fetchUser(): Promise; + signIn(email: string, password: string): Promise; + signUp( + email: string, + password: string, + inviteCode: string, + name?: string | null, + ): Promise; + signUpGuest(password: string, inviteCode: string): Promise; + signInGuest(id: string, password: string): Promise; + signOut(): Promise; + verifyEmail(code: string): Promise; + requestNewVerificationCode(): Promise; + convertGuestToUserAccount( + email: string, + password: string, + name?: string | null, + ): Promise; + initiateGoogleAuth(inviteCode?: string): Promise; + handleGoogleCallback( + code: string, + state: string, + inviteCode: string, + ): Promise; +}; + +type AuthServiceDeps = { + os: OpenSecretAuthApi; + storage: AuthStorage; + guestAccountStorage: GuestAccountStorage; + generateGuestPassword: () => Promise; + events: WalletEventEmitter; + /** Per-session cache cleanup on any session end (sign-out or expiry). */ + onSessionEnded?: () => void; + logger: Logger; +}; + +export class AuthService implements AuthApi { + private session: AuthSession = { isLoggedIn: false }; + private restorePromise: Promise | undefined; + private expiryTimeout: LongTimeout | undefined; + // Survives endSession() deliberately — see applySessionFromServer. + private lastUserId: string | undefined; + // Bumped on every session transition (login apply or session end). A + // restore captures it before its user fetch; a result from a generation + // that has passed must not apply, or it would clobber the newer session. + private sessionGeneration = 0; + private disposed = false; + + constructor(private readonly deps: AuthServiceDeps) {} + + getSession(): AuthSession { + return this.session; + } + + /** + * Idempotent session restore from the storage port; resolving anonymous is + * a state, not a failure. A rejection (unreadable storage, failed user + * fetch with tokens present) is not memoized, so a retry can recover. + */ + restoreSession(): Promise { + if (!this.restorePromise) { + const restorePromise: Promise = this.doRestore().catch((error) => { + // Un-memoize only our own memo: a session end may already have + // cleared it, and a newer restore may own the slot by now. + if (this.restorePromise === restorePromise) { + this.restorePromise = undefined; + } + throw error; + }); + this.restorePromise = restorePromise; + } + return this.restorePromise; + } + + private async doRestore(): Promise { + if (this.session.isLoggedIn) { + // A verb already established the session (and a previous session end + // un-memoized the restore); booting from storage would only repeat the + // verb's user fetch. + return; + } + const [accessToken, refreshToken] = await Promise.all([ + this.deps.storage.persistent.getItem(accessTokenKey), + this.deps.storage.persistent.getItem(refreshTokenKey), + ]); + if (!accessToken || !refreshToken) { + return; + } + if (!safeJwtDecode(refreshToken)?.exp) { + // An undecodable (or exp-less) refresh token can't arm the expiry + // machinery and can't be refreshed — the restored session would be + // unmanaged and die unrecoverably mid-use. Restore anonymous instead. + return; + } + const generation = this.sessionGeneration; + try { + await this.applySessionFromServer({ expectedGeneration: generation }); + } catch (error) { + if (this.session.isLoggedIn) { + // An auth verb established a session while this restore was in + // flight; the restore result is moot. + return; + } + if (this.sessionGeneration !== generation) { + // Another transition (a verb, sign-out, or a newer restore's apply) + // owns the session state now; ending the session here would bump the + // generation again and fence that owner's in-flight apply out. + throw error; + } + // Contract: init() rejects on refresh errors (tokens exist but can't + // be validated). endSession keeps the instance consistent; the + // rejection is un-memoized by restoreSession, so a retry can succeed. + this.endSession(); + throw error; + } + } + + async signUp(email: string, password: string): Promise { + await this.deps.os.signUp(email, password, ''); + await this.refreshSessionSnapshot('sign up'); + } + + async signUpGuest(): Promise { + await this.signInGuestAccount(); + await this.refreshSessionSnapshot('guest sign up'); + } + + /** + * Signs into the stored guest account, creating and persisting a new one + * when none is stored. Fresh tokens land in storage as a side effect; the + * session snapshot is not touched. + */ + private async signInGuestAccount(): Promise { + const existingGuestAccount = await this.deps.guestAccountStorage.get(); + if (existingGuestAccount) { + await this.deps.os.signInGuest( + existingGuestAccount.id, + existingGuestAccount.password, + ); + return; + } + const password = await this.deps.generateGuestPassword(); + const guestAccount = await this.deps.os.signUpGuest(password, ''); + try { + await this.deps.guestAccountStorage.store({ + id: guestAccount.id, + password, + }); + } catch (error) { + // Credentials that can't be persisted strand the account at its first + // expiry (the extension would mint a fresh guest and the funds would be + // unreachable). Undo the sign-up and fail loudly so the retry lands on + // a recoverable account. + try { + await this.deps.os.signOut(); + } catch (undoError) { + this.deps.logger.warn( + 'Failed to sign out a guest account with unpersisted credentials', + undoError, + ); + } + throw error; + } + } + + async signIn(email: string, password: string): Promise { + await this.deps.os.signIn(email, password); + await this.refreshSessionSnapshot('sign in'); + } + + async signOut(): Promise { + try { + await this.deps.os.signOut(); + } finally { + this.endSession(); + } + } + + async verifyEmail(code: string): Promise { + await this.deps.os.verifyEmail(code); + await this.refreshSessionSnapshot('email verification'); + } + + requestNewVerificationCode(): Promise { + return this.deps.os.requestNewVerificationCode(); + } + + async convertGuestToFullAccount( + email: string, + password: string, + ): Promise { + await this.deps.os.convertGuestToUserAccount(email, password); + await this.deps.guestAccountStorage.clear(); + await this.refreshSessionSnapshot('guest conversion'); + } + + async initiateGoogleAuth(): Promise<{ authUrl: string }> { + const response = await this.deps.os.initiateGoogleAuth(''); + return { authUrl: response.auth_url }; + } + + async completeGoogleAuth(params: { + code: string; + state: string; + }): Promise { + await this.deps.os.handleGoogleCallback(params.code, params.state, ''); + await this.refreshSessionSnapshot('google auth'); + } + + /** + * Terminally disarms the expiry machinery: cancels the timer and prevents + * in-flight continuations (a restore, a verb, a fired timer) from re-arming + * it. Auth verbs still work afterwards — disposal is not logout. + */ + teardown(): void { + this.disposed = true; + this.disarmExpiryTimer(); + } + + private async refreshSessionSnapshot(context: string): Promise { + try { + await this.applySessionFromServer(); + } catch (error) { + // Swallowed for parity: a verb whose fetchUser fails leaves an + // anonymous session the host discovers on its next read, like the old + // web glue. endSession (not a bare snapshot clear) so the per-session + // caches die with the session — the Supabase token cache in particular + // must never outlive it. + this.deps.logger.error(`Failed to fetch user (${context})`, error); + this.endSession(); + } + } + + private async applySessionFromServer(options?: { + /** Apply only while the session generation still matches; a speculative caller (restore) passes the generation it observed. */ + expectedGeneration?: number; + }): Promise { + const response = await this.deps.os.fetchUser(); + if ( + options?.expectedGeneration !== undefined && + options.expectedGeneration !== this.sessionGeneration + ) { + // A verb or session end won while this fetch was in flight; the stale + // result must not overwrite the newer session state. + return; + } + // Compared against the last seen user rather than the live session: a + // memo repopulated by a request that resolved after sign-out must still + // be wiped when a DIFFERENT user's session begins, and by then the + // session is anonymous. Same-user re-login keeps its memos warm. + if (this.lastUserId && this.lastUserId !== response.user.id) { + this.deps.onSessionEnded?.(); + } + this.lastUserId = response.user.id; + this.sessionGeneration += 1; + this.session = { isLoggedIn: true, user: response.user }; + await this.armExpiryTimer(); + } + + private endSession(): void { + this.sessionGeneration += 1; + this.session = { isLoggedIn: false }; + this.disarmExpiryTimer(); + // Un-memoize the restore so the next init() re-evaluates from storage — + // a verb whose post-login fetchUser failed leaves tokens behind, and the + // next invalidation can then recover the session like the old glue did. + this.restorePromise = undefined; + this.deps.onSessionEnded?.(); + } + + private async armExpiryTimer(): Promise { + const remaining = await this.getRemainingSessionTimeMs(); + // Disarm only after the await, so disarm+check+assign form one + // synchronous block — two overlapping arms can't interleave and orphan a + // timer, and a teardown during the await can't be re-armed past. + this.disarmExpiryTimer(); + if (this.disposed || remaining === null) { + return; + } + // Floor of 1ms: setLongTimeout fires synchronously at delay 0, which + // would recurse into handleSessionExpiry from inside a login verb. + this.expiryTimeout = setLongTimeout( + () => { + void this.handleSessionExpiry(); + }, + Math.max(remaining, 1), + ); + } + + /** + * Milliseconds until the stored refresh token is treated as expired (5s + * before actual expiry, matching the previous web behavior), floored at 0. + * Null when the token is absent or undecodable. + */ + private async getRemainingSessionTimeMs(): Promise { + const refreshToken = + await this.deps.storage.persistent.getItem(refreshTokenKey); + if (!refreshToken) { + return null; + } + const decoded = safeJwtDecode(refreshToken); + if (!decoded?.exp) { + return null; + } + return Math.max((decoded.exp - 5) * 1000 - Date.now(), 0); + } + + private disarmExpiryTimer(): void { + if (this.expiryTimeout) { + clearLongTimeout(this.expiryTimeout); + this.expiryTimeout = undefined; + } + } + + // KNOWN ISSUE (accepted for now — PR #1166 review, finding 1): this + // handler races concurrent session transitions, because a fired timer + // callback can't be cancelled. A sign-out landing while the guest + // re-sign-in below is in flight is silently undone — the re-sign-in + // rewrites fresh tokens over the sign-out's clear and the unfenced apply + // resurrects the session. Same class: an in-flight handler survives + // teardown() and can clear tokens a successor instance restored (HMR). + // Inherited from master's expiry hook, not a regression. The planned fix + // is structural — serialize all session transitions through a single + // command lane (single-writer) instead of fencing every await; the + // it.todo tests in auth-service.test.ts specify the required behavior. + private async handleSessionExpiry(): Promise { + const session = this.session; + if (this.disposed || !session.isLoggedIn) { + return; + } + // The Open Secret SDK rotates the refresh token during its internal + // refresh flow, so the expiry this timer was armed for may have moved. + // Re-check the stored token and re-arm instead of expiring a live session. + const remaining = await this.getRemainingSessionTimeMs(); + if (remaining !== null && remaining > 0) { + await this.armExpiryTimer(); + return; + } + const isGuest = !session.user.email; + if (isGuest) { + try { + // Re-signing-in the stored guest account gets fresh tokens and re-arms + // the timer; the host never observes the expiry. + await this.signUpGuest(); + const extendedRemaining = await this.getRemainingSessionTimeMs(); + if ( + extendedRemaining !== null && + extendedRemaining > 0 && + this.session.isLoggedIn + ) { + // The host didn't initiate this refresh, so it must be told — + // the web re-syncs its auth query + session-hint cookie from it. + this.deps.events.emit('auth.session-refreshed', {}); + return; + } + // Falls through when the extension produced no live session (already- + // expired token — also guards a hot extend loop — or a failed + // post-extend user fetch), so the death path emits the event instead + // of leaving a wedged half-session. + this.deps.logger.warn( + 'Guest session extension did not produce a live session; ending it', + ); + } catch (error) { + this.deps.logger.error('Failed to extend guest session', error); + } + } + try { + await this.deps.os.signOut(); + } catch (error) { + this.deps.logger.warn('Sign out during session expiry failed', error); + } + this.endSession(); + this.deps.events.emit('auth.session-expired', {}); + } +} diff --git a/packages/wallet-sdk/domain/user/guest-account-storage.test.ts b/packages/wallet-sdk/domain/user/guest-account-storage.test.ts new file mode 100644 index 000000000..15c7c3507 --- /dev/null +++ b/packages/wallet-sdk/domain/user/guest-account-storage.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'bun:test'; +import { nullLogger } from '../../lib/logger'; +import type { AuthKeyValueStore } from '../../sdk'; +import { createGuestAccountStorage } from './guest-account-storage'; + +const createMemoryStore = (): AuthKeyValueStore & { + data: Map; +} => { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +describe('guestAccountStorage', () => { + it('round-trips guest account details under the legacy key', async () => { + const store = createMemoryStore(); + const storage = createGuestAccountStorage(store, nullLogger); + + await storage.store({ id: 'guest-1', password: 'pw' }); + + expect(store.data.has('guestAccount')).toBe(true); + expect(await storage.get()).toEqual({ id: 'guest-1', password: 'pw' }); + }); + + it('returns null when nothing is stored', async () => { + const storage = createGuestAccountStorage(createMemoryStore(), nullLogger); + expect(await storage.get()).toBeNull(); + }); + + it('returns null for corrupt or invalid data', async () => { + const store = createMemoryStore(); + store.data.set('guestAccount', 'not-json'); + const storage = createGuestAccountStorage(store, nullLogger); + expect(await storage.get()).toBeNull(); + + store.data.set('guestAccount', JSON.stringify({ id: 42 })); + expect(await storage.get()).toBeNull(); + }); + + it('clear removes the stored account', async () => { + const store = createMemoryStore(); + const storage = createGuestAccountStorage(store, nullLogger); + await storage.store({ id: 'guest-1', password: 'pw' }); + + await storage.clear(); + + expect(await storage.get()).toBeNull(); + }); +}); diff --git a/packages/wallet-sdk/domain/user/guest-account-storage.ts b/packages/wallet-sdk/domain/user/guest-account-storage.ts new file mode 100644 index 000000000..8405927d2 --- /dev/null +++ b/packages/wallet-sdk/domain/user/guest-account-storage.ts @@ -0,0 +1,52 @@ +import { safeJsonParse } from '@agicash/utils'; +import { z } from 'zod/mini'; +import type { AuthKeyValueStore, Logger } from '../../sdk'; + +// Key predates the SDK move — existing devices have guest credentials stored +// under it, so it must not change. +const storageKey = 'guestAccount'; + +const GuestAccountDetailsSchema = z.object({ + id: z.string(), + password: z.string(), +}); + +export type GuestAccountDetails = z.infer; + +export type GuestAccountStorage = { + get(): Promise; + store(details: GuestAccountDetails): Promise; + clear(): Promise; +}; + +export function createGuestAccountStorage( + store: AuthKeyValueStore, + logger: Logger, +): GuestAccountStorage { + return { + async get() { + const dataString = await store.getItem(storageKey); + if (!dataString) { + return null; + } + const parseResult = safeJsonParse(dataString); + if (!parseResult.success) { + return null; + } + const validationResult = GuestAccountDetailsSchema.safeParse( + parseResult.data, + ); + if (!validationResult.success) { + logger.warn('Invalid guest account data found in the storage'); + return null; + } + return validationResult.data; + }, + async store(details) { + await store.setItem(storageKey, JSON.stringify(details)); + }, + async clear() { + await store.removeItem(storageKey); + }, + }; +} diff --git a/packages/wallet-sdk/domain/user/user-api.test.ts b/packages/wallet-sdk/domain/user/user-api.test.ts new file mode 100644 index 000000000..23490970c --- /dev/null +++ b/packages/wallet-sdk/domain/user/user-api.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'bun:test'; +import type { AgicashDb } from '../../db/database'; +import type { AuthUser } from '../../sdk'; +import { createUserApi } from './user-api'; + +const authUser = (id: string): AuthUser => + ({ + id, + name: null, + email: 'a@b.c', + email_verified: true, + login_method: 'email', + created_at: '2026-01-01', + updated_at: '2026-01-01', + }) as AuthUser; + +const dbUserRow = (id: string) => ({ + id, + username: 'name', + email: 'a@b.c', + email_verified: true, + created_at: '2026-01-01', + updated_at: '2026-01-01', + cashu_locking_xpub: 'xpub', + encryption_public_key: 'enc', + spark_identity_public_key: 'spark', + default_btc_account_id: 'acct-1', + default_usd_account_id: null, + default_currency: 'BTC', + terms_accepted_at: null, + gift_card_mint_terms_accepted_at: null, +}); + +type Filters = Record; + +/** + * Chainable fake covering exactly the two query shapes setDefaultAccount + * issues: the accounts ownership read and the users row update. + */ +const createDbFake = (deps: { + onAccountRead: (filters: Filters) => Record; + onUserUpdate: (filters: Filters, data: Record) => void; +}) => { + const from = (table: string) => { + const filters: Filters = {}; + let updateData: Record = {}; + const chain = { + select: () => chain, + update: (data: Record) => { + updateData = data; + return chain; + }, + eq: (column: string, value: unknown) => { + filters[column] = value; + return chain; + }, + single: async () => { + if (table === 'accounts') { + return { data: deps.onAccountRead(filters), error: null }; + } + deps.onUserUpdate(filters, updateData); + return { data: dbUserRow(String(filters.id)), error: null }; + }, + }; + return chain; + }; + return { from } as unknown as AgicashDb; +}; + +describe('createUserApi', () => { + describe('setDefaultAccount', () => { + it('writes onto the user that validated account ownership, not the current session user', async () => { + let sessionUserId = 'user-a'; + let updatedUserId: unknown; + let updatedData: Record = {}; + const api = createUserApi({ + db: createDbFake({ + onAccountRead: (filters) => { + // the session switches while the account read is in flight + sessionUserId = 'user-b'; + return { id: filters.id, currency: 'BTC' }; + }, + onUserUpdate: (filters, data) => { + updatedUserId = filters.id; + updatedData = data; + }, + }), + getSession: () => ({ isLoggedIn: true, user: authUser(sessionUserId) }), + }); + + await api.setDefaultAccount({ accountId: 'acct-1' }); + + expect(updatedUserId).toBe('user-a'); + expect(updatedData.default_btc_account_id).toBe('acct-1'); + }); + }); +}); diff --git a/packages/wallet-sdk/domain/user/user-api.ts b/packages/wallet-sdk/domain/user/user-api.ts new file mode 100644 index 000000000..9435b2f21 --- /dev/null +++ b/packages/wallet-sdk/domain/user/user-api.ts @@ -0,0 +1,76 @@ +import type { Currency } from '@agicash/money'; +import type { AgicashDb } from '../../db/database'; +import { NoSessionError } from '../../lib/error'; +import type { AuthSession, UserApi } from '../../sdk'; +import { ReadUserRepository, WriteUserRepository } from './user-repository'; +import { UserService } from './user-service'; + +type Deps = { + db: AgicashDb; + getSession: () => AuthSession; +}; + +export function createUserApi(deps: Deps): UserApi { + const readRepository = new ReadUserRepository(deps.db); + const writeRepository = new WriteUserRepository(deps.db); + const userService = new UserService(writeRepository); + + const requireUserId = (): string => { + const session = deps.getSession(); + if (!session.isLoggedIn) { + throw new NoSessionError(); + } + return session.user.id; + }; + + const getAccountRef = async ( + userId: string, + accountId: string, + ): Promise<{ id: string; currency: Currency }> => { + const { data, error } = await deps.db + .from('accounts') + .select('id, currency') + .eq('id', accountId) + // RLS already scopes rows to the user; this is defense-in-depth per the + // "userId implicit from session" convention. + .eq('user_id', userId) + .single(); + if (error) { + throw new Error('Failed to get account', { cause: error }); + } + return data; + }; + + // Methods are async so a missing session surfaces as a rejection, matching + // the Promise-returning contract, not a synchronous throw. + return { + get: async () => readRepository.get(requireUserId()), + updateUsername: async (username) => + writeRepository.update(requireUserId(), { username }), + acceptTerms: async (params) => { + const now = new Date().toISOString(); + return writeRepository.update(requireUserId(), { + termsAcceptedAt: params.walletTerms ? now : undefined, + giftCardMintTermsAcceptedAt: params.giftCardTerms ? now : undefined, + }); + }, + setDefaultCurrency: async (params) => + writeRepository.update(requireUserId(), { + defaultCurrency: params.currency, + }), + setDefaultAccount: async (params) => { + // The session is read once for the whole verb: the user that validated + // account ownership must be the user whose row is written, or a session + // switch during the account fetch writes the previous user's account + // onto the next user's row. + const userId = requireUserId(); + // One read, not two: the account row is fetched to derive the + // per-currency column server-truthfully; the user row isn't needed + // because the update only writes the changed columns. + const account = await getAccountRef(userId, params.accountId); + return userService.setDefaultAccount({ id: userId }, account, { + setDefaultCurrency: params.setDefaultCurrency, + }); + }, + }; +} diff --git a/packages/wallet-sdk/domain/user/user-repository.ts b/packages/wallet-sdk/domain/user/user-repository.ts index 680dccee6..e68c9e74f 100644 --- a/packages/wallet-sdk/domain/user/user-repository.ts +++ b/packages/wallet-sdk/domain/user/user-repository.ts @@ -54,10 +54,7 @@ type AccountInput = { >; export class WriteUserRepository { - constructor( - private readonly db: AgicashDb, - private readonly accountRepository: AccountRepository, - ) {} + constructor(private readonly db: AgicashDb) {} /** * Updates a user in the database. @@ -146,6 +143,7 @@ export class WriteUserRepository { */ giftCardMintTermsAcceptedAt?: string; }, + accountRepository: AccountRepository, options?: Options, ): Promise<{ user: User; accounts: Account[] }> { const accountsToAdd = user.accounts.map((account) => ({ @@ -195,7 +193,7 @@ export class WriteUserRepository { return { user: ReadUserRepository.toUser(upsertedUser), accounts: await Promise.all( - accounts.map((account) => this.accountRepository.toAccount(account)), + accounts.map((account) => accountRepository.toAccount(account)), ), }; } diff --git a/packages/wallet-sdk/domain/user/user-service.ts b/packages/wallet-sdk/domain/user/user-service.ts index 54b3ca7d0..41323c6b7 100644 --- a/packages/wallet-sdk/domain/user/user-service.ts +++ b/packages/wallet-sdk/domain/user/user-service.ts @@ -45,10 +45,12 @@ export class UserService { /** * Sets the account as the user's default account for the respective currency. * If setDefaultCurrency option is set to true, the user's default currency will also be set to the account's currency. + * Writes only the changed columns, so concurrent changes to the other + * defaults can't be clobbered by stale caller state. */ async setDefaultAccount( - user: User, - account: Account, + user: Pick, + account: Pick, options: SetDefaultAccountOptions = { setDefaultCurrency: false, }, @@ -60,13 +62,12 @@ export class UserService { return this.userRepository.update( user.id, { - defaultCurrency: options.setDefaultCurrency - ? account.currency - : user.defaultCurrency, - defaultBtcAccountId: - account.currency === 'BTC' ? account.id : user.defaultBtcAccountId, - defaultUsdAccountId: - account.currency === 'USD' ? account.id : user.defaultUsdAccountId, + ...(account.currency === 'BTC' + ? { defaultBtcAccountId: account.id } + : { defaultUsdAccountId: account.id }), + ...(options.setDefaultCurrency + ? { defaultCurrency: account.currency } + : {}), }, { abortSignal: options.abortSignal }, ); diff --git a/packages/wallet-sdk/index.ts b/packages/wallet-sdk/index.ts index f24a1472a..91d593744 100644 --- a/packages/wallet-sdk/index.ts +++ b/packages/wallet-sdk/index.ts @@ -8,6 +8,8 @@ // deletes its names here when it flips the web imports, surfacing the // projections. export * from './sdk'; +export { AgicashSdk } from './agicash-sdk'; +export { nullLogger } from './lib/logger'; export { ConcurrencyError, DomainError, diff --git a/packages/wallet-sdk/lib/error.ts b/packages/wallet-sdk/lib/error.ts index 628d366b4..46fcdf671 100644 --- a/packages/wallet-sdk/lib/error.ts +++ b/packages/wallet-sdk/lib/error.ts @@ -30,3 +30,11 @@ export class ConcurrencyError extends SdkError { this.name = 'ConcurrencyError'; } } + +/** Thrown when a namespace method requiring an authenticated session runs without one. */ +export class NoSessionError extends SdkError { + constructor() { + super('No authenticated session'); + this.name = 'NoSessionError'; + } +} diff --git a/packages/wallet-sdk/lib/events.test.ts b/packages/wallet-sdk/lib/events.test.ts new file mode 100644 index 000000000..6678d8b4f --- /dev/null +++ b/packages/wallet-sdk/lib/events.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'bun:test'; +import { WalletEventEmitter } from './events'; +import { nullLogger } from './logger'; + +describe('WalletEventEmitter', () => { + it('delivers payloads to subscribed handlers', () => { + const emitter = new WalletEventEmitter(nullLogger); + const received: unknown[] = []; + emitter.on('auth.session-expired', (payload) => received.push(payload)); + + emitter.emit('auth.session-expired', {}); + + expect(received).toEqual([{}]); + }); + + it('stops delivering after unsubscribe', () => { + const emitter = new WalletEventEmitter(nullLogger); + let calls = 0; + const unsubscribe = emitter.on('auth.session-expired', () => { + calls += 1; + }); + + unsubscribe(); + emitter.emit('auth.session-expired', {}); + + expect(calls).toBe(0); + }); + + it('does not deliver the current emit to a handler subscribed mid-emit', () => { + const emitter = new WalletEventEmitter(nullLogger); + const order: string[] = []; + let lateHandlerSubscribed = false; + emitter.on('auth.session-expired', () => { + order.push('first'); + if (!lateHandlerSubscribed) { + lateHandlerSubscribed = true; + emitter.on('auth.session-expired', () => { + order.push('late'); + }); + } + }); + + emitter.emit('auth.session-expired', {}); + expect(order).toEqual(['first']); + + emitter.emit('auth.session-expired', {}); + expect(order).toEqual(['first', 'first', 'late']); + }); + + it('isolates a throwing handler and reports it to the logger', () => { + const errors: string[] = []; + const emitter = new WalletEventEmitter({ + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: (message) => { + errors.push(message); + }, + }); + let secondHandlerRan = false; + emitter.on('auth.session-expired', () => { + throw new Error('boom'); + }); + emitter.on('auth.session-expired', () => { + secondHandlerRan = true; + }); + + emitter.emit('auth.session-expired', {}); + + expect(secondHandlerRan).toBe(true); + expect(errors).toHaveLength(1); + }); +}); diff --git a/packages/wallet-sdk/lib/events.ts b/packages/wallet-sdk/lib/events.ts new file mode 100644 index 000000000..4038f6a2e --- /dev/null +++ b/packages/wallet-sdk/lib/events.ts @@ -0,0 +1,40 @@ +import type { Logger, WalletEventMap, WalletEvents } from '../sdk'; + +type Handler = (payload: never) => void; + +export class WalletEventEmitter implements WalletEvents { + private readonly handlers = new Map>(); + + constructor(private readonly logger: Logger) {} + + on( + event: K, + handler: (payload: WalletEventMap[K]) => void, + ): () => void { + const set = this.handlers.get(event) ?? new Set(); + set.add(handler as Handler); + this.handlers.set(event, set); + return () => { + set.delete(handler as Handler); + }; + } + + emit( + event: K, + payload: WalletEventMap[K], + ): void { + const set = this.handlers.get(event); + if (!set) { + return; + } + // Snapshot: a handler that (un)subscribes mid-emit must not change the + // current dispatch. + for (const handler of [...set]) { + try { + (handler as (payload: WalletEventMap[K]) => void)(payload); + } catch (error) { + this.logger.error(`Event handler for ${event} threw`, error); + } + } + } +} diff --git a/packages/wallet-sdk/lib/logger.ts b/packages/wallet-sdk/lib/logger.ts new file mode 100644 index 000000000..95afd1950 --- /dev/null +++ b/packages/wallet-sdk/lib/logger.ts @@ -0,0 +1,11 @@ +import type { Logger } from '../sdk'; + +const noop = () => undefined; + +/** Discards all diagnostics — for hosts that want no logging. */ +export const nullLogger: Logger = { + debug: noop, + info: noop, + warn: noop, + error: noop, +}; diff --git a/packages/wallet-sdk/lib/password.test.ts b/packages/wallet-sdk/lib/password.test.ts new file mode 100644 index 000000000..784d56790 --- /dev/null +++ b/packages/wallet-sdk/lib/password.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'bun:test'; +import { generateRandomPassword } from './password'; + +describe('generateRandomPassword', () => { + it('generates the requested length', async () => { + const password = await generateRandomPassword(32); + expect(password).toHaveLength(32); + }); +}); diff --git a/apps/web-wallet/app/lib/password-generator.ts b/packages/wallet-sdk/lib/password.ts similarity index 75% rename from apps/web-wallet/app/lib/password-generator.ts rename to packages/wallet-sdk/lib/password.ts index 326e42871..7d6257410 100644 --- a/apps/web-wallet/app/lib/password-generator.ts +++ b/packages/wallet-sdk/lib/password.ts @@ -1,20 +1,13 @@ -interface PasswordOptions { +type PasswordOptions = { letters?: boolean; numbers?: boolean; special?: boolean; -} +}; export async function generateRandomPassword( length = 24, options: PasswordOptions = { letters: true, numbers: true, special: true }, ): Promise { - if (window.getMockPassword) { - const password = await window.getMockPassword(); - if (password) { - return password; - } - } - let charset = ''; if (options.letters) @@ -32,7 +25,7 @@ export async function generateRandomPassword( for (let i = 0; i < length; i++) { const randomIndex = - window.crypto.getRandomValues(new Uint32Array(1))[0] % charset.length; + globalThis.crypto.getRandomValues(new Uint32Array(1))[0] % charset.length; password.push(charset[randomIndex]); } diff --git a/packages/wallet-sdk/package.json b/packages/wallet-sdk/package.json index ab40f6f48..7c6e3736c 100644 --- a/packages/wallet-sdk/package.json +++ b/packages/wallet-sdk/package.json @@ -35,7 +35,7 @@ "@stablelib/base64": "catalog:", "@supabase/supabase-js": "2.95.2", "big.js": "catalog:", - "jwt-decode": "4.0.0", + "jwt-decode": "catalog:", "ky": "catalog:", "type-fest": "catalog:", "zod": "catalog:" diff --git a/packages/wallet-sdk/sdk.ts b/packages/wallet-sdk/sdk.ts deleted file mode 100644 index faf0742be..000000000 --- a/packages/wallet-sdk/sdk.ts +++ /dev/null @@ -1,405 +0,0 @@ -// Public contract of @agicash/wallet-sdk. Prose contract: -// docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md -import type { - LNURLError, - LNURLPayParams, - LNURLPayResult, - LNURLVerifyResult, -} from '@agicash/lnurl'; -import type { Money } from '@agicash/money'; -import type { SparkNetwork } from './db/json-models/spark-account-details-db-data'; -import type { - CashuAccount as DomainCashuAccount, - SparkAccount as DomainSparkAccount, -} from './domain/accounts/account'; -import type { Contact as DomainContact } from './domain/contacts/contact'; -import type { CashuReceiveQuote as DomainCashuReceiveQuote } from './domain/receive/cashu-receive-quote'; -import type { CashuReceiveLightningQuote } from './domain/receive/cashu-receive-quote-core'; -import type { CashuReceiveSwap as DomainCashuReceiveSwap } from './domain/receive/cashu-receive-swap'; -import type { SparkReceiveQuote as DomainSparkReceiveQuote } from './domain/receive/spark-receive-quote'; -import type { SparkReceiveLightningQuote } from './domain/receive/spark-receive-quote-core'; -import type { CashuSendQuote as DomainCashuSendQuote } from './domain/send/cashu-send-quote'; -import type { CashuLightningQuote } from './domain/send/cashu-send-quote-service'; -import type { CashuSendSwap as DomainCashuSendSwap } from './domain/send/cashu-send-swap'; -import type { CashuSwapQuote } from './domain/send/cashu-send-swap-service'; -import type { SparkSendQuote as DomainSparkSendQuote } from './domain/send/spark-send-quote'; -import type { SparkLightningQuote } from './domain/send/spark-send-quote-service'; -import type { Transaction as DomainTransaction } from './domain/transactions/transaction'; -import type { Cursor } from './domain/transactions/transaction-repository'; -import type { TransferQuote } from './domain/transfer/transfer-service'; -import type { User } from './domain/user/user'; -import type { SdkError } from './lib/error'; -import type { FeatureFlag } from './lib/feature-flag-service'; -import type { DestinationDetails } from './lib/send-destination'; - -export type { Cursor }; - -// Public projections of the domain entities: `userId`/`ownerId` are implicit -// from the session; raw wallet handles and proof material stay internal. - -/** Carries `balance` on every rail, never a raw wallet handle or proof material. */ -export type CashuAccount = Omit< - DomainCashuAccount, - 'keysetCounters' | 'proofs' | 'wallet' -> & { balance: Money | null }; -export type SparkAccount = Omit; -export type Account = CashuAccount | SparkAccount; - -export type Contact = Omit; -export type Transaction = Omit; -export type CashuReceiveQuote = Omit; -export type SparkReceiveQuote = Omit; -export type CashuReceiveSwap = Omit; -export type CashuSendQuote = Omit; -export type CashuSendSwap = Omit< - DomainCashuSendSwap, - 'inputProofs' | 'proofsToSend' | 'userId' ->; -export type SparkSendQuote = Omit; - -/** Host-backed session persistence. */ -export type AuthStorage = { - get(key: string): Promise; - set(key: string, value: string): Promise; - remove(key: string): Promise; -}; - -/** Diagnostic sink; the SDK never writes to the console directly. */ -export type Logger = { - debug(message: string, meta?: unknown): void; - info(message: string, meta?: unknown): void; - warn(message: string, meta?: unknown): void; - error(message: string, meta?: unknown): void; -}; - -export type SdkConfig = { - db: { - url: string; - anonKey: string; - }; - auth: { - apiUrl: string; - clientId: string; - storage: AuthStorage; - }; - spark: { - breezApiKey: string; - /** Default for account creation; the persisted per-account value is authoritative. */ - network: SparkNetwork; - /** Node hosts; browser default applies. */ - storageDir?: string; - }; - /** lud16 domain. */ - lightningAddressDomain: string; - logger?: Logger; -}; - -export type Sdk = { - readonly auth: AuthApi; - readonly user: UserApi; - readonly accounts: AccountsApi; - readonly contacts: ContactsApi; - readonly transactions: TransactionsApi; - readonly receive: ReceiveApi; - readonly send: SendApi; - readonly transfer: TransferApi; - readonly featureFlags: FeatureFlagsApi; - readonly events: WalletEvents; - readonly background: BackgroundApi; - /** - * Front-loads session restore and the Breez WASM load. Resolves when no - * session exists (a state, not a failure); rejects on actual failures, - * e.g. `WebAssemblyUnavailableError`. Required before any Spark operation — - * the SDK does not lazy-load the WASM, so Spark calls without a completed - * `init()` throw a typed `SdkError`. Non-Spark usage lazy-initializes on - * first use. - */ - init(): Promise; - /** - * Awaits in-flight background transitions to their next checkpoint, then - * tears down realtime + background; still-pending namespace promises reject - * with a typed `SdkError`. - */ - dispose(): Promise; -}; - -/** `create` is sync; no I/O. */ -export type SdkConstructor = { - create(config: SdkConfig): Sdk; -}; - -export type AuthUser = unknown; // settles in step 5 (auth & user) - -export type AuthSession = - | { isLoggedIn: true; user: AuthUser } - | { isLoggedIn: false }; - -export type AuthApi = { - /** Creates a full account and signs the user in. */ - signUp(email: string, password: string): Promise; - /** Re-signs-in this device's prior guest account if one exists. */ - signUpGuest(): Promise; - signIn(email: string, password: string): Promise; - /** - * Stops background, tears down realtime, clears the stored session; the - * instance stays usable in anonymous state. - */ - signOut(): Promise; - verifyEmail(code: string): Promise; - requestNewVerificationCode(): Promise; - convertGuestToFullAccount(email: string, password: string): Promise; - /** Returns the URL to redirect to. */ - initiateGoogleAuth(): Promise<{ authUrl: string }>; - /** OAuth callback leg. */ - completeGoogleAuth(params: { code: string; state: string }): Promise; - /** Sync snapshot; no I/O. */ - getSession(): AuthSession; -}; - -export type UserApi = { - get(): Promise; - updateUsername(username: string): Promise; - acceptTerms(params: AcceptTermsParams): Promise; - setDefaultAccount(params: SetDefaultAccountParams): Promise; - setDefaultCurrency(params: SetDefaultCurrencyParams): Promise; -}; - -export type AccountsApi = { - get(id: string): Promise; - /** Active accounts of the current user. */ - list(): Promise; - cashu: { - add(params: AddCashuAccountParams): Promise; - }; -}; - -export type ContactsApi = { - get(id: string): Promise; - list(): Promise; - create(params: CreateContactParams): Promise; - delete(id: string): Promise; - findContactCandidates(query: string): Promise; -}; - -export type TransactionsApi = { - get(id: string): Promise; - list(params: { - /** Opaque pagination token from a previous page's `nextCursor`. */ - cursor?: Cursor; - pageSize?: number; - accountId?: string; - }): Promise<{ transactions: Transaction[]; nextCursor: Cursor | null }>; - countPendingAck(): Promise; - acknowledge(transactionId: string): Promise; -}; - -/** - * `get*` methods are stateless previews; `create*` methods persist and enter - * the entity into the background lifecycle. Completion is observed via - * `events`, never called by the host. - */ -export type ReceiveApi = { - cashu: { - getLightningQuote( - params: GetCashuReceiveLightningQuoteParams, - ): Promise; - createQuote( - params: CreateCashuReceiveQuoteParams, - ): Promise; - getQuote(id: string): Promise; - }; - spark: { - getLightningQuote( - params: GetSparkReceiveLightningQuoteParams, - ): Promise; - createQuote( - params: CreateSparkReceiveQuoteParams, - ): Promise; - getQuote(id: string): Promise; - }; - cashuToken: { - getQuote( - params: GetReceiveCashuTokenQuoteParams, - ): Promise; - claim(params: ClaimCashuTokenParams): Promise; - }; -}; - -export type SendApi = { - resolveDestination(input: string): Promise; - cashu: { - getLightningQuote( - params: GetCashuSendLightningQuoteParams, - ): Promise; - createQuote( - params: CreateCashuSendQuoteParams, - ): Promise<{ transactionId: string }>; - /** Send-to-token. */ - getSwapQuote(params: GetCashuSwapQuoteParams): Promise; - createSwap(params: CreateCashuSwapParams): Promise; - }; - spark: { - getLightningQuote( - params: GetSparkSendLightningQuoteParams, - ): Promise; - createQuote( - params: CreateSparkSendQuoteParams, - ): Promise<{ transactionId: string }>; - }; -}; - -export type TransferApi = { - /** Stateless preview. */ - getQuote(params: GetTransferQuoteParams): Promise; // public projection of TransferQuote settles in step 16 - initiate(params: InitiateTransferParams): Promise<{ transactionId: string }>; -}; - -/** Flags are a process-local cached read — the one no-cache exception. */ -export type FeatureFlagsApi = { - get(flag: FeatureFlag): boolean; - /** Cache-change signal; returns unsubscribe. */ - subscribe(listener: () => void): () => void; -}; - -export type BackgroundState = 'stopped' | 'follower' | 'leader' | 'error'; - -/** - * Execution is background-only: a host must run `start()` somewhere or - * nothing moves money. The executing instance may differ from the initiating - * one (the leader lock is per-user across devices). - */ -export type BackgroundApi = { - /** - * Leader election + processors. - * @throws {SdkError} when no authenticated session exists. - */ - start(): void; - /** - * Stops claiming new work immediately, awaits in-flight iterations to their - * next checkpoint (bounded by a timeout), releases the leader lock, and - * abandons the remaining queue. - */ - stop(): Promise; - readonly state: BackgroundState; -}; - -/** - * Payloads are decrypted domain objects. Naming: `.`, verbs per - * entity; terminal transitions arrive as `updated` with the new state on the - * payload. Adding events is non-breaking; renaming is breaking. - */ -export type WalletEventMap = { - /** The session died without a `signOut()` call (expiry / failed refresh). */ - 'auth.session-expired': Record; - 'user.updated': { user: User }; - 'account.created': { account: Account }; - /** A persisted row changed; the payload carries a `version` consumers gate on. */ - 'account.updated': { account: Account }; - /** Versionless balance signal from both rails; spark's only balance path. */ - 'account.balance-changed': { accountId: string; balance: Money }; - 'contact.created': { contact: Contact }; - 'contact.deleted': { contact: Contact }; - 'transaction.created': { transaction: Transaction }; - 'transaction.updated': { transaction: Transaction }; - 'cashu-receive-quote.created': { quote: CashuReceiveQuote }; - 'cashu-receive-quote.updated': { quote: CashuReceiveQuote }; - 'cashu-receive-swap.created': { swap: CashuReceiveSwap }; - 'cashu-receive-swap.updated': { swap: CashuReceiveSwap }; - 'spark-receive-quote.created': { quote: SparkReceiveQuote }; - 'spark-receive-quote.updated': { quote: SparkReceiveQuote }; - 'cashu-send-quote.created': { quote: CashuSendQuote }; - 'cashu-send-quote.updated': { quote: CashuSendQuote }; - 'cashu-send-swap.created': { swap: CashuSendSwap }; - 'cashu-send-swap.updated': { swap: CashuSendSwap }; - 'spark-send-quote.created': { quote: SparkSendQuote }; - 'spark-send-quote.updated': { quote: SparkSendQuote }; - /** - * Emits on every transition into `connected`, including the initial - * connection — the invalidate-all signal. `error` is terminal: the channel - * is dead after retries exhaust, distinct from a long `reconnecting`. - */ - 'connection.changed': { state: 'connected' | 'reconnecting' | 'error' }; - /** - * Fires on every `state` transition; `error` set on transitions into - * `'error'`. Per-task errors never change state, so they never fire it. - */ - 'background.state-changed': { state: BackgroundState; error?: SdkError }; -}; - -/** - * `on()` only registers a handler and is callable with no session; the - * per-user realtime channel is established when a session comes into - * existence (login, or `init()` session restore). Returns unsubscribe. - */ -export type WalletEvents = { - on( - event: K, - handler: (payload: WalletEventMap[K]) => void, - ): () => void; -}; - -/** - * Server-side trust model: service-role key, no user session, per-request - * scope. No `auth`, no `events`, no `background`. - */ -export type ServerSdkConfig = { - db: { url: string; serviceRoleKey: string }; - spark: { - breezApiKey: string; - network: SparkNetwork; - mnemonic: string; - storageDir: string; - }; - /** Hex; encrypts LNURL verify payloads. */ - quoteEncryptionKey: string; -}; - -export type ServerSdk = { - readonly lightningAddress: { - handleLud16Request(params: { - username: string; - baseUrl: string; - }): Promise; - handleLnurlpCallback(params: { - userId: string; - amount: Money<'BTC'>; - baseUrl: string; - /** Per-request by design — instance state would race on the per-process singleton. */ - bypassAmountValidation?: boolean; - }): Promise; - handleLnurlpVerify(params: { - encryptedQuoteData: string; - }): Promise; - }; -}; - -/** Singleton per process. */ -export type ServerSdkConstructor = { - create(config: ServerSdkConfig): ServerSdk; -}; - -// Settles in step N — pinned by that slice PR to the public projection of -// today's service types. - -export type AcceptTermsParams = unknown; // step 5 (auth & user) -export type SetDefaultAccountParams = unknown; // step 5 (auth & user) -export type SetDefaultCurrencyParams = unknown; // step 5 (auth & user) -export type AddCashuAccountParams = unknown; // step 6 (accounts) -export type CreateContactParams = unknown; // step 7 (contacts) -export type GetCashuReceiveLightningQuoteParams = unknown; // step 9 (cashu receive quote) -export type CreateCashuReceiveQuoteParams = unknown; // step 9 (cashu receive quote) -export type GetSparkReceiveLightningQuoteParams = unknown; // step 11 (spark receive quote) -export type CreateSparkReceiveQuoteParams = unknown; // step 11 (spark receive quote) -export type GetReceiveCashuTokenQuoteParams = unknown; // step 12 (receive cashu token) -export type ReceiveCashuTokenQuote = unknown; // step 12 (receive cashu token) -export type ClaimCashuTokenParams = unknown; // step 12 (receive cashu token) -export type ClaimCashuTokenResult = unknown; // step 12 (receive cashu token) -export type GetCashuSendLightningQuoteParams = unknown; // step 13 (cashu send quote) -export type CreateCashuSendQuoteParams = unknown; // step 13 (cashu send quote) -export type GetCashuSwapQuoteParams = unknown; // step 14 (cashu send swap) -export type CreateCashuSwapParams = unknown; // step 14 (cashu send swap) -export type CreateCashuSwapResult = unknown; // step 14 (cashu send swap) -export type GetSparkSendLightningQuoteParams = unknown; // step 15 (spark send quote) -export type CreateSparkSendQuoteParams = unknown; // step 15 (spark send quote) -export type GetTransferQuoteParams = unknown; // step 16 (transfer) -export type InitiateTransferParams = unknown; // step 16 (transfer) diff --git a/packages/wallet-sdk/sdk/accounts.ts b/packages/wallet-sdk/sdk/accounts.ts new file mode 100644 index 000000000..4978d2c67 --- /dev/null +++ b/packages/wallet-sdk/sdk/accounts.ts @@ -0,0 +1,24 @@ +import type { Money } from '@agicash/money'; +import type { + CashuAccount as DomainCashuAccount, + SparkAccount as DomainSparkAccount, +} from '../domain/accounts/account'; + +/** Carries `balance` on every rail, never a raw wallet handle or proof material. */ +export type CashuAccount = Omit< + DomainCashuAccount, + 'keysetCounters' | 'proofs' | 'wallet' +> & { balance: Money | null }; +export type SparkAccount = Omit; +export type Account = CashuAccount | SparkAccount; + +export type AccountsApi = { + get(id: string): Promise; + /** Active accounts of the current user. */ + list(): Promise; + cashu: { + add(params: AddCashuAccountParams): Promise; + }; +}; + +export type AddCashuAccountParams = unknown; // step 6 (accounts) diff --git a/packages/wallet-sdk/sdk/auth.ts b/packages/wallet-sdk/sdk/auth.ts new file mode 100644 index 000000000..dc576c723 --- /dev/null +++ b/packages/wallet-sdk/sdk/auth.ts @@ -0,0 +1,52 @@ +import type { UserResponse } from '@agicash/opensecret'; + +/** + * Minimal key/value store — the Web Storage API subset the SDK persists auth + * state through. Methods may be sync (window.localStorage) or async (React + * Native AsyncStorage, SQLite); the SDK always awaits results. Matches the + * @agicash/opensecret StorageProvider interface verbatim, so one host object + * backs both. + */ +export type AuthKeyValueStore = { + getItem(key: string): string | null | Promise; + setItem(key: string, value: string): void | Promise; + removeItem(key: string): void | Promise; +}; + +/** + * Host-backed session persistence. `persistent` must survive restarts (auth + * tokens, guest credentials); `session` is per-app-session (attestation + * handshake material). Browser hosts map them to localStorage/sessionStorage. + */ +export type AuthStorage = { + persistent: AuthKeyValueStore; + session: AuthKeyValueStore; +}; + +export type AuthUser = UserResponse['user']; + +export type AuthSession = + | { isLoggedIn: true; user: AuthUser } + | { isLoggedIn: false }; + +export type AuthApi = { + /** Creates a full account and signs the user in. */ + signUp(email: string, password: string): Promise; + /** Re-signs-in this device's prior guest account if one exists. */ + signUpGuest(): Promise; + signIn(email: string, password: string): Promise; + /** + * Stops background, tears down realtime, clears the stored session; the + * instance stays usable in anonymous state. + */ + signOut(): Promise; + verifyEmail(code: string): Promise; + requestNewVerificationCode(): Promise; + convertGuestToFullAccount(email: string, password: string): Promise; + /** Returns the URL to redirect to. */ + initiateGoogleAuth(): Promise<{ authUrl: string }>; + /** OAuth callback leg. */ + completeGoogleAuth(params: { code: string; state: string }): Promise; + /** Sync snapshot; no I/O. */ + getSession(): AuthSession; +}; diff --git a/packages/wallet-sdk/sdk/background.ts b/packages/wallet-sdk/sdk/background.ts new file mode 100644 index 000000000..7e24a752f --- /dev/null +++ b/packages/wallet-sdk/sdk/background.ts @@ -0,0 +1,21 @@ +export type BackgroundState = 'stopped' | 'follower' | 'leader' | 'error'; + +/** + * Execution is background-only: a host must run `start()` somewhere or + * nothing moves money. The executing instance may differ from the initiating + * one (the leader lock is per-user across devices). + */ +export type BackgroundApi = { + /** + * Leader election + processors. + * @throws {SdkError} when no authenticated session exists. + */ + start(): void; + /** + * Stops claiming new work immediately, awaits in-flight iterations to their + * next checkpoint (bounded by a timeout), releases the leader lock, and + * abandons the remaining queue. + */ + stop(): Promise; + readonly state: BackgroundState; +}; diff --git a/packages/wallet-sdk/sdk/contacts.ts b/packages/wallet-sdk/sdk/contacts.ts new file mode 100644 index 000000000..48d631239 --- /dev/null +++ b/packages/wallet-sdk/sdk/contacts.ts @@ -0,0 +1,13 @@ +import type { Contact as DomainContact } from '../domain/contacts/contact'; + +export type Contact = Omit; + +export type ContactsApi = { + get(id: string): Promise; + list(): Promise; + create(params: CreateContactParams): Promise; + delete(id: string): Promise; + findContactCandidates(query: string): Promise; +}; + +export type CreateContactParams = unknown; // step 7 (contacts) diff --git a/packages/wallet-sdk/sdk/events.ts b/packages/wallet-sdk/sdk/events.ts new file mode 100644 index 000000000..b90447536 --- /dev/null +++ b/packages/wallet-sdk/sdk/events.ts @@ -0,0 +1,75 @@ +import type { Money } from '@agicash/money'; +import type { User } from '../domain/user/user'; +import type { SdkError } from '../lib/error'; +import type { Account } from './accounts'; +import type { BackgroundState } from './background'; +import type { Contact } from './contacts'; +import type { + CashuReceiveQuote, + CashuReceiveSwap, + SparkReceiveQuote, +} from './receive'; +import type { CashuSendQuote, CashuSendSwap, SparkSendQuote } from './send'; +import type { Transaction } from './transactions'; + +/** + * Payloads are decrypted domain objects. Naming: `.`, verbs per + * entity; terminal transitions arrive as `updated` with the new state on the + * payload. Adding events is non-breaking; renaming is breaking. + */ +export type WalletEventMap = { + /** The session died without a `signOut()` call (expiry / failed refresh). */ + 'auth.session-expired': Record; + /** + * The SDK refreshed the session without a host-initiated verb — today: + * guest auto-extension at refresh-token expiry. Host-initiated verbs never + * fire it (the host knows its own actions). Hosts re-sync session-derived + * state from it (the web: auth query + session-hint cookie). + */ + 'auth.session-refreshed': Record; + 'user.updated': { user: User }; + 'account.created': { account: Account }; + /** A persisted row changed; the payload carries a `version` consumers gate on. */ + 'account.updated': { account: Account }; + /** Versionless balance signal from both rails; spark's only balance path. */ + 'account.balance-changed': { accountId: string; balance: Money }; + 'contact.created': { contact: Contact }; + 'contact.deleted': { contact: Contact }; + 'transaction.created': { transaction: Transaction }; + 'transaction.updated': { transaction: Transaction }; + 'cashu-receive-quote.created': { quote: CashuReceiveQuote }; + 'cashu-receive-quote.updated': { quote: CashuReceiveQuote }; + 'cashu-receive-swap.created': { swap: CashuReceiveSwap }; + 'cashu-receive-swap.updated': { swap: CashuReceiveSwap }; + 'spark-receive-quote.created': { quote: SparkReceiveQuote }; + 'spark-receive-quote.updated': { quote: SparkReceiveQuote }; + 'cashu-send-quote.created': { quote: CashuSendQuote }; + 'cashu-send-quote.updated': { quote: CashuSendQuote }; + 'cashu-send-swap.created': { swap: CashuSendSwap }; + 'cashu-send-swap.updated': { swap: CashuSendSwap }; + 'spark-send-quote.created': { quote: SparkSendQuote }; + 'spark-send-quote.updated': { quote: SparkSendQuote }; + /** + * Emits on every transition into `connected`, including the initial + * connection — the invalidate-all signal. `error` is terminal: the channel + * is dead after retries exhaust, distinct from a long `reconnecting`. + */ + 'connection.changed': { state: 'connected' | 'reconnecting' | 'error' }; + /** + * Fires on every `state` transition; `error` set on transitions into + * `'error'`. Per-task errors never change state, so they never fire it. + */ + 'background.state-changed': { state: BackgroundState; error?: SdkError }; +}; + +/** + * `on()` only registers a handler and is callable with no session; the + * per-user realtime channel is established when a session comes into + * existence (login, or `init()` session restore). Returns unsubscribe. + */ +export type WalletEvents = { + on( + event: K, + handler: (payload: WalletEventMap[K]) => void, + ): () => void; +}; diff --git a/packages/wallet-sdk/sdk/feature-flags.ts b/packages/wallet-sdk/sdk/feature-flags.ts new file mode 100644 index 000000000..4ce1fa153 --- /dev/null +++ b/packages/wallet-sdk/sdk/feature-flags.ts @@ -0,0 +1,8 @@ +import type { FeatureFlag } from '../lib/feature-flag-service'; + +/** Flags are a process-local cached read — the one no-cache exception. */ +export type FeatureFlagsApi = { + get(flag: FeatureFlag): boolean; + /** Cache-change signal; returns unsubscribe. */ + subscribe(listener: () => void): () => void; +}; diff --git a/packages/wallet-sdk/sdk/index.ts b/packages/wallet-sdk/sdk/index.ts new file mode 100644 index 000000000..728063690 --- /dev/null +++ b/packages/wallet-sdk/sdk/index.ts @@ -0,0 +1,105 @@ +// Public contract of @agicash/wallet-sdk, one file per namespace. Prose +// contract: docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +// +// The entity types the namespaces expose are public projections of the domain +// entities: `userId`/`ownerId` are implicit from the session; raw wallet +// handles and proof material stay internal. +import type { SparkNetwork } from '../db/json-models/spark-account-details-db-data'; +import type { AccountsApi } from './accounts'; +import type { AuthApi, AuthStorage } from './auth'; +import type { BackgroundApi } from './background'; +import type { ContactsApi } from './contacts'; +import type { WalletEvents } from './events'; +import type { FeatureFlagsApi } from './feature-flags'; +import type { ReceiveApi } from './receive'; +import type { SendApi } from './send'; +import type { TransactionsApi } from './transactions'; +import type { TransferApi } from './transfer'; +import type { UserApi } from './user'; + +export * from './accounts'; +export * from './auth'; +export * from './background'; +export * from './contacts'; +export * from './events'; +export * from './feature-flags'; +export * from './receive'; +export * from './send'; +export * from './server'; +export * from './transactions'; +export * from './transfer'; +export * from './user'; + +/** Diagnostic sink; the SDK never writes to the console directly. */ +export type Logger = { + debug(message: string, meta?: unknown): void; + info(message: string, meta?: unknown): void; + warn(message: string, meta?: unknown): void; + error(message: string, meta?: unknown): void; +}; + +export type SdkConfig = { + db: { + url: string; + anonKey: string; + }; + auth: { + apiUrl: string; + clientId: string; + storage: AuthStorage; + /** + * Host override for guest credential generation; resolve null to use the + * SDK's CSPRNG generator. Test seam (the web bridges its e2e password + * mock through it). + */ + generateGuestPassword?: () => Promise; + }; + spark: { + breezApiKey: string; + /** Default for account creation; the persisted per-account value is authoritative. */ + network: SparkNetwork; + /** Node hosts; browser default applies. */ + storageDir?: string; + }; + /** lud16 domain. */ + lightningAddressDomain: string; + /** Diagnostic sink; hosts that want no logging pass the package's `nullLogger` export. */ + logger: Logger; +}; + +export type Sdk = { + readonly auth: AuthApi; + readonly user: UserApi; + readonly accounts: AccountsApi; + readonly contacts: ContactsApi; + readonly transactions: TransactionsApi; + readonly receive: ReceiveApi; + readonly send: SendApi; + readonly transfer: TransferApi; + readonly featureFlags: FeatureFlagsApi; + readonly events: WalletEvents; + readonly background: BackgroundApi; + /** + * Front-loads session restore and the Breez WASM load. Resolves when no + * session exists (a state, not a failure); rejects on actual failures, + * e.g. `WebAssemblyUnavailableError`. Required before any Spark operation — + * the SDK does not lazy-load the WASM, so Spark calls without a completed + * `init()` throw a typed `SdkError`. Non-Spark usage lazy-initializes on + * first use. + * + * Migration note: until the first Spark slice lands, `init()` performs + * session restore only — the WASM load still runs host-side. + */ + init(): Promise; + /** + * Awaits in-flight background transitions to their next checkpoint, then + * tears down realtime + background; still-pending namespace promises reject + * with a typed `SdkError`. + */ + dispose(): Promise; +}; + +/** `create` is sync; no I/O. */ +export type SdkConstructor = { + create(config: SdkConfig): Sdk; +}; diff --git a/packages/wallet-sdk/sdk/receive.ts b/packages/wallet-sdk/sdk/receive.ts new file mode 100644 index 000000000..6e454e6dd --- /dev/null +++ b/packages/wallet-sdk/sdk/receive.ts @@ -0,0 +1,96 @@ +import type { + CashuReceiveQuoteStateVariant, + CashuReceiveQuoteTypeVariant, + CashuReceiveQuote as DomainCashuReceiveQuote, +} from '../domain/receive/cashu-receive-quote'; +import type { CashuReceiveLightningQuote } from '../domain/receive/cashu-receive-quote-core'; +import type { + CashuReceiveSwapStateVariant, + CashuReceiveSwap as DomainCashuReceiveSwap, +} from '../domain/receive/cashu-receive-swap'; +import type { CashuTokenMeltData } from '../domain/receive/cashu-token-melt-data'; +import type { + SparkReceiveQuote as DomainSparkReceiveQuote, + SparkReceiveQuoteStateVariant, + SparkReceiveQuoteTypeVariant, +} from '../domain/receive/spark-receive-quote'; +import type { SparkReceiveLightningQuote } from '../domain/receive/spark-receive-quote-core'; + +// The domain entities are intersections over variant unions (`Base & (A | B)`), +// and a bare `Omit` over such a type collapses each union to its shared keys — +// variant-only fields silently vanish and discriminant narrowing breaks. The +// projections below therefore omit base keys only and re-apply the variant +// unions. Spendable Cashu proof material (top-level `tokenProofs` and the +// melt data's `tokenProofs`) is stripped from the public shapes; the +// implementing slices (steps 9/11/12) must strip it at runtime at this same +// boundary. + +/** Distributes over a type-variant union, stripping proofs from the melt data. */ +type WithPublicTokenReceiveData = T extends { + tokenReceiveData: CashuTokenMeltData; +} + ? Omit & { + tokenReceiveData: Omit; + } + : T; + +export type CashuReceiveQuote = Omit< + DomainCashuReceiveQuote, + 'userId' | 'type' | 'state' +> & + WithPublicTokenReceiveData & + CashuReceiveQuoteStateVariant; + +export type SparkReceiveQuote = Omit< + DomainSparkReceiveQuote, + 'userId' | 'type' | 'state' +> & + WithPublicTokenReceiveData & + SparkReceiveQuoteStateVariant; + +export type CashuReceiveSwap = Omit< + DomainCashuReceiveSwap, + 'userId' | 'tokenProofs' | 'state' +> & + CashuReceiveSwapStateVariant; + +/** + * `get*` methods are stateless previews; `create*` methods persist and enter + * the entity into the background lifecycle. Completion is observed via + * `events`, never called by the host. + */ +export type ReceiveApi = { + cashu: { + getLightningQuote( + params: GetCashuReceiveLightningQuoteParams, + ): Promise; + createQuote( + params: CreateCashuReceiveQuoteParams, + ): Promise; + getQuote(id: string): Promise; + }; + spark: { + getLightningQuote( + params: GetSparkReceiveLightningQuoteParams, + ): Promise; + createQuote( + params: CreateSparkReceiveQuoteParams, + ): Promise; + getQuote(id: string): Promise; + }; + cashuToken: { + getQuote( + params: GetReceiveCashuTokenQuoteParams, + ): Promise; + claim(params: ClaimCashuTokenParams): Promise; + }; +}; + +export type GetCashuReceiveLightningQuoteParams = unknown; // step 9 (cashu receive quote) +export type CreateCashuReceiveQuoteParams = unknown; // step 9 (cashu receive quote) +export type GetSparkReceiveLightningQuoteParams = unknown; // step 11 (spark receive quote) +export type CreateSparkReceiveQuoteParams = unknown; // step 11 (spark receive quote) +export type GetReceiveCashuTokenQuoteParams = unknown; // step 12 (receive cashu token) +export type ReceiveCashuTokenQuote = unknown; // step 12 (receive cashu token) +export type ClaimCashuTokenParams = unknown; // step 12 (receive cashu token) +export type ClaimCashuTokenResult = unknown; // step 12 (receive cashu token) diff --git a/packages/wallet-sdk/sdk/send.ts b/packages/wallet-sdk/sdk/send.ts new file mode 100644 index 000000000..645146b7e --- /dev/null +++ b/packages/wallet-sdk/sdk/send.ts @@ -0,0 +1,45 @@ +import type { CashuSendQuote as DomainCashuSendQuote } from '../domain/send/cashu-send-quote'; +import type { CashuLightningQuote } from '../domain/send/cashu-send-quote-service'; +import type { CashuSendSwap as DomainCashuSendSwap } from '../domain/send/cashu-send-swap'; +import type { CashuSwapQuote } from '../domain/send/cashu-send-swap-service'; +import type { SparkSendQuote as DomainSparkSendQuote } from '../domain/send/spark-send-quote'; +import type { SparkLightningQuote } from '../domain/send/spark-send-quote-service'; +import type { DestinationDetails } from '../lib/send-destination'; + +export type CashuSendQuote = Omit; +export type CashuSendSwap = Omit< + DomainCashuSendSwap, + 'inputProofs' | 'proofsToSend' | 'userId' +>; +export type SparkSendQuote = Omit; + +export type SendApi = { + resolveDestination(input: string): Promise; + cashu: { + getLightningQuote( + params: GetCashuSendLightningQuoteParams, + ): Promise; + createQuote( + params: CreateCashuSendQuoteParams, + ): Promise<{ transactionId: string }>; + /** Send-to-token. */ + getSwapQuote(params: GetCashuSwapQuoteParams): Promise; + createSwap(params: CreateCashuSwapParams): Promise; + }; + spark: { + getLightningQuote( + params: GetSparkSendLightningQuoteParams, + ): Promise; + createQuote( + params: CreateSparkSendQuoteParams, + ): Promise<{ transactionId: string }>; + }; +}; + +export type GetCashuSendLightningQuoteParams = unknown; // step 13 (cashu send quote) +export type CreateCashuSendQuoteParams = unknown; // step 13 (cashu send quote) +export type GetCashuSwapQuoteParams = unknown; // step 14 (cashu send swap) +export type CreateCashuSwapParams = unknown; // step 14 (cashu send swap) +export type CreateCashuSwapResult = unknown; // step 14 (cashu send swap) +export type GetSparkSendLightningQuoteParams = unknown; // step 15 (spark send quote) +export type CreateSparkSendQuoteParams = unknown; // step 15 (spark send quote) diff --git a/packages/wallet-sdk/sdk/server.ts b/packages/wallet-sdk/sdk/server.ts new file mode 100644 index 000000000..fac6fb754 --- /dev/null +++ b/packages/wallet-sdk/sdk/server.ts @@ -0,0 +1,48 @@ +import type { + LNURLError, + LNURLPayParams, + LNURLPayResult, + LNURLVerifyResult, +} from '@agicash/lnurl'; +import type { Money } from '@agicash/money'; +import type { SparkNetwork } from '../db/json-models/spark-account-details-db-data'; + +/** + * Server-side trust model: service-role key, no user session, per-request + * scope. No `auth`, no `events`, no `background`. + */ +export type ServerSdkConfig = { + db: { url: string; serviceRoleKey: string }; + spark: { + breezApiKey: string; + network: SparkNetwork; + mnemonic: string; + storageDir: string; + }; + /** Hex; encrypts LNURL verify payloads. */ + quoteEncryptionKey: string; +}; + +export type ServerSdk = { + readonly lightningAddress: { + handleLud16Request(params: { + username: string; + baseUrl: string; + }): Promise; + handleLnurlpCallback(params: { + userId: string; + amount: Money<'BTC'>; + baseUrl: string; + /** Per-request by design — instance state would race on the per-process singleton. */ + bypassAmountValidation?: boolean; + }): Promise; + handleLnurlpVerify(params: { + encryptedQuoteData: string; + }): Promise; + }; +}; + +/** Singleton per process. */ +export type ServerSdkConstructor = { + create(config: ServerSdkConfig): ServerSdk; +}; diff --git a/packages/wallet-sdk/sdk/transactions.ts b/packages/wallet-sdk/sdk/transactions.ts new file mode 100644 index 000000000..5d9820af4 --- /dev/null +++ b/packages/wallet-sdk/sdk/transactions.ts @@ -0,0 +1,18 @@ +import type { Transaction as DomainTransaction } from '../domain/transactions/transaction'; +import type { Cursor } from '../domain/transactions/transaction-repository'; + +export type { Cursor }; + +export type Transaction = Omit; + +export type TransactionsApi = { + get(id: string): Promise; + list(params: { + /** Opaque pagination token from a previous page's `nextCursor`. */ + cursor?: Cursor; + pageSize?: number; + accountId?: string; + }): Promise<{ transactions: Transaction[]; nextCursor: Cursor | null }>; + countPendingAck(): Promise; + acknowledge(transactionId: string): Promise; +}; diff --git a/packages/wallet-sdk/sdk/transfer.ts b/packages/wallet-sdk/sdk/transfer.ts new file mode 100644 index 000000000..4b58b42da --- /dev/null +++ b/packages/wallet-sdk/sdk/transfer.ts @@ -0,0 +1,10 @@ +import type { TransferQuote } from '../domain/transfer/transfer-service'; + +export type TransferApi = { + /** Stateless preview. */ + getQuote(params: GetTransferQuoteParams): Promise; // public projection of TransferQuote settles in step 16 + initiate(params: InitiateTransferParams): Promise<{ transactionId: string }>; +}; + +export type GetTransferQuoteParams = unknown; // step 16 (transfer) +export type InitiateTransferParams = unknown; // step 16 (transfer) diff --git a/packages/wallet-sdk/sdk/user.ts b/packages/wallet-sdk/sdk/user.ts new file mode 100644 index 000000000..20c9ce348 --- /dev/null +++ b/packages/wallet-sdk/sdk/user.ts @@ -0,0 +1,25 @@ +import type { Currency } from '@agicash/money'; +import type { User } from '../domain/user/user'; + +export type UserApi = { + get(): Promise; + updateUsername(username: string): Promise; + acceptTerms(params: AcceptTermsParams): Promise; + setDefaultAccount(params: SetDefaultAccountParams): Promise; + setDefaultCurrency(params: SetDefaultCurrencyParams): Promise; +}; + +export type AcceptTermsParams = { + walletTerms?: boolean; + giftCardTerms?: boolean; +}; + +export type SetDefaultAccountParams = { + accountId: string; + /** Also switch the user's default currency to the account's currency. */ + setDefaultCurrency?: boolean; +}; + +export type SetDefaultCurrencyParams = { + currency: Currency; +};