diff --git a/apps/web-wallet-e2e/e2e/fixtures/open-secret/fixture.ts b/apps/web-wallet-e2e/e2e/fixtures/open-secret/fixture.ts index 53097e4d0..ca8838561 100644 --- a/apps/web-wallet-e2e/e2e/fixtures/open-secret/fixture.ts +++ b/apps/web-wallet-e2e/e2e/fixtures/open-secret/fixture.ts @@ -1,7 +1,7 @@ import * as crypto from 'node:crypto'; +import { delay } from '@agicash/utils'; import { type Page, type Route, test as base, expect } from '@playwright/test'; import { decode } from '@stablelib/base64'; -import delay from '~/lib/delay'; import { session } from '../../mocks/open-secret'; import { openSecretEncryption } from './encryption'; diff --git a/apps/web-wallet-e2e/package.json b/apps/web-wallet-e2e/package.json index 6da43b215..3ab3aa495 100644 --- a/apps/web-wallet-e2e/package.json +++ b/apps/web-wallet-e2e/package.json @@ -8,6 +8,7 @@ }, "devDependencies": { "@agicash/opensecret": "catalog:", + "@agicash/utils": "workspace:*", "@playwright/test": "1.49.1", "@stablelib/base64": "catalog:", "@stablelib/chacha20poly1305": "catalog:", diff --git a/apps/web-wallet/app/entry.client.tsx b/apps/web-wallet/app/entry.client.tsx index f91b2e6bb..ff1557083 100644 --- a/apps/web-wallet/app/entry.client.tsx +++ b/apps/web-wallet/app/entry.client.tsx @@ -21,6 +21,7 @@ import { loadFeatureFlags } from './features/shared/feature-flags'; // later slice constructs the SDK explicitly at boot and moves feature flags // onto the instance, dropping this ordering dependency (PR #1166). import './features/shared/sdk.client'; +import { registerSessionStarted } from './features/user/session-started'; import { registerMoneyDevToolsFormatter } from './lib/money-devtools-formatter'; import { getTracesSampleRate, sanitizeUrl } from './tracing-utils'; @@ -48,6 +49,11 @@ ensureBreezWasm().catch(() => { configureFeatureFlags(agicashDbClient); void loadFeatureFlags(); +// Seed the user + accounts caches from the SDK's auth.session-started event once +// provisioning settles the identity. Registered before the router so the first +// protected middleware reads already-seeded caches. +registerSessionStarted(); + const sentryDsn = import.meta.env.VITE_SENTRY_DSN ?? ''; if (!sentryDsn) { throw new Error('VITE_SENTRY_DSN is not set'); diff --git a/apps/web-wallet/app/features/accounts/account-hooks.ts b/apps/web-wallet/app/features/accounts/account-hooks.ts index 09c45c275..69c66f7d1 100644 --- a/apps/web-wallet/app/features/accounts/account-hooks.ts +++ b/apps/web-wallet/app/features/accounts/account-hooks.ts @@ -1,19 +1,17 @@ import { type Currency, Money } from '@agicash/money'; -import type { - Account, - AccountPurpose, - AccountState, - AccountType, - CashuAccount, - ExtendedAccount, - SparkAccount, +import { + type Account, + type AccountPurpose, + type AccountState, + type AccountType, + type AddCashuAccountParams, + type CashuAccount, + type ExtendedAccount, + type SparkAccount, + getExtendedAccounts, } from '@agicash/wallet-sdk'; -import type { - AccountRepository, - AgicashDbAccountWithProofs, -} from '@agicash/wallet-sdk/temporary'; +import type { AgicashDbAccountWithProofs } from '@agicash/wallet-sdk/temporary'; import { - UserService, getAccountBalance, sparkDebugLog, } from '@agicash/wallet-sdk/temporary'; @@ -26,9 +24,9 @@ import { useSuspenseQuery, } from '@tanstack/react-query'; import { useCallback, useMemo, useRef } from 'react'; +import { sdk } from '~/features/shared/sdk.client'; import { useUser } from '../user/user-hooks'; import { useAccountRepository } from './account-repository-hooks'; -import { useAccountService } from './account-service-hooks'; export class AccountsCache { public static Key = 'accounts'; @@ -117,8 +115,8 @@ export function useAccountsCache() { * Hook that returns an account change handlers. */ export function useAccountChangeHandlers() { - const accountRepository = useAccountRepository(); const accountCache = useAccountsCache(); + const accountRepository = useAccountRepository(); return [ { @@ -138,13 +136,10 @@ export function useAccountChangeHandlers() { ]; } -export const accountsQueryOptions = ({ - userId, - accountRepository, -}: { userId: string; accountRepository: AccountRepository }) => { +export const accountsQueryOptions = () => { return queryOptions({ queryKey: [AccountsCache.Key], - queryFn: () => accountRepository.getAllActive(userId), + queryFn: () => sdk.accounts.list(), staleTime: Number.POSITIVE_INFINITY, // Refetches use `getAllActive`, so any expired account previously in the // cache (lazy-fetched via useAccountOrNull, or just expired before the @@ -253,18 +248,17 @@ export function useAccounts< select?: UseAccountsSelect, ): UseSuspenseQueryResult[]> { const user = useUser(); - const accountRepository = useAccountRepository(); const { currency, type, isOnline, purpose, state = 'active' } = select ?? {}; return useSuspenseQuery({ - ...accountsQueryOptions({ userId: user.id, accountRepository }), + ...accountsQueryOptions(), refetchOnWindowFocus: 'always', refetchOnReconnect: 'always', select: useCallback( (data: Account[]) => { const allowedStates = Array.isArray(state) ? state : [state]; - const extendedData = UserService.getExtendedAccounts(user, data); + const extendedData = getExtendedAccounts(user, data); const filteredData = extendedData.filter((account) => { if (!allowedStates.includes(account.state)) { @@ -330,14 +324,13 @@ const ALL_ACCOUNT_STATES: AccountState[] = ['active', 'expired']; */ export function useAccountOrNull(id: string | null): Account | null { const accountsCache = useAccountsCache(); - const accountRepository = useAccountRepository(); const { data: accounts } = useAccounts({ state: ALL_ACCOUNT_STATES }); useSuspenseQuery({ queryKey: ['fetch-account-by-id', id], queryFn: async () => { if (!id || accountsCache.get(id)) return null; - const fetched = await accountRepository.get(id); + const fetched = await sdk.accounts.get(id); if (fetched) accountsCache.upsert(fetched); return null; }, @@ -468,14 +461,11 @@ export function useAccountOrDefault(accountId: string | null) { } export function useAddCashuAccount() { - const userId = useUser((x) => x.id); const accountCache = useAccountsCache(); - const accountService = useAccountService(); const { mutateAsync } = useMutation({ - mutationFn: async ( - account: Parameters[0]['account'], - ) => accountService.addCashuAccount({ userId, account }), + mutationFn: (params: AddCashuAccountParams) => + sdk.accounts.cashu.add(params), onSuccess: (account) => { // We add the account as soon as it is created so that it is available in the cache immediately. // This is important when using other hooks that are trying to use the account immediately after it is created. diff --git a/apps/web-wallet/app/features/accounts/account-service-hooks.ts b/apps/web-wallet/app/features/accounts/account-service-hooks.ts deleted file mode 100644 index 307159cb4..000000000 --- a/apps/web-wallet/app/features/accounts/account-service-hooks.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AccountService } from '@agicash/wallet-sdk/temporary'; -import { useAccountRepository } from './account-repository-hooks'; - -export function useAccountService() { - const accountRepository = useAccountRepository(); - return new AccountService(accountRepository); -} diff --git a/apps/web-wallet/app/features/gift-cards/add-gift-card.tsx b/apps/web-wallet/app/features/gift-cards/add-gift-card.tsx index 60fc554e5..5cd7e7eaa 100644 --- a/apps/web-wallet/app/features/gift-cards/add-gift-card.tsx +++ b/apps/web-wallet/app/features/gift-cards/add-gift-card.tsx @@ -39,7 +39,6 @@ function useAddGiftCard() { name, currency, mintUrl: url, - type: 'cashu', purpose: 'gift-card', }); } 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 ad7f9276d..c5a597e80 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 @@ -9,6 +9,7 @@ import { type LongTimeout, clearLongTimeout, setLongTimeout, + withRetry, } from '@agicash/utils'; import type { CashuAccount, @@ -37,16 +38,15 @@ import { useQueryClient, } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAccountRepository } from '~/features/accounts/account-repository-hooks'; import { useOnMeltQuoteStateChange } from '~/lib/cashu/melt-quote-subscription'; import { MintQuoteSubscriptionManager } from '~/lib/cashu/mint-quote-subscription-manager'; import { useLatest } from '~/lib/use-latest'; -import { withRetry } from '~/lib/with-retry'; import { useGetCashuAccount, useGetCashuAccountByMintUrlAndCurrency, useSelectItemsWithOnlineAccount, } from '../accounts/account-hooks'; -import { useAccountRepository } from '../accounts/account-repository-hooks'; import { agicashDbClient } from '../agicash-db/database.client'; import { useCashuCryptography } from '../shared/cashu-hooks'; import { useEncryption } from '../shared/encryption-hooks'; diff --git a/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts b/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts index 3d07fdcaf..c5d4ad00c 100644 --- a/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts +++ b/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts @@ -13,11 +13,11 @@ import { useQueryClient, } from '@tanstack/react-query'; import { useMemo } from 'react'; +import { useAccountRepository } from '~/features/accounts/account-repository-hooks'; import { useGetCashuAccount, useSelectItemsWithOnlineAccount, } from '../accounts/account-hooks'; -import { useAccountRepository } from '../accounts/account-repository-hooks'; import { agicashDbClient } from '../agicash-db/database.client'; import { useEncryption } from '../shared/encryption-hooks'; import { useUser } from '../user/user-hooks'; diff --git a/apps/web-wallet/app/features/settings/accounts/add-mint-form.tsx b/apps/web-wallet/app/features/settings/accounts/add-mint-form.tsx index 4761db336..88b5f834b 100644 --- a/apps/web-wallet/app/features/settings/accounts/add-mint-form.tsx +++ b/apps/web-wallet/app/features/settings/accounts/add-mint-form.tsx @@ -100,7 +100,6 @@ export function AddMintForm() { name: data.name, currency: ACCOUNT_CURRENCY, mintUrl: data.mintUrl, - type: 'cashu', purpose, }); toast({ diff --git a/apps/web-wallet/app/features/shared/cashu-query-options.ts b/apps/web-wallet/app/features/shared/cashu-query-options.ts index 622e3cd25..e7b3ae567 100644 --- a/apps/web-wallet/app/features/shared/cashu-query-options.ts +++ b/apps/web-wallet/app/features/shared/cashu-query-options.ts @@ -7,10 +7,11 @@ import { getMintInfo, } from '@agicash/wallet-sdk/temporary'; import { type QueryClient, queryOptions } from '@tanstack/react-query'; +import { derivedKeyQueryPrefix } from './session-key-queries'; export const seedQueryOptions = () => queryOptions({ - queryKey: ['cashu-seed'], + queryKey: [derivedKeyQueryPrefix, 'cashu-seed'], queryFn: () => getCashuSeed(), staleTime: Number.POSITIVE_INFINITY, }); @@ -20,7 +21,7 @@ export const xpubQueryOptions = ({ derivationPath, }: { queryClient: QueryClient; derivationPath?: string }) => queryOptions({ - queryKey: ['cashu-xpub', derivationPath], + queryKey: [derivedKeyQueryPrefix, 'cashu-xpub', derivationPath], queryFn: async () => deriveCashuXpub( await queryClient.fetchQuery(seedQueryOptions()), @@ -33,7 +34,7 @@ const privateKeyQueryOptions = ({ derivationPath, }: { derivationPath?: string } = {}) => queryOptions({ - queryKey: ['cashu-private-key', derivationPath], + queryKey: [derivedKeyQueryPrefix, 'cashu-private-key', derivationPath], queryFn: () => getCashuPrivateKey(derivationPath), staleTime: Number.POSITIVE_INFINITY, }); diff --git a/apps/web-wallet/app/features/shared/encryption-hooks.ts b/apps/web-wallet/app/features/shared/encryption-hooks.ts index ad53c17de..0f8037f5d 100644 --- a/apps/web-wallet/app/features/shared/encryption-hooks.ts +++ b/apps/web-wallet/app/features/shared/encryption-hooks.ts @@ -1,43 +1,42 @@ -import { getPrivateKeyBytes, getPublicKey } from '@agicash/opensecret'; import type { Encryption } from '@agicash/wallet-sdk/temporary'; -import { getEncryption } from '@agicash/wallet-sdk/temporary'; -import { hexToBytes } from '@noble/hashes/utils'; +import { + decryptBatchWithPrivateKey, + decryptWithPrivateKey, + encryptBatchToPublicKey, + encryptToPublicKey, + readEncryptionPrivateKey, + readEncryptionPublicKey, +} from '@agicash/wallet-sdk/temporary'; import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { derivedKeyQueryPrefix } from './session-key-queries'; -// 10111099 is 'enc' (for encryption) in ascii -const encryptionKeyDerivationPath = `m/10111099'/0'`; - -export const encryptionPrivateKeyQueryOptions = () => +export const encryptionQueryOptions = () => queryOptions({ - queryKey: ['encryption-private-key'], - queryFn: () => - getPrivateKeyBytes({ - private_key_derivation_path: encryptionKeyDerivationPath, - }).then((response) => hexToBytes(response.private_key)), + queryKey: [derivedKeyQueryPrefix, 'encryption'], + // Derives then wraps so the raw private-key bytes are never stored in the + // query cache — only the encrypt/decrypt closures that capture them are. + // TEMPORARY: duplicates the SDK session-key facade's construction + // (packages/wallet-sdk/domain/sdk/session-keys.ts builds the same object + // literal from these primitives). Deleted at step 18 when receive/send/claim + // migrate into the SDK and this query is removed. + queryFn: async (): Promise => { + const privateKey = await readEncryptionPrivateKey(); + const publicKeyHex = await readEncryptionPublicKey(); + return { + encrypt: async (data: T) => + encryptToPublicKey(data, publicKeyHex), + decrypt: async (data: string) => + decryptWithPrivateKey(data, privateKey), + encryptBatch: async (data: T) => + encryptBatchToPublicKey(data, publicKeyHex), + decryptBatch: async ( + data: readonly [...{ [K in keyof T]: string }], + ) => decryptBatchWithPrivateKey(data, privateKey), + }; + }, staleTime: Number.POSITIVE_INFINITY, }); -export const useEncryptionPrivateKey = () => { - const { data } = useSuspenseQuery(encryptionPrivateKeyQueryOptions()); - return data; -}; - -export const encryptionPublicKeyQueryOptions = () => - queryOptions({ - queryKey: ['encryption-public-key'], - queryFn: () => - getPublicKey('schnorr', { - private_key_derivation_path: encryptionKeyDerivationPath, - }).then((response) => response.public_key), - staleTime: Number.POSITIVE_INFINITY, - }); - -export const useEncryptionPublicKeyHex = () => { - const { data } = useSuspenseQuery(encryptionPublicKeyQueryOptions()); - return data; -}; - /** * Hook that provides the encryption functions. * Reference of the returned data is stable and doesn't change between renders. @@ -48,11 +47,6 @@ export const useEncryptionPublicKeyHex = () => { * @returns The encryption functions. */ export const useEncryption = (): Encryption => { - const privateKey = useEncryptionPrivateKey(); - const publicKeyHex = useEncryptionPublicKeyHex(); - - return useMemo( - () => getEncryption(privateKey, publicKeyHex), - [privateKey, publicKeyHex], - ); + const { data } = useSuspenseQuery(encryptionQueryOptions()); + return data; }; diff --git a/apps/web-wallet/app/features/shared/session-key-queries.test.ts b/apps/web-wallet/app/features/shared/session-key-queries.test.ts new file mode 100644 index 000000000..177e53f6d --- /dev/null +++ b/apps/web-wallet/app/features/shared/session-key-queries.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'bun:test'; +import { QueryClient } from '@tanstack/react-query'; +import { seedQueryOptions, xpubQueryOptions } from './cashu-query-options'; +import { encryptionQueryOptions } from './encryption-hooks'; +import { + derivedKeyQueryPrefix, + evictDerivedKeyQueries, +} from './session-key-queries'; +import { sparkMnemonicQueryOptions } from './spark-query-options'; + +describe('evictDerivedKeyQueries', () => { + it('drops every derived-key query under the shared prefix (incl. derivation-path variants) in one removeQueries, and leaves others', () => { + const queryClient = new QueryClient(); + // The five derived-key queries, each under the shared prefix, mirroring the + // shapes the query defs produce (two carry a derivation-path segment). + const derivedKeys = [ + [derivedKeyQueryPrefix, 'encryption'], + [derivedKeyQueryPrefix, 'cashu-seed'], + [derivedKeyQueryPrefix, 'cashu-xpub', "m/0'"], + [derivedKeyQueryPrefix, 'cashu-private-key', "m/0'"], + [derivedKeyQueryPrefix, 'spark-mnemonic'], + ]; + for (const queryKey of derivedKeys) { + queryClient.setQueryData(queryKey, 'previous-user'); + } + // A non-derived query must survive the prefix eviction. + queryClient.setQueryData(['auth-state'], 'keep'); + + evictDerivedKeyQueries(queryClient); + + for (const queryKey of derivedKeys) { + expect(queryClient.getQueryData(queryKey)).toBeUndefined(); + } + expect(queryClient.getQueryData(['auth-state'])).toBe('keep'); + }); +}); + +describe('derived-key query defs', () => { + it('key every derived-key query under the shared prefix, so evictDerivedKeyQueries drops them', () => { + // Asserts the defs themselves adopt the prefix: a def regressing to a bare + // key — a silent cache-key mismatch typecheck cannot catch — fails here, not + // only in the grep gate. Covers the cashu-xpub derivation-path variant both + // with and without a path. + const queryClient = new QueryClient(); + const derivedKeyDefs = [ + encryptionQueryOptions().queryKey, + seedQueryOptions().queryKey, + xpubQueryOptions({ queryClient, derivationPath: "m/0'" }).queryKey, + xpubQueryOptions({ queryClient }).queryKey, + sparkMnemonicQueryOptions().queryKey, + ]; + for (const queryKey of derivedKeyDefs) { + expect(queryKey[0]).toBe(derivedKeyQueryPrefix); + } + }); +}); diff --git a/apps/web-wallet/app/features/shared/session-key-queries.ts b/apps/web-wallet/app/features/shared/session-key-queries.ts new file mode 100644 index 000000000..c78d2f31f --- /dev/null +++ b/apps/web-wallet/app/features/shared/session-key-queries.ts @@ -0,0 +1,19 @@ +import type { QueryClient } from '@tanstack/react-query'; + +// Shared head segment for every session-derived-key query — encryption, cashu +// seed/xpub/private-key, and spark mnemonic — which cache Open Secret derivations +// with an infinity stale time. Keying them all under this prefix lets one +// partial-prefix removeQueries drop them (and any future derived-key query) on an +// auth change, so a cross-user login can't read — or leave a consumer holding a +// revoked — the previous session's key material. The query defs import this, so +// the defs and the eviction share one source of truth. +export const derivedKeyQueryPrefix = 'derived-key'; + +/** + * Drops every cached derived-key query — a partial-prefix match on + * {@link derivedKeyQueryPrefix}, so the parameterized derivation-path variants go + * too — so the next session derives fresh. + */ +export const evictDerivedKeyQueries = (queryClient: QueryClient): void => { + queryClient.removeQueries({ queryKey: [derivedKeyQueryPrefix] }); +}; diff --git a/apps/web-wallet/app/features/shared/spark-query-options.ts b/apps/web-wallet/app/features/shared/spark-query-options.ts index 92b53f7dd..e5c63ba09 100644 --- a/apps/web-wallet/app/features/shared/spark-query-options.ts +++ b/apps/web-wallet/app/features/shared/spark-query-options.ts @@ -1,26 +1,10 @@ -import type { SparkNetwork } from '@agicash/wallet-sdk'; -import { - getSparkIdentityPublicKeyFromMnemonic, - getSparkMnemonic, -} from '@agicash/wallet-sdk/temporary'; -import { type QueryClient, queryOptions } from '@tanstack/react-query'; +import { getSparkMnemonic } from '@agicash/wallet-sdk/temporary'; +import { queryOptions } from '@tanstack/react-query'; +import { derivedKeyQueryPrefix } from './session-key-queries'; export const sparkMnemonicQueryOptions = () => queryOptions({ - queryKey: ['spark-mnemonic'], + queryKey: [derivedKeyQueryPrefix, 'spark-mnemonic'], queryFn: () => getSparkMnemonic(), staleTime: Number.POSITIVE_INFINITY, }); - -export const sparkIdentityPublicKeyQueryOptions = ({ - queryClient, - network, -}: { queryClient: QueryClient; network: SparkNetwork }) => - queryOptions({ - queryKey: ['spark-identity-public-key'], - queryFn: async () => - getSparkIdentityPublicKeyFromMnemonic( - await queryClient.fetchQuery(sparkMnemonicQueryOptions()), - network.toLowerCase() as 'mainnet' | 'regtest', - ), - }); diff --git a/apps/web-wallet/app/features/user/auth.ts b/apps/web-wallet/app/features/user/auth.ts index 493cdb603..7ab20e4fa 100644 --- a/apps/web-wallet/app/features/user/auth.ts +++ b/apps/web-wallet/app/features/user/auth.ts @@ -15,6 +15,7 @@ import { } from '~/features/shared/feature-flags'; import { getQueryClient } from '~/features/shared/query-client'; import { sdk } from '~/features/shared/sdk.client'; +import { evictDerivedKeyQueries } from '~/features/shared/session-key-queries'; import { useLatest } from '~/lib/use-latest'; import { oauthLoginSessionStorage } from './oauth-login-session-storage'; import { sessionHintCookie } from './session-hint-cookie'; @@ -64,9 +65,16 @@ export const authQueryOptions = () => 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. + // A session that established but whose provisioning threw is not an + // anonymous boot: the identity is authenticated, only provisioning as + // the settled user failed. Surface it to the error boundary rather than + // masking a half-provisioned session as logged-out. init()'s rejection + // is not memoized, so a later invalidateAuthQueries() retries. + if (sdk.auth.getSession().isLoggedIn) { + throw error; + } + // Restore genuinely failed with tokens present (e.g. a network blip at + // boot). Boot anonymous; the same un-memoized retry applies. console.error('Failed to initialize sdk', { cause: error }); Sentry.setUser(null); sessionHintCookie.clear(); @@ -119,6 +127,10 @@ export const authQueryOptions = () => */ export const invalidateAuthQueries = async () => { const queryClient = getQueryClient(); + // Auth changed ⇒ drop the previous user's infinity-stale derived keys so the + // next read re-derives under the new session. Every auth-change path funnels + // through here, so the eviction lives at this choke point, not each call site. + evictDerivedKeyQueries(queryClient); await Promise.all([ queryClient.invalidateQueries({ queryKey: [authStateQueryKey], diff --git a/apps/web-wallet/app/features/user/session-started.ts b/apps/web-wallet/app/features/user/session-started.ts new file mode 100644 index 000000000..a7e3e4407 --- /dev/null +++ b/apps/web-wallet/app/features/user/session-started.ts @@ -0,0 +1,20 @@ +import { AccountsCache } from '~/features/accounts/account-hooks'; +import { getQueryClient } from '~/features/shared/query-client'; +import { sdk } from '~/features/shared/sdk.client'; +import { UserCache } from '~/features/user/user-hooks'; + +/** + * Seeds the user and accounts caches from the SDK's `auth.session-started` event + * so the provisioned identity and its accounts are in cache before the + * protected tree reads them — the host no longer provisions or fetches them + * itself. Registered at boot, before the router, so the first protected + * middleware sees the seeded caches; replay-latest delivers the most recent + * payload even when `init()` established the session before this subscribed. + * @returns The unsubscribe function. + */ +export const registerSessionStarted = (): (() => void) => + sdk.events.on('auth.session-started', ({ user, accounts }) => { + const queryClient = getQueryClient(); + queryClient.setQueryData([UserCache.Key], user); + queryClient.setQueryData([AccountsCache.Key], accounts); + }); diff --git a/apps/web-wallet/app/features/user/user-hooks.tsx b/apps/web-wallet/app/features/user/user-hooks.tsx index 693d97601..5a4dafa7b 100644 --- a/apps/web-wallet/app/features/user/user-hooks.tsx +++ b/apps/web-wallet/app/features/user/user-hooks.tsx @@ -92,44 +92,6 @@ export const useUser = ( return data; }; -const isDevelopmentMode = import.meta.env.MODE === 'development'; - -export const defaultAccounts = [ - { - type: 'spark', - currency: 'BTC', - name: 'Bitcoin', - network: 'MAINNET', - isDefault: true, - purpose: 'transactional', - expiresAt: null, - }, - ...(isDevelopmentMode - ? ([ - { - type: 'cashu', - currency: 'BTC', - name: 'Testnut BTC', - mintUrl: 'https://testnut.cashu.space', - isTestMint: true, - isDefault: false, - purpose: 'transactional', - expiresAt: null, - }, - { - type: 'cashu', - currency: 'USD', - name: 'Testnut USD', - mintUrl: 'https://testnut.cashu.space', - isTestMint: true, - isDefault: true, - purpose: 'transactional', - expiresAt: null, - }, - ] as const) - : []), -] as const; - export const useUserRef = () => { const user = useUser(); return useLatest(user); @@ -247,8 +209,17 @@ export const useUpdateUsername = () => { export const useAcceptTerms = () => { const { mutateAsync } = useUserUpdatingMutation( - (params: { walletTerms?: boolean; giftCardTerms?: boolean }) => - sdk.user.acceptTerms(params), + (params: { walletTerms?: boolean; giftCardTerms?: boolean }) => { + // The acceptance timestamp is the moment of this click, recorded here + // rather than server-side, so it reflects when the user actually accepted. + const acceptedAt = new Date().toISOString(); + return sdk.user.acceptTerms({ + walletTermsAcceptedAt: params.walletTerms ? acceptedAt : undefined, + giftCardMintTermsAcceptedAt: params.giftCardTerms + ? acceptedAt + : undefined, + }); + }, ); return mutateAsync; 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 68d8af895..60db5fc59 100644 --- a/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx +++ b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx @@ -1,5 +1,6 @@ import { validateCashuToken } from '@agicash/cashu'; import type { Account, User } from '@agicash/wallet-sdk'; +import { isDefaultAccount } from '@agicash/wallet-sdk'; import { AccountRepository, AccountService, @@ -12,9 +13,7 @@ import { ReceiveCashuTokenService, SparkReceiveQuoteRepository, SparkReceiveQuoteService, - UserService, decodeCashuToken, - getEncryption, } from '@agicash/wallet-sdk/temporary'; import * as Sentry from '@sentry/react-router'; import type { QueryClient } from '@tanstack/react-query'; @@ -33,10 +32,7 @@ import { getCashuCryptography, seedQueryOptions, } from '~/features/shared/cashu-query-options'; -import { - encryptionPrivateKeyQueryOptions, - encryptionPublicKeyQueryOptions, -} from '~/features/shared/encryption-hooks'; +import { encryptionQueryOptions } 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'; @@ -49,14 +45,12 @@ import { ReceiveCashuTokenSkeleton } from './receive-cashu-token-skeleton'; const getServices = async () => { const queryClient = getQueryClient(); - const [encryptionPrivateKey, encryptionPublicKey] = await Promise.all([ - queryClient.ensureQueryData(encryptionPrivateKeyQueryOptions()), - queryClient.ensureQueryData(encryptionPublicKeyQueryOptions()), - ]); const getCashuWalletSeed = () => queryClient.fetchQuery(seedQueryOptions()); const getSparkWalletMnemonic = () => queryClient.fetchQuery(sparkMnemonicQueryOptions()); - const encryption = getEncryption(encryptionPrivateKey, encryptionPublicKey); + const encryption = await queryClient.ensureQueryData( + encryptionQueryOptions(), + ); const accountRepository = new AccountRepository( agicashDbClient, encryption, @@ -99,7 +93,7 @@ const getServices = async () => { (ticker) => getExchangeRate(queryClient, ticker), ); - return { claimCashuTokenService, accountRepository }; + return { claimCashuTokenService }; }; /** @@ -115,7 +109,7 @@ async function trySetReceiveAccountAsDefault( ): Promise { if ( account.currency === user.defaultCurrency && - UserService.isDefaultAccount(user, account) + isDefaultAccount(user, account) ) { return; } @@ -167,11 +161,9 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { if (claimTo) { const user = getUserFromCacheOrThrow(); - const { claimCashuTokenService, accountRepository } = await getServices(); + const { claimCashuTokenService } = await getServices(); const queryClient = getQueryClient(); - const accounts = await queryClient.fetchQuery( - accountsQueryOptions({ userId: user.id, accountRepository }), - ); + const accounts = await queryClient.fetchQuery(accountsQueryOptions()); const result = await claimCashuTokenService.claimToken( user, diff --git a/apps/web-wallet/app/routes/_protected.tsx b/apps/web-wallet/app/routes/_protected.tsx index 5fb4e3cb7..f77204980 100644 --- a/apps/web-wallet/app/routes/_protected.tsx +++ b/apps/web-wallet/app/routes/_protected.tsx @@ -1,47 +1,22 @@ -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, - UpsertUserRepository, - ensureBreezWasm, - getEncryption, -} from '@agicash/wallet-sdk/temporary'; -import type { QueryClient } from '@tanstack/react-query'; +import { shouldAcceptTerms } from '@agicash/wallet-sdk'; +import { ensureBreezWasm } from '@agicash/wallet-sdk/temporary'; import { Outlet, redirect } from 'react-router'; -import { core } from 'zod/mini'; -import { AccountsCache } from '~/features/accounts/account-hooks'; -import { agicashDbClient } from '~/features/agicash-db/database.client'; import { supabaseSessionTokenQuery } from '~/features/agicash-db/supabase-session'; import { LoadingScreen } from '~/features/loading/LoadingScreen'; -import { - seedQueryOptions as cashuSeedQueryOptions, - xpubQueryOptions, -} from '~/features/shared/cashu-query-options'; -import { - encryptionPrivateKeyQueryOptions, - encryptionPublicKeyQueryOptions, -} from '~/features/shared/encryption-hooks'; +import { seedQueryOptions } from '~/features/shared/cashu-query-options'; +import { encryptionQueryOptions } from '~/features/shared/encryption-hooks'; import { getQueryClient } from '~/features/shared/query-client'; -import { - sparkIdentityPublicKeyQueryOptions, - sparkMnemonicQueryOptions, -} from '~/features/shared/spark-query-options'; +import { sdk } from '~/features/shared/sdk.client'; +import { sparkMnemonicQueryOptions } from '~/features/shared/spark-query-options'; import { authQueryOptions, useAuthState } from '~/features/user/auth'; import { pendingGiftCardMintTermsStorage, pendingWalletTermsStorage, } from '~/features/user/pending-terms-storage'; import { requireSessionHintOrRedirect } from '~/features/user/require-session-hint.server'; -import { - UserCache, - defaultAccounts, - getUserFromCache, -} from '~/features/user/user-hooks'; +import { UserCache, getUserFromCache } from '~/features/user/user-hooks'; import { Wallet } from '~/features/wallet/wallet'; -import { breezApiKey } from '~/lib/breez'; -import { withRetry } from '~/lib/with-retry'; import type { Route } from './+types/_protected'; const shouldUserVerifyEmail = (user: AuthUser) => { @@ -62,95 +37,6 @@ const buildRedirectWithReturnUrl = ( return redirect(`${destinationRoute}${search}${hash}`); }; -const hasUserChanged = (user: User, authUser: AuthUser) => { - const currentAuthUserEmail = authUser.email ?? null; - const currentUserEmail = user.isGuest ? null : user.email; - - return ( - currentUserEmail !== currentAuthUserEmail || - user.emailVerified !== authUser.email_verified - ); -}; - -const ensureUserData = async ( - queryClient: QueryClient, - authUser: AuthUser, - termsAcceptedAt?: string, - giftCardMintTermsAcceptedAt?: string, -): Promise => { - let user = getUserFromCache(queryClient); - - if (!user) { - queryClient.prefetchQuery(supabaseSessionTokenQuery()); - } - - if (!user || hasUserChanged(user, authUser)) { - const [ - encryptionPrivateKey, - encryptionPublicKey, - cashuLockingXpub, - sparkIdentityPublicKey, - ] = await Promise.all([ - queryClient.ensureQueryData(encryptionPrivateKeyQueryOptions()), - queryClient.ensureQueryData(encryptionPublicKeyQueryOptions()), - queryClient.ensureQueryData( - xpubQueryOptions({ - queryClient, - derivationPath: BASE_CASHU_LOCKING_DERIVATION_PATH, - }), - ), - // TODO: how to handle this network? We specify the network on the account creation. - queryClient.ensureQueryData( - sparkIdentityPublicKeyQueryOptions({ queryClient, network: 'MAINNET' }), - ), - queryClient.ensureQueryData(sparkMnemonicQueryOptions()), - queryClient.ensureQueryData(cashuSeedQueryOptions()), - ]); - const encryption = getEncryption(encryptionPrivateKey, encryptionPublicKey); - const getCashuWalletSeed = () => - queryClient.fetchQuery(cashuSeedQueryOptions()); - const getSparkWalletMnemonic = () => - queryClient.fetchQuery(sparkMnemonicQueryOptions()); - const accountRepository = new AccountRepository( - agicashDbClient, - encryption, - getCashuWalletSeed, - getSparkWalletMnemonic, - { storageDir: './.spark-data', apiKey: breezApiKey }, - ); - const upsertUserRepository = new UpsertUserRepository( - agicashDbClient, - accountRepository, - ); - - const { user: upsertedUser, accounts } = await withRetry({ - fn: () => - upsertUserRepository.upsert({ - id: authUser.id, - email: authUser.email, - emailVerified: authUser.email_verified, - accounts: [...defaultAccounts], - cashuLockingXpub, - encryptionPublicKey, - sparkIdentityPublicKey, - termsAcceptedAt, - giftCardMintTermsAcceptedAt, - }), - retry: (attemptIndex, error) => { - if (error instanceof core.$ZodError) { - return false; - } - return attemptIndex < 2; - }, - }); - user = upsertedUser; - queryClient.setQueryData([UserCache.Key], user); - queryClient.setQueryData([AccountsCache.Key], accounts); - } - - return user; -}; - const routeGuardMiddleware: Route.ClientMiddlewareFunction = async ( { request }, next, @@ -192,6 +78,23 @@ const routeGuardMiddleware: Route.ClientMiddlewareFunction = async ( throw redirect(`/home${search}${hash}`); } + // TEMPORARY: these prefetches populate cache entries that receive/send/claim + // repositories not yet migrated into the SDK still read (session token, + // encryption, seed, spark mnemonic); each is deleted when its feature migrates + // into the SDK. ensureBreezWasm first: the spark mnemonic prefetch derives the + // Spark identity via defaultExternalSigner(), which requires WASM. Shared with + // entry.client.tsx so the init is typically already in-flight here. + await ensureBreezWasm(); + queryClient.prefetchQuery(supabaseSessionTokenQuery()); + await Promise.all([ + queryClient.ensureQueryData(encryptionQueryOptions()), + queryClient.ensureQueryData(sparkMnemonicQueryOptions()), + queryClient.ensureQueryData(seedQueryOptions()), + ]); + + // The provisioned user and accounts arrive via the SDK's auth.session-started + // event (seeded into cache at boot). Replay any terms accepted before this + // session existed, then gate on the result. const pendingTermsAcceptedAt = pendingWalletTermsStorage.get(); if (pendingTermsAcceptedAt) { pendingWalletTermsStorage.remove(); @@ -203,16 +106,14 @@ const routeGuardMiddleware: Route.ClientMiddlewareFunction = async ( pendingGiftCardMintTermsStorage.remove(); } - // ensureUserData derives the Spark identity public key via defaultExternalSigner(), - // which requires WASM to be initialized. Shared with entry.client.tsx so the init - // is typically already in-flight (or complete) by the time we await here. - await ensureBreezWasm(); - const user = await ensureUserData( - queryClient, - authUser, - pendingTermsAcceptedAt, - pendingGiftCardMintTermsAcceptedAt, - ); + let user = getUserFromCache(queryClient) ?? (await sdk.user.get()); + if (pendingTermsAcceptedAt || pendingGiftCardMintTermsAcceptedAt) { + user = await sdk.user.acceptTerms({ + walletTermsAcceptedAt: pendingTermsAcceptedAt, + giftCardMintTermsAcceptedAt: pendingGiftCardMintTermsAcceptedAt, + }); + queryClient.setQueryData([UserCache.Key], user); + } const shouldRedirectToAcceptTerms = shouldAcceptTerms(user) && !isAcceptTermsRoute; diff --git a/bun.lock b/bun.lock index b16b90afe..d6b8f0a61 100644 --- a/bun.lock +++ b/bun.lock @@ -103,6 +103,7 @@ "name": "web-wallet-e2e", "devDependencies": { "@agicash/opensecret": "catalog:", + "@agicash/utils": "workspace:*", "@playwright/test": "1.49.1", "@stablelib/base64": "catalog:", "@stablelib/chacha20poly1305": "catalog:", diff --git a/docs/superpowers/plans/2026-07-13-wallet-sdk-accounts-slice.md b/docs/superpowers/plans/2026-07-13-wallet-sdk-accounts-slice.md new file mode 100644 index 000000000..1b4ed3950 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-wallet-sdk-accounts-slice.md @@ -0,0 +1,203 @@ +# Wallet SDK Accounts Slice (Step 6) Implementation Plan + +Sources of truth: + +- `docs/superpowers/specs/2026-06-24-wallet-sdk-no-cache-production-design.md` — step 6: wrap the accounts domain in `AccountsApi` + flip the web's accounts imports off `/temporary`. +- `docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md` — `AccountsApi` shape, projections, migration mapping. +- `docs/superpowers/plans/2026-07-09-wallet-sdk-auth-slice.md` — step-5 decisions (A1–A13) and its Deferred list, three items of which land here. +- Branch base: `sdk/accounts-slice` off `sdk/auth-slice` @937e23a9 (step 5, unmerged). Behavior baseline for parity is `master`. + +## Global Constraints + +1. **Behavior parity with `master`.** Where the port surfaces something improvable, port the master behavior as-is and flag the spot in the Decision Record for a now-vs-defer call. Nothing gets silently "fixed". +2. Web keeps TanStack Query + its realtime layer until step 18. The SDK owns no cache. +3. `userId` never crosses the public surface (contract decision #3); namespaces close over the session. +4. **Projection discipline per the B1 ruling:** the public types strip `wallet`/`proofs`/`keysetCounters` at the *type level* now; at runtime the objects stay fat during migration (hidden fields ride along) and the strip becomes physical at step 18. Hidden fields are reachable only through the sanctioned unwrap sites (B1.3); anywhere else is banned and grep-enforced. +5. One instance per process; namespace factories close over the SDK's own db client + key getters (step-5 `AgicashSdk` pattern). +6. **Single mapper choke point:** objects enter the `['accounts']` cache only through the shared domain→projection mapper (computes `balance`, keeps hidden fields). Cashu `balance` must be recomputed wherever proofs change — one entry point or it drifts. + +## Decision Record + +### B1 — Projection-typed cache over a runtime-fat migration representation (RESOLVED, maintainer 2026-07-13) + +Ruling: `sdk.accounts.*` does **not** sit test-only until step 18 — each slice moves real consumers toward end-state; step 18 keeps only what truly depends on it. + +1. **The `['accounts']` cache holds projection-typed objects, fetched via `sdk.accounts.list()`** — a production consumer today. At runtime the objects stay fat during migration: domain fields ride along hidden, plus computed `balance` attached. The strip is type-level until step 18, when it becomes physical and this arrangement ends. +2. **One shared mapper** (attach `balance`, keep hidden fields) is the only way objects enter the cache — queryFn, realtime row mapping, `add` onSuccess, `ensure` seed, claim upserts. Never hand-built elsewhere. +3. **`/temporary` bridge shrinks to:** the internal-repo accessor (unmigrated receive/send repo constructors + realtime row mapping) and a **`toDomainAccount()` checked cast** (asserts hidden fields present). The shared mapper is also `/temporary`-exported for the web-side cache-entry paths (realtime) until step 18. All domain access routes through the existing getter hooks (`useGetCashuAccount`, spark listeners, selector→store handoffs), which unwrap internally; touching hidden fields anywhere else is banned (self-review grep). +4. **Display consumers** read projections straight off the cache (`.balance` etc.). Root `Account` types flip to projections — the index.ts domain shadow for accounts types is deleted; code needing domain types imports them from `/temporary`. + +Invariant (stands): the bridge and `sdk.accounts.*` are two faces of **one** instance-internal repository — a single data path with a dual type-surface. At no point do two sources of truth exist. + +### B2 — `ensureUserData` → `sdk.user.ensure()` (RESOLVED, maintainer 2026-07-13) + +Ruling: `sdk.user.ensure(params): Promise<{ user: User; accounts: Account[] }>` — accounts projection-typed, mapped through the same shared mapper; the web seeds its cache from the **public return**. No bridge involvement in the seed; the signature is already the end-state one. User-side home confirmed. The timestamp params are **intentional** (replayed acceptance times from pending-terms storage) — kept, documented in JSDoc. Both sub-calls below are closed by this ruling; retained as the rationale trail. + +Master (`_protected.tsx:75–152`): derives 4 keys + warms seed/mnemonic, constructs `AccountRepository` + `WriteUserRepository`, calls `writeUserRepository.upsert({...authUser fields, accounts: defaultAccounts, ...pubkeys, terms}, accountRepository)` with Zod-aware retry, seeds **both** the user cache and the accounts cache from the returned `{ user, accounts }`. + +Key derivation, repositories, retry and the default-accounts constant move SDK-internal. The web middleware keeps: pending-terms storage reads, change-detection short-circuit semantics (the SDK upserts on every call — the host owns caching and call frequency), redirect logic, cache seeding. + +`ensureUserData` is cross-domain (user row + default accounts in one operation) — A1 parked it here because of the `AccountRepository` dependency; the verb reads user-side (`sdk.user.ensure`, recommended). + +**Open sub-call 1 — return shape:** under B1 the seed no longer needs the bridge: propose `ensure(params): Promise<{ user: User; accounts: Account[] }>` with accounts projection-typed (runtime-fat, through the shared mapper) — the web seeds both caches exactly as master does, zero extra fetch, contract-legal now. Alternative: `Promise` + a follow-up `sdk.accounts.list()` seed (+1 round-trip cold login). + +**Open sub-call 2 — terms param shape:** `EnsureUserParams = { termsAcceptedAt?, giftCardMintTermsAcceptedAt? }` carries *timestamps* (replayed from the web's pending-terms storage) while the existing `acceptTerms` takes *booleans* and stamps the time internally — two terms verbs, two philosophies on one `UserApi`. Intentional (bootstrap replays stored acceptance times) or harmonize? Maintainer call. + +### B3 — `AddCashuAccountParams` (RESOLVED — verified exact against the domain signature) + +```ts +export type AddCashuAccountParams = { + name: string; + mintUrl: string; + currency: Currency; + purpose: AccountPurpose; +}; +``` + +`type` implied by the rail-nested method; `userId` session-implicit — the `cashu.add` mapper re-injects both before the service call. Return `Promise` (projection-typed; fresh account `balance` = zero from empty proofs). + +### B4 — `useAddCashuAccount` flips now (RESOLVED by B1) + +The mutation calls `sdk.accounts.cashu.add()`; the projection-typed return is runtime-fat, so `onSuccess` upserts it into the cache through the shared mapper — master's immediate-availability semantics preserved, no extra read. (The cache's `version`-guarded upsert works unchanged: `version` is a public projection field.) + +### B5 — Statics and balance reads (updated per B1) + +- `UserService.getExtendedAccounts` / `isDefaultAccount` → root exports, re-typed over the **projection** types (pure fns over public fields — id/currency/default ids; no hidden-field access). `useAccounts` flips its import; root `ExtendedAccount` flips with the root-type flip (B1.4). +- Balance reads off the cache (`useBalance`, tiles, selectors) read the mapper-computed `.balance` field directly — same values and the same freshness as master's render-time `getAccountBalance` (both derive from the proofs of the object that last entered the cache). +- `getAccountBalance` is `/temporary`-exported, typed over *domain* accounts (a root export would re-leak domain types through its signature after the root-type flip); the mapper uses it SDK-internally. +- `ReadUserRepository.toUser` + realtime row mapping stay `/temporary` → step 18 (step-5 Deferred, unchanged). + +### B6 — `sparkDebugLog` inside the web's `AccountsCache` (RESOLVED, maintainer 2026-07-13) + +Ruling: neither root-export nor drop — the `/temporary` import **stays as a named exception**, same as the other spark consumers; it dies at step 18 with its call site. `/temporary` exists exactly for this. (`updateSparkAccountBalance` is an in-place field update on an existing cache entry — not a cache-entry path, so not mapper-gated.) + +### B7 — WASM posture (RESOLVED, maintainer 2026-07-13: master parity confirmed, good to go) + +The fat cache must carry live spark `wallet` handles during migration (unmigrated flows unwrap and use them), so `sdk.accounts.list()`/`get()` **do** construct wallets on the fetch path until step 18 — the earlier "reads never touch WASM" gate cannot hold during migration; it becomes the **step-18 end-state property** (physical strip ⇒ no wallet construction on reads). + +Migration-time acceptance instead = **master WASM-posture parity, byte-for-byte**: the web's `ensureBreezWasm` guards stay exactly where master has them (entry + `_protected` middleware before any accounts fetch), and `list()`/`get()`/`toAccount` preserve master's behavior under WASM-unavailable — including the offline/stub wallet path (`domain` spark accounts carry a throwing stub when not online). Build-time verify: what master's protected surface actually does under iOS Lockdown (stub-and-degrade vs throw) — `list()` must match it exactly, whatever it is. A3's login-page concern is untouched: auth surfaces fetch no accounts. + +### B8 — Cache layering (maintainer-endorsed) + +The web caches what the user sees; the SDK holds what the session owns. Domain objects (accounts, user, transactions) live in the web query cache because their consumers are UX. Session plumbing (key derivations, wallet handles, connections) lives in SDK-internal memos because its lifetime is the session's. Test for placement: does a component render it, or does the session own it? + +Renames (2026-07-20, per #1167 review): `sdk.user.ensure` → `sdk.user.provision`, `session-keys.ts` → `lib/session-keys.ts`, `live*` instance accessors → `current*`. Reading rule: mentions of `ensure`/`ensureUserData`/`EnsureUserParams` elsewhere in this document are the historical spelling at decision time, kept as the rationale trail; the shipped API is `sdk.user.provision(params: ProvisionUserParams)`. + +## Accepted behavior deltas (candidates — settle with the remaining rulings) + +1. **Reality-class record — `sdk.accounts.*` is TYPE-honest / RUNTIME-fat until step 18** (B1 ruling: "the strip is type-level until step 18"). Public types understate the runtime objects during migration — intended, not incidental. Holds under three conditions, all tracked: (i) **web-internal consumers only** — no external/untrusted host consumes `sdk.accounts.*` before the physical strip, so the fat is reachable only by code already holding the proofs (to confirm with the maintainer alongside the open B points); (ii) time-boxed to step 18, where the strip becomes physical and this record closes; (iii) nobody claims runtime projection-honesty for accounts returns meanwhile. Contained by the mapper choke point, the checked-cast unwrap, and the hidden-fields grep. +2. **`balance` becomes a cache-entry-computed field** read by display consumers (B5): equal values/freshness to master's render-time compute; listed because the mechanism changes. +3. SDK-internal key getters memoize per session **generation-fenced** (cleared in `onSessionEnded` alongside the existing spark-wallet/mint-CAT clears) — same effective lifetime as master's infinity-stale TanStack entries dying with `queryClient.clear()` on sign-out; listed because the mechanism changes. +4. **Transitional key double-derivation** (review round): the SDK's session-keys memos and the web's key query entries both derive the same keys on cold login, concurrently — total latency unchanged, extra enclave calls until the consuming domains migrate (steps 8–16). The middleware warms the five web entries the unmigrated receive/send/claim repos still read (master's warm-cache and fail-in-middleware properties preserved; the sixth, spark-identity, was dropped with its dead query options — no readers remain). `ensure()`'s internal derivation batch runs under `withRetry`, standing in for the host query layer's transient-failure retries that served master. Partial-failure corner: a warm can fail after the concurrent idempotent upsert completed (master's sequential order prevented the upsert) — the middleware still rejects and nothing is seeded; the persisted pending-terms replay is the favorable direction vs. master's loss of the popped timestamps. + +## Deferred (tracked, out of scope) + +- **Physical projection strip + bridge/mapper deletion → step 18/19** (B1.1: "this whole arrangement ends"); the never-touch-WASM read property lands there too (B7). **Strip precondition:** every unwrap site (the getter hooks over `toDomainAccount()`) must already read `wallet`/`proofs` from the SDK instead of the cache *before* the strip lands — once the mapper stops carrying hidden fields, `toDomainAccount()` can no longer unwrap. Cheap to carry now, expensive to discover at 18. +- `useAccountChangeHandlers`' row mapping (`toAccount` + `AgicashDbAccountWithProofs` row types) stays bridge-served → step 18; its cache writes go through the shared mapper now. +- Web `encryption-hooks.ts` / `cashu-hooks.ts` / `spark-query-options.ts` remain for the *unmigrated* domains (transactions/receive/send construct their own repos until steps 8–16); accounts stops consuming them. +- `sdk.accounts.spark.add` — no addable spark rail today; contract already reserves the shape. +- WASM into `init()` → first Spark slice (A3, restated). +- SdkError wrapping for repo-thrown errors → step 17/19 (step-5 deferred, unchanged by this slice). +- Root types of **unmigrated** domains still embed domain accounts, wallet handles and proof material (`GetLightningQuoteParams`, `CreateQuoteBaseParams`, `ReceiveCashuTokenAccount`/`CashuAccountWithTokenFlags`, `CrossAccountReceiveQuotesResult`, the `Transfer*` types, quote/swap `proofs` fields) — baseline-inherited; they flip with their slices (steps 8–16). The step-18 strip must re-audit the whole root surface rather than trust the accounts-only cleanup above. + +## Scope map (import-by-import) + +| Today (`/temporary` or web-local) | After step 6 | +| --- | --- | +| `account-repository-hooks.ts` (web builds `AccountRepository` + encryption/seed getters) | **relocated to `features/receive/`** — its only consumers are the unmigrated receive repos, which construct synchronously (gate record); the accounts feature no longer imports it | +| `account-service-hooks.ts` (web builds `AccountService`) | **deleted** — service inside the SDK behind `accounts.cashu.add` | +| `accountsQueryOptions` queryFn `repository.getAllActive` | `sdk.accounts.list()` (projection-typed, mapper-fed); query key/staleTime/structuralSharing unchanged | +| `useAccountOrNull` lazy `repository.get` | `sdk.accounts.get(id)` + mapper-gated upsert | +| `useAddCashuAccount` → `service.addCashuAccount` | `sdk.accounts.cashu.add()` (B4) | +| `ensureUserData` in `_protected.tsx` | `sdk.user.ensure()` (B2) | +| Realtime `ACCOUNT_CREATED/UPDATED` → `repository.toAccount` → upsert | bridge repo accessor → `toAccount` → **shared mapper** → upsert | +| Root `Account`/`CashuAccount`/`SparkAccount`/`ExtendedAccount` = domain (index.ts shadow) | shadow **deleted** — root types are the projections; domain-type importers flip to `/temporary` | +| `useAccounts` → `UserService.getExtendedAccounts` | root export re-typed over projections (B5) | +| `useBalance`/display → `getAccountBalance(account)` | `.balance` off the cache (B5); `getAccountBalance` is `/temporary` for domain contexts | +| Money flows reading `wallet`/`proofs` off the cache | unchanged call sites via getter hooks, which unwrap through `/temporary` `toDomainAccount()` internally | +| `AccountsCache.updateSparkAccountBalance` → `sparkDebugLog` | unchanged — named `/temporary` exception, dies at 18 with its call site (B6) | +| `sdk/accounts.ts` `AddCashuAccountParams = unknown` | pinned (B3, resolved) | + +## Task outline + +1. **SDK-internal key plumbing** — encryption keypair (m/10111099'/0'), cashu seed, spark mnemonic, cashu locking xpub, spark identity pubkey: memoized getters over `@agicash/opensecret`, generation-fenced, cleared in `onSessionEnded`. +2. **Shared mapper + `createAccountsApi`** — the domain→projection mapper (attach `balance` via `getAccountBalance`, keep hidden fields; fresh types — domain `RedactedAccount` strips only `proofs` and must not be reused); factory wraps repository/service; `cashu.add` re-injects `type`+`userId`; wired into `AgicashSdk` (`Pick` grows `'accounts'`); `AddCashuAccountParams` lands in `sdk/accounts.ts`. +3. **`/temporary` bridge v2** — internal-repo accessor; **`toDomainAccount()` checked cast — the integrity linchpin of the fat-cache arrangement**: genuinely asserts the runtime object carries the hidden domain fields and throws a loud typed error naming the missing fields when handed a thin object. Never a bare `as`-cast — that would let a mapper bug flow a thin object into a money path expecting `.wallet`/`.proofs` and explode past the type checker. Mapper re-export; step-18 removal note on all three. +4. **`sdk.user.ensure()`** — port `ensureUserData` internals verbatim (keys, repos, Zod-aware retry, default-accounts constant; change detection stays host-side — `ensure()` upserts on every call, no SDK memo); return shape per B2 sub-call 1; web `_protected.tsx` flip with cache seeding through the mapper. +5. **Web flip sweep** — scope map rows: queryFn/lazy-get/add/realtime re-source; delete the two hook files; root shadow deletion + flip web domain-type importers to `/temporary`; display consumers to `.balance`; unwrap sites route through `toDomainAccount()` at the getter hooks and the named picker/route seams (gate record). **Glue-parity notes:** `_protected.tsx` keeps master's own structure around `sdk.user.ensure()` — the `getUserFromCache` + `hasUserChanged` short-circuit and the **conditional** cache seeding (seed only on the upsert branch; unconditional seeding would clobber a realtime-fresher cache with the ensure-returned bootstrap snapshot on route remounts) and the `!user → prefetchQuery(supabaseSessionTokenQuery())` warm-up; master's key warms run on the same branch, concurrent with `ensure()` (delta 4); `ensureBreezWasm` calls stay where master has them (B7). +6. **PR declarations** (declared questions, not changes): `accounts.get(id)` is not session-gated (master's repository posture — RLS scopes it; `list()`/`add` gate on the session for `userId`) while `user.*` gates every verb — parity kept, consistency question declared; `withRetry`/`delay` are ported into the SDK lib while the web copies remain for unmigrated consumers (transitional duplication, dies with their slices). +6. **Tests** — projections complete (type-level: no `wallet`/`proofs`/`keysetCounters` on public types; runtime: hidden fields present + `balance` correct through every SDK mapper-fed path, incl. `ensure`'s account mapping); checked-cast failure on a stripped object; params; ensure retry/no-memo; key-getter fencing across session end. Web-side cache-entry paths (realtime, claim, seed) are compile-enforced and grep-audited, not unit-run — the web suite has no accounts harness (master parity). WASM-posture parity is structural, not a test: the guards sit at master's exact sites and reads reuse the unchanged repository (B7). +7. **Verification** — `bun run fix:all` + `bun run typecheck` (workspace incl. web), unit suite, production build; **hidden-fields grep** (no `.proofs`/`.wallet`/`.keysetCounters` outside sanctioned unwrap sites + `/temporary` importers); browser smoke: cold login (user+accounts bootstrap), add mint, accounts settings pages, default-account switch, balances render, send/receive still work off the cache (unwrap path intact). +8. **PR** — base `master` after step 5 merges (two-green-PRs rule: rebase + re-verify against merged master pre-merge); title `feat(wallet-sdk): accounts slice (step 6)`. + +## Gate record (2026-07-13, keeper-verified) + +Built state = commits `58742fca`/`b614b9a2`/`3c8c5b60`/`8a1e1125` (SDK, tasks 1–4) + `2da05af0`/`ccffcdba`/`c204b96b` (task 5). Full chain re-run on the frozen tree by the gate, not taken from the builder: `fix:all` clean, workspace typecheck green, tests exit 0 (wallet-sdk 96, web 36, money 14, bolt11 9, ecies 18, cashu 35). + +- **Selector boundary as built:** `account-selector` renders DOMAIN accounts via `getAccountBalance` (master verbatim) and the picker option-build sites unwrap via `toDomainAccount()` — a sanctioned B1.3 "selector→store handoff". Display hooks (`useAccounts`/`useAccount`/`useAccountOrNull`) stay projection-typed with `.balance`; the getter hooks and `useDefaultAccount`/`useAccountOrDefault` unwrap internally and return domain for the money flows. Hidden-fields grep clean; every `.wallet`/`.proofs` read passes `toDomainAccount()` at a named seam (seam list in the PR body). +- **Declared deviation:** `account-repository-hooks` is relocated to `features/receive/` rather than deleted — its only remaining consumers are the unmigrated receive repos, which construct `AccountRepository` synchronously (the bridge accessor is async; reshaping them is step 8–16 scope). `account-service-hooks` is deleted. The accounts feature no longer imports either. +- **Browser smoke scope (env-limited on the build box):** verified on the exact head — app boots, landing + signup render with zero console/page errors, guest flow drives to Terms of Service and initiates enclave registration. Registration itself is blocked by the dev enclave's server-side 403 for this box's origin (environmental; master fails identically here) — post-auth surfaces (wallet home, add mint, balances) were NOT browser-verified on this box and want a maintainer-env smoke at review. Compensating evidence: the ensure/mapper/projection/fencing paths are unit-covered (25 new tests), and the full suite is green. + +## Review round (2026-07-14, lead-verified) + +Four clean-context reviewers (general ×2 — fable + opus, master-parity, plan-compliance/boundary) over `origin/sdk/auth-slice...HEAD`. Fencing, mapper choke point, checked-cast discipline, hidden-fields grep, payload/retry parity independently confirmed. Findings, fixed on top of the gate tree: + +- **Key-warm parity restored** (`_protected.tsx`): the flip dropped master's middleware `ensureQueryData` warms for the web-side key entries, so unmigrated receive/send/claim consumers cold-derived at first Wallet render — duplicate enclave calls serialized after `ensure()`, a new mount suspense, and the failure surface moved out of the middleware. Restored on the upsert branch, concurrent with `ensure()`; recorded as delta 4. +- **`ensure()` derivation retry** (`user-api.ts`): the key batch runs under `withRetry` — master derived through the host query layer, which retried transient failures; single-shot was a silent resilience loss on the login path. The memoized getters make each retry re-fetch only the keys that failed. +- **Dead code dropped**: the web `defaultAccounts` copy (`user-hooks.tsx` — B2 moved the constant SDK-internal; the leftover export invited silent drift), `sparkIdentityPublicKeyQueryOptions` (`spark-query-options.ts` — its cache entry lost its only reader in the flip), and the `RedactedAccount`/`RedactedCashuAccount` root re-exports (`index.ts` — the last **accounts-domain** root types referencing wallet handles; both types stay SDK-internal, zero web importers). +- **`ensure` mapping test**: `createRepository` test seam on `createUserApi` (mirroring `accounts-api`); a new test drives a returned account row through `toAccount` → shared mapper and asserts the projection is runtime-fat with the computed balance (previously only exercised with `accounts: []`). +- **Doc drift amended in place**: B2/tasks 4–6 no longer describe the SDK ensure-memo dropped in `0eda60b5` (the host owns caching); B5 and the scope map match the `/temporary`-only `getAccountBalance`; scope-map row 1 records the relocation the gate declared; task 6 states the test scope honestly (web-side entry paths compile-enforced + grep-audited, WASM parity structural). +- **Recorded, not changed**: realtime handlers resolve the bridge accessor per event, so an event landing in the dev-HMR dispose→create window rejects instead of mapping (master captured the repository at hook mount). Kept as-is — per-event resolution retries transient failures that a mount-captured promise would latch until remount; the window is dev-only and self-corrects on reload. + +Chain re-run on this tree: `fix:all` clean; tests exit 0 — wallet-sdk 96 (+2 todo), web 36, money 14, bolt11 9, ecies 18, cashu 35. Supersedes the counts above — the prior gate attested the tree before `0eda60b5` (which landed at 95) and before this round's additions. + +**Round 2 (fresh opus general + fix-verification):** all round-1 fixes verified — warm set exact with per-entry reader evidence, retry non-duplicating over the memos, seam test-only and unreachable from the exports map, deletions reference-free repo-wide, SDK↔web derivation byte-identical, gate counts reconciled statically. One claim-accuracy finding, amended above: the `Redacted*` removal cleaned the last **accounts-domain** root types, not the whole root — unmigrated-domain root types still carry wallet handles/proofs (new Deferred bullet; step 18 re-audits the full root surface). Also added: delta-4 partial-failure clause and a transient key-derivation retry test. Chain re-run: `fix:all` clean; wallet-sdk 97 (+2 todo), others unchanged. + +## Self-Review Checklist + +1. Spec coverage: `AccountsApi` methods wrapped and web-consumed (B1 ruling); web accounts imports flipped per scope map; step-5 deferred items A1/getEncryption landed here or explicitly re-deferred with reason. +2. Parity scan: every master behavior preserved or listed under Accepted behavior deltas. +3. Projection discipline: public types carry no hidden fields; hidden-fields grep clean; every cache write goes through the shared mapper; `toDomainAccount()` is the only cast site. +4. Key-fencing: sign-out → sign-in as a different user cannot serve the first user's keys/seeds from any SDK memo. +5. `/temporary` surface: only the declared bridge v2 (repo accessor, checked cast, mapper), each carrying a step-18 removal note. +6. WASM posture: master parity verified per B7's build-time check, not assumed. + +## Supersession — accounts contract exposes domain types (2026-07-17, maintainer) + +Everything above records the slice as first built, against a projection-typed +`AccountsApi`. A maintainer ruling on the contract itself (commit `d5d2f18a`, +PR #1166) changed that shape after the slice was written, and the slice was +reconstructed on the new base to match it. The past entries stand as the +decision trail; this section states what is now true where it diverges. + +- **B1 projection apparatus — superseded.** `sdk.accounts.*` returns the domain + account entities directly (`Account`/`CashuAccount`/`SparkAccount`, re-exported + by the contract), not projection types. There is no shared domain→projection + mapper, no `toAccountProjection`, and no `toDomainAccount` checked cast; the + `['accounts']` cache holds domain accounts exactly as master did, and the + single-data-path invariant now means one repository behind both the namespace + and the bridge, with one type-surface rather than two. `/temporary` carries the + internal-repository accessor and the session-keys accessor only. The root + `Account` family stays the domain entities (the explicit `index.ts` account-type + exports are kept, not deleted), and web domain-type importers stay on the root + package — no consumer moved to `/temporary` for account types. +- **B5 balance reads — revert to `getAccountBalance`.** Cashu accounts carry no + `balance` field; display consumers read `getAccountBalance(account)` exactly as + master does, not a mapper-computed `.balance`. `getAccountBalance` stays a + `/temporary` export. `getExtendedAccounts`/`isDefaultAccount` are still extracted + to standalone root exports, now typed over the domain accounts with the plain + (non-generic) signature. +- **B3 pin — unchanged.** `AddCashuAccountParams = { name; mintUrl; currency; + purpose }` stands as ruled. +- **B2 ensure — same shape, simpler body.** `sdk.user.ensure()` still returns + `{ user; accounts }`; the accounts come straight from the base + `UpsertUserRepository`, which is already domain-typed, so there is no projection + mapping on the return. `withRetry`/`delay` moved into `@agicash/utils` (their web + copies deleted) so the SDK can share the retry helper. +- **Reality-class record and the physical projection strip — retired as moot.** + Accepted-delta 1 ("TYPE-honest / RUNTIME-fat until step 18") and the step-18 + physical strip in the Deferred list no longer apply: there is no projection to + strip, so accounts returns are runtime-honest today. The `/temporary` bridge and + the delegating web key queries still retire at step 18 with their unmigrated + consumers, and the transitional key double-derivation (delta 4) still holds + until then. diff --git a/docs/superpowers/plans/2026-07-21-provision-internalization.md b/docs/superpowers/plans/2026-07-21-provision-internalization.md new file mode 100644 index 000000000..263cc8029 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-provision-internalization.md @@ -0,0 +1,172 @@ +# Provision internalization — design decisions + +Direction agreed by both maintainers (Discord thread, 7/21). No code here — +this settles the open decisions so the build round can start. Each decision +lists the options and our recommendation; rule on them like the B-rounds. + +Grounding: head `da633cab` plus the session-lifecycle hardening PR (this doc +**assumes that PR lands first** — the transition-suspension in D5 builds +directly on the session signal/dispose machinery it adds). File:line refs are +on that tree. + +## Settled (constraints — not re-opened here) + +- Provision moves fully **SDK-internal**, fired on the SDK auth lifecycle: + `signUp`, `signInGuest`, `signIn`, `restore`, and mid-session identity change. +- Guarded by an **identity fingerprint** (`userId` + `email` + `emailVerified`) + vs the last-provisioned state — re-provisions only when it changes. +- Seed data reaches the host via a **`session.established` event** carrying + `{ user, accounts }`; the host re-seeds its caches by plain reads. +- **Terms decouple from provision**: the pending-terms `sessionStorage` stays + web-side and is replayed post-auth via `acceptTerms`. +- The web provision gate is deleted: `hasUserChanged` + (`apps/web-wallet/app/routes/_protected.tsx:42`), the provisioning path in + `ensureUserData` (`:64-82`), and its terms plumbing. + +## Current shapes (what changes) + +- Host-side gate today: `_protected.tsx:52-85` `ensureUserData` calls + `sdk.user.provision(...)` (`:71`) whenever there's no cached user or + `hasUserChanged` (`:64`), then seeds `UserCache`/`AccountsCache` (`:80-81`); + pending terms read at `:128-134`. +- SDK provision today: `packages/wallet-sdk/domain/user/user-api.ts:104-180` + (host-called; takes `termsAcceptedAt`/`giftCardMintTermsAcceptedAt`). +- Event carrier: `packages/wallet-sdk/domain/sdk/events.ts` — `WalletEventEmitter` + dispatches to *current* handlers only (`:103-120`), **no replay**; `on()` is + callable with no session and returns unsubscribe (`:74-82`); adding an event is + non-breaking (`:16-21`). +- Auth lifecycle: `auth-service.ts:200-203` (`signIn` awaits `os.signIn` then + `refreshSessionSnapshot`); `:295-315` (`applySessionFromServer`: `fetchUser` → + different-user `onSessionEnded` `:308-311` → `startNewSessionScope` → set + session); `init()` → `restoreSession` (`sdk.ts:161-163`). + +--- + +## D1 — `session.established` event shape + +**Decision:** payload, and how a host that subscribes *after* the initial +establish still gets it. + +The emitter has no replay (`events.ts:103-120`): an event fired during `init()` +is lost to a host that subscribes after `init()` resolves (the common React +case — the tree mounts after boot). + +- **(a) emit-only** — host must `on(...)` before `init()`. Cheap, but a fragile + ordering contract; a late subscriber silently gets no seed. +- **(b) replay-latest (recommended)** — the emitter retains the last + `session.established` payload and replays it to a late subscriber (only this + event, not the whole bus). Robust regardless of subscribe/`init()` order. +- **(c) read-after-init** — no event for the initial seed; host reads + `{user, accounts}` via plain getters once `init()` resolves, event only for + mid-session change. Simple but splits initial vs mid-session into two host + paths. + +**Recommendation: (b).** Payload `{ user: User; accounts: Account[] }`. Fires on +every establish (initial + mid-session identity change), after the snapshot + +internal provision complete; `init()` resolves after the initial establish. +Replay-latest keeps one host path and removes the subscribe-before-`init()` +footgun; it's a small targeted retention on one event, not general buffering. + +## D2 — terms replay (petar's ruling pending — both presented) + +`provision` stops taking terms; the web replays pending acceptance via +`acceptTerms` after auth. + +- **(a) `acceptTerms({ acceptedAt? })` (recommended)** — web passes the timestamp + captured when the user actually accepted (pre-auth). Preserves the true ToS + acceptance time. +- **(b) stamp-at-replay** — `acceptTerms` stamps `now()` at replay. Simpler, but + records a time later than the real acceptance (drift across the auth round-trip + and any retry), which matters for a ToS audit trail. + +**Recommendation: (a)** — the acceptance time is a legal/audit fact; record when +the user clicked, not when the SDK got around to persisting it. (petar to rule.) + +## D3 — provision-failure surface during `signIn`/`restore` + +Provision now runs *inside* the auth verb. Auth can succeed while provision +fails (DB error, derivation) — the user is authenticated but unseeded. + +- **(a) reject the auth verb** — `signIn`/`init` reject on provision failure. + Conflates "login failed" with "seeding failed"; a genuinely logged-in user + looks logged-out. +- **(b) `session.established` carries the outcome (recommended)** — discriminated + payload `{ user, accounts } | { user, error }`; auth resolves successfully, + the host learns provision failed from the event and can offer retry (re-fire + provision) without re-authenticating. +- **(c) separate `provision.failed` event** — extra event; host must correlate it + with the establish. + +**Recommendation: (b)** — separates auth success from seed success, matching +reality. `init()` still resolves (restore succeeded); the event reports the seed +result. Provision already self-retries transient failures +(`user-api.ts:147-172`), so the error surfaced here is the terminal one. + +## D4 — fingerprint persistence + +- **(a) in-memory per instance (recommended)** — reset on cold boot; provision + re-fires on the first auth after boot. That's exactly today's semantics + (`ensureUserData:60-64` provisions whenever there's no cached user, i.e. every + cold load) and provision is an idempotent upsert, so the re-fire is cheap and + safe. +- **(b) durable (localStorage)** — survives boot, skips the boot-time upsert. + Adds a persistence surface and a staleness/tampering risk for the gain of one + idempotent upsert per cold boot. + +**Recommendation: (a)** — matches cold-boot semantics, no new persisted state. + +## D5 — ★ auth-transition ordering (the foundation) + +Internal provision runs *during* sign-in, which turns a cross-user window our +session-lifecycle review found (details and reproduction in the accompanying +review notes) from an optional fix into a **required foundation** for this +round. + +Today `signIn` awaits `os.signIn(B)` (`auth-service.ts:200-203`), and Open Secret +writes B's tokens before returning; the different-user cleanup +(`onSessionEnded` → `keys.reset()`) only runs after B's `fetchUser` +(`:308-311`). So in the window between the token write and the snapshot apply, +snapshot = A and the session signal is still A's, but a fresh derivation reads +B's identity. Our review reproduced provision upserting **user A with B's +keys**. Once provision is SDK-internal and fired on `signIn`, the SDK *itself* +drives an operation through that window — it is no longer only a racing host +call. + +**Design — transition suspension.** Before the token-mutating call: + +1. `beginTransition()` — abort the current session scope (in-flight + key-dependent ops reject `SessionEndedError`, per the fence) and set a + `transitioning` flag under which new key getters and internal provision + reject/defer. +2. `os.signIn(B)` / `signInGuest` / `signUp` (token mutation) runs while + suspended — nothing key-dependent can act on the mismatched snapshot. +3. `applySessionFromServer` (after `fetchUser`) installs the fresh scope, clears + `transitioning`, sets the new snapshot, then fires `session.established` and + runs internal provision — now unambiguously under B. +4. Rollback: if the auth call throws, clear `transitioning` and restore the prior + state (or `endSession`), so a failed switch doesn't strand the instance + suspended. + +**Recommendation:** adopt the suspension as the foundation of this round. This is +the auth-mutation serialization petar declined in #1166 [37]; the new +justification is concrete — internalizing provision makes the window +self-exercised and cross-user, so it must close here. Gate it with a real Open +Secret integration test driving `signIn(B)` over a live A session (the unit +fakes can't exercise the token-write ordering). + +## D6 — migration path + +- **#1167 ships as-is:** host-called `sdk.user.provision` + the session fence + + the web `ensureUserData` gate that calls it. Provision stays host-driven. +- **This round (lands after #1167 merges) deletes:** `hasUserChanged` + (`_protected.tsx:42`), the provisioning path in `ensureUserData` (`:64-82`), + and the web terms plumbing — replaced by SDK-internal provision on the auth + lifecycle + `session.established` + the D5 suspension + web terms replay. +- **Out of scope (stays):** the `/temporary` key prefetches in `ensureUserData` + (`:65-78`, encryption/seed/spark-mnemonic for unmigrated receive/send/claim) — + those die with their features at step 18, not here. This round removes only the + provision gate, not the key prefetches. + +**Recommendation:** sequence strictly after #1167 and the session-lifecycle +hardening PR (the D5 substrate); keep the `/temporary` prefetch removal on the +step-18 track. diff --git a/apps/web-wallet/app/lib/delay.ts b/packages/utils/src/delay.ts similarity index 95% rename from apps/web-wallet/app/lib/delay.ts rename to packages/utils/src/delay.ts index 132fbe8ab..4b2677ab2 100644 --- a/apps/web-wallet/app/lib/delay.ts +++ b/packages/utils/src/delay.ts @@ -7,7 +7,7 @@ export type DelayOptions = { * @param ms Number of milliseconds to wait * @param signal Abort signal that can be used to cancel the delay */ -export default async function delay( +export async function delay( ms: number, { signal }: DelayOptions = {}, ): Promise { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 73992e90b..9de0394cc 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -6,3 +6,5 @@ export * from './type-utils'; export * from './sha256'; export * from './timeout'; export * from './xchacha20poly1305'; +export * from './delay'; +export * from './with-retry'; diff --git a/apps/web-wallet/app/lib/with-retry.ts b/packages/utils/src/with-retry.ts similarity index 98% rename from apps/web-wallet/app/lib/with-retry.ts rename to packages/utils/src/with-retry.ts index 442ddd28a..8e57d34f3 100644 --- a/apps/web-wallet/app/lib/with-retry.ts +++ b/packages/utils/src/with-retry.ts @@ -1,4 +1,4 @@ -import delay from './delay'; +import { delay } from './delay'; /** * Predicate that determines whether a function should be retried. diff --git a/packages/wallet-sdk/db/supabase/.env b/packages/wallet-sdk/db/supabase/.env deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/wallet-sdk/domain/accounts/account-service.ts b/packages/wallet-sdk/domain/accounts/account-service.ts index bfb7c99a0..697172d04 100644 --- a/packages/wallet-sdk/domain/accounts/account-service.ts +++ b/packages/wallet-sdk/domain/accounts/account-service.ts @@ -11,25 +11,28 @@ import type { AccountRepository } from './account-repository'; export class AccountService { constructor(private readonly accountRepository: AccountRepository) {} - async addCashuAccount({ - userId, - account, - }: { - userId: string; - account: DistributedOmit< - CashuAccount, - | 'id' - | 'createdAt' - | 'expiresAt' - | 'isTestMint' - | 'keysetCounters' - | 'proofs' - | 'version' - | 'wallet' - | 'isOnline' - | 'state' - >; - }) { + async addCashuAccount( + { + userId, + account, + }: { + userId: string; + account: DistributedOmit< + CashuAccount, + | 'id' + | 'createdAt' + | 'expiresAt' + | 'isTestMint' + | 'keysetCounters' + | 'proofs' + | 'version' + | 'wallet' + | 'isOnline' + | 'state' + >; + }, + options?: { abortSignal?: AbortSignal }, + ) { const isTestMint = checkIsTestMint(account.mintUrl); let expiresAt: string | null = null; @@ -41,12 +44,15 @@ export class AccountService { } } - return this.accountRepository.create({ - ...account, - userId, - isTestMint, - expiresAt, - keysetCounters: {}, - }); + return this.accountRepository.create( + { + ...account, + userId, + isTestMint, + expiresAt, + keysetCounters: {}, + }, + options, + ); } } diff --git a/packages/wallet-sdk/domain/accounts/accounts-api.test.ts b/packages/wallet-sdk/domain/accounts/accounts-api.test.ts new file mode 100644 index 000000000..25af3b437 --- /dev/null +++ b/packages/wallet-sdk/domain/accounts/accounts-api.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from 'bun:test'; +import { Money } from '@agicash/money'; +import type { AgicashDb } from '../../db/database'; +import { NoSessionError, SessionEndedError } from '../../lib/error'; +import type { SparkWalletConfig } from '../../lib/spark/wallet'; +import type { AddCashuAccountParams, AuthSession, AuthUser } from '../sdk'; +import { createSessionKeys } from '../sdk/session-keys'; +import { + type Account as DomainAccount, + type CashuAccount as DomainCashuAccount, + type SparkAccount as DomainSparkAccount, + getAccountBalance, +} from './account'; +import type { AccountRepository } from './account-repository'; +import { createAccountsApi } from './accounts-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 loggedIn = (id: string): AuthSession => ({ + isLoggedIn: true, + user: authUser(id), +}); + +const cashuDomain = ( + overrides: Partial> = {}, +): DomainCashuAccount => + ({ + id: 'acct-cashu', + name: 'Testnut BTC', + type: 'cashu', + purpose: 'transactional', + state: 'active', + isOnline: true, + currency: 'BTC', + createdAt: '2026-01-01T00:00:00Z', + version: 1, + expiresAt: null, + mintUrl: 'https://testnut.cashu.space', + isTestMint: true, + keysetCounters: {}, + proofs: [{ amount: 100 }, { amount: 50 }], + wallet: { marker: 'cashu-wallet' }, + ...overrides, + }) as unknown as DomainCashuAccount; + +const sparkDomain = (): DomainSparkAccount => + ({ + id: 'acct-spark', + name: 'Bitcoin', + type: 'spark', + purpose: 'transactional', + state: 'active', + isOnline: true, + currency: 'BTC', + createdAt: '2026-01-01T00:00:00Z', + version: 1, + expiresAt: null, + network: 'MAINNET', + balance: new Money({ amount: 42, currency: 'BTC', unit: 'sat' }), + wallet: { marker: 'spark-wallet' }, + }) as unknown as DomainSparkAccount; + +const makeApi = (deps: { + session: AuthSession; + repository?: Partial; +}) => + createAccountsApi({ + db: {} as unknown as AgicashDb, + keys: createSessionKeys(), + sparkConfig: { storageDir: '.', apiKey: 'k' } satisfies SparkWalletConfig, + getSession: () => deps.session, + createRepository: async () => + (deps.repository ?? {}) as unknown as AccountRepository, + }); + +describe('createAccountsApi', () => { + describe('cashu.add', () => { + it('re-injects type:cashu and the session userId, then returns the created account', async () => { + let created: Record | undefined; + const { api } = makeApi({ + session: loggedIn('user-x'), + repository: { + create: (async (input: Record) => { + created = input; + return cashuDomain({ mintUrl: input.mintUrl as string }); + }) as unknown as AccountRepository['create'], + }, + }); + + const params: AddCashuAccountParams = { + name: 'My mint', + mintUrl: 'https://testnut.cashu.space', + currency: 'BTC', + purpose: 'transactional', + }; + const result = await api.cashu.add(params); + + expect(created?.type).toBe('cashu'); + expect(created?.userId).toBe('user-x'); + expect(created?.name).toBe('My mint'); + expect(created?.currency).toBe('BTC'); + expect(created?.purpose).toBe('transactional'); + // the return is the domain account: proofs ride along, balance derives + expect(result.proofs).toBeDefined(); + expect(getAccountBalance(result)?.amount('sat').toNumber()).toBe(150); + }); + + it('throws NoSessionError without a session', async () => { + const { api } = makeApi({ session: { isLoggedIn: false } }); + await expect( + api.cashu.add({ + name: 'x', + mintUrl: 'https://testnut.cashu.space', + currency: 'BTC', + purpose: 'transactional', + }), + ).rejects.toBeInstanceOf(NoSessionError); + }); + + it('rejects with SessionEndedError and creates nothing when the session ends before the write', async () => { + const keys = createSessionKeys(); + let createCalls = 0; + const { api } = createAccountsApi({ + db: {} as unknown as AgicashDb, + keys, + sparkConfig: { + storageDir: '.', + apiKey: 'k', + } satisfies SparkWalletConfig, + getSession: () => loggedIn('user-x'), + createRepository: async () => { + keys.reset(); + return { + create: (async () => { + createCalls += 1; + return cashuDomain(); + }) as unknown as AccountRepository['create'], + } as unknown as AccountRepository; + }, + }); + + await expect( + api.cashu.add({ + name: 'My mint', + mintUrl: 'https://testnut.cashu.space', + currency: 'BTC', + purpose: 'transactional', + }), + ).rejects.toBeInstanceOf(SessionEndedError); + expect(createCalls).toBe(0); + }); + }); + + describe('list', () => { + it('returns every active account for the session user', async () => { + let requestedUserId: string | undefined; + const { api } = makeApi({ + session: loggedIn('user-x'), + repository: { + getAllActive: (async (userId: string) => { + requestedUserId = userId; + return [cashuDomain(), sparkDomain()] as DomainAccount[]; + }) as unknown as AccountRepository['getAllActive'], + }, + }); + + const accounts = await api.list(); + + expect(requestedUserId).toBe('user-x'); + expect(accounts).toHaveLength(2); + const balances = accounts.map((account) => + getAccountBalance(account)?.amount('sat').toNumber(), + ); + expect(balances).toEqual(expect.arrayContaining([150, 42])); + }); + + it('throws NoSessionError without a session', async () => { + const { api } = makeApi({ session: { isLoggedIn: false } }); + await expect(api.list()).rejects.toBeInstanceOf(NoSessionError); + }); + + it('rejects with SessionEndedError and issues no read when the session ends before the read', async () => { + const keys = createSessionKeys(); + let getAllActiveCalls = 0; + const { api } = createAccountsApi({ + db: {} as unknown as AgicashDb, + keys, + sparkConfig: { + storageDir: '.', + apiKey: 'k', + } satisfies SparkWalletConfig, + getSession: () => loggedIn('user-x'), + createRepository: async () => { + // The session ends between the signal capture and the read. + keys.reset(); + return { + getAllActive: (async () => { + getAllActiveCalls += 1; + return []; + }) as unknown as AccountRepository['getAllActive'], + } as unknown as AccountRepository; + }, + }); + + await expect(api.list()).rejects.toBeInstanceOf(SessionEndedError); + expect(getAllActiveCalls).toBe(0); + }); + + it('rejects with SessionEndedError when the session ends while the read hydrates', async () => { + const keys = createSessionKeys(); + const { api } = createAccountsApi({ + db: {} as unknown as AgicashDb, + keys, + sparkConfig: { + storageDir: '.', + apiKey: 'k', + } satisfies SparkWalletConfig, + getSession: () => loggedIn('user-x'), + createRepository: async () => + ({ + getAllActive: (async () => { + // The session ends while the repository hydrates the rows. + keys.reset(); + return [] as DomainAccount[]; + }) as unknown as AccountRepository['getAllActive'], + }) as unknown as AccountRepository, + }); + + await expect(api.list()).rejects.toBeInstanceOf(SessionEndedError); + }); + }); + + describe('get', () => { + it('returns a found account', async () => { + const { api } = makeApi({ + session: loggedIn('user-x'), + repository: { + get: (async () => + cashuDomain()) as unknown as AccountRepository['get'], + }, + }); + + const account = await api.get('acct-cashu'); + + if (!account) throw new Error('expected an account'); + expect(getAccountBalance(account)?.amount('sat').toNumber()).toBe(150); + }); + + it('returns null when the account is not found', async () => { + const { api } = makeApi({ + session: loggedIn('user-x'), + repository: { + get: (async () => null) as unknown as AccountRepository['get'], + }, + }); + + expect(await api.get('missing')).toBeNull(); + }); + }); + + describe('AddCashuAccountParams (B3)', () => { + it('accepts exactly name, mintUrl, currency, purpose', () => { + const params: AddCashuAccountParams = { + name: 'My mint', + mintUrl: 'https://testnut.cashu.space', + currency: 'BTC', + purpose: 'transactional', + }; + expect(params).toBeDefined(); + + const withUserId: AddCashuAccountParams = { + name: 'My mint', + mintUrl: 'https://testnut.cashu.space', + currency: 'BTC', + purpose: 'transactional', + // @ts-expect-error - userId is session-implicit, not part of the params + userId: 'user-x', + }; + expect(withUserId).toBeDefined(); + }); + }); +}); diff --git a/packages/wallet-sdk/domain/accounts/accounts-api.ts b/packages/wallet-sdk/domain/accounts/accounts-api.ts new file mode 100644 index 000000000..812893a3e --- /dev/null +++ b/packages/wallet-sdk/domain/accounts/accounts-api.ts @@ -0,0 +1,102 @@ +import type { AgicashDb } from '../../db/database'; +import { NoSessionError, SessionEndedError } from '../../lib/error'; +import type { SparkWalletConfig } from '../../lib/spark/wallet'; +import type { AccountsApi, AuthSession, CashuAccount } from '../sdk'; +import type { SessionKeys } from '../sdk/session-keys'; +import { AccountRepository } from './account-repository'; +import { AccountService } from './account-service'; + +type Deps = { + db: AgicashDb; + getSession: () => AuthSession; + keys: SessionKeys; + sparkConfig: SparkWalletConfig; + /** Test seam; defaults to building the repository from db + session keys. */ + createRepository?: () => Promise; +}; + +/** + * The `accounts` namespace and its bridge share one data path: every method + * builds the repository from the same db + session keys, and `getRepository` + * hands `/temporary` the same repository for unmigrated flows. + */ +export function createAccountsApi(deps: Deps): { + api: AccountsApi; + getRepository: () => Promise; +} { + const requireUserId = (): string => { + const session = deps.getSession(); + if (!session.isLoggedIn) { + throw new NoSessionError(); + } + return session.user.id; + }; + + const getRepository = + deps.createRepository ?? + (async (): Promise => { + const encryption = await deps.keys.getEncryption(); + return new AccountRepository( + deps.db, + encryption, + deps.keys.getCashuSeed, + deps.keys.getSparkMnemonic, + deps.sparkConfig, + ); + }); + + return { + getRepository, + api: { + get: async (id) => { + const signal = deps.keys.sessionSignal(); + const repository = await getRepository(); + if (signal.aborted) { + throw new SessionEndedError(); + } + const account = await repository.get(id, { abortSignal: signal }); + if (signal.aborted) { + throw new SessionEndedError(); + } + return account; + }, + list: async () => { + const userId = requireUserId(); + const signal = deps.keys.sessionSignal(); + const repository = await getRepository(); + if (signal.aborted) { + throw new SessionEndedError(); + } + const accounts = await repository.getAllActive(userId, { + abortSignal: signal, + }); + if (signal.aborted) { + throw new SessionEndedError(); + } + return accounts; + }, + cashu: { + add: async (params): Promise => { + const userId = requireUserId(); + const signal = deps.keys.sessionSignal(); + const repository = await getRepository(); + if (signal.aborted) { + throw new SessionEndedError(); + } + const service = new AccountService(repository); + const account = await service.addCashuAccount( + { + userId, + account: { ...params, type: 'cashu' }, + }, + { abortSignal: signal }, + ); + if (signal.aborted) { + throw new SessionEndedError(); + } + return account; + }, + }, + }, + }; +} diff --git a/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts b/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts index ca79d7630..855915845 100644 --- a/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts +++ b/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts @@ -5,7 +5,7 @@ import type { Account, CashuAccount, SparkAccount } from '../accounts/account'; import type { AccountService } from '../accounts/account-service'; import type { Ticker } from '../exchange-rate'; import type { User } from '../user/user'; -import { UserService } from '../user/user-service'; +import { getExtendedAccounts } from '../user/user-service'; import type { CashuReceiveQuoteService } from './cashu-receive-quote-service'; import type { CashuReceiveSwap } from './cashu-receive-swap'; import type { CashuReceiveSwapService } from './cashu-receive-swap-service'; @@ -82,7 +82,7 @@ export class ClaimCashuTokenService { ): Promise { const changedAccounts: Account[] = []; - const extendedAccounts = UserService.getExtendedAccounts(user, accounts); + const extendedAccounts = getExtendedAccounts(user, accounts); const preferredReceiveAccountId = claimTo === 'spark' ? extendedAccounts.find((a) => a.type === 'spark')?.id diff --git a/packages/wallet-sdk/domain/sdk/accounts.ts b/packages/wallet-sdk/domain/sdk/accounts.ts index d0cd783e7..c2cc41e95 100644 --- a/packages/wallet-sdk/domain/sdk/accounts.ts +++ b/packages/wallet-sdk/domain/sdk/accounts.ts @@ -1,4 +1,10 @@ -import type { Account, CashuAccount, SparkAccount } from '../accounts/account'; +import type { Currency } from '@agicash/money'; +import type { + Account, + AccountPurpose, + CashuAccount, + SparkAccount, +} from '../accounts/account'; // The public account types are the domain entities for now: only the apps // consume the SDK and they just read these shapes, so fields like proofs, @@ -16,4 +22,9 @@ export type AccountsApi = { }; }; -export type AddCashuAccountParams = unknown; // step 6 (accounts) +export type AddCashuAccountParams = { + name: string; + mintUrl: string; + currency: Currency; + purpose: AccountPurpose; +}; diff --git a/packages/wallet-sdk/domain/sdk/events.test.ts b/packages/wallet-sdk/domain/sdk/events.test.ts index ac291c233..93c27e055 100644 --- a/packages/wallet-sdk/domain/sdk/events.test.ts +++ b/packages/wallet-sdk/domain/sdk/events.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { nullLogger } from '../../lib/logger'; +import type { Account } from '../accounts/account'; +import type { User } from '../user/user'; import { WalletEventEmitter } from './events'; describe('WalletEventEmitter', () => { @@ -70,4 +72,68 @@ describe('WalletEventEmitter', () => { expect(secondHandlerRan).toBe(true); expect(errors).toHaveLength(1); }); + + describe('auth.session-started replay-latest', () => { + const startedPayload = (id: string) => ({ + user: { id } as unknown as User, + accounts: [] as unknown as Account[], + }); + + it('replays the most recent payload to a handler subscribed after the emit', () => { + const emitter = new WalletEventEmitter(nullLogger); + const payload = startedPayload('user-1'); + emitter.emit('auth.session-started', payload); + + const received: unknown[] = []; + emitter.on('auth.session-started', (p) => received.push(p)); + + expect(received).toEqual([payload]); + }); + + it('replays only the latest payload', () => { + const emitter = new WalletEventEmitter(nullLogger); + emitter.emit('auth.session-started', startedPayload('user-1')); + const latest = startedPayload('user-2'); + emitter.emit('auth.session-started', latest); + + const received: unknown[] = []; + emitter.on('auth.session-started', (p) => received.push(p)); + + expect(received).toEqual([latest]); + }); + + it('delivers a live emit to an already-subscribed handler', () => { + const emitter = new WalletEventEmitter(nullLogger); + const received: unknown[] = []; + emitter.on('auth.session-started', (p) => received.push(p)); + + const payload = startedPayload('user-1'); + emitter.emit('auth.session-started', payload); + + expect(received).toEqual([payload]); + }); + + it('does not replay other event types to a late subscriber', () => { + const emitter = new WalletEventEmitter(nullLogger); + emitter.emit('auth.session-expired', {}); + + let calls = 0; + emitter.on('auth.session-expired', () => { + calls += 1; + }); + + expect(calls).toBe(0); + }); + + it('does not replay after the retained payload is cleared', () => { + const emitter = new WalletEventEmitter(nullLogger); + emitter.emit('auth.session-started', startedPayload('user-1')); + emitter.clear(); + + const received: unknown[] = []; + emitter.on('auth.session-started', (p) => received.push(p)); + + expect(received).toEqual([]); + }); + }); }); diff --git a/packages/wallet-sdk/domain/sdk/events.ts b/packages/wallet-sdk/domain/sdk/events.ts index 214d3fab7..d3e6138d4 100644 --- a/packages/wallet-sdk/domain/sdk/events.ts +++ b/packages/wallet-sdk/domain/sdk/events.ts @@ -29,6 +29,15 @@ export type WalletEventMap = { * session-derived state from it. */ 'auth.session-refreshed': Record; + /** + * A session settled onto its current user (initial login/restore or a + * mid-session identity change) and the user was provisioned — carries the + * provisioned user and their accounts so the host seeds its caches by plain + * reads. Replay-latest: a handler that subscribes after the establish still + * receives the most recent payload immediately (the common React case, where + * the tree mounts after `init()` already established the session). + */ + 'auth.session-started': { user: User; accounts: Account[] }; 'user.updated': { user: User }; 'account.created': { account: Account }; /** A persisted row changed; the payload carries a `version` consumers gate on. */ @@ -85,6 +94,11 @@ type Handler = (payload: never) => void; export class WalletEventEmitter implements WalletEvents { private readonly handlers = new Map>(); + // Retained for replay-latest: the most recent auth.session-started payload, + // replayed to a handler that subscribes after the establish. + private lastSessionStarted: + | WalletEventMap['auth.session-started'] + | undefined; constructor(private readonly logger: Logger) {} @@ -95,15 +109,38 @@ export class WalletEventEmitter implements WalletEvents { const set = this.handlers.get(event) ?? new Set(); set.add(handler as Handler); this.handlers.set(event, set); + if (event === 'auth.session-started' && this.lastSessionStarted) { + this.dispatch( + handler as (payload: WalletEventMap['auth.session-started']) => void, + 'auth.session-started', + this.lastSessionStarted, + ); + } return () => { set.delete(handler as Handler); }; } + private dispatch( + handler: (payload: WalletEventMap[K]) => void, + event: K, + payload: WalletEventMap[K], + ): void { + try { + handler(payload); + } catch (error) { + this.logger.error(`Event handler for ${event} threw`, error); + } + } + emit( event: K, payload: WalletEventMap[K], ): void { + if (event === 'auth.session-started') { + this.lastSessionStarted = + payload as WalletEventMap['auth.session-started']; + } const set = this.handlers.get(event); if (!set) { return; @@ -111,11 +148,21 @@ export class WalletEventEmitter implements WalletEvents { // 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); - } + this.dispatch( + handler as (payload: WalletEventMap[K]) => void, + event, + payload, + ); } } + + /** + * Drops all retained replay-latest payloads so a subscriber that registers + * after a session end is not replayed the previous session's events (today the + * retained `auth.session-started` user and accounts; extend here as more events + * adopt replay-latest). Called by the SDK on session end. + */ + clear(): void { + this.lastSessionStarted = undefined; + } } diff --git a/packages/wallet-sdk/domain/sdk/sdk.ts b/packages/wallet-sdk/domain/sdk/sdk.ts index a0c780b24..5f69a9895 100644 --- a/packages/wallet-sdk/domain/sdk/sdk.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.ts @@ -2,6 +2,7 @@ import * as openSecret from '@agicash/opensecret'; import type { AccountsApi, AuthApi, + AuthSession, ContactsApi, FeatureFlagsApi, ReceiveApi, @@ -17,31 +18,36 @@ import type { import { createAgicashDbClient } from '../../db/client'; import { createSupabaseSessionTokenGetter } from '../../db/supabase-session'; import { clearAgicashMintAuthToken } from '../../lib/agicash-mint-auth-provider'; -import { NotImplementedError } from '../../lib/error'; +import { DisposedError, NotImplementedError } from '../../lib/error'; import { generateRandomPassword } from '../../lib/password'; -import { clearSparkWallets } from '../../lib/spark/wallet'; +import { + type SparkWalletConfig, + clearSparkWallets, +} from '../../lib/spark/wallet'; +import { createAccountsApi } from '../accounts/accounts-api'; import { AuthService } from '../user/auth-service'; import { createUserApi } from '../user/user-api'; import { WalletEventEmitter } from './events'; +import { type OwnedSessionKeys, createSessionKeys } from './session-keys'; +import { createUserProvisioner } from './user-provisioner'; -// 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; +// The current instance: the instance currently constructed and not yet +// disposed. 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 currentInstance: AgicashSdk | undefined; /** * Runtime implementation of the SDK contract. Namespaces land slice by slice — - * auth, user, and events so far; accessing a namespace whose migration slice - * hasn't landed throws `NotImplementedError`. + * auth, user, accounts, and events so far; accessing a namespace whose migration + * slice hasn't landed throws `NotImplementedError`. */ export class AgicashSdk implements Sdk { readonly auth: AuthApi; readonly user: UserApi; + readonly accounts: AccountsApi; readonly events: WalletEvents; - get accounts(): AccountsApi { - throw new NotImplementedError('accounts'); - } get contacts(): ContactsApi { throw new NotImplementedError('contacts'); } @@ -65,6 +71,8 @@ export class AgicashSdk implements Sdk { } private readonly authService: AuthService; + private readonly keys: OwnedSessionKeys; + private disposed = false; private constructor(config: SdkConfig) { // The Open Secret client is module-scoped in @agicash/opensecret, so auth @@ -79,6 +87,9 @@ export class AgicashSdk implements Sdk { const events = new WalletEventEmitter(config.logger); + const keys = createSessionKeys(); + this.keys = keys; + // Created before authService — the isLoggedIn closure dereferences it // lazily at request time, after the constructor has assigned it. const sessionToken = createSupabaseSessionTokenGetter({ @@ -86,6 +97,17 @@ export class AgicashSdk implements Sdk { generateToken: () => openSecret.generateThirdPartyToken(), }); + // Provisioning runs internally, post-establish, as the settled identity — + // fingerprint-guarded so it re-provisions only when the identity changes, + // held in memory, not persisted. A terminal failure propagates to the caller + // so the host surfaces its error boundary; a session-lifecycle abort is moot + // for the session that is starting. The guard resets on session end (below) + // so a same-user re-login re-provisions the caches the end cleared. + const userProvisioner = createUserProvisioner({ + provision: () => this.user.provision(), + emit: (payload) => events.emit('auth.session-started', payload), + }); + this.authService = new AuthService({ os: openSecret, storage: config.auth.storage, @@ -93,6 +115,7 @@ export class AgicashSdk implements Sdk { (await config.auth.generateGuestPassword?.()) ?? generateRandomPassword(32), events, + onSessionStarted: userProvisioner.provision, onSessionEnded: () => { // The token cache must die with the session: a token minted for one // user must never serve the next login's queries. Anything wiped here @@ -102,33 +125,63 @@ export class AgicashSdk implements Sdk { sessionToken.reset(); clearSparkWallets(); clearAgicashMintAuthToken(); + keys.reset(); + // Provisioning state is session-scoped too: after a sign-out the next + // login — even the same user — must re-provision and re-emit + // auth.session-started so the host reseeds the caches it cleared on end. + userProvisioner.reset(); + events.clear(); }, logger: config.logger, }); + // The namespaces read the session through this, not the public + // auth.getSession(): a call on a namespace handle retained across dispose() + // rejects instead of acting on the dead instance's last session snapshot. + const getLiveSession = (): AuthSession => { + if (this.disposed) { + throw new DisposedError(); + } + return this.authService.getSession(); + }; + const db = createAgicashDbClient({ url: config.db.url, anonKey: config.db.anonKey, accessToken: sessionToken.getToken, }); + const sparkConfig: SparkWalletConfig = { + storageDir: config.spark.storageDir ?? './.spark-data', + apiKey: config.spark.breezApiKey, + }; + const accounts = createAccountsApi({ + db, + getSession: getLiveSession, + keys, + sparkConfig, + }); + this.auth = this.authService; this.user = createUserApi({ db, - getSession: () => this.authService.getSession(), + getSession: getLiveSession, + keys, + getAccountRepository: accounts.getRepository, }); + this.accounts = accounts.api; 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) { + if (currentInstance) { 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; + currentInstance = new AgicashSdk(config); + return currentInstance; } /** @@ -142,9 +195,11 @@ export class AgicashSdk implements Sdk { } async dispose(): Promise { + this.disposed = true; this.authService.teardown(); - if (liveInstance === this) { - liveInstance = undefined; + this.keys.dispose(); + if (currentInstance === this) { + currentInstance = undefined; } } } diff --git a/packages/wallet-sdk/domain/sdk/session-keys.test.ts b/packages/wallet-sdk/domain/sdk/session-keys.test.ts new file mode 100644 index 000000000..ca4c6247e --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/session-keys.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'bun:test'; +import { BASE_CASHU_LOCKING_DERIVATION_PATH } from '../../lib/cashu'; +import { deriveCashuXpub } from '../../lib/cryptography'; +import { DisposedError, SessionEndedError } from '../../lib/error'; +import { createSessionKeys } from './session-keys'; + +const seedA = new Uint8Array(64).fill(1); +const seedB = new Uint8Array(64).fill(2); + +describe('createSessionKeys', () => { + it('memoizes each derivation within a session (the reader runs once)', async () => { + let calls = 0; + const keys = createSessionKeys({ + readCashuSeed: async () => { + calls += 1; + return seedA; + }, + }); + + await Promise.all([ + keys.getCashuSeed(), + keys.getCashuSeed(), + keys.getCashuSeed(), + ]); + await keys.getCashuSeed(); + + expect(calls).toBe(1); + }); + + it('serves the next session fresh keys after reset (a different user never gets the first user keys)', async () => { + let session: 'a' | 'b' = 'a'; + const keys = createSessionKeys({ + readEncryptionPublicKey: async () => + session === 'a' ? 'pub-a' : 'pub-b', + readCashuSeed: async () => (session === 'a' ? seedA : seedB), + readSparkMnemonic: async () => + session === 'a' ? 'mnemonic a' : 'mnemonic b', + }); + + expect(await keys.getEncryptionPublicKey()).toBe('pub-a'); + expect(await keys.getCashuSeed()).toBe(seedA); + expect(await keys.getSparkMnemonic()).toBe('mnemonic a'); + expect(await keys.getCashuLockingXpub()).toBe( + deriveCashuXpub(seedA, BASE_CASHU_LOCKING_DERIVATION_PATH), + ); + + session = 'b'; + keys.reset(); + + expect(await keys.getEncryptionPublicKey()).toBe('pub-b'); + expect(await keys.getCashuSeed()).toBe(seedB); + expect(await keys.getSparkMnemonic()).toBe('mnemonic b'); + expect(await keys.getCashuLockingXpub()).toBe( + deriveCashuXpub(seedB, BASE_CASHU_LOCKING_DERIVATION_PATH), + ); + }); + + it('rejects an in-flight derivation whose session ended, and serves the next session fresh', async () => { + let session: 'a' | 'b' = 'a'; + let releaseA: (value: string) => void = () => undefined; + const gate = new Promise((resolve) => { + releaseA = resolve; + }); + const keys = createSessionKeys({ + readSparkMnemonic: () => + session === 'a' ? gate : Promise.resolve('mnemonic b'), + }); + + const inFlight = keys.getSparkMnemonic(); + session = 'b'; + keys.reset(); + releaseA('mnemonic a'); + + // The caller that started under session a must not receive a's key once a + // has ended — it rejects rather than resolving the stale value... + await expect(inFlight).rejects.toBeInstanceOf(SessionEndedError); + // ...and the stale resolution did not populate the cache, so the next read + // derives session b fresh. + expect(await keys.getSparkMnemonic()).toBe('mnemonic b'); + }); + + it('rejects getEncryption when the session ends between its two derivations', async () => { + let releasePublicKey: (value: string) => void = () => undefined; + const publicKeyGate = new Promise((resolve) => { + releasePublicKey = resolve; + }); + const keys = createSessionKeys({ + readEncryptionPrivateKey: async () => new Uint8Array(32).fill(7), + readEncryptionPublicKey: () => publicKeyGate, + }); + + const pending = keys.getEncryption(); + keys.reset(); + releasePublicKey('public-key-a'); + + // A reset between the private- and public-key derivations would otherwise + // pair session a's private key with a later session's public key; the + // composite rejects instead of returning a mismatched pair. + await expect(pending).rejects.toBeInstanceOf(SessionEndedError); + }); + + it('rejects every getter terminally after dispose, so a retained handle cannot serve a disposed instance', async () => { + const keys = createSessionKeys({ + readCashuSeed: async () => seedA, + readSparkMnemonic: async () => 'mnemonic a', + }); + // Warm a memo: a plain reset would let this cached value keep resolving. + expect(await keys.getCashuSeed()).toBe(seedA); + + keys.dispose(); + + await expect(keys.getCashuSeed()).rejects.toBeInstanceOf(DisposedError); + await expect(keys.getSparkMnemonic()).rejects.toBeInstanceOf(DisposedError); + await expect(keys.getEncryption()).rejects.toBeInstanceOf(DisposedError); + }); + + it('rejects a derivation in flight when dispose lands', async () => { + let releaseSeed: (value: Uint8Array) => void = () => undefined; + const gate = new Promise((resolve) => { + releaseSeed = resolve; + }); + const keys = createSessionKeys({ readCashuSeed: () => gate }); + + const inFlight = keys.getCashuSeed(); + keys.dispose(); + releaseSeed(seedA); + + await expect(inFlight).rejects.toBeInstanceOf(DisposedError); + }); + + it('exposes a session signal that aborts on reset and on dispose', () => { + const keys = createSessionKeys(); + + const firstSession = keys.sessionSignal(); + expect(firstSession.aborted).toBe(false); + + keys.reset(); + expect(firstSession.aborted).toBe(true); + + const secondSession = keys.sessionSignal(); + expect(secondSession.aborted).toBe(false); + + keys.dispose(); + expect(secondSession.aborted).toBe(true); + }); + + it('rejects a getter reentered from a synchronous abort listener during reset', async () => { + const keys = createSessionKeys({ readCashuSeed: async () => seedA }); + expect(await keys.getCashuSeed()).toBe(seedA); + + let reentered: Promise | undefined; + keys.sessionSignal().addEventListener('abort', () => { + // reset() aborts the signal before it clears the memos; a getter reached + // from this synchronous listener must not hand back the ended session's + // cached key. + reentered = keys.getCashuSeed(); + }); + + keys.reset(); + + expect(reentered).toBeDefined(); + await expect(reentered).rejects.toBeInstanceOf(SessionEndedError); + }); + + it('revokes a retained encryption handle once its session is disposed', async () => { + const privateKey = new Uint8Array(32); + privateKey[31] = 1; + const publicKey = + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const keys = createSessionKeys({ + readEncryptionPrivateKey: async () => privateKey, + readEncryptionPublicKey: async () => publicKey, + }); + const encryption = await keys.getEncryption(); + // Works while the session is live. + const ciphertext = await encryption.encrypt({ owner: 'a' }); + expect(await encryption.decrypt<{ owner: string }>(ciphertext)).toEqual({ + owner: 'a', + }); + + keys.dispose(); + + // A handle retained across dispose can't keep operating on the dead + // session's keys. + await expect(encryption.encrypt({ owner: 'a' })).rejects.toBeInstanceOf( + DisposedError, + ); + await expect(encryption.decrypt(ciphertext)).rejects.toBeInstanceOf( + DisposedError, + ); + }); + + it('revokes a retained encryption handle once its session ends', async () => { + const privateKey = new Uint8Array(32); + privateKey[31] = 1; + const publicKey = + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const keys = createSessionKeys({ + readEncryptionPrivateKey: async () => privateKey, + readEncryptionPublicKey: async () => publicKey, + }); + const encryption = await keys.getEncryption(); + const ciphertext = await encryption.encrypt({ owner: 'a' }); + + keys.reset(); + + // A handle retained across a session end (reset, not disposal) can't keep + // operating on the ended session's keys — it rejects rather than encrypting + // or decrypting under a key that no longer belongs to the live session. + await expect(encryption.encrypt({ owner: 'a' })).rejects.toBeInstanceOf( + SessionEndedError, + ); + await expect(encryption.decrypt(ciphertext)).rejects.toBeInstanceOf( + SessionEndedError, + ); + }); +}); diff --git a/packages/wallet-sdk/domain/sdk/session-keys.ts b/packages/wallet-sdk/domain/sdk/session-keys.ts new file mode 100644 index 000000000..12e9e78eb --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/session-keys.ts @@ -0,0 +1,286 @@ +import { + BASE_CASHU_LOCKING_DERIVATION_PATH, + getCashuSeed, +} from '../../lib/cashu'; +import { deriveCashuXpub } from '../../lib/cryptography'; +import { + type Encryption, + decryptBatchWithPrivateKey, + decryptWithPrivateKey, + encryptBatchToPublicKey, + encryptToPublicKey, + readEncryptionPrivateKey, + readEncryptionPublicKey, +} from '../../lib/encryption'; +import { DisposedError, SessionEndedError } from '../../lib/error'; +import { + getSparkIdentityPublicKeyFromMnemonic, + getSparkMnemonic, +} from '../../lib/spark/wallet'; + +/** + * The per-session key material the accounts and user namespaces derive from + * Open Secret. Every getter is memoized for the life of the session. A getter + * whose session ends before it resolves rejects with `SessionEndedError` rather + * than resolving one user's key into the next user's session, so a key-dependent + * operation rejects instead of running on a key that no longer belongs to the + * live session; a getter reached after disposal rejects with `DisposedError`. + */ +export type SessionKeys = { + /** ECIES encryption functions bound to the session's encryption keypair. */ + getEncryption(): Promise; + /** Hex-encoded encryption public key, as persisted on the user row. */ + getEncryptionPublicKey(): Promise; + /** BIP39 master seed for Cashu wallet derivation. */ + getCashuSeed(): Promise; + /** BIP39 mnemonic for Spark wallet derivation. */ + getSparkMnemonic(): Promise; + /** Extended public key for locking proofs and mint quotes, as persisted on the user row. */ + getCashuLockingXpub(): Promise; + /** Spark identity public key, as persisted on the user row. */ + getSparkIdentityPublicKey(): Promise; + /** + * The current session's abort signal. It aborts on {@link SessionKeys.reset} + * (a session end) and on disposal. A key-dependent operation captures it at + * its start and rejects if it aborts before the operation returns, so the + * operation rejects rather than resolving for a session that has ended. + * Cancellation of a DB write it threads the signal into is best-effort (a + * remote commit can still win the race); the guarantee is that no result is + * used, and no cross-session key material, for an ended session. + */ + sessionSignal(): AbortSignal; + /** Clears every memo. Call on session end so the next user derives fresh keys. */ + reset(): void; +}; + +/** {@link SessionKeys} plus the terminal teardown the owning SDK instance holds. */ +export type OwnedSessionKeys = SessionKeys & { + /** + * Terminal teardown, distinct from the reusable {@link SessionKeys.reset}: + * after it every getter rejects with `DisposedError`, an already-returned + * {@link Encryption} facade rejects, and no further derivation runs. Called on + * SDK dispose so a capability retained across disposal can't serve a dead + * instance's keys. + */ + dispose(): void; +}; + +/** + * Test seam. Each reader defaults to the real Open Secret derivation; tests + * override them to assert the memo fencing without a live Open Secret. + */ +type SessionKeysDeps = { + readEncryptionPrivateKey?: () => Promise; + readEncryptionPublicKey?: () => Promise; + readCashuSeed?: () => Promise; + readSparkMnemonic?: () => Promise; +}; + +/** + * Memoizes an async derivation for the life of a session. Every call checks the + * current session signal before serving a cached value, so a getter reached + * while the session is aborted — including reentrantly, from a synchronous abort + * listener during {@link SessionKeys.reset} before the memos are cleared — + * rejects with `SessionEndedError` instead of returning stale key material. A + * derivation in flight when its session ends likewise rejects rather than + * resolving to its caller; a rejection is not cached, so a retry can recover. + * A getter reached after disposal rejects with `DisposedError`. + */ +function createMemo( + fetcher: () => Promise, + getSignal: () => AbortSignal, + isDisposed: () => boolean, +) { + let cached: { value: T } | undefined; + let inFlight: Promise | undefined; + + return { + clear: () => { + cached = undefined; + inFlight = undefined; + }, + get: (): Promise => { + if (isDisposed()) { + return Promise.reject(new DisposedError()); + } + const signal = getSignal(); + if (signal.aborted) { + return Promise.reject(new SessionEndedError()); + } + if (cached) { + return Promise.resolve(cached.value); + } + if (!inFlight) { + inFlight = (async () => { + try { + const value = await fetcher(); + if (isDisposed()) { + throw new DisposedError(); + } + if (signal.aborted) { + throw new SessionEndedError(); + } + cached = { value }; + return value; + } finally { + if (!signal.aborted) { + inFlight = undefined; + } + } + })(); + } + return inFlight; + }, + }; +} + +export function createSessionKeys( + deps: SessionKeysDeps = {}, +): OwnedSessionKeys { + // Aborted on every session end (see reset) and on disposal (see dispose). A + // derivation in flight when the session ends holds the signal it started + // under; once that signal is aborted its result belongs to a session that no + // longer exists and rejects rather than resolving into the next one. + let sessionScope = new AbortController(); + let disposed = false; + const getSignal = () => sessionScope.signal; + const isDisposed = () => disposed; + + const encryptionPrivateKey = createMemo( + deps.readEncryptionPrivateKey ?? readEncryptionPrivateKey, + getSignal, + isDisposed, + ); + const encryptionPublicKey = createMemo( + deps.readEncryptionPublicKey ?? readEncryptionPublicKey, + getSignal, + isDisposed, + ); + const cashuSeed = createMemo( + deps.readCashuSeed ?? getCashuSeed, + getSignal, + isDisposed, + ); + const sparkMnemonic = createMemo( + deps.readSparkMnemonic ?? getSparkMnemonic, + getSignal, + isDisposed, + ); + const cashuLockingXpub = createMemo( + async () => + deriveCashuXpub( + await cashuSeed.get(), + BASE_CASHU_LOCKING_DERIVATION_PATH, + ), + getSignal, + isDisposed, + ); + const sparkIdentityPublicKey = createMemo( + async () => + // Network is fixed to mainnet here; per-account network selection is + // not yet wired to config.spark.network. + getSparkIdentityPublicKeyFromMnemonic( + await sparkMnemonic.get(), + 'mainnet', + ), + getSignal, + isDisposed, + ); + + const clearMemos = () => { + encryptionPrivateKey.clear(); + encryptionPublicKey.clear(); + cashuSeed.clear(); + sparkMnemonic.clear(); + cashuLockingXpub.clear(); + sparkIdentityPublicKey.clear(); + }; + + return { + getEncryption: async () => { + if (disposed) { + throw new DisposedError(); + } + // Fences on the scope the composite began under: each getter fences its + // own derivation, but a reset landing between the two would otherwise pair + // one session's private key with the next session's public key. + const signal = sessionScope.signal; + const [privateKey, publicKey] = await Promise.all([ + encryptionPrivateKey.get(), + encryptionPublicKey.get(), + ]); + if (disposed) { + throw new DisposedError(); + } + if (signal.aborted) { + throw new SessionEndedError(); + } + // The returned capability is revocable: raw key bytes copied out can't be, + // but each method on this facade rejects once its session ends or the + // instance disposes, so a handle a host caches can't keep encrypting with a + // dead session's keys. Explicit methods (not a Proxy) keep the Encryption + // generic signatures intact for callers and delegate to the lib primitives. + return { + encrypt: async (data: T) => { + if (disposed) { + return Promise.reject(new DisposedError()); + } + if (signal.aborted) { + return Promise.reject(new SessionEndedError()); + } + return encryptToPublicKey(data, publicKey); + }, + decrypt: async (data: string) => { + if (disposed) { + return Promise.reject(new DisposedError()); + } + if (signal.aborted) { + return Promise.reject(new SessionEndedError()); + } + return decryptWithPrivateKey(data, privateKey); + }, + encryptBatch: async (data: T) => { + if (disposed) { + return Promise.reject(new DisposedError()); + } + if (signal.aborted) { + return Promise.reject(new SessionEndedError()); + } + return encryptBatchToPublicKey(data, publicKey); + }, + decryptBatch: async ( + data: readonly [...{ [K in keyof T]: string }], + ) => { + if (disposed) { + return Promise.reject(new DisposedError()); + } + if (signal.aborted) { + return Promise.reject(new SessionEndedError()); + } + return decryptBatchWithPrivateKey(data, privateKey); + }, + }; + }, + getEncryptionPublicKey: encryptionPublicKey.get, + getCashuSeed: cashuSeed.get, + getSparkMnemonic: sparkMnemonic.get, + getCashuLockingXpub: cashuLockingXpub.get, + getSparkIdentityPublicKey: sparkIdentityPublicKey.get, + sessionSignal: () => sessionScope.signal, + reset: () => { + // Disposal is terminal: keep the aborted signal and disposed getters + // rather than installing a fresh scope a late onSessionEnded would revive. + if (disposed) { + return; + } + sessionScope.abort(); + sessionScope = new AbortController(); + clearMemos(); + }, + dispose: () => { + disposed = true; + sessionScope.abort(); + clearMemos(); + }, + }; +} diff --git a/packages/wallet-sdk/domain/sdk/user-provisioner.test.ts b/packages/wallet-sdk/domain/sdk/user-provisioner.test.ts new file mode 100644 index 000000000..0af5ea748 --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/user-provisioner.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'bun:test'; +import type { AuthUser } from '.'; +import { DisposedError, SessionEndedError } from '../../lib/error'; +import type { Account } from '../accounts/account'; +import type { User } from '../user/user'; +import { createUserProvisioner } from './user-provisioner'; + +const authUser = (id: string): AuthUser => + ({ + id, + email: 'a@b.c', + email_verified: true, + }) as AuthUser; + +const makeHarness = ( + provision?: () => Promise<{ user: User; accounts: Account[] }>, +) => { + let provisionCalls = 0; + const emitted: { user: User; accounts: Account[] }[] = []; + const provisioner = createUserProvisioner({ + provision: + provision ?? + (async () => { + provisionCalls += 1; + return { user: { id: 'user-1' } as unknown as User, accounts: [] }; + }), + emit: (payload) => { + emitted.push(payload); + }, + }); + return { provisioner, emitted, provisionCalls: () => provisionCalls }; +}; + +describe('createUserProvisioner', () => { + it('provisions and emits on the first establish', async () => { + const { provisioner, emitted, provisionCalls } = makeHarness(); + await provisioner.provision(authUser('user-1')); + expect(emitted).toHaveLength(1); + expect(provisionCalls()).toBe(1); + }); + + it('skips a same-user re-establish (fingerprint guard) without a session end', async () => { + const { provisioner, emitted, provisionCalls } = makeHarness(); + await provisioner.provision(authUser('user-1')); + await provisioner.provision(authUser('user-1')); + expect(emitted).toHaveLength(1); + expect(provisionCalls()).toBe(1); + }); + + it('re-provisions and re-emits on a same-user re-establish after reset', async () => { + const { provisioner, emitted, provisionCalls } = makeHarness(); + await provisioner.provision(authUser('user-1')); + provisioner.reset(); + await provisioner.provision(authUser('user-1')); + expect(emitted).toHaveLength(2); + expect(provisionCalls()).toBe(2); + }); + + it('re-provisions when the identity changes', async () => { + const { provisioner, emitted } = makeHarness(); + await provisioner.provision(authUser('user-1')); + await provisioner.provision(authUser('user-2')); + expect(emitted).toHaveLength(2); + }); + + it('propagates a terminal provision failure', async () => { + const { provisioner } = makeHarness(async () => { + throw new Error('provision failed'); + }); + await expect(provisioner.provision(authUser('user-1'))).rejects.toThrow( + 'provision failed', + ); + }); + + it('swallows a session-lifecycle abort without emitting', async () => { + const { provisioner, emitted } = makeHarness(async () => { + throw new SessionEndedError(); + }); + await provisioner.provision(authUser('user-1')); + expect(emitted).toHaveLength(0); + }); + + it('swallows a DisposedError without emitting', async () => { + const { provisioner, emitted } = makeHarness(async () => { + throw new DisposedError(); + }); + await provisioner.provision(authUser('user-1')); + expect(emitted).toHaveLength(0); + }); +}); diff --git a/packages/wallet-sdk/domain/sdk/user-provisioner.ts b/packages/wallet-sdk/domain/sdk/user-provisioner.ts new file mode 100644 index 000000000..e557d90c0 --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/user-provisioner.ts @@ -0,0 +1,53 @@ +import type { AuthUser } from '.'; +import { DisposedError, SessionEndedError } from '../../lib/error'; +import type { Account } from '../accounts/account'; +import type { User } from '../user/user'; + +type UserProvisionerDeps = { + /** Provisions the settled identity; returns the user and their accounts. */ + provision: () => Promise<{ user: User; accounts: Account[] }>; + /** Emits the `auth.session-started` payload to the host. */ + emit: (payload: { user: User; accounts: Account[] }) => void; +}; + +/** + * Provisions the settled user once per identity and emits `auth.session-started`. + * Fingerprint-guarded in memory (userId + email + emailVerified) so it fires only + * when the identity changes; a terminal provision failure propagates to the caller + * (so the host surfaces its error boundary), while a session-lifecycle abort is + * swallowed (moot for a session that is ending or gone). `reset` clears the guard + * on session end so a same-user re-login after a sign-out — which cleared the host + * caches — re-provisions and re-emits rather than being skipped as unchanged. + */ +export const createUserProvisioner = ( + deps: UserProvisionerDeps, +): { + provision: (authUser: AuthUser) => Promise; + reset: () => void; +} => { + let lastProvisionedFingerprint: string | undefined; + return { + provision: async (authUser: AuthUser): Promise => { + const fingerprint = `${authUser.id} ${authUser.email ?? ''} ${authUser.email_verified}`; + if (fingerprint === lastProvisionedFingerprint) { + return; + } + try { + const { user, accounts } = await deps.provision(); + lastProvisionedFingerprint = fingerprint; + deps.emit({ user, accounts }); + } catch (error) { + if ( + error instanceof SessionEndedError || + error instanceof DisposedError + ) { + return; + } + throw error; + } + }, + reset: (): void => { + lastProvisionedFingerprint = undefined; + }, + }; +}; diff --git a/packages/wallet-sdk/domain/sdk/user.ts b/packages/wallet-sdk/domain/sdk/user.ts index 343715399..7628ad3e3 100644 --- a/packages/wallet-sdk/domain/sdk/user.ts +++ b/packages/wallet-sdk/domain/sdk/user.ts @@ -8,11 +8,31 @@ export type UserApi = { acceptTerms(params: AcceptTermsParams): Promise; setDefaultAccount(params: SetDefaultAccountParams): Promise; setDefaultCurrency(params: SetDefaultCurrencyParams): Promise; + /** + * Provisions the signed-in user. Fired internally on the auth lifecycle + * (post-establish), not host-called. Idempotent — creates the user row and + * default accounts on the first establish of an identity, updates the auth + * data (email / email-verified) when it changed, and no-ops otherwise. + * Returns the user with their accounts (carried to the host via the + * `auth.session-started` event). Terms are recorded separately via + * `acceptTerms`. + */ + provision(): Promise<{ user: User; accounts: Account[] }>; }; export type AcceptTermsParams = { - walletTerms?: boolean; - giftCardTerms?: boolean; + /** + * ISO 8601 timestamp when the user accepted wallet terms — the real click + * time the host captured (pre-auth pending acceptance replayed post-provision, + * or an in-session accept), not "now" stamped at the SDK. Omit to leave wallet + * terms unchanged. + */ + walletTermsAcceptedAt?: string; + /** + * ISO 8601 timestamp when the user accepted gift-card-mint terms. Omit to + * leave gift-card-mint terms unchanged. + */ + giftCardMintTermsAcceptedAt?: string; }; export type SetDefaultAccountParams = { diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts index 79f124d7c..e7cbad63d 100644 --- a/packages/wallet-sdk/domain/user/auth-service.test.ts +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { DisposedError } from '../../lib/error'; import { nullLogger } from '../../lib/logger'; -import type { AuthKeyValueStore, AuthStorage } from '../sdk'; +import type { AuthKeyValueStore, AuthStorage, AuthUser } from '../sdk'; import { WalletEventEmitter } from '../sdk/events'; import { AuthService, type OpenSecretAuthApi } from './auth-service'; @@ -100,6 +100,7 @@ const createService = ( os?: Partial; storage?: ReturnType; onSessionEnded?: () => void; + onSessionStarted?: (authUser: AuthUser) => Promise; } = {}, ) => { const storage = options.storage ?? createStorage(); @@ -111,6 +112,7 @@ const createService = ( generateGuestPassword: async () => 'generated-pw', events, onSessionEnded: options.onSessionEnded, + onSessionStarted: options.onSessionStarted, logger: nullLogger, }); return { service, storage, calls, events }; @@ -122,6 +124,113 @@ describe('AuthService', () => { expect(service.getSession()).toEqual({ isLoggedIn: false }); }); + describe('onSessionStarted (provisioning)', () => { + const withTokens = (storage: ReturnType) => { + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + }; + + it('fires post-establish on restore, with the settled user', async () => { + const storage = createStorage(); + withTokens(storage); + const established: AuthUser[] = []; + const { service } = createService({ + storage, + onSessionStarted: async (user) => { + established.push(user); + }, + }); + + await service.restoreSession(); + + expect(established).toHaveLength(1); + expect(established[0]?.id).toBe('user-1'); + expect(service.getSession().isLoggedIn).toBe(true); + }); + + it('fires post-establish on a sign-in', async () => { + const established: AuthUser[] = []; + const { service } = createService({ + onSessionStarted: async (user) => { + established.push(user); + }, + }); + + await service.signIn('a@b.c', 'pw'); + + expect(established).toHaveLength(1); + expect(established[0]?.id).toBe('user-1'); + }); + + it('propagates a terminal provisioning failure out of the verb, keeping the established session', async () => { + const { service } = createService({ + onSessionStarted: async () => { + throw new Error('provision failed'); + }, + }); + + await expect(service.signIn('a@b.c', 'pw')).rejects.toThrow( + 'provision failed', + ); + // The identity is authenticated; only provisioning failed. + expect(service.getSession().isLoggedIn).toBe(true); + }); + + it('propagates a terminal provisioning failure out of restore', async () => { + const storage = createStorage(); + withTokens(storage); + const { service } = createService({ + storage, + onSessionStarted: async () => { + throw new Error('provision failed'); + }, + }); + + await expect(service.restoreSession()).rejects.toThrow( + 'provision failed', + ); + expect(service.getSession().isLoggedIn).toBe(true); + }); + + it('does not fire when restore establishes no session', async () => { + const established: AuthUser[] = []; + const { service } = createService({ + onSessionStarted: async (user) => { + established.push(user); + }, + }); + + await service.restoreSession(); + + expect(established).toHaveLength(0); + expect(service.getSession().isLoggedIn).toBe(false); + }); + + it('re-provisions on a later restore when a prior establish left it owed', async () => { + const storage = createStorage(); + withTokens(storage); + let attempts = 0; + const { service } = createService({ + storage, + onSessionStarted: async () => { + attempts += 1; + if (attempts === 1) { + throw new Error('provision failed'); + } + }, + }); + + await expect(service.restoreSession()).rejects.toThrow( + 'provision failed', + ); + // The session is already established; the retry must still re-provision. + await service.restoreSession(); + + expect(attempts).toBe(2); + expect(service.getSession().isLoggedIn).toBe(true); + }); + }); + describe('restoreSession', () => { it('stays anonymous without stored tokens and does not call fetchUser', async () => { const { service, calls } = createService(); diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index 71e558e92..d7a0da436 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -14,7 +14,13 @@ import { type GuestAccountStorage, createGuestAccountStorage, } from '../../lib/guest-account-storage'; -import type { AuthApi, AuthSession, AuthStorage, Logger } from '../sdk'; +import type { + AuthApi, + AuthSession, + AuthStorage, + AuthUser, + Logger, +} from '../sdk'; import type { WalletEventEmitter } from '../sdk/events'; // Keys are owned by @agicash/opensecret's token persistence; the service reads @@ -57,6 +63,15 @@ type AuthServiceDeps = { events: WalletEventEmitter; /** Per-session cache cleanup on any session end (sign-out or expiry). */ onSessionEnded?: () => void; + /** + * Notifies the higher layer that a session settled onto its current user — the + * session snapshot + keys are that settled identity — on every session-started + * transition. The higher layer decides what that means (today: provision the + * user). A terminal failure from the callback rejects and propagates to the + * caller (the auth verb, or `init()` on restore) so the host surfaces its error + * boundary; the session is kept. + */ + onSessionStarted?: (authUser: AuthUser) => Promise; logger: Logger; }; @@ -108,7 +123,11 @@ export class AuthService implements AuthApi { if (this.session.isLoggedIn) { // A sign-in (or another auth action) already established the session and // a preceding session end un-memoized the restore; booting from storage - // now would only repeat the user fetch that action already did. + // now would only repeat the user fetch that action already did. Still + // (re)notify that the session started: a prior notify whose downstream + // provisioning threw left it owed, and the higher layer's fingerprint guard + // makes this a no-op once provisioning has succeeded. + await this.notifySessionStarted(); return; } const [accessToken, refreshToken] = await Promise.all([ @@ -145,6 +164,11 @@ export class AuthService implements AuthApi { this.endSession(); throw error; } + // Restore started the session → notify the higher layer. A terminal failure + // from the callback propagates through init() so the host surfaces the error + // boundary; the session stays (it is not a restore failure that boots + // anonymous). + await this.notifySessionStarted(); } async signUp(email: string, password: string): Promise { @@ -273,8 +297,11 @@ export class AuthService implements AuthApi { context: string, scope?: AbortSignal, ): Promise { + let applied: boolean; try { - return await this.applySessionFromServer(scope ? { scope } : undefined); + applied = await this.applySessionFromServer( + scope ? { scope } : undefined, + ); } catch (error) { // An auth action whose fetchUser fails leaves an anonymous session the // host discovers on its next read. endSession (not a bare snapshot clear) @@ -285,6 +312,21 @@ export class AuthService implements AuthApi { this.endSession(); return true; } + if (applied) { + await this.notifySessionStarted(); + } + return applied; + } + + // Notifies the higher layer that a session started onto its current user; that + // layer decides what it means (today: provision the user). A terminal failure + // from the callback propagates to the caller (the auth verb, or init() on + // restore) so the host surfaces its error boundary; the session is kept. + private async notifySessionStarted(): Promise { + if (!this.session.isLoggedIn) { + return; + } + await this.deps.onSessionStarted?.(this.session.user); } /** diff --git a/packages/wallet-sdk/domain/user/user-api.test.ts b/packages/wallet-sdk/domain/user/user-api.test.ts index 2b28a6289..ceeef86cd 100644 --- a/packages/wallet-sdk/domain/user/user-api.test.ts +++ b/packages/wallet-sdk/domain/user/user-api.test.ts @@ -1,8 +1,17 @@ import { describe, expect, it } from 'bun:test'; import type { Currency } from '@agicash/money'; +import { core, z } from 'zod/mini'; import type { AgicashDb } from '../../db/database'; -import type { Account } from '../accounts/account'; +import type { Encryption } from '../../lib/encryption'; +import { SessionEndedError } from '../../lib/error'; +import { + type Account, + type CashuAccount as DomainCashuAccount, + getAccountBalance, +} from '../accounts/account'; +import type { AccountRepository } from '../accounts/account-repository'; import type { AuthUser } from '../sdk'; +import type { SessionKeys } from '../sdk/session-keys'; import { createUserApi } from './user-api'; const authUser = (id: string): AuthUser => @@ -36,12 +45,28 @@ const dbUserRow = (id: string) => ({ const account = (id: string, currency: Currency): Account => ({ id, currency }) as unknown as Account; +const fakeAccountRepository = {} as AccountRepository; +const getAccountRepository = async () => fakeAccountRepository; + +const fakeKeys = ( + signal: AbortSignal = new AbortController().signal, +): SessionKeys => ({ + getEncryption: async () => ({}) as Encryption, + getEncryptionPublicKey: async () => 'enc-pub', + getCashuSeed: async () => new Uint8Array(64), + getSparkMnemonic: async () => 'mnemonic', + getCashuLockingXpub: async () => 'xpub', + getSparkIdentityPublicKey: async () => 'spark-id', + sessionSignal: () => signal, + reset: () => undefined, +}); + type Filters = Record; /** - * Fake covering the one query setDefaultAccount now issues: the users row - * update. A read from any other table would mean the api regressed to fetching - * the account instead of trusting the cached one the caller passes. + * Fake covering the one query setDefaultAccount issues: the users row update. A + * read from any other table would mean the api regressed to fetching the account + * instead of trusting the cached one the caller passes. */ const createDbFake = ( onUserUpdate: (filters: Filters, data: Record) => void, @@ -72,6 +97,49 @@ const createDbFake = ( return { from } as unknown as AgicashDb; }; +// The PostgREST builder upsert drives: awaitable (a real promise), with a +// chainable no-op abortSignal() (the real builder wires the signal into the +// underlying fetch). +const rpcQuery = (result: Promise<{ data: unknown; error: unknown }>) => { + const query = Object.assign(result, { abortSignal: () => query }); + return query; +}; + +/** Fake covering the single `upsert_user_with_accounts` RPC provision issues. */ +const createUpsertDbFake = ( + outcomes: Array<{ reject: unknown } | { ok: true }>, + accountRows: Record[] = [], +) => { + const calls: Record[] = []; + const db = { + rpc: (_name: string, params: Record) => { + const outcome = outcomes[Math.min(calls.length, outcomes.length - 1)]; + calls.push(params); + return rpcQuery( + outcome && 'reject' in outcome + ? Promise.reject(outcome.reject) + : Promise.resolve({ + data: { + user: dbUserRow(String(params.p_user_id)), + accounts: accountRows, + }, + error: null, + }), + ); + }, + } as unknown as AgicashDb; + return { db, calls }; +}; + +const makeZodError = (): unknown => { + try { + z.number().parse('not a number'); + } catch (error) { + return error; + } + throw new Error('expected zod parse to throw'); +}; + describe('createUserApi', () => { describe('setDefaultAccount', () => { it('writes the cached account onto the session user, keyed by its currency', async () => { @@ -83,6 +151,8 @@ describe('createUserApi', () => { updatedData = data; }), getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, }); await api.setDefaultAccount({ account: account('acct-1', 'BTC') }); @@ -91,4 +161,262 @@ describe('createUserApi', () => { expect(updatedData.default_btc_account_id).toBe('acct-1'); }); }); + + describe('provision', () => { + it('upserts with the derived keys, returning the user and accounts', async () => { + const { db, calls } = createUpsertDbFake([{ ok: true }]); + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, + }); + + const result = await api.provision(); + + expect(calls).toHaveLength(1); + expect(calls[0]?.p_user_id).toBe('user-a'); + expect(calls[0]?.p_cashu_locking_xpub).toBe('xpub'); + expect(calls[0]?.p_encryption_public_key).toBe('enc-pub'); + expect(calls[0]?.p_spark_identity_public_key).toBe('spark-id'); + expect(result.user.id).toBe('user-a'); + expect(result.accounts).toEqual([]); + }); + + it('returns the upserted account rows as domain accounts through the repository', async () => { + const row = { id: 'acct-cashu' }; + const { db, calls } = createUpsertDbFake([{ ok: true }], [row]); + const domainCashuAccount = { + id: 'acct-cashu', + name: 'Testnut BTC', + type: 'cashu', + purpose: 'transactional', + state: 'active', + isOnline: true, + currency: 'BTC', + createdAt: '2026-01-01T00:00:00Z', + version: 1, + expiresAt: null, + mintUrl: 'https://testnut.cashu.space', + isTestMint: true, + keysetCounters: { ks1: 3 }, + proofs: [{ amount: 100 }, { amount: 50 }], + wallet: { marker: 'cashu-wallet' }, + } as unknown as DomainCashuAccount; + const toAccountCalls: unknown[] = []; + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository: async () => + ({ + toAccount: async (input: unknown) => { + toAccountCalls.push(input); + return domainCashuAccount; + }, + }) as unknown as AccountRepository, + }); + + const result = await api.provision(); + + expect(calls).toHaveLength(1); + expect(toAccountCalls).toEqual([row]); + const [account] = result.accounts; + if (!account) throw new Error('expected an account'); + expect(account.type).toBe('cashu'); + expect(getAccountBalance(account)?.amount('sat').toNumber()).toBe(150); + expect('proofs' in account).toBe(true); + expect('wallet' in account).toBe(true); + expect('keysetCounters' in account).toBe(true); + }); + + it('upserts on every call and returns each call fresh result (no memo)', async () => { + const rows = [ + dbUserRow('user-a'), + { ...dbUserRow('user-a'), username: 'renamed' }, + ]; + const calls: Record[] = []; + const db = { + rpc: (_name: string, params: Record) => { + const row = rows[Math.min(calls.length, rows.length - 1)]; + calls.push(params); + return rpcQuery( + Promise.resolve({ data: { user: row, accounts: [] }, error: null }), + ); + }, + } as unknown as AgicashDb; + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, + }); + + const first = await api.provision(); + const second = await api.provision(); + + expect(calls).toHaveLength(2); + expect(first.user.username).toBe('name'); + expect(second.user.username).toBe('renamed'); + }); + + it('does not pass terms into the upsert — terms are decoupled to acceptTerms', async () => { + const { db, calls } = createUpsertDbFake([{ ok: true }]); + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, + }); + + await api.provision(); + + expect(calls).toHaveLength(1); + expect(calls[0]?.p_terms_accepted_at).toBeUndefined(); + expect(calls[0]?.p_gift_card_mint_terms_accepted_at).toBeUndefined(); + }); + + it('retries a transient key-derivation failure before upserting', async () => { + const { db, calls } = createUpsertDbFake([{ ok: true }]); + let attempts = 0; + const keys = fakeKeys(); + keys.getSparkIdentityPublicKey = async () => { + attempts += 1; + if (attempts === 1) { + throw new Error('transient enclave failure'); + } + return 'spark-id'; + }; + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys, + getAccountRepository, + }); + + const result = await api.provision(); + + expect(attempts).toBe(2); + expect(calls).toHaveLength(1); + expect(calls[0]?.p_spark_identity_public_key).toBe('spark-id'); + expect(result.user.id).toBe('user-a'); + }); + + it('retries a generic upsert failure, then succeeds', async () => { + const { db, calls } = createUpsertDbFake([ + { reject: new Error('transient') }, + { ok: true }, + ]); + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, + }); + + const result = await api.provision(); + + expect(calls).toHaveLength(2); + expect(result.user.id).toBe('user-a'); + }); + + it('does not retry a Zod validation error', async () => { + const zodError = makeZodError(); + expect(zodError).toBeInstanceOf(core.$ZodError); + const { db, calls } = createUpsertDbFake([{ reject: zodError }]); + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, + }); + + await expect(api.provision()).rejects.toBe(zodError); + expect(calls).toHaveLength(1); + }); + + it('throws NoSessionError without a session', async () => { + const { db } = createUpsertDbFake([{ ok: true }]); + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: false }), + keys: fakeKeys(), + getAccountRepository, + }); + + await expect(api.provision()).rejects.toThrow(); + }); + + it('rejects with SessionEndedError and issues no upsert when the session ends before the write', async () => { + const controller = new AbortController(); + const { db, calls } = createUpsertDbFake([{ ok: true }]); + const keys = fakeKeys(controller.signal); + // The session ends while the keys the write depends on are being derived. + keys.getSparkIdentityPublicKey = async () => { + controller.abort(); + return 'spark-id'; + }; + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys, + getAccountRepository, + }); + + await expect(api.provision()).rejects.toBeInstanceOf(SessionEndedError); + expect(calls).toHaveLength(0); + }); + + it('rejects with SessionEndedError when the session ends while the upsert result is mapped', async () => { + const controller = new AbortController(); + const keys = fakeKeys(controller.signal); + const db = { + rpc: (_name: string, params: Record) => { + // The session ends while the committed row is mapped to accounts. + controller.abort(); + return rpcQuery( + Promise.resolve({ + data: { + user: dbUserRow(String(params.p_user_id)), + accounts: [], + }, + error: null, + }), + ); + }, + } as unknown as AgicashDb; + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys, + getAccountRepository, + }); + + await expect(api.provision()).rejects.toBeInstanceOf(SessionEndedError); + }); + }); + + describe('acceptTerms', () => { + it('records the real click timestamps the host passes, not now()', async () => { + let updatedData: Record = {}; + const api = createUserApi({ + db: createDbFake((_filters, data) => { + updatedData = data; + }), + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, + }); + + await api.acceptTerms({ + walletTermsAcceptedAt: '2026-01-02T03:04:05Z', + giftCardMintTermsAcceptedAt: '2026-01-03T00:00:00Z', + }); + + expect(updatedData.terms_accepted_at).toBe('2026-01-02T03:04:05Z'); + expect(updatedData.gift_card_mint_terms_accepted_at).toBe( + '2026-01-03T00:00:00Z', + ); + }); + }); }); diff --git a/packages/wallet-sdk/domain/user/user-api.ts b/packages/wallet-sdk/domain/user/user-api.ts index f7889f30b..39daf9c5a 100644 --- a/packages/wallet-sdk/domain/user/user-api.ts +++ b/packages/wallet-sdk/domain/user/user-api.ts @@ -1,14 +1,72 @@ +import { withRetry } from '@agicash/utils'; +import { core } from 'zod/mini'; import type { AgicashDb } from '../../db/database'; -import { NoSessionError } from '../../lib/error'; +import { + DisposedError, + NoSessionError, + SessionEndedError, +} from '../../lib/error'; +import type { AccountRepository } from '../accounts/account-repository'; import type { AuthSession, UserApi } from '../sdk'; -import { ReadUserRepository, UpdateUserRepository } from './user-repository'; +import type { SessionKeys } from '../sdk/session-keys'; +import { + ReadUserRepository, + UpdateUserRepository, + UpsertUserRepository, +} from './user-repository'; import { UserService } from './user-service'; type Deps = { db: AgicashDb; getSession: () => AuthSession; + keys: SessionKeys; + /** The accounts namespace's repository — one construction path for the whole instance. */ + getAccountRepository: () => Promise; }; +const isDevelopmentMode = import.meta.env.MODE === 'development'; + +const defaultAccounts = [ + { + type: 'spark', + currency: 'BTC', + name: 'Bitcoin', + network: 'MAINNET', + isDefault: true, + purpose: 'transactional', + expiresAt: null, + }, + ...(isDevelopmentMode + ? ([ + { + type: 'cashu', + currency: 'BTC', + name: 'Testnut BTC', + mintUrl: 'https://testnut.cashu.space', + isTestMint: true, + isDefault: false, + purpose: 'transactional', + expiresAt: null, + }, + { + type: 'cashu', + currency: 'USD', + name: 'Testnut USD', + mintUrl: 'https://testnut.cashu.space', + isTestMint: true, + isDefault: true, + purpose: 'transactional', + expiresAt: null, + }, + ] as const) + : []), +] as const; + +// A session ending mid-operation is terminal for that operation — retrying +// would only re-derive keys for a session that no longer owns the work. +const isSessionLifecycleError = (error: unknown): boolean => + error instanceof SessionEndedError || error instanceof DisposedError; + export function createUserApi(deps: Deps): UserApi { const readRepository = new ReadUserRepository(deps.db); const updateRepository = new UpdateUserRepository(deps.db); @@ -28,13 +86,11 @@ export function createUserApi(deps: Deps): UserApi { get: async () => readRepository.get(requireUserId()), updateUsername: async (username) => updateRepository.update(requireUserId(), { username }), - acceptTerms: async (params) => { - const now = new Date().toISOString(); - return updateRepository.update(requireUserId(), { - termsAcceptedAt: params.walletTerms ? now : undefined, - giftCardMintTermsAcceptedAt: params.giftCardTerms ? now : undefined, - }); - }, + acceptTerms: async (params) => + updateRepository.update(requireUserId(), { + termsAcceptedAt: params.walletTermsAcceptedAt, + giftCardMintTermsAcceptedAt: params.giftCardMintTermsAcceptedAt, + }), setDefaultCurrency: async (params) => updateRepository.update(requireUserId(), { defaultCurrency: params.currency, @@ -43,5 +99,80 @@ export function createUserApi(deps: Deps): UserApi { userService.setDefaultAccount(requireUserId(), params.account, { setDefaultCurrency: params.setDefaultCurrency, }), + provision: async () => { + const session = deps.getSession(); + if (!session.isLoggedIn) { + throw new NoSessionError(); + } + const authUser = session.user; + // Bind the operation to the session live at its start: the keys derived + // and the user id written below belong to it, so a session end mid-flight + // must abort the write rather than persist one user's data under the + // next session. Captured with no await after the session read so the two + // can't straddle a transition. + const signal = deps.keys.sessionSignal(); + + // The memoized getters re-fetch only on failure, so retrying the batch + // re-derives only the keys that failed, not the ones already resolved. + const [ + encryptionPublicKey, + cashuLockingXpub, + sparkIdentityPublicKey, + accountRepository, + ] = await withRetry({ + fn: () => + Promise.all([ + deps.keys.getEncryptionPublicKey(), + deps.keys.getCashuLockingXpub(), + deps.keys.getSparkIdentityPublicKey(), + deps.getAccountRepository(), + ]), + retry: (attemptIndex, error) => + !signal.aborted && + !isSessionLifecycleError(error) && + attemptIndex < 3, + }); + + if (signal.aborted) { + throw new SessionEndedError(); + } + + const upsertRepository = new UpsertUserRepository( + deps.db, + accountRepository, + ); + + const result = await withRetry({ + fn: () => + upsertRepository.upsert( + { + id: authUser.id, + email: authUser.email, + emailVerified: authUser.email_verified, + accounts: [...defaultAccounts], + cashuLockingXpub, + encryptionPublicKey, + sparkIdentityPublicKey, + }, + { abortSignal: signal }, + ), + retry: (attemptIndex, error) => { + if (error instanceof core.$ZodError) { + return false; + } + if (signal.aborted || isSessionLifecycleError(error)) { + return false; + } + return attemptIndex < 2; + }, + }); + // The RPC committed A's row, but its accounts were mapped through + // toAccount after; if the session ended meanwhile, don't resolve A's + // user/accounts into the next session's caller. + if (signal.aborted) { + throw new SessionEndedError(); + } + return result; + }, }; } diff --git a/packages/wallet-sdk/domain/user/user-service.ts b/packages/wallet-sdk/domain/user/user-service.ts index ec1a5e157..0ddad2ac9 100644 --- a/packages/wallet-sdk/domain/user/user-service.ts +++ b/packages/wallet-sdk/domain/user/user-service.ts @@ -2,6 +2,41 @@ import type { Account, ExtendedAccount } from '../accounts/account'; import type { User } from './user'; import type { UpdateUserRepository } from './user-repository'; +type UserDefaults = Pick; + +/** + * Returns true if the account is the user's default account for its currency. + */ +export function isDefaultAccount( + user: UserDefaults, + account: Pick, +): boolean { + if (account.currency === 'BTC') { + return user.defaultBtcAccountId === account.id; + } + if (account.currency === 'USD') { + return user.defaultUsdAccountId === account.id; + } + return false; +} + +/** + * Returns the accounts with the isDefault flag set to true if the account is the + * user's default account for the respective currency. Sorts the default account + * to the top. + */ +export function getExtendedAccounts( + user: UserDefaults, + accounts: Account[], +): ExtendedAccount[] { + return accounts + .map((account) => ({ + ...account, + isDefault: isDefaultAccount(user, account), + })) + .sort((_, b) => (b.isDefault ? 1 : -1)); +} + type SetDefaultAccountOptions = { /** * Whether to set the user'sdefault currency to the account's currency. @@ -13,35 +48,6 @@ type SetDefaultAccountOptions = { export class UserService { constructor(private readonly userRepository: UpdateUserRepository) {} - /** - * Returns true if the account is the user's default account for the respective currency. - */ - static isDefaultAccount(user: User, account: Account) { - if (account.currency === 'BTC') { - return user.defaultBtcAccountId === account.id; - } - if (account.currency === 'USD') { - return user.defaultUsdAccountId === account.id; - } - return false; - } - - /** - * Returns the accounts with the isDefault flag set to true if the account is the user's - * default account for the respective currency. Sorts the default account to the top. - */ - static getExtendedAccounts( - user: User, - accounts: Account[], - ): ExtendedAccount[] { - return accounts - .map((account) => ({ - ...account, - isDefault: UserService.isDefaultAccount(user, account), - })) - .sort((_, b) => (b.isDefault ? 1 : -1)); // Sort the default account to the top; - } - /** * 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. diff --git a/packages/wallet-sdk/index.ts b/packages/wallet-sdk/index.ts index a759d3e89..e44f3c4bc 100644 --- a/packages/wallet-sdk/index.ts +++ b/packages/wallet-sdk/index.ts @@ -16,6 +16,7 @@ export { DomainError, NotFoundError, SdkError, + SessionEndedError, UniqueConstraintError, } from './lib/error'; export { WebAssemblyUnavailableError } from './lib/spark/errors'; @@ -50,6 +51,10 @@ export { shouldAcceptTerms, shouldVerifyEmail, } from './domain/user/user'; +export { + getExtendedAccounts, + isDefaultAccount, +} from './domain/user/user-service'; export type { Contact } from './domain/contacts/contact'; export type { TransactionDirection, diff --git a/packages/wallet-sdk/lib/encryption.ts b/packages/wallet-sdk/lib/encryption.ts index 00e578739..c3da4d5a2 100644 --- a/packages/wallet-sdk/lib/encryption.ts +++ b/packages/wallet-sdk/lib/encryption.ts @@ -5,6 +5,7 @@ import { eciesEncryptBatch, } from '@agicash/ecies'; import { Money } from '@agicash/money'; +import { getPrivateKeyBytes, getPublicKey } from '@agicash/opensecret'; import { hexToBytes } from '@noble/hashes/utils'; import { decode, encode } from '@stablelib/base64'; @@ -187,19 +188,24 @@ export type Encryption = { ) => Promise; }; -export const getEncryption = ( - privateKey: Uint8Array, - publicKeyHex: string, -): Encryption => { - return { - encrypt: async (data: T) => - encryptToPublicKey(data, publicKeyHex), - decrypt: async (data: string) => - decryptWithPrivateKey(data, privateKey), - encryptBatch: async (data: T) => - encryptBatchToPublicKey(data, publicKeyHex), - decryptBatch: async ( - data: readonly [...{ [K in keyof T]: string }], - ) => decryptBatchWithPrivateKey(data, privateKey), - }; -}; +// 10111099 is 'enc' (for encryption) in ascii +const encryptionKeyDerivationPath = `m/10111099'/0'`; + +/** + * Derives the session's encryption private key from Open Secret. + * Network leaf: the caller memoizes (the SDK's session keys, or the web's + * TanStack cache). + */ +export const readEncryptionPrivateKey = (): Promise => + getPrivateKeyBytes({ + private_key_derivation_path: encryptionKeyDerivationPath, + }).then((response) => hexToBytes(response.private_key)); + +/** + * Derives the session's encryption public key from Open Secret, hex-encoded + * as persisted on the user row. Network leaf: the caller memoizes. + */ +export const readEncryptionPublicKey = (): Promise => + getPublicKey('schnorr', { + private_key_derivation_path: encryptionKeyDerivationPath, + }).then((response) => response.public_key); diff --git a/packages/wallet-sdk/lib/error.ts b/packages/wallet-sdk/lib/error.ts index 946d23d72..7ba3c627d 100644 --- a/packages/wallet-sdk/lib/error.ts +++ b/packages/wallet-sdk/lib/error.ts @@ -47,6 +47,20 @@ export class DisposedError extends SdkError { } } +/** + * Thrown when the session an operation belongs to ends (sign-out, a different + * user's login, or expiry) while the operation is in flight, so its result must + * not be used. Never retry the same operation — it would run under a session + * that no longer owns it. Transient, unlike {@link DisposedError}: the instance + * stays usable, and a fresh operation under the new session is what recovers. + */ +export class SessionEndedError extends SdkError { + constructor() { + super('The session ended before the operation completed'); + this.name = 'SessionEndedError'; + } +} + /** Thrown when a namespace is accessed before its migration slice has landed. */ export class NotImplementedError extends SdkError { constructor(namespace: string) { diff --git a/packages/wallet-sdk/temporary.ts b/packages/wallet-sdk/temporary.ts index e9c638cbe..f71595429 100644 --- a/packages/wallet-sdk/temporary.ts +++ b/packages/wallet-sdk/temporary.ts @@ -41,7 +41,8 @@ export { decryptWithPrivateKey, encryptBatchToPublicKey, encryptToPublicKey, - getEncryption, + readEncryptionPrivateKey, + readEncryptionPublicKey, } from './lib/encryption'; export * from './lib/spark'; export {