diff --git a/packages/wallet-sdk/src/domains/auth.test.ts b/packages/wallet-sdk/src/domains/auth.test.ts new file mode 100644 index 000000000..163a06ec2 --- /dev/null +++ b/packages/wallet-sdk/src/domains/auth.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, mock, test } from 'bun:test'; +import type { GuestAccountStorage } from '../internal/guest-account-storage'; +import type { OpenSecretClient } from '../internal/open-secret'; +import type { SessionResolver } from '../internal/session'; +import type { User } from '../types/user'; +import { AuthDomainImpl } from './auth'; + +/** A signed-in user fixture returned by the (faked) session resolver. */ +const signedInUser = { id: 'user-1', username: 'alice' } as unknown as User; + +/** A `Promise`-returning mock body (non-empty, to satisfy the no-empty-block lint). */ +const resolved = () => Promise.resolve(); + +/** + * Build an {@link AuthDomainImpl} over mocked collaborators, exposing the mocks so a test + * can assert calls. `storedGuest` seeds the guest-credential store. + */ +function makeAuth(storedGuest: { id: string; password: string } | null = null) { + const openSecret = { + signIn: mock(resolved), + signUp: mock(resolved), + signUpGuest: mock(() => Promise.resolve({ id: 'guest-new' })), + signInGuest: mock(resolved), + signOut: mock(resolved), + refresh: mock(resolved), + convertGuestToUserAccount: mock(resolved), + changePassword: mock(resolved), + requestPasswordReset: mock(resolved), + initiateGoogleAuth: mock(() => + Promise.resolve({ authUrl: 'https://auth/url' }), + ), + handleGoogleCallback: mock(resolved), + } as unknown as OpenSecretClient; + + const session = { + completeSignIn: mock(() => Promise.resolve(signedInUser)), + completeSignOut: mock(() => undefined), + } as unknown as SessionResolver; + + const guestStorage = { + get: mock(() => Promise.resolve(storedGuest)), + store: mock(resolved), + clear: mock(resolved), + } as unknown as GuestAccountStorage; + + const auth = new AuthDomainImpl(openSecret, session, guestStorage); + return { auth, openSecret, session, guestStorage }; +} + +describe('AuthDomainImpl.signIn', () => { + test('calls OpenSecret.signIn then resolves + returns the user', async () => { + const { auth, openSecret, session } = makeAuth(); + + const user = await auth.signIn({ + email: 'a@b.com', + password: 'pw', + }); + + expect(openSecret.signIn).toHaveBeenCalledWith('a@b.com', 'pw'); + expect(session.completeSignIn).toHaveBeenCalledTimes(1); + expect(user).toBe(signedInUser); + }); +}); + +describe('AuthDomainImpl.signInGuest', () => { + test('with no stored guest: creates one, stores creds, completes sign-in', async () => { + const { auth, openSecret, guestStorage } = makeAuth(null); + + await auth.signInGuest(); + + expect(openSecret.signUpGuest).toHaveBeenCalledTimes(1); + // signInGuest (re-sign-in path) must NOT be used when minting a fresh guest + expect(openSecret.signInGuest).not.toHaveBeenCalled(); + expect(guestStorage.store).toHaveBeenCalledTimes(1); + const stored = (guestStorage.store as ReturnType).mock + .calls[0][0]; + expect(stored.id).toBe('guest-new'); + expect(typeof stored.password).toBe('string'); + }); + + test('with a stored guest: re-signs into it, does not mint a new one', async () => { + const { auth, openSecret, guestStorage } = makeAuth({ + id: 'guest-existing', + password: 'secret', + }); + + await auth.signInGuest(); + + expect(openSecret.signInGuest).toHaveBeenCalledWith( + 'guest-existing', + 'secret', + ); + expect(openSecret.signUpGuest).not.toHaveBeenCalled(); + expect(guestStorage.store).not.toHaveBeenCalled(); + }); +}); + +describe('AuthDomainImpl.upgradeGuest', () => { + test('converts the account, clears guest creds, returns the user', async () => { + const { auth, openSecret, guestStorage } = makeAuth(); + + const user = await auth.upgradeGuest({ + email: 'a@b.com', + password: 'pw', + }); + + expect(openSecret.convertGuestToUserAccount).toHaveBeenCalledWith( + 'a@b.com', + 'pw', + ); + expect(guestStorage.clear).toHaveBeenCalledTimes(1); + expect(user).toBe(signedInUser); + }); +}); + +describe('AuthDomainImpl.signOut', () => { + test('signs out of the enclave then completes the local sign-out', async () => { + const { auth, openSecret, session } = makeAuth(); + + await auth.signOut(); + + expect(openSecret.signOut).toHaveBeenCalledTimes(1); + expect(session.completeSignOut).toHaveBeenCalledTimes(1); + }); +}); + +describe('AuthDomainImpl.resetPassword', () => { + test('sends a hashed secret (not the plaintext email) to OpenSecret', async () => { + const { auth, openSecret } = makeAuth(); + + await auth.resetPassword('a@b.com'); + + expect(openSecret.requestPasswordReset).toHaveBeenCalledTimes(1); + const [email, hashedSecret] = ( + openSecret.requestPasswordReset as ReturnType + ).mock.calls[0]; + expect(email).toBe('a@b.com'); + // a SHA-256 hex digest of the generated secret + expect(hashedSecret).toMatch(/^[0-9a-f]{64}$/); + }); +}); + +describe('AuthDomainImpl.changePassword', () => { + test('passes current + new passwords through to the enclave', async () => { + const { auth, openSecret } = makeAuth(); + + await auth.changePassword({ current: 'old', new: 'new' }); + + expect(openSecret.changePassword).toHaveBeenCalledWith('old', 'new'); + }); +}); + +describe('AuthDomainImpl.beginGoogleSignIn', () => { + test('returns the enclave auth URL', async () => { + const { auth } = makeAuth(); + expect(await auth.beginGoogleSignIn()).toEqual({ + authUrl: 'https://auth/url', + }); + }); +}); + +describe('AuthDomainImpl.completeOAuth', () => { + test('forwards code/state/inviteCode and resolves the user', async () => { + const { auth, openSecret, session } = makeAuth(); + + const user = await auth.completeOAuth({ + code: 'c', + state: 's', + inviteCode: 'inv', + }); + + expect(openSecret.handleGoogleCallback).toHaveBeenCalledWith( + 'c', + 's', + 'inv', + ); + expect(session.completeSignIn).toHaveBeenCalledTimes(1); + expect(user).toBe(signedInUser); + }); + + test("defaults inviteCode to '' when omitted", async () => { + const { auth, openSecret } = makeAuth(); + + await auth.completeOAuth({ code: 'c', state: 's' }); + + expect(openSecret.handleGoogleCallback).toHaveBeenCalledWith('c', 's', ''); + }); +}); diff --git a/packages/wallet-sdk/src/domains/auth.ts b/packages/wallet-sdk/src/domains/auth.ts new file mode 100644 index 000000000..9c5d4af7d --- /dev/null +++ b/packages/wallet-sdk/src/domains/auth.ts @@ -0,0 +1,202 @@ +/** + * `AuthDomain` implementation — §4 of the contract, Slice 1. + * + * EXTRACTED (re-housed framework-free) from `apps/web-wallet/app/features/user/auth.ts` + * (the `useAuthActions` hook + the OpenSecret-SDK wrappers) and + * `apps/web-wallet/app/features/shared/auth.ts`. Master expresses auth as a React hook + * over TanStack-Query invalidation + `react-router` navigation + `window.localStorage`; + * all of that is stripped. What remains is the enclave calls + the agicash `User` + * resolution + the SDK's `auth:*` events: + * + * - sign-in/up/guest/upgrade/OAuth-complete call OpenSecret, then resolve the + * `wallet.users` DB row (via {@link SessionResolver.completeSignIn}) and return the + * domain {@link User}, emitting `auth:signed-in`; + * - sign-out calls OpenSecret then emits `auth:signed-out`; + * - refresh / changePassword / resetPassword are thin enclave pass-throughs that master + * expresses implicitly via the OS SDK (the contract names them explicitly). + * + * No `getCurrentSession` method (contract decision 4 — methods return `User`, not a + * session; the JWT stays SDK-internal). OAuth is a browser REDIRECT (`beginGoogleSignIn` + * returns `{ authUrl }`), web-only. + * + * @module + */ +import { computeSHA256, generateRandomPassword } from '../internal/crypto'; +import type { GuestAccountStorage } from '../internal/guest-account-storage'; +import type { OpenSecretClient } from '../internal/open-secret'; +import type { SessionResolver } from '../internal/session'; +import type { AuthDomain } from '../domains'; +import type { User } from '../types/user'; + +/** Length of a generated guest-account password (master `generateRandomPassword(32)`). */ +const GUEST_PASSWORD_LENGTH = 32; +/** Length of the generated password-reset secret (master `generateRandomPassword(20)`). */ +const RESET_SECRET_LENGTH = 20; + +/** OAuth callback params handed to {@link AuthDomainImpl.completeOAuth}. */ +type OAuthCallbackParams = { + /** OAuth authorization code from the redirect. */ + code: string; + /** OAuth `state` param from the redirect (CSRF + session correlation). */ + state: string; + /** Optional invite code for new-user registration (defaults to `''`, as master does). */ + inviteCode?: string; +}; + +/** + * The auth domain. Construct with the enclave client, the session resolver (id → DB user + * + `auth:*` emission), and the guest-credential store. + */ +export class AuthDomainImpl implements AuthDomain { + /** + * @param openSecret - the OpenSecret enclave client. + * @param session - resolves the agicash user + emits `auth:*`. + * @param guestStorage - persists guest credentials for same-device re-sign-in. + */ + constructor( + private readonly openSecret: OpenSecretClient, + private readonly session: SessionResolver, + private readonly guestStorage: GuestAccountStorage, + ) {} + + /** + * Sign in an existing user with email + password. + * + * @param params - `{ email, password }`. + * @returns the signed-in {@link User}. + */ + async signIn(params: { email: string; password: string }): Promise { + await this.openSecret.signIn(params.email, params.password); + return this.session.completeSignIn(); + } + + /** + * Create a new full (email) account and sign it in. + * + * @param params - `{ email, password }`. + * @returns the new {@link User}. + */ + async signUp(params: { email: string; password: string }): Promise { + await this.openSecret.signUp(params.email, params.password); + return this.session.completeSignIn(); + } + + /** + * Create and sign in an anonymous guest user. If this device already has a stored guest + * account, signs back into THAT account instead of minting a new one (master parity: + * `useAuthActions.signUpGuest`). The generated password is persisted via the storage + * adapter. + * + * @returns the guest {@link User}. + */ + async signInGuest(): Promise { + const existing = await this.guestStorage.get(); + if (existing) { + await this.openSecret.signInGuest(existing.id, existing.password); + return this.session.completeSignIn(); + } + + const password = generateRandomPassword(GUEST_PASSWORD_LENGTH); + const { id } = await this.openSecret.signUpGuest(password); + await this.guestStorage.store({ id, password }); + return this.session.completeSignIn(); + } + + /** + * Sign out the current user and clear the session. + * + * The OpenSecret SDK clears its own persisted tokens; this drops the cached Supabase + * token and emits `auth:signed-out`. + */ + async signOut(): Promise { + await this.openSecret.signOut(); + this.session.completeSignOut(); + } + + /** + * Refresh the current session/access token (extends the session). Master expresses this + * implicitly via the OS SDK; the contract names it explicitly. + */ + async refresh(): Promise { + await this.openSecret.refresh(); + } + + /** + * Send a password-reset email to `email`. + * + * Re-houses master `useAuthActions.requestPasswordReset`: generate a random secret, send + * its SHA-256 hash to OpenSecret (the emailed code + this secret later confirm the reset + * via the enclave). The contract types this `Promise`; the secret needed for the + * confirm step is held by OpenSecret's flow, so it is not surfaced here. + * + * @param email - the account email to reset. + */ + async resetPassword(email: string): Promise { + const secret = generateRandomPassword(RESET_SECRET_LENGTH); + const hashedSecret = await computeSHA256(secret); + await this.openSecret.requestPasswordReset(email, hashedSecret); + } + + /** + * Change the signed-in user's password (requires the current password). + * + * @param params - `{ current, new }` passwords. + */ + async changePassword(params: { + current: string; + new: string; + }): Promise { + await this.openSecret.changePassword(params.current, params.new); + } + + /** + * Upgrade the current guest user into a full email account, preserving funds/history. + * Clears the stored guest credentials on success (master parity: + * `useUpgradeGuestToFullAccount`). + * + * @param params - `{ email, password }` for the new full account. + * @returns the upgraded {@link User}. + */ + async upgradeGuest(params: { + email: string; + password: string; + }): Promise { + await this.openSecret.convertGuestToUserAccount( + params.email, + params.password, + ); + await this.guestStorage.clear(); + return this.session.completeSignIn(); + } + + /** + * Begin Google OAuth — returns the URL to redirect the browser to. OAuth is a REDIRECT + * flow, not a synchronous session; web-only (the MCP daemon cannot do Google auth). + * + * NOTE: master also stashes the pre-redirect `location.search`/`hash` into an OAuth + * login-session store and folds a `sessionId` into the `state` param so the post-redirect + * page can restore context. That stashing is browser/router-specific UI plumbing and is + * left to the web consumer (the SDK is framework-free); the SDK returns the enclave's + * `authUrl` directly. + * + * @returns `{ authUrl }` to redirect to. + */ + async beginGoogleSignIn(): Promise<{ authUrl: string }> { + return this.openSecret.initiateGoogleAuth(); + } + + /** + * Complete OAuth from the redirect callback params; resolves with the user. + * + * @param params - `{ code, state, inviteCode? }` from the OAuth redirect. + * @returns the signed-in {@link User}. + */ + async completeOAuth(params: OAuthCallbackParams): Promise { + await this.openSecret.handleGoogleCallback( + params.code, + params.state, + params.inviteCode ?? '', + ); + return this.session.completeSignIn(); + } +} diff --git a/packages/wallet-sdk/src/domains/user.test.ts b/packages/wallet-sdk/src/domains/user.test.ts new file mode 100644 index 000000000..fd409f195 --- /dev/null +++ b/packages/wallet-sdk/src/domains/user.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { DomainError } from '../errors'; +import type { SessionResolver } from '../internal/session'; +import type { UserRepository } from '../internal/user-repository'; +import type { User } from '../types/user'; +import { UserDomainImpl } from './user'; + +const currentUser = { id: 'user-1', username: 'alice' } as unknown as User; + +describe('UserDomainImpl.getCurrentUser', () => { + test('delegates to the session resolver', async () => { + const session = { + getCurrentUser: mock(async () => currentUser), + } as unknown as SessionResolver; + const users = {} as unknown as UserRepository; + const domain = new UserDomainImpl(session, users); + + expect(await domain.getCurrentUser()).toBe(currentUser); + expect(session.getCurrentUser).toHaveBeenCalledTimes(1); + }); + + test('returns null when signed out', async () => { + const session = { + getCurrentUser: mock(async () => null), + } as unknown as SessionResolver; + const domain = new UserDomainImpl(session, {} as unknown as UserRepository); + + expect(await domain.getCurrentUser()).toBeNull(); + }); +}); + +describe('UserDomainImpl.updateUsername', () => { + test('resolves the current user id then updates the username', async () => { + const updated = { ...currentUser, username: 'bob' }; + const session = { + requireCurrentUser: mock(async () => currentUser), + } as unknown as SessionResolver; + const users = { + updateUsername: mock(async () => updated), + } as unknown as UserRepository; + const domain = new UserDomainImpl(session, users); + + const result = await domain.updateUsername('bob'); + + expect(session.requireCurrentUser).toHaveBeenCalledTimes(1); + expect(users.updateUsername).toHaveBeenCalledWith('user-1', 'bob'); + expect(result.username).toBe('bob'); + }); + + test('propagates a DomainError from the repository (username taken)', async () => { + const session = { + requireCurrentUser: mock(async () => currentUser), + } as unknown as SessionResolver; + const users = { + updateUsername: mock(async () => { + throw new DomainError( + 'This username is already taken', + 'USERNAME_TAKEN', + ); + }), + } as unknown as UserRepository; + const domain = new UserDomainImpl(session, users); + + await expect(domain.updateUsername('taken')).rejects.toBeInstanceOf( + DomainError, + ); + }); +}); diff --git a/packages/wallet-sdk/src/domains/user.ts b/packages/wallet-sdk/src/domains/user.ts new file mode 100644 index 000000000..2c883644e --- /dev/null +++ b/packages/wallet-sdk/src/domains/user.ts @@ -0,0 +1,64 @@ +/** + * `UserDomain` implementation — §4 of the contract, Slice 1. + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/user/user-hooks.tsx` (`useUser` / `useUpdateUsername` / + * `useUpdateUser`) + `app/features/user/user-repository.ts` (the read + the username + * update). Master expresses these as React hooks over TanStack-Query (a `useSuspenseQuery` + * read + a `useMutation` that writes the `UserCache`); the SDK exposes them as plain async + * methods over the `wallet.users` repository, with no cache (events drive the consumer's + * read-model). + * + * @module + */ +import type { SessionResolver } from '../internal/session'; +import type { UserRepository } from '../internal/user-repository'; +import type { UserDomain } from '../domains'; +import type { User } from '../types/user'; + +/** + * The user domain. Construct with the session resolver (current-user resolution) and the + * `wallet.users` repository (username update). + */ +export class UserDomainImpl implements UserDomain { + /** + * @param session - resolves the current agicash {@link User} (enclave id → DB row). + * @param users - the `wallet.users` repository (username update). + */ + constructor( + private readonly session: SessionResolver, + private readonly users: UserRepository, + ) {} + + /** + * The currently signed-in user, or `null` if none. + * + * Re-houses master `useUser`: the enclave user id → the `wallet.users` DB row → the + * domain {@link User}. Returns `null` when signed out. + * + * @returns the current {@link User}, or `null`. + */ + async getCurrentUser(): Promise { + return this.session.getCurrentUser(); + } + + /** + * Change the signed-in user's username. + * + * Re-houses master `useUpdateUsername` → `WriteUserRepository.update({ username })`. + * Resolves the current user id from the session, updates the row, and returns the fresh + * {@link User}. Throws {@link DomainError} if the username is already taken (the + * repository maps the Postgres unique-violation to it). + * + * @param username - the new username. + * @returns the updated {@link User}. + * @throws DomainError if the username is taken. + * @throws Error if there is no authenticated user. + */ + async updateUsername(username: string): Promise { + const current = await this.session.requireCurrentUser(); + // The realtime forwarder (Slice 5) also surfaces the row change as an event; this + // returns the fresh user directly so the caller has it without awaiting that. + return this.users.updateUsername(current.id, username); + } +} diff --git a/packages/wallet-sdk/src/internal/crypto.test.ts b/packages/wallet-sdk/src/internal/crypto.test.ts new file mode 100644 index 000000000..c7cd98d51 --- /dev/null +++ b/packages/wallet-sdk/src/internal/crypto.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'bun:test'; +import { computeSHA256, generateRandomPassword } from './crypto'; + +describe('generateRandomPassword', () => { + test('returns a string of the requested length', () => { + expect(generateRandomPassword(32)).toHaveLength(32); + expect(generateRandomPassword(20)).toHaveLength(20); + }); + + test('defaults to length 24', () => { + expect(generateRandomPassword()).toHaveLength(24); + }); + + test('produces a different value each call (random)', () => { + const a = generateRandomPassword(32); + const b = generateRandomPassword(32); + expect(a).not.toBe(b); + }); + + test('only uses the allowed character set', () => { + const allowed = /^[a-zA-Z0-9!@#$%^&*()_+~]+$/; + expect(generateRandomPassword(200)).toMatch(allowed); + }); +}); + +describe('computeSHA256', () => { + test('returns the known SHA-256 hex digest of "abc"', async () => { + // canonical SHA-256("abc") + expect(await computeSHA256('abc')).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ); + }); + + test('returns the digest of the empty string', async () => { + expect(await computeSHA256('')).toBe( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ); + }); + + test('is a 64-char lowercase hex string', async () => { + const digest = await computeSHA256('hello world'); + expect(digest).toMatch(/^[0-9a-f]{64}$/); + }); +}); diff --git a/packages/wallet-sdk/src/internal/crypto.ts b/packages/wallet-sdk/src/internal/crypto.ts new file mode 100644 index 000000000..c9992bec3 --- /dev/null +++ b/packages/wallet-sdk/src/internal/crypto.ts @@ -0,0 +1,54 @@ +/** + * Small crypto helpers — Slice 1 (auth + user). + * + * EXTRACTED (re-housed framework-free) from `app/lib/password-generator.ts` and + * `app/lib/sha256.ts`. The only re-housing is `window.crypto` → the global `crypto` + * (WebCrypto; available in browsers + Bun/Node ≥ 19 — the SDK's targets) and dropping + * master's `window.getMockPassword` test hook. Used for guest-account passwords and the + * password-reset secret hash. + * + * @module + */ + +/** The character sets a generated password may draw from. */ +const LETTERS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; +const NUMBERS = '0123456789'; +const SPECIAL = '!@#$%^&*()_+~'; + +/** + * Generate a cryptographically-random password. + * + * Verbatim logic from master `generateRandomPassword` (letters + numbers + special by + * default), re-housed off `window.crypto` onto the global `crypto.getRandomValues`. + * + * @param length - password length (default 24; master uses 32 for guest accounts, 20 for + * the reset secret). + * @returns the generated password. + */ +export function generateRandomPassword(length = 24): string { + const charset = LETTERS + NUMBERS + SPECIAL; + const password: string[] = []; + for (let i = 0; i < length; i++) { + const randomIndex = + crypto.getRandomValues(new Uint32Array(1))[0] % charset.length; + password.push(charset[randomIndex]); + } + return password.join(''); +} + +/** + * SHA-256 a string and return the lowercase hex digest. + * + * Verbatim from master `computeSHA256` (WebCrypto `crypto.subtle.digest`). Used to hash + * the password-reset secret before sending it to OpenSecret. + * + * @param message - the input string. + * @returns the hex-encoded SHA-256 digest. + */ +export async function computeSHA256(message: string): Promise { + const data = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/packages/wallet-sdk/src/internal/db-user.test.ts b/packages/wallet-sdk/src/internal/db-user.test.ts new file mode 100644 index 000000000..74c02129b --- /dev/null +++ b/packages/wallet-sdk/src/internal/db-user.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test'; +import { type AgicashDbUser, dbUserToUser } from './db-user'; + +/** A complete `wallet.users` row with an email (a full user). */ +const fullRow: AgicashDbUser = { + id: 'user-1', + username: 'alice', + email: 'alice@example.com', + email_verified: true, + created_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-02T00:00:00.000Z', + default_btc_account_id: 'btc-acct', + default_usd_account_id: 'usd-acct', + default_currency: 'BTC', + cashu_locking_xpub: 'xpub-1', + encryption_public_key: 'enc-pk', + spark_identity_public_key: 'spark-pk', + terms_accepted_at: '2024-01-01T00:00:00.000Z', + gift_card_mint_terms_accepted_at: null, +}; + +describe('dbUserToUser', () => { + test('maps a row with an email to a FullUser (isGuest: false)', () => { + const user = dbUserToUser(fullRow); + + expect(user.isGuest).toBe(false); + // narrow for the email field + if (user.isGuest === false) { + expect(user.email).toBe('alice@example.com'); + } + expect(user.id).toBe('user-1'); + expect(user.username).toBe('alice'); + expect(user.emailVerified).toBe(true); + expect(user.createdAt).toBe('2024-01-01T00:00:00.000Z'); + expect(user.updatedAt).toBe('2024-01-02T00:00:00.000Z'); + expect(user.defaultBtcAccountId).toBe('btc-acct'); + expect(user.defaultUsdAccountId).toBe('usd-acct'); + expect(user.defaultCurrency).toBe('BTC'); + expect(user.cashuLockingXpub).toBe('xpub-1'); + expect(user.encryptionPublicKey).toBe('enc-pk'); + expect(user.sparkIdentityPublicKey).toBe('spark-pk'); + expect(user.termsAcceptedAt).toBe('2024-01-01T00:00:00.000Z'); + expect(user.giftCardMintTermsAcceptedAt).toBeNull(); + }); + + test('maps a row with no email to a GuestUser (isGuest: true)', () => { + const user = dbUserToUser({ ...fullRow, email: null }); + + expect(user.isGuest).toBe(true); + expect('email' in user).toBe(false); + }); + + test('defaults a null default_btc_account_id to an empty string', () => { + const user = dbUserToUser({ ...fullRow, default_btc_account_id: null }); + expect(user.defaultBtcAccountId).toBe(''); + }); + + test('passes through a null default_usd_account_id as null', () => { + const user = dbUserToUser({ ...fullRow, default_usd_account_id: null }); + expect(user.defaultUsdAccountId).toBeNull(); + }); +}); diff --git a/packages/wallet-sdk/src/internal/db-user.ts b/packages/wallet-sdk/src/internal/db-user.ts new file mode 100644 index 000000000..6cd0d075c --- /dev/null +++ b/packages/wallet-sdk/src/internal/db-user.ts @@ -0,0 +1,100 @@ +/** + * Internal DB ⇄ domain `User` mapping — Slice 1 (auth + user). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/user/user-repository.ts` (`ReadUserRepository.toUser` + * + the `users`-table reads/writes). Master expresses the user as a DB row in the + * `wallet.users` table keyed by the OpenSecret user id — the agicash domain `User` + * is NOT the OpenSecret `UserResponse`; it is that DB row mapped to the domain shape. + * + * This module owns: + * - {@link AgicashDbUser} — the `wallet.users` row shape (master: + * `agicash-db/database.ts#AgicashDbUser`, itself `database.types.ts` generated). Lifted + * verbatim as a hand-written type so the SDK can type the otherwise-`any` Supabase + * reads without pulling the full generated `Database` types (those land in a later slice). + * - {@link dbUserToUser} — the row→domain mapper (verbatim logic from + * `ReadUserRepository.toUser`: email present ⇒ `FullUser`, else `GuestUser`; + * `default_btc_account_id` defaults to `''`). + * + * @module + */ +import type { Currency } from '../types/money'; +import type { FullUser, GuestUser, User } from '../types/user'; + +/** + * A row of the `wallet.users` table. + * + * Lifted verbatim from master `agicash-db/database.ts#AgicashDbUser` + * (`Database['wallet']['Tables']['users']['Row']`, generated in + * `supabase/database.types.ts`). Hand-written here so the SDK can narrow the (currently + * untyped) Supabase client reads in this slice; replaced by the generated `Database` + * types when those are lifted into the package. + */ +export type AgicashDbUser = { + /** UUID primary key (matches the OpenSecret user id). */ + id: string; + /** The user's unique handle. */ + username: string; + /** The user's email, or null for a guest. */ + email: string | null; + /** Whether the email has been verified. */ + email_verified: boolean; + /** Row creation time, ISO 8601. */ + created_at: string; + /** Row last-update time, ISO 8601. */ + updated_at: string; + /** UUID of the default BTC account, or null if unset. */ + default_btc_account_id: string | null; + /** UUID of the default USD account, or null if unset. */ + default_usd_account_id: string | null; + /** The user's preferred display currency. */ + default_currency: Currency; + /** Extended public key used to derive cashu quote-locking keys. */ + cashu_locking_xpub: string; + /** Public key used to encrypt the user's data at rest. */ + encryption_public_key: string; + /** The user's Spark identity public key. */ + spark_identity_public_key: string; + /** When the user accepted the terms of service (null if not yet accepted). */ + terms_accepted_at: string | null; + /** When the user accepted the gift-card mint terms (null if not yet accepted). */ + gift_card_mint_terms_accepted_at: string | null; +}; + +/** + * Map a `wallet.users` DB row to the domain {@link User}. + * + * Verbatim logic from master `ReadUserRepository.toUser`: an `email` present ⇒ a + * {@link FullUser} (`isGuest: false`), otherwise a {@link GuestUser} (`isGuest: true`); + * `default_btc_account_id` falls back to `''` when null. + * + * @param dbUser - the DB row. + * @returns the domain user. + */ +export function dbUserToUser(dbUser: AgicashDbUser): User { + const commonData = { + id: dbUser.id, + username: dbUser.username, + emailVerified: dbUser.email_verified, + createdAt: dbUser.created_at, + updatedAt: dbUser.updated_at, + cashuLockingXpub: dbUser.cashu_locking_xpub, + encryptionPublicKey: dbUser.encryption_public_key, + sparkIdentityPublicKey: dbUser.spark_identity_public_key, + defaultBtcAccountId: dbUser.default_btc_account_id ?? '', + defaultUsdAccountId: dbUser.default_usd_account_id, + defaultCurrency: dbUser.default_currency, + termsAcceptedAt: dbUser.terms_accepted_at, + giftCardMintTermsAcceptedAt: dbUser.gift_card_mint_terms_accepted_at, + }; + + if (dbUser.email) { + return { + ...commonData, + email: dbUser.email, + isGuest: false, + } satisfies FullUser; + } + + return { ...commonData, isGuest: true } satisfies GuestUser; +} diff --git a/packages/wallet-sdk/src/internal/guest-account-storage.ts b/packages/wallet-sdk/src/internal/guest-account-storage.ts new file mode 100644 index 000000000..8b30e1ba2 --- /dev/null +++ b/packages/wallet-sdk/src/internal/guest-account-storage.ts @@ -0,0 +1,83 @@ +/** + * Guest-account credential storage — Slice 1 (auth + user). + * + * EXTRACTED (re-housed framework-free) from + * `app/features/user/guest-account-storage.ts`. A guest account has no email, so its + * generated id + password must be persisted to let the SAME device sign back into the + * SAME guest account (rather than minting a new one each time). Master persists this in + * `window.localStorage`; the SDK persists it through the injected {@link StorageAdapter} + * (so MCP/fs works too). + * + * @module + */ +import type { StorageAdapter } from '../types/dependencies'; + +/** Storage key the guest credentials are persisted under (matches master). */ +const STORAGE_KEY = 'guestAccount'; + +/** The persisted guest credentials. */ +export type GuestAccount = { + /** The guest user's id (from OpenSecret `signUpGuest`). */ + id: string; + /** The randomly-generated guest password. */ + password: string; +}; + +/** + * Validate a parsed value as {@link GuestAccount} (both fields present + strings). Replaces + * master's `zod/mini` schema check without adding a zod dependency to the SDK for this one + * shape. + */ +function isGuestAccount(value: unknown): value is GuestAccount { + return ( + typeof value === 'object' && + value !== null && + typeof (value as GuestAccount).id === 'string' && + typeof (value as GuestAccount).password === 'string' + ); +} + +/** + * A small store for the guest-account credentials, over the injected storage adapter. + * + * Re-houses master's `guestAccountStorage` singleton as an instance bound to the SDK's + * {@link StorageAdapter}. `get` tolerates absent / malformed data by returning `null`. + */ +export class GuestAccountStorage { + /** + * @param storage - the pluggable storage adapter. + */ + constructor(private readonly storage: StorageAdapter) {} + + /** + * The persisted guest credentials, or `null` if none / malformed. + * + * @returns the stored {@link GuestAccount} or `null`. + */ + async get(): Promise { + const raw = await this.storage.getItem(STORAGE_KEY); + if (!raw) { + return null; + } + try { + const parsed: unknown = JSON.parse(raw); + return isGuestAccount(parsed) ? parsed : null; + } catch { + return null; + } + } + + /** + * Persist the guest credentials. + * + * @param account - the `{ id, password }` to store. + */ + async store(account: GuestAccount): Promise { + await this.storage.setItem(STORAGE_KEY, JSON.stringify(account)); + } + + /** Remove the persisted guest credentials (e.g. after upgrading to a full account). */ + async clear(): Promise { + await this.storage.removeItem(STORAGE_KEY); + } +} diff --git a/packages/wallet-sdk/src/internal/open-secret.ts b/packages/wallet-sdk/src/internal/open-secret.ts index c69395a63..abe5fcad3 100644 --- a/packages/wallet-sdk/src/internal/open-secret.ts +++ b/packages/wallet-sdk/src/internal/open-secret.ts @@ -19,9 +19,46 @@ * * @module */ -import { configure, generateThirdPartyToken } from '@agicash/opensecret'; +import { + changePassword as osChangePassword, + configure, + confirmPasswordReset as osConfirmPasswordReset, + convertGuestToUserAccount as osConvertGuestToUserAccount, + fetchUser as osFetchUser, + generateThirdPartyToken, + handleGoogleCallback as osHandleGoogleCallback, + initiateGoogleAuth as osInitiateGoogleAuth, + refreshAccessToken as osRefreshAccessToken, + requestPasswordReset as osRequestPasswordReset, + signIn as osSignIn, + signInGuest as osSignInGuest, + signOut as osSignOut, + signUp as osSignUp, + signUpGuest as osSignUpGuest, +} from '@agicash/opensecret'; +import { jwtDecode } from 'jwt-decode'; import type { StorageAdapter } from '../types/dependencies'; +/** + * The current OpenSecret user (master `AuthUser = UserResponse['user']`). This is the + * ENCLAVE user (id / email / verified flag), NOT the agicash domain `User` (which is a + * `wallet.users` DB row). The auth/user domains use `.id` to read the DB row. + */ +export type OpenSecretUser = { + id: string; + name: string | null; + email?: string; + email_verified: boolean; + login_method: string; + created_at: string; + updated_at: string; +}; + +/** localStorage key the OpenSecret SDK persists its access token under. */ +const ACCESS_TOKEN_KEY = 'access_token'; +/** localStorage key the OpenSecret SDK persists its refresh token under. */ +const REFRESH_TOKEN_KEY = 'refresh_token'; + /** Init params for the OpenSecret client (from `SdkConfig.openSecret`). */ export type OpenSecretConfig = { /** enclave/auth backend URL (master `VITE_OPEN_SECRET_API_URL`). */ @@ -70,4 +107,135 @@ export class OpenSecretClient { const { token } = await generateThirdPartyToken(audience); return token; } + + // --- session presence ------------------------------------------------------ + + /** + * Whether a (non-expired) OpenSecret session exists, read from the storage adapter. + * + * Re-houses master `shared/auth.ts#isLoggedIn` off `window.localStorage` onto the + * injected {@link StorageAdapter}: there must be both an access and a refresh token, + * and the refresh token must not be expired. Used to short-circuit `getCurrentUser` + * and the Supabase token provider to `null` when signed out (rather than letting the + * enclave request fail). + * + * @returns `true` when a live session is present. + */ + async hasSession(): Promise { + const [accessToken, refreshToken] = await Promise.all([ + this.storage.getItem(ACCESS_TOKEN_KEY), + this.storage.getItem(REFRESH_TOKEN_KEY), + ]); + if (!accessToken || !refreshToken) { + return false; + } + const { exp } = jwtDecode(refreshToken); + return !!exp && exp * 1000 > Date.now(); + } + + // --- auth (OpenSecret SDK wrappers; framework-free) ------------------------ + // + // Each is a thin pass-through to the module-global `@agicash/opensecret` function; + // the SDK domain layer (see ../domains/auth) adds the DB-user resolution + events. + // The OpenSecret SDK persists/clears its own access+refresh tokens internally. + + /** Sign in an existing user (enclave). Persists the session internally. */ + async signIn(email: string, password: string): Promise { + await osSignIn(email, password); + } + + /** Create a full (email) user and sign it in (enclave). `inviteCode` is `''` (master). */ + async signUp(email: string, password: string): Promise { + await osSignUp(email, password, ''); + } + + /** Create a guest user (enclave), returning its generated id. `inviteCode` is `''`. */ + async signUpGuest(password: string): Promise<{ id: string }> { + const { id } = await osSignUpGuest(password, ''); + return { id }; + } + + /** Sign in to an existing guest account by its id + generated password (enclave). */ + async signInGuest(id: string, password: string): Promise { + await osSignInGuest(id, password); + } + + /** Sign out (enclave). Clears the persisted session internally. */ + async signOut(): Promise { + await osSignOut(); + } + + /** Refresh the access token (enclave). Updates the persisted session internally. */ + async refresh(): Promise { + await osRefreshAccessToken(); + } + + /** Convert the current guest user into a full (email) account (enclave). */ + async convertGuestToUserAccount( + email: string, + password: string, + ): Promise { + await osConvertGuestToUserAccount(email, password); + } + + /** Change the signed-in user's password (enclave). */ + async changePassword( + currentPassword: string, + newPassword: string, + ): Promise { + await osChangePassword(currentPassword, newPassword); + } + + /** + * Request a password reset (enclave). The caller hashes a freshly-generated secret and + * passes the hash; the secret is returned so the caller can later confirm the reset. + */ + async requestPasswordReset( + email: string, + hashedSecret: string, + ): Promise { + await osRequestPasswordReset(email, hashedSecret); + } + + /** Confirm a password reset with the emailed code + the secret from the request (enclave). */ + async confirmPasswordReset( + email: string, + alphanumericCode: string, + plaintextSecret: string, + newPassword: string, + ): Promise { + await osConfirmPasswordReset( + email, + alphanumericCode, + plaintextSecret, + newPassword, + ); + } + + /** Begin Google OAuth (enclave); returns the raw `auth_url` to redirect to. `inviteCode` is `''`. */ + async initiateGoogleAuth(): Promise<{ authUrl: string }> { + const { auth_url } = await osInitiateGoogleAuth(''); + return { authUrl: auth_url }; + } + + /** Complete Google OAuth from the redirect callback params (enclave). Persists the session. */ + async handleGoogleCallback( + code: string, + state: string, + inviteCode: string, + ): Promise { + await osHandleGoogleCallback(code, state, inviteCode); + } + + /** + * The current enclave user (`fetchUser`), or `null` when there is no session. Returns + * the OpenSecret user — the domain layer maps `.id` to the `wallet.users` DB row. + */ + async fetchUser(): Promise { + if (!(await this.hasSession())) { + return null; + } + const { user } = await osFetchUser(); + return user; + } } diff --git a/packages/wallet-sdk/src/internal/session.test.ts b/packages/wallet-sdk/src/internal/session.test.ts new file mode 100644 index 000000000..8cd218280 --- /dev/null +++ b/packages/wallet-sdk/src/internal/session.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'bun:test'; +import type { SdkEventMap } from '../events'; +import type { User } from '../types/user'; +import { TypedEventEmitter } from './event-emitter'; +import type { OpenSecretClient, OpenSecretUser } from './open-secret'; +import { SessionResolver } from './session'; +import type { SupabaseSessionTokenProvider } from './supabase-session'; +import type { UserRepository } from './user-repository'; + +/** A domain user fixture. */ +const domainUser: User = { + id: 'user-1', + username: 'alice', + email: 'alice@example.com', + isGuest: false, + emailVerified: true, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + defaultBtcAccountId: 'btc', + defaultUsdAccountId: null, + defaultCurrency: 'BTC', + cashuLockingXpub: 'xpub', + encryptionPublicKey: 'enc', + sparkIdentityPublicKey: 'spark', + termsAcceptedAt: null, + giftCardMintTermsAcceptedAt: null, +}; + +/** An OpenSecret enclave user fixture. */ +const osUser: OpenSecretUser = { + id: 'user-1', + name: null, + email: 'alice@example.com', + email_verified: true, + login_method: 'email', + created_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', +}; + +/** + * Build a {@link SessionResolver} over fakes. `currentOsUser` controls who the enclave + * reports (null = signed out); the user repo always maps id → {@link domainUser}. Returns + * the resolver plus the spies (token-cleared count, emitted events). + */ +function makeResolver(currentOsUser: OpenSecretUser | null) { + let cleared = 0; + const events = new TypedEventEmitter(); + const openSecret = { + fetchUser: async () => currentOsUser, + } as unknown as OpenSecretClient; + const users = { + get: async (id: string) => ({ ...domainUser, id }), + } as unknown as UserRepository; + const sessionToken = { + clear: () => { + cleared += 1; + }, + } as unknown as SupabaseSessionTokenProvider; + + const resolver = new SessionResolver(openSecret, users, sessionToken, events); + return { resolver, events, getCleared: () => cleared }; +} + +describe('SessionResolver.getCurrentUser', () => { + test('returns null when there is no enclave session', async () => { + const { resolver } = makeResolver(null); + expect(await resolver.getCurrentUser()).toBeNull(); + }); + + test('resolves the DB user for the signed-in enclave id', async () => { + const { resolver } = makeResolver(osUser); + const user = await resolver.getCurrentUser(); + expect(user?.id).toBe('user-1'); + expect(user?.username).toBe('alice'); + }); +}); + +describe('SessionResolver.requireCurrentUser', () => { + test('throws when signed out', async () => { + const { resolver } = makeResolver(null); + await expect(resolver.requireCurrentUser()).rejects.toThrow( + 'No authenticated user', + ); + }); +}); + +describe('SessionResolver.completeSignIn', () => { + test('clears the cached token, returns the user, and emits auth:signed-in', async () => { + const { resolver, events, getCleared } = makeResolver(osUser); + const received: User[] = []; + events.on('auth:signed-in', ({ user }) => received.push(user)); + + const user = await resolver.completeSignIn(); + + expect(getCleared()).toBe(1); + expect(user.id).toBe('user-1'); + expect(received).toHaveLength(1); + expect(received[0]?.id).toBe('user-1'); + }); +}); + +describe('SessionResolver.completeSignOut', () => { + test('clears the cached token and emits auth:signed-out', () => { + const { resolver, events, getCleared } = makeResolver(osUser); + let signedOut = 0; + events.on('auth:signed-out', () => { + signedOut += 1; + }); + + resolver.completeSignOut(); + + expect(getCleared()).toBe(1); + expect(signedOut).toBe(1); + }); +}); diff --git a/packages/wallet-sdk/src/internal/session.ts b/packages/wallet-sdk/src/internal/session.ts new file mode 100644 index 000000000..df941d2e6 --- /dev/null +++ b/packages/wallet-sdk/src/internal/session.ts @@ -0,0 +1,97 @@ +/** + * Internal session resolver — Slice 1 (auth + user). + * + * The agicash domain {@link User} lives in the `wallet.users` DB row, keyed by the + * OpenSecret user id. Resolving "the current user" therefore takes two steps: ask the + * enclave who is signed in (`OpenSecretClient.fetchUser`), then read that id's DB row + * (`UserRepository.get`). Both the auth domain (whose methods return the user after a + * sign-in) and the user domain (`getCurrentUser` / `updateUsername`) need this, so it is + * factored here. + * + * This object also owns the post-auth bookkeeping shared by every sign-in path: dropping + * the cached Supabase token (so the next DB read re-fetches under the new session) and + * emitting `auth:signed-in`. + * + * @module + */ +import type { TypedEventEmitter } from './event-emitter'; +import type { OpenSecretClient } from './open-secret'; +import type { SupabaseSessionTokenProvider } from './supabase-session'; +import type { UserRepository } from './user-repository'; +import type { SdkEventMap } from '../events'; +import type { User } from '../types/user'; + +/** + * Resolves + tracks the current authenticated user across the OpenSecret enclave and the + * `wallet.users` DB row. One per `Sdk` instance; shared by the auth + user domains. + */ +export class SessionResolver { + /** + * @param openSecret - the enclave client (who-is-signed-in + session ops). + * @param users - the `wallet.users` repository (id → domain user). + * @param sessionToken - the Supabase access-token cache (cleared on any session change). + * @param events - the SDK event emitter (`auth:*`). + */ + constructor( + private readonly openSecret: OpenSecretClient, + private readonly users: UserRepository, + private readonly sessionToken: SupabaseSessionTokenProvider, + private readonly events: TypedEventEmitter, + ) {} + + /** + * The currently signed-in user, or `null` when there is no session. + * + * Re-houses master `useUser` / `ReadUserRepository.get`: enclave user id → DB row → + * domain {@link User}. Returns `null` (not a throw) when signed out, matching the + * contract's `getCurrentUser(): Promise`. + * + * @returns the current user, or `null`. + */ + async getCurrentUser(): Promise { + const osUser = await this.openSecret.fetchUser(); + if (!osUser) { + return null; + } + return this.users.get(osUser.id); + } + + /** + * The current user, asserting there is one. Used by the auth methods that resolve the + * user immediately after a successful enclave sign-in (where a session is guaranteed), + * and by `updateUsername`. + * + * @returns the current user. + * @throws Error if no session exists (should not happen on a freshly-authenticated path). + */ + async requireCurrentUser(): Promise { + const user = await this.getCurrentUser(); + if (!user) { + throw new Error('No authenticated user'); + } + return user; + } + + /** + * Resolve the current user after a sign-in transition, then drop the cached Supabase + * token (the next DB read re-fetches under the new identity) and emit `auth:signed-in`. + * The shared tail of every sign-in / sign-up / guest / upgrade / OAuth-complete path. + * + * @returns the freshly signed-in user. + */ + async completeSignIn(): Promise { + this.sessionToken.clear(); + const user = await this.requireCurrentUser(); + this.events.emit('auth:signed-in', { user }); + return user; + } + + /** + * Tear down the session locally after an enclave sign-out: drop the cached Supabase + * token and emit `auth:signed-out`. + */ + completeSignOut(): void { + this.sessionToken.clear(); + this.events.emit('auth:signed-out', {}); + } +} diff --git a/packages/wallet-sdk/src/internal/stub-domains.ts b/packages/wallet-sdk/src/internal/stub-domains.ts index 00a84b47c..ccdd3b12f 100644 --- a/packages/wallet-sdk/src/internal/stub-domains.ts +++ b/packages/wallet-sdk/src/internal/stub-domains.ts @@ -3,8 +3,8 @@ * * Each factory returns an object implementing its domain interface (§2-§10) where * every method throws {@link NotImplementedError}. The `Sdk` shell wires its domain - * accessors to these so the public surface is fully present + type-correct in PR2, - * while the real business logic lands in later slices (auth → S1, accounts/scan → S2, + * accessors to these so the public surface is fully present + type-correct, while the + * real business logic lands in later slices (auth/user → S1 (DONE), accounts/scan → S2, * cashu/spark → S3, transactions/contacts/transfers → S4, background → S5). Swapping a * stub for a real impl is the unit of work for each slice — these are the seams. * @@ -15,7 +15,6 @@ */ import type { AccountsDomain, - AuthDomain, BackgroundDomain, CashuDomain, ContactsDomain, @@ -24,7 +23,6 @@ import type { SparkDomain, TransactionsDomain, TransfersDomain, - UserDomain, } from '../domains'; import { NotImplementedError } from '../errors'; import type { BackgroundState } from '../events'; @@ -34,25 +32,11 @@ const unimplemented = (method: string): never => { throw new NotImplementedError(method); }; -/** Stub `AuthDomain` (real impl: Slice 1). */ -export const createAuthStub = (): AuthDomain => ({ - signIn: () => unimplemented('auth.signIn'), - signUp: () => unimplemented('auth.signUp'), - signInGuest: () => unimplemented('auth.signInGuest'), - signOut: () => unimplemented('auth.signOut'), - refresh: () => unimplemented('auth.refresh'), - resetPassword: () => unimplemented('auth.resetPassword'), - changePassword: () => unimplemented('auth.changePassword'), - upgradeGuest: () => unimplemented('auth.upgradeGuest'), - beginGoogleSignIn: () => unimplemented('auth.beginGoogleSignIn'), - completeOAuth: () => unimplemented('auth.completeOAuth'), -}); - -/** Stub `UserDomain` (real impl: Slice 1). */ -export const createUserStub = (): UserDomain => ({ - getCurrentUser: () => unimplemented('user.getCurrentUser'), - updateUsername: () => unimplemented('user.updateUsername'), -}); +/** + * Stub factories for the domains not yet implemented. `auth` + `user` (Slice 1) are no + * longer stubbed — they are real (`../domains/auth`, `../domains/user`), wired directly in + * `Sdk.create`. + */ /** Stub `AccountsDomain` (real impl: Slice 2). */ export const createAccountsStub = (): AccountsDomain => ({ diff --git a/packages/wallet-sdk/src/internal/user-repository.test.ts b/packages/wallet-sdk/src/internal/user-repository.test.ts new file mode 100644 index 000000000..e8835d7b2 --- /dev/null +++ b/packages/wallet-sdk/src/internal/user-repository.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from 'bun:test'; +import { DomainError, NotFoundError } from '../errors'; +import type { AgicashDbUser } from './db-user'; +import type { WalletSupabaseClient } from './supabase-client'; +import { UserRepository } from './user-repository'; + +/** A representative `wallet.users` row. */ +const row: AgicashDbUser = { + id: 'user-1', + username: 'alice', + email: 'alice@example.com', + email_verified: true, + created_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-02T00:00:00.000Z', + default_btc_account_id: 'btc-acct', + default_usd_account_id: null, + default_currency: 'BTC', + cashu_locking_xpub: 'xpub-1', + encryption_public_key: 'enc-pk', + spark_identity_public_key: 'spark-pk', + terms_accepted_at: null, + gift_card_mint_terms_accepted_at: null, +}; + +/** The shape every terminal builder call (`single`/`maybeSingle`) resolves to. */ +type Result = { data: unknown; error: unknown }; + +/** + * A fake Supabase client whose fluent `from(...).select/update/eq/...` chain is a no-op and + * whose terminal `single()` / `maybeSingle()` resolve to a pre-set result. Records the last + * `update(...)` payload so tests can assert what was written. Cast to the client type — only + * the methods the repository touches are implemented. + */ +function fakeDb(result: Result) { + let lastUpdate: unknown; + const chain: Record = { + select: () => chain, + update: (payload: unknown) => { + lastUpdate = payload; + return chain; + }, + eq: () => chain, + single: async () => result, + maybeSingle: async () => result, + }; + const db = { + from: () => chain, + } as unknown as WalletSupabaseClient; + return { db, getLastUpdate: () => lastUpdate }; +} + +describe('UserRepository.get', () => { + test('returns the mapped domain user for an existing row', async () => { + const { db } = fakeDb({ data: row, error: null }); + const repo = new UserRepository(db); + + const user = await repo.get('user-1'); + + expect(user.id).toBe('user-1'); + expect(user.username).toBe('alice'); + expect(user.isGuest).toBe(false); + }); + + test('throws NotFoundError when no row exists', async () => { + const { db } = fakeDb({ data: null, error: null }); + const repo = new UserRepository(db); + + await expect(repo.get('missing')).rejects.toBeInstanceOf(NotFoundError); + }); + + test('throws on a read error', async () => { + const { db } = fakeDb({ data: null, error: { message: 'boom' } }); + const repo = new UserRepository(db); + + await expect(repo.get('user-1')).rejects.toThrow('Failed to get user'); + }); +}); + +describe('UserRepository.updateUsername', () => { + test('writes the username and returns the updated user', async () => { + const updatedRow = { ...row, username: 'bob' }; + const { db, getLastUpdate } = fakeDb({ data: updatedRow, error: null }); + const repo = new UserRepository(db); + + const user = await repo.updateUsername('user-1', 'bob'); + + expect(getLastUpdate()).toEqual({ username: 'bob' }); + expect(user.username).toBe('bob'); + }); + + test('throws DomainError when the username is taken (unique violation 23505)', async () => { + const { db } = fakeDb({ + data: null, + error: { code: '23505', message: 'duplicate key value' }, + }); + const repo = new UserRepository(db); + + const promise = repo.updateUsername('user-1', 'taken'); + await expect(promise).rejects.toBeInstanceOf(DomainError); + await expect(promise).rejects.toThrow('already taken'); + }); + + test('throws a generic error for a non-unique-violation failure', async () => { + const { db } = fakeDb({ + data: null, + error: { code: '500', message: 'server error' }, + }); + const repo = new UserRepository(db); + + const promise = repo.updateUsername('user-1', 'bob'); + await expect(promise).rejects.toThrow('Failed to update user'); + await expect(promise).rejects.not.toBeInstanceOf(DomainError); + }); +}); diff --git a/packages/wallet-sdk/src/internal/user-repository.ts b/packages/wallet-sdk/src/internal/user-repository.ts new file mode 100644 index 000000000..dc7eed645 --- /dev/null +++ b/packages/wallet-sdk/src/internal/user-repository.ts @@ -0,0 +1,104 @@ +/** + * Internal `wallet.users` repository — Slice 1 (auth + user). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/user/user-repository.ts` + * (`ReadUserRepository.get` + `WriteUserRepository.update`). Master expresses these as + * React-hook-constructed repositories over the TanStack-wired Supabase client; here they + * are plain async methods over the SDK-owned client (passed in), reading/writing the + * `wallet.users` table and mapping rows via {@link dbUserToUser}. + * + * Only the two reads/writes the auth + user domains need are ported: fetch-by-id and + * username update. The rest of master's user repository (upsert-with-accounts, default + * account resolution) belongs to later slices. + * + * @module + */ +import { DomainError, NotFoundError } from '../errors'; +import type { WalletSupabaseClient } from './supabase-client'; +import { type AgicashDbUser, dbUserToUser } from './db-user'; +import type { User } from '../types/user'; + +/** + * Postgres unique-violation SQLSTATE. The `wallet.users.username` column is unique, so a + * `23505` on the username update means the chosen username is already taken — surfaced as + * a {@link DomainError} (the contract's `updateUsername` "throws DomainError if taken"). + * Master raises `UniqueConstraintError` here; the SDK collapses it to `DomainError`. + */ +const UNIQUE_VIOLATION = '23505'; + +/** + * Reads + writes for the `wallet.users` table, scoped (via RLS) to the signed-in user. + * + * Holds the SDK-owned Supabase client. Methods take the `userId` (the OpenSecret user id) + * the auth/user domain resolves from the current session. + */ +export class UserRepository { + /** + * @param db - the SDK-owned Supabase client (schema pinned to `wallet`). + */ + constructor(private readonly db: WalletSupabaseClient) {} + + /** + * Fetch the `wallet.users` row for `userId` and map it to the domain {@link User}. + * + * Verbatim logic from master `ReadUserRepository.get` (+ the row→domain mapping), minus + * the abort-signal plumbing. A missing row surfaces as {@link NotFoundError}. + * + * @param userId - the user id (matches the OpenSecret user id). + * @returns the domain user. + * @throws NotFoundError if no row exists for `userId`. + * @throws Error if the read otherwise fails. + */ + async get(userId: string): Promise { + const { data, error } = await this.db + .from('users') + .select() + .eq('id', userId) + .maybeSingle(); + + if (error) { + throw new Error('Failed to get user', { cause: error }); + } + if (!data) { + throw new NotFoundError(`User ${userId} not found`, 'USER_NOT_FOUND'); + } + + return dbUserToUser(data); + } + + /** + * Update the user's username and return the updated domain {@link User}. + * + * Verbatim logic from master `WriteUserRepository.update` (the `username` path): updates + * the row, selects it back, and maps it. A unique-violation ({@link UNIQUE_VIOLATION}) + * means the username is taken → {@link DomainError} (master raised `UniqueConstraintError`; + * the SDK surfaces the contract's `DomainError`). + * + * @param userId - the user id. + * @param username - the new username. + * @returns the updated domain user. + * @throws DomainError if the username is already taken. + * @throws Error if the update otherwise fails. + */ + async updateUsername(userId: string, username: string): Promise { + const { data, error } = await this.db + .from('users') + .update({ username }) + .eq('id', userId) + .select() + .single(); + + if (error) { + if (error.code === UNIQUE_VIOLATION) { + throw new DomainError( + 'This username is already taken', + 'USERNAME_TAKEN', + ); + } + throw new Error('Failed to update user', { cause: error }); + } + + return dbUserToUser(data); + } +} diff --git a/packages/wallet-sdk/src/sdk.ts b/packages/wallet-sdk/src/sdk.ts index f14ff4da0..7aa6ac670 100644 --- a/packages/wallet-sdk/src/sdk.ts +++ b/packages/wallet-sdk/src/sdk.ts @@ -31,11 +31,14 @@ import type { UserDomain, } from './domains'; import type { EventEmitter, SdkEventMap } from './events'; +import { AuthDomainImpl } from './domains/auth'; +import { UserDomainImpl } from './domains/user'; import { TypedEventEmitter } from './internal/event-emitter'; +import { GuestAccountStorage } from './internal/guest-account-storage'; import { OpenSecretClient } from './internal/open-secret'; +import { SessionResolver } from './internal/session'; import { createAccountsStub, - createAuthStub, createBackgroundStub, createCashuStub, createContactsStub, @@ -44,7 +47,6 @@ import { createSparkStub, createTransactionsStub, createTransfersStub, - createUserStub, } from './internal/stub-domains'; import { type SupabaseConnectionConfig, @@ -52,6 +54,7 @@ import { createSupabaseClient, } from './internal/supabase-client'; import { SupabaseSessionTokenProvider } from './internal/supabase-session'; +import { UserRepository } from './internal/user-repository'; import type { StorageAdapter } from './types/dependencies'; /** @@ -141,17 +144,26 @@ export class Sdk { private readonly connections: SdkConnections; /** - * Private — construct via {@link Sdk.create}. Takes the assembled connection bundle and - * wires the domain accessors. PR2 wires every accessor to a stub; later slices replace - * the stub factories here with real implementations that receive `connections`. + * Private — construct via {@link Sdk.create}. Takes the assembled connection bundle plus + * the already-built real domains (`auth` + `user` as of Slice 1) and wires the domain + * accessors. Domains not yet implemented are wired to a stub; later slices replace each + * stub here with its real implementation. + * + * @param connections - the shared connection bundle. + * @param domains - the real domain implementations built in {@link Sdk.create}. */ - private constructor(connections: SdkConnections) { + private constructor( + connections: SdkConnections, + domains: { auth: AuthDomain; user: UserDomain }, + ) { this.connections = connections; this.events = connections.events; - // --- domain accessors (STUBS in PR2 — swap per slice) -------------------- - this.auth = createAuthStub(); - this.user = createUserStub(); + // --- real domains (Slice 1: auth + user) --------------------------------- + this.auth = domains.auth; + this.user = domains.user; + + // --- domain accessors still STUBBED (swap per slice) --------------------- this.accounts = createAccountsStub(); this.scan = createScanStub(); this.cashu = createCashuStub(); @@ -184,9 +196,14 @@ export class Sdk { // Access-token provider: the Supabase `accessToken` callback. Audience = the Supabase // project URL (so the mint-CAT audience stays separate, per master's two-audience use). - const sessionToken = new SupabaseSessionTokenProvider(() => - openSecret.generateThirdPartyToken(config.supabase.url), - ); + // Short-circuit to `null` when signed out (master `supabase-session.ts` gates on + // `isLoggedIn()`) so unauthenticated DB reads don't trigger a failing enclave call. + const sessionToken = new SupabaseSessionTokenProvider(async () => { + if (!(await openSecret.hasSession())) { + return null; + } + return openSecret.generateThirdPartyToken(config.supabase.url); + }); // Supabase: SDK-owned client (schema 'wallet', RLS via the token provider). const supabaseConfig: SupabaseConnectionConfig = { @@ -199,16 +216,33 @@ export class Sdk { sessionToken.getToken, ); + const events = new TypedEventEmitter(); + const connections: SdkConnections = { supabase, openSecret, sessionToken, storage: config.storage, - events: new TypedEventEmitter(), + events, clientId: config.clientId ?? generateClientId(), }; - return new Sdk(connections); + // --- Slice 1: auth + user domains ---------------------------------------- + // The agicash domain `User` is the `wallet.users` row keyed by the OpenSecret user id, + // so both domains share a session resolver (enclave id → DB row + `auth:*` events) over + // one user repository. + const users = new UserRepository(supabase); + const session = new SessionResolver( + openSecret, + users, + sessionToken, + events, + ); + const guestStorage = new GuestAccountStorage(config.storage); + const auth = new AuthDomainImpl(openSecret, session, guestStorage); + const user = new UserDomainImpl(session, users); + + return new Sdk(connections, { auth, user }); } /**