From 5f1f66031abdf5910da6267994fb4dc40cf2b03a Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Thu, 9 Jul 2026 19:49:54 +0200 Subject: [PATCH 01/49] feat(deps): adopt React-agnostic @agicash/opensecret 1.0.0-rc.0 Co-Authored-By: Claude Fable 5 --- apps/web-wallet/app/entry.client.tsx | 3 ++- apps/web-wallet/package.json | 2 +- bun.lock | 9 +++++---- package.json | 3 ++- packages/wallet-sdk/package.json | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/web-wallet/app/entry.client.tsx b/apps/web-wallet/app/entry.client.tsx index d75a4f689..31a7f7cb7 100644 --- a/apps/web-wallet/app/entry.client.tsx +++ b/apps/web-wallet/app/entry.client.tsx @@ -1,4 +1,4 @@ -import { configure } from '@agicash/opensecret'; +import { browserStorage, configure } from '@agicash/opensecret'; import { configureFeatureFlags, ensureBreezWasm, @@ -36,6 +36,7 @@ if (!openSecretClientId) { configure({ apiUrl: openSecretApiUrl, clientId: openSecretClientId, + storage: browserStorage, }); // Start Breez WASM fetch/compile as early as possible so it overlaps with diff --git a/apps/web-wallet/package.json b/apps/web-wallet/package.json index 5a96869ea..b9aba0625 100644 --- a/apps/web-wallet/package.json +++ b/apps/web-wallet/package.json @@ -63,7 +63,7 @@ "embla-carousel-react": "8.5.2", "express": "5.2.1", "isbot": "5.1.34", - "jwt-decode": "4.0.0", + "jwt-decode": "catalog:", "ky": "catalog:", "lucide-react": "0.468.0", "qrcode.react": "4.2.0", diff --git a/bun.lock b/bun.lock index 061a01f8e..61af2e308 100644 --- a/bun.lock +++ b/bun.lock @@ -60,7 +60,7 @@ "embla-carousel-react": "8.5.2", "express": "5.2.1", "isbot": "5.1.34", - "jwt-decode": "4.0.0", + "jwt-decode": "catalog:", "ky": "catalog:", "lucide-react": "0.468.0", "qrcode.react": "4.2.0", @@ -207,7 +207,7 @@ "@stablelib/base64": "catalog:", "@supabase/supabase-js": "2.95.2", "big.js": "catalog:", - "jwt-decode": "4.0.0", + "jwt-decode": "catalog:", "ky": "catalog:", "type-fest": "catalog:", "zod": "catalog:", @@ -224,7 +224,7 @@ "@sentry/core@10.42.0": "patches/@sentry%2Fcore@10.42.0.patch", }, "catalog": { - "@agicash/opensecret": "0.1.0", + "@agicash/opensecret": "1.0.0-rc.0", "@cashu/cashu-ts": "3.6.1", "@noble/ciphers": "1.3.0", "@noble/curves": "1.9.7", @@ -236,6 +236,7 @@ "@types/bun": "1.3.11", "big.js": "7.0.1", "dotenv": "16.4.7", + "jwt-decode": "4.0.0", "jwt-encode": "1.0.1", "ky": "1.14.3", "type-fest": "5.4.3", @@ -257,7 +258,7 @@ "@agicash/money": ["@agicash/money@workspace:packages/money"], - "@agicash/opensecret": ["@agicash/opensecret@0.1.0", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-5cbr7hgKlrY3aLm2RPrk9+xfserhEgG60V+zxtABcD0ZG7Gw6AgeMUuijWyp8mAqZ7G+bZr4Us0ZvGq63ISzog=="], + "@agicash/opensecret": ["@agicash/opensecret@1.0.0-rc.0", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" } }, "sha512-BbXdFQp1NTug5jA7SHcZDP0a9qRmwl36pF+bgWv69ohAZMdYH7k4WiEbLgRBLoYXGj2VyyUEene0n3GIbDmHMQ=="], "@agicash/qr-scanner": ["@agicash/qr-scanner@0.1.2", "", { "dependencies": { "zxing-wasm": "2.2.4" } }, "sha512-7qNJoDRqBbHSm12qZ1l0O2JDof0sXNiooebsCXGZOsJpCLBYF9bAJXuy/4Q/jFwbiH0knFZq8myRI5v6IFqpEQ=="], diff --git a/package.json b/package.json index 20b311b4c..aa1f87aac 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "workspaces": { "packages": ["apps/*", "packages/*"], "catalog": { - "@agicash/opensecret": "0.1.0", + "@agicash/opensecret": "1.0.0-rc.0", "@cashu/cashu-ts": "3.6.1", "@noble/ciphers": "1.3.0", "@noble/curves": "1.9.7", @@ -17,6 +17,7 @@ "@types/bun": "1.3.11", "big.js": "7.0.1", "dotenv": "16.4.7", + "jwt-decode": "4.0.0", "jwt-encode": "1.0.1", "ky": "1.14.3", "type-fest": "5.4.3", diff --git a/packages/wallet-sdk/package.json b/packages/wallet-sdk/package.json index ab40f6f48..7c6e3736c 100644 --- a/packages/wallet-sdk/package.json +++ b/packages/wallet-sdk/package.json @@ -35,7 +35,7 @@ "@stablelib/base64": "catalog:", "@supabase/supabase-js": "2.95.2", "big.js": "catalog:", - "jwt-decode": "4.0.0", + "jwt-decode": "catalog:", "ky": "catalog:", "type-fest": "catalog:", "zod": "catalog:" From 099e5acce9acd935e0504ec4a2d2099f889f9fc0 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Thu, 9 Jul 2026 19:54:19 +0200 Subject: [PATCH 02/49] refactor(utils): move setLongTimeout/clearLongTimeout to @agicash/utils Co-Authored-By: Claude Fable 5 --- .../app/features/receive/cashu-receive-quote-hooks.ts | 10 +++++----- apps/web-wallet/app/hooks/use-long-timeout.ts | 2 +- .../app/lib/cashu/melt-quote-subscription.ts | 6 +++++- packages/utils/src/index.ts | 1 + .../app/lib => packages/utils/src}/timeout.ts | 0 packages/utils/tsconfig.json | 2 +- 6 files changed, 13 insertions(+), 8 deletions(-) rename {apps/web-wallet/app/lib => packages/utils/src}/timeout.ts (100%) diff --git a/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts b/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts index beccc2f19..ad7f9276d 100644 --- a/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts +++ b/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts @@ -5,6 +5,11 @@ import { sumProofs, } from '@agicash/cashu'; import type { Money } from '@agicash/money'; +import { + type LongTimeout, + clearLongTimeout, + setLongTimeout, +} from '@agicash/utils'; import type { CashuAccount, CashuReceiveQuote, @@ -34,11 +39,6 @@ import { import { useCallback, useEffect, useMemo, useState } from 'react'; import { useOnMeltQuoteStateChange } from '~/lib/cashu/melt-quote-subscription'; import { MintQuoteSubscriptionManager } from '~/lib/cashu/mint-quote-subscription-manager'; -import { - type LongTimeout, - clearLongTimeout, - setLongTimeout, -} from '~/lib/timeout'; import { useLatest } from '~/lib/use-latest'; import { withRetry } from '~/lib/with-retry'; import { diff --git a/apps/web-wallet/app/hooks/use-long-timeout.ts b/apps/web-wallet/app/hooks/use-long-timeout.ts index 9b15a5f53..4409ea972 100644 --- a/apps/web-wallet/app/hooks/use-long-timeout.ts +++ b/apps/web-wallet/app/hooks/use-long-timeout.ts @@ -1,5 +1,5 @@ +import { clearLongTimeout, setLongTimeout } from '@agicash/utils'; import { useEffect } from 'react'; -import { clearLongTimeout, setLongTimeout } from '~/lib/timeout'; import { useLatest } from '~/lib/use-latest'; /** diff --git a/apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts b/apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts index 03764794b..823009d3e 100644 --- a/apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts +++ b/apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts @@ -1,9 +1,13 @@ import type { ExtendedCashuWallet } from '@agicash/cashu'; import type { Currency } from '@agicash/money'; +import { + type LongTimeout, + clearLongTimeout, + setLongTimeout, +} from '@agicash/utils'; import { type MeltQuoteBolt11Response, MeltQuoteState } from '@cashu/cashu-ts'; import { useMutation } from '@tanstack/react-query'; import { useCallback, useEffect, useState } from 'react'; -import { type LongTimeout, clearLongTimeout, setLongTimeout } from '../timeout'; import { useLatest } from '../use-latest'; import { MeltQuoteSubscriptionManager } from './melt-quote-subscription-manager'; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 5fae18a36..a55ba6a77 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -3,4 +3,5 @@ export * from './json'; export * from './zod'; export * from './type-utils'; export * from './sha256'; +export * from './timeout'; export * from './xchacha20poly1305'; diff --git a/apps/web-wallet/app/lib/timeout.ts b/packages/utils/src/timeout.ts similarity index 100% rename from apps/web-wallet/app/lib/timeout.ts rename to packages/utils/src/timeout.ts diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index 0814fa583..efe3145de 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -4,7 +4,7 @@ "exclude": ["node_modules"], "compilerOptions": { "lib": ["ES2022"], - "types": [], + "types": ["bun"], "noEmit": true } } From 27bdd2fa1105c0d10939b25f5c8ca0513915b1b7 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Thu, 9 Jul 2026 19:56:00 +0200 Subject: [PATCH 03/49] feat(wallet-sdk): settle the step-5 contract types (auth storage, AuthUser, user params) Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-09-wallet-sdk-auth-slice.md | 2740 +++++++++++++++++ ...2026-07-02-wallet-sdk-contract-proposal.md | 1 + packages/wallet-sdk/sdk.ts | 62 +- 3 files changed, 2794 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-09-wallet-sdk-auth-slice.md diff --git a/docs/superpowers/plans/2026-07-09-wallet-sdk-auth-slice.md b/docs/superpowers/plans/2026-07-09-wallet-sdk-auth-slice.md new file mode 100644 index 000000000..ea5fccfb9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-wallet-sdk-auth-slice.md @@ -0,0 +1,2740 @@ +# Wallet SDK Auth & User Slice (Step 5) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement step 5 of the no-cache SDK migration: the first runtime `Sdk` class (`AgicashSdk.create(config)`) with working `auth`, `user`, and `events` namespaces, adopt the React-agnostic `@agicash/opensecret@1.0.0-rc.0`, settle the step-5 contract placeholders, and flip the web app's auth + user imports from `/temporary` to `sdk.*`. + +**Architecture:** The SDK configures Open Secret itself inside `create(config)` (host passes `apiUrl`/`clientId`/storage adapter), builds its own Supabase client with an internal Open Secret → Supabase token getter, and keeps an in-memory `AuthSession` snapshot maintained by `AuthService` (restore on `init()`, refresh after every auth verb, refresh-token expiry timer with guest auto-extend and `auth.session-expired` emission). The web keeps thin glue: TanStack `authQueryOptions` wrapping `sdk.init()` + `sdk.auth.getSession()`, plus Sentry/session-hint-cookie/navigation concerns. Specs: `docs/superpowers/specs/2026-06-24-wallet-sdk-no-cache-production-design.md` (parent) and `docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md` (contract). + +**Tech Stack:** TypeScript (bun workspace monorepo), React Router v7, TanStack Query v5 (web only), `@agicash/opensecret@1.0.0-rc.0`, `@supabase/supabase-js`, `jwt-decode`, `zod/mini`, `bun test` for SDK unit tests. + +## Global Constraints + +- **SDK is React-agnostic and headless-safe.** No file under `packages/wallet-sdk/` may import `react`, `@tanstack/react-query`, or read `window`/`document`/`localStorage`/`sessionStorage`/cookies. Host state enters only via `create(config)` ports (`config.auth.storage`). +- **`@agicash/opensecret` is pinned exact** in the root `workspaces.catalog`: `"1.0.0-rc.0"` after Task 1. Verified: the RC's storage keys are unchanged (`access_token`, `refresh_token`), `browserStorage` maps `persistent`→`localStorage` and `session`→`sessionStorage` lazily, and the package has **no React peer deps**. Existing user sessions survive the upgrade. +- **The contract types in `packages/wallet-sdk/sdk.ts` are the source of truth.** `AuthStorage` binds *verbatim* to the RC's `StorageProvider` shape (scopes `persistent`/`session`, methods `getItem`/`setItem`/`removeItem`, sync-or-async returns) so the SDK ships no adapter over it. +- **`create()` is sync with no I/O; `init()` is memoized single-flight.** In this slice `init()` = session restore only. The web keeps calling `ensureBreezWasm()` from `/temporary` (entry.client + `_protected` middleware) — WASM folds into `init()` when the first Spark namespace lands (decision, see Decision Record). +- **Web-only concerns stay in the web glue:** Sentry user tracking, session-hint cookie, TanStack invalidation, navigation/revalidation, feature-flag load/reset, OAuth deep-link restore (`oauthLoginSessionStorage`), pending-terms storage. +- **Package manager is `bun`; never npm/npx/yarn/pnpm.** In this environment `bun` is NOT on PATH — prefix commands with `export PATH="$PWD/.devenv/profile/bin:$PATH"` (run from the repo root). +- **Branch `sdk/auth-slice` off `master`.** Conventional commits (`feat(wallet-sdk):`, `refactor(web-wallet):`, …). Run `bun run fix:all && bun run typecheck` before every commit — `fix:all` is biome lint/format ONLY (it does not typecheck, despite CLAUDE.md's description); `typecheck` runs each package's `tsc`. +- **Behavior parity is the review bar.** Every auth flow (email login/signup, guest signup + re-signin, Google OAuth, verify email, convert guest, sign out, session expiry) must behave as on `master`; deltas are listed in "Accepted behavior deltas" below and nothing else may change. + +## Decision Record (resolved with maintainer, 2026-07-09) + +| # | Decision | +|---|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| A1 | **`ensureUserData` (user + default-accounts upsert in `_protected.tsx`) stays in the web via `/temporary` this slice.** It constructs `AccountRepository` (accounts domain); the accounts slice (step 6) gives it a contract home. Step 5 flips every other user-domain consumer. | +| A2 | **Session-expiry machinery moves into the SDK now.** `AuthService` arms a long-timeout at refresh-token expiry−5s: guests are auto-re-signed-in internally; full accounts get the session cleared + `auth.session-expired` emitted. This requires the minimal typed event emitter now (`auth.session-expired` is explicitly not realtime-backed). Web's `useHandleSessionExpiry` is replaced by an event subscription. | +| A3 | **`init()` = session restore only** this slice (see Global Constraints). Rationale: gating `authQueryOptions` on a combined restore+WASM `init()` would newly break login pages under WASM-unavailable (iOS Lockdown Mode) — a regression. No Spark ops exist on the contract until steps 11/15. | +| A4 | **Guest password generation: SDK default + optional host override.** `SdkConfig.auth.generateGuestPassword?: () => Promise` — resolving null falls through to the SDK's internal CSPRNG generator, which is the ONLY generator (the web's `~/lib/password-generator.ts` is deleted). The web passes a two-line bridge to `window.getMockPassword`, preserving the e2e seam with zero e2e changes. *(Refined 2026-07-09 from a full-replacement port, removing the duplicated generator body.)* | +| A5 | **`AuthUser` settles to Open Secret's user shape verbatim:** `export type AuthUser = UserResponse['user']` (id, name, email?, email_verified, login_method, created_at, updated_at). The web already consumes exactly these fields (`_protected.tsx`, `_auth.tsx`). | +| A6 | **The SDK builds its own Supabase client** (contract decision #1 from #1164) with an internal memoized third-party-token getter. Two Supabase clients coexist during migration: web's (`database.client.ts` — unmigrated domains + realtime) and the SDK's (auth/user namespaces). Accepted transitional cost; each caches its own token. | +| A7 | **`guestAccountStorage` moves into the SDK** behind `config.auth.storage.persistent` (same `guestAccount` localStorage key → existing guest creds survive). `oauth-login-session-storage`, `pending-terms-storage`, `session-hint-cookie`, and `shared/auth.ts` (`isLoggedIn` for the web's own DB client) **stay in the web**. | +| A8 | **`setLongTimeout`/`clearLongTimeout` move from `~/lib/timeout` to `@agicash/utils`** (needed by both web and SDK). | +| A9 | **`WriteUserRepository` drops `accountRepository` from its constructor**; `upsert()` takes it as a parameter (only `upsert` uses it). Lets the SDK construct the user namespace without the accounts graph. `UserService.setDefaultAccount` loosens `account` to `Pick`. | +| A10 | Step-5 params settle as: `AcceptTermsParams = { walletTerms?: boolean; giftCardTerms?: boolean }`, `SetDefaultAccountParams = { accountId: string; setDefaultCurrency?: boolean }`, `SetDefaultCurrencyParams = { currency: Currency }`. | +| A11 | The `_protected.receive.cashu_.token.tsx` default-account write flips to `sdk.user.setDefaultAccount({ accountId, setDefaultCurrency: true })` now (it is user-domain surface). `UserService`/`ReadUserRepository` remain exported from `/temporary` for their **static** helpers (`isDefaultAccount`, `getExtendedAccounts`, `toUser` for the realtime row mapper) until steps 6/18. | +| A12 | `sdk.featureFlags` namespace is **not** wired this slice (flags stay web-configured via `configureFeatureFlags(agicashDbClient)`); it needs no auth work and has no assigned step — it lands alongside a later slice. | +| A13 | **`auth.session-refreshed` added to the event map** (2026-07-09, maintainer-approved): fires only on SDK-initiated session refreshes — today exactly the guest auto-extension; host-initiated verbs never fire it (same principle as `auth.session-expired`'s "the host knows its own logout"). Rationale trail: PR #1164 floated `auth.changed` and landed the narrower `auth.session-expired`, but the recorded reasoning covered sign-out, not SDK-internal extension, and the hint-cookie consequence was never discussed there — an unconsidered gap, and the contract states adding events is non-breaking. The web's session-events hook invalidates the auth query on it, restoring master's cookie freshness after a guest auto-extend. | + +## Accepted behavior deltas (everything else is parity) + +1. **Guest extend failure:** master does `removeKeys()` + `window.location.reload()`; now the SDK falls through to the death path (session cleared + `auth.session-expired`) and the web shows the toast + redirects. Strictly better UX, same terminal state. +2. **Session-hint cookie refresh after guest auto-extend:** restored to master behavior via `auth.session-refreshed` (A13) — the web's session-events hook invalidates the auth query, whose refetch re-sets the cookie with the extended expiry, exactly like master's extend-through-invalidation. Residual: on pages where the hook isn't mounted (public/marketing — where master never armed an extension timer at all, see delta 6), the `staleTime` pinned to fetch-time expiry re-syncs the cookie on the next focus/mount refetch after the old expiry; a background tab there can still hit one cold-load `/home` bounce. Auth itself is unaffected (the cookie is a non-authoritative SSR hint). +3. **Sentry `setUser` timing:** master sets `{id: sub}` from the raw JWT before `fetchUser`; the glue still does this, then upgrades to `{id, isGuest}` after restore — unchanged ordering, but the fetch itself now happens inside `sdk.init()`. +4. **Sign-out memo-clear ordering:** master clears the SDK module memos (`clearSparkWallets`, `clearAgicashMintAuthToken`) last, after `queryClient.clear()`; now they clear at session end inside `sdk.auth.signOut()`. Post-clear repopulation by an in-flight request remains possible for the spark-wallet and mint-CAT memos **under either ordering** (master's clear-last only narrowed the window) but is harmless by construction: the Supabase token cache is generation-fenced (cannot cache post-reset), spark-wallet entries are keyed by the user's mnemonic (never served cross-user), and the mint CAT is wiped again when a *different* user's session begins (the `lastUserId` guard in `applySessionFromServer`). Residual: a same-user memo staying warm across their own sign-out/sign-in (benign), and one theoretical sliver — a CAT fetch from the previous session resolving *after* the next user's sign-in — tracked in Deferred. +5. **`authQueryOptions` is snapshot-driven:** master's queryFn called `fetchUser()` on every refetch; now a refetch awaits the memoized `sdk.init()` and reads the in-memory session snapshot. Every existing invalidation site is preceded by an SDK auth verb that already refreshed the snapshot, so reads stay fresh — but future code calling `invalidateAuthQueries()` with no preceding auth verb reads the snapshot, not the server. (A session-ending failure clears the restore memo, so the next invalidation re-restores from storage — a transient post-login `fetchUser` blip recovers on the glue's own invalidation, like master.) +6. **Expiry-timer scope:** master armed the refresh-expiry timer only under the protected layout's `` (which wraps every protected page, verify-email and accept-terms included); the SDK arms it whenever a session exists — now also on public/marketing pages that read auth state. A full-account expiry there ends the session in place without a toast (the subscriber lives in `Wallet`); master had no timer on those pages and logged out on the next protected navigation. Same terminal state. +7. **`setDefaultAccount` reads the account row (and writes column-minimally):** master passed the cached user + account objects and wrote all three default columns, echoing unchanged fields from the cache — which could revert a concurrent change from another device. Now the update writes only the changed columns (no user read, no echo, lost-update-proof) and reads just the account row by PK to derive the per-currency column server-truthfully. One extra lightweight read on the settings and token-claim paths; strictly safer writes. + +## Deferred (tracked, out of scope) + +- `ensureUserData` bootstrap → step 6 (A1). `_protected.tsx` keeps `/temporary` imports for it. +- `getEncryption` + the encryption/cryptography key plumbing (`encryption-hooks.ts`, `cryptography-hooks.ts`, `/temporary`'s `getEncryption`): the contract's migration mapping assigns these "internal (auth slice)", but their remaining web consumers are the not-yet-migrated domains and `ensureUserData` — they go SDK-internal with the accounts slice (step 6, alongside A1) at the earliest. +- `ReadUserDefaultAccountRepository` has NO web consumers (its only caller is the SDK-internal `lightning-address-service`); it stays on `/temporary`'s export list untouched and gets dropped there in step 17/19. +- `ReadUserRepository.toUser` in `useUserChangeHandlers` (realtime row mapping) → step 18. +- Breez WASM into `init()` → first Spark slice (A3). +- `sdk.featureFlags` wiring (A12). +- Deleting `UserService`/user repos from `/temporary` → step 6/18 once the statics find contract homes. +- Exposing refresh-token expiry on `getSession()` (so the web glue stops reading Open Secret's storage keys for the hint cookie and `staleTime`) → revisit with the step-18 session/events work. +- SDK-thrown error typing: the user repos throw plain `Error` today, so `sdk.user.*` doesn't yet honor the contract's "everything the SDK throws extends `SdkError`". Wrapping repo errors is a cross-domain pass — tracked for step 17/19. (Web behavior is unaffected: unknown errors already get the generic destructive toast.) +- Generation-fencing the mint-CAT memo (like the Supabase token getter): closes the last theoretical repopulation sliver — a CAT fetch from the previous session resolving after the next user's sign-in (i.e., surviving both the sign-out and the sign-in). Pre-existing on master with the same window; sub-second across two user actions. (Cancellation is not an alternative: the fetch happens inside `@agicash/opensecret`'s encrypted tunnel, which exposes no AbortSignal — and an abort landing after the response is queued can't prevent the write anyway; the fence is the correctness mechanism.) +- Session-scoped AbortController for SDK-internal reads: created per session, aborted on `endSession()`, signal threaded into the namespaces' Supabase queries (the repos already accept `abortSignal`), so in-flight namespace promises reject at session end instead of resolving into a dead session. Implements the contract's `dispose()` teardown semantics ("still-pending namespace promises reject with a typed `SdkError`") → step-18/dispose work. + +--- + +## File Structure + +**Created (packages/):** +- `packages/wallet-sdk/lib/events.ts` — `WalletEventEmitter` (typed `on`/`emit`) +- `packages/wallet-sdk/lib/events.test.ts` +- `packages/wallet-sdk/lib/password.ts` — CSPRNG `generateRandomPassword` +- `packages/wallet-sdk/domain/user/guest-account-storage.ts` — port-backed guest creds +- `packages/wallet-sdk/domain/user/guest-account-storage.test.ts` +- `packages/wallet-sdk/domain/user/auth-service.ts` — `AuthService implements AuthApi` +- `packages/wallet-sdk/domain/user/auth-service.test.ts` +- `packages/wallet-sdk/domain/user/user-api.ts` — `createUserApi(deps): UserApi` +- `packages/wallet-sdk/db/client.ts` — `createAgicashDbClient` +- `packages/wallet-sdk/db/supabase-session.ts` — memoized third-party-token getter +- `packages/wallet-sdk/db/supabase-session.test.ts` +- `packages/wallet-sdk/agicash-sdk.ts` — `class AgicashSdk` +- `packages/utils/src/timeout.ts` (moved from web) + +**Modified (SDK):** +- `packages/wallet-sdk/sdk.ts` — settle `AuthStorage`, `AuthUser`, 3 param types; add `generateGuestPassword` port +- `packages/wallet-sdk/index.ts` — export `AgicashSdk` +- `packages/wallet-sdk/lib/error.ts` — add internal `NoSessionError` +- `packages/wallet-sdk/domain/user/user-repository.ts` — A9 refactor +- `packages/wallet-sdk/domain/user/user-service.ts` — A9 param loosening +- Root `package.json` — catalog bump to `1.0.0-rc.0` + +**Created (web):** +- `apps/web-wallet/app/features/shared/sdk.client.ts` — config assembly + `sdk` singleton + +**Modified (web):** +- `apps/web-wallet/app/entry.client.tsx` — drop `configure()`, import `sdk` +- `apps/web-wallet/app/features/agicash-db/database.client.ts` — export `supabaseUrl`/`supabaseAnonKey` +- `apps/web-wallet/app/features/user/auth.ts` — rewrite as thin glue over `sdk.auth` +- `apps/web-wallet/app/features/user/user-hooks.tsx` — flip to `sdk.user`/`sdk.auth` +- `apps/web-wallet/app/features/wallet/wallet.tsx` — expiry hook swap +- `apps/web-wallet/app/routes/_protected.tsx` — `AuthUser` import + A9 call-site +- `apps/web-wallet/app/routes/_auth.oauth.$provider.tsx` — `completeGoogleAuth` +- `apps/web-wallet/app/features/signup/verify-email.ts` — `sdk.auth.verifyEmail` +- `apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx` — A11 +- `apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts`, `apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts`, `apps/web-wallet/app/hooks/use-long-timeout.ts` — timeout import repoint (the hook is later deleted in Task 11) + +**Deleted (web):** +- `apps/web-wallet/app/features/user/guest-account-storage.ts` (→ SDK) +- `apps/web-wallet/app/features/user/user-repository-hooks.ts` +- `apps/web-wallet/app/features/user/user-service-hooks.ts` +- `apps/web-wallet/app/lib/timeout.ts` (→ `@agicash/utils`) +- `apps/web-wallet/app/hooks/use-long-timeout.ts` (its only consumer is the old `useHandleSessionExpiry`; dead after Task 11) +- `apps/web-wallet/app/lib/password-generator.ts` (→ SDK `lib/password.ts` as the only generator; the web keeps just the `window.getMockPassword` bridge in `sdk.client.ts` — A4) + +--- + +### Task 1: Adopt `@agicash/opensecret@1.0.0-rc.0` + +**Files:** +- Modify: `package.json` (repo root, `workspaces.catalog`) +- Modify: `apps/web-wallet/package.json`, `packages/wallet-sdk/package.json` (`jwt-decode` → `catalog:`) +- Modify: `apps/web-wallet/app/entry.client.tsx:36-39` (temporary `storage` arg to stay green) + +**Interfaces:** +- Produces: the RC API for all later tasks — `configure({ apiUrl, clientId, storage })`, `browserStorage`, `StorageProvider`/`KeyValueStore` types, and the unchanged fn set (`signIn`, `signUp`, `signUpGuest`, `signInGuest`, `signOut`, `fetchUser`, `verifyEmail`, `requestNewVerificationCode`, `convertGuestToUserAccount`, `initiateGoogleAuth`, `handleGoogleCallback`, `generateThirdPartyToken`, `getPrivateKey`, `getPrivateKeyBytes`, `getPublicKey`, `signMessage`). + +- [ ] **Step 1: Bump the catalog version (+ fold `jwt-decode` into the catalog)** + +In root `package.json`, change: + +```json +"@agicash/opensecret": "0.1.0", +``` + +to: + +```json +"@agicash/opensecret": "1.0.0-rc.0", +``` + +and add `"jwt-decode": "4.0.0"` to the same `workspaces.catalog` block, switching the two consumers (`apps/web-wallet/package.json`, `packages/wallet-sdk/package.json`) from `"jwt-decode": "4.0.0"` to `"jwt-decode": "catalog:"` — repo rule: a dep shared by ≥2 packages lives in the catalog. + +- [ ] **Step 2: Install** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun install` +Expected: lockfile updates; no peer-dep warnings (RC has no React peer deps). + +- [ ] **Step 3: Satisfy the now-required `storage` option in `entry.client.tsx`** + +The RC's `configure()` requires `storage`. In `apps/web-wallet/app/entry.client.tsx`, change: + +```ts +import { configure } from '@agicash/opensecret'; +``` +```ts +configure({ + apiUrl: openSecretApiUrl, + clientId: openSecretClientId, +}); +``` + +to: + +```ts +import { browserStorage, configure } from '@agicash/opensecret'; +``` +```ts +configure({ + apiUrl: openSecretApiUrl, + clientId: openSecretClientId, + storage: browserStorage, +}); +``` + +(This is transitional; Task 10 moves `configure()` into `AgicashSdk.create()` and reverts this file further.) + +- [ ] **Step 4: Verify types + boot** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` +Expected: PASS (all used fn signatures are unchanged in the RC). + +Smoke: `bun run dev`, open `http://127.0.0.1:3000`, log in (or guest signup), confirm wallet loads. A pre-existing session in localStorage must still be logged in (keys unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add package.json bun.lock apps/web-wallet/package.json packages/wallet-sdk/package.json apps/web-wallet/app/entry.client.tsx +git commit -m "feat(deps): adopt React-agnostic @agicash/opensecret 1.0.0-rc.0" +``` + +--- + +### Task 2: Move the long-timeout util to `@agicash/utils` + +**Files:** +- Create: `packages/utils/src/timeout.ts` (content = current `apps/web-wallet/app/lib/timeout.ts`, verbatim) +- Modify: `packages/utils/src/index.ts` (add `export * from './timeout';`) +- Delete: `apps/web-wallet/app/lib/timeout.ts` +- Modify: `apps/web-wallet/app/hooks/use-long-timeout.ts` (import `{ clearLongTimeout, setLongTimeout }` from `@agicash/utils`) +- Modify: `apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts:~41` (same repoint) +- Modify: `apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts:6` (imports the util RELATIVELY: `from '../timeout'` → `from '@agicash/utils'`) + +**Interfaces:** +- Produces: `setLongTimeout(callback: () => void, delay: number): LongTimeout`, `clearLongTimeout(timeout: LongTimeout): void`, `type LongTimeout` from `@agicash/utils` — consumed by Task 6's `AuthService`. + +- [ ] **Step 1: Move the file** — `git mv` semantics: create `packages/utils/src/timeout.ts` with the exact current content of `apps/web-wallet/app/lib/timeout.ts`, delete the web file, add the barrel export. + +- [ ] **Step 2: Repoint the three web importers** — `use-long-timeout.ts` and `cashu-receive-quote-hooks.ts` import via the `~/lib/timeout` alias; `lib/cashu/melt-quote-subscription.ts:6` imports via the RELATIVE specifier `'../timeout'`. Replace all three with `@agicash/utils`. Keep imported names identical (`type LongTimeout`, `clearLongTimeout`, `setLongTimeout`). + +- [ ] **Step 3: Verify** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` +Expected: PASS. `grep -rn "from '.*timeout'" apps/web-wallet/app | grep -v '@agicash/utils'` returns nothing (catches alias AND relative specifiers). + +- [ ] **Step 4: Commit** + +```bash +git add packages/utils apps/web-wallet +git commit -m "refactor(utils): move setLongTimeout/clearLongTimeout to @agicash/utils" +``` + +--- + +### Task 3: Settle the step-5 contract types in `sdk.ts` + +**Files:** +- Modify: `packages/wallet-sdk/sdk.ts` + +**Interfaces:** +- Produces (consumed by every later task): + - `AuthKeyValueStore` = `{ getItem(key): string | null | Promise; setItem(key, value): void | Promise; removeItem(key): void | Promise }` + - `AuthStorage` = `{ persistent: AuthKeyValueStore; session: AuthKeyValueStore }` + - `SdkConfig['auth']` gains `generateGuestPassword?: () => Promise` + - `AuthUser = UserResponse['user']` (from `@agicash/opensecret`) + - `AcceptTermsParams`, `SetDefaultAccountParams`, `SetDefaultCurrencyParams` per A10. + +- [ ] **Step 1: Replace the `AuthStorage` placeholder** (`sdk.ts:60-65`) with the RC-verbatim shape: + +```ts +/** + * Minimal key/value store — the Web Storage API subset the SDK persists auth + * state through. Methods may be sync (window.localStorage) or async (React + * Native AsyncStorage, SQLite); the SDK always awaits results. Matches the + * @agicash/opensecret StorageProvider interface verbatim, so one host object + * backs both. + */ +export type AuthKeyValueStore = { + getItem(key: string): string | null | Promise; + setItem(key: string, value: string): void | Promise; + removeItem(key: string): void | Promise; +}; + +/** + * Host-backed session persistence. `persistent` must survive restarts (auth + * tokens, guest credentials); `session` is per-app-session (attestation + * handshake material). Browser hosts map them to localStorage/sessionStorage. + */ +export type AuthStorage = { + persistent: AuthKeyValueStore; + session: AuthKeyValueStore; +}; +``` + +- [ ] **Step 2: Add the guest-password port** to `SdkConfig.auth`: + +```ts + auth: { + apiUrl: string; + clientId: string; + storage: AuthStorage; + /** + * Host override for guest credential generation; resolve null to use the + * SDK's CSPRNG generator. Test seam (the web bridges its e2e password + * mock through it). + */ + generateGuestPassword?: () => Promise; + }; +``` + +- [ ] **Step 3: Settle `AuthUser`** — replace `export type AuthUser = unknown; // settles in step 5 (auth & user)` with: + +```ts +import type { UserResponse } from '@agicash/opensecret'; +// … +export type AuthUser = UserResponse['user']; +``` + +(import goes at the top with the other imports). + +- [ ] **Step 4: Settle the three param placeholders** — replace the `unknown` lines at the bottom: + +```ts +export type AcceptTermsParams = { + walletTerms?: boolean; + giftCardTerms?: boolean; +}; + +export type SetDefaultAccountParams = { + accountId: string; + /** Also switch the user's default currency to the account's currency. */ + setDefaultCurrency?: boolean; +}; + +export type SetDefaultCurrencyParams = { + currency: Currency; +}; +``` + +Add `Currency` to the existing `@agicash/money` type import (`import type { Currency, Money } from '@agicash/money';`). + +- [ ] **Step 5: Add `auth.session-refreshed` to `WalletEventMap`** (contract addition per A13 — adding events is non-breaking per the map's own invariant). Insert directly after the `'auth.session-expired'` entry in `sdk.ts`: + +```ts + /** + * The SDK refreshed the session without a host-initiated verb — today: + * guest auto-extension at refresh-token expiry. Host-initiated verbs never + * fire it (the host knows its own actions). Hosts re-sync session-derived + * state from it (the web: auth query + session-hint cookie). + */ + 'auth.session-refreshed': Record; +``` + +Append the same entry with a one-line A13 note to the event map in `docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md`, so the prose contract stays in sync with `sdk.ts`. + +- [ ] **Step 6: Annotate `init()`'s contract JSDoc for the migration window** — the doc-comment on `Sdk['init']` promises session restore AND the Breez WASM load, but step 5 ships restore-only (A3). Append one line to that JSDoc so contract readers aren't misled mid-migration: + +```ts + * Migration note: until the first Spark slice lands, `init()` performs + * session restore only — the WASM load still runs host-side. +``` + +- [ ] **Step 7: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. + +```bash +git add packages/wallet-sdk/sdk.ts +git commit -m "feat(wallet-sdk): settle the step-5 contract types (auth storage, AuthUser, user params)" +``` + +--- + +### Task 4: Typed event emitter (`lib/events.ts`) + +**Files:** +- Create: `packages/wallet-sdk/lib/events.ts` +- Test: `packages/wallet-sdk/lib/events.test.ts` + +**Interfaces:** +- Consumes: `WalletEventMap`, `WalletEvents`, `Logger` types from `../sdk`. +- Produces: `class WalletEventEmitter implements WalletEvents` with `on(event, handler): () => void` and `emit(event, payload): void` — consumed by Tasks 6 and 8. + +- [ ] **Step 1: Write the failing tests** + +```ts +// packages/wallet-sdk/lib/events.test.ts +import { describe, expect, it } from 'bun:test'; +import { WalletEventEmitter } from './events'; + +describe('WalletEventEmitter', () => { + it('delivers payloads to subscribed handlers', () => { + const emitter = new WalletEventEmitter(); + const received: unknown[] = []; + emitter.on('auth.session-expired', (payload) => received.push(payload)); + + emitter.emit('auth.session-expired', {}); + + expect(received).toEqual([{}]); + }); + + it('stops delivering after unsubscribe', () => { + const emitter = new WalletEventEmitter(); + let calls = 0; + const unsubscribe = emitter.on('auth.session-expired', () => { + calls += 1; + }); + + unsubscribe(); + emitter.emit('auth.session-expired', {}); + + expect(calls).toBe(0); + }); + + it('isolates a throwing handler and reports it to the logger', () => { + const errors: string[] = []; + const emitter = new WalletEventEmitter({ + debug: () => {}, + info: () => {}, + warn: () => {}, + error: (message) => { + errors.push(message); + }, + }); + let secondHandlerRan = false; + emitter.on('auth.session-expired', () => { + throw new Error('boom'); + }); + emitter.on('auth.session-expired', () => { + secondHandlerRan = true; + }); + + emitter.emit('auth.session-expired', {}); + + expect(secondHandlerRan).toBe(true); + expect(errors).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && cd packages/wallet-sdk && bun test lib/events.test.ts` +Expected: FAIL — `events.ts` does not exist. + +- [ ] **Step 3: Implement** + +```ts +// packages/wallet-sdk/lib/events.ts +import type { Logger, WalletEventMap, WalletEvents } from '../sdk'; + +type Handler = (payload: never) => void; + +export class WalletEventEmitter implements WalletEvents { + private readonly handlers = new Map>(); + + constructor(private readonly logger?: Logger) {} + + on( + event: K, + handler: (payload: WalletEventMap[K]) => void, + ): () => void { + const set = this.handlers.get(event) ?? new Set(); + set.add(handler as Handler); + this.handlers.set(event, set); + return () => { + set.delete(handler as Handler); + }; + } + + emit( + event: K, + payload: WalletEventMap[K], + ): void { + const set = this.handlers.get(event); + if (!set) { + return; + } + for (const handler of set) { + try { + (handler as (payload: WalletEventMap[K]) => void)(payload); + } catch (error) { + this.logger?.error(`Event handler for ${event} threw`, error); + } + } + } +} +``` + +- [ ] **Step 4: Run tests** — same command, expected: 3 pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/wallet-sdk/lib/events.ts packages/wallet-sdk/lib/events.test.ts +git commit -m "feat(wallet-sdk): add the typed wallet event emitter" +``` + +--- + +### Task 5: SDK password generator + guest-account storage + +**Files:** +- Create: `packages/wallet-sdk/lib/password.ts` +- Create: `packages/wallet-sdk/domain/user/guest-account-storage.ts` +- Test: `packages/wallet-sdk/domain/user/guest-account-storage.test.ts` + +**Interfaces:** +- Consumes: `AuthKeyValueStore`, `Logger` from `../../sdk`; `safeJsonParse` from `@agicash/utils`. +- Produces: + - `generateRandomPassword(length?: number): Promise` (CSPRNG, no window access) + - `type GuestAccountDetails = { id: string; password: string }` + - `createGuestAccountStorage(store: AuthKeyValueStore, logger?: Logger): GuestAccountStorage` where `GuestAccountStorage = { get(): Promise; store(details): Promise; clear(): Promise }` — consumed by Task 6. + +- [ ] **Step 1: Write the failing storage tests** + +```ts +// packages/wallet-sdk/domain/user/guest-account-storage.test.ts +import { describe, expect, it } from 'bun:test'; +import type { AuthKeyValueStore } from '../../sdk'; +import { createGuestAccountStorage } from './guest-account-storage'; + +const createMemoryStore = (): AuthKeyValueStore & { data: Map } => { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +describe('guestAccountStorage', () => { + it('round-trips guest account details under the legacy key', async () => { + const store = createMemoryStore(); + const storage = createGuestAccountStorage(store); + + await storage.store({ id: 'guest-1', password: 'pw' }); + + expect(store.data.has('guestAccount')).toBe(true); + expect(await storage.get()).toEqual({ id: 'guest-1', password: 'pw' }); + }); + + it('returns null when nothing is stored', async () => { + const storage = createGuestAccountStorage(createMemoryStore()); + expect(await storage.get()).toBeNull(); + }); + + it('returns null for corrupt or invalid data', async () => { + const store = createMemoryStore(); + store.data.set('guestAccount', 'not-json'); + const storage = createGuestAccountStorage(store); + expect(await storage.get()).toBeNull(); + + store.data.set('guestAccount', JSON.stringify({ id: 42 })); + expect(await storage.get()).toBeNull(); + }); + + it('clear removes the stored account', async () => { + const store = createMemoryStore(); + const storage = createGuestAccountStorage(store); + await storage.store({ id: 'guest-1', password: 'pw' }); + + await storage.clear(); + + expect(await storage.get()).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `cd packages/wallet-sdk && bun test domain/user/guest-account-storage.test.ts` → FAIL (module missing). + +- [ ] **Step 3: Implement storage** + +```ts +// packages/wallet-sdk/domain/user/guest-account-storage.ts +import { safeJsonParse } from '@agicash/utils'; +import { z } from 'zod/mini'; +import type { AuthKeyValueStore, Logger } from '../../sdk'; + +// Key predates the SDK move — existing devices have guest credentials stored +// under it, so it must not change. +const storageKey = 'guestAccount'; + +const GuestAccountDetailsSchema = z.object({ + id: z.string(), + password: z.string(), +}); + +export type GuestAccountDetails = z.infer; + +export type GuestAccountStorage = { + get(): Promise; + store(details: GuestAccountDetails): Promise; + clear(): Promise; +}; + +export function createGuestAccountStorage( + store: AuthKeyValueStore, + logger?: Logger, +): GuestAccountStorage { + return { + async get() { + const dataString = await store.getItem(storageKey); + if (!dataString) { + return null; + } + const parseResult = safeJsonParse(dataString); + if (!parseResult.success) { + return null; + } + const validationResult = GuestAccountDetailsSchema.safeParse( + parseResult.data, + ); + if (!validationResult.success) { + logger?.warn('Invalid guest account data found in the storage'); + return null; + } + return validationResult.data; + }, + async store(details) { + await store.setItem(storageKey, JSON.stringify(details)); + }, + async clear() { + await store.removeItem(storageKey); + }, + }; +} +``` + +- [ ] **Step 4: Implement the password generator** (moved from `apps/web-wallet/app/lib/password-generator.ts`, minus the `window.getMockPassword` hook and `window.` prefixes — this becomes the ONLY generator; the web file is deleted in Task 11 once its last importer, the old `auth.ts`, is rewritten): + +```ts +// packages/wallet-sdk/lib/password.ts +type PasswordOptions = { + letters?: boolean; + numbers?: boolean; + special?: boolean; +}; + +export async function generateRandomPassword( + length = 24, + options: PasswordOptions = { letters: true, numbers: true, special: true }, +): Promise { + let charset = ''; + + if (options.letters) + charset += 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if (options.numbers) charset += '0123456789'; + if (options.special) charset += '!@#$%^&*()_+~'; + + if (!charset) { + throw new Error( + 'At least one character set (letters, numbers, special) must be selected.', + ); + } + + const password: string[] = []; + + for (let i = 0; i < length; i++) { + const randomIndex = + globalThis.crypto.getRandomValues(new Uint32Array(1))[0] % charset.length; + password.push(charset[randomIndex]); + } + + return password.join(''); +} +``` + +- [ ] **Step 5: Run tests** — storage tests pass; `bun run fix:all && bun run typecheck` passes. + +- [ ] **Step 6: Commit** + +```bash +git add packages/wallet-sdk/lib/password.ts packages/wallet-sdk/domain/user +git commit -m "feat(wallet-sdk): add port-backed guest-account storage and CSPRNG password generator" +``` + +---### Task 6: `AuthService` — session state, verbs, expiry machinery + +**Files:** +- Create: `packages/wallet-sdk/domain/user/auth-service.ts` +- Test: `packages/wallet-sdk/domain/user/auth-service.test.ts` +- Modify: `packages/wallet-sdk/lib/error.ts` (add `NoSessionError`) + +**Interfaces:** +- Consumes: `AuthApi`, `AuthSession`, `AuthStorage`, `Logger` from `../../sdk`; `WalletEventEmitter` from `../../lib/events`; `GuestAccountStorage` from `./guest-account-storage`; `setLongTimeout`/`clearLongTimeout`/`LongTimeout` from `@agicash/utils`; `jwtDecode` from `jwt-decode`. +- Produces: `class AuthService implements AuthApi` with constructor `new AuthService(deps: AuthServiceDeps)`, plus `restoreSession(): Promise` and `teardown(): void` beyond the contract surface. `type OpenSecretAuthApi` naming the 11 Open Secret fns it uses (so `import * as openSecret` satisfies it structurally). Consumed by Task 8. + +- [ ] **Step 1: Add the internal error class** to `packages/wallet-sdk/lib/error.ts`: + +```ts +/** Thrown when a namespace method requiring an authenticated session runs without one. */ +export class NoSessionError extends SdkError { + constructor() { + super('No authenticated session'); + this.name = 'NoSessionError'; + } +} +``` + +(Not added to `index.ts`/`temporary.ts` — hosts catch it via the exported `SdkError` base.) + +- [ ] **Step 2: Write the failing tests.** Use fake deps throughout — no module mocking. Helper to mint JWTs with a chosen `exp` so timer math is real: + +```ts +// packages/wallet-sdk/domain/user/auth-service.test.ts +import { describe, expect, it } from 'bun:test'; +import type { AuthKeyValueStore, AuthStorage } from '../../sdk'; +import { WalletEventEmitter } from '../../lib/events'; +import { AuthService, type OpenSecretAuthApi } from './auth-service'; +import { createGuestAccountStorage } from './guest-account-storage'; + +const createMemoryStore = (): AuthKeyValueStore & { data: Map } => { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +const createStorage = (): AuthStorage & { persistent: ReturnType } => ({ + persistent: createMemoryStore(), + session: createMemoryStore(), +}); + +const toBase64Url = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + +const createJwt = (expSecondsFromNow: number, sub = 'user-1') => + `${toBase64Url({ alg: 'none' })}.${toBase64Url({ + sub, + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + })}.sig`; + +const fullUser = { + id: 'user-1', + name: null, + email: 'a@b.c', + email_verified: true, + login_method: 'email', + created_at: '2026-01-01', + updated_at: '2026-01-01', +}; + +const guestUser = { ...fullUser, email: undefined }; + +const createOsFake = ( + tokenStore: ReturnType, + overrides: Partial = {}, +) => { + const calls: string[] = []; + // The real Open Secret SDK persists fresh tokens on every login path; the + // fakes mirror that so a timer re-arm after login reads a live refresh token. + const login = (id: string) => { + tokenStore.data.set('access_token', createJwt(600, id)); + tokenStore.data.set('refresh_token', createJwt(3600, id)); + return { id, access_token: 'a', refresh_token: 'r' }; + }; + const os: OpenSecretAuthApi = { + fetchUser: async () => ({ user: fullUser }), + signIn: async () => login('user-1'), + signUp: async () => login('user-1'), + signUpGuest: async () => login('guest-1'), + signInGuest: async () => login('guest-1'), + signOut: async () => {}, + verifyEmail: async () => {}, + requestNewVerificationCode: async () => {}, + convertGuestToUserAccount: async () => {}, + initiateGoogleAuth: async () => ({ auth_url: 'https://accounts.google/x', csrf_token: 'c' }), + handleGoogleCallback: async () => login('user-1'), + ...overrides, + }; + // wrap every fn to record invocation order + for (const key of Object.keys(os) as (keyof OpenSecretAuthApi)[]) { + const original = os[key] as (...args: unknown[]) => unknown; + // biome-ignore lint/suspicious/noExplicitAny: test instrumentation + (os as any)[key] = (...args: unknown[]) => { + calls.push(key); + return original(...args); + }; + } + return { os, calls }; +}; + +const createService = (options: { + os?: Partial; + storage?: ReturnType; + onSessionEnded?: () => void; +} = {}) => { + const storage = options.storage ?? createStorage(); + const { os, calls } = createOsFake(storage.persistent, options.os); + const events = new WalletEventEmitter(); + const service = new AuthService({ + os, + storage, + guestAccountStorage: createGuestAccountStorage(storage.persistent), + generateGuestPassword: async () => 'generated-pw', + events, + onSessionEnded: options.onSessionEnded, + }); + return { service, storage, calls, events }; +}; + +describe('AuthService', () => { + it('starts anonymous', () => { + const { service } = createService(); + expect(service.getSession()).toEqual({ isLoggedIn: false }); + }); + + describe('restoreSession', () => { + it('stays anonymous without stored tokens and does not call fetchUser', async () => { + const { service, calls } = createService(); + await service.restoreSession(); + expect(service.getSession().isLoggedIn).toBe(false); + expect(calls).not.toContain('fetchUser'); + }); + + it('restores a session from stored tokens', async () => { + const { service, storage } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await service.restoreSession(); + + expect(service.getSession()).toEqual({ isLoggedIn: true, user: fullUser }); + service.teardown(); + }); + + it('rejects when tokens exist but the user fetch fails, then recovers on retry', async () => { + let sessionEnded = false; + let failFetch = true; + const { service, storage } = createService({ + os: { + fetchUser: async () => { + if (failFetch) { + throw new Error('network'); + } + return { user: fullUser }; + }, + }, + onSessionEnded: () => { + sessionEnded = true; + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await expect(service.restoreSession()).rejects.toThrow('network'); + // per-session caches were torn down with the failed restore + expect(sessionEnded).toBe(true); + expect(service.getSession().isLoggedIn).toBe(false); + + // the rejection is not memoized — a retry can succeed + failFetch = false; + await service.restoreSession(); + + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('is single-flight', async () => { + const { service, storage, calls } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await Promise.all([service.restoreSession(), service.restoreSession()]); + + expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); + service.teardown(); + }); + }); + + describe('signUpGuest', () => { + it('creates a new guest account and stores the credentials', async () => { + const { service, storage, calls } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + + await service.signUpGuest(); + + expect(calls).toContain('signUpGuest'); + expect(JSON.parse(storage.persistent.data.get('guestAccount') ?? '')).toEqual({ + id: 'guest-1', + password: 'generated-pw', + }); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('re-signs-in the stored guest account instead of creating a new one', async () => { + const { service, storage, calls } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'stored-pw' }), + ); + + await service.signUpGuest(); + + expect(calls).toContain('signInGuest'); + expect(calls).not.toContain('signUpGuest'); + service.teardown(); + }); + }); + + describe('signOut', () => { + it('clears the session, keeps guest credentials, and runs onSessionEnded', async () => { + let sessionEnded = false; + const { service, storage } = createService({ + onSessionEnded: () => { + sessionEnded = true; + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + await service.restoreSession(); + + await service.signOut(); + + expect(service.getSession().isLoggedIn).toBe(false); + expect(sessionEnded).toBe(true); + expect(storage.persistent.data.has('guestAccount')).toBe(true); + }); + + it('wipes per-session caches again when a different user signs in after sign-out', async () => { + let sessionEndedCount = 0; + const userIds = ['user-a', 'user-b']; + let fetchCalls = 0; + const { service } = createService({ + os: { + fetchUser: async () => ({ + user: { ...fullUser, id: userIds[Math.min(fetchCalls++, 1)] }, + }), + }, + onSessionEnded: () => { + sessionEndedCount += 1; + }, + }); + + await service.signIn('a@b.c', 'pw'); + await service.signOut(); // ends user-a's session → 1 + await service.signIn('b@b.c', 'pw'); // different user → wiped again → 2 + + expect(sessionEndedCount).toBe(2); + service.teardown(); + }); + }); + + describe('convertGuestToFullAccount', () => { + it('clears the stored guest credentials', async () => { + const { service, storage } = createService(); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await service.convertGuestToFullAccount('a@b.c', 'pw2'); + + expect(storage.persistent.data.has('guestAccount')).toBe(false); + service.teardown(); + }); + }); + + describe('session expiry', () => { + it('emits auth.session-expired and ends the session for a full account', async () => { + let sessionEnded = false; + const { service, storage, events } = createService({ + onSessionEnded: () => { + sessionEnded = true; + }, + }); + // refresh token expiring "now" (exp-5s already past) → timer fires immediately + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // the 1ms-floored timer fires on the next macrotask + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(expired).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(false); + expect(sessionEnded).toBe(true); + }); + + it('auto-extends a guest session instead of expiring it', async () => { + const { service, storage, calls, events } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + const refreshed: unknown[] = []; + events.on('auth.session-refreshed', (payload) => refreshed.push(payload)); + + await service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(calls).toContain('signInGuest'); + expect(expired).toHaveLength(0); + // the host is told about the refresh it didn't initiate + expect(refreshed).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('re-arms instead of expiring when the stored refresh token was rotated forward', async () => { + const { service, storage, events } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + // (exp - 5s) is ~100ms away, so the timer fires shortly after restore + storage.persistent.data.set('refresh_token', createJwt(5.1)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // the Open Secret SDK rotates the refresh token during its internal + // refresh flow; simulate a rotation landing before the timer fires + storage.persistent.data.set('refresh_token', createJwt(3600)); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(expired).toHaveLength(0); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('ends the session when the extension cannot restore the guest user', async () => { + let fetchCalls = 0; + const { service, storage, events } = createService({ + os: { + fetchUser: async () => { + fetchCalls += 1; + // restore succeeds; the post-extend fetch fails + if (fetchCalls > 1) { + throw new Error('network'); + } + return { user: guestUser }; + }, + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // no wedged half-session: the death path ran and told the host + expect(expired).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(false); + }); + }); + + it('initiateGoogleAuth returns the raw auth url', async () => { + const { service } = createService(); + expect(await service.initiateGoogleAuth()).toEqual({ + authUrl: 'https://accounts.google/x', + }); + }); +}); +``` + +Note: the login fakes MUST write fresh tokens into the store (mirroring the real Open Secret SDK, which persists tokens on every login path) — otherwise the guest-extend test would end the session instead of extending it: the post-extend expiry check would see an unmoved expiry and fall through to the death path. (In production that check plus the 1ms timer floor is what guards against a hot extend loop when a returned token is already expired.) Bun runs all test files in one process, so every test that leaves a session (and therefore a live expiry timer) ends with `service.teardown()` to avoid leaking timers into the rest of the suite. + +- [ ] **Step 3: Run to verify failure** — `cd packages/wallet-sdk && bun test domain/user/auth-service.test.ts` → FAIL (module missing). + +- [ ] **Step 4: Implement `AuthService`** + +```ts +// packages/wallet-sdk/domain/user/auth-service.ts +import { + type LongTimeout, + clearLongTimeout, + setLongTimeout, +} from '@agicash/utils'; +import type { + GoogleAuthResponse, + LoginResponse, + UserResponse, +} from '@agicash/opensecret'; +import { jwtDecode } from 'jwt-decode'; +import type { WalletEventEmitter } from '../../lib/events'; +import type { AuthApi, AuthSession, AuthStorage, Logger } from '../../sdk'; +import type { GuestAccountStorage } from './guest-account-storage'; + +// Keys are owned by @agicash/opensecret's token persistence; the service reads +// them (never writes) for session detection and expiry math. +const accessTokenKey = 'access_token'; +const refreshTokenKey = 'refresh_token'; + +// A corrupt stored token must degrade (no timer / expiry path), never throw +// from a timer callback or a query fn. +const decodeJwt = (token: string): { exp?: number } | null => { + try { + return jwtDecode(token); + } catch { + return null; + } +}; + +/** The subset of @agicash/opensecret the auth service drives. `import * as openSecret` satisfies it. */ +export type OpenSecretAuthApi = { + fetchUser(): Promise; + signIn(email: string, password: string): Promise; + signUp( + email: string, + password: string, + inviteCode: string, + name?: string | null, + ): Promise; + signUpGuest(password: string, inviteCode: string): Promise; + signInGuest(id: string, password: string): Promise; + signOut(): Promise; + verifyEmail(code: string): Promise; + requestNewVerificationCode(): Promise; + convertGuestToUserAccount( + email: string, + password: string, + name?: string | null, + ): Promise; + initiateGoogleAuth(inviteCode?: string): Promise; + handleGoogleCallback( + code: string, + state: string, + inviteCode: string, + ): Promise; +}; + +type AuthServiceDeps = { + os: OpenSecretAuthApi; + storage: AuthStorage; + guestAccountStorage: GuestAccountStorage; + generateGuestPassword: () => Promise; + events: WalletEventEmitter; + /** Instance-memo cleanup on any session end (sign-out or expiry). */ + onSessionEnded?: () => void; + logger?: Logger; +}; + +export class AuthService implements AuthApi { + private session: AuthSession = { isLoggedIn: false }; + private restorePromise: Promise | undefined; + private expiryTimeout: LongTimeout | undefined; + // Survives endSession() deliberately — see applySessionFromServer. + private lastUserId: string | undefined; + + constructor(private readonly deps: AuthServiceDeps) {} + + getSession(): AuthSession { + return this.session; + } + + /** + * Idempotent session restore from the storage port; resolving anonymous is + * a state, not a failure. A rejection (unreadable storage) is not memoized, + * so the host's query retries can recover. + */ + restoreSession(): Promise { + this.restorePromise ??= this.doRestore().catch((error) => { + this.restorePromise = undefined; + throw error; + }); + return this.restorePromise; + } + + private async doRestore(): Promise { + const [accessToken, refreshToken] = await Promise.all([ + this.deps.storage.persistent.getItem(accessTokenKey), + this.deps.storage.persistent.getItem(refreshTokenKey), + ]); + if (!accessToken || !refreshToken) { + return; + } + try { + await this.applySessionFromServer(); + } catch (error) { + if (this.session.isLoggedIn) { + // An auth verb established a session while this restore was in + // flight; the restore result is moot. + return; + } + // Contract: init() rejects on refresh errors (tokens exist but can't + // be validated). endSession keeps the instance consistent; the + // rejection is un-memoized by restoreSession, so a retry can succeed. + this.endSession(); + throw error; + } + } + + async signUp(email: string, password: string): Promise { + await this.deps.os.signUp(email, password, ''); + await this.refreshSessionSnapshot('sign up'); + } + + async signUpGuest(): Promise { + const existingGuestAccount = await this.deps.guestAccountStorage.get(); + if (existingGuestAccount) { + await this.deps.os.signInGuest( + existingGuestAccount.id, + existingGuestAccount.password, + ); + } else { + const password = await this.deps.generateGuestPassword(); + const guestAccount = await this.deps.os.signUpGuest(password, ''); + await this.deps.guestAccountStorage.store({ + id: guestAccount.id, + password, + }); + } + await this.refreshSessionSnapshot('guest sign up'); + } + + async signIn(email: string, password: string): Promise { + await this.deps.os.signIn(email, password); + await this.refreshSessionSnapshot('sign in'); + } + + async signOut(): Promise { + try { + await this.deps.os.signOut(); + } finally { + this.endSession(); + } + } + + async verifyEmail(code: string): Promise { + await this.deps.os.verifyEmail(code); + await this.refreshSessionSnapshot('email verification'); + } + + requestNewVerificationCode(): Promise { + return this.deps.os.requestNewVerificationCode(); + } + + async convertGuestToFullAccount( + email: string, + password: string, + ): Promise { + await this.deps.os.convertGuestToUserAccount(email, password); + await this.deps.guestAccountStorage.clear(); + await this.refreshSessionSnapshot('guest conversion'); + } + + async initiateGoogleAuth(): Promise<{ authUrl: string }> { + const response = await this.deps.os.initiateGoogleAuth(''); + return { authUrl: response.auth_url }; + } + + async completeGoogleAuth(params: { + code: string; + state: string; + }): Promise { + await this.deps.os.handleGoogleCallback(params.code, params.state, ''); + await this.refreshSessionSnapshot('google auth'); + } + + /** Cancels the expiry timer; the instance stays usable. */ + teardown(): void { + this.disarmExpiryTimer(); + } + + private async refreshSessionSnapshot(context: string): Promise { + try { + await this.applySessionFromServer(); + } catch (error) { + // Swallowed for parity: a verb whose fetchUser fails leaves an + // anonymous session the host discovers on its next read, like the old + // web glue. endSession (not a bare snapshot clear) so the per-session + // caches die with the session — the Supabase token cache in particular + // must never outlive it. + this.deps.logger?.error(`Failed to fetch user (${context})`, error); + this.endSession(); + } + } + + private async applySessionFromServer(): Promise { + const response = await this.deps.os.fetchUser(); + // Compared against the last seen user rather than the live session: a + // memo repopulated by a request that resolved after sign-out must still + // be wiped when a DIFFERENT user's session begins, and by then the + // session is anonymous. Same-user re-login keeps its memos warm. + if (this.lastUserId && this.lastUserId !== response.user.id) { + this.deps.onSessionEnded?.(); + } + this.lastUserId = response.user.id; + this.session = { isLoggedIn: true, user: response.user }; + await this.armExpiryTimer(); + } + + private endSession(): void { + this.session = { isLoggedIn: false }; + this.disarmExpiryTimer(); + // Un-memoize the restore so the next init() re-evaluates from storage — + // a verb whose post-login fetchUser failed leaves tokens behind, and the + // next invalidation can then recover the session like the old glue did. + this.restorePromise = undefined; + this.deps.onSessionEnded?.(); + } + + private async armExpiryTimer(): Promise { + const remaining = await this.getRemainingSessionTimeMs(); + // Disarm only after the await, so disarm+assign form one synchronous + // block — two overlapping arms can't interleave and orphan a timer. + this.disarmExpiryTimer(); + if (remaining === null) { + return; + } + // Floor of 1ms: setLongTimeout fires synchronously at delay 0, which + // would recurse into handleSessionExpiry from inside a login verb. + this.expiryTimeout = setLongTimeout( + () => { + void this.handleSessionExpiry(); + }, + Math.max(remaining, 1), + ); + } + + /** + * Milliseconds until the stored refresh token is treated as expired (5s + * before actual expiry, matching the previous web behavior), floored at 0. + * Null when the token is absent or undecodable. + */ + private async getRemainingSessionTimeMs(): Promise { + const refreshToken = + await this.deps.storage.persistent.getItem(refreshTokenKey); + if (!refreshToken) { + return null; + } + const decoded = decodeJwt(refreshToken); + if (!decoded?.exp) { + return null; + } + return Math.max((decoded.exp - 5) * 1000 - Date.now(), 0); + } + + private disarmExpiryTimer(): void { + if (this.expiryTimeout) { + clearLongTimeout(this.expiryTimeout); + this.expiryTimeout = undefined; + } + } + + private async handleSessionExpiry(): Promise { + const session = this.session; + if (!session.isLoggedIn) { + return; + } + // The Open Secret SDK rotates the refresh token during its internal + // refresh flow, so the expiry this timer was armed for may have moved. + // Re-check the stored token and re-arm instead of expiring a live session. + const remaining = await this.getRemainingSessionTimeMs(); + if (remaining !== null && remaining > 0) { + await this.armExpiryTimer(); + return; + } + const isGuest = !session.user.email; + if (isGuest) { + try { + // Re-signing-in the stored guest account gets fresh tokens and re-arms + // the timer; the host never observes the expiry. + await this.signUpGuest(); + const extendedRemaining = await this.getRemainingSessionTimeMs(); + if ( + extendedRemaining !== null && + extendedRemaining > 0 && + this.session.isLoggedIn + ) { + // The host didn't initiate this refresh, so it must be told — + // the web re-syncs its auth query + session-hint cookie from it. + this.deps.events.emit('auth.session-refreshed', {}); + return; + } + // Falls through when the extension produced no live session (already- + // expired token — also guards a hot extend loop — or a failed + // post-extend user fetch), so the death path emits the event instead + // of leaving a wedged half-session. + this.deps.logger?.warn( + 'Guest session extension did not produce a live session; ending it', + ); + } catch (error) { + this.deps.logger?.error('Failed to extend guest session', error); + } + } + try { + await this.deps.os.signOut(); + } catch (error) { + this.deps.logger?.warn('Sign out during session expiry failed', error); + } + this.endSession(); + this.deps.events.emit('auth.session-expired', {}); + } +} +``` + +- [ ] **Step 5: Run tests** — all `auth-service.test.ts` tests pass; run `bun test` (whole SDK) and `bun run fix:all && bun run typecheck` → PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/wallet-sdk/domain/user/auth-service.ts packages/wallet-sdk/domain/user/auth-service.test.ts packages/wallet-sdk/lib/error.ts +git commit -m "feat(wallet-sdk): add AuthService with session snapshot and expiry machinery" +``` + +--- + +### Task 7: SDK Supabase client + internal session-token getter + +**Files:** +- Create: `packages/wallet-sdk/db/client.ts` +- Create: `packages/wallet-sdk/db/supabase-session.ts` +- Test: `packages/wallet-sdk/db/supabase-session.test.ts` + +**Interfaces:** +- Consumes: `Database`, `AgicashDb` types from `./database`; `generateThirdPartyToken` from `@agicash/opensecret`. +- Produces: + - `createSupabaseSessionTokenGetter(deps: { isLoggedIn: () => boolean; generateToken?: () => Promise<{ token: string }> }): SupabaseSessionTokenSource` where `SupabaseSessionTokenSource = { getToken: () => Promise; reset: () => void }`. `reset` MUST be invoked on session end (Task 9 wires it into `onSessionEnded`) — the cache is otherwise only re-validated by expiry, and a token minted for one user must never survive into another user's session (sign out → sign in as a different user would otherwise query with the old JWT until it expires). + - `createAgicashDbClient(config: { url: string; anonKey: string; accessToken: () => Promise }): AgicashDb` — consumed by Task 9. + +- [ ] **Step 1: Write the failing token-getter tests** + +```ts +// packages/wallet-sdk/db/supabase-session.test.ts +import { describe, expect, it } from 'bun:test'; +import { createSupabaseSessionTokenGetter } from './supabase-session'; + +const toBase64Url = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + +const createToken = (expSecondsFromNow: number) => + `${toBase64Url({ alg: 'none' })}.${toBase64Url({ + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + })}.sig`; + +describe('createSupabaseSessionTokenGetter', () => { + it('returns null and skips token generation when logged out', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => false, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + expect(await getToken()).toBeNull(); + expect(generated).toBe(0); + }); + + it('memoizes the token until close to expiry', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + const first = await getToken(); + const second = await getToken(); + + expect(first).toBe(second as string); + expect(generated).toBe(1); + }); + + it('re-generates once the cached token is within 5s of expiry', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + // expires in 3s → refreshAt is already in the past + return { token: createToken(3) }; + }, + }); + + await getToken(); + await getToken(); + + expect(generated).toBe(2); + }); + + it('shares one in-flight request between concurrent callers', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + await new Promise((resolve) => setTimeout(resolve, 5)); + return { token: createToken(3600) }; + }, + }); + + await Promise.all([getToken(), getToken(), getToken()]); + + expect(generated).toBe(1); + }); + + it('drops the cache when the session ends', async () => { + let loggedIn = true; + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => loggedIn, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + await getToken(); + loggedIn = false; + expect(await getToken()).toBeNull(); + loggedIn = true; + await getToken(); + + expect(generated).toBe(2); + }); + + it('reset drops the cached token so the next session cannot reuse it', async () => { + let generated = 0; + const source = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + await source.getToken(); + source.reset(); + await source.getToken(); + + expect(generated).toBe(2); + }); + + it('does not cache a token that resolves after reset', async () => { + let generated = 0; + let release: (() => void) | undefined; + const source = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + if (generated === 1) { + await new Promise((resolve) => { + release = resolve; + }); + } + return { token: createToken(3600) }; + }, + }); + + const firstCall = source.getToken(); + // session ends while the first exchange is still in flight + source.reset(); + release?.(); + await firstCall; + + await source.getToken(); + + // the stale in-flight token was not cached; the new session exchanged fresh + expect(generated).toBe(2); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `cd packages/wallet-sdk && bun test db/supabase-session.test.ts` → FAIL. + +- [ ] **Step 3: Implement** + +```ts +// packages/wallet-sdk/db/supabase-session.ts +import { generateThirdPartyToken } from '@agicash/opensecret'; +import { jwtDecode } from 'jwt-decode'; + +type Deps = { + isLoggedIn: () => boolean; + /** Test seam; defaults to Open Secret's generateThirdPartyToken. */ + generateToken?: () => Promise<{ token: string }>; +}; + +export type SupabaseSessionTokenSource = { + /** Supabase `accessToken` callback; null selects the anon key. */ + getToken: () => Promise; + /** + * Drops the cached token. Must be called when the session ends — the cache + * is otherwise only re-validated by expiry, and a token minted for one user + * must never survive into another user's session. + */ + reset: () => void; +}; + +/** + * Builds the Supabase `accessToken` source: exchanges the Open Secret JWT + * for a Supabase third-party token and memoizes it until 5 seconds before its + * expiry. Concurrent callers share one in-flight exchange. Returns null when + * no session exists (the client then uses the anon key). + */ +export function createSupabaseSessionTokenGetter( + deps: Deps, +): SupabaseSessionTokenSource { + const generateToken = deps.generateToken ?? (() => generateThirdPartyToken()); + let cached: { token: string; refreshAtMs: number } | undefined; + let inFlight: Promise | undefined; + // Incremented by reset(); an exchange started under an older generation + // must not populate the cache — its token belongs to the ended session. + let generation = 0; + + const invalidate = () => { + generation += 1; + cached = undefined; + inFlight = undefined; + }; + + return { + reset: invalidate, + getToken: async () => { + if (!deps.isLoggedIn()) { + // Same full invalidation as reset(): an exchange in flight when the + // session ended must not populate the cache either. + invalidate(); + return null; + } + if (cached && Date.now() < cached.refreshAtMs) { + return cached.token; + } + if (!inFlight) { + const startedGeneration = generation; + inFlight = (async () => { + try { + const { token } = await generateToken(); + if (generation === startedGeneration) { + const { exp } = jwtDecode(token); + cached = { token, refreshAtMs: exp ? (exp - 5) * 1000 : 0 }; + } + return token; + } finally { + if (generation === startedGeneration) { + inFlight = undefined; + } + } + })(); + } + return inFlight; + }, + }; +} +``` + +```ts +// packages/wallet-sdk/db/client.ts +import { createClient } from '@supabase/supabase-js'; +import type { AgicashDb, Database } from './database'; + +type AgicashDbClientConfig = { + url: string; + anonKey: string; + /** Resolves the Supabase session JWT; null selects the anon key. */ + accessToken: () => Promise; +}; + +/** Builds the SDK's own Supabase client (wallet schema). */ +export function createAgicashDbClient(config: AgicashDbClientConfig): AgicashDb { + return createClient(config.url, config.anonKey, { + accessToken: config.accessToken, + db: { + schema: 'wallet', + }, + }); +} +``` + +If `AgicashDb` in `db/database.ts` is not exactly the return type of this `createClient` call, type the function's return as `AgicashDb` only if it assigns cleanly — otherwise return the inferred type and alias it; check `db/database.ts`'s `AgicashDb` definition first and match it. + +- [ ] **Step 4: Run tests** — token tests pass; `bun run fix:all && bun run typecheck` passes. + +- [ ] **Step 5: Commit** + +```bash +git add packages/wallet-sdk/db +git commit -m "feat(wallet-sdk): add the SDK-internal Supabase client and session token getter" +``` + +--- + +### Task 8: `WriteUserRepository`/`UserService` refactor (A9) + `createUserApi` + +**Files:** +- Modify: `packages/wallet-sdk/domain/user/user-repository.ts:56-60,107-201` +- Modify: `packages/wallet-sdk/domain/user/user-service.ts:49-73` +- Modify: `apps/web-wallet/app/routes/_protected.tsx:124-127` (call-site) +- Modify: `apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx:92-95` (call-site — there are THREE `new WriteUserRepository(...)` sites, not two) +- Modify: `apps/web-wallet/app/features/user/user-repository-hooks.ts` (drop accountRepository arg) +- Create: `packages/wallet-sdk/domain/user/user-api.ts` + +**Interfaces:** +- Consumes: `ReadUserRepository`, `WriteUserRepository`, `UserService` (existing), `NoSessionError` (Task 6), `AgicashDb`, contract types from `../../sdk`. +- Produces: + - `new WriteUserRepository(db)` (constructor loses `accountRepository`); `upsert(user, accountRepository: AccountRepository, options?)` gains it as a param. + - `UserService.setDefaultAccount(user: Pick, account: Pick, options?)` — column-minimal update; no field echo. + - `createUserApi(deps: { db: AgicashDb; getSession: () => AuthSession }): UserApi` — consumed by Task 9. + +- [ ] **Step 1: Refactor `WriteUserRepository`** + +```ts +export class WriteUserRepository { + constructor(private readonly db: AgicashDb) {} +``` + +and change `upsert`'s signature (body unchanged except `this.accountRepository` → `accountRepository`): + +```ts + async upsert( + user: { + // ... existing param docs unchanged ... + }, + accountRepository: AccountRepository, + options?: Options, + ): Promise<{ user: User; accounts: Account[] }> { +``` + +- [ ] **Step 2: Loosen `UserService.setDefaultAccount`'s params and make the update column-minimal** + +```ts + async setDefaultAccount( + user: Pick, + account: Pick, + options: SetDefaultAccountOptions = { + setDefaultCurrency: false, + }, + ): Promise { + if (!['BTC', 'USD'].includes(account.currency)) { + throw new Error('Unsupported currency'); + } + + return this.userRepository.update( + user.id, + { + ...(account.currency === 'BTC' + ? { defaultBtcAccountId: account.id } + : { defaultUsdAccountId: account.id }), + ...(options.setDefaultCurrency + ? { defaultCurrency: account.currency } + : {}), + }, + { abortSignal: options.abortSignal }, + ); + } +``` + +Only the changed columns are written — `WriteUserRepository.update` passes `undefined` for the omitted fields and supabase-js drops them (behavior the `acceptTerms` path already relies on in production), so the untouched defaults can't be clobbered by stale caller state under concurrency. `user` narrows to `Pick` because the echo of unchanged fields is gone — the service no longer needs the rest of the user. (Existing callers pass full `User`/`Account` objects — assignable, no further changes.) + +- [ ] **Step 3: Update the three web call-sites** + +`apps/web-wallet/app/routes/_protected.tsx` (`ensureUserData`): + +```ts + const writeUserRepository = new WriteUserRepository(agicashDbClient); + + const { user: upsertedUser, accounts } = await withRetry({ + fn: () => + writeUserRepository.upsert( + { + id: authUser.id, + email: authUser.email, + emailVerified: authUser.email_verified, + accounts: [...defaultAccounts], + cashuLockingXpub, + encryptionPublicKey, + sparkIdentityPublicKey, + termsAcceptedAt, + giftCardMintTermsAcceptedAt, + }, + accountRepository, + ), +``` + +`apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx:92-95` — the route never calls `upsert`, so the `accountRepository` arg simply goes: + +```ts + const userRepository = new WriteUserRepository(agicashDbClient); +``` + +`apps/web-wallet/app/features/user/user-repository-hooks.ts`: + +```ts +export function useWriteUserRepository() { + return new WriteUserRepository(agicashDbClient); +} +``` + +(and drop the now-unused `useAccountRepository` import; the whole file is deleted in Task 12.) + +- [ ] **Step 4: Create `createUserApi`** + +```ts +// packages/wallet-sdk/domain/user/user-api.ts +import type { Currency } from '@agicash/money'; +import type { AgicashDb } from '../../db/database'; +import { NoSessionError } from '../../lib/error'; +import type { AuthSession, UserApi } from '../../sdk'; +import { ReadUserRepository, WriteUserRepository } from './user-repository'; +import { UserService } from './user-service'; + +type Deps = { + db: AgicashDb; + getSession: () => AuthSession; +}; + +export function createUserApi(deps: Deps): UserApi { + const readRepository = new ReadUserRepository(deps.db); + const writeRepository = new WriteUserRepository(deps.db); + const userService = new UserService(writeRepository); + + const requireUserId = (): string => { + const session = deps.getSession(); + if (!session.isLoggedIn) { + throw new NoSessionError(); + } + return session.user.id; + }; + + const getAccountRef = async ( + accountId: string, + ): Promise<{ id: string; currency: Currency }> => { + const { data, error } = await deps.db + .from('accounts') + .select('id, currency') + .eq('id', accountId) + // RLS already scopes rows to the user; this is defense-in-depth per the + // "userId implicit from session" convention. + .eq('user_id', requireUserId()) + .single(); + if (error) { + throw new Error('Failed to get account', { cause: error }); + } + return data; + }; + + // Methods are async so a missing session surfaces as a rejection, matching + // the Promise-returning contract, not a synchronous throw. + return { + get: async () => readRepository.get(requireUserId()), + updateUsername: async (username) => + writeRepository.update(requireUserId(), { username }), + acceptTerms: async (params) => { + const now = new Date().toISOString(); + return writeRepository.update(requireUserId(), { + termsAcceptedAt: params.walletTerms ? now : undefined, + giftCardMintTermsAcceptedAt: params.giftCardTerms ? now : undefined, + }); + }, + setDefaultCurrency: async (params) => + writeRepository.update(requireUserId(), { + defaultCurrency: params.currency, + }), + setDefaultAccount: async (params) => { + // One read, not two: the account row is fetched to derive the + // per-currency column server-truthfully; the user row isn't needed + // because the update only writes the changed columns. + const account = await getAccountRef(params.accountId); + return userService.setDefaultAccount({ id: requireUserId() }, account, { + setDefaultCurrency: params.setDefaultCurrency, + }); + }, + }; +} +``` + +(`accounts.currency` resolves to the `wallet` schema's currency enum, `'BTC' | 'USD'`, which is exactly `Currency` — `data` assigns cleanly.) + +- [ ] **Step 5: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. + +```bash +git add packages/wallet-sdk/domain/user apps/web-wallet/app/routes/_protected.tsx "apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx" apps/web-wallet/app/features/user/user-repository-hooks.ts +git commit -m "refactor(wallet-sdk): free WriteUserRepository from the accounts graph; add createUserApi" +``` + +--- + +### Task 9: `AgicashSdk` class + +**Files:** +- Create: `packages/wallet-sdk/agicash-sdk.ts` +- Modify: `packages/wallet-sdk/index.ts` (export it) + +**Interfaces:** +- Consumes: everything produced by Tasks 3–8; `configure` + module fns from `@agicash/opensecret`; `clearSparkWallets` from `./lib/spark/wallet`; `clearAgicashMintAuthToken` from `./lib/agicash-mint-auth-provider`. +- Produces: `class AgicashSdk` with `static create(config: SdkConfig): AgicashSdk`, `readonly auth: AuthApi`, `readonly user: UserApi`, `readonly events: WalletEvents`, `init(): Promise`, `dispose(): Promise` — consumed by the web in Task 10. Exported from `@agicash/wallet-sdk`. + +- [ ] **Step 1: Implement** + +```ts +// packages/wallet-sdk/agicash-sdk.ts +import * as openSecret from '@agicash/opensecret'; +import { createAgicashDbClient } from './db/client'; +import { createSupabaseSessionTokenGetter } from './db/supabase-session'; +import { AuthService } from './domain/user/auth-service'; +import { createGuestAccountStorage } from './domain/user/guest-account-storage'; +import { createUserApi } from './domain/user/user-api'; +import { clearAgicashMintAuthToken } from './lib/agicash-mint-auth-provider'; +import { WalletEventEmitter } from './lib/events'; +import { generateRandomPassword } from './lib/password'; +import { clearSparkWallets } from './lib/spark/wallet'; +import type { AuthApi, SdkConfig, UserApi, WalletEvents } from './sdk'; + +/** + * Runtime implementation of the SDK contract, filled namespace-by-namespace + * as the migration slices land (auth/user/events since step 5). It will + * declare `implements Sdk` once every namespace exists. + */ +export class AgicashSdk { + readonly auth: AuthApi; + readonly user: UserApi; + readonly events: WalletEvents; + + private readonly authService: AuthService; + + private constructor(config: SdkConfig) { + // The Open Secret client is module-scoped in @agicash/opensecret, so auth + // configuration is process-global: a second AgicashSdk instance would + // re-configure it. One instance per process until the library ships an + // instance API. + openSecret.configure({ + apiUrl: config.auth.apiUrl, + clientId: config.auth.clientId, + storage: config.auth.storage, + }); + + const events = new WalletEventEmitter(config.logger); + + // Created before authService — the isLoggedIn closure dereferences it + // lazily at request time, after the constructor has assigned it. + const sessionToken = createSupabaseSessionTokenGetter({ + isLoggedIn: () => this.authService.getSession().isLoggedIn, + }); + + this.authService = new AuthService({ + os: openSecret, + storage: config.auth.storage, + guestAccountStorage: createGuestAccountStorage( + config.auth.storage.persistent, + config.logger, + ), + generateGuestPassword: async () => + (await config.auth.generateGuestPassword?.()) ?? + generateRandomPassword(32), + events, + onSessionEnded: () => { + // The token cache must die with the session: a token minted for one + // user must never serve the next login's queries. + sessionToken.reset(); + clearSparkWallets(); + clearAgicashMintAuthToken(); + }, + logger: config.logger, + }); + + const db = createAgicashDbClient({ + url: config.db.url, + anonKey: config.db.anonKey, + accessToken: sessionToken.getToken, + }); + + this.auth = this.authService; + this.user = createUserApi({ + db, + getSession: () => this.authService.getSession(), + }); + this.events = events; + } + + /** Sync; no I/O. */ + static create(config: SdkConfig): AgicashSdk { + return new AgicashSdk(config); + } + + /** + * Session restore only for now — the Breez WASM load folds in when the + * first Spark namespace lands. Resolves when no session exists. Delegates + * to the auth service, which is single-flight and memoizes success but + * clears a rejection, so the host's query retries can recover. + */ + init(): Promise { + return this.authService.restoreSession(); + } + + async dispose(): Promise { + this.authService.teardown(); + } +} +``` + +Check the actual export site of `clearSparkWallets`: it lives in `lib/spark/wallet.ts:150`; import from `'./lib/spark'` if the barrel re-exports it (temporary.ts does `export * from './lib/spark'`), otherwise from `'./lib/spark/wallet'`. + +- [ ] **Step 2: Export from `index.ts`** — add below the `export * from './sdk';` line: + +```ts +export { AgicashSdk } from './agicash-sdk'; +``` + +(The root export is the contract-mandated surface ("Runtime public surface = the `Sdk` class"). `agicash-sdk.ts` has no top-level side effects, so bundles that never touch the class tree-shake it; its spark/supabase graph is already server-import-safe via `/temporary`'s use in `_protected.tsx`.) + +- [ ] **Step 3: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck && cd packages/wallet-sdk && bun test` → PASS. +Then from the repo root: `bun run build` → PASS. (`fix:all` won't catch an SSR-bundle break; this is the first task that changes what the root export pulls into consumers, so exercise the real client + server build now, not only in Task 13.) + +```bash +git add packages/wallet-sdk/agicash-sdk.ts packages/wallet-sdk/index.ts +git commit -m "feat(wallet-sdk): add the AgicashSdk runtime with auth, user, and events namespaces" +``` + +--- + +### Task 10: Web config assembly (`sdk.client.ts`) + entry wiring + +**Files:** +- Create: `apps/web-wallet/app/features/shared/sdk.client.ts` +- Modify: `apps/web-wallet/app/features/agicash-db/database.client.ts` (export url/key consts) +- Modify: `apps/web-wallet/app/entry.client.tsx` (drop `configure`, import `sdk`) + +**Interfaces:** +- Consumes: `AgicashSdk` from `@agicash/wallet-sdk`; `browserStorage` from `@agicash/opensecret`; `breezApiKey` from `~/lib/breez`; the `window.getMockPassword` global (declared in `vite-env.d.ts`, armed only by the Playwright fixture). +- Produces: `export const sdk: AgicashSdk` — the web's singleton, imported by Tasks 11–12. (`.client.ts` suffix keeps it out of the server module graph, like `database.client.ts`.) + +- [ ] **Step 1: Export the resolved Supabase config from `database.client.ts`** — change the two consts to exported: + +```ts +export const supabaseUrl = getSupabaseUrl(); +``` +```ts +export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? ''; +``` + +(only the `const` declarations gain `export`; everything else unchanged). + +- [ ] **Step 2: Create `sdk.client.ts`** + +```ts +// apps/web-wallet/app/features/shared/sdk.client.ts +import { browserStorage } from '@agicash/opensecret'; +import { AgicashSdk } from '@agicash/wallet-sdk'; +import { + supabaseAnonKey, + supabaseUrl, +} from '~/features/agicash-db/database.client'; +import { breezApiKey } from '~/lib/breez'; + +const openSecretApiUrl = import.meta.env.VITE_OPEN_SECRET_API_URL ?? ''; +if (!openSecretApiUrl) { + throw new Error('VITE_OPEN_SECRET_API_URL is not set'); +} + +const openSecretClientId = import.meta.env.VITE_OPEN_SECRET_CLIENT_ID ?? ''; +if (!openSecretClientId) { + throw new Error('VITE_OPEN_SECRET_CLIENT_ID is not set'); +} + +const consoleLogger = { + debug: (message: string, meta?: unknown) => + meta === undefined ? console.debug(message) : console.debug(message, meta), + info: (message: string, meta?: unknown) => + meta === undefined ? console.info(message) : console.info(message, meta), + warn: (message: string, meta?: unknown) => + meta === undefined ? console.warn(message) : console.warn(message, meta), + error: (message: string, meta?: unknown) => + meta === undefined ? console.error(message) : console.error(message, meta), +}; + +export const sdk = AgicashSdk.create({ + db: { + url: supabaseUrl, + anonKey: supabaseAnonKey, + }, + auth: { + apiUrl: openSecretApiUrl, + clientId: openSecretClientId, + storage: browserStorage, + // e2e bridge: the Playwright fixture arms window.getMockPassword; in + // production it's absent, so this resolves null and the SDK generates. + generateGuestPassword: async () => + (await window.getMockPassword?.()) ?? null, + }, + spark: { + breezApiKey, + network: 'MAINNET', + }, + lightningAddressDomain: window.location.host, + logger: consoleLogger, +}); + +if (import.meta.hot) { + // A hot reload of this module constructs a second SDK; dispose the old one + // so its expiry timer doesn't leak. + import.meta.hot.dispose(() => void sdk.dispose()); +} +``` + +(`window.location.host` includes the port, matching what `useLocationData` derives for the settings screen today; nothing consumes the value until the contacts/receive slices.) + +- [ ] **Step 3: Rewire `entry.client.tsx`** — remove the `configure` import and call (added in Task 1), remove the now-unused env reads, and import the sdk module first so Open Secret is configured before any consumer runs: + +```ts +import { + configureFeatureFlags, + ensureBreezWasm, +} from '@agicash/wallet-sdk/temporary'; +// ... existing imports ... +import { agicashDbClient } from './features/agicash-db/database.client'; +// Importing the module constructs the SDK, which configures Open Secret as an +// import-evaluation side effect — before any body code below runs. +import './features/shared/sdk.client'; +``` + +Delete these lines: the `import { browserStorage, configure } from '@agicash/opensecret';`, the `openSecretApiUrl`/`openSecretClientId` env-read blocks, and the `configure({ ... })` call. Everything else (Breez WASM kickoff, feature flags, Sentry) stays. + +- [ ] **Step 4: Verify** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. +Smoke: `bun run dev` → app boots; existing session still logged in; network tab shows Open Secret calls working (attestation/session handshake unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web-wallet/app/features/shared/sdk.client.ts apps/web-wallet/app/features/agicash-db/database.client.ts apps/web-wallet/app/entry.client.tsx +git commit -m "feat(web-wallet): construct the wallet SDK instance and move Open Secret config into it" +``` + +--- + +### Task 11: Web auth glue flip + +**Files:** +- Modify: `apps/web-wallet/app/features/user/auth.ts` (full rewrite below) +- Modify: `apps/web-wallet/app/features/wallet/wallet.tsx` +- Modify: `apps/web-wallet/app/routes/_protected.tsx:30-34` (`AuthUser` import) +- Modify: `apps/web-wallet/app/routes/_auth.oauth.$provider.tsx` +- Modify: `apps/web-wallet/app/features/signup/verify-email.ts` +- Delete: `apps/web-wallet/app/hooks/use-long-timeout.ts` (its only consumer is the old `useHandleSessionExpiry`, which this task removes) +- Delete: `apps/web-wallet/app/lib/password-generator.ts` (its last importer is the old `auth.ts`; the SDK's `lib/password.ts` is now the only generator — A4) + +**Interfaces:** +- Consumes: `sdk` from `~/features/shared/sdk.client`; `AuthSession`, `AuthUser` from `@agicash/wallet-sdk`. +- Produces (same names as today so forms/pages don't change): `authQueryOptions`, `authStateQueryKey`, `invalidateAuthQueries`, `useAuthState`, `useAuthActions` (same verb signatures incl. `signOut(options?: { redirectTo?: string })`), `useSignOut`, `type AuthUser`; NEW `useHandleSessionEvents(onSessionExpired: () => void)` replacing `useHandleSessionExpiry` (subscribes to both `auth.session-expired` and `auth.session-refreshed`). + +- [ ] **Step 1: Rewrite `features/user/auth.ts`** + +```ts +import type { AuthUser } from '@agicash/wallet-sdk'; +import * as Sentry from '@sentry/react-router'; +import { decodeURLSafe, encodeURLSafe } from '@stablelib/base64'; +import { + queryOptions, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import { jwtDecode } from 'jwt-decode'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useRevalidator } from 'react-router'; +import { + loadFeatureFlags, + resetFeatureFlags, +} from '~/features/shared/feature-flags'; +import { getQueryClient } from '~/features/shared/query-client'; +import { sdk } from '~/features/shared/sdk.client'; +import { useLatest } from '~/lib/use-latest'; +import { oauthLoginSessionStorage } from './oauth-login-session-storage'; +import { sessionHintCookie } from './session-hint-cookie'; + +export type { AuthUser }; + +type AuthState = + | { + isLoggedIn: true; + user: AuthUser; + /** Unix seconds, captured at fetch time; drives the hint-cookie lifetime and query staleness. */ + refreshTokenExpiresAt: number | null; + } + | { + isLoggedIn: false; + user?: undefined; + }; + +export const authStateQueryKey = 'auth-state'; + +// A corrupt stored token must degrade to "no value", never throw from a query +// fn or staleTime callback (that would error-page every route, /login included). +const safeJwtDecode = ( + token: string, +): { exp?: number; sub?: string } | null => { + try { + return jwtDecode(token); + } catch { + return null; + } +}; + +const getRefreshTokenExpiry = (): number | null => { + const refreshToken = window.localStorage.getItem('refresh_token'); + if (!refreshToken) { + return null; + } + return safeJwtDecode(refreshToken)?.exp ?? null; +}; + +export const authQueryOptions = () => + queryOptions({ + queryKey: [authStateQueryKey], + queryFn: async (): Promise => { + // Associate Sentry events with the user as early as possible, before + // session restore completes. + const accessToken = window.localStorage.getItem('access_token'); + const sub = accessToken ? safeJwtDecode(accessToken)?.sub : undefined; + if (sub) { + Sentry.setUser({ id: sub }); + } + + try { + await sdk.init(); + } catch (error) { + // Restore failed with tokens present (e.g. a network blip at boot). + // Boot anonymous; init()'s rejection is not memoized, so a later + // invalidateAuthQueries() retries the restore. + console.error('Failed to restore session', { cause: error }); + Sentry.setUser(null); + sessionHintCookie.clear(); + return { isLoggedIn: false }; + } + const session = sdk.auth.getSession(); + + if (!session.isLoggedIn) { + Sentry.setUser(null); + sessionHintCookie.clear(); + return { isLoggedIn: false }; + } + + Sentry.setUser({ id: session.user.id, isGuest: !session.user.email }); + + // Mirror auth state into a hint cookie so the server can short-circuit + // SSR for unauthenticated visits. Lifetime matches the refresh token + // so we don't leave a stale "logged in" hint after the session + // genuinely expires. + const exp = getRefreshTokenExpiry(); + if (exp) { + sessionHintCookie.set(exp - Math.floor(Date.now() / 1000)); + } + + return { ...session, refreshTokenExpiresAt: exp }; + }, + // Logged-in state is fresh until the refresh token expires; a refetch + // after that point re-reads the (SDK-extended or ended) session and + // re-syncs the hint cookie. Anonymous state only changes through explicit + // invalidation. Staleness is pinned to the expiry captured AT FETCH TIME + // (not re-read from storage), so an SDK-internal guest extension can't + // slide freshness forward and postpone the cookie re-sync forever. + staleTime: ({ state: { data, dataUpdatedAt } }) => { + if (!data?.isLoggedIn) { + return Number.POSITIVE_INFINITY; + } + if (!data.refreshTokenExpiresAt) { + return 0; + } + return Math.max( + (data.refreshTokenExpiresAt - 5) * 1000 - dataUpdatedAt, + 0, + ); + }, + }); + +/** + * Invalidates all queries that depend on the current auth session. + * Call after any auth state change (login, logout, email verification, etc.) + */ +export const invalidateAuthQueries = async () => { + const queryClient = getQueryClient(); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [authStateQueryKey], + refetchType: 'all', + }), + loadFeatureFlags(), + ]); +}; + +export const useAuthState = (): AuthState => { + const { data } = useSuspenseQuery(authQueryOptions()); + return data; +}; + +type SignOutOptions = { + /** + * The URL to redirect to after signing out. If not provided, the user will be redirected to the singup page by the protected layout. + */ + redirectTo?: string; +}; + +type AuthActions = { + signUp: (email: string, password: string) => Promise; + signUpGuest: () => Promise; + signIn: (email: string, password: string) => Promise; + signOut: (options?: SignOutOptions) => Promise; + initiateGoogleAuth: () => Promise<{ authUrl: string }>; + verifyEmail: (code: string) => Promise; + convertGuestToFullAccount: (email: string, password: string) => Promise; +}; + +/** + * Authentication actions backed by the wallet SDK, wrapped with the web + * concerns the SDK doesn't own: query invalidation, navigation, Sentry user + * tracking, and the OAuth deep-link session. + */ +export const useAuthActions = (): AuthActions => { + const queryClient = useQueryClient(); + const { revalidate } = useRevalidator(); + const navigate = useNavigate(); + + const refreshSession = useCallback( + async (redirectTo?: string) => { + await invalidateAuthQueries(); + if (redirectTo) { + await navigate(redirectTo); + } else { + await revalidate(); + } + }, + [navigate, revalidate], + ); + + const signUp = useCallback( + async (email: string, password: string) => { + await sdk.auth.signUp(email, password); + await refreshSession(); + }, + [refreshSession], + ); + + const signIn = useCallback( + async (email: string, password: string) => { + await sdk.auth.signIn(email, password); + await refreshSession(); + }, + [refreshSession], + ); + + const signUpGuest = useCallback(async () => { + await sdk.auth.signUpGuest(); + await refreshSession(); + }, [refreshSession]); + + const signOut = useCallback( + async (options: SignOutOptions = {}) => { + await sdk.auth.signOut(); + // Before the refresh below so the previous user's flags are gone even if + // the anon re-fetch fails, and so its result isn't clobbered afterwards. + resetFeatureFlags(); + await refreshSession(options.redirectTo); + Sentry.setUser(null); + queryClient.clear(); + }, + [refreshSession, queryClient], + ); + + const initiateGoogleAuth = useCallback(async () => { + const { authUrl } = await sdk.auth.initiateGoogleAuth(); + + // Stash the current location under a session id and thread it through the + // OAuth state param, so the callback route can restore the deep link. + const authLocation = new URL(authUrl); + const stateParam = authLocation.searchParams.get('state'); + const state = stateParam + ? JSON.parse(new TextDecoder().decode(decodeURLSafe(stateParam))) + : {}; + + const oauthLoginSession = oauthLoginSessionStorage.create({ + search: location.search, + hash: location.hash, + }); + state.sessionId = oauthLoginSession.sessionId; + + const stateEncoded = encodeURLSafe( + new TextEncoder().encode(JSON.stringify(state)), + ); + authLocation.searchParams.set('state', stateEncoded); + + return { authUrl: authLocation.href }; + }, []); + + const verifyEmail = useCallback( + async (code: string) => { + await sdk.auth.verifyEmail(code); + await refreshSession(); + }, + [refreshSession], + ); + + const convertGuestToFullAccount = useCallback( + async (email: string, password: string) => { + await sdk.auth.convertGuestToFullAccount(email, password); + await refreshSession(); + }, + [refreshSession], + ); + + return { + signUp, + signUpGuest, + signIn, + signOut, + initiateGoogleAuth, + verifyEmail, + convertGuestToFullAccount, + }; +}; + +export const useSignOut = () => { + const { signOut } = useAuthActions(); + const [loading, setLoading] = useState(false); + + const handleSignOut = async () => { + setLoading(true); + await signOut({ redirectTo: '/home' }); + setLoading(false); + }; + return { isSigningOut: loading, signOut: handleSignOut }; +}; + +/** + * Reacts to SDK-initiated session transitions the host didn't trigger. + * Expiry (refresh-token death with failed/impossible extension): notifies the + * user and resets the web session state. Refresh (guest auto-extension): + * re-runs the auth query so the session-hint cookie picks up the new expiry, + * matching master's extend-through-invalidation behavior. + */ +export const useHandleSessionEvents = (onSessionExpired: () => void) => { + const queryClient = useQueryClient(); + const { revalidate } = useRevalidator(); + const onSessionExpiredRef = useLatest(onSessionExpired); + + useEffect(() => { + const unsubscribeExpired = sdk.events.on('auth.session-expired', () => { + void (async () => { + onSessionExpiredRef.current(); + resetFeatureFlags(); + await invalidateAuthQueries(); + await revalidate(); + Sentry.setUser(null); + queryClient.clear(); + })(); + }); + const unsubscribeRefreshed = sdk.events.on('auth.session-refreshed', () => { + void invalidateAuthQueries(); + }); + return () => { + unsubscribeExpired(); + unsubscribeRefreshed(); + }; + }, [queryClient, revalidate, onSessionExpiredRef]); +}; +``` + +Deleted vs master: `useHandleSessionExpiry`, the `OpenSecretJwt` helpers (`getJwt`, `removeKeys`, `getRefreshToken`, `getRemainingSessionTimeInMs`), the direct `@agicash/opensecret` + `/temporary` imports, `guestAccountStorage` usage, `generateRandomPassword` usage (now the SDK's concern via the config port). + +- [ ] **Step 2: Update `wallet.tsx`** — replace the `useHandleSessionExpiry` import + call: + +```ts +import { useHandleSessionEvents } from '../user/auth'; +``` +```ts + useHandleSessionEvents(() => { + toast({ + title: 'Session expired', + description: + 'The session has expired. You will be redirected to the login page.', + }); + }); +``` + +(the `isGuestAccount` prop disappears — guests are auto-extended inside the SDK). + +- [ ] **Step 3: Repoint `AuthUser` in `_protected.tsx`** + +```ts +import type { AuthUser } from '@agicash/wallet-sdk'; +import { authQueryOptions, useAuthState } from '~/features/user/auth'; +``` + +- [ ] **Step 4: Flip the OAuth callback route** (`_auth.oauth.$provider.tsx`) — replace the `handleGoogleCallback` import with the sdk and change the call: + +```ts +import { sdk } from '~/features/shared/sdk.client'; +``` +```ts + switch (provider) { + case 'google': + await sdk.auth.completeGoogleAuth({ code, state }); + break; +``` + +- [ ] **Step 5: Flip `features/signup/verify-email.ts`** — replace `import { verifyEmail as osVerifyEmail } from '@agicash/opensecret';` with the sdk import and change the call: + +```ts +import { sdk } from '~/features/shared/sdk.client'; +``` +```ts + await sdk.auth.verifyEmail(code); + await invalidateAuthQueries(); +``` + +- [ ] **Step 6: Delete `apps/web-wallet/app/hooks/use-long-timeout.ts` and `apps/web-wallet/app/lib/password-generator.ts`** — their only consumers were the removed `useHandleSessionExpiry` and the old guest-signup path (the SDK's generator + the `window.getMockPassword` bridge in `sdk.client.ts` replace the latter). Verify: `grep -rn "useLongTimeout\|password-generator" apps/web-wallet/app` returns nothing. + +- [ ] **Step 7: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. + +```bash +git add apps/web-wallet +git commit -m "refactor(web-wallet): route auth flows through sdk.auth" +``` + +--- + +### Task 12: Web user-domain flip + +**Files:** +- Modify: `apps/web-wallet/app/features/user/user-hooks.tsx` +- Delete: `apps/web-wallet/app/features/user/user-repository-hooks.ts` +- Delete: `apps/web-wallet/app/features/user/user-service-hooks.ts` +- Delete: `apps/web-wallet/app/features/user/guest-account-storage.ts` (moved into the SDK in Task 5; `user-hooks.tsx` is its last importer and stops using it in Step 2) +- Modify: `apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx` + +**Interfaces:** +- Consumes: `sdk.user`, `sdk.auth` from `~/features/shared/sdk.client`. +- Produces: unchanged hook names/signatures (`useUser`, `useUserRef`, `useUpgradeGuestToFullAccount`, `useRequestNewEmailVerificationCode`, `useVerifyEmail`, `useSetDefaultCurrency`, `useSetDefaultAccount`, `useUpdateUsername`, `useAcceptTerms`, `UserCache`, `useUserCache`, `useUserChangeHandlers`, `getUserFromCache`, `getUserFromCacheOrThrow`, `defaultAccounts`). + +- [ ] **Step 1: Flip `useUser`** — drop the repository plumbing: + +```ts +const userQueryOptions = ({ + select, +}: { + select?: (data: User) => TData; +}) => ({ + queryKey: [UserCache.Key], + queryFn: () => sdk.user.get(), + select, +}); + +export const useUser = ( + select?: (data: User) => TData, +): TData => { + const authState = useAuthState(); + if (!authState.user) { + throw new Error('Cannot use useUser hook in anonymous context'); + } + + const { data } = useSuspenseQuery(userQueryOptions({ select })); + + return data; +}; +``` + +(imports: add `import { sdk } from '~/features/shared/sdk.client';`, drop `ReadUserRepository`, `useReadUserRepository`, `useWriteUserRepository`, `useUserService`, `requestNewVerificationCode`, `guestAccountStorage`; keep the `ReadUserRepository` import **only** via `@agicash/wallet-sdk/temporary` for `useUserChangeHandlers`'s static `toUser` — that usage stays.) + +- [ ] **Step 2: Flip the mutations** + +```ts +export const useUpgradeGuestToFullAccount = (): (( + email: string, + password: string, +) => Promise) => { + const userRef = useUserRef(); + const { convertGuestToFullAccount } = useAuthActions(); + + const { mutateAsync } = useMutation({ + mutationKey: ['upgrade-guest-to-full-account'], + mutationFn: (variables: { email: string; password: string }) => { + if (!userRef.current.isGuest) { + throw new Error('User already has a full account'); + } + + return convertGuestToFullAccount(variables.email, variables.password); + }, + scope: { + id: 'upgrade-guest-to-full-account', + }, + }); + + return useCallback( + (email: string, password: string) => mutateAsync({ email, password }), + [mutateAsync], + ); +}; +``` + +(the `guestAccountStorage.clear()` follow-up is gone — the SDK clears it inside `convertGuestToFullAccount`). + +```ts +export const useRequestNewEmailVerificationCode = (): (() => Promise) => { + const userRef = useUserRef(); + + const { mutateAsync } = useMutation({ + mutationKey: ['request-new-email-verification-code'], + mutationFn: () => { + if (userRef.current.isGuest) { + throw new Error('Cannot request email verification for guest account'); + } + if (userRef.current.emailVerified) { + throw new Error('Email is already verified'); + } + + return sdk.auth.requestNewVerificationCode(); + }, + scope: { + id: 'request-new-email-verification-code', + }, + }); + + return mutateAsync; +}; +``` + +Replace `useUpdateUser` + the three wrappers with direct sdk calls (the `UpdateUser` type import from `/temporary` goes away): + +```ts +const useUserUpdatingMutation = ( + mutationFn: (variables: TVariables) => Promise, +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn, + onSuccess: (data) => { + queryClient.setQueryData([UserCache.Key], data); + }, + }); +}; + +export const useSetDefaultCurrency = () => { + const { mutateAsync } = useUserUpdatingMutation((currency: Currency) => + sdk.user.setDefaultCurrency({ currency }), + ); + return mutateAsync; +}; + +export const useSetDefaultAccount = () => { + const { mutateAsync } = useUserUpdatingMutation((account: Account) => + sdk.user.setDefaultAccount({ accountId: account.id }), + ); + return mutateAsync; +}; + +export const useUpdateUsername = () => { + const { mutateAsync } = useUserUpdatingMutation((username: string) => + sdk.user.updateUsername(username), + ); + return mutateAsync; +}; + +export const useAcceptTerms = () => { + const { mutateAsync } = useUserUpdatingMutation( + (params: { walletTerms?: boolean; giftCardTerms?: boolean }) => + sdk.user.acceptTerms(params), + ); + return mutateAsync; +}; +``` + +(`useVerifyEmail` is unchanged — it already goes through `useAuthActions`.) + +- [ ] **Step 3: Delete `user-repository-hooks.ts`, `user-service-hooks.ts`, and `guest-account-storage.ts`.** Verify no importers remain: `grep -rn "user-repository-hooks\|user-service-hooks\|user/guest-account-storage" apps/` → empty. + +- [ ] **Step 4: Flip the receive-token route** (`_protected.receive.cashu_.token.tsx`) — in `trySetReceiveAccountAsDefault` (line 117), replace the `userService.setDefaultAccount(user, account, { setDefaultCurrency: true })` call with: + +```ts + const updatedUser = await sdk.user.setDefaultAccount({ + accountId: account.id, + setDefaultCurrency: true, + }); + new UserCache(queryClient).set(updatedUser); +``` + +Concretely, five removals or the file won't compile clean: (1) drop `userService` and the `userRepository` construction feeding it from `getServices()` (construction + return object); (2) remove `userService` from the destructure at ~line 176; (3) remove the `userService: UserService` parameter from `trySetReceiveAccountAsDefault` (line ~117-122); (4) remove the corresponding argument at its call site (~line 197); (5) drop the now-unused `WriteUserRepository` import. Keep the `UserService.isDefaultAccount` static usage (its `/temporary` import stays). Add the `sdk` import. + +- [ ] **Step 5: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. +Confirm the only remaining web importers of user-domain classes from `/temporary` are: `_protected.tsx` (`WriteUserRepository` for `ensureUserData`, A1), `user-hooks.tsx` (`ReadUserRepository.toUser` static), the receive route + any accounts/transactions files (`UserService` statics, `ReadUserDefaultAccountRepository`) — run `grep -rn "UserRepository\|UserService" apps/web-wallet/app --include='*.ts*'` and check the list matches the Deferred section. + +```bash +git add apps/web-wallet +git commit -m "refactor(web-wallet): route user reads and writes through sdk.user" +``` + +--- + +### Task 13: Full verification + +- [ ] **Step 1: Static + unit + production build** + +```bash +export PATH="$PWD/.devenv/profile/bin:$PATH" +bun run fix:all && bun run typecheck +bun run test +bun run build +``` +Expected: all PASS. The build step exercises the real client + server bundles (the dev server alone doesn't), confirming the root `AgicashSdk` export is server-bundle-safe. + +- [ ] **Step 2: Browser smoke (dev server + Chrome MCP or manual)** — `bun run dev`, then walk: + +1. `/home` (marketing, anonymous) → Sign Up → **Create wallet as Guest** → wallet home renders (dev auto-creates Testnut accounts). +2. Reload → still signed in (session restore through `sdk.init()`). +3. Settings → Sign Out → back at signup; localStorage keeps `guestAccount`. +4. **Create wallet as Guest** again → re-signs into the SAME guest account (no new account). +5. Settings → edit username → persists after reload (`sdk.user.updateUsername` + cache). +6. Switch default account/currency in settings → theme flips (USD/BTC), persists. +7. Google login page renders and the Google button redirects to an accounts.google.com URL with a `state` containing `sessionId` (don't complete externally). +8. DevTools: no console errors; `wallet.users` reads go through the SDK client (two Supabase token exchanges are expected — web + SDK clients). + +- [ ] **Step 3: E2E (ask the user before running)** + +```bash +cd apps/web-wallet-e2e && bun run test:e2e -- --grep "signup|login|verify" +``` +(Run from the package dir — arg forwarding through the root script's `bun --filter` isn't guaranteed to reach playwright, and a silently-unfiltered full run is the failure mode.) +Expected: signup.spec, login.spec, verify-email.spec pass unchanged (the RC talks to the same endpoints; the password mock still applies through the config port). + +- [ ] **Step 4: Existing-session upgrade check** — with a session created on `master` (localStorage tokens present), switch to the branch, reload: still logged in. + +- [ ] **Step 5: Commit any fixes; then push and open the PR** + +```bash +git push -u origin sdk/auth-slice +``` + +PR: base `master`, title `feat(wallet-sdk): auth & user slice (step 5)`, description listing: contract placeholders settled, opensecret RC adoption, AgicashSdk runtime, web flips, Decision Record A1–A12, deferred items. + +--- + +## Self-Review Checklist (run after Task 13) + +1. **Spec coverage:** step-5 line items — contract methods wrapped (AuthApi ✓ Task 6/9, UserApi ✓ Task 8/9), web imports flipped (✓ Tasks 11–12 minus documented deferrals), storage-adapter port settled (✓ Task 3), React-agnostic opensecret adopted (✓ Task 1), port shapes settled (✓ Tasks 3–5). +2. **Placeholder scan:** no TBDs; every step has code or exact commands. +3. **Type consistency:** `AuthKeyValueStore`/`AuthStorage` (Task 3) = what `guest-account-storage.ts` (Task 5), `AuthService` (Task 6), and `browserStorage` (Task 10) consume; `OpenSecretAuthApi` fn names = RC exports; `SetDefaultAccountParams.setDefaultCurrency` used in Task 12 Step 4. +4. **Parity scan:** every master behavior either preserved or listed under "Accepted behavior deltas". diff --git a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md index 9e020ceff..e9b943dc2 100644 --- a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +++ b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md @@ -332,6 +332,7 @@ only an id — the asymmetry is intentional, not an oversight. ```ts type WalletEventMap = { 'auth.session-expired': Record; // session died without signOut() (expiry / failed refresh) + 'auth.session-refreshed': Record; // SDK-initiated refresh (guest auto-extend); host verbs never fire it — added by the auth slice (step-5 plan, A13) 'user.updated': { user: User }; 'account.created' | 'account.updated': { account: Account }; 'account.balance-changed': { accountId: string; balance: Money }; // both rails; no version diff --git a/packages/wallet-sdk/sdk.ts b/packages/wallet-sdk/sdk.ts index faf0742be..68ab26503 100644 --- a/packages/wallet-sdk/sdk.ts +++ b/packages/wallet-sdk/sdk.ts @@ -6,7 +6,8 @@ import type { LNURLPayResult, LNURLVerifyResult, } from '@agicash/lnurl'; -import type { Money } from '@agicash/money'; +import type { Currency, Money } from '@agicash/money'; +import type { UserResponse } from '@agicash/opensecret'; import type { SparkNetwork } from './db/json-models/spark-account-details-db-data'; import type { CashuAccount as DomainCashuAccount, @@ -57,11 +58,27 @@ export type CashuSendSwap = Omit< >; export type SparkSendQuote = Omit; -/** Host-backed session persistence. */ +/** + * Minimal key/value store — the Web Storage API subset the SDK persists auth + * state through. Methods may be sync (window.localStorage) or async (React + * Native AsyncStorage, SQLite); the SDK always awaits results. Matches the + * @agicash/opensecret StorageProvider interface verbatim, so one host object + * backs both. + */ +export type AuthKeyValueStore = { + getItem(key: string): string | null | Promise; + setItem(key: string, value: string): void | Promise; + removeItem(key: string): void | Promise; +}; + +/** + * Host-backed session persistence. `persistent` must survive restarts (auth + * tokens, guest credentials); `session` is per-app-session (attestation + * handshake material). Browser hosts map them to localStorage/sessionStorage. + */ export type AuthStorage = { - get(key: string): Promise; - set(key: string, value: string): Promise; - remove(key: string): Promise; + persistent: AuthKeyValueStore; + session: AuthKeyValueStore; }; /** Diagnostic sink; the SDK never writes to the console directly. */ @@ -81,6 +98,12 @@ export type SdkConfig = { apiUrl: string; clientId: string; storage: AuthStorage; + /** + * Host override for guest credential generation; resolve null to use the + * SDK's CSPRNG generator. Test seam (the web bridges its e2e password + * mock through it). + */ + generateGuestPassword?: () => Promise; }; spark: { breezApiKey: string; @@ -113,6 +136,9 @@ export type Sdk = { * the SDK does not lazy-load the WASM, so Spark calls without a completed * `init()` throw a typed `SdkError`. Non-Spark usage lazy-initializes on * first use. + * + * Migration note: until the first Spark slice lands, `init()` performs + * session restore only — the WASM load still runs host-side. */ init(): Promise; /** @@ -128,7 +154,7 @@ export type SdkConstructor = { create(config: SdkConfig): Sdk; }; -export type AuthUser = unknown; // settles in step 5 (auth & user) +export type AuthUser = UserResponse['user']; export type AuthSession = | { isLoggedIn: true; user: AuthUser } @@ -291,6 +317,13 @@ export type BackgroundApi = { export type WalletEventMap = { /** The session died without a `signOut()` call (expiry / failed refresh). */ 'auth.session-expired': Record; + /** + * The SDK refreshed the session without a host-initiated verb — today: + * guest auto-extension at refresh-token expiry. Host-initiated verbs never + * fire it (the host knows its own actions). Hosts re-sync session-derived + * state from it (the web: auth query + session-hint cookie). + */ + 'auth.session-refreshed': Record; 'user.updated': { user: User }; 'account.created': { account: Account }; /** A persisted row changed; the payload carries a `version` consumers gate on. */ @@ -381,9 +414,20 @@ export type ServerSdkConstructor = { // Settles in step N — pinned by that slice PR to the public projection of // today's service types. -export type AcceptTermsParams = unknown; // step 5 (auth & user) -export type SetDefaultAccountParams = unknown; // step 5 (auth & user) -export type SetDefaultCurrencyParams = unknown; // step 5 (auth & user) +export type AcceptTermsParams = { + walletTerms?: boolean; + giftCardTerms?: boolean; +}; + +export type SetDefaultAccountParams = { + accountId: string; + /** Also switch the user's default currency to the account's currency. */ + setDefaultCurrency?: boolean; +}; + +export type SetDefaultCurrencyParams = { + currency: Currency; +}; export type AddCashuAccountParams = unknown; // step 6 (accounts) export type CreateContactParams = unknown; // step 7 (contacts) export type GetCashuReceiveLightningQuoteParams = unknown; // step 9 (cashu receive quote) From cd2acd8cdf7ca2a22247f60542805df59a61efcf Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Thu, 9 Jul 2026 19:57:45 +0200 Subject: [PATCH 04/49] feat(wallet-sdk): add the typed wallet event emitter Co-Authored-By: Claude Fable 5 --- packages/wallet-sdk/lib/events.test.ts | 51 ++++++++++++++++++++++++++ packages/wallet-sdk/lib/events.ts | 38 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 packages/wallet-sdk/lib/events.test.ts create mode 100644 packages/wallet-sdk/lib/events.ts diff --git a/packages/wallet-sdk/lib/events.test.ts b/packages/wallet-sdk/lib/events.test.ts new file mode 100644 index 000000000..8283d8c02 --- /dev/null +++ b/packages/wallet-sdk/lib/events.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'bun:test'; +import { WalletEventEmitter } from './events'; + +describe('WalletEventEmitter', () => { + it('delivers payloads to subscribed handlers', () => { + const emitter = new WalletEventEmitter(); + const received: unknown[] = []; + emitter.on('auth.session-expired', (payload) => received.push(payload)); + + emitter.emit('auth.session-expired', {}); + + expect(received).toEqual([{}]); + }); + + it('stops delivering after unsubscribe', () => { + const emitter = new WalletEventEmitter(); + let calls = 0; + const unsubscribe = emitter.on('auth.session-expired', () => { + calls += 1; + }); + + unsubscribe(); + emitter.emit('auth.session-expired', {}); + + expect(calls).toBe(0); + }); + + it('isolates a throwing handler and reports it to the logger', () => { + const errors: string[] = []; + const emitter = new WalletEventEmitter({ + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: (message) => { + errors.push(message); + }, + }); + let secondHandlerRan = false; + emitter.on('auth.session-expired', () => { + throw new Error('boom'); + }); + emitter.on('auth.session-expired', () => { + secondHandlerRan = true; + }); + + emitter.emit('auth.session-expired', {}); + + expect(secondHandlerRan).toBe(true); + expect(errors).toHaveLength(1); + }); +}); diff --git a/packages/wallet-sdk/lib/events.ts b/packages/wallet-sdk/lib/events.ts new file mode 100644 index 000000000..6d1b74793 --- /dev/null +++ b/packages/wallet-sdk/lib/events.ts @@ -0,0 +1,38 @@ +import type { Logger, WalletEventMap, WalletEvents } from '../sdk'; + +type Handler = (payload: never) => void; + +export class WalletEventEmitter implements WalletEvents { + private readonly handlers = new Map>(); + + constructor(private readonly logger?: Logger) {} + + on( + event: K, + handler: (payload: WalletEventMap[K]) => void, + ): () => void { + const set = this.handlers.get(event) ?? new Set(); + set.add(handler as Handler); + this.handlers.set(event, set); + return () => { + set.delete(handler as Handler); + }; + } + + emit( + event: K, + payload: WalletEventMap[K], + ): void { + const set = this.handlers.get(event); + if (!set) { + return; + } + for (const handler of set) { + try { + (handler as (payload: WalletEventMap[K]) => void)(payload); + } catch (error) { + this.logger?.error(`Event handler for ${event} threw`, error); + } + } + } +} From 9935c818dcf1a7e7499cdcd5f2faff65d217f816 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Thu, 9 Jul 2026 19:58:48 +0200 Subject: [PATCH 05/49] feat(wallet-sdk): add port-backed guest-account storage and CSPRNG password generator Co-Authored-By: Claude Fable 5 --- .../domain/user/guest-account-storage.test.ts | 56 +++++++++++++++++++ .../domain/user/guest-account-storage.ts | 52 +++++++++++++++++ packages/wallet-sdk/lib/password.ts | 33 +++++++++++ 3 files changed, 141 insertions(+) create mode 100644 packages/wallet-sdk/domain/user/guest-account-storage.test.ts create mode 100644 packages/wallet-sdk/domain/user/guest-account-storage.ts create mode 100644 packages/wallet-sdk/lib/password.ts diff --git a/packages/wallet-sdk/domain/user/guest-account-storage.test.ts b/packages/wallet-sdk/domain/user/guest-account-storage.test.ts new file mode 100644 index 000000000..fdbbe3600 --- /dev/null +++ b/packages/wallet-sdk/domain/user/guest-account-storage.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'bun:test'; +import type { AuthKeyValueStore } from '../../sdk'; +import { createGuestAccountStorage } from './guest-account-storage'; + +const createMemoryStore = (): AuthKeyValueStore & { + data: Map; +} => { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +describe('guestAccountStorage', () => { + it('round-trips guest account details under the legacy key', async () => { + const store = createMemoryStore(); + const storage = createGuestAccountStorage(store); + + await storage.store({ id: 'guest-1', password: 'pw' }); + + expect(store.data.has('guestAccount')).toBe(true); + expect(await storage.get()).toEqual({ id: 'guest-1', password: 'pw' }); + }); + + it('returns null when nothing is stored', async () => { + const storage = createGuestAccountStorage(createMemoryStore()); + expect(await storage.get()).toBeNull(); + }); + + it('returns null for corrupt or invalid data', async () => { + const store = createMemoryStore(); + store.data.set('guestAccount', 'not-json'); + const storage = createGuestAccountStorage(store); + expect(await storage.get()).toBeNull(); + + store.data.set('guestAccount', JSON.stringify({ id: 42 })); + expect(await storage.get()).toBeNull(); + }); + + it('clear removes the stored account', async () => { + const store = createMemoryStore(); + const storage = createGuestAccountStorage(store); + await storage.store({ id: 'guest-1', password: 'pw' }); + + await storage.clear(); + + expect(await storage.get()).toBeNull(); + }); +}); diff --git a/packages/wallet-sdk/domain/user/guest-account-storage.ts b/packages/wallet-sdk/domain/user/guest-account-storage.ts new file mode 100644 index 000000000..7390d7a3c --- /dev/null +++ b/packages/wallet-sdk/domain/user/guest-account-storage.ts @@ -0,0 +1,52 @@ +import { safeJsonParse } from '@agicash/utils'; +import { z } from 'zod/mini'; +import type { AuthKeyValueStore, Logger } from '../../sdk'; + +// Key predates the SDK move — existing devices have guest credentials stored +// under it, so it must not change. +const storageKey = 'guestAccount'; + +const GuestAccountDetailsSchema = z.object({ + id: z.string(), + password: z.string(), +}); + +export type GuestAccountDetails = z.infer; + +export type GuestAccountStorage = { + get(): Promise; + store(details: GuestAccountDetails): Promise; + clear(): Promise; +}; + +export function createGuestAccountStorage( + store: AuthKeyValueStore, + logger?: Logger, +): GuestAccountStorage { + return { + async get() { + const dataString = await store.getItem(storageKey); + if (!dataString) { + return null; + } + const parseResult = safeJsonParse(dataString); + if (!parseResult.success) { + return null; + } + const validationResult = GuestAccountDetailsSchema.safeParse( + parseResult.data, + ); + if (!validationResult.success) { + logger?.warn('Invalid guest account data found in the storage'); + return null; + } + return validationResult.data; + }, + async store(details) { + await store.setItem(storageKey, JSON.stringify(details)); + }, + async clear() { + await store.removeItem(storageKey); + }, + }; +} diff --git a/packages/wallet-sdk/lib/password.ts b/packages/wallet-sdk/lib/password.ts new file mode 100644 index 000000000..7d6257410 --- /dev/null +++ b/packages/wallet-sdk/lib/password.ts @@ -0,0 +1,33 @@ +type PasswordOptions = { + letters?: boolean; + numbers?: boolean; + special?: boolean; +}; + +export async function generateRandomPassword( + length = 24, + options: PasswordOptions = { letters: true, numbers: true, special: true }, +): Promise { + let charset = ''; + + if (options.letters) + charset += 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if (options.numbers) charset += '0123456789'; + if (options.special) charset += '!@#$%^&*()_+~'; + + if (!charset) { + throw new Error( + 'At least one character set (letters, numbers, special) must be selected.', + ); + } + + const password: string[] = []; + + for (let i = 0; i < length; i++) { + const randomIndex = + globalThis.crypto.getRandomValues(new Uint32Array(1))[0] % charset.length; + password.push(charset[randomIndex]); + } + + return password.join(''); +} From 69e5a54620abbce1b83c9f00ae58c4d8c424e0d6 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Thu, 9 Jul 2026 20:01:36 +0200 Subject: [PATCH 06/49] feat(wallet-sdk): add AuthService with session snapshot and expiry machinery Co-Authored-By: Claude Fable 5 --- .../domain/user/auth-service.test.ts | 390 ++++++++++++++++++ .../wallet-sdk/domain/user/auth-service.ts | 322 +++++++++++++++ packages/wallet-sdk/lib/error.ts | 8 + 3 files changed, 720 insertions(+) create mode 100644 packages/wallet-sdk/domain/user/auth-service.test.ts create mode 100644 packages/wallet-sdk/domain/user/auth-service.ts diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts new file mode 100644 index 000000000..0729e2036 --- /dev/null +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, it } from 'bun:test'; +import { WalletEventEmitter } from '../../lib/events'; +import type { AuthKeyValueStore, AuthStorage } from '../../sdk'; +import { AuthService, type OpenSecretAuthApi } from './auth-service'; +import { createGuestAccountStorage } from './guest-account-storage'; + +const createMemoryStore = (): AuthKeyValueStore & { + data: Map; +} => { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +const createStorage = (): AuthStorage & { + persistent: ReturnType; +} => ({ + persistent: createMemoryStore(), + session: createMemoryStore(), +}); + +const toBase64Url = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + +const createJwt = (expSecondsFromNow: number, sub = 'user-1') => + `${toBase64Url({ alg: 'none' })}.${toBase64Url({ + sub, + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + })}.sig`; + +const fullUser = { + id: 'user-1', + name: null, + email: 'a@b.c', + email_verified: true, + login_method: 'email', + created_at: '2026-01-01', + updated_at: '2026-01-01', +}; + +const guestUser = { ...fullUser, email: undefined }; + +const createOsFake = ( + tokenStore: ReturnType, + overrides: Partial = {}, +) => { + const calls: string[] = []; + // The real Open Secret SDK persists fresh tokens on every login path; the + // fakes mirror that so a timer re-arm after login reads a live refresh token. + const login = (id: string) => { + tokenStore.data.set('access_token', createJwt(600, id)); + tokenStore.data.set('refresh_token', createJwt(3600, id)); + return { id, access_token: 'a', refresh_token: 'r' }; + }; + const os: OpenSecretAuthApi = { + fetchUser: async () => ({ user: fullUser }), + signIn: async () => login('user-1'), + signUp: async () => login('user-1'), + signUpGuest: async () => login('guest-1'), + signInGuest: async () => login('guest-1'), + signOut: async () => undefined, + verifyEmail: async () => undefined, + requestNewVerificationCode: async () => undefined, + convertGuestToUserAccount: async () => undefined, + initiateGoogleAuth: async () => ({ + auth_url: 'https://accounts.google/x', + csrf_token: 'c', + }), + handleGoogleCallback: async () => login('user-1'), + ...overrides, + }; + // wrap every fn to record invocation order + for (const key of Object.keys(os) as (keyof OpenSecretAuthApi)[]) { + const original = os[key] as (...args: unknown[]) => unknown; + // biome-ignore lint/suspicious/noExplicitAny: test instrumentation + (os as any)[key] = (...args: unknown[]) => { + calls.push(key); + return original(...args); + }; + } + return { os, calls }; +}; + +const createService = ( + options: { + os?: Partial; + storage?: ReturnType; + onSessionEnded?: () => void; + } = {}, +) => { + const storage = options.storage ?? createStorage(); + const { os, calls } = createOsFake(storage.persistent, options.os); + const events = new WalletEventEmitter(); + const service = new AuthService({ + os, + storage, + guestAccountStorage: createGuestAccountStorage(storage.persistent), + generateGuestPassword: async () => 'generated-pw', + events, + onSessionEnded: options.onSessionEnded, + }); + return { service, storage, calls, events }; +}; + +describe('AuthService', () => { + it('starts anonymous', () => { + const { service } = createService(); + expect(service.getSession()).toEqual({ isLoggedIn: false }); + }); + + describe('restoreSession', () => { + it('stays anonymous without stored tokens and does not call fetchUser', async () => { + const { service, calls } = createService(); + await service.restoreSession(); + expect(service.getSession().isLoggedIn).toBe(false); + expect(calls).not.toContain('fetchUser'); + }); + + it('restores a session from stored tokens', async () => { + const { service, storage } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await service.restoreSession(); + + expect(service.getSession()).toEqual({ + isLoggedIn: true, + user: fullUser, + }); + service.teardown(); + }); + + it('rejects when tokens exist but the user fetch fails, then recovers on retry', async () => { + let sessionEnded = false; + let failFetch = true; + const { service, storage } = createService({ + os: { + fetchUser: async () => { + if (failFetch) { + throw new Error('network'); + } + return { user: fullUser }; + }, + }, + onSessionEnded: () => { + sessionEnded = true; + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await expect(service.restoreSession()).rejects.toThrow('network'); + // per-session caches were torn down with the failed restore + expect(sessionEnded).toBe(true); + expect(service.getSession().isLoggedIn).toBe(false); + + // the rejection is not memoized — a retry can succeed + failFetch = false; + await service.restoreSession(); + + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('is single-flight', async () => { + const { service, storage, calls } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await Promise.all([service.restoreSession(), service.restoreSession()]); + + expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); + service.teardown(); + }); + }); + + describe('signUpGuest', () => { + it('creates a new guest account and stores the credentials', async () => { + const { service, storage, calls } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + + await service.signUpGuest(); + + expect(calls).toContain('signUpGuest'); + expect( + JSON.parse(storage.persistent.data.get('guestAccount') ?? ''), + ).toEqual({ + id: 'guest-1', + password: 'generated-pw', + }); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('re-signs-in the stored guest account instead of creating a new one', async () => { + const { service, storage, calls } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'stored-pw' }), + ); + + await service.signUpGuest(); + + expect(calls).toContain('signInGuest'); + expect(calls).not.toContain('signUpGuest'); + service.teardown(); + }); + }); + + describe('signOut', () => { + it('clears the session, keeps guest credentials, and runs onSessionEnded', async () => { + let sessionEnded = false; + const { service, storage } = createService({ + onSessionEnded: () => { + sessionEnded = true; + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + await service.restoreSession(); + + await service.signOut(); + + expect(service.getSession().isLoggedIn).toBe(false); + expect(sessionEnded).toBe(true); + expect(storage.persistent.data.has('guestAccount')).toBe(true); + }); + + it('wipes per-session caches again when a different user signs in after sign-out', async () => { + let sessionEndedCount = 0; + const userIds = ['user-a', 'user-b']; + let fetchCalls = 0; + const { service } = createService({ + os: { + fetchUser: async () => ({ + user: { ...fullUser, id: userIds[Math.min(fetchCalls++, 1)] }, + }), + }, + onSessionEnded: () => { + sessionEndedCount += 1; + }, + }); + + await service.signIn('a@b.c', 'pw'); + await service.signOut(); // ends user-a's session → 1 + await service.signIn('b@b.c', 'pw'); // different user → wiped again → 2 + + expect(sessionEndedCount).toBe(2); + service.teardown(); + }); + }); + + describe('convertGuestToFullAccount', () => { + it('clears the stored guest credentials', async () => { + const { service, storage } = createService(); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await service.convertGuestToFullAccount('a@b.c', 'pw2'); + + expect(storage.persistent.data.has('guestAccount')).toBe(false); + service.teardown(); + }); + }); + + describe('session expiry', () => { + it('emits auth.session-expired and ends the session for a full account', async () => { + let sessionEnded = false; + const { service, storage, events } = createService({ + onSessionEnded: () => { + sessionEnded = true; + }, + }); + // refresh token expiring "now" (exp-5s already past) → timer fires immediately + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // the 1ms-floored timer fires on the next macrotask + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(expired).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(false); + expect(sessionEnded).toBe(true); + }); + + it('auto-extends a guest session instead of expiring it', async () => { + const { service, storage, calls, events } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + const refreshed: unknown[] = []; + events.on('auth.session-refreshed', (payload) => refreshed.push(payload)); + + await service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(calls).toContain('signInGuest'); + expect(expired).toHaveLength(0); + // the host is told about the refresh it didn't initiate + expect(refreshed).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('re-arms instead of expiring when the stored refresh token was rotated forward', async () => { + const { service, storage, events } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + // (exp - 5s) is ~100ms away, so the timer fires shortly after restore + storage.persistent.data.set('refresh_token', createJwt(5.1)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // the Open Secret SDK rotates the refresh token during its internal + // refresh flow; simulate a rotation landing before the timer fires + storage.persistent.data.set('refresh_token', createJwt(3600)); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(expired).toHaveLength(0); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('ends the session when the extension cannot restore the guest user', async () => { + let fetchCalls = 0; + const { service, storage, events } = createService({ + os: { + fetchUser: async () => { + fetchCalls += 1; + // restore succeeds; the post-extend fetch fails + if (fetchCalls > 1) { + throw new Error('network'); + } + return { user: guestUser }; + }, + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // no wedged half-session: the death path ran and told the host + expect(expired).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(false); + }); + }); + + it('initiateGoogleAuth returns the raw auth url', async () => { + const { service } = createService(); + expect(await service.initiateGoogleAuth()).toEqual({ + authUrl: 'https://accounts.google/x', + }); + }); +}); diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts new file mode 100644 index 000000000..f767f965d --- /dev/null +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -0,0 +1,322 @@ +import type { + GoogleAuthResponse, + LoginResponse, + UserResponse, +} from '@agicash/opensecret'; +import { + type LongTimeout, + clearLongTimeout, + setLongTimeout, +} from '@agicash/utils'; +import { jwtDecode } from 'jwt-decode'; +import type { WalletEventEmitter } from '../../lib/events'; +import type { AuthApi, AuthSession, AuthStorage, Logger } from '../../sdk'; +import type { GuestAccountStorage } from './guest-account-storage'; + +// Keys are owned by @agicash/opensecret's token persistence; the service reads +// them (never writes) for session detection and expiry math. +const accessTokenKey = 'access_token'; +const refreshTokenKey = 'refresh_token'; + +// A corrupt stored token must degrade (no timer / expiry path), never throw +// from a timer callback or a query fn. +const decodeJwt = (token: string): { exp?: number } | null => { + try { + return jwtDecode(token); + } catch { + return null; + } +}; + +/** The subset of @agicash/opensecret the auth service drives. `import * as openSecret` satisfies it. */ +export type OpenSecretAuthApi = { + fetchUser(): Promise; + signIn(email: string, password: string): Promise; + signUp( + email: string, + password: string, + inviteCode: string, + name?: string | null, + ): Promise; + signUpGuest(password: string, inviteCode: string): Promise; + signInGuest(id: string, password: string): Promise; + signOut(): Promise; + verifyEmail(code: string): Promise; + requestNewVerificationCode(): Promise; + convertGuestToUserAccount( + email: string, + password: string, + name?: string | null, + ): Promise; + initiateGoogleAuth(inviteCode?: string): Promise; + handleGoogleCallback( + code: string, + state: string, + inviteCode: string, + ): Promise; +}; + +type AuthServiceDeps = { + os: OpenSecretAuthApi; + storage: AuthStorage; + guestAccountStorage: GuestAccountStorage; + generateGuestPassword: () => Promise; + events: WalletEventEmitter; + /** Per-session cache cleanup on any session end (sign-out or expiry). */ + onSessionEnded?: () => void; + logger?: Logger; +}; + +export class AuthService implements AuthApi { + private session: AuthSession = { isLoggedIn: false }; + private restorePromise: Promise | undefined; + private expiryTimeout: LongTimeout | undefined; + // Survives endSession() deliberately — see applySessionFromServer. + private lastUserId: string | undefined; + + constructor(private readonly deps: AuthServiceDeps) {} + + getSession(): AuthSession { + return this.session; + } + + /** + * Idempotent session restore from the storage port; resolving anonymous is + * a state, not a failure. A rejection (unreadable storage, failed user + * fetch with tokens present) is not memoized, so a retry can recover. + */ + restoreSession(): Promise { + this.restorePromise ??= this.doRestore().catch((error) => { + this.restorePromise = undefined; + throw error; + }); + return this.restorePromise; + } + + private async doRestore(): Promise { + const [accessToken, refreshToken] = await Promise.all([ + this.deps.storage.persistent.getItem(accessTokenKey), + this.deps.storage.persistent.getItem(refreshTokenKey), + ]); + if (!accessToken || !refreshToken) { + return; + } + try { + await this.applySessionFromServer(); + } catch (error) { + if (this.session.isLoggedIn) { + // An auth verb established a session while this restore was in + // flight; the restore result is moot. + return; + } + // Contract: init() rejects on refresh errors (tokens exist but can't + // be validated). endSession keeps the instance consistent; the + // rejection is un-memoized by restoreSession, so a retry can succeed. + this.endSession(); + throw error; + } + } + + async signUp(email: string, password: string): Promise { + await this.deps.os.signUp(email, password, ''); + await this.refreshSessionSnapshot('sign up'); + } + + async signUpGuest(): Promise { + const existingGuestAccount = await this.deps.guestAccountStorage.get(); + if (existingGuestAccount) { + await this.deps.os.signInGuest( + existingGuestAccount.id, + existingGuestAccount.password, + ); + } else { + const password = await this.deps.generateGuestPassword(); + const guestAccount = await this.deps.os.signUpGuest(password, ''); + await this.deps.guestAccountStorage.store({ + id: guestAccount.id, + password, + }); + } + await this.refreshSessionSnapshot('guest sign up'); + } + + async signIn(email: string, password: string): Promise { + await this.deps.os.signIn(email, password); + await this.refreshSessionSnapshot('sign in'); + } + + async signOut(): Promise { + try { + await this.deps.os.signOut(); + } finally { + this.endSession(); + } + } + + async verifyEmail(code: string): Promise { + await this.deps.os.verifyEmail(code); + await this.refreshSessionSnapshot('email verification'); + } + + requestNewVerificationCode(): Promise { + return this.deps.os.requestNewVerificationCode(); + } + + async convertGuestToFullAccount( + email: string, + password: string, + ): Promise { + await this.deps.os.convertGuestToUserAccount(email, password); + await this.deps.guestAccountStorage.clear(); + await this.refreshSessionSnapshot('guest conversion'); + } + + async initiateGoogleAuth(): Promise<{ authUrl: string }> { + const response = await this.deps.os.initiateGoogleAuth(''); + return { authUrl: response.auth_url }; + } + + async completeGoogleAuth(params: { + code: string; + state: string; + }): Promise { + await this.deps.os.handleGoogleCallback(params.code, params.state, ''); + await this.refreshSessionSnapshot('google auth'); + } + + /** Cancels the expiry timer; the instance stays usable. */ + teardown(): void { + this.disarmExpiryTimer(); + } + + private async refreshSessionSnapshot(context: string): Promise { + try { + await this.applySessionFromServer(); + } catch (error) { + // Swallowed for parity: a verb whose fetchUser fails leaves an + // anonymous session the host discovers on its next read, like the old + // web glue. endSession (not a bare snapshot clear) so the per-session + // caches die with the session — the Supabase token cache in particular + // must never outlive it. + this.deps.logger?.error(`Failed to fetch user (${context})`, error); + this.endSession(); + } + } + + private async applySessionFromServer(): Promise { + const response = await this.deps.os.fetchUser(); + // Compared against the last seen user rather than the live session: a + // memo repopulated by a request that resolved after sign-out must still + // be wiped when a DIFFERENT user's session begins, and by then the + // session is anonymous. Same-user re-login keeps its memos warm. + if (this.lastUserId && this.lastUserId !== response.user.id) { + this.deps.onSessionEnded?.(); + } + this.lastUserId = response.user.id; + this.session = { isLoggedIn: true, user: response.user }; + await this.armExpiryTimer(); + } + + private endSession(): void { + this.session = { isLoggedIn: false }; + this.disarmExpiryTimer(); + // Un-memoize the restore so the next init() re-evaluates from storage — + // a verb whose post-login fetchUser failed leaves tokens behind, and the + // next invalidation can then recover the session like the old glue did. + this.restorePromise = undefined; + this.deps.onSessionEnded?.(); + } + + private async armExpiryTimer(): Promise { + const remaining = await this.getRemainingSessionTimeMs(); + // Disarm only after the await, so disarm+assign form one synchronous + // block — two overlapping arms can't interleave and orphan a timer. + this.disarmExpiryTimer(); + if (remaining === null) { + return; + } + // Floor of 1ms: setLongTimeout fires synchronously at delay 0, which + // would recurse into handleSessionExpiry from inside a login verb. + this.expiryTimeout = setLongTimeout( + () => { + void this.handleSessionExpiry(); + }, + Math.max(remaining, 1), + ); + } + + /** + * Milliseconds until the stored refresh token is treated as expired (5s + * before actual expiry, matching the previous web behavior), floored at 0. + * Null when the token is absent or undecodable. + */ + private async getRemainingSessionTimeMs(): Promise { + const refreshToken = + await this.deps.storage.persistent.getItem(refreshTokenKey); + if (!refreshToken) { + return null; + } + const decoded = decodeJwt(refreshToken); + if (!decoded?.exp) { + return null; + } + return Math.max((decoded.exp - 5) * 1000 - Date.now(), 0); + } + + private disarmExpiryTimer(): void { + if (this.expiryTimeout) { + clearLongTimeout(this.expiryTimeout); + this.expiryTimeout = undefined; + } + } + + private async handleSessionExpiry(): Promise { + const session = this.session; + if (!session.isLoggedIn) { + return; + } + // The Open Secret SDK rotates the refresh token during its internal + // refresh flow, so the expiry this timer was armed for may have moved. + // Re-check the stored token and re-arm instead of expiring a live session. + const remaining = await this.getRemainingSessionTimeMs(); + if (remaining !== null && remaining > 0) { + await this.armExpiryTimer(); + return; + } + const isGuest = !session.user.email; + if (isGuest) { + try { + // Re-signing-in the stored guest account gets fresh tokens and re-arms + // the timer; the host never observes the expiry. + await this.signUpGuest(); + const extendedRemaining = await this.getRemainingSessionTimeMs(); + if ( + extendedRemaining !== null && + extendedRemaining > 0 && + this.session.isLoggedIn + ) { + // The host didn't initiate this refresh, so it must be told — + // the web re-syncs its auth query + session-hint cookie from it. + this.deps.events.emit('auth.session-refreshed', {}); + return; + } + // Falls through when the extension produced no live session (already- + // expired token — also guards a hot extend loop — or a failed + // post-extend user fetch), so the death path emits the event instead + // of leaving a wedged half-session. + this.deps.logger?.warn( + 'Guest session extension did not produce a live session; ending it', + ); + } catch (error) { + this.deps.logger?.error('Failed to extend guest session', error); + } + } + try { + await this.deps.os.signOut(); + } catch (error) { + this.deps.logger?.warn('Sign out during session expiry failed', error); + } + this.endSession(); + this.deps.events.emit('auth.session-expired', {}); + } +} diff --git a/packages/wallet-sdk/lib/error.ts b/packages/wallet-sdk/lib/error.ts index 628d366b4..46fcdf671 100644 --- a/packages/wallet-sdk/lib/error.ts +++ b/packages/wallet-sdk/lib/error.ts @@ -30,3 +30,11 @@ export class ConcurrencyError extends SdkError { this.name = 'ConcurrencyError'; } } + +/** Thrown when a namespace method requiring an authenticated session runs without one. */ +export class NoSessionError extends SdkError { + constructor() { + super('No authenticated session'); + this.name = 'NoSessionError'; + } +} From c0957483e4a075524abddaa4e65fc523a252d7e2 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Thu, 9 Jul 2026 20:04:37 +0200 Subject: [PATCH 07/49] feat(wallet-sdk): add the SDK-internal Supabase client and session token getter Co-Authored-By: Claude Fable 5 --- packages/wallet-sdk/db/client.ts | 21 +++ .../wallet-sdk/db/supabase-session.test.ts | 141 ++++++++++++++++++ packages/wallet-sdk/db/supabase-session.ts | 76 ++++++++++ 3 files changed, 238 insertions(+) create mode 100644 packages/wallet-sdk/db/client.ts create mode 100644 packages/wallet-sdk/db/supabase-session.test.ts create mode 100644 packages/wallet-sdk/db/supabase-session.ts diff --git a/packages/wallet-sdk/db/client.ts b/packages/wallet-sdk/db/client.ts new file mode 100644 index 000000000..32e9f750a --- /dev/null +++ b/packages/wallet-sdk/db/client.ts @@ -0,0 +1,21 @@ +import { createClient } from '@supabase/supabase-js'; +import type { AgicashDb, Database } from './database'; + +type AgicashDbClientConfig = { + url: string; + anonKey: string; + /** Resolves the Supabase session JWT; null selects the anon key. */ + accessToken: () => Promise; +}; + +/** Builds the SDK's own Supabase client (wallet schema). */ +export function createAgicashDbClient( + config: AgicashDbClientConfig, +): AgicashDb { + return createClient(config.url, config.anonKey, { + accessToken: config.accessToken, + db: { + schema: 'wallet', + }, + }); +} diff --git a/packages/wallet-sdk/db/supabase-session.test.ts b/packages/wallet-sdk/db/supabase-session.test.ts new file mode 100644 index 000000000..1d7eddbe1 --- /dev/null +++ b/packages/wallet-sdk/db/supabase-session.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'bun:test'; +import { createSupabaseSessionTokenGetter } from './supabase-session'; + +const toBase64Url = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + +const createToken = (expSecondsFromNow: number) => + `${toBase64Url({ alg: 'none' })}.${toBase64Url({ + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + })}.sig`; + +describe('createSupabaseSessionTokenGetter', () => { + it('returns null and skips token generation when logged out', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => false, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + expect(await getToken()).toBeNull(); + expect(generated).toBe(0); + }); + + it('memoizes the token until close to expiry', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + const first = await getToken(); + const second = await getToken(); + + expect(first).toBe(second as string); + expect(generated).toBe(1); + }); + + it('re-generates once the cached token is within 5s of expiry', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + // expires in 3s → refreshAt is already in the past + return { token: createToken(3) }; + }, + }); + + await getToken(); + await getToken(); + + expect(generated).toBe(2); + }); + + it('shares one in-flight request between concurrent callers', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + await new Promise((resolve) => setTimeout(resolve, 5)); + return { token: createToken(3600) }; + }, + }); + + await Promise.all([getToken(), getToken(), getToken()]); + + expect(generated).toBe(1); + }); + + it('drops the cache when the session ends', async () => { + let loggedIn = true; + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => loggedIn, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + await getToken(); + loggedIn = false; + expect(await getToken()).toBeNull(); + loggedIn = true; + await getToken(); + + expect(generated).toBe(2); + }); + + it('reset drops the cached token so the next session cannot reuse it', async () => { + let generated = 0; + const source = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + await source.getToken(); + source.reset(); + await source.getToken(); + + expect(generated).toBe(2); + }); + + it('does not cache a token that resolves after reset', async () => { + let generated = 0; + let release: (() => void) | undefined; + const source = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + if (generated === 1) { + await new Promise((resolve) => { + release = resolve; + }); + } + return { token: createToken(3600) }; + }, + }); + + const firstCall = source.getToken(); + // session ends while the first exchange is still in flight + source.reset(); + release?.(); + await firstCall; + + await source.getToken(); + + // the stale in-flight token was not cached; the new session exchanged fresh + expect(generated).toBe(2); + }); +}); diff --git a/packages/wallet-sdk/db/supabase-session.ts b/packages/wallet-sdk/db/supabase-session.ts new file mode 100644 index 000000000..f9ac33887 --- /dev/null +++ b/packages/wallet-sdk/db/supabase-session.ts @@ -0,0 +1,76 @@ +import { generateThirdPartyToken } from '@agicash/opensecret'; +import { jwtDecode } from 'jwt-decode'; + +type Deps = { + isLoggedIn: () => boolean; + /** Test seam; defaults to Open Secret's generateThirdPartyToken. */ + generateToken?: () => Promise<{ token: string }>; +}; + +export type SupabaseSessionTokenSource = { + /** Supabase `accessToken` callback; null selects the anon key. */ + getToken: () => Promise; + /** + * Drops the cached token. Must be called when the session ends — the cache + * is otherwise only re-validated by expiry, and a token minted for one user + * must never survive into another user's session. + */ + reset: () => void; +}; + +/** + * Builds the Supabase `accessToken` source: exchanges the Open Secret JWT + * for a Supabase third-party token and memoizes it until 5 seconds before its + * expiry. Concurrent callers share one in-flight exchange. Returns null when + * no session exists (the client then uses the anon key). + */ +export function createSupabaseSessionTokenGetter( + deps: Deps, +): SupabaseSessionTokenSource { + const generateToken = deps.generateToken ?? (() => generateThirdPartyToken()); + let cached: { token: string; refreshAtMs: number } | undefined; + let inFlight: Promise | undefined; + // Incremented on invalidation; an exchange started under an older + // generation must not populate the cache — its token belongs to the ended + // session. + let generation = 0; + + const invalidate = () => { + generation += 1; + cached = undefined; + inFlight = undefined; + }; + + return { + reset: invalidate, + getToken: async () => { + if (!deps.isLoggedIn()) { + // Same full invalidation as reset(): an exchange in flight when the + // session ended must not populate the cache either. + invalidate(); + return null; + } + if (cached && Date.now() < cached.refreshAtMs) { + return cached.token; + } + if (!inFlight) { + const startedGeneration = generation; + inFlight = (async () => { + try { + const { token } = await generateToken(); + if (generation === startedGeneration) { + const { exp } = jwtDecode(token); + cached = { token, refreshAtMs: exp ? (exp - 5) * 1000 : 0 }; + } + return token; + } finally { + if (generation === startedGeneration) { + inFlight = undefined; + } + } + })(); + } + return inFlight; + }, + }; +} From 32bbf9d7f88721751d2d6109047661017624eb83 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Thu, 9 Jul 2026 20:06:22 +0200 Subject: [PATCH 08/49] refactor(wallet-sdk): free WriteUserRepository from the accounts graph; add createUserApi Co-Authored-By: Claude Fable 5 --- .../features/user/user-repository-hooks.ts | 4 +- .../_protected.receive.cashu_.token.tsx | 5 +- apps/web-wallet/app/routes/_protected.tsx | 30 ++++---- packages/wallet-sdk/domain/user/user-api.ts | 70 +++++++++++++++++++ .../wallet-sdk/domain/user/user-repository.ts | 8 +-- .../wallet-sdk/domain/user/user-service.ts | 19 ++--- 6 files changed, 100 insertions(+), 36 deletions(-) create mode 100644 packages/wallet-sdk/domain/user/user-api.ts diff --git a/apps/web-wallet/app/features/user/user-repository-hooks.ts b/apps/web-wallet/app/features/user/user-repository-hooks.ts index 157793783..bb95b5d59 100644 --- a/apps/web-wallet/app/features/user/user-repository-hooks.ts +++ b/apps/web-wallet/app/features/user/user-repository-hooks.ts @@ -2,7 +2,6 @@ import { ReadUserRepository, WriteUserRepository, } from '@agicash/wallet-sdk/temporary'; -import { useAccountRepository } from '~/features/accounts/account-repository-hooks'; import { agicashDbClient } from '~/features/agicash-db/database.client'; export function useReadUserRepository() { @@ -10,6 +9,5 @@ export function useReadUserRepository() { } export function useWriteUserRepository() { - const accountRepository = useAccountRepository(); - return new WriteUserRepository(agicashDbClient, accountRepository); + return new WriteUserRepository(agicashDbClient); } diff --git a/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx index 6d4e35295..fa63cf49b 100644 --- a/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx +++ b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx @@ -89,10 +89,7 @@ const getServices = async () => { cashuReceiveQuoteService, sparkReceiveQuoteService, ); - const userRepository = new WriteUserRepository( - agicashDbClient, - accountRepository, - ); + const userRepository = new WriteUserRepository(agicashDbClient); const userService = new UserService(userRepository); const claimCashuTokenService = new ClaimCashuTokenService( diff --git a/apps/web-wallet/app/routes/_protected.tsx b/apps/web-wallet/app/routes/_protected.tsx index d0214f41c..16151a124 100644 --- a/apps/web-wallet/app/routes/_protected.tsx +++ b/apps/web-wallet/app/routes/_protected.tsx @@ -121,24 +121,24 @@ const ensureUserData = async ( getSparkWalletMnemonic, { storageDir: './.spark-data', apiKey: breezApiKey }, ); - const writeUserRepository = new WriteUserRepository( - agicashDbClient, - accountRepository, - ); + const writeUserRepository = new WriteUserRepository(agicashDbClient); const { user: upsertedUser, accounts } = await withRetry({ fn: () => - writeUserRepository.upsert({ - id: authUser.id, - email: authUser.email, - emailVerified: authUser.email_verified, - accounts: [...defaultAccounts], - cashuLockingXpub, - encryptionPublicKey, - sparkIdentityPublicKey, - termsAcceptedAt, - giftCardMintTermsAcceptedAt, - }), + writeUserRepository.upsert( + { + id: authUser.id, + email: authUser.email, + emailVerified: authUser.email_verified, + accounts: [...defaultAccounts], + cashuLockingXpub, + encryptionPublicKey, + sparkIdentityPublicKey, + termsAcceptedAt, + giftCardMintTermsAcceptedAt, + }, + accountRepository, + ), retry: (attemptIndex, error) => { if (error instanceof core.$ZodError) { return false; diff --git a/packages/wallet-sdk/domain/user/user-api.ts b/packages/wallet-sdk/domain/user/user-api.ts new file mode 100644 index 000000000..979948a9f --- /dev/null +++ b/packages/wallet-sdk/domain/user/user-api.ts @@ -0,0 +1,70 @@ +import type { Currency } from '@agicash/money'; +import type { AgicashDb } from '../../db/database'; +import { NoSessionError } from '../../lib/error'; +import type { AuthSession, UserApi } from '../../sdk'; +import { ReadUserRepository, WriteUserRepository } from './user-repository'; +import { UserService } from './user-service'; + +type Deps = { + db: AgicashDb; + getSession: () => AuthSession; +}; + +export function createUserApi(deps: Deps): UserApi { + const readRepository = new ReadUserRepository(deps.db); + const writeRepository = new WriteUserRepository(deps.db); + const userService = new UserService(writeRepository); + + const requireUserId = (): string => { + const session = deps.getSession(); + if (!session.isLoggedIn) { + throw new NoSessionError(); + } + return session.user.id; + }; + + const getAccountRef = async ( + accountId: string, + ): Promise<{ id: string; currency: Currency }> => { + const { data, error } = await deps.db + .from('accounts') + .select('id, currency') + .eq('id', accountId) + // RLS already scopes rows to the user; this is defense-in-depth per the + // "userId implicit from session" convention. + .eq('user_id', requireUserId()) + .single(); + if (error) { + throw new Error('Failed to get account', { cause: error }); + } + return data; + }; + + // Methods are async so a missing session surfaces as a rejection, matching + // the Promise-returning contract, not a synchronous throw. + return { + get: async () => readRepository.get(requireUserId()), + updateUsername: async (username) => + writeRepository.update(requireUserId(), { username }), + acceptTerms: async (params) => { + const now = new Date().toISOString(); + return writeRepository.update(requireUserId(), { + termsAcceptedAt: params.walletTerms ? now : undefined, + giftCardMintTermsAcceptedAt: params.giftCardTerms ? now : undefined, + }); + }, + setDefaultCurrency: async (params) => + writeRepository.update(requireUserId(), { + defaultCurrency: params.currency, + }), + setDefaultAccount: async (params) => { + // One read, not two: the account row is fetched to derive the + // per-currency column server-truthfully; the user row isn't needed + // because the update only writes the changed columns. + const account = await getAccountRef(params.accountId); + return userService.setDefaultAccount({ id: requireUserId() }, account, { + setDefaultCurrency: params.setDefaultCurrency, + }); + }, + }; +} diff --git a/packages/wallet-sdk/domain/user/user-repository.ts b/packages/wallet-sdk/domain/user/user-repository.ts index 680dccee6..e68c9e74f 100644 --- a/packages/wallet-sdk/domain/user/user-repository.ts +++ b/packages/wallet-sdk/domain/user/user-repository.ts @@ -54,10 +54,7 @@ type AccountInput = { >; export class WriteUserRepository { - constructor( - private readonly db: AgicashDb, - private readonly accountRepository: AccountRepository, - ) {} + constructor(private readonly db: AgicashDb) {} /** * Updates a user in the database. @@ -146,6 +143,7 @@ export class WriteUserRepository { */ giftCardMintTermsAcceptedAt?: string; }, + accountRepository: AccountRepository, options?: Options, ): Promise<{ user: User; accounts: Account[] }> { const accountsToAdd = user.accounts.map((account) => ({ @@ -195,7 +193,7 @@ export class WriteUserRepository { return { user: ReadUserRepository.toUser(upsertedUser), accounts: await Promise.all( - accounts.map((account) => this.accountRepository.toAccount(account)), + accounts.map((account) => accountRepository.toAccount(account)), ), }; } diff --git a/packages/wallet-sdk/domain/user/user-service.ts b/packages/wallet-sdk/domain/user/user-service.ts index 54b3ca7d0..41323c6b7 100644 --- a/packages/wallet-sdk/domain/user/user-service.ts +++ b/packages/wallet-sdk/domain/user/user-service.ts @@ -45,10 +45,12 @@ export class UserService { /** * Sets the account as the user's default account for the respective currency. * If setDefaultCurrency option is set to true, the user's default currency will also be set to the account's currency. + * Writes only the changed columns, so concurrent changes to the other + * defaults can't be clobbered by stale caller state. */ async setDefaultAccount( - user: User, - account: Account, + user: Pick, + account: Pick, options: SetDefaultAccountOptions = { setDefaultCurrency: false, }, @@ -60,13 +62,12 @@ export class UserService { return this.userRepository.update( user.id, { - defaultCurrency: options.setDefaultCurrency - ? account.currency - : user.defaultCurrency, - defaultBtcAccountId: - account.currency === 'BTC' ? account.id : user.defaultBtcAccountId, - defaultUsdAccountId: - account.currency === 'USD' ? account.id : user.defaultUsdAccountId, + ...(account.currency === 'BTC' + ? { defaultBtcAccountId: account.id } + : { defaultUsdAccountId: account.id }), + ...(options.setDefaultCurrency + ? { defaultCurrency: account.currency } + : {}), }, { abortSignal: options.abortSignal }, ); From 2930e9d0cfb6090836e61e81ba55c804c1cc4b90 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Thu, 9 Jul 2026 20:09:13 +0200 Subject: [PATCH 09/49] feat(wallet-sdk): add the AgicashSdk runtime with auth, user, and events namespaces Co-Authored-By: Claude Fable 5 --- packages/wallet-sdk/agicash-sdk.ts | 97 ++++++++++++++++++++++++++++++ packages/wallet-sdk/index.ts | 1 + 2 files changed, 98 insertions(+) create mode 100644 packages/wallet-sdk/agicash-sdk.ts diff --git a/packages/wallet-sdk/agicash-sdk.ts b/packages/wallet-sdk/agicash-sdk.ts new file mode 100644 index 000000000..192b6076f --- /dev/null +++ b/packages/wallet-sdk/agicash-sdk.ts @@ -0,0 +1,97 @@ +import * as openSecret from '@agicash/opensecret'; +import { createAgicashDbClient } from './db/client'; +import { createSupabaseSessionTokenGetter } from './db/supabase-session'; +import { AuthService } from './domain/user/auth-service'; +import { createGuestAccountStorage } from './domain/user/guest-account-storage'; +import { createUserApi } from './domain/user/user-api'; +import { clearAgicashMintAuthToken } from './lib/agicash-mint-auth-provider'; +import { WalletEventEmitter } from './lib/events'; +import { generateRandomPassword } from './lib/password'; +import { clearSparkWallets } from './lib/spark/wallet'; +import type { AuthApi, SdkConfig, UserApi, WalletEvents } from './sdk'; + +/** + * Runtime implementation of the SDK contract, filled namespace-by-namespace + * as the migration slices land (auth/user/events since step 5). It will + * declare `implements Sdk` once every namespace exists. + */ +export class AgicashSdk { + readonly auth: AuthApi; + readonly user: UserApi; + readonly events: WalletEvents; + + private readonly authService: AuthService; + + private constructor(config: SdkConfig) { + // The Open Secret client is module-scoped in @agicash/opensecret, so auth + // configuration is process-global: a second AgicashSdk instance would + // re-configure it. One instance per process until the library ships an + // instance API. + openSecret.configure({ + apiUrl: config.auth.apiUrl, + clientId: config.auth.clientId, + storage: config.auth.storage, + }); + + const events = new WalletEventEmitter(config.logger); + + // Created before authService — the isLoggedIn closure dereferences it + // lazily at request time, after the constructor has assigned it. + const sessionToken = createSupabaseSessionTokenGetter({ + isLoggedIn: () => this.authService.getSession().isLoggedIn, + }); + + this.authService = new AuthService({ + os: openSecret, + storage: config.auth.storage, + guestAccountStorage: createGuestAccountStorage( + config.auth.storage.persistent, + config.logger, + ), + generateGuestPassword: async () => + (await config.auth.generateGuestPassword?.()) ?? + generateRandomPassword(32), + events, + onSessionEnded: () => { + // The token cache must die with the session: a token minted for one + // user must never serve the next login's queries. + sessionToken.reset(); + clearSparkWallets(); + clearAgicashMintAuthToken(); + }, + logger: config.logger, + }); + + const db = createAgicashDbClient({ + url: config.db.url, + anonKey: config.db.anonKey, + accessToken: sessionToken.getToken, + }); + + this.auth = this.authService; + this.user = createUserApi({ + db, + getSession: () => this.authService.getSession(), + }); + this.events = events; + } + + /** Sync; no I/O. */ + static create(config: SdkConfig): AgicashSdk { + return new AgicashSdk(config); + } + + /** + * Session restore only for now — the Breez WASM load folds in when the + * first Spark slice lands. Resolves when no session exists. Delegates + * to the auth service, which is single-flight and memoizes success but + * clears a rejection, so the host's query retries can recover. + */ + init(): Promise { + return this.authService.restoreSession(); + } + + async dispose(): Promise { + this.authService.teardown(); + } +} diff --git a/packages/wallet-sdk/index.ts b/packages/wallet-sdk/index.ts index f24a1472a..a371aa460 100644 --- a/packages/wallet-sdk/index.ts +++ b/packages/wallet-sdk/index.ts @@ -8,6 +8,7 @@ // deletes its names here when it flips the web imports, surfacing the // projections. export * from './sdk'; +export { AgicashSdk } from './agicash-sdk'; export { ConcurrencyError, DomainError, From ad1be0b2053ee35405fd33db52e0af836379bf8a Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Thu, 9 Jul 2026 20:13:10 +0200 Subject: [PATCH 10/49] feat(web-wallet): construct the wallet SDK instance and move Open Secret config into it Co-Authored-By: Claude Fable 5 --- apps/web-wallet/app/entry.client.tsx | 20 +------ .../features/agicash-db/database.client.ts | 4 +- .../app/features/shared/sdk.client.ts | 56 +++++++++++++++++++ 3 files changed, 61 insertions(+), 19 deletions(-) create mode 100644 apps/web-wallet/app/features/shared/sdk.client.ts diff --git a/apps/web-wallet/app/entry.client.tsx b/apps/web-wallet/app/entry.client.tsx index 31a7f7cb7..d060ee856 100644 --- a/apps/web-wallet/app/entry.client.tsx +++ b/apps/web-wallet/app/entry.client.tsx @@ -1,4 +1,3 @@ -import { browserStorage, configure } from '@agicash/opensecret'; import { configureFeatureFlags, ensureBreezWasm, @@ -15,6 +14,9 @@ import { HydratedRouter } from 'react-router/dom'; import { getEnvironment, isServedLocally } from './environment'; import { agicashDbClient } from './features/agicash-db/database.client'; import { loadFeatureFlags } from './features/shared/feature-flags'; +// Importing the module constructs the SDK, which configures Open Secret as an +// import-evaluation side effect — before any body code below runs. +import './features/shared/sdk.client'; import { registerMoneyDevToolsFormatter } from './lib/money-devtools-formatter'; import { getTracesSampleRate, sanitizeUrl } from './tracing-utils'; @@ -23,22 +25,6 @@ if (process.env.NODE_ENV === 'development') { registerMoneyDevToolsFormatter(); } -const openSecretApiUrl = import.meta.env.VITE_OPEN_SECRET_API_URL ?? ''; -if (!openSecretApiUrl) { - throw new Error('VITE_OPEN_SECRET_API_URL is not set'); -} - -const openSecretClientId = import.meta.env.VITE_OPEN_SECRET_CLIENT_ID ?? ''; -if (!openSecretClientId) { - throw new Error('VITE_OPEN_SECRET_CLIENT_ID is not set'); -} - -configure({ - apiUrl: openSecretApiUrl, - clientId: openSecretClientId, - storage: browserStorage, -}); - // Start Breez WASM fetch/compile as early as possible so it overlaps with // hydration, Sentry init, and the auth query — by the time the _protected // middleware awaits it, init is often already done. diff --git a/apps/web-wallet/app/features/agicash-db/database.client.ts b/apps/web-wallet/app/features/agicash-db/database.client.ts index ae18659f7..8f87bd3c8 100644 --- a/apps/web-wallet/app/features/agicash-db/database.client.ts +++ b/apps/web-wallet/app/features/agicash-db/database.client.ts @@ -24,9 +24,9 @@ const getSupabaseUrl = () => { return supabaseUrl; }; -const supabaseUrl = getSupabaseUrl(); +export const supabaseUrl = getSupabaseUrl(); -const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? ''; +export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? ''; if (!supabaseAnonKey) { throw new Error('VITE_SUPABASE_ANON_KEY is not set'); } diff --git a/apps/web-wallet/app/features/shared/sdk.client.ts b/apps/web-wallet/app/features/shared/sdk.client.ts new file mode 100644 index 000000000..46b4701f8 --- /dev/null +++ b/apps/web-wallet/app/features/shared/sdk.client.ts @@ -0,0 +1,56 @@ +import { browserStorage } from '@agicash/opensecret'; +import { AgicashSdk } from '@agicash/wallet-sdk'; +import { + supabaseAnonKey, + supabaseUrl, +} from '~/features/agicash-db/database.client'; +import { breezApiKey } from '~/lib/breez'; + +const openSecretApiUrl = import.meta.env.VITE_OPEN_SECRET_API_URL ?? ''; +if (!openSecretApiUrl) { + throw new Error('VITE_OPEN_SECRET_API_URL is not set'); +} + +const openSecretClientId = import.meta.env.VITE_OPEN_SECRET_CLIENT_ID ?? ''; +if (!openSecretClientId) { + throw new Error('VITE_OPEN_SECRET_CLIENT_ID is not set'); +} + +const consoleLogger = { + debug: (message: string, meta?: unknown) => + meta === undefined ? console.debug(message) : console.debug(message, meta), + info: (message: string, meta?: unknown) => + meta === undefined ? console.info(message) : console.info(message, meta), + warn: (message: string, meta?: unknown) => + meta === undefined ? console.warn(message) : console.warn(message, meta), + error: (message: string, meta?: unknown) => + meta === undefined ? console.error(message) : console.error(message, meta), +}; + +export const sdk = AgicashSdk.create({ + db: { + url: supabaseUrl, + anonKey: supabaseAnonKey, + }, + auth: { + apiUrl: openSecretApiUrl, + clientId: openSecretClientId, + storage: browserStorage, + // e2e bridge: the Playwright fixture arms window.getMockPassword; in + // production it's absent, so this resolves null and the SDK generates. + generateGuestPassword: async () => + (await window.getMockPassword?.()) ?? null, + }, + spark: { + breezApiKey, + network: 'MAINNET', + }, + lightningAddressDomain: window.location.host, + logger: consoleLogger, +}); + +if (import.meta.hot) { + // A hot reload of this module constructs a second SDK; dispose the old one + // so its expiry timer doesn't leak. + import.meta.hot.dispose(() => void sdk.dispose()); +} From 50d2d2589cee4605ccc0af0beadce1d1bc2ab2db Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Thu, 9 Jul 2026 20:32:08 +0200 Subject: [PATCH 11/49] refactor(web-wallet): route auth flows through sdk.auth Co-Authored-By: Claude Fable 5 --- .../app/features/signup/verify-email.ts | 4 +- apps/web-wallet/app/features/user/auth.ts | 346 +++++++----------- .../web-wallet/app/features/wallet/wallet.tsx | 17 +- apps/web-wallet/app/hooks/use-long-timeout.ts | 45 --- apps/web-wallet/app/lib/password-generator.ts | 40 -- .../app/routes/_auth.oauth.$provider.tsx | 4 +- apps/web-wallet/app/routes/_protected.tsx | 7 +- 7 files changed, 137 insertions(+), 326 deletions(-) delete mode 100644 apps/web-wallet/app/hooks/use-long-timeout.ts delete mode 100644 apps/web-wallet/app/lib/password-generator.ts diff --git a/apps/web-wallet/app/features/signup/verify-email.ts b/apps/web-wallet/app/features/signup/verify-email.ts index c619e8a4a..a48559078 100644 --- a/apps/web-wallet/app/features/signup/verify-email.ts +++ b/apps/web-wallet/app/features/signup/verify-email.ts @@ -1,8 +1,8 @@ -import { verifyEmail as osVerifyEmail } from '@agicash/opensecret'; import type { FullUser } from '@agicash/wallet-sdk'; import { shouldVerifyEmail } from '@agicash/wallet-sdk'; import { useState } from 'react'; import { createContext, redirect } from 'react-router'; +import { sdk } from '~/features/shared/sdk.client'; import { useToast } from '~/hooks/use-toast'; import type { Route } from '../../routes/+types/_protected.verify-email.($code)'; import { invalidateAuthQueries } from '../user/auth'; @@ -37,7 +37,7 @@ export const verifyEmail = async ( code: string, ): Promise<{ verified: true } | { verified: false; error: Error }> => { try { - await osVerifyEmail(code); + await sdk.auth.verifyEmail(code); await invalidateAuthQueries(); return { verified: true }; } catch (e) { diff --git a/apps/web-wallet/app/features/user/auth.ts b/apps/web-wallet/app/features/user/auth.ts index dee12f0dd..bae0654f3 100644 --- a/apps/web-wallet/app/features/user/auth.ts +++ b/apps/web-wallet/app/features/user/auth.ts @@ -1,19 +1,4 @@ -import { - type UserResponse, - fetchUser, - convertGuestToUserAccount as osConvertGuestToFullAccount, - initiateGoogleAuth as osInitiateGoogleAuth, - signIn as osSignIn, - signInGuest as osSignInGuest, - signOut as osSignOut, - signUp as osSignUp, - signUpGuest as osSignUpGuest, - verifyEmail as osVerifyEmail, -} from '@agicash/opensecret'; -import { - clearAgicashMintAuthToken, - clearSparkWallets, -} from '@agicash/wallet-sdk/temporary'; +import type { AuthUser } from '@agicash/wallet-sdk'; import * as Sentry from '@sentry/react-router'; import { decodeURLSafe, encodeURLSafe } from '@stablelib/base64'; import { @@ -22,25 +7,26 @@ import { useSuspenseQuery, } from '@tanstack/react-query'; import { jwtDecode } from 'jwt-decode'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useNavigate, useRevalidator } from 'react-router'; import { loadFeatureFlags, resetFeatureFlags, } from '~/features/shared/feature-flags'; import { getQueryClient } from '~/features/shared/query-client'; -import { useLongTimeout } from '~/hooks/use-long-timeout'; -import { generateRandomPassword } from '~/lib/password-generator'; -import { guestAccountStorage } from './guest-account-storage'; +import { sdk } from '~/features/shared/sdk.client'; +import { useLatest } from '~/lib/use-latest'; import { oauthLoginSessionStorage } from './oauth-login-session-storage'; import { sessionHintCookie } from './session-hint-cookie'; -export type AuthUser = UserResponse['user']; +export type { AuthUser }; type AuthState = | { isLoggedIn: true; user: AuthUser; + /** Unix seconds, captured at fetch time; drives the hint-cookie lifetime and query staleness. */ + refreshTokenExpiresAt: number | null; } | { isLoggedIn: false; @@ -49,43 +35,88 @@ type AuthState = export const authStateQueryKey = 'auth-state'; +// A corrupt stored token must degrade to "no value", never throw from a query +// fn or staleTime callback (that would error-page every route, /login included). +const safeJwtDecode = ( + token: string, +): { exp?: number; sub?: string } | null => { + try { + return jwtDecode(token); + } catch { + return null; + } +}; + +const getRefreshTokenExpiry = (): number | null => { + const refreshToken = window.localStorage.getItem('refresh_token'); + if (!refreshToken) { + return null; + } + return safeJwtDecode(refreshToken)?.exp ?? null; +}; + export const authQueryOptions = () => queryOptions({ queryKey: [authStateQueryKey], - queryFn: async () => { - const access_token = window.localStorage.getItem('access_token'); - const refresh_token = window.localStorage.getItem('refresh_token'); - if (!access_token || !refresh_token) { - sessionHintCookie.clear(); - return { isLoggedIn: false } as const; + queryFn: async (): Promise => { + // Associate Sentry events with the user as early as possible, before + // session restore completes. + const accessToken = window.localStorage.getItem('access_token'); + const sub = accessToken ? safeJwtDecode(accessToken)?.sub : undefined; + if (sub) { + Sentry.setUser({ id: sub }); } try { - // We want to set Sentry user id here to make sure that Sentry events are associated with the user as soon as possible. - const { sub } = jwtDecode(access_token); - Sentry.setUser({ id: sub }); + await sdk.init(); + } catch (error) { + // Restore failed with tokens present (e.g. a network blip at boot). + // Boot anonymous; init()'s rejection is not memoized, so a later + // invalidateAuthQueries() retries the restore. + console.error('Failed to restore session', { cause: error }); + Sentry.setUser(null); + sessionHintCookie.clear(); + return { isLoggedIn: false }; + } + const session = sdk.auth.getSession(); - const response = await fetchUser(); + if (!session.isLoggedIn) { + Sentry.setUser(null); + sessionHintCookie.clear(); + return { isLoggedIn: false }; + } - // Set Sentry user again to include the isGuest flag - Sentry.setUser({ id: response.user.id, isGuest: !response.user.email }); + Sentry.setUser({ id: session.user.id, isGuest: !session.user.email }); - // Mirror auth state into a hint cookie so the server can short-circuit - // SSR for unauthenticated visits. Lifetime matches the refresh token - // so we don't leave a stale "logged in" hint after the session - // genuinely expires. - const { exp } = jwtDecode(refresh_token); + // Mirror auth state into a hint cookie so the server can short-circuit + // SSR for unauthenticated visits. Lifetime matches the refresh token + // so we don't leave a stale "logged in" hint after the session + // genuinely expires. + const exp = getRefreshTokenExpiry(); + if (exp) { sessionHintCookie.set(exp - Math.floor(Date.now() / 1000)); + } - return { isLoggedIn: true, user: response.user } as const; - } catch (error) { - console.error('Failed to fetch user', { cause: error }); - Sentry.setUser(null); - sessionHintCookie.clear(); - return { isLoggedIn: false } as const; + return { ...session, refreshTokenExpiresAt: exp }; + }, + // Logged-in state is fresh until the refresh token expires; a refetch + // after that point re-reads the (SDK-extended or ended) session and + // re-syncs the hint cookie. Anonymous state only changes through explicit + // invalidation. Staleness is pinned to the expiry captured AT FETCH TIME + // (not re-read from storage), so an SDK-internal guest extension can't + // slide freshness forward and postpone the cookie re-sync forever. + staleTime: ({ state: { data, dataUpdatedAt } }) => { + if (!data?.isLoggedIn) { + return Number.POSITIVE_INFINITY; + } + if (!data.refreshTokenExpiresAt) { + return 0; } + return Math.max( + (data.refreshTokenExpiresAt - 5) * 1000 - dataUpdatedAt, + 0, + ); }, - staleTime: Number.POSITIVE_INFINITY, }); /** @@ -116,64 +147,19 @@ type SignOutOptions = { }; type AuthActions = { - /** - * Creates a new full user account. Automatically signs in the user after sign up. - * @param email - * @param password - */ signUp: (email: string, password: string) => Promise; - - /** - * Creates a new guest user account. If the user has already signed up as a guest on the same device before, the sign - * in to that account will be performed instead. Automatically signs in the user after sign up. - */ signUpGuest: () => Promise; - - /** - * Signs in the existing user - * @param email - * @param password - */ signIn: (email: string, password: string) => Promise; - - /** - * Signs out the current user - * @param options Options for the sign out - */ signOut: (options?: SignOutOptions) => Promise; - - /** - * Initiates a Google authentication flow - * Returns the auth URL to redirect the user to - */ - initiateGoogleAuth: () => Promise<{ - /** - * The auth URL to redirect the user to to perform the Google authentication flow - */ - authUrl: string; - }>; - - /** - * Verifies the email address - * @param code The code from the email verification - */ + initiateGoogleAuth: () => Promise<{ authUrl: string }>; verifyEmail: (code: string) => Promise; - - /** - * Converts a guest account to a full account - * @param email The email address of the user - * @param password The password of the user - */ convertGuestToFullAccount: (email: string, password: string) => Promise; }; /** - * A hook that provides authentication actions by wrapping functionalities from the OpenSecret SDK. - * The actions include user signing up, signing in, and signing out. - * References for these actions are memoized to ensure consistent references across renders, - * improving performance and preventing unnecessary re-renders or function evaluations. - * - * @returns {AuthActions} + * Authentication actions backed by the wallet SDK, wrapped with the web + * concerns the SDK doesn't own: query invalidation, navigation, Sentry user + * tracking, and the OAuth deep-link session. */ export const useAuthActions = (): AuthActions => { const queryClient = useQueryClient(); @@ -194,7 +180,7 @@ export const useAuthActions = (): AuthActions => { const signUp = useCallback( async (email: string, password: string) => { - await osSignUp(email, password, ''); + await sdk.auth.signUp(email, password); await refreshSession(); }, [refreshSession], @@ -202,42 +188,36 @@ export const useAuthActions = (): AuthActions => { const signIn = useCallback( async (email: string, password: string) => { - await osSignIn(email, password); + await sdk.auth.signIn(email, password); await refreshSession(); }, [refreshSession], ); - const signInGuest = useCallback( - async (id: string, password: string) => { - await osSignInGuest(id, password); - await refreshSession(); - }, - [refreshSession], - ); + const signUpGuest = useCallback(async () => { + await sdk.auth.signUpGuest(); + await refreshSession(); + }, [refreshSession]); const signOut = useCallback( async (options: SignOutOptions = {}) => { - await osSignOut(); + await sdk.auth.signOut(); // Before the refresh below so the previous user's flags are gone even if // the anon re-fetch fails, and so its result isn't clobbered afterwards. resetFeatureFlags(); await refreshSession(options.redirectTo); Sentry.setUser(null); queryClient.clear(); - // The SDK's module-level memos (spark wallet connections, agicash-mint - // CAT) live outside the query cache, so clear them alongside it so the - // next session starts fresh. - clearSparkWallets(); - clearAgicashMintAuthToken(); }, [refreshSession, queryClient], ); const initiateGoogleAuth = useCallback(async () => { - const response = await osInitiateGoogleAuth(''); + const { authUrl } = await sdk.auth.initiateGoogleAuth(); - const authLocation = new URL(response.auth_url); + // Stash the current location under a session id and thread it through the + // OAuth state param, so the callback route can restore the deep link. + const authLocation = new URL(authUrl); const stateParam = authLocation.searchParams.get('state'); const state = stateParam ? JSON.parse(new TextDecoder().decode(decodeURLSafe(stateParam))) @@ -257,28 +237,9 @@ export const useAuthActions = (): AuthActions => { return { authUrl: authLocation.href }; }, []); - const signUpGuest = useCallback(async () => { - const existingGuestAccount = guestAccountStorage.get(); - if (existingGuestAccount) { - return signInGuest( - existingGuestAccount.id, - existingGuestAccount.password, - ); - } - - const createGuestAccount = async () => { - const password = await generateRandomPassword(32); - const guestAccount = await osSignUpGuest(password, ''); - guestAccountStorage.store({ id: guestAccount.id, password }); - await refreshSession(); - }; - - await createGuestAccount(); - }, [signInGuest, refreshSession]); - const verifyEmail = useCallback( async (code: string) => { - await osVerifyEmail(code); + await sdk.auth.verifyEmail(code); await refreshSession(); }, [refreshSession], @@ -286,7 +247,7 @@ export const useAuthActions = (): AuthActions => { const convertGuestToFullAccount = useCallback( async (email: string, password: string) => { - await osConvertGuestToFullAccount(email, password); + await sdk.auth.convertGuestToFullAccount(email, password); await refreshSession(); }, [refreshSession], @@ -315,94 +276,35 @@ export const useSignOut = () => { return { isSigningOut: loading, signOut: handleSignOut }; }; -type OpenSecretJwt = { - /** - * Token expiration time. It's a unix timestamp in seconds - */ - exp: number; - - /** - * Time when the token was issues. It's a unix timestamp in seconds - */ - iat: number; - - /** - * ID of the logged-in user - */ - sub: string; - - /** - * Audience - */ - aud: 'access' | 'refresh'; -}; - -const accessTokenKey = 'access_token'; -const refreshTokenKey = 'refresh_token'; - -const getJwt = (key: string): OpenSecretJwt | null => { - const jwt = localStorage.getItem(key); - if (!jwt) { - return null; - } - return jwtDecode(jwt); -}; - -const removeKeys = () => { - localStorage.removeItem(accessTokenKey); - localStorage.removeItem(refreshTokenKey); - sessionHintCookie.clear(); -}; - -const getRefreshToken = () => getJwt(refreshTokenKey); - -const getRemainingSessionTimeInMs = ( - token: OpenSecretJwt | null, -): number | null => { - if (!token) { - return null; - } - // We are treating the session as expired 5 seconds before the actual expiry just in case - const fiveSecondsBeforeExpiry = token.exp - 5; - const fiveSecondsBeforeExpiryInMs = fiveSecondsBeforeExpiry * 1000; - const remainingTime = fiveSecondsBeforeExpiryInMs - Date.now(); - return Math.max(remainingTime, 0); -}; - -type HandleSessionExpiryProps = { - isGuestAccount: boolean; - onLogout: () => void; -}; - -export const useHandleSessionExpiry = ({ - isGuestAccount, - onLogout, -}: HandleSessionExpiryProps) => { - const { signUpGuest: extendGuestSession, signOut } = useAuthActions(); - const refreshToken = getRefreshToken(); - const remainingSessionTime = getRemainingSessionTimeInMs(refreshToken); - - const handleSessionExpiry = async () => { - try { - if (isGuestAccount) { - // Extend guest session will get new extended access and refresh token from Open Secret. The OS code can be seen - // here https://github.com/OpenSecretCloud/OpenSecret-SDK/blob/master/src/lib/main.tsx#L441. Because setState is - // called after this method is executed the new render will be triggered and useHandleSessionExpiry will be - // executed again which will result in new session expiry timeout being set. - await extendGuestSession(); - } else { - onLogout(); - // Open secret is already handling potential errors in signOut method and removes the keys from the storage so - // in that case our catch should never be triggered, which is fine. We are leaving it there for the guest use - // case and just in case. - await signOut(); - } - } catch (e) { - console.error('Failed to handle session expiry', { cause: e }); - removeKeys(); - window.location.reload(); - } - }; - - useLongTimeout(handleSessionExpiry, remainingSessionTime); +/** + * Reacts to SDK-initiated session transitions the host didn't trigger. + * Expiry (refresh-token death with failed/impossible extension): notifies the + * user and resets the web session state. Refresh (guest auto-extension): + * re-runs the auth query so the session-hint cookie picks up the new expiry, + * matching master's extend-through-invalidation behavior. + */ +export const useHandleSessionEvents = (onSessionExpired: () => void) => { + const queryClient = useQueryClient(); + const { revalidate } = useRevalidator(); + const onSessionExpiredRef = useLatest(onSessionExpired); + + useEffect(() => { + const unsubscribeExpired = sdk.events.on('auth.session-expired', () => { + void (async () => { + onSessionExpiredRef.current(); + resetFeatureFlags(); + await invalidateAuthQueries(); + await revalidate(); + Sentry.setUser(null); + queryClient.clear(); + })(); + }); + const unsubscribeRefreshed = sdk.events.on('auth.session-refreshed', () => { + void invalidateAuthQueries(); + }); + return () => { + unsubscribeExpired(); + unsubscribeRefreshed(); + }; + }, [queryClient, revalidate]); }; diff --git a/apps/web-wallet/app/features/wallet/wallet.tsx b/apps/web-wallet/app/features/wallet/wallet.tsx index a76549083..e867b6fa3 100644 --- a/apps/web-wallet/app/features/wallet/wallet.tsx +++ b/apps/web-wallet/app/features/wallet/wallet.tsx @@ -4,7 +4,7 @@ import { useToast } from '~/hooks/use-toast'; import { useSupabaseRealtimeActivityTracking } from '~/lib/supabase'; import { agicashRealtimeClient } from '../agicash-db/database.client'; import { useTheme } from '../theme'; -import { useHandleSessionExpiry } from '../user/auth'; +import { useHandleSessionEvents } from '../user/auth'; import { useUser } from '../user/user-hooks'; import { TaskProcessor, useTakeTaskProcessingLead } from './task-processing'; import { useTrackAndUpdateSparkAccountBalances } from './use-track-spark-account-balances'; @@ -38,15 +38,12 @@ export const Wallet = ({ children }: PropsWithChildren) => { // Logout handles clearing Sentry user on actual logout. }, [user]); - useHandleSessionExpiry({ - isGuestAccount: user.isGuest, - onLogout: () => { - toast({ - title: 'Session expired', - description: - 'The session has expired. You will be redirected to the login page.', - }); - }, + useHandleSessionEvents(() => { + toast({ + title: 'Session expired', + description: + 'The session has expired. You will be redirected to the login page.', + }); }); useSyncThemeWithDefaultCurrency(); diff --git a/apps/web-wallet/app/hooks/use-long-timeout.ts b/apps/web-wallet/app/hooks/use-long-timeout.ts deleted file mode 100644 index 4409ea972..000000000 --- a/apps/web-wallet/app/hooks/use-long-timeout.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { clearLongTimeout, setLongTimeout } from '@agicash/utils'; -import { useEffect } from 'react'; -import { useLatest } from '~/lib/use-latest'; - -/** - * Custom hook that handles long timeouts in React components using the `setLongTimeout API`. - * The code of useLongTimeout is copied from [`usehooks-ts` lib](https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useTimeout/useTimeout.ts) - * and calls to setTimeout and clearTimeout were replaced with long timeout alternatives. - * Support arbitrary delays. - * @param {() => void} callback - The function to be executed when the timeout elapses. - * @param {number | null} delay - The duration (in milliseconds) for the timeout. Set to `null` to clear the timeout. - * @returns {void} This hook does not return anything. - * @public - * @example - * ```tsx - * // Usage of useLongTimeout hook - * useLongTimeout(() => { - * // Code to be executed after the specified delay - * }, 1000); // Set a timeout of 1000 milliseconds (1 second) - * ``` - */ -export function useLongTimeout( - callback: () => void, - delay: number | null, -): void { - // Remember the latest callback if it changes. - const savedCallback = useLatest(callback); - - // Set up the timeout. - useEffect(() => { - // Don't schedule if no delay is specified. - // Note: 0 is a valid value for delay. - if (!delay && delay !== 0) { - return; - } - - const id = setLongTimeout(() => { - savedCallback.current(); - }, delay); - - return () => { - clearLongTimeout(id); - }; - }, [delay]); -} diff --git a/apps/web-wallet/app/lib/password-generator.ts b/apps/web-wallet/app/lib/password-generator.ts deleted file mode 100644 index 326e42871..000000000 --- a/apps/web-wallet/app/lib/password-generator.ts +++ /dev/null @@ -1,40 +0,0 @@ -interface PasswordOptions { - letters?: boolean; - numbers?: boolean; - special?: boolean; -} - -export async function generateRandomPassword( - length = 24, - options: PasswordOptions = { letters: true, numbers: true, special: true }, -): Promise { - if (window.getMockPassword) { - const password = await window.getMockPassword(); - if (password) { - return password; - } - } - - let charset = ''; - - if (options.letters) - charset += 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - if (options.numbers) charset += '0123456789'; - if (options.special) charset += '!@#$%^&*()_+~'; - - if (!charset) { - throw new Error( - 'At least one character set (letters, numbers, special) must be selected.', - ); - } - - const password: string[] = []; - - for (let i = 0; i < length; i++) { - const randomIndex = - window.crypto.getRandomValues(new Uint32Array(1))[0] % charset.length; - password.push(charset[randomIndex]); - } - - return password.join(''); -} diff --git a/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx b/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx index 9dbf0a371..16f1a8614 100644 --- a/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx +++ b/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx @@ -1,7 +1,7 @@ -import { handleGoogleCallback } from '@agicash/opensecret'; import { decodeURLSafe } from '@stablelib/base64'; import { redirect } from 'react-router'; import { LoadingScreen } from '~/features/loading/LoadingScreen'; +import { sdk } from '~/features/shared/sdk.client'; import { invalidateAuthQueries } from '~/features/user/auth'; import { oauthLoginSessionStorage } from '~/features/user/oauth-login-session-storage'; import { toast } from '~/hooks/use-toast'; @@ -38,7 +38,7 @@ export async function clientLoader({ try { switch (provider) { case 'google': - await handleGoogleCallback(code, state, ''); + await sdk.auth.completeGoogleAuth({ code, state }); break; default: { throw new UnsupportedOAuthProviderError( diff --git a/apps/web-wallet/app/routes/_protected.tsx b/apps/web-wallet/app/routes/_protected.tsx index 16151a124..4b743885c 100644 --- a/apps/web-wallet/app/routes/_protected.tsx +++ b/apps/web-wallet/app/routes/_protected.tsx @@ -1,5 +1,6 @@ import type { User } from '@agicash/wallet-sdk'; import { shouldAcceptTerms } from '@agicash/wallet-sdk'; +import type { AuthUser } from '@agicash/wallet-sdk'; import { AccountRepository, BASE_CASHU_LOCKING_DERIVATION_PATH, @@ -27,11 +28,7 @@ import { sparkIdentityPublicKeyQueryOptions, sparkMnemonicQueryOptions, } from '~/features/shared/spark-query-options'; -import { - type AuthUser, - authQueryOptions, - useAuthState, -} from '~/features/user/auth'; +import { authQueryOptions, useAuthState } from '~/features/user/auth'; import { pendingGiftCardMintTermsStorage, pendingWalletTermsStorage, From 3b3e98c44271cfc9a9a238d477ba243c1f69a868 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Thu, 9 Jul 2026 20:36:53 +0200 Subject: [PATCH 12/49] refactor(web-wallet): route user reads and writes through sdk.user Co-Authored-By: Claude Fable 5 --- .../features/user/guest-account-storage.ts | 44 --------- .../app/features/user/user-hooks.tsx | 93 ++++++------------- .../features/user/user-repository-hooks.ts | 13 --- .../app/features/user/user-service-hooks.ts | 7 -- .../_protected.receive.cashu_.token.tsx | 15 +-- 5 files changed, 32 insertions(+), 140 deletions(-) delete mode 100644 apps/web-wallet/app/features/user/guest-account-storage.ts delete mode 100644 apps/web-wallet/app/features/user/user-repository-hooks.ts delete mode 100644 apps/web-wallet/app/features/user/user-service-hooks.ts diff --git a/apps/web-wallet/app/features/user/guest-account-storage.ts b/apps/web-wallet/app/features/user/guest-account-storage.ts deleted file mode 100644 index b856f5c09..000000000 --- a/apps/web-wallet/app/features/user/guest-account-storage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { safeJsonParse } from '@agicash/utils'; -import { z } from 'zod/mini'; - -const storageKey = 'guestAccount'; - -const GuestAccountDetailsSchema = z.object({ - id: z.string(), - password: z.string(), -}); - -type GuestAccountDetails = z.infer; - -const getGuestAccount = (): GuestAccountDetails | null => { - const dataString = localStorage.getItem(storageKey); - if (!dataString) { - return null; - } - const parseResult = safeJsonParse(dataString); - if (!parseResult.success) { - return null; - } - const validationResult = GuestAccountDetailsSchema.safeParse( - parseResult.data, - ); - if (!validationResult.success) { - console.warn('Invalid guest account data found in the storage'); - return null; - } - return validationResult.data; -}; - -const storeGuestAccount = (data: GuestAccountDetails) => { - localStorage.setItem(storageKey, JSON.stringify(data)); -}; - -const removeGuestAccount = () => { - localStorage.removeItem(storageKey); -}; - -export const guestAccountStorage = { - get: getGuestAccount, - store: storeGuestAccount, - clear: removeGuestAccount, -}; diff --git a/apps/web-wallet/app/features/user/user-hooks.tsx b/apps/web-wallet/app/features/user/user-hooks.tsx index 16edb1c30..9ce16d670 100644 --- a/apps/web-wallet/app/features/user/user-hooks.tsx +++ b/apps/web-wallet/app/features/user/user-hooks.tsx @@ -1,7 +1,6 @@ import type { Currency } from '@agicash/money'; -import { requestNewVerificationCode } from '@agicash/opensecret'; import type { Account, User } from '@agicash/wallet-sdk'; -import type { AgicashDbUser, UpdateUser } from '@agicash/wallet-sdk/temporary'; +import type { AgicashDbUser } from '@agicash/wallet-sdk/temporary'; import { ReadUserRepository } from '@agicash/wallet-sdk/temporary'; import { type QueryClient, @@ -11,14 +10,9 @@ import { import { useQueryClient } from '@tanstack/react-query'; import { useCallback, useMemo } from 'react'; import { getQueryClient } from '~/features/shared/query-client'; +import { sdk } from '~/features/shared/sdk.client'; import { useAuthActions, useAuthState } from '~/features/user/auth'; import { useLatest } from '~/lib/use-latest'; -import { guestAccountStorage } from './guest-account-storage'; -import { - useReadUserRepository, - useWriteUserRepository, -} from './user-repository-hooks'; -import { useUserService } from './user-service-hooks'; export class UserCache { public static Key = 'user'; @@ -71,16 +65,12 @@ export const getUserFromCacheOrThrow = () => { }; const userQueryOptions = ({ - userId, - userRepository, select, }: { - userId: string; - userRepository: ReadUserRepository; select?: (data: User) => TData; }) => ({ queryKey: [UserCache.Key], - queryFn: () => userRepository.get(userId), + queryFn: () => sdk.user.get(), select, }); @@ -93,16 +83,11 @@ export const useUser = ( select?: (data: User) => TData, ): TData => { const authState = useAuthState(); - const authUser = authState.user; - if (!authUser) { + if (!authState.user) { throw new Error('Cannot use useUser hook in anonymous context'); } - const userRepository = useReadUserRepository(); - - const { data } = useSuspenseQuery( - userQueryOptions({ userId: authUser.id, userRepository, select }), - ); + const { data } = useSuspenseQuery(userQueryOptions({ select })); return data; }; @@ -164,12 +149,7 @@ export const useUpgradeGuestToFullAccount = (): (( throw new Error('User already has a full account'); } - return convertGuestToFullAccount( - variables.email, - variables.password, - ).then(() => { - guestAccountStorage.clear(); - }); + return convertGuestToFullAccount(variables.email, variables.password); }, scope: { id: 'upgrade-guest-to-full-account', @@ -195,7 +175,7 @@ export const useRequestNewEmailVerificationCode = (): (() => Promise) => { throw new Error('Email is already verified'); } - return requestNewVerificationCode(); + return sdk.auth.requestNewVerificationCode(); }, scope: { id: 'request-new-email-verification-code', @@ -228,13 +208,13 @@ export const useVerifyEmail = (): ((code: string) => Promise) => { return mutateAsync; }; -const useUpdateUser = () => { +const useUserUpdatingMutation = ( + mutationFn: (variables: TVariables) => Promise, +) => { const queryClient = useQueryClient(); - const userId = useUser((user) => user.id); - const userRepository = useWriteUserRepository(); return useMutation({ - mutationFn: (updates: UpdateUser) => userRepository.update(userId, updates), + mutationFn, onSuccess: (data) => { queryClient.setQueryData([UserCache.Key], data); }, @@ -242,53 +222,34 @@ const useUpdateUser = () => { }; export const useSetDefaultCurrency = () => { - const { mutateAsync: updateUser } = useUpdateUser(); - - return useCallback( - (currency: Currency) => updateUser({ defaultCurrency: currency }), - [updateUser], + const { mutateAsync } = useUserUpdatingMutation((currency: Currency) => + sdk.user.setDefaultCurrency({ currency }), ); + + return mutateAsync; }; export const useSetDefaultAccount = () => { - const userService = useUserService(); - const user = useUserRef(); - const queryClient = useQueryClient(); - - const { mutateAsync } = useMutation({ - mutationFn: (account: Account) => - userService.setDefaultAccount(user.current, account), - onSuccess: (data) => { - queryClient.setQueryData([UserCache.Key], data); - }, - }); + const { mutateAsync } = useUserUpdatingMutation((account: Account) => + sdk.user.setDefaultAccount({ accountId: account.id }), + ); return mutateAsync; }; export const useUpdateUsername = () => { - const { mutateAsync: updateUser } = useUpdateUser(); - - return useCallback( - (username: string) => updateUser({ username }), - [updateUser], + const { mutateAsync } = useUserUpdatingMutation((username: string) => + sdk.user.updateUsername(username), ); + + return mutateAsync; }; export const useAcceptTerms = () => { - const { mutateAsync: updateUser } = useUpdateUser(); - - return useCallback( - ({ - walletTerms, - giftCardTerms, - }: { walletTerms?: boolean; giftCardTerms?: boolean }) => { - const now = new Date().toISOString(); - const updates: UpdateUser = {}; - if (walletTerms) updates.termsAcceptedAt = now; - if (giftCardTerms) updates.giftCardMintTermsAcceptedAt = now; - return updateUser(updates); - }, - [updateUser], + const { mutateAsync } = useUserUpdatingMutation( + (params: { walletTerms?: boolean; giftCardTerms?: boolean }) => + sdk.user.acceptTerms(params), ); + + return mutateAsync; }; diff --git a/apps/web-wallet/app/features/user/user-repository-hooks.ts b/apps/web-wallet/app/features/user/user-repository-hooks.ts deleted file mode 100644 index bb95b5d59..000000000 --- a/apps/web-wallet/app/features/user/user-repository-hooks.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { - ReadUserRepository, - WriteUserRepository, -} from '@agicash/wallet-sdk/temporary'; -import { agicashDbClient } from '~/features/agicash-db/database.client'; - -export function useReadUserRepository() { - return new ReadUserRepository(agicashDbClient); -} - -export function useWriteUserRepository() { - return new WriteUserRepository(agicashDbClient); -} diff --git a/apps/web-wallet/app/features/user/user-service-hooks.ts b/apps/web-wallet/app/features/user/user-service-hooks.ts deleted file mode 100644 index 192d9a080..000000000 --- a/apps/web-wallet/app/features/user/user-service-hooks.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { UserService } from '@agicash/wallet-sdk/temporary'; -import { useWriteUserRepository } from './user-repository-hooks'; - -export function useUserService() { - const userRepository = useWriteUserRepository(); - return new UserService(userRepository); -} diff --git a/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx index fa63cf49b..30b83bdab 100644 --- a/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx +++ b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx @@ -13,7 +13,6 @@ import { SparkReceiveQuoteRepository, SparkReceiveQuoteService, UserService, - WriteUserRepository, decodeCashuToken, getEncryption, } from '@agicash/wallet-sdk/temporary'; @@ -39,6 +38,7 @@ import { encryptionPublicKeyQueryOptions, } from '~/features/shared/encryption-hooks'; import { getQueryClient } from '~/features/shared/query-client'; +import { sdk } from '~/features/shared/sdk.client'; import { sparkMnemonicQueryOptions } from '~/features/shared/spark-query-options'; import { UserCache, getUserFromCacheOrThrow } from '~/features/user/user-hooks'; import { getExchangeRate } from '~/hooks/use-exchange-rate'; @@ -89,9 +89,6 @@ const getServices = async () => { cashuReceiveQuoteService, sparkReceiveQuoteService, ); - const userRepository = new WriteUserRepository(agicashDbClient); - const userService = new UserService(userRepository); - const claimCashuTokenService = new ClaimCashuTokenService( accountService, receiveSwapService, @@ -102,7 +99,7 @@ const getServices = async () => { (ticker) => getExchangeRate(queryClient, ticker), ); - return { claimCashuTokenService, accountRepository, userService }; + return { claimCashuTokenService, accountRepository }; }; /** @@ -112,7 +109,6 @@ const getServices = async () => { * UX, so it lives here rather than in the claim service. */ async function trySetReceiveAccountAsDefault( - userService: UserService, queryClient: QueryClient, user: User, account: Account, @@ -124,7 +120,8 @@ async function trySetReceiveAccountAsDefault( return; } try { - const updatedUser = await userService.setDefaultAccount(user, account, { + const updatedUser = await sdk.user.setDefaultAccount({ + accountId: account.id, setDefaultCurrency: true, }); new UserCache(queryClient).set(updatedUser); @@ -170,8 +167,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { if (claimTo) { const user = getUserFromCacheOrThrow(); - const { claimCashuTokenService, accountRepository, userService } = - await getServices(); + const { claimCashuTokenService, accountRepository } = await getServices(); const queryClient = getQueryClient(); const accounts = await queryClient.fetchQuery( accountsQueryOptions({ userId: user.id, accountRepository }), @@ -192,7 +188,6 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { accountsCache.upsert(account); } await trySetReceiveAccountAsDefault( - userService, queryClient, user, result.receiveAccount, From 6c5b6ae09e398007953efba24f6f39d07210849f Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Fri, 10 Jul 2026 10:40:42 +0200 Subject: [PATCH 13/49] fix(wallet-sdk): fence stale restore applies and skip restore on undecodable refresh tokens A restore whose fetchUser resolved after a concurrent auth verb (or session end) could clobber the newer session with the stale result; a session generation counter now gates the apply. A present-but-undecodable refresh token now restores anonymous instead of establishing a session the expiry machinery can neither time out nor refresh. Co-Authored-By: Claude Fable 5 --- .../domain/user/auth-service.test.ts | 57 +++++++++++++++++++ .../wallet-sdk/domain/user/auth-service.ts | 29 +++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts index 0729e2036..cdccc518f 100644 --- a/packages/wallet-sdk/domain/user/auth-service.test.ts +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -170,6 +170,53 @@ describe('AuthService', () => { service.teardown(); }); + it('stays anonymous when the stored refresh token is undecodable', async () => { + const { service, storage, calls } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', 'not-a-jwt'); + + await service.restoreSession(); + + expect(service.getSession().isLoggedIn).toBe(false); + expect(calls).not.toContain('fetchUser'); + }); + + it('does not clobber a session a verb established while the restore fetch was in flight', async () => { + let releaseRestoreFetch = (): void => undefined; + const restoreFetchGate = new Promise((resolve) => { + releaseRestoreFetch = resolve; + }); + const verbUser = { ...fullUser, id: 'user-verb' }; + let fetchCalls = 0; + const { service, storage } = createService({ + os: { + fetchUser: async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + await restoreFetchGate; + return { user: fullUser }; + } + return { user: verbUser }; + }, + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + const restore = service.restoreSession(); + // Flush microtasks so the restore's gated fetchUser is in flight first. + await new Promise((resolve) => setTimeout(resolve, 0)); + await service.signIn('verb@b.c', 'pw'); + releaseRestoreFetch(); + await restore; + + expect(service.getSession()).toEqual({ + isLoggedIn: true, + user: verbUser, + }); + service.teardown(); + }); + it('is single-flight', async () => { const { service, storage, calls } = createService(); storage.persistent.data.set('access_token', createJwt(600)); @@ -387,4 +434,14 @@ describe('AuthService', () => { authUrl: 'https://accounts.google/x', }); }); + + it('completeGoogleAuth establishes the session', async () => { + const { service, calls } = createService(); + + await service.completeGoogleAuth({ code: 'auth-code', state: 'state' }); + + expect(calls).toContain('handleGoogleCallback'); + expect(service.getSession()).toEqual({ isLoggedIn: true, user: fullUser }); + service.teardown(); + }); }); diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index f767f965d..f27c51c18 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -73,6 +73,10 @@ export class AuthService implements AuthApi { private expiryTimeout: LongTimeout | undefined; // Survives endSession() deliberately — see applySessionFromServer. private lastUserId: string | undefined; + // Bumped on every session transition (login apply or session end). A + // restore captures it before its user fetch; a result from a generation + // that has passed must not apply, or it would clobber the newer session. + private sessionGeneration = 0; constructor(private readonly deps: AuthServiceDeps) {} @@ -101,8 +105,16 @@ export class AuthService implements AuthApi { if (!accessToken || !refreshToken) { return; } + if (!decodeJwt(refreshToken)?.exp) { + // An undecodable (or exp-less) refresh token can't arm the expiry + // machinery and can't be refreshed — the restored session would be + // unmanaged and die unrecoverably mid-use. Restore anonymous instead. + return; + } try { - await this.applySessionFromServer(); + await this.applySessionFromServer({ + expectedGeneration: this.sessionGeneration, + }); } catch (error) { if (this.session.isLoggedIn) { // An auth verb established a session while this restore was in @@ -203,8 +215,19 @@ export class AuthService implements AuthApi { } } - private async applySessionFromServer(): Promise { + private async applySessionFromServer(options?: { + /** Apply only while the session generation still matches; a speculative caller (restore) passes the generation it observed. */ + expectedGeneration?: number; + }): Promise { const response = await this.deps.os.fetchUser(); + if ( + options?.expectedGeneration !== undefined && + options.expectedGeneration !== this.sessionGeneration + ) { + // A verb or session end won while this fetch was in flight; the stale + // result must not overwrite the newer session state. + return; + } // Compared against the last seen user rather than the live session: a // memo repopulated by a request that resolved after sign-out must still // be wiped when a DIFFERENT user's session begins, and by then the @@ -213,11 +236,13 @@ export class AuthService implements AuthApi { this.deps.onSessionEnded?.(); } this.lastUserId = response.user.id; + this.sessionGeneration += 1; this.session = { isLoggedIn: true, user: response.user }; await this.armExpiryTimer(); } private endSession(): void { + this.sessionGeneration += 1; this.session = { isLoggedIn: false }; this.disarmExpiryTimer(); // Un-memoize the restore so the next init() re-evaluates from storage — From 7b1e4cc7fb016a6aa2a4468486c37f7628318e01 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Fri, 10 Jul 2026 10:41:04 +0200 Subject: [PATCH 14/49] fix(web-wallet): catch up on session expiry missed before mount and add a reload fallback The SDK arms its expiry timer during init(), so an expiry can fire before Wallet's event subscription mounts and emit to no subscribers; the hook now detects the already-dead SDK session on mount and runs the expiry handling. The handler also gets a hard window.location.reload() fallback so an unexpected failure mid-reset can't strand the user on a blank page. Co-Authored-By: Claude Fable 5 --- apps/web-wallet/app/features/user/auth.ts | 25 ++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/web-wallet/app/features/user/auth.ts b/apps/web-wallet/app/features/user/auth.ts index bae0654f3..c3e329bd8 100644 --- a/apps/web-wallet/app/features/user/auth.ts +++ b/apps/web-wallet/app/features/user/auth.ts @@ -289,7 +289,7 @@ export const useHandleSessionEvents = (onSessionExpired: () => void) => { const onSessionExpiredRef = useLatest(onSessionExpired); useEffect(() => { - const unsubscribeExpired = sdk.events.on('auth.session-expired', () => { + const handleSessionExpired = () => { void (async () => { onSessionExpiredRef.current(); resetFeatureFlags(); @@ -297,11 +297,30 @@ export const useHandleSessionEvents = (onSessionExpired: () => void) => { await revalidate(); Sentry.setUser(null); queryClient.clear(); - })(); - }); + })().catch((error) => { + // Hard fallback: the SDK already ended the session, so a reload + // boots anonymous even when the soft reset above fails mid-flight. + console.error('Failed to handle session expiry', { cause: error }); + window.location.reload(); + }); + }; + + const unsubscribeExpired = sdk.events.on( + 'auth.session-expired', + handleSessionExpired, + ); const unsubscribeRefreshed = sdk.events.on('auth.session-refreshed', () => { void invalidateAuthQueries(); }); + + // The SDK arms its expiry timer during init(), before this subscription + // exists; an expiry firing in that window emitted to no subscribers. + // This hook mounts only under an authenticated layout, so an already-dead + // SDK session here means exactly that missed event — handle it now. + if (!sdk.auth.getSession().isLoggedIn) { + handleSessionExpired(); + } + return () => { unsubscribeExpired(); unsubscribeRefreshed(); From 10cbac719aab86b2b23c80b31c78566c6da54b33 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Fri, 10 Jul 2026 13:47:26 +0200 Subject: [PATCH 15/49] refactor(wallet-sdk): split the sdk.ts contract into per-namespace files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each migration slice now settles its types in its own sdk/.ts instead of growing one contract file (19 placeholders across steps 6-16 remain to land); cross-cutting pieces (SdkConfig, Sdk, Logger) stay in sdk/index.ts. Pure move — the exported surface is unchanged, and existing './sdk' import specifiers resolve to the directory index as before. Co-Authored-By: Claude Fable 5 --- packages/wallet-sdk/sdk.ts | 449 ----------------------- packages/wallet-sdk/sdk/accounts.ts | 24 ++ packages/wallet-sdk/sdk/auth.ts | 52 +++ packages/wallet-sdk/sdk/background.ts | 21 ++ packages/wallet-sdk/sdk/contacts.ts | 13 + packages/wallet-sdk/sdk/events.ts | 75 ++++ packages/wallet-sdk/sdk/feature-flags.ts | 8 + packages/wallet-sdk/sdk/index.ts | 104 ++++++ packages/wallet-sdk/sdk/receive.ts | 50 +++ packages/wallet-sdk/sdk/send.ts | 45 +++ packages/wallet-sdk/sdk/server.ts | 48 +++ packages/wallet-sdk/sdk/transactions.ts | 18 + packages/wallet-sdk/sdk/transfer.ts | 10 + packages/wallet-sdk/sdk/user.ts | 25 ++ 14 files changed, 493 insertions(+), 449 deletions(-) delete mode 100644 packages/wallet-sdk/sdk.ts create mode 100644 packages/wallet-sdk/sdk/accounts.ts create mode 100644 packages/wallet-sdk/sdk/auth.ts create mode 100644 packages/wallet-sdk/sdk/background.ts create mode 100644 packages/wallet-sdk/sdk/contacts.ts create mode 100644 packages/wallet-sdk/sdk/events.ts create mode 100644 packages/wallet-sdk/sdk/feature-flags.ts create mode 100644 packages/wallet-sdk/sdk/index.ts create mode 100644 packages/wallet-sdk/sdk/receive.ts create mode 100644 packages/wallet-sdk/sdk/send.ts create mode 100644 packages/wallet-sdk/sdk/server.ts create mode 100644 packages/wallet-sdk/sdk/transactions.ts create mode 100644 packages/wallet-sdk/sdk/transfer.ts create mode 100644 packages/wallet-sdk/sdk/user.ts diff --git a/packages/wallet-sdk/sdk.ts b/packages/wallet-sdk/sdk.ts deleted file mode 100644 index 68ab26503..000000000 --- a/packages/wallet-sdk/sdk.ts +++ /dev/null @@ -1,449 +0,0 @@ -// Public contract of @agicash/wallet-sdk. Prose contract: -// docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md -import type { - LNURLError, - LNURLPayParams, - LNURLPayResult, - LNURLVerifyResult, -} from '@agicash/lnurl'; -import type { Currency, Money } from '@agicash/money'; -import type { UserResponse } from '@agicash/opensecret'; -import type { SparkNetwork } from './db/json-models/spark-account-details-db-data'; -import type { - CashuAccount as DomainCashuAccount, - SparkAccount as DomainSparkAccount, -} from './domain/accounts/account'; -import type { Contact as DomainContact } from './domain/contacts/contact'; -import type { CashuReceiveQuote as DomainCashuReceiveQuote } from './domain/receive/cashu-receive-quote'; -import type { CashuReceiveLightningQuote } from './domain/receive/cashu-receive-quote-core'; -import type { CashuReceiveSwap as DomainCashuReceiveSwap } from './domain/receive/cashu-receive-swap'; -import type { SparkReceiveQuote as DomainSparkReceiveQuote } from './domain/receive/spark-receive-quote'; -import type { SparkReceiveLightningQuote } from './domain/receive/spark-receive-quote-core'; -import type { CashuSendQuote as DomainCashuSendQuote } from './domain/send/cashu-send-quote'; -import type { CashuLightningQuote } from './domain/send/cashu-send-quote-service'; -import type { CashuSendSwap as DomainCashuSendSwap } from './domain/send/cashu-send-swap'; -import type { CashuSwapQuote } from './domain/send/cashu-send-swap-service'; -import type { SparkSendQuote as DomainSparkSendQuote } from './domain/send/spark-send-quote'; -import type { SparkLightningQuote } from './domain/send/spark-send-quote-service'; -import type { Transaction as DomainTransaction } from './domain/transactions/transaction'; -import type { Cursor } from './domain/transactions/transaction-repository'; -import type { TransferQuote } from './domain/transfer/transfer-service'; -import type { User } from './domain/user/user'; -import type { SdkError } from './lib/error'; -import type { FeatureFlag } from './lib/feature-flag-service'; -import type { DestinationDetails } from './lib/send-destination'; - -export type { Cursor }; - -// Public projections of the domain entities: `userId`/`ownerId` are implicit -// from the session; raw wallet handles and proof material stay internal. - -/** Carries `balance` on every rail, never a raw wallet handle or proof material. */ -export type CashuAccount = Omit< - DomainCashuAccount, - 'keysetCounters' | 'proofs' | 'wallet' -> & { balance: Money | null }; -export type SparkAccount = Omit; -export type Account = CashuAccount | SparkAccount; - -export type Contact = Omit; -export type Transaction = Omit; -export type CashuReceiveQuote = Omit; -export type SparkReceiveQuote = Omit; -export type CashuReceiveSwap = Omit; -export type CashuSendQuote = Omit; -export type CashuSendSwap = Omit< - DomainCashuSendSwap, - 'inputProofs' | 'proofsToSend' | 'userId' ->; -export type SparkSendQuote = Omit; - -/** - * Minimal key/value store — the Web Storage API subset the SDK persists auth - * state through. Methods may be sync (window.localStorage) or async (React - * Native AsyncStorage, SQLite); the SDK always awaits results. Matches the - * @agicash/opensecret StorageProvider interface verbatim, so one host object - * backs both. - */ -export type AuthKeyValueStore = { - getItem(key: string): string | null | Promise; - setItem(key: string, value: string): void | Promise; - removeItem(key: string): void | Promise; -}; - -/** - * Host-backed session persistence. `persistent` must survive restarts (auth - * tokens, guest credentials); `session` is per-app-session (attestation - * handshake material). Browser hosts map them to localStorage/sessionStorage. - */ -export type AuthStorage = { - persistent: AuthKeyValueStore; - session: AuthKeyValueStore; -}; - -/** Diagnostic sink; the SDK never writes to the console directly. */ -export type Logger = { - debug(message: string, meta?: unknown): void; - info(message: string, meta?: unknown): void; - warn(message: string, meta?: unknown): void; - error(message: string, meta?: unknown): void; -}; - -export type SdkConfig = { - db: { - url: string; - anonKey: string; - }; - auth: { - apiUrl: string; - clientId: string; - storage: AuthStorage; - /** - * Host override for guest credential generation; resolve null to use the - * SDK's CSPRNG generator. Test seam (the web bridges its e2e password - * mock through it). - */ - generateGuestPassword?: () => Promise; - }; - spark: { - breezApiKey: string; - /** Default for account creation; the persisted per-account value is authoritative. */ - network: SparkNetwork; - /** Node hosts; browser default applies. */ - storageDir?: string; - }; - /** lud16 domain. */ - lightningAddressDomain: string; - logger?: Logger; -}; - -export type Sdk = { - readonly auth: AuthApi; - readonly user: UserApi; - readonly accounts: AccountsApi; - readonly contacts: ContactsApi; - readonly transactions: TransactionsApi; - readonly receive: ReceiveApi; - readonly send: SendApi; - readonly transfer: TransferApi; - readonly featureFlags: FeatureFlagsApi; - readonly events: WalletEvents; - readonly background: BackgroundApi; - /** - * Front-loads session restore and the Breez WASM load. Resolves when no - * session exists (a state, not a failure); rejects on actual failures, - * e.g. `WebAssemblyUnavailableError`. Required before any Spark operation — - * the SDK does not lazy-load the WASM, so Spark calls without a completed - * `init()` throw a typed `SdkError`. Non-Spark usage lazy-initializes on - * first use. - * - * Migration note: until the first Spark slice lands, `init()` performs - * session restore only — the WASM load still runs host-side. - */ - init(): Promise; - /** - * Awaits in-flight background transitions to their next checkpoint, then - * tears down realtime + background; still-pending namespace promises reject - * with a typed `SdkError`. - */ - dispose(): Promise; -}; - -/** `create` is sync; no I/O. */ -export type SdkConstructor = { - create(config: SdkConfig): Sdk; -}; - -export type AuthUser = UserResponse['user']; - -export type AuthSession = - | { isLoggedIn: true; user: AuthUser } - | { isLoggedIn: false }; - -export type AuthApi = { - /** Creates a full account and signs the user in. */ - signUp(email: string, password: string): Promise; - /** Re-signs-in this device's prior guest account if one exists. */ - signUpGuest(): Promise; - signIn(email: string, password: string): Promise; - /** - * Stops background, tears down realtime, clears the stored session; the - * instance stays usable in anonymous state. - */ - signOut(): Promise; - verifyEmail(code: string): Promise; - requestNewVerificationCode(): Promise; - convertGuestToFullAccount(email: string, password: string): Promise; - /** Returns the URL to redirect to. */ - initiateGoogleAuth(): Promise<{ authUrl: string }>; - /** OAuth callback leg. */ - completeGoogleAuth(params: { code: string; state: string }): Promise; - /** Sync snapshot; no I/O. */ - getSession(): AuthSession; -}; - -export type UserApi = { - get(): Promise; - updateUsername(username: string): Promise; - acceptTerms(params: AcceptTermsParams): Promise; - setDefaultAccount(params: SetDefaultAccountParams): Promise; - setDefaultCurrency(params: SetDefaultCurrencyParams): Promise; -}; - -export type AccountsApi = { - get(id: string): Promise; - /** Active accounts of the current user. */ - list(): Promise; - cashu: { - add(params: AddCashuAccountParams): Promise; - }; -}; - -export type ContactsApi = { - get(id: string): Promise; - list(): Promise; - create(params: CreateContactParams): Promise; - delete(id: string): Promise; - findContactCandidates(query: string): Promise; -}; - -export type TransactionsApi = { - get(id: string): Promise; - list(params: { - /** Opaque pagination token from a previous page's `nextCursor`. */ - cursor?: Cursor; - pageSize?: number; - accountId?: string; - }): Promise<{ transactions: Transaction[]; nextCursor: Cursor | null }>; - countPendingAck(): Promise; - acknowledge(transactionId: string): Promise; -}; - -/** - * `get*` methods are stateless previews; `create*` methods persist and enter - * the entity into the background lifecycle. Completion is observed via - * `events`, never called by the host. - */ -export type ReceiveApi = { - cashu: { - getLightningQuote( - params: GetCashuReceiveLightningQuoteParams, - ): Promise; - createQuote( - params: CreateCashuReceiveQuoteParams, - ): Promise; - getQuote(id: string): Promise; - }; - spark: { - getLightningQuote( - params: GetSparkReceiveLightningQuoteParams, - ): Promise; - createQuote( - params: CreateSparkReceiveQuoteParams, - ): Promise; - getQuote(id: string): Promise; - }; - cashuToken: { - getQuote( - params: GetReceiveCashuTokenQuoteParams, - ): Promise; - claim(params: ClaimCashuTokenParams): Promise; - }; -}; - -export type SendApi = { - resolveDestination(input: string): Promise; - cashu: { - getLightningQuote( - params: GetCashuSendLightningQuoteParams, - ): Promise; - createQuote( - params: CreateCashuSendQuoteParams, - ): Promise<{ transactionId: string }>; - /** Send-to-token. */ - getSwapQuote(params: GetCashuSwapQuoteParams): Promise; - createSwap(params: CreateCashuSwapParams): Promise; - }; - spark: { - getLightningQuote( - params: GetSparkSendLightningQuoteParams, - ): Promise; - createQuote( - params: CreateSparkSendQuoteParams, - ): Promise<{ transactionId: string }>; - }; -}; - -export type TransferApi = { - /** Stateless preview. */ - getQuote(params: GetTransferQuoteParams): Promise; // public projection of TransferQuote settles in step 16 - initiate(params: InitiateTransferParams): Promise<{ transactionId: string }>; -}; - -/** Flags are a process-local cached read — the one no-cache exception. */ -export type FeatureFlagsApi = { - get(flag: FeatureFlag): boolean; - /** Cache-change signal; returns unsubscribe. */ - subscribe(listener: () => void): () => void; -}; - -export type BackgroundState = 'stopped' | 'follower' | 'leader' | 'error'; - -/** - * Execution is background-only: a host must run `start()` somewhere or - * nothing moves money. The executing instance may differ from the initiating - * one (the leader lock is per-user across devices). - */ -export type BackgroundApi = { - /** - * Leader election + processors. - * @throws {SdkError} when no authenticated session exists. - */ - start(): void; - /** - * Stops claiming new work immediately, awaits in-flight iterations to their - * next checkpoint (bounded by a timeout), releases the leader lock, and - * abandons the remaining queue. - */ - stop(): Promise; - readonly state: BackgroundState; -}; - -/** - * Payloads are decrypted domain objects. Naming: `.`, verbs per - * entity; terminal transitions arrive as `updated` with the new state on the - * payload. Adding events is non-breaking; renaming is breaking. - */ -export type WalletEventMap = { - /** The session died without a `signOut()` call (expiry / failed refresh). */ - 'auth.session-expired': Record; - /** - * The SDK refreshed the session without a host-initiated verb — today: - * guest auto-extension at refresh-token expiry. Host-initiated verbs never - * fire it (the host knows its own actions). Hosts re-sync session-derived - * state from it (the web: auth query + session-hint cookie). - */ - 'auth.session-refreshed': Record; - 'user.updated': { user: User }; - 'account.created': { account: Account }; - /** A persisted row changed; the payload carries a `version` consumers gate on. */ - 'account.updated': { account: Account }; - /** Versionless balance signal from both rails; spark's only balance path. */ - 'account.balance-changed': { accountId: string; balance: Money }; - 'contact.created': { contact: Contact }; - 'contact.deleted': { contact: Contact }; - 'transaction.created': { transaction: Transaction }; - 'transaction.updated': { transaction: Transaction }; - 'cashu-receive-quote.created': { quote: CashuReceiveQuote }; - 'cashu-receive-quote.updated': { quote: CashuReceiveQuote }; - 'cashu-receive-swap.created': { swap: CashuReceiveSwap }; - 'cashu-receive-swap.updated': { swap: CashuReceiveSwap }; - 'spark-receive-quote.created': { quote: SparkReceiveQuote }; - 'spark-receive-quote.updated': { quote: SparkReceiveQuote }; - 'cashu-send-quote.created': { quote: CashuSendQuote }; - 'cashu-send-quote.updated': { quote: CashuSendQuote }; - 'cashu-send-swap.created': { swap: CashuSendSwap }; - 'cashu-send-swap.updated': { swap: CashuSendSwap }; - 'spark-send-quote.created': { quote: SparkSendQuote }; - 'spark-send-quote.updated': { quote: SparkSendQuote }; - /** - * Emits on every transition into `connected`, including the initial - * connection — the invalidate-all signal. `error` is terminal: the channel - * is dead after retries exhaust, distinct from a long `reconnecting`. - */ - 'connection.changed': { state: 'connected' | 'reconnecting' | 'error' }; - /** - * Fires on every `state` transition; `error` set on transitions into - * `'error'`. Per-task errors never change state, so they never fire it. - */ - 'background.state-changed': { state: BackgroundState; error?: SdkError }; -}; - -/** - * `on()` only registers a handler and is callable with no session; the - * per-user realtime channel is established when a session comes into - * existence (login, or `init()` session restore). Returns unsubscribe. - */ -export type WalletEvents = { - on( - event: K, - handler: (payload: WalletEventMap[K]) => void, - ): () => void; -}; - -/** - * Server-side trust model: service-role key, no user session, per-request - * scope. No `auth`, no `events`, no `background`. - */ -export type ServerSdkConfig = { - db: { url: string; serviceRoleKey: string }; - spark: { - breezApiKey: string; - network: SparkNetwork; - mnemonic: string; - storageDir: string; - }; - /** Hex; encrypts LNURL verify payloads. */ - quoteEncryptionKey: string; -}; - -export type ServerSdk = { - readonly lightningAddress: { - handleLud16Request(params: { - username: string; - baseUrl: string; - }): Promise; - handleLnurlpCallback(params: { - userId: string; - amount: Money<'BTC'>; - baseUrl: string; - /** Per-request by design — instance state would race on the per-process singleton. */ - bypassAmountValidation?: boolean; - }): Promise; - handleLnurlpVerify(params: { - encryptedQuoteData: string; - }): Promise; - }; -}; - -/** Singleton per process. */ -export type ServerSdkConstructor = { - create(config: ServerSdkConfig): ServerSdk; -}; - -// Settles in step N — pinned by that slice PR to the public projection of -// today's service types. - -export type AcceptTermsParams = { - walletTerms?: boolean; - giftCardTerms?: boolean; -}; - -export type SetDefaultAccountParams = { - accountId: string; - /** Also switch the user's default currency to the account's currency. */ - setDefaultCurrency?: boolean; -}; - -export type SetDefaultCurrencyParams = { - currency: Currency; -}; -export type AddCashuAccountParams = unknown; // step 6 (accounts) -export type CreateContactParams = unknown; // step 7 (contacts) -export type GetCashuReceiveLightningQuoteParams = unknown; // step 9 (cashu receive quote) -export type CreateCashuReceiveQuoteParams = unknown; // step 9 (cashu receive quote) -export type GetSparkReceiveLightningQuoteParams = unknown; // step 11 (spark receive quote) -export type CreateSparkReceiveQuoteParams = unknown; // step 11 (spark receive quote) -export type GetReceiveCashuTokenQuoteParams = unknown; // step 12 (receive cashu token) -export type ReceiveCashuTokenQuote = unknown; // step 12 (receive cashu token) -export type ClaimCashuTokenParams = unknown; // step 12 (receive cashu token) -export type ClaimCashuTokenResult = unknown; // step 12 (receive cashu token) -export type GetCashuSendLightningQuoteParams = unknown; // step 13 (cashu send quote) -export type CreateCashuSendQuoteParams = unknown; // step 13 (cashu send quote) -export type GetCashuSwapQuoteParams = unknown; // step 14 (cashu send swap) -export type CreateCashuSwapParams = unknown; // step 14 (cashu send swap) -export type CreateCashuSwapResult = unknown; // step 14 (cashu send swap) -export type GetSparkSendLightningQuoteParams = unknown; // step 15 (spark send quote) -export type CreateSparkSendQuoteParams = unknown; // step 15 (spark send quote) -export type GetTransferQuoteParams = unknown; // step 16 (transfer) -export type InitiateTransferParams = unknown; // step 16 (transfer) diff --git a/packages/wallet-sdk/sdk/accounts.ts b/packages/wallet-sdk/sdk/accounts.ts new file mode 100644 index 000000000..4978d2c67 --- /dev/null +++ b/packages/wallet-sdk/sdk/accounts.ts @@ -0,0 +1,24 @@ +import type { Money } from '@agicash/money'; +import type { + CashuAccount as DomainCashuAccount, + SparkAccount as DomainSparkAccount, +} from '../domain/accounts/account'; + +/** Carries `balance` on every rail, never a raw wallet handle or proof material. */ +export type CashuAccount = Omit< + DomainCashuAccount, + 'keysetCounters' | 'proofs' | 'wallet' +> & { balance: Money | null }; +export type SparkAccount = Omit; +export type Account = CashuAccount | SparkAccount; + +export type AccountsApi = { + get(id: string): Promise; + /** Active accounts of the current user. */ + list(): Promise; + cashu: { + add(params: AddCashuAccountParams): Promise; + }; +}; + +export type AddCashuAccountParams = unknown; // step 6 (accounts) diff --git a/packages/wallet-sdk/sdk/auth.ts b/packages/wallet-sdk/sdk/auth.ts new file mode 100644 index 000000000..dc576c723 --- /dev/null +++ b/packages/wallet-sdk/sdk/auth.ts @@ -0,0 +1,52 @@ +import type { UserResponse } from '@agicash/opensecret'; + +/** + * Minimal key/value store — the Web Storage API subset the SDK persists auth + * state through. Methods may be sync (window.localStorage) or async (React + * Native AsyncStorage, SQLite); the SDK always awaits results. Matches the + * @agicash/opensecret StorageProvider interface verbatim, so one host object + * backs both. + */ +export type AuthKeyValueStore = { + getItem(key: string): string | null | Promise; + setItem(key: string, value: string): void | Promise; + removeItem(key: string): void | Promise; +}; + +/** + * Host-backed session persistence. `persistent` must survive restarts (auth + * tokens, guest credentials); `session` is per-app-session (attestation + * handshake material). Browser hosts map them to localStorage/sessionStorage. + */ +export type AuthStorage = { + persistent: AuthKeyValueStore; + session: AuthKeyValueStore; +}; + +export type AuthUser = UserResponse['user']; + +export type AuthSession = + | { isLoggedIn: true; user: AuthUser } + | { isLoggedIn: false }; + +export type AuthApi = { + /** Creates a full account and signs the user in. */ + signUp(email: string, password: string): Promise; + /** Re-signs-in this device's prior guest account if one exists. */ + signUpGuest(): Promise; + signIn(email: string, password: string): Promise; + /** + * Stops background, tears down realtime, clears the stored session; the + * instance stays usable in anonymous state. + */ + signOut(): Promise; + verifyEmail(code: string): Promise; + requestNewVerificationCode(): Promise; + convertGuestToFullAccount(email: string, password: string): Promise; + /** Returns the URL to redirect to. */ + initiateGoogleAuth(): Promise<{ authUrl: string }>; + /** OAuth callback leg. */ + completeGoogleAuth(params: { code: string; state: string }): Promise; + /** Sync snapshot; no I/O. */ + getSession(): AuthSession; +}; diff --git a/packages/wallet-sdk/sdk/background.ts b/packages/wallet-sdk/sdk/background.ts new file mode 100644 index 000000000..7e24a752f --- /dev/null +++ b/packages/wallet-sdk/sdk/background.ts @@ -0,0 +1,21 @@ +export type BackgroundState = 'stopped' | 'follower' | 'leader' | 'error'; + +/** + * Execution is background-only: a host must run `start()` somewhere or + * nothing moves money. The executing instance may differ from the initiating + * one (the leader lock is per-user across devices). + */ +export type BackgroundApi = { + /** + * Leader election + processors. + * @throws {SdkError} when no authenticated session exists. + */ + start(): void; + /** + * Stops claiming new work immediately, awaits in-flight iterations to their + * next checkpoint (bounded by a timeout), releases the leader lock, and + * abandons the remaining queue. + */ + stop(): Promise; + readonly state: BackgroundState; +}; diff --git a/packages/wallet-sdk/sdk/contacts.ts b/packages/wallet-sdk/sdk/contacts.ts new file mode 100644 index 000000000..48d631239 --- /dev/null +++ b/packages/wallet-sdk/sdk/contacts.ts @@ -0,0 +1,13 @@ +import type { Contact as DomainContact } from '../domain/contacts/contact'; + +export type Contact = Omit; + +export type ContactsApi = { + get(id: string): Promise; + list(): Promise; + create(params: CreateContactParams): Promise; + delete(id: string): Promise; + findContactCandidates(query: string): Promise; +}; + +export type CreateContactParams = unknown; // step 7 (contacts) diff --git a/packages/wallet-sdk/sdk/events.ts b/packages/wallet-sdk/sdk/events.ts new file mode 100644 index 000000000..b90447536 --- /dev/null +++ b/packages/wallet-sdk/sdk/events.ts @@ -0,0 +1,75 @@ +import type { Money } from '@agicash/money'; +import type { User } from '../domain/user/user'; +import type { SdkError } from '../lib/error'; +import type { Account } from './accounts'; +import type { BackgroundState } from './background'; +import type { Contact } from './contacts'; +import type { + CashuReceiveQuote, + CashuReceiveSwap, + SparkReceiveQuote, +} from './receive'; +import type { CashuSendQuote, CashuSendSwap, SparkSendQuote } from './send'; +import type { Transaction } from './transactions'; + +/** + * Payloads are decrypted domain objects. Naming: `.`, verbs per + * entity; terminal transitions arrive as `updated` with the new state on the + * payload. Adding events is non-breaking; renaming is breaking. + */ +export type WalletEventMap = { + /** The session died without a `signOut()` call (expiry / failed refresh). */ + 'auth.session-expired': Record; + /** + * The SDK refreshed the session without a host-initiated verb — today: + * guest auto-extension at refresh-token expiry. Host-initiated verbs never + * fire it (the host knows its own actions). Hosts re-sync session-derived + * state from it (the web: auth query + session-hint cookie). + */ + 'auth.session-refreshed': Record; + 'user.updated': { user: User }; + 'account.created': { account: Account }; + /** A persisted row changed; the payload carries a `version` consumers gate on. */ + 'account.updated': { account: Account }; + /** Versionless balance signal from both rails; spark's only balance path. */ + 'account.balance-changed': { accountId: string; balance: Money }; + 'contact.created': { contact: Contact }; + 'contact.deleted': { contact: Contact }; + 'transaction.created': { transaction: Transaction }; + 'transaction.updated': { transaction: Transaction }; + 'cashu-receive-quote.created': { quote: CashuReceiveQuote }; + 'cashu-receive-quote.updated': { quote: CashuReceiveQuote }; + 'cashu-receive-swap.created': { swap: CashuReceiveSwap }; + 'cashu-receive-swap.updated': { swap: CashuReceiveSwap }; + 'spark-receive-quote.created': { quote: SparkReceiveQuote }; + 'spark-receive-quote.updated': { quote: SparkReceiveQuote }; + 'cashu-send-quote.created': { quote: CashuSendQuote }; + 'cashu-send-quote.updated': { quote: CashuSendQuote }; + 'cashu-send-swap.created': { swap: CashuSendSwap }; + 'cashu-send-swap.updated': { swap: CashuSendSwap }; + 'spark-send-quote.created': { quote: SparkSendQuote }; + 'spark-send-quote.updated': { quote: SparkSendQuote }; + /** + * Emits on every transition into `connected`, including the initial + * connection — the invalidate-all signal. `error` is terminal: the channel + * is dead after retries exhaust, distinct from a long `reconnecting`. + */ + 'connection.changed': { state: 'connected' | 'reconnecting' | 'error' }; + /** + * Fires on every `state` transition; `error` set on transitions into + * `'error'`. Per-task errors never change state, so they never fire it. + */ + 'background.state-changed': { state: BackgroundState; error?: SdkError }; +}; + +/** + * `on()` only registers a handler and is callable with no session; the + * per-user realtime channel is established when a session comes into + * existence (login, or `init()` session restore). Returns unsubscribe. + */ +export type WalletEvents = { + on( + event: K, + handler: (payload: WalletEventMap[K]) => void, + ): () => void; +}; diff --git a/packages/wallet-sdk/sdk/feature-flags.ts b/packages/wallet-sdk/sdk/feature-flags.ts new file mode 100644 index 000000000..4ce1fa153 --- /dev/null +++ b/packages/wallet-sdk/sdk/feature-flags.ts @@ -0,0 +1,8 @@ +import type { FeatureFlag } from '../lib/feature-flag-service'; + +/** Flags are a process-local cached read — the one no-cache exception. */ +export type FeatureFlagsApi = { + get(flag: FeatureFlag): boolean; + /** Cache-change signal; returns unsubscribe. */ + subscribe(listener: () => void): () => void; +}; diff --git a/packages/wallet-sdk/sdk/index.ts b/packages/wallet-sdk/sdk/index.ts new file mode 100644 index 000000000..032ecf207 --- /dev/null +++ b/packages/wallet-sdk/sdk/index.ts @@ -0,0 +1,104 @@ +// Public contract of @agicash/wallet-sdk, one file per namespace. Prose +// contract: docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +// +// The entity types the namespaces expose are public projections of the domain +// entities: `userId`/`ownerId` are implicit from the session; raw wallet +// handles and proof material stay internal. +import type { SparkNetwork } from '../db/json-models/spark-account-details-db-data'; +import type { AccountsApi } from './accounts'; +import type { AuthApi, AuthStorage } from './auth'; +import type { BackgroundApi } from './background'; +import type { ContactsApi } from './contacts'; +import type { WalletEvents } from './events'; +import type { FeatureFlagsApi } from './feature-flags'; +import type { ReceiveApi } from './receive'; +import type { SendApi } from './send'; +import type { TransactionsApi } from './transactions'; +import type { TransferApi } from './transfer'; +import type { UserApi } from './user'; + +export * from './accounts'; +export * from './auth'; +export * from './background'; +export * from './contacts'; +export * from './events'; +export * from './feature-flags'; +export * from './receive'; +export * from './send'; +export * from './server'; +export * from './transactions'; +export * from './transfer'; +export * from './user'; + +/** Diagnostic sink; the SDK never writes to the console directly. */ +export type Logger = { + debug(message: string, meta?: unknown): void; + info(message: string, meta?: unknown): void; + warn(message: string, meta?: unknown): void; + error(message: string, meta?: unknown): void; +}; + +export type SdkConfig = { + db: { + url: string; + anonKey: string; + }; + auth: { + apiUrl: string; + clientId: string; + storage: AuthStorage; + /** + * Host override for guest credential generation; resolve null to use the + * SDK's CSPRNG generator. Test seam (the web bridges its e2e password + * mock through it). + */ + generateGuestPassword?: () => Promise; + }; + spark: { + breezApiKey: string; + /** Default for account creation; the persisted per-account value is authoritative. */ + network: SparkNetwork; + /** Node hosts; browser default applies. */ + storageDir?: string; + }; + /** lud16 domain. */ + lightningAddressDomain: string; + logger?: Logger; +}; + +export type Sdk = { + readonly auth: AuthApi; + readonly user: UserApi; + readonly accounts: AccountsApi; + readonly contacts: ContactsApi; + readonly transactions: TransactionsApi; + readonly receive: ReceiveApi; + readonly send: SendApi; + readonly transfer: TransferApi; + readonly featureFlags: FeatureFlagsApi; + readonly events: WalletEvents; + readonly background: BackgroundApi; + /** + * Front-loads session restore and the Breez WASM load. Resolves when no + * session exists (a state, not a failure); rejects on actual failures, + * e.g. `WebAssemblyUnavailableError`. Required before any Spark operation — + * the SDK does not lazy-load the WASM, so Spark calls without a completed + * `init()` throw a typed `SdkError`. Non-Spark usage lazy-initializes on + * first use. + * + * Migration note: until the first Spark slice lands, `init()` performs + * session restore only — the WASM load still runs host-side. + */ + init(): Promise; + /** + * Awaits in-flight background transitions to their next checkpoint, then + * tears down realtime + background; still-pending namespace promises reject + * with a typed `SdkError`. + */ + dispose(): Promise; +}; + +/** `create` is sync; no I/O. */ +export type SdkConstructor = { + create(config: SdkConfig): Sdk; +}; diff --git a/packages/wallet-sdk/sdk/receive.ts b/packages/wallet-sdk/sdk/receive.ts new file mode 100644 index 000000000..607f0458a --- /dev/null +++ b/packages/wallet-sdk/sdk/receive.ts @@ -0,0 +1,50 @@ +import type { CashuReceiveQuote as DomainCashuReceiveQuote } from '../domain/receive/cashu-receive-quote'; +import type { CashuReceiveLightningQuote } from '../domain/receive/cashu-receive-quote-core'; +import type { CashuReceiveSwap as DomainCashuReceiveSwap } from '../domain/receive/cashu-receive-swap'; +import type { SparkReceiveQuote as DomainSparkReceiveQuote } from '../domain/receive/spark-receive-quote'; +import type { SparkReceiveLightningQuote } from '../domain/receive/spark-receive-quote-core'; + +export type CashuReceiveQuote = Omit; +export type SparkReceiveQuote = Omit; +export type CashuReceiveSwap = Omit; + +/** + * `get*` methods are stateless previews; `create*` methods persist and enter + * the entity into the background lifecycle. Completion is observed via + * `events`, never called by the host. + */ +export type ReceiveApi = { + cashu: { + getLightningQuote( + params: GetCashuReceiveLightningQuoteParams, + ): Promise; + createQuote( + params: CreateCashuReceiveQuoteParams, + ): Promise; + getQuote(id: string): Promise; + }; + spark: { + getLightningQuote( + params: GetSparkReceiveLightningQuoteParams, + ): Promise; + createQuote( + params: CreateSparkReceiveQuoteParams, + ): Promise; + getQuote(id: string): Promise; + }; + cashuToken: { + getQuote( + params: GetReceiveCashuTokenQuoteParams, + ): Promise; + claim(params: ClaimCashuTokenParams): Promise; + }; +}; + +export type GetCashuReceiveLightningQuoteParams = unknown; // step 9 (cashu receive quote) +export type CreateCashuReceiveQuoteParams = unknown; // step 9 (cashu receive quote) +export type GetSparkReceiveLightningQuoteParams = unknown; // step 11 (spark receive quote) +export type CreateSparkReceiveQuoteParams = unknown; // step 11 (spark receive quote) +export type GetReceiveCashuTokenQuoteParams = unknown; // step 12 (receive cashu token) +export type ReceiveCashuTokenQuote = unknown; // step 12 (receive cashu token) +export type ClaimCashuTokenParams = unknown; // step 12 (receive cashu token) +export type ClaimCashuTokenResult = unknown; // step 12 (receive cashu token) diff --git a/packages/wallet-sdk/sdk/send.ts b/packages/wallet-sdk/sdk/send.ts new file mode 100644 index 000000000..645146b7e --- /dev/null +++ b/packages/wallet-sdk/sdk/send.ts @@ -0,0 +1,45 @@ +import type { CashuSendQuote as DomainCashuSendQuote } from '../domain/send/cashu-send-quote'; +import type { CashuLightningQuote } from '../domain/send/cashu-send-quote-service'; +import type { CashuSendSwap as DomainCashuSendSwap } from '../domain/send/cashu-send-swap'; +import type { CashuSwapQuote } from '../domain/send/cashu-send-swap-service'; +import type { SparkSendQuote as DomainSparkSendQuote } from '../domain/send/spark-send-quote'; +import type { SparkLightningQuote } from '../domain/send/spark-send-quote-service'; +import type { DestinationDetails } from '../lib/send-destination'; + +export type CashuSendQuote = Omit; +export type CashuSendSwap = Omit< + DomainCashuSendSwap, + 'inputProofs' | 'proofsToSend' | 'userId' +>; +export type SparkSendQuote = Omit; + +export type SendApi = { + resolveDestination(input: string): Promise; + cashu: { + getLightningQuote( + params: GetCashuSendLightningQuoteParams, + ): Promise; + createQuote( + params: CreateCashuSendQuoteParams, + ): Promise<{ transactionId: string }>; + /** Send-to-token. */ + getSwapQuote(params: GetCashuSwapQuoteParams): Promise; + createSwap(params: CreateCashuSwapParams): Promise; + }; + spark: { + getLightningQuote( + params: GetSparkSendLightningQuoteParams, + ): Promise; + createQuote( + params: CreateSparkSendQuoteParams, + ): Promise<{ transactionId: string }>; + }; +}; + +export type GetCashuSendLightningQuoteParams = unknown; // step 13 (cashu send quote) +export type CreateCashuSendQuoteParams = unknown; // step 13 (cashu send quote) +export type GetCashuSwapQuoteParams = unknown; // step 14 (cashu send swap) +export type CreateCashuSwapParams = unknown; // step 14 (cashu send swap) +export type CreateCashuSwapResult = unknown; // step 14 (cashu send swap) +export type GetSparkSendLightningQuoteParams = unknown; // step 15 (spark send quote) +export type CreateSparkSendQuoteParams = unknown; // step 15 (spark send quote) diff --git a/packages/wallet-sdk/sdk/server.ts b/packages/wallet-sdk/sdk/server.ts new file mode 100644 index 000000000..fac6fb754 --- /dev/null +++ b/packages/wallet-sdk/sdk/server.ts @@ -0,0 +1,48 @@ +import type { + LNURLError, + LNURLPayParams, + LNURLPayResult, + LNURLVerifyResult, +} from '@agicash/lnurl'; +import type { Money } from '@agicash/money'; +import type { SparkNetwork } from '../db/json-models/spark-account-details-db-data'; + +/** + * Server-side trust model: service-role key, no user session, per-request + * scope. No `auth`, no `events`, no `background`. + */ +export type ServerSdkConfig = { + db: { url: string; serviceRoleKey: string }; + spark: { + breezApiKey: string; + network: SparkNetwork; + mnemonic: string; + storageDir: string; + }; + /** Hex; encrypts LNURL verify payloads. */ + quoteEncryptionKey: string; +}; + +export type ServerSdk = { + readonly lightningAddress: { + handleLud16Request(params: { + username: string; + baseUrl: string; + }): Promise; + handleLnurlpCallback(params: { + userId: string; + amount: Money<'BTC'>; + baseUrl: string; + /** Per-request by design — instance state would race on the per-process singleton. */ + bypassAmountValidation?: boolean; + }): Promise; + handleLnurlpVerify(params: { + encryptedQuoteData: string; + }): Promise; + }; +}; + +/** Singleton per process. */ +export type ServerSdkConstructor = { + create(config: ServerSdkConfig): ServerSdk; +}; diff --git a/packages/wallet-sdk/sdk/transactions.ts b/packages/wallet-sdk/sdk/transactions.ts new file mode 100644 index 000000000..5d9820af4 --- /dev/null +++ b/packages/wallet-sdk/sdk/transactions.ts @@ -0,0 +1,18 @@ +import type { Transaction as DomainTransaction } from '../domain/transactions/transaction'; +import type { Cursor } from '../domain/transactions/transaction-repository'; + +export type { Cursor }; + +export type Transaction = Omit; + +export type TransactionsApi = { + get(id: string): Promise; + list(params: { + /** Opaque pagination token from a previous page's `nextCursor`. */ + cursor?: Cursor; + pageSize?: number; + accountId?: string; + }): Promise<{ transactions: Transaction[]; nextCursor: Cursor | null }>; + countPendingAck(): Promise; + acknowledge(transactionId: string): Promise; +}; diff --git a/packages/wallet-sdk/sdk/transfer.ts b/packages/wallet-sdk/sdk/transfer.ts new file mode 100644 index 000000000..4b58b42da --- /dev/null +++ b/packages/wallet-sdk/sdk/transfer.ts @@ -0,0 +1,10 @@ +import type { TransferQuote } from '../domain/transfer/transfer-service'; + +export type TransferApi = { + /** Stateless preview. */ + getQuote(params: GetTransferQuoteParams): Promise; // public projection of TransferQuote settles in step 16 + initiate(params: InitiateTransferParams): Promise<{ transactionId: string }>; +}; + +export type GetTransferQuoteParams = unknown; // step 16 (transfer) +export type InitiateTransferParams = unknown; // step 16 (transfer) diff --git a/packages/wallet-sdk/sdk/user.ts b/packages/wallet-sdk/sdk/user.ts new file mode 100644 index 000000000..20c9ce348 --- /dev/null +++ b/packages/wallet-sdk/sdk/user.ts @@ -0,0 +1,25 @@ +import type { Currency } from '@agicash/money'; +import type { User } from '../domain/user/user'; + +export type UserApi = { + get(): Promise; + updateUsername(username: string): Promise; + acceptTerms(params: AcceptTermsParams): Promise; + setDefaultAccount(params: SetDefaultAccountParams): Promise; + setDefaultCurrency(params: SetDefaultCurrencyParams): Promise; +}; + +export type AcceptTermsParams = { + walletTerms?: boolean; + giftCardTerms?: boolean; +}; + +export type SetDefaultAccountParams = { + accountId: string; + /** Also switch the user's default currency to the account's currency. */ + setDefaultCurrency?: boolean; +}; + +export type SetDefaultCurrencyParams = { + currency: Currency; +}; From d5cdb7532485522e8c743f1fec1b59228b22a14e Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Fri, 10 Jul 2026 14:32:47 +0200 Subject: [PATCH 16/49] refactor(wallet-sdk): declare AgicashSdk's contract conformance via Pick The compiler now checks the implemented subset (auth, user, events, init, dispose) against the Sdk contract; each slice adds its namespace to the Pick until it collapses to the full Sdk. Unimplemented namespaces stay absent rather than stubbed, so consuming them early remains a compile error instead of a runtime crash. Co-Authored-By: Claude Fable 5 --- packages/wallet-sdk/agicash-sdk.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/wallet-sdk/agicash-sdk.ts b/packages/wallet-sdk/agicash-sdk.ts index 192b6076f..4832b86e3 100644 --- a/packages/wallet-sdk/agicash-sdk.ts +++ b/packages/wallet-sdk/agicash-sdk.ts @@ -8,14 +8,16 @@ import { clearAgicashMintAuthToken } from './lib/agicash-mint-auth-provider'; import { WalletEventEmitter } from './lib/events'; import { generateRandomPassword } from './lib/password'; import { clearSparkWallets } from './lib/spark/wallet'; -import type { AuthApi, SdkConfig, UserApi, WalletEvents } from './sdk'; +import type { AuthApi, Sdk, SdkConfig, UserApi, WalletEvents } from './sdk'; /** * Runtime implementation of the SDK contract, filled namespace-by-namespace - * as the migration slices land (auth/user/events since step 5). It will - * declare `implements Sdk` once every namespace exists. + * as the migration slices land (auth/user/events since step 5). Each slice + * adds its namespace to the `Pick` until it collapses to the full `Sdk`. */ -export class AgicashSdk { +export class AgicashSdk + implements Pick +{ readonly auth: AuthApi; readonly user: UserApi; readonly events: WalletEvents; From 8dc1c80a810f819622ae6fe8d205f31208ba55c8 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Fri, 10 Jul 2026 14:59:13 +0200 Subject: [PATCH 17/49] feat(wallet-sdk): require the logger port and export nullLogger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host that wants no diagnostics now passes the exported no-op nullLogger instead of omitting the field, so silence is an explicit choice. Internal components take a required Logger too — forgetting to forward one is now a compile error rather than a silently diagnostics-less component. Co-Authored-By: Claude Fable 5 --- .../specs/2026-07-02-wallet-sdk-contract-proposal.md | 7 ++++++- packages/wallet-sdk/domain/user/auth-service.test.ts | 9 +++++++-- packages/wallet-sdk/domain/user/auth-service.ts | 10 +++++----- .../domain/user/guest-account-storage.test.ts | 9 +++++---- .../wallet-sdk/domain/user/guest-account-storage.ts | 4 ++-- packages/wallet-sdk/index.ts | 1 + packages/wallet-sdk/lib/events.test.ts | 5 +++-- packages/wallet-sdk/lib/events.ts | 4 ++-- packages/wallet-sdk/lib/logger.ts | 11 +++++++++++ packages/wallet-sdk/sdk/index.ts | 3 ++- 10 files changed, 44 insertions(+), 19 deletions(-) create mode 100644 packages/wallet-sdk/lib/logger.ts diff --git a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md index e9b943dc2..19af95815 100644 --- a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +++ b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md @@ -44,7 +44,10 @@ type SdkConfig = { storageDir?: string; // node hosts; browser default applies }; lightningAddressDomain: string; // lud16 domain for contacts/display - logger?: Logger; // diagnostic sink; MCP stdio hosts route to stderr + logger: Logger; // diagnostic sink; MCP stdio hosts route to stderr. + // Required; hosts that want no logging pass the + // exported `nullLogger` (explicit choice over a + // silently-absent default). }; // Illustrative shape — binds to the React-agnostic @agicash/opensecret release's @@ -460,6 +463,8 @@ helpers the web consumes that need no instance state: WebAssembly is unavailable; web `instanceof`-checks it for the fallback UI). Subclass semantics are contract: `DomainError.message` is the only user-displayable message; `ConcurrencyError` always means retry. +- logging: `nullLogger` — the no-op `Logger` hosts pass when they want no + diagnostics (the `logger` config port is required) - exchange rate: `exchangeRate` — provider fallback chain (mempool → coingecko → coinbase); holds no instance state or ports, so a rate lookup needs no `Sdk`. diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts index cdccc518f..ba8e2a2df 100644 --- a/packages/wallet-sdk/domain/user/auth-service.test.ts +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { WalletEventEmitter } from '../../lib/events'; +import { nullLogger } from '../../lib/logger'; import type { AuthKeyValueStore, AuthStorage } from '../../sdk'; import { AuthService, type OpenSecretAuthApi } from './auth-service'; import { createGuestAccountStorage } from './guest-account-storage'; @@ -98,14 +99,18 @@ const createService = ( ) => { const storage = options.storage ?? createStorage(); const { os, calls } = createOsFake(storage.persistent, options.os); - const events = new WalletEventEmitter(); + const events = new WalletEventEmitter(nullLogger); const service = new AuthService({ os, storage, - guestAccountStorage: createGuestAccountStorage(storage.persistent), + guestAccountStorage: createGuestAccountStorage( + storage.persistent, + nullLogger, + ), generateGuestPassword: async () => 'generated-pw', events, onSessionEnded: options.onSessionEnded, + logger: nullLogger, }); return { service, storage, calls, events }; }; diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index f27c51c18..2890a1d7e 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -64,7 +64,7 @@ type AuthServiceDeps = { events: WalletEventEmitter; /** Per-session cache cleanup on any session end (sign-out or expiry). */ onSessionEnded?: () => void; - logger?: Logger; + logger: Logger; }; export class AuthService implements AuthApi { @@ -210,7 +210,7 @@ export class AuthService implements AuthApi { // web glue. endSession (not a bare snapshot clear) so the per-session // caches die with the session — the Supabase token cache in particular // must never outlive it. - this.deps.logger?.error(`Failed to fetch user (${context})`, error); + this.deps.logger.error(`Failed to fetch user (${context})`, error); this.endSession(); } } @@ -329,17 +329,17 @@ export class AuthService implements AuthApi { // expired token — also guards a hot extend loop — or a failed // post-extend user fetch), so the death path emits the event instead // of leaving a wedged half-session. - this.deps.logger?.warn( + this.deps.logger.warn( 'Guest session extension did not produce a live session; ending it', ); } catch (error) { - this.deps.logger?.error('Failed to extend guest session', error); + this.deps.logger.error('Failed to extend guest session', error); } } try { await this.deps.os.signOut(); } catch (error) { - this.deps.logger?.warn('Sign out during session expiry failed', error); + this.deps.logger.warn('Sign out during session expiry failed', error); } this.endSession(); this.deps.events.emit('auth.session-expired', {}); diff --git a/packages/wallet-sdk/domain/user/guest-account-storage.test.ts b/packages/wallet-sdk/domain/user/guest-account-storage.test.ts index fdbbe3600..15c7c3507 100644 --- a/packages/wallet-sdk/domain/user/guest-account-storage.test.ts +++ b/packages/wallet-sdk/domain/user/guest-account-storage.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'bun:test'; +import { nullLogger } from '../../lib/logger'; import type { AuthKeyValueStore } from '../../sdk'; import { createGuestAccountStorage } from './guest-account-storage'; @@ -21,7 +22,7 @@ const createMemoryStore = (): AuthKeyValueStore & { describe('guestAccountStorage', () => { it('round-trips guest account details under the legacy key', async () => { const store = createMemoryStore(); - const storage = createGuestAccountStorage(store); + const storage = createGuestAccountStorage(store, nullLogger); await storage.store({ id: 'guest-1', password: 'pw' }); @@ -30,14 +31,14 @@ describe('guestAccountStorage', () => { }); it('returns null when nothing is stored', async () => { - const storage = createGuestAccountStorage(createMemoryStore()); + const storage = createGuestAccountStorage(createMemoryStore(), nullLogger); expect(await storage.get()).toBeNull(); }); it('returns null for corrupt or invalid data', async () => { const store = createMemoryStore(); store.data.set('guestAccount', 'not-json'); - const storage = createGuestAccountStorage(store); + const storage = createGuestAccountStorage(store, nullLogger); expect(await storage.get()).toBeNull(); store.data.set('guestAccount', JSON.stringify({ id: 42 })); @@ -46,7 +47,7 @@ describe('guestAccountStorage', () => { it('clear removes the stored account', async () => { const store = createMemoryStore(); - const storage = createGuestAccountStorage(store); + const storage = createGuestAccountStorage(store, nullLogger); await storage.store({ id: 'guest-1', password: 'pw' }); await storage.clear(); diff --git a/packages/wallet-sdk/domain/user/guest-account-storage.ts b/packages/wallet-sdk/domain/user/guest-account-storage.ts index 7390d7a3c..8405927d2 100644 --- a/packages/wallet-sdk/domain/user/guest-account-storage.ts +++ b/packages/wallet-sdk/domain/user/guest-account-storage.ts @@ -21,7 +21,7 @@ export type GuestAccountStorage = { export function createGuestAccountStorage( store: AuthKeyValueStore, - logger?: Logger, + logger: Logger, ): GuestAccountStorage { return { async get() { @@ -37,7 +37,7 @@ export function createGuestAccountStorage( parseResult.data, ); if (!validationResult.success) { - logger?.warn('Invalid guest account data found in the storage'); + logger.warn('Invalid guest account data found in the storage'); return null; } return validationResult.data; diff --git a/packages/wallet-sdk/index.ts b/packages/wallet-sdk/index.ts index a371aa460..91d593744 100644 --- a/packages/wallet-sdk/index.ts +++ b/packages/wallet-sdk/index.ts @@ -9,6 +9,7 @@ // projections. export * from './sdk'; export { AgicashSdk } from './agicash-sdk'; +export { nullLogger } from './lib/logger'; export { ConcurrencyError, DomainError, diff --git a/packages/wallet-sdk/lib/events.test.ts b/packages/wallet-sdk/lib/events.test.ts index 8283d8c02..d5467fa4e 100644 --- a/packages/wallet-sdk/lib/events.test.ts +++ b/packages/wallet-sdk/lib/events.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'bun:test'; import { WalletEventEmitter } from './events'; +import { nullLogger } from './logger'; describe('WalletEventEmitter', () => { it('delivers payloads to subscribed handlers', () => { - const emitter = new WalletEventEmitter(); + const emitter = new WalletEventEmitter(nullLogger); const received: unknown[] = []; emitter.on('auth.session-expired', (payload) => received.push(payload)); @@ -13,7 +14,7 @@ describe('WalletEventEmitter', () => { }); it('stops delivering after unsubscribe', () => { - const emitter = new WalletEventEmitter(); + const emitter = new WalletEventEmitter(nullLogger); let calls = 0; const unsubscribe = emitter.on('auth.session-expired', () => { calls += 1; diff --git a/packages/wallet-sdk/lib/events.ts b/packages/wallet-sdk/lib/events.ts index 6d1b74793..e4921d336 100644 --- a/packages/wallet-sdk/lib/events.ts +++ b/packages/wallet-sdk/lib/events.ts @@ -5,7 +5,7 @@ type Handler = (payload: never) => void; export class WalletEventEmitter implements WalletEvents { private readonly handlers = new Map>(); - constructor(private readonly logger?: Logger) {} + constructor(private readonly logger: Logger) {} on( event: K, @@ -31,7 +31,7 @@ export class WalletEventEmitter implements WalletEvents { try { (handler as (payload: WalletEventMap[K]) => void)(payload); } catch (error) { - this.logger?.error(`Event handler for ${event} threw`, error); + this.logger.error(`Event handler for ${event} threw`, error); } } } diff --git a/packages/wallet-sdk/lib/logger.ts b/packages/wallet-sdk/lib/logger.ts new file mode 100644 index 000000000..95afd1950 --- /dev/null +++ b/packages/wallet-sdk/lib/logger.ts @@ -0,0 +1,11 @@ +import type { Logger } from '../sdk'; + +const noop = () => undefined; + +/** Discards all diagnostics — for hosts that want no logging. */ +export const nullLogger: Logger = { + debug: noop, + info: noop, + warn: noop, + error: noop, +}; diff --git a/packages/wallet-sdk/sdk/index.ts b/packages/wallet-sdk/sdk/index.ts index 032ecf207..728063690 100644 --- a/packages/wallet-sdk/sdk/index.ts +++ b/packages/wallet-sdk/sdk/index.ts @@ -63,7 +63,8 @@ export type SdkConfig = { }; /** lud16 domain. */ lightningAddressDomain: string; - logger?: Logger; + /** Diagnostic sink; hosts that want no logging pass the package's `nullLogger` export. */ + logger: Logger; }; export type Sdk = { From 1780f59956cd794c06a5edb9fe470146469f4426 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Fri, 10 Jul 2026 15:46:37 +0200 Subject: [PATCH 18/49] fix(wallet-sdk): make AuthService.teardown terminal for the expiry machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fetchUser continuation resolving after teardown (restore or verb) could re-arm the expiry timer on a disposed instance, resurrecting the zombie timer the HMR dispose hook exists to prevent. A disposed flag now blocks re-arming and a late-firing timer callback; verbs and the session snapshot are unaffected — disposal is not logout. Co-Authored-By: Claude Fable 5 --- .../domain/user/auth-service.test.ts | 34 +++++++++++++++++++ .../wallet-sdk/domain/user/auth-service.ts | 17 +++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts index ba8e2a2df..561c2b2d8 100644 --- a/packages/wallet-sdk/domain/user/auth-service.test.ts +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -232,6 +232,40 @@ describe('AuthService', () => { expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); service.teardown(); }); + + it('does not re-arm the expiry timer when the restore resolves after teardown', async () => { + let releaseRestoreFetch = (): void => undefined; + const restoreFetchGate = new Promise((resolve) => { + releaseRestoreFetch = resolve; + }); + const { service, storage, events } = createService({ + os: { + fetchUser: async () => { + await restoreFetchGate; + return { user: fullUser }; + }, + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + // Refresh token already inside the exp-5s window: a timer armed by the + // late-resolving restore would fire immediately and expire the session. + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + const restore = service.restoreSession(); + // Flush microtasks so the gated fetchUser is in flight before teardown. + await new Promise((resolve) => setTimeout(resolve, 0)); + service.teardown(); + releaseRestoreFetch(); + await restore; + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(expired).toHaveLength(0); + // The stale apply still lands (teardown is not logout); only the + // expiry machinery stays off. + expect(service.getSession().isLoggedIn).toBe(true); + }); }); describe('signUpGuest', () => { diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index 2890a1d7e..148f35cd0 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -77,6 +77,7 @@ export class AuthService implements AuthApi { // restore captures it before its user fetch; a result from a generation // that has passed must not apply, or it would clobber the newer session. private sessionGeneration = 0; + private disposed = false; constructor(private readonly deps: AuthServiceDeps) {} @@ -196,8 +197,13 @@ export class AuthService implements AuthApi { await this.refreshSessionSnapshot('google auth'); } - /** Cancels the expiry timer; the instance stays usable. */ + /** + * Terminally disarms the expiry machinery: cancels the timer and prevents + * in-flight continuations (a restore, a verb, a fired timer) from re-arming + * it. Auth verbs still work afterwards — disposal is not logout. + */ teardown(): void { + this.disposed = true; this.disarmExpiryTimer(); } @@ -254,10 +260,11 @@ export class AuthService implements AuthApi { private async armExpiryTimer(): Promise { const remaining = await this.getRemainingSessionTimeMs(); - // Disarm only after the await, so disarm+assign form one synchronous - // block — two overlapping arms can't interleave and orphan a timer. + // Disarm only after the await, so disarm+check+assign form one + // synchronous block — two overlapping arms can't interleave and orphan a + // timer, and a teardown during the await can't be re-armed past. this.disarmExpiryTimer(); - if (remaining === null) { + if (this.disposed || remaining === null) { return; } // Floor of 1ms: setLongTimeout fires synchronously at delay 0, which @@ -297,7 +304,7 @@ export class AuthService implements AuthApi { private async handleSessionExpiry(): Promise { const session = this.session; - if (!session.isLoggedIn) { + if (this.disposed || !session.isLoggedIn) { return; } // The Open Secret SDK rotates the refresh token during its internal From 4a8afe5cfc904644bfaba9d3b24bb29c0c76a1c9 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Fri, 10 Jul 2026 15:46:45 +0200 Subject: [PATCH 19/49] fix(web-wallet): return the dispose promise to Vite's HMR hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite awaits a promise returned from hot.dispose, so the old SDK's teardown is guaranteed to complete before the replacement module constructs the new instance — which matters once dispose() gains async teardown work. Co-Authored-By: Claude Fable 5 --- apps/web-wallet/app/features/shared/sdk.client.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web-wallet/app/features/shared/sdk.client.ts b/apps/web-wallet/app/features/shared/sdk.client.ts index 46b4701f8..491029edd 100644 --- a/apps/web-wallet/app/features/shared/sdk.client.ts +++ b/apps/web-wallet/app/features/shared/sdk.client.ts @@ -51,6 +51,7 @@ export const sdk = AgicashSdk.create({ if (import.meta.hot) { // A hot reload of this module constructs a second SDK; dispose the old one - // so its expiry timer doesn't leak. - import.meta.hot.dispose(() => void sdk.dispose()); + // so its expiry timer doesn't leak. Returning the promise makes Vite await + // the teardown before evaluating the replacement module. + import.meta.hot.dispose(() => sdk.dispose()); } From 337e878483bc06cc105c4ad73c34bb2410cb9e57 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Fri, 10 Jul 2026 16:25:59 +0200 Subject: [PATCH 20/49] refactor(web-wallet): extract the shared session-end cleanup from signOut and the expiry handler Both ran the same order-sensitive sequence (flags reset, auth invalidation, navigate/revalidate, Sentry clear, queryClient.clear) with the ordering rationale documented on only one of them; useSessionEndCleanup now owns the sequence and its invariants, while each caller keeps its own edges (the sdk.auth.signOut() call and redirect target vs the toast and reload fallback). Co-Authored-By: Claude Fable 5 --- apps/web-wallet/app/features/user/auth.ts | 56 +++++++++++++++-------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/apps/web-wallet/app/features/user/auth.ts b/apps/web-wallet/app/features/user/auth.ts index c3e329bd8..a3066c58e 100644 --- a/apps/web-wallet/app/features/user/auth.ts +++ b/apps/web-wallet/app/features/user/auth.ts @@ -139,6 +139,36 @@ export const useAuthState = (): AuthState => { return data; }; +/** + * The web-side counterpart of the SDK's session end: forgets all + * session-derived web state after a session ends (sign-out or expiry). + * Ordering is load-bearing: the flags reset first, so the previous user's + * flags are gone even if the anonymous re-fetch fails and can't clobber its + * result; queryClient.clear() runs last, once navigation/revalidation has + * settled, so the still-mounted protected tree doesn't lose its suspended + * query data mid-transition. + */ +const useSessionEndCleanup = () => { + const queryClient = useQueryClient(); + const { revalidate } = useRevalidator(); + const navigate = useNavigate(); + + return useCallback( + async ({ redirectTo }: { redirectTo?: string } = {}) => { + resetFeatureFlags(); + await invalidateAuthQueries(); + if (redirectTo) { + await navigate(redirectTo); + } else { + await revalidate(); + } + Sentry.setUser(null); + queryClient.clear(); + }, + [navigate, revalidate, queryClient], + ); +}; + type SignOutOptions = { /** * The URL to redirect to after signing out. If not provided, the user will be redirected to the singup page by the protected layout. @@ -162,9 +192,9 @@ type AuthActions = { * tracking, and the OAuth deep-link session. */ export const useAuthActions = (): AuthActions => { - const queryClient = useQueryClient(); const { revalidate } = useRevalidator(); const navigate = useNavigate(); + const endSessionCleanup = useSessionEndCleanup(); const refreshSession = useCallback( async (redirectTo?: string) => { @@ -202,14 +232,9 @@ export const useAuthActions = (): AuthActions => { const signOut = useCallback( async (options: SignOutOptions = {}) => { await sdk.auth.signOut(); - // Before the refresh below so the previous user's flags are gone even if - // the anon re-fetch fails, and so its result isn't clobbered afterwards. - resetFeatureFlags(); - await refreshSession(options.redirectTo); - Sentry.setUser(null); - queryClient.clear(); + await endSessionCleanup({ redirectTo: options.redirectTo }); }, - [refreshSession, queryClient], + [endSessionCleanup], ); const initiateGoogleAuth = useCallback(async () => { @@ -284,20 +309,13 @@ export const useSignOut = () => { * matching master's extend-through-invalidation behavior. */ export const useHandleSessionEvents = (onSessionExpired: () => void) => { - const queryClient = useQueryClient(); - const { revalidate } = useRevalidator(); + const endSessionCleanup = useSessionEndCleanup(); const onSessionExpiredRef = useLatest(onSessionExpired); useEffect(() => { const handleSessionExpired = () => { - void (async () => { - onSessionExpiredRef.current(); - resetFeatureFlags(); - await invalidateAuthQueries(); - await revalidate(); - Sentry.setUser(null); - queryClient.clear(); - })().catch((error) => { + onSessionExpiredRef.current(); + void endSessionCleanup().catch((error) => { // Hard fallback: the SDK already ended the session, so a reload // boots anonymous even when the soft reset above fails mid-flight. console.error('Failed to handle session expiry', { cause: error }); @@ -325,5 +343,5 @@ export const useHandleSessionEvents = (onSessionExpired: () => void) => { unsubscribeExpired(); unsubscribeRefreshed(); }; - }, [queryClient, revalidate]); + }, [endSessionCleanup]); }; From ef3402f7f01f620d71db0f7c1689b02b7fbaf5f5 Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Fri, 10 Jul 2026 16:46:44 +0200 Subject: [PATCH 21/49] refactor(utils): extract safeJwtDecode for stored-token reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fail-soft decode idiom (a corrupt STORED token must degrade to null, never throw) was duplicated in the SDK's auth service and the web's auth glue, and missing from shared/auth.ts's isLoggedIn — where a corrupt refresh token would throw through the DB client's token getter into every query; it now reads as logged out. Tokens freshly minted by a server keep decoding with jwtDecode directly so a malformed one fails loudly. Co-Authored-By: Claude Fable 5 --- apps/web-wallet/app/features/shared/auth.ts | 12 ++++++++---- apps/web-wallet/app/features/user/auth.ts | 17 ++++------------- bun.lock | 1 + packages/utils/package.json | 1 + packages/utils/src/index.ts | 1 + packages/utils/src/jwt.ts | 15 +++++++++++++++ packages/wallet-sdk/domain/user/auth-service.ts | 16 +++------------- 7 files changed, 33 insertions(+), 30 deletions(-) create mode 100644 packages/utils/src/jwt.ts diff --git a/apps/web-wallet/app/features/shared/auth.ts b/apps/web-wallet/app/features/shared/auth.ts index 69f52f6ae..24e673e1c 100644 --- a/apps/web-wallet/app/features/shared/auth.ts +++ b/apps/web-wallet/app/features/shared/auth.ts @@ -1,12 +1,16 @@ -import { jwtDecode } from 'jwt-decode'; +import { safeJwtDecode } from '@agicash/utils'; -/** Check if the user is logged in by verifying localStorage tokens. */ +/** + * Check if the user is logged in by verifying localStorage tokens. A corrupt + * stored token counts as logged out — this feeds the DB client's token + * getter, so it must never throw into every query. + */ export const isLoggedIn = (): boolean => { const accessToken = window.localStorage.getItem('access_token'); const refreshToken = window.localStorage.getItem('refresh_token'); if (!accessToken || !refreshToken) { return false; } - const decoded = jwtDecode(refreshToken); - return !!decoded.exp && decoded.exp * 1000 > Date.now(); + const decoded = safeJwtDecode(refreshToken); + return !!decoded?.exp && decoded.exp * 1000 > Date.now(); }; diff --git a/apps/web-wallet/app/features/user/auth.ts b/apps/web-wallet/app/features/user/auth.ts index a3066c58e..9dc334e55 100644 --- a/apps/web-wallet/app/features/user/auth.ts +++ b/apps/web-wallet/app/features/user/auth.ts @@ -1,3 +1,4 @@ +import { safeJwtDecode } from '@agicash/utils'; import type { AuthUser } from '@agicash/wallet-sdk'; import * as Sentry from '@sentry/react-router'; import { decodeURLSafe, encodeURLSafe } from '@stablelib/base64'; @@ -6,7 +7,6 @@ import { useQueryClient, useSuspenseQuery, } from '@tanstack/react-query'; -import { jwtDecode } from 'jwt-decode'; import { useCallback, useEffect, useState } from 'react'; import { useNavigate, useRevalidator } from 'react-router'; import { @@ -35,18 +35,9 @@ type AuthState = export const authStateQueryKey = 'auth-state'; -// A corrupt stored token must degrade to "no value", never throw from a query -// fn or staleTime callback (that would error-page every route, /login included). -const safeJwtDecode = ( - token: string, -): { exp?: number; sub?: string } | null => { - try { - return jwtDecode(token); - } catch { - return null; - } -}; - +// The stored token may be corrupt — safeJwtDecode keeps it from throwing out +// of the queryFn or staleTime callback (that would error-page every route, +// /login included). const getRefreshTokenExpiry = (): number | null => { const refreshToken = window.localStorage.getItem('refresh_token'); if (!refreshToken) { diff --git a/bun.lock b/bun.lock index 61af2e308..b16b90afe 100644 --- a/bun.lock +++ b/bun.lock @@ -180,6 +180,7 @@ "dependencies": { "@noble/ciphers": "catalog:", "@noble/hashes": "catalog:", + "jwt-decode": "catalog:", "type-fest": "catalog:", "zod": "catalog:", }, diff --git a/packages/utils/package.json b/packages/utils/package.json index 14b06b138..b75e69879 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -12,6 +12,7 @@ "dependencies": { "@noble/ciphers": "catalog:", "@noble/hashes": "catalog:", + "jwt-decode": "catalog:", "type-fest": "catalog:", "zod": "catalog:" }, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index a55ba6a77..73992e90b 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,5 +1,6 @@ export * from './collections'; export * from './json'; +export * from './jwt'; export * from './zod'; export * from './type-utils'; export * from './sha256'; diff --git a/packages/utils/src/jwt.ts b/packages/utils/src/jwt.ts new file mode 100644 index 000000000..ecbdf1f59 --- /dev/null +++ b/packages/utils/src/jwt.ts @@ -0,0 +1,15 @@ +import { type JwtPayload, jwtDecode } from 'jwt-decode'; + +/** + * Decodes a JWT's payload, returning null for an undecodable token instead of + * throwing. For tokens read from storage, which may be corrupt. A token just + * minted by a server should be decoded with `jwtDecode` directly, so a + * malformed one fails its operation loudly instead of passing as absent. + */ +export const safeJwtDecode = (token: string): JwtPayload | null => { + try { + return jwtDecode(token); + } catch { + return null; + } +}; diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index 148f35cd0..018615071 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -6,9 +6,9 @@ import type { import { type LongTimeout, clearLongTimeout, + safeJwtDecode, setLongTimeout, } from '@agicash/utils'; -import { jwtDecode } from 'jwt-decode'; import type { WalletEventEmitter } from '../../lib/events'; import type { AuthApi, AuthSession, AuthStorage, Logger } from '../../sdk'; import type { GuestAccountStorage } from './guest-account-storage'; @@ -18,16 +18,6 @@ import type { GuestAccountStorage } from './guest-account-storage'; const accessTokenKey = 'access_token'; const refreshTokenKey = 'refresh_token'; -// A corrupt stored token must degrade (no timer / expiry path), never throw -// from a timer callback or a query fn. -const decodeJwt = (token: string): { exp?: number } | null => { - try { - return jwtDecode(token); - } catch { - return null; - } -}; - /** The subset of @agicash/opensecret the auth service drives. `import * as openSecret` satisfies it. */ export type OpenSecretAuthApi = { fetchUser(): Promise; @@ -106,7 +96,7 @@ export class AuthService implements AuthApi { if (!accessToken || !refreshToken) { return; } - if (!decodeJwt(refreshToken)?.exp) { + if (!safeJwtDecode(refreshToken)?.exp) { // An undecodable (or exp-less) refresh token can't arm the expiry // machinery and can't be refreshed — the restored session would be // unmanaged and die unrecoverably mid-use. Restore anonymous instead. @@ -288,7 +278,7 @@ export class AuthService implements AuthApi { if (!refreshToken) { return null; } - const decoded = decodeJwt(refreshToken); + const decoded = safeJwtDecode(refreshToken); if (!decoded?.exp) { return null; } From b4ba28e33ff5414048059dc3698828724b00374c Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Fri, 10 Jul 2026 17:02:05 +0200 Subject: [PATCH 22/49] docs(web-wallet): mark the Open Secret storage-key reads as a temporary leak Co-Authored-By: Claude Fable 5 --- apps/web-wallet/app/features/shared/auth.ts | 3 +++ apps/web-wallet/app/features/user/auth.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/apps/web-wallet/app/features/shared/auth.ts b/apps/web-wallet/app/features/shared/auth.ts index 24e673e1c..64ecf0404 100644 --- a/apps/web-wallet/app/features/shared/auth.ts +++ b/apps/web-wallet/app/features/shared/auth.ts @@ -4,6 +4,9 @@ import { safeJwtDecode } from '@agicash/utils'; * Check if the user is logged in by verifying localStorage tokens. A corrupt * stored token counts as logged out — this feeds the DB client's token * getter, so it must never throw into every query. + * + * Temporary leak: reads Open Secret's storage keys directly until the late + * SDK-migration steps retire the web's own DB client. */ export const isLoggedIn = (): boolean => { const accessToken = window.localStorage.getItem('access_token'); diff --git a/apps/web-wallet/app/features/user/auth.ts b/apps/web-wallet/app/features/user/auth.ts index 9dc334e55..6191501c3 100644 --- a/apps/web-wallet/app/features/user/auth.ts +++ b/apps/web-wallet/app/features/user/auth.ts @@ -38,6 +38,8 @@ export const authStateQueryKey = 'auth-state'; // The stored token may be corrupt — safeJwtDecode keeps it from throwing out // of the queryFn or staleTime callback (that would error-page every route, // /login included). +// Temporary leak: reads Open Secret's storage keys directly until step 18 +// exposes the refresh-token expiry on the SDK session. const getRefreshTokenExpiry = (): number | null => { const refreshToken = window.localStorage.getItem('refresh_token'); if (!refreshToken) { @@ -52,6 +54,8 @@ export const authQueryOptions = () => queryFn: async (): Promise => { // Associate Sentry events with the user as early as possible, before // session restore completes. + // Temporary leak: reads Open Secret's storage keys directly until + // step 18 exposes a pre-restore session hint on the SDK. const accessToken = window.localStorage.getItem('access_token'); const sub = accessToken ? safeJwtDecode(accessToken)?.sub : undefined; if (sub) { From 937e23a98acfaa5621ce58a54a61e6addcd7e2cf Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Sat, 11 Jul 2026 13:43:48 +0200 Subject: [PATCH 23/49] revert(wallet-sdk): keep master's password generator sampling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the rejection sampling added in 7d3bc1c0 (review LOW finding), returning the generator to master's algorithm verbatim. The modulo skew is ~1 in 5.7e7 per character over a 32-character password — cryptographically negligible — and master parity is preferred for the slice. The redraw test goes with it; the length sanity test stays. Co-Authored-By: Claude Fable 5 revert(wallet-sdk): unfence the expiry handler, park its races as a known issue Reverts the generation fencing of handleSessionExpiry from 1e6a6272 (review finding 1). The per-await fence pattern is too subtle to maintain — the plan is to fix the race class structurally by serializing all session transitions through a single command lane (single-writer) instead. The handler returns to the shape inherited from master's expiry hook, with a KNOWN ISSUE note: a sign-out landing while the guest re-sign-in is in flight is silently undone, and an in-flight handler survives teardown() (HMR). The two race regression tests stay as it.todo — they are the spec the command-lane refactor must satisfy. Kept from the reverted commit: the signInGuestAccount extraction (the guest-persist undo lives there) and the test fake's real-SDK sign-out fidelity (clears token keys), which later tests rely on. Co-Authored-By: Claude Fable 5 fix(wallet-sdk): tighten three lib-level invariants from the review - generateRandomPassword now rejection-samples instead of a bare modulo, so every charset character is exactly equally likely — this string is the sole credential of funded guest accounts. - WalletEventEmitter.emit dispatches over a snapshot, so a handler that (un)subscribes mid-emit can't alter the current dispatch. - AgicashSdk.create() now throws while an undisposed instance exists, making the one-instance-per-process constraint (module-global Open Secret state) self-enforcing; dispose() releases the slot, so the HMR dispose-then-recreate cycle still works. LOW findings of the cross-model review on PR #1166; each regression test fails against the previous code. Co-Authored-By: Claude Fable 5 fix(web-wallet): make the session-end web cleanup unconditional and close two expiry-path gaps Three findings from the PR #1166 cross-model review: - A throwing sdk.auth.signOut() skipped the whole web cleanup while the SDK had already ended the local session (its own finally), leaving query caches, the Sentry identity, and the hint cookie serving a dead session — with the sign-out button wedged in its loading state. The cleanup and the loading reset now run in finally. - A guest auto-extension during init() fires auth.session-refreshed before the events hook subscribes, so the auth query and session-hint cookie stayed pinned to the old expiry. The hook now re-syncs on mount when the stored refresh-token expiry moved past what the query captured. - The expiry hard-fallback reload had no guard; it is now throttled through a sessionStorage timestamp so a persistently failing cleanup can't reload-loop the tab. Co-Authored-By: Claude Fable 5 fix(wallet-sdk): read the session once per setDefaultAccount verb The verb derived the user from the live session twice — once inside the account-ownership read and again, after that await, for the row update. A session switch between the two wrote the previous user's account id onto the next user's row (the schema has no same-user constraint on the default account FKs, so the broken pointer persists; RLS still hides the other user's data). The session is now captured once at verb entry and threaded through, so the validating user and the written user cannot diverge. Finding 3 of the cross-model review on PR #1166. The regression test fails against the previous code. Co-Authored-By: Claude Fable 5 fix(wallet-sdk): stop the public receive projections leaking proofs and collapsing variants The receive domain entities are intersections over variant unions, and the contract projected them with a bare `Omit`. TypeScript collapses each union to its shared keys under `keyof`, so the projections simultaneously leaked the swap's top-level `tokenProofs` (spendable Cashu proof material, also via the receive events' payload types) and silently dropped every variant-only field (`tokenReceiveData`, `failureReason`, keyset fields) along with discriminant narrowing. The domain schemas now name their variant unions once (feeding both the schema and an exported variant type), and the projections omit base keys only, re-apply the variant unions, and strip proof material at both levels — top-level `tokenProofs` and the melt data's nested `tokenProofs`. The implementing slices (steps 9/11/12) must strip the same fields at runtime. Inherited from #1164 (the projection sweep missed the receive entities); finding 2 of the cross-model review on PR #1166. Co-Authored-By: Claude Fable 5 fix(wallet-sdk): harden the restore memo and guest sign-up edge paths Three LOW findings from the PR #1166 cross-model review: - A slow-failing restore cleared the restore memo unconditionally and its endSession bumped the generation, fencing out a newer restore's apply — an anonymous boot despite valid tokens. The rejection now un-memoizes only its own memo, and the failure path leaves the session alone when another transition owns it. - A restore after a same-lifetime sign-out/sign-in repeated the verb's user fetch; doRestore now returns early when a verb already established the session. - A guest sign-up whose credential persist failed left a live session whose account would strand (with any funds) at its first expiry. The sign-up is now undone and the verb rejects, so the retry lands on a recoverable account. All three regression tests fail against the previous code. Co-Authored-By: Claude Fable 5 fix(wallet-sdk): fence the expiry handler against concurrent session transitions A fired expiry timer can't be cancelled, so its handler raced every other session transition. Worst case: a sign-out landing while the guest auto-extension's re-sign-in was in flight got silently undone — the re-sign-in wrote fresh tokens over the sign-out's clear and the unfenced snapshot apply flipped the session back to logged-in (money access after a believed sign-out on a shared device). An in-flight handler also survived teardown(), letting a disposed instance clear tokens its successor had just restored. The handler now captures the session generation at entry and re-checks it plus the disposed flag after every await: a raced re-sign-in's tokens are cleared again instead of resurrecting the session, the extension's apply is generation-fenced, and the death path (sign-out, end, expired event) only runs while the handler still owns the session it started from. Finding 1 (HIGH) of the cross-model review on PR #1166. Both regression tests fail against the previous code. Co-Authored-By: Claude Fable 5 --- apps/web-wallet/app/features/user/auth.ts | 43 +++- packages/wallet-sdk/agicash-sdk.test.ts | 45 ++++ packages/wallet-sdk/agicash-sdk.ts | 18 +- .../domain/receive/cashu-receive-quote.ts | 35 +++- .../domain/receive/cashu-receive-swap.ts | 21 +- .../domain/receive/spark-receive-quote.ts | 35 +++- .../domain/user/auth-service.test.ts | 192 +++++++++++++++++- .../wallet-sdk/domain/user/auth-service.ts | 76 ++++++- .../wallet-sdk/domain/user/user-api.test.ts | 97 +++++++++ packages/wallet-sdk/domain/user/user-api.ts | 12 +- packages/wallet-sdk/lib/events.test.ts | 21 ++ packages/wallet-sdk/lib/events.ts | 4 +- packages/wallet-sdk/lib/password.test.ts | 9 + packages/wallet-sdk/sdk/receive.ts | 58 +++++- 14 files changed, 610 insertions(+), 56 deletions(-) create mode 100644 packages/wallet-sdk/agicash-sdk.test.ts create mode 100644 packages/wallet-sdk/domain/user/user-api.test.ts create mode 100644 packages/wallet-sdk/lib/password.test.ts diff --git a/apps/web-wallet/app/features/user/auth.ts b/apps/web-wallet/app/features/user/auth.ts index 6191501c3..9fdc266c8 100644 --- a/apps/web-wallet/app/features/user/auth.ts +++ b/apps/web-wallet/app/features/user/auth.ts @@ -226,8 +226,14 @@ export const useAuthActions = (): AuthActions => { const signOut = useCallback( async (options: SignOutOptions = {}) => { - await sdk.auth.signOut(); - await endSessionCleanup({ redirectTo: options.redirectTo }); + try { + await sdk.auth.signOut(); + } finally { + // The SDK ends the local session even when the sign-out throws, so + // the web state must be reset regardless — otherwise a dead session + // keeps a live-looking wallet on screen. + await endSessionCleanup({ redirectTo: options.redirectTo }); + } }, [endSessionCleanup], ); @@ -290,8 +296,11 @@ export const useSignOut = () => { const handleSignOut = async () => { setLoading(true); - await signOut({ redirectTo: '/home' }); - setLoading(false); + try { + await signOut({ redirectTo: '/home' }); + } finally { + setLoading(false); + } }; return { isSigningOut: loading, signOut: handleSignOut }; }; @@ -303,7 +312,12 @@ export const useSignOut = () => { * re-runs the auth query so the session-hint cookie picks up the new expiry, * matching master's extend-through-invalidation behavior. */ +// Guards the expiry hard-fallback below: at most one reload per window, so a +// persistently failing cleanup can't reload-loop the tab. +const expiryReloadAtKey = 'agicash.session-expiry-reload-at'; + export const useHandleSessionEvents = (onSessionExpired: () => void) => { + const queryClient = useQueryClient(); const endSessionCleanup = useSessionEndCleanup(); const onSessionExpiredRef = useLatest(onSessionExpired); @@ -314,6 +328,13 @@ export const useHandleSessionEvents = (onSessionExpired: () => void) => { // Hard fallback: the SDK already ended the session, so a reload // boots anonymous even when the soft reset above fails mid-flight. console.error('Failed to handle session expiry', { cause: error }); + const lastReloadAt = Number( + window.sessionStorage.getItem(expiryReloadAtKey) ?? 0, + ); + if (Date.now() - lastReloadAt < 30_000) { + return; + } + window.sessionStorage.setItem(expiryReloadAtKey, String(Date.now())); window.location.reload(); }); }; @@ -332,11 +353,23 @@ export const useHandleSessionEvents = (onSessionExpired: () => void) => { // SDK session here means exactly that missed event — handle it now. if (!sdk.auth.getSession().isLoggedIn) { handleSessionExpired(); + } else { + // The mirror gap for guests: an auto-extension during init() missed its + // auth.session-refreshed the same way. If the stored expiry moved past + // what the auth query captured, re-sync the query (and with it the + // session-hint cookie) now. + const authState = queryClient.getQueryData(authQueryOptions().queryKey); + if ( + authState?.isLoggedIn && + authState.refreshTokenExpiresAt !== getRefreshTokenExpiry() + ) { + void invalidateAuthQueries(); + } } return () => { unsubscribeExpired(); unsubscribeRefreshed(); }; - }, [endSessionCleanup]); + }, [endSessionCleanup, queryClient]); }; diff --git a/packages/wallet-sdk/agicash-sdk.test.ts b/packages/wallet-sdk/agicash-sdk.test.ts new file mode 100644 index 000000000..c30a12ae1 --- /dev/null +++ b/packages/wallet-sdk/agicash-sdk.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'bun:test'; +import { AgicashSdk } from './agicash-sdk'; +import { nullLogger } from './lib/logger'; +import type { AuthKeyValueStore, SdkConfig } from './sdk'; + +const createMemoryStore = (): AuthKeyValueStore => { + const data = new Map(); + return { + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +const createConfig = (): SdkConfig => ({ + db: { url: 'http://localhost:54321', anonKey: 'anon-key' }, + auth: { + apiUrl: 'http://localhost:3100', + clientId: '00000000-0000-0000-0000-000000000000', + storage: { persistent: createMemoryStore(), session: createMemoryStore() }, + }, + spark: { breezApiKey: 'key', network: 'MAINNET' }, + lightningAddressDomain: 'localhost', + logger: nullLogger, +}); + +describe('AgicashSdk.create', () => { + it('refuses a second instance until the first is disposed', async () => { + const sdk = AgicashSdk.create(createConfig()); + try { + expect(() => AgicashSdk.create(createConfig())).toThrow( + /dispose\(\) the previous instance/, + ); + } finally { + await sdk.dispose(); + } + + const next = AgicashSdk.create(createConfig()); + await next.dispose(); + }); +}); diff --git a/packages/wallet-sdk/agicash-sdk.ts b/packages/wallet-sdk/agicash-sdk.ts index 4832b86e3..54ec1781f 100644 --- a/packages/wallet-sdk/agicash-sdk.ts +++ b/packages/wallet-sdk/agicash-sdk.ts @@ -10,6 +10,11 @@ import { generateRandomPassword } from './lib/password'; import { clearSparkWallets } from './lib/spark/wallet'; import type { AuthApi, Sdk, SdkConfig, UserApi, WalletEvents } from './sdk'; +// Makes the one-instance-per-process constraint (see the constructor note) +// self-enforcing: create() refuses to run while an undisposed instance holds +// the module-global Open Secret configuration. +let liveInstance: AgicashSdk | undefined; + /** * Runtime implementation of the SDK contract, filled namespace-by-namespace * as the migration slices land (auth/user/events since step 5). Each slice @@ -78,9 +83,15 @@ export class AgicashSdk this.events = events; } - /** Sync; no I/O. */ + /** Sync; no I/O. Throws when an undisposed instance already exists (see the constructor note). */ static create(config: SdkConfig): AgicashSdk { - return new AgicashSdk(config); + if (liveInstance) { + throw new Error( + 'An AgicashSdk instance already exists in this process. @agicash/opensecret holds module-global auth state, so dispose() the previous instance before creating another.', + ); + } + liveInstance = new AgicashSdk(config); + return liveInstance; } /** @@ -95,5 +106,8 @@ export class AgicashSdk async dispose(): Promise { this.authService.teardown(); + if (liveInstance === this) { + liveInstance = undefined; + } } } diff --git a/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts b/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts index e9c3d0b6e..7b2ebad10 100644 --- a/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts +++ b/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts @@ -138,22 +138,35 @@ const CashuReceiveQuoteFailedStateSchema = z.object({ failureReason: z.string(), }); +const CashuReceiveQuoteTypeSchema = z.union([ + CashuReceiveQuoteLightningTypeSchema, + CashuReceiveQuoteCashuTokenTypeSchema, +]); + +const CashuReceiveQuoteStateSchema = z.union([ + CashuReceiveQuoteUnpaidExpiredStateSchema, + CashuReceiveQuotePaidCompletedStateSchema, + CashuReceiveQuoteFailedStateSchema, +]); + /** * Schema for cashu receive quote. */ export const CashuReceiveQuoteSchema = z.intersection( CashuReceiveQuoteBaseSchema, - z.intersection( - z.union([ - CashuReceiveQuoteLightningTypeSchema, - CashuReceiveQuoteCashuTokenTypeSchema, - ]), - z.union([ - CashuReceiveQuoteUnpaidExpiredStateSchema, - CashuReceiveQuotePaidCompletedStateSchema, - CashuReceiveQuoteFailedStateSchema, - ]), - ), + z.intersection(CashuReceiveQuoteTypeSchema, CashuReceiveQuoteStateSchema), ); export type CashuReceiveQuote = z.infer; + +/** + * The variant unions on their own, for projections that rebuild the + * intersection — a bare `Omit` over the full type collapses each union to its + * shared keys. + */ +export type CashuReceiveQuoteTypeVariant = z.infer< + typeof CashuReceiveQuoteTypeSchema +>; +export type CashuReceiveQuoteStateVariant = z.infer< + typeof CashuReceiveQuoteStateSchema +>; diff --git a/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts b/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts index c2ac3e3dc..abc59b9c2 100644 --- a/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts +++ b/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts @@ -102,16 +102,27 @@ const CashuReceiveSwapFailedStateSchema = z.object({ failureReason: z.string(), }); +const CashuReceiveSwapStateSchema = z.union([ + CashuReceiveSwapPendingStateSchema, + CashuReceiveSwapCompletedStateSchema, + CashuReceiveSwapFailedStateSchema, +]); + /** * Schema for cashu receive swap. */ export const CashuReceiveSwapSchema = z.intersection( CashuReceiveSwapBaseSchema, - z.union([ - CashuReceiveSwapPendingStateSchema, - CashuReceiveSwapCompletedStateSchema, - CashuReceiveSwapFailedStateSchema, - ]), + CashuReceiveSwapStateSchema, ); export type CashuReceiveSwap = z.infer; + +/** + * The state variant union on its own, for projections that rebuild the + * intersection — a bare `Omit` over the full type collapses the union to its + * shared keys. + */ +export type CashuReceiveSwapStateVariant = z.infer< + typeof CashuReceiveSwapStateSchema +>; diff --git a/packages/wallet-sdk/domain/receive/spark-receive-quote.ts b/packages/wallet-sdk/domain/receive/spark-receive-quote.ts index f3c9efe36..ac3a6ab7c 100644 --- a/packages/wallet-sdk/domain/receive/spark-receive-quote.ts +++ b/packages/wallet-sdk/domain/receive/spark-receive-quote.ts @@ -125,22 +125,35 @@ const SparkReceiveQuoteFailedStateSchema = z.object({ failureReason: z.string(), }); +const SparkReceiveQuoteTypeSchema = z.union([ + SparkReceiveQuoteLightningTypeSchema, + SparkReceiveQuoteCashuTokenTypeSchema, +]); + +const SparkReceiveQuoteStateSchema = z.union([ + SparkReceiveQuoteUnpaidExpiredStateSchema, + SparkReceiveQuotePaidStateSchema, + SparkReceiveQuoteFailedStateSchema, +]); + /** * Schema for Spark receive quote. */ export const SparkReceiveQuoteSchema = z.intersection( SparkReceiveQuoteBaseSchema, - z.intersection( - z.union([ - SparkReceiveQuoteLightningTypeSchema, - SparkReceiveQuoteCashuTokenTypeSchema, - ]), - z.union([ - SparkReceiveQuoteUnpaidExpiredStateSchema, - SparkReceiveQuotePaidStateSchema, - SparkReceiveQuoteFailedStateSchema, - ]), - ), + z.intersection(SparkReceiveQuoteTypeSchema, SparkReceiveQuoteStateSchema), ); export type SparkReceiveQuote = z.infer; + +/** + * The variant unions on their own, for projections that rebuild the + * intersection — a bare `Omit` over the full type collapses each union to its + * shared keys. + */ +export type SparkReceiveQuoteTypeVariant = z.infer< + typeof SparkReceiveQuoteTypeSchema +>; +export type SparkReceiveQuoteStateVariant = z.infer< + typeof SparkReceiveQuoteStateSchema +>; diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts index 561c2b2d8..51d832a11 100644 --- a/packages/wallet-sdk/domain/user/auth-service.test.ts +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -67,7 +67,12 @@ const createOsFake = ( signUp: async () => login('user-1'), signUpGuest: async () => login('guest-1'), signInGuest: async () => login('guest-1'), - signOut: async () => undefined, + // The real SDK swallows the network logout failure and clears its token + // keys unconditionally. + signOut: async () => { + tokenStore.data.delete('access_token'); + tokenStore.data.delete('refresh_token'); + }, verifyEmail: async () => undefined, requestNewVerificationCode: async () => undefined, convertGuestToUserAccount: async () => undefined, @@ -222,6 +227,66 @@ describe('AuthService', () => { service.teardown(); }); + it('does not repeat the user fetch when a verb already established the session', async () => { + const { service, calls } = createService(); + + await service.signIn('a@b.c', 'pw'); + await service.restoreSession(); + + expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); + service.teardown(); + }); + + it('does not fence out a newer restore when an older one fails late', async () => { + let fetchCalls = 0; + let releaseFirstFetch = (): void => undefined; + const firstFetchGate = new Promise((resolve) => { + releaseFirstFetch = resolve; + }); + let releaseThirdFetch = (): void => undefined; + const thirdFetchGate = new Promise((resolve) => { + releaseThirdFetch = resolve; + }); + const { service, storage } = createService({ + os: { + fetchUser: async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + // restore A: fails only after restore B is in flight + await firstFetchGate; + throw new Error('slow network failure'); + } + if (fetchCalls === 2) { + // the verb's snapshot refresh: ends the session, un-memoizes A + throw new Error('verb fetch failed'); + } + // restore B + await thirdFetchGate; + return { user: fullUser }; + }, + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + const restoreA = service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await service.signIn('a@b.c', 'pw'); + const restoreB = service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 0)); + releaseFirstFetch(); + await expect(restoreA).rejects.toThrow('slow network failure'); + releaseThirdFetch(); + await restoreB; + + // A's late failure neither bumped the generation (which would fence + // B's apply out) nor clobbered B's memo + expect(service.getSession().isLoggedIn).toBe(true); + await service.restoreSession(); + expect(fetchCalls).toBe(3); + service.teardown(); + }); + it('is single-flight', async () => { const { service, storage, calls } = createService(); storage.persistent.data.set('access_token', createJwt(600)); @@ -287,6 +352,29 @@ describe('AuthService', () => { service.teardown(); }); + it('undoes the guest sign-up when persisting the credentials fails', async () => { + const storage = createStorage(); + const write = storage.persistent.setItem; + storage.persistent.setItem = (key, value) => { + if (key === 'guestAccount') { + throw new Error('quota exceeded'); + } + write(key, value); + }; + const { service, calls } = createService({ + storage, + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + + await expect(service.signUpGuest()).rejects.toThrow('quota exceeded'); + + // no live session on unpersistable credentials — it would strand the + // account (and any funds) at its first expiry + expect(calls).toContain('signOut'); + expect(service.getSession().isLoggedIn).toBe(false); + expect(storage.persistent.data.has('refresh_token')).toBe(false); + }); + it('re-signs-in the stored guest account instead of creating a new one', async () => { const { service, storage, calls } = createService({ os: { fetchUser: async () => ({ user: guestUser }) }, @@ -435,6 +523,108 @@ describe('AuthService', () => { service.teardown(); }); + // Parked (it.todo) as the spec for the planned command-lane + // serialization of session transitions: the unfenced handler inherited + // from master fails this — see the KNOWN ISSUE note on + // handleSessionExpiry in auth-service.ts (PR #1166 review, finding 1). + it.todo( + 'does not resurrect a session that was signed out while a guest extension was in flight', + async () => { + let releaseSignInGuest = (): void => undefined; + const signInGuestGate = new Promise((resolve) => { + releaseSignInGuest = resolve; + }); + const storage = createStorage(); + const { service, calls, events } = createService({ + storage, + os: { + fetchUser: async () => ({ user: guestUser }), + signInGuest: async () => { + // The extension's fresh tokens land only once the gate opens — + // i.e. after the concurrent sign-out already cleared storage. + await signInGuestGate; + storage.persistent.data.set( + 'access_token', + createJwt(600, 'guest-1'), + ); + storage.persistent.data.set( + 'refresh_token', + createJwt(3600, 'guest-1'), + ); + return { id: 'guest-1', access_token: 'a', refresh_token: 'r' }; + }, + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const refreshed: unknown[] = []; + events.on('auth.session-refreshed', (payload) => + refreshed.push(payload), + ); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // let the expiry timer fire and park the extension on the gated sign-in + await new Promise((resolve) => setTimeout(resolve, 10)); + await service.signOut(); + releaseSignInGuest(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(service.getSession().isLoggedIn).toBe(false); + expect(refreshed).toHaveLength(0); + expect(expired).toHaveLength(0); + // the extension's freshly written tokens were cleared again instead of + // being left to resurrect the signed-out session on the next boot + expect(storage.persistent.data.has('refresh_token')).toBe(false); + expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); + }, + ); + + // Parked (it.todo) with the test above — the SDK-lifecycle variant of + // the same known issue, solved by the same command-lane serialization + // (dispose drains the lane). + it.todo( + 'stops an in-flight expiry handler at teardown without clearing tokens', + async () => { + let gateReads = false; + let releaseTokenRead = (): void => undefined; + const readGate = new Promise((resolve) => { + releaseTokenRead = resolve; + }); + const storage = createStorage(); + const readValue = storage.persistent.getItem; + storage.persistent.getItem = async (key) => { + if (gateReads) { + await readGate; + } + return readValue(key); + }; + const { service, calls, events } = createService({ storage }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // gate the handler's token re-read so teardown lands mid-handler + gateReads = true; + await new Promise((resolve) => setTimeout(resolve, 10)); + service.teardown(); + releaseTokenRead(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(calls).not.toContain('signOut'); + expect(expired).toHaveLength(0); + // teardown is not logout: tokens survive for a successor instance + expect(storage.persistent.data.has('refresh_token')).toBe(true); + }, + ); + it('ends the session when the extension cannot restore the guest user', async () => { let fetchCalls = 0; const { service, storage, events } = createService({ diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index 018615071..d8ded9f3b 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -81,14 +81,27 @@ export class AuthService implements AuthApi { * fetch with tokens present) is not memoized, so a retry can recover. */ restoreSession(): Promise { - this.restorePromise ??= this.doRestore().catch((error) => { - this.restorePromise = undefined; - throw error; - }); + if (!this.restorePromise) { + const restorePromise: Promise = this.doRestore().catch((error) => { + // Un-memoize only our own memo: a session end may already have + // cleared it, and a newer restore may own the slot by now. + if (this.restorePromise === restorePromise) { + this.restorePromise = undefined; + } + throw error; + }); + this.restorePromise = restorePromise; + } return this.restorePromise; } private async doRestore(): Promise { + if (this.session.isLoggedIn) { + // A verb already established the session (and a previous session end + // un-memoized the restore); booting from storage would only repeat the + // verb's user fetch. + return; + } const [accessToken, refreshToken] = await Promise.all([ this.deps.storage.persistent.getItem(accessTokenKey), this.deps.storage.persistent.getItem(refreshTokenKey), @@ -102,16 +115,21 @@ export class AuthService implements AuthApi { // unmanaged and die unrecoverably mid-use. Restore anonymous instead. return; } + const generation = this.sessionGeneration; try { - await this.applySessionFromServer({ - expectedGeneration: this.sessionGeneration, - }); + await this.applySessionFromServer({ expectedGeneration: generation }); } catch (error) { if (this.session.isLoggedIn) { // An auth verb established a session while this restore was in // flight; the restore result is moot. return; } + if (this.sessionGeneration !== generation) { + // Another transition (a verb, sign-out, or a newer restore's apply) + // owns the session state now; ending the session here would bump the + // generation again and fence that owner's in-flight apply out. + throw error; + } // Contract: init() rejects on refresh errors (tokens exist but can't // be validated). endSession keeps the instance consistent; the // rejection is un-memoized by restoreSession, so a retry can succeed. @@ -126,21 +144,46 @@ export class AuthService implements AuthApi { } async signUpGuest(): Promise { + await this.signInGuestAccount(); + await this.refreshSessionSnapshot('guest sign up'); + } + + /** + * Signs into the stored guest account, creating and persisting a new one + * when none is stored. Fresh tokens land in storage as a side effect; the + * session snapshot is not touched. + */ + private async signInGuestAccount(): Promise { const existingGuestAccount = await this.deps.guestAccountStorage.get(); if (existingGuestAccount) { await this.deps.os.signInGuest( existingGuestAccount.id, existingGuestAccount.password, ); - } else { - const password = await this.deps.generateGuestPassword(); - const guestAccount = await this.deps.os.signUpGuest(password, ''); + return; + } + const password = await this.deps.generateGuestPassword(); + const guestAccount = await this.deps.os.signUpGuest(password, ''); + try { await this.deps.guestAccountStorage.store({ id: guestAccount.id, password, }); + } catch (error) { + // Credentials that can't be persisted strand the account at its first + // expiry (the extension would mint a fresh guest and the funds would be + // unreachable). Undo the sign-up and fail loudly so the retry lands on + // a recoverable account. + try { + await this.deps.os.signOut(); + } catch (undoError) { + this.deps.logger.warn( + 'Failed to sign out a guest account with unpersisted credentials', + undoError, + ); + } + throw error; } - await this.refreshSessionSnapshot('guest sign up'); } async signIn(email: string, password: string): Promise { @@ -292,6 +335,17 @@ export class AuthService implements AuthApi { } } + // KNOWN ISSUE (accepted for now — PR #1166 review, finding 1): this + // handler races concurrent session transitions, because a fired timer + // callback can't be cancelled. A sign-out landing while the guest + // re-sign-in below is in flight is silently undone — the re-sign-in + // rewrites fresh tokens over the sign-out's clear and the unfenced apply + // resurrects the session. Same class: an in-flight handler survives + // teardown() and can clear tokens a successor instance restored (HMR). + // Inherited from master's expiry hook, not a regression. The planned fix + // is structural — serialize all session transitions through a single + // command lane (single-writer) instead of fencing every await; the + // it.todo tests in auth-service.test.ts specify the required behavior. private async handleSessionExpiry(): Promise { const session = this.session; if (this.disposed || !session.isLoggedIn) { diff --git a/packages/wallet-sdk/domain/user/user-api.test.ts b/packages/wallet-sdk/domain/user/user-api.test.ts new file mode 100644 index 000000000..23490970c --- /dev/null +++ b/packages/wallet-sdk/domain/user/user-api.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'bun:test'; +import type { AgicashDb } from '../../db/database'; +import type { AuthUser } from '../../sdk'; +import { createUserApi } from './user-api'; + +const authUser = (id: string): AuthUser => + ({ + id, + name: null, + email: 'a@b.c', + email_verified: true, + login_method: 'email', + created_at: '2026-01-01', + updated_at: '2026-01-01', + }) as AuthUser; + +const dbUserRow = (id: string) => ({ + id, + username: 'name', + email: 'a@b.c', + email_verified: true, + created_at: '2026-01-01', + updated_at: '2026-01-01', + cashu_locking_xpub: 'xpub', + encryption_public_key: 'enc', + spark_identity_public_key: 'spark', + default_btc_account_id: 'acct-1', + default_usd_account_id: null, + default_currency: 'BTC', + terms_accepted_at: null, + gift_card_mint_terms_accepted_at: null, +}); + +type Filters = Record; + +/** + * Chainable fake covering exactly the two query shapes setDefaultAccount + * issues: the accounts ownership read and the users row update. + */ +const createDbFake = (deps: { + onAccountRead: (filters: Filters) => Record; + onUserUpdate: (filters: Filters, data: Record) => void; +}) => { + const from = (table: string) => { + const filters: Filters = {}; + let updateData: Record = {}; + const chain = { + select: () => chain, + update: (data: Record) => { + updateData = data; + return chain; + }, + eq: (column: string, value: unknown) => { + filters[column] = value; + return chain; + }, + single: async () => { + if (table === 'accounts') { + return { data: deps.onAccountRead(filters), error: null }; + } + deps.onUserUpdate(filters, updateData); + return { data: dbUserRow(String(filters.id)), error: null }; + }, + }; + return chain; + }; + return { from } as unknown as AgicashDb; +}; + +describe('createUserApi', () => { + describe('setDefaultAccount', () => { + it('writes onto the user that validated account ownership, not the current session user', async () => { + let sessionUserId = 'user-a'; + let updatedUserId: unknown; + let updatedData: Record = {}; + const api = createUserApi({ + db: createDbFake({ + onAccountRead: (filters) => { + // the session switches while the account read is in flight + sessionUserId = 'user-b'; + return { id: filters.id, currency: 'BTC' }; + }, + onUserUpdate: (filters, data) => { + updatedUserId = filters.id; + updatedData = data; + }, + }), + getSession: () => ({ isLoggedIn: true, user: authUser(sessionUserId) }), + }); + + await api.setDefaultAccount({ accountId: 'acct-1' }); + + expect(updatedUserId).toBe('user-a'); + expect(updatedData.default_btc_account_id).toBe('acct-1'); + }); + }); +}); diff --git a/packages/wallet-sdk/domain/user/user-api.ts b/packages/wallet-sdk/domain/user/user-api.ts index 979948a9f..9435b2f21 100644 --- a/packages/wallet-sdk/domain/user/user-api.ts +++ b/packages/wallet-sdk/domain/user/user-api.ts @@ -24,6 +24,7 @@ export function createUserApi(deps: Deps): UserApi { }; const getAccountRef = async ( + userId: string, accountId: string, ): Promise<{ id: string; currency: Currency }> => { const { data, error } = await deps.db @@ -32,7 +33,7 @@ export function createUserApi(deps: Deps): UserApi { .eq('id', accountId) // RLS already scopes rows to the user; this is defense-in-depth per the // "userId implicit from session" convention. - .eq('user_id', requireUserId()) + .eq('user_id', userId) .single(); if (error) { throw new Error('Failed to get account', { cause: error }); @@ -58,11 +59,16 @@ export function createUserApi(deps: Deps): UserApi { defaultCurrency: params.currency, }), setDefaultAccount: async (params) => { + // The session is read once for the whole verb: the user that validated + // account ownership must be the user whose row is written, or a session + // switch during the account fetch writes the previous user's account + // onto the next user's row. + const userId = requireUserId(); // One read, not two: the account row is fetched to derive the // per-currency column server-truthfully; the user row isn't needed // because the update only writes the changed columns. - const account = await getAccountRef(params.accountId); - return userService.setDefaultAccount({ id: requireUserId() }, account, { + const account = await getAccountRef(userId, params.accountId); + return userService.setDefaultAccount({ id: userId }, account, { setDefaultCurrency: params.setDefaultCurrency, }); }, diff --git a/packages/wallet-sdk/lib/events.test.ts b/packages/wallet-sdk/lib/events.test.ts index d5467fa4e..6678d8b4f 100644 --- a/packages/wallet-sdk/lib/events.test.ts +++ b/packages/wallet-sdk/lib/events.test.ts @@ -26,6 +26,27 @@ describe('WalletEventEmitter', () => { expect(calls).toBe(0); }); + it('does not deliver the current emit to a handler subscribed mid-emit', () => { + const emitter = new WalletEventEmitter(nullLogger); + const order: string[] = []; + let lateHandlerSubscribed = false; + emitter.on('auth.session-expired', () => { + order.push('first'); + if (!lateHandlerSubscribed) { + lateHandlerSubscribed = true; + emitter.on('auth.session-expired', () => { + order.push('late'); + }); + } + }); + + emitter.emit('auth.session-expired', {}); + expect(order).toEqual(['first']); + + emitter.emit('auth.session-expired', {}); + expect(order).toEqual(['first', 'first', 'late']); + }); + it('isolates a throwing handler and reports it to the logger', () => { const errors: string[] = []; const emitter = new WalletEventEmitter({ diff --git a/packages/wallet-sdk/lib/events.ts b/packages/wallet-sdk/lib/events.ts index e4921d336..4038f6a2e 100644 --- a/packages/wallet-sdk/lib/events.ts +++ b/packages/wallet-sdk/lib/events.ts @@ -27,7 +27,9 @@ export class WalletEventEmitter implements WalletEvents { if (!set) { return; } - for (const handler of set) { + // 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) { diff --git a/packages/wallet-sdk/lib/password.test.ts b/packages/wallet-sdk/lib/password.test.ts new file mode 100644 index 000000000..784d56790 --- /dev/null +++ b/packages/wallet-sdk/lib/password.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'bun:test'; +import { generateRandomPassword } from './password'; + +describe('generateRandomPassword', () => { + it('generates the requested length', async () => { + const password = await generateRandomPassword(32); + expect(password).toHaveLength(32); + }); +}); diff --git a/packages/wallet-sdk/sdk/receive.ts b/packages/wallet-sdk/sdk/receive.ts index 607f0458a..6e454e6dd 100644 --- a/packages/wallet-sdk/sdk/receive.ts +++ b/packages/wallet-sdk/sdk/receive.ts @@ -1,12 +1,58 @@ -import type { CashuReceiveQuote as DomainCashuReceiveQuote } from '../domain/receive/cashu-receive-quote'; +import type { + CashuReceiveQuoteStateVariant, + CashuReceiveQuoteTypeVariant, + CashuReceiveQuote as DomainCashuReceiveQuote, +} from '../domain/receive/cashu-receive-quote'; import type { CashuReceiveLightningQuote } from '../domain/receive/cashu-receive-quote-core'; -import type { CashuReceiveSwap as DomainCashuReceiveSwap } from '../domain/receive/cashu-receive-swap'; -import type { SparkReceiveQuote as DomainSparkReceiveQuote } from '../domain/receive/spark-receive-quote'; +import type { + CashuReceiveSwapStateVariant, + CashuReceiveSwap as DomainCashuReceiveSwap, +} from '../domain/receive/cashu-receive-swap'; +import type { CashuTokenMeltData } from '../domain/receive/cashu-token-melt-data'; +import type { + SparkReceiveQuote as DomainSparkReceiveQuote, + SparkReceiveQuoteStateVariant, + SparkReceiveQuoteTypeVariant, +} from '../domain/receive/spark-receive-quote'; import type { SparkReceiveLightningQuote } from '../domain/receive/spark-receive-quote-core'; -export type CashuReceiveQuote = Omit; -export type SparkReceiveQuote = Omit; -export type CashuReceiveSwap = Omit; +// The domain entities are intersections over variant unions (`Base & (A | B)`), +// and a bare `Omit` over such a type collapses each union to its shared keys — +// variant-only fields silently vanish and discriminant narrowing breaks. The +// projections below therefore omit base keys only and re-apply the variant +// unions. Spendable Cashu proof material (top-level `tokenProofs` and the +// melt data's `tokenProofs`) is stripped from the public shapes; the +// implementing slices (steps 9/11/12) must strip it at runtime at this same +// boundary. + +/** Distributes over a type-variant union, stripping proofs from the melt data. */ +type WithPublicTokenReceiveData = T extends { + tokenReceiveData: CashuTokenMeltData; +} + ? Omit & { + tokenReceiveData: Omit; + } + : T; + +export type CashuReceiveQuote = Omit< + DomainCashuReceiveQuote, + 'userId' | 'type' | 'state' +> & + WithPublicTokenReceiveData & + CashuReceiveQuoteStateVariant; + +export type SparkReceiveQuote = Omit< + DomainSparkReceiveQuote, + 'userId' | 'type' | 'state' +> & + WithPublicTokenReceiveData & + SparkReceiveQuoteStateVariant; + +export type CashuReceiveSwap = Omit< + DomainCashuReceiveSwap, + 'userId' | 'tokenProofs' | 'state' +> & + CashuReceiveSwapStateVariant; /** * `get*` methods are stateless previews; `create*` methods persist and enter From e89ab3355472a6fd8de0617f9eed5db10eb50540 Mon Sep 17 00:00:00 2001 From: orveth Date: Thu, 16 Jul 2026 11:01:45 -0700 Subject: [PATCH 24/49] refactor(utils): make setLongTimeout defer like setTimeout setLongTimeout fired its callback synchronously when the delay was <= 0 (the initial scheduleNext ran inline), unlike setTimeout which always defers to a future task. It now schedules every path through setTimeout, so a non-positive delay runs on the next tick: callers can rely on the callback never running inline and can always cancel it via clearLongTimeout. Addresses a #1166 review comment: with this, the auth service's expiry arm no longer needs its 1ms floor to avoid recursing synchronously. Co-Authored-By: Claude Opus 4.8 --- packages/utils/src/timeout.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/utils/src/timeout.ts b/packages/utils/src/timeout.ts index cc4a18d41..7c677a410 100644 --- a/packages/utils/src/timeout.ts +++ b/packages/utils/src/timeout.ts @@ -7,9 +7,11 @@ export type LongTimeout = { }; /** - * setTimeout alternative that supports bigger delays. Default setTimeout only supports delay up to ~24.8 days. + * setTimeout alternative that supports delays beyond setTimeout's ~24.8 day + * cap. Like setTimeout, the callback always runs asynchronously: a delay of 0 + * or less schedules it on the next tick rather than invoking it inline. * @param callback Callback to be invoked after the delay - * @param delay Delay after which callback should be executed + * @param delay Delay in milliseconds after which the callback runs * @returns {LongTimeout}. To clear the long timeout use `clearLongTimeout` function */ export function setLongTimeout( @@ -21,16 +23,11 @@ export function setLongTimeout( const longTimeout: LongTimeout = { id: null }; function scheduleNext() { - const elapsed = Date.now() - start; - - if (elapsed >= delay) { - callback(); + const remaining = delay - (Date.now() - start); + if (remaining > maxSetTimeoutDelay) { + longTimeout.id = setTimeout(scheduleNext, maxSetTimeoutDelay); } else { - const remaining = delay - elapsed; - longTimeout.id = setTimeout( - scheduleNext, - Math.min(remaining, maxSetTimeoutDelay), - ); + longTimeout.id = setTimeout(callback, Math.max(remaining, 0)); } } From 4d84689fcf23c9b414995257b3375276721090cc Mon Sep 17 00:00:00 2001 From: orveth Date: Thu, 16 Jul 2026 11:01:45 -0700 Subject: [PATCH 25/49] fix(wallet-sdk): fence+compensate the guest session-expiry race (#1166) Implements the reviewer's fence+compensate conclusion for finding 1, replacing the parked command-lane plan. handleSessionExpiry captures the session generation at entry; after the guest re-sign-in resolves, if a concurrent transition won the race and the session is anonymous, it compensates by signing out again, clearing the fresh tokens the re-sign-in wrote (opensecret owns the keys, so its signOut is the whole cleanup) instead of letting them resurrect the signed-out session on the next restore. teardown() is re-checked before the re-sign-in's tokens or the death path are acted on, so a disposed instance leaves tokens for its successor. The two regression tests are un-parked (both are satisfied by the compensate plus teardown guard); the KNOWN ISSUE note now describes only the residual windows. Also, from the same review: drop the "storage port" hexagonal-architecture jargon from the restoreSession doc, and remove the 1ms expiry-arm floor now that setLongTimeout defers like setTimeout. Co-Authored-By: Claude Opus 4.8 --- .../domain/user/auth-service.test.ts | 193 +++++++++--------- .../wallet-sdk/domain/user/auth-service.ts | 65 +++--- 2 files changed, 134 insertions(+), 124 deletions(-) diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts index 51d832a11..4c45f79e3 100644 --- a/packages/wallet-sdk/domain/user/auth-service.test.ts +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -470,7 +470,7 @@ describe('AuthService', () => { events.on('auth.session-expired', (payload) => expired.push(payload)); await service.restoreSession(); - // the 1ms-floored timer fires on the next macrotask + // the timer fires on the next macrotask await new Promise((resolve) => setTimeout(resolve, 10)); expect(expired).toHaveLength(1); @@ -523,107 +523,98 @@ describe('AuthService', () => { service.teardown(); }); - // Parked (it.todo) as the spec for the planned command-lane - // serialization of session transitions: the unfenced handler inherited - // from master fails this — see the KNOWN ISSUE note on - // handleSessionExpiry in auth-service.ts (PR #1166 review, finding 1). - it.todo( - 'does not resurrect a session that was signed out while a guest extension was in flight', - async () => { - let releaseSignInGuest = (): void => undefined; - const signInGuestGate = new Promise((resolve) => { - releaseSignInGuest = resolve; - }); - const storage = createStorage(); - const { service, calls, events } = createService({ - storage, - os: { - fetchUser: async () => ({ user: guestUser }), - signInGuest: async () => { - // The extension's fresh tokens land only once the gate opens — - // i.e. after the concurrent sign-out already cleared storage. - await signInGuestGate; - storage.persistent.data.set( - 'access_token', - createJwt(600, 'guest-1'), - ); - storage.persistent.data.set( - 'refresh_token', - createJwt(3600, 'guest-1'), - ); - return { id: 'guest-1', access_token: 'a', refresh_token: 'r' }; - }, + // A sign-out that lands while the guest extension's re-sign-in is in + // flight must not resurrect the session: the handler compensates by + // clearing the fresh tokens again (PR #1166 review, finding 1). + it('does not resurrect a session that was signed out while a guest extension was in flight', async () => { + let releaseSignInGuest = (): void => undefined; + const signInGuestGate = new Promise((resolve) => { + releaseSignInGuest = resolve; + }); + const storage = createStorage(); + const { service, calls, events } = createService({ + storage, + os: { + fetchUser: async () => ({ user: guestUser }), + signInGuest: async () => { + // The extension's fresh tokens land only once the gate opens — + // i.e. after the concurrent sign-out already cleared storage. + await signInGuestGate; + storage.persistent.data.set( + 'access_token', + createJwt(600, 'guest-1'), + ); + storage.persistent.data.set( + 'refresh_token', + createJwt(3600, 'guest-1'), + ); + return { id: 'guest-1', access_token: 'a', refresh_token: 'r' }; }, - }); - storage.persistent.data.set( - 'guestAccount', - JSON.stringify({ id: 'guest-1', password: 'pw' }), - ); - storage.persistent.data.set('access_token', createJwt(600)); - storage.persistent.data.set('refresh_token', createJwt(4)); - const refreshed: unknown[] = []; - events.on('auth.session-refreshed', (payload) => - refreshed.push(payload), - ); - const expired: unknown[] = []; - events.on('auth.session-expired', (payload) => expired.push(payload)); - - await service.restoreSession(); - // let the expiry timer fire and park the extension on the gated sign-in - await new Promise((resolve) => setTimeout(resolve, 10)); - await service.signOut(); - releaseSignInGuest(); - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(service.getSession().isLoggedIn).toBe(false); - expect(refreshed).toHaveLength(0); - expect(expired).toHaveLength(0); - // the extension's freshly written tokens were cleared again instead of - // being left to resurrect the signed-out session on the next boot - expect(storage.persistent.data.has('refresh_token')).toBe(false); - expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); - }, - ); - - // Parked (it.todo) with the test above — the SDK-lifecycle variant of - // the same known issue, solved by the same command-lane serialization - // (dispose drains the lane). - it.todo( - 'stops an in-flight expiry handler at teardown without clearing tokens', - async () => { - let gateReads = false; - let releaseTokenRead = (): void => undefined; - const readGate = new Promise((resolve) => { - releaseTokenRead = resolve; - }); - const storage = createStorage(); - const readValue = storage.persistent.getItem; - storage.persistent.getItem = async (key) => { - if (gateReads) { - await readGate; - } - return readValue(key); - }; - const { service, calls, events } = createService({ storage }); - storage.persistent.data.set('access_token', createJwt(600)); - storage.persistent.data.set('refresh_token', createJwt(4)); - const expired: unknown[] = []; - events.on('auth.session-expired', (payload) => expired.push(payload)); - - await service.restoreSession(); - // gate the handler's token re-read so teardown lands mid-handler - gateReads = true; - await new Promise((resolve) => setTimeout(resolve, 10)); - service.teardown(); - releaseTokenRead(); - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(calls).not.toContain('signOut'); - expect(expired).toHaveLength(0); - // teardown is not logout: tokens survive for a successor instance - expect(storage.persistent.data.has('refresh_token')).toBe(true); - }, - ); + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const refreshed: unknown[] = []; + events.on('auth.session-refreshed', (payload) => refreshed.push(payload)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // let the expiry timer fire and park the extension on the gated sign-in + await new Promise((resolve) => setTimeout(resolve, 10)); + await service.signOut(); + releaseSignInGuest(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(service.getSession().isLoggedIn).toBe(false); + expect(refreshed).toHaveLength(0); + expect(expired).toHaveLength(0); + // the extension's freshly written tokens were cleared again instead of + // being left to resurrect the signed-out session on the next boot + expect(storage.persistent.data.has('refresh_token')).toBe(false); + expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); + }); + + // The SDK-lifecycle variant: teardown() lands mid-handler, so the death + // path bails before signing out — tokens survive for a successor instance + // (PR #1166 review, finding 1). + it('stops an in-flight expiry handler at teardown without clearing tokens', async () => { + let gateReads = false; + let releaseTokenRead = (): void => undefined; + const readGate = new Promise((resolve) => { + releaseTokenRead = resolve; + }); + const storage = createStorage(); + const readValue = storage.persistent.getItem; + storage.persistent.getItem = async (key) => { + if (gateReads) { + await readGate; + } + return readValue(key); + }; + const { service, calls, events } = createService({ storage }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // gate the handler's token re-read so teardown lands mid-handler + gateReads = true; + await new Promise((resolve) => setTimeout(resolve, 10)); + service.teardown(); + releaseTokenRead(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(calls).not.toContain('signOut'); + expect(expired).toHaveLength(0); + // teardown is not logout: tokens survive for a successor instance + expect(storage.persistent.data.has('refresh_token')).toBe(true); + }); it('ends the session when the extension cannot restore the guest user', async () => { let fetchCalls = 0; diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index d8ded9f3b..73fd39269 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -76,7 +76,7 @@ export class AuthService implements AuthApi { } /** - * Idempotent session restore from the storage port; resolving anonymous is + * Idempotent session restore from storage; resolving anonymous is * a state, not a failure. A rejection (unreadable storage, failed user * fetch with tokens present) is not memoized, so a retry can recover. */ @@ -300,14 +300,9 @@ export class AuthService implements AuthApi { if (this.disposed || remaining === null) { return; } - // Floor of 1ms: setLongTimeout fires synchronously at delay 0, which - // would recurse into handleSessionExpiry from inside a login verb. - this.expiryTimeout = setLongTimeout( - () => { - void this.handleSessionExpiry(); - }, - Math.max(remaining, 1), - ); + this.expiryTimeout = setLongTimeout(() => { + void this.handleSessionExpiry(); + }, remaining); } /** @@ -335,19 +330,18 @@ export class AuthService implements AuthApi { } } - // KNOWN ISSUE (accepted for now — PR #1166 review, finding 1): this - // handler races concurrent session transitions, because a fired timer - // callback can't be cancelled. A sign-out landing while the guest - // re-sign-in below is in flight is silently undone — the re-sign-in - // rewrites fresh tokens over the sign-out's clear and the unfenced apply - // resurrects the session. Same class: an in-flight handler survives - // teardown() and can clear tokens a successor instance restored (HMR). - // Inherited from master's expiry hook, not a regression. The planned fix - // is structural — serialize all session transitions through a single - // command lane (single-writer) instead of fencing every await; the - // it.todo tests in auth-service.test.ts specify the required behavior. + // Best-effort against concurrent session transitions, not race-free + // (PR #1166 review, finding 1). When a sign-out wins the race while the + // guest re-sign-in below is in flight, the handler compensates by clearing + // the fresh tokens again; teardown() is likewise checked before the + // re-sign-in's tokens or the death path are acted on. Residual windows: a + // sign-out landing just after the re-sign-in's apply still resurrects the + // session, and the cross-instance (HMR successor) case keeps a narrow gap. + // Both require the timer to fire inside a sign-out's network round trip, on + // guest sessions only. private async handleSessionExpiry(): Promise { const session = this.session; + const generation = this.sessionGeneration; if (this.disposed || !session.isLoggedIn) { return; } @@ -362,9 +356,29 @@ export class AuthService implements AuthApi { const isGuest = !session.user.email; if (isGuest) { try { - // Re-signing-in the stored guest account gets fresh tokens and re-arms - // the timer; the host never observes the expiry. - await this.signUpGuest(); + // Re-signing-in the stored guest account gets fresh tokens; the + // snapshot below re-arms the timer so the host never observes expiry. + await this.signInGuestAccount(); + if (this.disposed || this.sessionGeneration !== generation) { + // A transition won the race while the re-sign-in was in flight. If a + // sign-out ended the session, the re-sign-in's fresh tokens would + // resurrect it on the next restore, so clear them again and revoke + // server-side (opensecret owns the keys, so its signOut is the whole + // cleanup). After teardown the tokens are left for the successor + // instance; a live session means another login won and owns them. + if (!this.disposed && !this.session.isLoggedIn) { + try { + await this.deps.os.signOut(); + } catch (error) { + this.deps.logger.warn( + 'Sign out compensating a raced guest extension failed', + error, + ); + } + } + return; + } + await this.refreshSessionSnapshot('guest session extension'); const extendedRemaining = await this.getRemainingSessionTimeMs(); if ( extendedRemaining !== null && @@ -387,6 +401,11 @@ export class AuthService implements AuthApi { this.deps.logger.error('Failed to extend guest session', error); } } + if (this.disposed) { + // teardown() won the race: a successor instance may already own the + // stored session, so this dead instance must not sign out or emit. + return; + } try { await this.deps.os.signOut(); } catch (error) { From 4a0790dbc2b07e2af02926fb02e004aec2162a85 Mon Sep 17 00:00:00 2001 From: orveth Date: Thu, 16 Jul 2026 11:01:45 -0700 Subject: [PATCH 26/49] refactor(wallet-sdk): setDefaultAccount takes the full account (#1166) Per the review, the caller passes its cached account instead of an id, so the api no longer issues the extra accounts read to derive the currency server-truthfully; the cached currency is trusted. SetDefaultAccountParams now carries `account: Account`, and the web callers pass their cached account. Ownership is still enforced by RLS on the user-row write. Co-Authored-By: Claude Opus 4.8 --- .../app/features/user/user-hooks.tsx | 2 +- .../_protected.receive.cashu_.token.tsx | 2 +- .../wallet-sdk/domain/user/user-api.test.ts | 43 +++++++++---------- packages/wallet-sdk/domain/user/user-api.ts | 35 ++------------- packages/wallet-sdk/sdk/user.ts | 3 +- 5 files changed, 27 insertions(+), 58 deletions(-) diff --git a/apps/web-wallet/app/features/user/user-hooks.tsx b/apps/web-wallet/app/features/user/user-hooks.tsx index 9ce16d670..693d97601 100644 --- a/apps/web-wallet/app/features/user/user-hooks.tsx +++ b/apps/web-wallet/app/features/user/user-hooks.tsx @@ -231,7 +231,7 @@ export const useSetDefaultCurrency = () => { export const useSetDefaultAccount = () => { const { mutateAsync } = useUserUpdatingMutation((account: Account) => - sdk.user.setDefaultAccount({ accountId: account.id }), + sdk.user.setDefaultAccount({ account }), ); 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 30b83bdab..68d8af895 100644 --- a/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx +++ b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx @@ -121,7 +121,7 @@ async function trySetReceiveAccountAsDefault( } try { const updatedUser = await sdk.user.setDefaultAccount({ - accountId: account.id, + account, setDefaultCurrency: true, }); new UserCache(queryClient).set(updatedUser); diff --git a/packages/wallet-sdk/domain/user/user-api.test.ts b/packages/wallet-sdk/domain/user/user-api.test.ts index 23490970c..f5da829f5 100644 --- a/packages/wallet-sdk/domain/user/user-api.test.ts +++ b/packages/wallet-sdk/domain/user/user-api.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'bun:test'; +import type { Currency } from '@agicash/money'; import type { AgicashDb } from '../../db/database'; import type { AuthUser } from '../../sdk'; +import type { Account } from '../accounts/account'; import { createUserApi } from './user-api'; const authUser = (id: string): AuthUser => @@ -31,16 +33,19 @@ const dbUserRow = (id: string) => ({ gift_card_mint_terms_accepted_at: null, }); +const account = (id: string, currency: Currency): Account => + ({ id, currency }) as unknown as Account; + type Filters = Record; /** - * Chainable fake covering exactly the two query shapes setDefaultAccount - * issues: the accounts ownership read and the users row update. + * 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. */ -const createDbFake = (deps: { - onAccountRead: (filters: Filters) => Record; - onUserUpdate: (filters: Filters, data: Record) => void; -}) => { +const createDbFake = ( + onUserUpdate: (filters: Filters, data: Record) => void, +) => { const from = (table: string) => { const filters: Filters = {}; let updateData: Record = {}; @@ -55,10 +60,10 @@ const createDbFake = (deps: { return chain; }, single: async () => { - if (table === 'accounts') { - return { data: deps.onAccountRead(filters), error: null }; + if (table !== 'users') { + throw new Error(`unexpected read from "${table}"`); } - deps.onUserUpdate(filters, updateData); + onUserUpdate(filters, updateData); return { data: dbUserRow(String(filters.id)), error: null }; }, }; @@ -69,26 +74,18 @@ const createDbFake = (deps: { describe('createUserApi', () => { describe('setDefaultAccount', () => { - it('writes onto the user that validated account ownership, not the current session user', async () => { - let sessionUserId = 'user-a'; + it('writes the cached account onto the session user, keyed by its currency', async () => { let updatedUserId: unknown; let updatedData: Record = {}; const api = createUserApi({ - db: createDbFake({ - onAccountRead: (filters) => { - // the session switches while the account read is in flight - sessionUserId = 'user-b'; - return { id: filters.id, currency: 'BTC' }; - }, - onUserUpdate: (filters, data) => { - updatedUserId = filters.id; - updatedData = data; - }, + db: createDbFake((filters, data) => { + updatedUserId = filters.id; + updatedData = data; }), - getSession: () => ({ isLoggedIn: true, user: authUser(sessionUserId) }), + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), }); - await api.setDefaultAccount({ accountId: 'acct-1' }); + await api.setDefaultAccount({ account: account('acct-1', 'BTC') }); expect(updatedUserId).toBe('user-a'); expect(updatedData.default_btc_account_id).toBe('acct-1'); diff --git a/packages/wallet-sdk/domain/user/user-api.ts b/packages/wallet-sdk/domain/user/user-api.ts index 9435b2f21..354e49733 100644 --- a/packages/wallet-sdk/domain/user/user-api.ts +++ b/packages/wallet-sdk/domain/user/user-api.ts @@ -1,4 +1,3 @@ -import type { Currency } from '@agicash/money'; import type { AgicashDb } from '../../db/database'; import { NoSessionError } from '../../lib/error'; import type { AuthSession, UserApi } from '../../sdk'; @@ -23,24 +22,6 @@ export function createUserApi(deps: Deps): UserApi { return session.user.id; }; - const getAccountRef = async ( - userId: string, - accountId: string, - ): Promise<{ id: string; currency: Currency }> => { - const { data, error } = await deps.db - .from('accounts') - .select('id, currency') - .eq('id', accountId) - // RLS already scopes rows to the user; this is defense-in-depth per the - // "userId implicit from session" convention. - .eq('user_id', userId) - .single(); - if (error) { - throw new Error('Failed to get account', { cause: error }); - } - return data; - }; - // Methods are async so a missing session surfaces as a rejection, matching // the Promise-returning contract, not a synchronous throw. return { @@ -58,19 +39,9 @@ export function createUserApi(deps: Deps): UserApi { writeRepository.update(requireUserId(), { defaultCurrency: params.currency, }), - setDefaultAccount: async (params) => { - // The session is read once for the whole verb: the user that validated - // account ownership must be the user whose row is written, or a session - // switch during the account fetch writes the previous user's account - // onto the next user's row. - const userId = requireUserId(); - // One read, not two: the account row is fetched to derive the - // per-currency column server-truthfully; the user row isn't needed - // because the update only writes the changed columns. - const account = await getAccountRef(userId, params.accountId); - return userService.setDefaultAccount({ id: userId }, account, { + setDefaultAccount: async (params) => + userService.setDefaultAccount({ id: requireUserId() }, params.account, { setDefaultCurrency: params.setDefaultCurrency, - }); - }, + }), }; } diff --git a/packages/wallet-sdk/sdk/user.ts b/packages/wallet-sdk/sdk/user.ts index 20c9ce348..82a1629ba 100644 --- a/packages/wallet-sdk/sdk/user.ts +++ b/packages/wallet-sdk/sdk/user.ts @@ -1,4 +1,5 @@ import type { Currency } from '@agicash/money'; +import type { Account } from '../domain/accounts/account'; import type { User } from '../domain/user/user'; export type UserApi = { @@ -15,7 +16,7 @@ export type AcceptTermsParams = { }; export type SetDefaultAccountParams = { - accountId: string; + account: Account; /** Also switch the user's default currency to the account's currency. */ setDefaultCurrency?: boolean; }; From 5bbdac921b02172e19a8ca3e50eeaae5469f2c03 Mon Sep 17 00:00:00 2001 From: orveth Date: Thu, 16 Jul 2026 11:01:45 -0700 Subject: [PATCH 27/49] refactor(web-wallet): address #1166 session-event and expiry feedback - useHandleSessionEvents now takes an options object ({ onSessionExpired }) so the call site names the event the toast fires on (Petar agreed). - Delete the expiry hard-fallback reload, its 30s lastReloadAt guard, and the sessionStorage key: the SDK has already ended the session by the time cleanup can throw, so nothing sensitive survives; the catch now just logs to console + Sentry. This drops the risk of a deterministic cleanup failure reload-looping the tab against the server. - Keep reading Open Secret's token keys from local storage (the web app owns the storage it hands the SDK); drop the "temporary leak / step 18" framing. - Explain why entry.client's sdk.client side-effect import must precede the body (SDK construction configures Open Secret, which loadFeatureFlags needs for a returning user's token) and that the arrangement is temporary. Co-Authored-By: Claude Opus 4.8 --- apps/web-wallet/app/entry.client.tsx | 8 +++-- apps/web-wallet/app/features/user/auth.ts | 33 ++++++++----------- .../web-wallet/app/features/wallet/wallet.tsx | 14 ++++---- 3 files changed, 27 insertions(+), 28 deletions(-) diff --git a/apps/web-wallet/app/entry.client.tsx b/apps/web-wallet/app/entry.client.tsx index d060ee856..f91b2e6bb 100644 --- a/apps/web-wallet/app/entry.client.tsx +++ b/apps/web-wallet/app/entry.client.tsx @@ -14,8 +14,12 @@ import { HydratedRouter } from 'react-router/dom'; import { getEnvironment, isServedLocally } from './environment'; import { agicashDbClient } from './features/agicash-db/database.client'; import { loadFeatureFlags } from './features/shared/feature-flags'; -// Importing the module constructs the SDK, which configures Open Secret as an -// import-evaluation side effect — before any body code below runs. +// Side-effect import: evaluating this module constructs the SDK, which +// configures Open Secret. loadFeatureFlags() below relies on that — for a +// returning user it fetches user-targeted flags with a token minted through +// Open Secret — so the construction has to run before the body. Temporary: a +// 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 { registerMoneyDevToolsFormatter } from './lib/money-devtools-formatter'; import { getTracesSampleRate, sanitizeUrl } from './tracing-utils'; diff --git a/apps/web-wallet/app/features/user/auth.ts b/apps/web-wallet/app/features/user/auth.ts index 9fdc266c8..dbcd0d6f5 100644 --- a/apps/web-wallet/app/features/user/auth.ts +++ b/apps/web-wallet/app/features/user/auth.ts @@ -38,8 +38,8 @@ export const authStateQueryKey = 'auth-state'; // The stored token may be corrupt — safeJwtDecode keeps it from throwing out // of the queryFn or staleTime callback (that would error-page every route, // /login included). -// Temporary leak: reads Open Secret's storage keys directly until step 18 -// exposes the refresh-token expiry on the SDK session. +// Reads Open Secret's token key straight from local storage: the web app owns +// the storage it hands the SDK, so the storage backend is known here. const getRefreshTokenExpiry = (): number | null => { const refreshToken = window.localStorage.getItem('refresh_token'); if (!refreshToken) { @@ -53,9 +53,8 @@ export const authQueryOptions = () => queryKey: [authStateQueryKey], queryFn: async (): Promise => { // Associate Sentry events with the user as early as possible, before - // session restore completes. - // Temporary leak: reads Open Secret's storage keys directly until - // step 18 exposes a pre-restore session hint on the SDK. + // session restore completes, by reading Open Secret's token key straight + // from local storage (the web app owns the storage it hands the SDK). const accessToken = window.localStorage.getItem('access_token'); const sub = accessToken ? safeJwtDecode(accessToken)?.sub : undefined; if (sub) { @@ -312,11 +311,11 @@ export const useSignOut = () => { * re-runs the auth query so the session-hint cookie picks up the new expiry, * matching master's extend-through-invalidation behavior. */ -// Guards the expiry hard-fallback below: at most one reload per window, so a -// persistently failing cleanup can't reload-loop the tab. -const expiryReloadAtKey = 'agicash.session-expiry-reload-at'; - -export const useHandleSessionEvents = (onSessionExpired: () => void) => { +export const useHandleSessionEvents = ({ + onSessionExpired, +}: { + onSessionExpired: () => void; +}) => { const queryClient = useQueryClient(); const endSessionCleanup = useSessionEndCleanup(); const onSessionExpiredRef = useLatest(onSessionExpired); @@ -325,17 +324,11 @@ export const useHandleSessionEvents = (onSessionExpired: () => void) => { const handleSessionExpired = () => { onSessionExpiredRef.current(); void endSessionCleanup().catch((error) => { - // Hard fallback: the SDK already ended the session, so a reload - // boots anonymous even when the soft reset above fails mid-flight. + // The SDK has already ended the session, so nothing sensitive + // survives a failed web-side cleanup — the worst case is stale + // logged-in UI until the user reloads (PR #1166 review). console.error('Failed to handle session expiry', { cause: error }); - const lastReloadAt = Number( - window.sessionStorage.getItem(expiryReloadAtKey) ?? 0, - ); - if (Date.now() - lastReloadAt < 30_000) { - return; - } - window.sessionStorage.setItem(expiryReloadAtKey, String(Date.now())); - window.location.reload(); + Sentry.captureException(error); }); }; diff --git a/apps/web-wallet/app/features/wallet/wallet.tsx b/apps/web-wallet/app/features/wallet/wallet.tsx index e867b6fa3..eec622d0c 100644 --- a/apps/web-wallet/app/features/wallet/wallet.tsx +++ b/apps/web-wallet/app/features/wallet/wallet.tsx @@ -38,12 +38,14 @@ export const Wallet = ({ children }: PropsWithChildren) => { // Logout handles clearing Sentry user on actual logout. }, [user]); - useHandleSessionEvents(() => { - toast({ - title: 'Session expired', - description: - 'The session has expired. You will be redirected to the login page.', - }); + useHandleSessionEvents({ + onSessionExpired: () => { + toast({ + title: 'Session expired', + description: + 'The session has expired. You will be redirected to the login page.', + }); + }, }); useSyncThemeWithDefaultCurrency(); From 157ba1bb4a09129dfd78e912a2ddae61529f09b6 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 04:55:46 -0700 Subject: [PATCH 28/49] test(wallet-sdk): expand password charset coverage; note crypto global (#1166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the charset invariants: throws when no set is selected, and the output alphabet is restricted to the enabled sets (letters-only, digits-only, special-only, and the letters+digits mix). Add a note on lib/password.ts explaining why the Web Crypto global is used directly — there is no isomorphic import (node:crypto is Node-only and breaks browser bundling). Threads: [15] [17] Co-Authored-By: Claude Opus 4.8 --- packages/wallet-sdk/lib/password.test.ts | 46 ++++++++++++++++++++++++ packages/wallet-sdk/lib/password.ts | 3 ++ 2 files changed, 49 insertions(+) diff --git a/packages/wallet-sdk/lib/password.test.ts b/packages/wallet-sdk/lib/password.test.ts index 784d56790..63e286597 100644 --- a/packages/wallet-sdk/lib/password.test.ts +++ b/packages/wallet-sdk/lib/password.test.ts @@ -6,4 +6,50 @@ describe('generateRandomPassword', () => { const password = await generateRandomPassword(32); expect(password).toHaveLength(32); }); + + it('throws when no character set is selected', async () => { + await expect( + generateRandomPassword(16, { + letters: false, + numbers: false, + special: false, + }), + ).rejects.toThrow(); + }); + + it('uses only letters when letters is the sole enabled set', async () => { + const password = await generateRandomPassword(128, { + letters: true, + numbers: false, + special: false, + }); + expect(password).toMatch(/^[a-zA-Z]+$/); + }); + + it('uses only digits when numbers is the sole enabled set', async () => { + const password = await generateRandomPassword(128, { + letters: false, + numbers: true, + special: false, + }); + expect(password).toMatch(/^[0-9]+$/); + }); + + it('uses only special characters when special is the sole enabled set', async () => { + const password = await generateRandomPassword(128, { + letters: false, + numbers: false, + special: true, + }); + expect(password).toMatch(/^[!@#$%^&*()_+~]+$/); + }); + + it('mixes letters and digits when both are enabled', async () => { + const password = await generateRandomPassword(128, { + letters: true, + numbers: true, + special: false, + }); + expect(password).toMatch(/^[a-zA-Z0-9]+$/); + }); }); diff --git a/packages/wallet-sdk/lib/password.ts b/packages/wallet-sdk/lib/password.ts index 7d6257410..35bf89965 100644 --- a/packages/wallet-sdk/lib/password.ts +++ b/packages/wallet-sdk/lib/password.ts @@ -23,6 +23,9 @@ export async function generateRandomPassword( const password: string[] = []; + // globalThis.crypto is the Web Crypto API, present in the browser, Node >=20, + // and Bun. There is no isomorphic import for it: node:crypto is Node-only and + // breaks browser bundling, so the global is the portable handle. for (let i = 0; i < length; i++) { const randomIndex = globalThis.crypto.getRandomValues(new Uint32Array(1))[0] % charset.length; From 7d9e69dd2a337e64e1abd2b03e06a09e2ea3d1af Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 04:59:15 -0700 Subject: [PATCH 29/49] refactor(wallet-sdk): rename background namespace to task-processor (#1166) `background` did not convey what the namespace does. Rename to `task-processor` across the contract: the `taskProcessor` namespace, the `TaskProcessorApi` / `TaskProcessorState` types, the `task-processor.state-changed` event, the `task-processor.ts` file, and the related doc comments. This matches the term already used in the web app (the TaskProcessor component). Threads: [8] [13] Co-Authored-By: Claude Opus 4.8 --- packages/wallet-sdk/sdk/auth.ts | 2 +- packages/wallet-sdk/sdk/events.ts | 7 +++++-- packages/wallet-sdk/sdk/index.ts | 12 ++++++------ packages/wallet-sdk/sdk/receive.ts | 2 +- packages/wallet-sdk/sdk/server.ts | 2 +- .../sdk/{background.ts => task-processor.ts} | 8 ++++---- 6 files changed, 18 insertions(+), 15 deletions(-) rename packages/wallet-sdk/sdk/{background.ts => task-processor.ts} (68%) diff --git a/packages/wallet-sdk/sdk/auth.ts b/packages/wallet-sdk/sdk/auth.ts index dc576c723..dec70a557 100644 --- a/packages/wallet-sdk/sdk/auth.ts +++ b/packages/wallet-sdk/sdk/auth.ts @@ -36,7 +36,7 @@ export type AuthApi = { signUpGuest(): Promise; signIn(email: string, password: string): Promise; /** - * Stops background, tears down realtime, clears the stored session; the + * Stops the task processor, tears down realtime, clears the stored session; the * instance stays usable in anonymous state. */ signOut(): Promise; diff --git a/packages/wallet-sdk/sdk/events.ts b/packages/wallet-sdk/sdk/events.ts index b90447536..8a1c20384 100644 --- a/packages/wallet-sdk/sdk/events.ts +++ b/packages/wallet-sdk/sdk/events.ts @@ -2,7 +2,7 @@ import type { Money } from '@agicash/money'; import type { User } from '../domain/user/user'; import type { SdkError } from '../lib/error'; import type { Account } from './accounts'; -import type { BackgroundState } from './background'; +import type { TaskProcessorState } from './task-processor'; import type { Contact } from './contacts'; import type { CashuReceiveQuote, @@ -59,7 +59,10 @@ export type WalletEventMap = { * Fires on every `state` transition; `error` set on transitions into * `'error'`. Per-task errors never change state, so they never fire it. */ - 'background.state-changed': { state: BackgroundState; error?: SdkError }; + 'task-processor.state-changed': { + state: TaskProcessorState; + error?: SdkError; + }; }; /** diff --git a/packages/wallet-sdk/sdk/index.ts b/packages/wallet-sdk/sdk/index.ts index 728063690..7caac3c64 100644 --- a/packages/wallet-sdk/sdk/index.ts +++ b/packages/wallet-sdk/sdk/index.ts @@ -7,7 +7,7 @@ import type { SparkNetwork } from '../db/json-models/spark-account-details-db-data'; import type { AccountsApi } from './accounts'; import type { AuthApi, AuthStorage } from './auth'; -import type { BackgroundApi } from './background'; +import type { TaskProcessorApi } from './task-processor'; import type { ContactsApi } from './contacts'; import type { WalletEvents } from './events'; import type { FeatureFlagsApi } from './feature-flags'; @@ -19,13 +19,13 @@ import type { UserApi } from './user'; export * from './accounts'; export * from './auth'; -export * from './background'; export * from './contacts'; export * from './events'; export * from './feature-flags'; export * from './receive'; export * from './send'; export * from './server'; +export * from './task-processor'; export * from './transactions'; export * from './transfer'; export * from './user'; @@ -78,7 +78,7 @@ export type Sdk = { readonly transfer: TransferApi; readonly featureFlags: FeatureFlagsApi; readonly events: WalletEvents; - readonly background: BackgroundApi; + readonly taskProcessor: TaskProcessorApi; /** * Front-loads session restore and the Breez WASM load. Resolves when no * session exists (a state, not a failure); rejects on actual failures, @@ -92,9 +92,9 @@ export type Sdk = { */ init(): Promise; /** - * Awaits in-flight background transitions to their next checkpoint, then - * tears down realtime + background; still-pending namespace promises reject - * with a typed `SdkError`. + * Awaits in-flight task-processor transitions to their next checkpoint, then + * tears down realtime + the task processor; still-pending namespace promises + * reject with a typed `SdkError`. */ dispose(): Promise; }; diff --git a/packages/wallet-sdk/sdk/receive.ts b/packages/wallet-sdk/sdk/receive.ts index 6e454e6dd..5a6d31906 100644 --- a/packages/wallet-sdk/sdk/receive.ts +++ b/packages/wallet-sdk/sdk/receive.ts @@ -56,7 +56,7 @@ export type CashuReceiveSwap = Omit< /** * `get*` methods are stateless previews; `create*` methods persist and enter - * the entity into the background lifecycle. Completion is observed via + * the entity into the task-processor lifecycle. Completion is observed via * `events`, never called by the host. */ export type ReceiveApi = { diff --git a/packages/wallet-sdk/sdk/server.ts b/packages/wallet-sdk/sdk/server.ts index fac6fb754..538148b45 100644 --- a/packages/wallet-sdk/sdk/server.ts +++ b/packages/wallet-sdk/sdk/server.ts @@ -9,7 +9,7 @@ import type { SparkNetwork } from '../db/json-models/spark-account-details-db-da /** * Server-side trust model: service-role key, no user session, per-request - * scope. No `auth`, no `events`, no `background`. + * scope. No `auth`, no `events`, no `taskProcessor`. */ export type ServerSdkConfig = { db: { url: string; serviceRoleKey: string }; diff --git a/packages/wallet-sdk/sdk/background.ts b/packages/wallet-sdk/sdk/task-processor.ts similarity index 68% rename from packages/wallet-sdk/sdk/background.ts rename to packages/wallet-sdk/sdk/task-processor.ts index 7e24a752f..f8c46aac5 100644 --- a/packages/wallet-sdk/sdk/background.ts +++ b/packages/wallet-sdk/sdk/task-processor.ts @@ -1,11 +1,11 @@ -export type BackgroundState = 'stopped' | 'follower' | 'leader' | 'error'; +export type TaskProcessorState = 'stopped' | 'follower' | 'leader' | 'error'; /** - * Execution is background-only: a host must run `start()` somewhere or + * Runs the money-moving work queue. A host must run `start()` somewhere or * nothing moves money. The executing instance may differ from the initiating * one (the leader lock is per-user across devices). */ -export type BackgroundApi = { +export type TaskProcessorApi = { /** * Leader election + processors. * @throws {SdkError} when no authenticated session exists. @@ -17,5 +17,5 @@ export type BackgroundApi = { * abandons the remaining queue. */ stop(): Promise; - readonly state: BackgroundState; + readonly state: TaskProcessorState; }; From 6f3bc32dde14061e8f17007bee868bc90ffccd53 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 05:03:50 -0700 Subject: [PATCH 30/49] refactor(wallet-sdk): expose receive/send domain types as the contract (#1166) The receive and send namespaces re-export the domain entity types directly instead of projecting them. Consumers of this SDK only read these shapes, so exposing the domain fields (proofs, userId) is acceptable for now and keeps a single source of truth; narrowing can come later. This removes the projection type aliases in sdk/receive.ts and sdk/send.ts and the variant-union type exports in the receive domain files that existed only to support them. Threads: [11] [12] [14] (accounts [0] flagged separately) Co-Authored-By: Claude Opus 4.8 --- .../domain/receive/cashu-receive-quote.ts | 12 ---- .../domain/receive/cashu-receive-swap.ts | 9 --- .../domain/receive/spark-receive-quote.ts | 12 ---- packages/wallet-sdk/sdk/receive.ts | 56 ++----------------- packages/wallet-sdk/sdk/send.ts | 12 +--- 5 files changed, 7 insertions(+), 94 deletions(-) diff --git a/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts b/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts index 7b2ebad10..60ba91651 100644 --- a/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts +++ b/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts @@ -158,15 +158,3 @@ export const CashuReceiveQuoteSchema = z.intersection( ); export type CashuReceiveQuote = z.infer; - -/** - * The variant unions on their own, for projections that rebuild the - * intersection — a bare `Omit` over the full type collapses each union to its - * shared keys. - */ -export type CashuReceiveQuoteTypeVariant = z.infer< - typeof CashuReceiveQuoteTypeSchema ->; -export type CashuReceiveQuoteStateVariant = z.infer< - typeof CashuReceiveQuoteStateSchema ->; diff --git a/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts b/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts index abc59b9c2..01ce0b972 100644 --- a/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts +++ b/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts @@ -117,12 +117,3 @@ export const CashuReceiveSwapSchema = z.intersection( ); export type CashuReceiveSwap = z.infer; - -/** - * The state variant union on its own, for projections that rebuild the - * intersection — a bare `Omit` over the full type collapses the union to its - * shared keys. - */ -export type CashuReceiveSwapStateVariant = z.infer< - typeof CashuReceiveSwapStateSchema ->; diff --git a/packages/wallet-sdk/domain/receive/spark-receive-quote.ts b/packages/wallet-sdk/domain/receive/spark-receive-quote.ts index ac3a6ab7c..bb2fbae63 100644 --- a/packages/wallet-sdk/domain/receive/spark-receive-quote.ts +++ b/packages/wallet-sdk/domain/receive/spark-receive-quote.ts @@ -145,15 +145,3 @@ export const SparkReceiveQuoteSchema = z.intersection( ); export type SparkReceiveQuote = z.infer; - -/** - * The variant unions on their own, for projections that rebuild the - * intersection — a bare `Omit` over the full type collapses each union to its - * shared keys. - */ -export type SparkReceiveQuoteTypeVariant = z.infer< - typeof SparkReceiveQuoteTypeSchema ->; -export type SparkReceiveQuoteStateVariant = z.infer< - typeof SparkReceiveQuoteStateSchema ->; diff --git a/packages/wallet-sdk/sdk/receive.ts b/packages/wallet-sdk/sdk/receive.ts index 5a6d31906..f92a06ca0 100644 --- a/packages/wallet-sdk/sdk/receive.ts +++ b/packages/wallet-sdk/sdk/receive.ts @@ -1,58 +1,10 @@ -import type { - CashuReceiveQuoteStateVariant, - CashuReceiveQuoteTypeVariant, - CashuReceiveQuote as DomainCashuReceiveQuote, -} from '../domain/receive/cashu-receive-quote'; +import type { CashuReceiveQuote } from '../domain/receive/cashu-receive-quote'; import type { CashuReceiveLightningQuote } from '../domain/receive/cashu-receive-quote-core'; -import type { - CashuReceiveSwapStateVariant, - CashuReceiveSwap as DomainCashuReceiveSwap, -} from '../domain/receive/cashu-receive-swap'; -import type { CashuTokenMeltData } from '../domain/receive/cashu-token-melt-data'; -import type { - SparkReceiveQuote as DomainSparkReceiveQuote, - SparkReceiveQuoteStateVariant, - SparkReceiveQuoteTypeVariant, -} from '../domain/receive/spark-receive-quote'; +import type { SparkReceiveQuote } from '../domain/receive/spark-receive-quote'; import type { SparkReceiveLightningQuote } from '../domain/receive/spark-receive-quote-core'; -// The domain entities are intersections over variant unions (`Base & (A | B)`), -// and a bare `Omit` over such a type collapses each union to its shared keys — -// variant-only fields silently vanish and discriminant narrowing breaks. The -// projections below therefore omit base keys only and re-apply the variant -// unions. Spendable Cashu proof material (top-level `tokenProofs` and the -// melt data's `tokenProofs`) is stripped from the public shapes; the -// implementing slices (steps 9/11/12) must strip it at runtime at this same -// boundary. - -/** Distributes over a type-variant union, stripping proofs from the melt data. */ -type WithPublicTokenReceiveData = T extends { - tokenReceiveData: CashuTokenMeltData; -} - ? Omit & { - tokenReceiveData: Omit; - } - : T; - -export type CashuReceiveQuote = Omit< - DomainCashuReceiveQuote, - 'userId' | 'type' | 'state' -> & - WithPublicTokenReceiveData & - CashuReceiveQuoteStateVariant; - -export type SparkReceiveQuote = Omit< - DomainSparkReceiveQuote, - 'userId' | 'type' | 'state' -> & - WithPublicTokenReceiveData & - SparkReceiveQuoteStateVariant; - -export type CashuReceiveSwap = Omit< - DomainCashuReceiveSwap, - 'userId' | 'tokenProofs' | 'state' -> & - CashuReceiveSwapStateVariant; +export type { CashuReceiveSwap } from '../domain/receive/cashu-receive-swap'; +export type { CashuReceiveQuote, SparkReceiveQuote }; /** * `get*` methods are stateless previews; `create*` methods persist and enter diff --git a/packages/wallet-sdk/sdk/send.ts b/packages/wallet-sdk/sdk/send.ts index 645146b7e..85c43172c 100644 --- a/packages/wallet-sdk/sdk/send.ts +++ b/packages/wallet-sdk/sdk/send.ts @@ -1,17 +1,11 @@ -import type { CashuSendQuote as DomainCashuSendQuote } from '../domain/send/cashu-send-quote'; import type { CashuLightningQuote } from '../domain/send/cashu-send-quote-service'; -import type { CashuSendSwap as DomainCashuSendSwap } from '../domain/send/cashu-send-swap'; import type { CashuSwapQuote } from '../domain/send/cashu-send-swap-service'; -import type { SparkSendQuote as DomainSparkSendQuote } from '../domain/send/spark-send-quote'; import type { SparkLightningQuote } from '../domain/send/spark-send-quote-service'; import type { DestinationDetails } from '../lib/send-destination'; -export type CashuSendQuote = Omit; -export type CashuSendSwap = Omit< - DomainCashuSendSwap, - 'inputProofs' | 'proofsToSend' | 'userId' ->; -export type SparkSendQuote = Omit; +export type { CashuSendQuote } from '../domain/send/cashu-send-quote'; +export type { CashuSendSwap } from '../domain/send/cashu-send-swap'; +export type { SparkSendQuote } from '../domain/send/spark-send-quote'; export type SendApi = { resolveDestination(input: string): Promise; From 76ae4bc925f4cf4f951214edadda32de193d2db1 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 05:12:37 -0700 Subject: [PATCH 31/49] refactor: inject the token exchanger, relocate supabase config + logger note (#1166) - supabase-session: `generateToken` is now required and injected from the composition root, so wallet-sdk/db no longer imports @agicash/opensecret. agicash-sdk passes openSecret.generateThirdPartyToken. - Define the Supabase url + anon key in sdk.client.ts and import them into database.client.ts, which is the file being retired. - Add a note on the console logger explaining why the meta === undefined check is shaped that way (it avoids logging a trailing "undefined"). Threads: [3] [24] [26] Co-Authored-By: Claude Opus 4.8 --- .../features/agicash-db/database.client.ts | 29 +--------------- .../app/features/shared/sdk.client.ts | 34 ++++++++++++++++--- packages/wallet-sdk/agicash-sdk.ts | 1 + packages/wallet-sdk/db/supabase-session.ts | 7 ++-- 4 files changed, 35 insertions(+), 36 deletions(-) diff --git a/apps/web-wallet/app/features/agicash-db/database.client.ts b/apps/web-wallet/app/features/agicash-db/database.client.ts index 8f87bd3c8..d724a35c4 100644 --- a/apps/web-wallet/app/features/agicash-db/database.client.ts +++ b/apps/web-wallet/app/features/agicash-db/database.client.ts @@ -1,36 +1,9 @@ import type { Database } from '@agicash/wallet-sdk/temporary'; import { createClient } from '@supabase/supabase-js'; +import { supabaseAnonKey, supabaseUrl } from '~/features/shared/sdk.client'; import { SupabaseRealtimeManager } from '~/lib/supabase'; import { getSupabaseSessionToken } from './supabase-session'; -const getSupabaseUrl = () => { - const supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? ''; - if (!supabaseUrl) { - throw new Error('VITE_SUPABASE_URL is not set'); - } - - if ( - supabaseUrl.includes('127.0.0.1') && - typeof window !== 'undefined' && - window.location.protocol === 'https:' && - (window.location.hostname.endsWith('.local') || - window.location.hostname.startsWith('192.168.') || - window.location.hostname.startsWith('10.') || - /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(window.location.hostname)) - ) { - return supabaseUrl.replace('127.0.0.1', window.location.hostname); - } - - return supabaseUrl; -}; - -export const supabaseUrl = getSupabaseUrl(); - -export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? ''; -if (!supabaseAnonKey) { - throw new Error('VITE_SUPABASE_ANON_KEY is not set'); -} - /** * The client-side Supabase database client. * If you need to use a client on the server, which bypasses RLS, use `agicashDbServer` instead. diff --git a/apps/web-wallet/app/features/shared/sdk.client.ts b/apps/web-wallet/app/features/shared/sdk.client.ts index 491029edd..46c979c57 100644 --- a/apps/web-wallet/app/features/shared/sdk.client.ts +++ b/apps/web-wallet/app/features/shared/sdk.client.ts @@ -1,11 +1,35 @@ import { browserStorage } from '@agicash/opensecret'; import { AgicashSdk } from '@agicash/wallet-sdk'; -import { - supabaseAnonKey, - supabaseUrl, -} from '~/features/agicash-db/database.client'; import { breezApiKey } from '~/lib/breez'; +const getSupabaseUrl = () => { + const supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? ''; + if (!supabaseUrl) { + throw new Error('VITE_SUPABASE_URL is not set'); + } + + if ( + supabaseUrl.includes('127.0.0.1') && + typeof window !== 'undefined' && + window.location.protocol === 'https:' && + (window.location.hostname.endsWith('.local') || + window.location.hostname.startsWith('192.168.') || + window.location.hostname.startsWith('10.') || + /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(window.location.hostname)) + ) { + return supabaseUrl.replace('127.0.0.1', window.location.hostname); + } + + return supabaseUrl; +}; + +export const supabaseUrl = getSupabaseUrl(); + +export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? ''; +if (!supabaseAnonKey) { + throw new Error('VITE_SUPABASE_ANON_KEY is not set'); +} + const openSecretApiUrl = import.meta.env.VITE_OPEN_SECRET_API_URL ?? ''; if (!openSecretApiUrl) { throw new Error('VITE_OPEN_SECRET_API_URL is not set'); @@ -16,6 +40,8 @@ if (!openSecretClientId) { throw new Error('VITE_OPEN_SECRET_CLIENT_ID is not set'); } +// console.debug(message, undefined) prints a trailing "undefined". Most calls +// pass no meta, so the second argument is omitted unless meta is provided. const consoleLogger = { debug: (message: string, meta?: unknown) => meta === undefined ? console.debug(message) : console.debug(message, meta), diff --git a/packages/wallet-sdk/agicash-sdk.ts b/packages/wallet-sdk/agicash-sdk.ts index 54ec1781f..63ead53a1 100644 --- a/packages/wallet-sdk/agicash-sdk.ts +++ b/packages/wallet-sdk/agicash-sdk.ts @@ -46,6 +46,7 @@ export class AgicashSdk // lazily at request time, after the constructor has assigned it. const sessionToken = createSupabaseSessionTokenGetter({ isLoggedIn: () => this.authService.getSession().isLoggedIn, + generateToken: () => openSecret.generateThirdPartyToken(), }); this.authService = new AuthService({ diff --git a/packages/wallet-sdk/db/supabase-session.ts b/packages/wallet-sdk/db/supabase-session.ts index f9ac33887..5aeddb26c 100644 --- a/packages/wallet-sdk/db/supabase-session.ts +++ b/packages/wallet-sdk/db/supabase-session.ts @@ -1,10 +1,9 @@ -import { generateThirdPartyToken } from '@agicash/opensecret'; import { jwtDecode } from 'jwt-decode'; type Deps = { isLoggedIn: () => boolean; - /** Test seam; defaults to Open Secret's generateThirdPartyToken. */ - generateToken?: () => Promise<{ token: string }>; + /** Exchanges the Open Secret JWT for a Supabase third-party token. */ + generateToken: () => Promise<{ token: string }>; }; export type SupabaseSessionTokenSource = { @@ -27,7 +26,7 @@ export type SupabaseSessionTokenSource = { export function createSupabaseSessionTokenGetter( deps: Deps, ): SupabaseSessionTokenSource { - const generateToken = deps.generateToken ?? (() => generateThirdPartyToken()); + const generateToken = deps.generateToken; let cached: { token: string; refreshAtMs: number } | undefined; let inFlight: Promise | undefined; // Incremented on invalidation; an exchange started under an older From 43a638b4407e310256ca64f6125d87f942ee2fba Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 05:12:38 -0700 Subject: [PATCH 32/49] refactor(wallet-sdk): split user write repository by operation (#1166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update-only consumers (UserService, user-api) do not need AccountRepository, so the write repository is split: UpdateUserRepository takes only `db`, and UpsertUserRepository takes `db` + `accountRepository` in its constructor rather than as a per-call parameter — consistent with how other services receive their dependencies. The web _protected upsert glue constructs UpsertUserRepository with the account repository. Threads: [22] Co-Authored-By: Claude Opus 4.8 --- apps/web-wallet/app/routes/_protected.tsx | 32 +++++++++---------- packages/wallet-sdk/domain/user/user-api.ts | 12 +++---- .../wallet-sdk/domain/user/user-repository.ts | 12 +++++-- .../wallet-sdk/domain/user/user-service.ts | 4 +-- packages/wallet-sdk/temporary.ts | 3 +- 5 files changed, 35 insertions(+), 28 deletions(-) diff --git a/apps/web-wallet/app/routes/_protected.tsx b/apps/web-wallet/app/routes/_protected.tsx index 4b743885c..5fb4e3cb7 100644 --- a/apps/web-wallet/app/routes/_protected.tsx +++ b/apps/web-wallet/app/routes/_protected.tsx @@ -4,7 +4,7 @@ import type { AuthUser } from '@agicash/wallet-sdk'; import { AccountRepository, BASE_CASHU_LOCKING_DERIVATION_PATH, - WriteUserRepository, + UpsertUserRepository, ensureBreezWasm, getEncryption, } from '@agicash/wallet-sdk/temporary'; @@ -118,24 +118,24 @@ const ensureUserData = async ( getSparkWalletMnemonic, { storageDir: './.spark-data', apiKey: breezApiKey }, ); - const writeUserRepository = new WriteUserRepository(agicashDbClient); + const upsertUserRepository = new UpsertUserRepository( + agicashDbClient, + accountRepository, + ); const { user: upsertedUser, accounts } = await withRetry({ fn: () => - writeUserRepository.upsert( - { - id: authUser.id, - email: authUser.email, - emailVerified: authUser.email_verified, - accounts: [...defaultAccounts], - cashuLockingXpub, - encryptionPublicKey, - sparkIdentityPublicKey, - termsAcceptedAt, - giftCardMintTermsAcceptedAt, - }, - accountRepository, - ), + 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; diff --git a/packages/wallet-sdk/domain/user/user-api.ts b/packages/wallet-sdk/domain/user/user-api.ts index 354e49733..ad3f66412 100644 --- a/packages/wallet-sdk/domain/user/user-api.ts +++ b/packages/wallet-sdk/domain/user/user-api.ts @@ -1,7 +1,7 @@ import type { AgicashDb } from '../../db/database'; import { NoSessionError } from '../../lib/error'; import type { AuthSession, UserApi } from '../../sdk'; -import { ReadUserRepository, WriteUserRepository } from './user-repository'; +import { ReadUserRepository, UpdateUserRepository } from './user-repository'; import { UserService } from './user-service'; type Deps = { @@ -11,8 +11,8 @@ type Deps = { export function createUserApi(deps: Deps): UserApi { const readRepository = new ReadUserRepository(deps.db); - const writeRepository = new WriteUserRepository(deps.db); - const userService = new UserService(writeRepository); + const updateRepository = new UpdateUserRepository(deps.db); + const userService = new UserService(updateRepository); const requireUserId = (): string => { const session = deps.getSession(); @@ -27,16 +27,16 @@ export function createUserApi(deps: Deps): UserApi { return { get: async () => readRepository.get(requireUserId()), updateUsername: async (username) => - writeRepository.update(requireUserId(), { username }), + updateRepository.update(requireUserId(), { username }), acceptTerms: async (params) => { const now = new Date().toISOString(); - return writeRepository.update(requireUserId(), { + return updateRepository.update(requireUserId(), { termsAcceptedAt: params.walletTerms ? now : undefined, giftCardMintTermsAcceptedAt: params.giftCardTerms ? now : undefined, }); }, setDefaultCurrency: async (params) => - writeRepository.update(requireUserId(), { + updateRepository.update(requireUserId(), { defaultCurrency: params.currency, }), setDefaultAccount: async (params) => diff --git a/packages/wallet-sdk/domain/user/user-repository.ts b/packages/wallet-sdk/domain/user/user-repository.ts index e68c9e74f..87698641e 100644 --- a/packages/wallet-sdk/domain/user/user-repository.ts +++ b/packages/wallet-sdk/domain/user/user-repository.ts @@ -53,7 +53,7 @@ type AccountInput = { | 'state' >; -export class WriteUserRepository { +export class UpdateUserRepository { constructor(private readonly db: AgicashDb) {} /** @@ -95,6 +95,13 @@ export class WriteUserRepository { return ReadUserRepository.toUser(updatedUser); } +} + +export class UpsertUserRepository { + constructor( + private readonly db: AgicashDb, + private readonly accountRepository: AccountRepository, + ) {} /** * Inserts a user into the database. If the user already exists, it updates the user. @@ -143,7 +150,6 @@ export class WriteUserRepository { */ giftCardMintTermsAcceptedAt?: string; }, - accountRepository: AccountRepository, options?: Options, ): Promise<{ user: User; accounts: Account[] }> { const accountsToAdd = user.accounts.map((account) => ({ @@ -193,7 +199,7 @@ export class WriteUserRepository { return { user: ReadUserRepository.toUser(upsertedUser), accounts: await Promise.all( - accounts.map((account) => accountRepository.toAccount(account)), + accounts.map((account) => this.accountRepository.toAccount(account)), ), }; } diff --git a/packages/wallet-sdk/domain/user/user-service.ts b/packages/wallet-sdk/domain/user/user-service.ts index 41323c6b7..3e415ab6e 100644 --- a/packages/wallet-sdk/domain/user/user-service.ts +++ b/packages/wallet-sdk/domain/user/user-service.ts @@ -1,6 +1,6 @@ import type { Account, ExtendedAccount } from '../accounts/account'; import type { User } from './user'; -import type { WriteUserRepository } from './user-repository'; +import type { UpdateUserRepository } from './user-repository'; type SetDefaultAccountOptions = { /** @@ -11,7 +11,7 @@ type SetDefaultAccountOptions = { }; export class UserService { - constructor(private readonly userRepository: WriteUserRepository) {} + constructor(private readonly userRepository: UpdateUserRepository) {} /** * Returns true if the account is the user's default account for the respective currency. diff --git a/packages/wallet-sdk/temporary.ts b/packages/wallet-sdk/temporary.ts index fd2296f29..f4f8c9635 100644 --- a/packages/wallet-sdk/temporary.ts +++ b/packages/wallet-sdk/temporary.ts @@ -108,7 +108,8 @@ export { AccountService } from './domain/accounts/account-service'; export { ReadUserDefaultAccountRepository, ReadUserRepository, - WriteUserRepository, + UpdateUserRepository, + UpsertUserRepository, } from './domain/user/user-repository'; export { UserService } from './domain/user/user-service'; export { isContact } from './domain/contacts/contact'; From 84083647c33e32733d0baaed4878e5aaeddb4738 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 05:26:35 -0700 Subject: [PATCH 33/49] refactor(wallet-sdk): scope the session with an AbortController + guard disposal (#1166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth-service: - The session is scoped by an AbortController (`sessionScope`): every session transition aborts the current scope and installs a fresh one, and an in-flight restore or apply bails when its scope is aborted. This fences a stale restore out when a sign-out or a newer login wins the race, and clears a raced guest re-sign-in's fresh tokens when a sign-out wins. - Invoking an action after teardown throws DisposedError (new error type). - The expiry timer helpers are named setExpiryTimer / clearExpiryTimer, and the discarded-promise call site no longer needs the `void` operator. - Comments read in plain language: an auth action (sign-in, sign-up, …), with each explaining its own reason directly. - events: auth.session-refreshed and the event-naming grammar read plainly. - Tests cover the abort paths (a sign-out fences an in-flight restore) and that actions throw once the instance is disposed. Threads: [6] [27] [29] [30] [31] [32] [34] [36] [37] [38] [39] Co-Authored-By: Claude Opus 4.8 --- .../domain/user/auth-service.test.ts | 60 +++++++- .../wallet-sdk/domain/user/auth-service.ts | 144 ++++++++++-------- packages/wallet-sdk/lib/error.ts | 8 + packages/wallet-sdk/sdk/events.ts | 17 ++- packages/wallet-sdk/sdk/index.ts | 2 +- 5 files changed, 153 insertions(+), 78 deletions(-) diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts index 4c45f79e3..b6379647f 100644 --- a/packages/wallet-sdk/domain/user/auth-service.test.ts +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'bun:test'; +import { DisposedError } from '../../lib/error'; import { WalletEventEmitter } from '../../lib/events'; import { nullLogger } from '../../lib/logger'; import type { AuthKeyValueStore, AuthStorage } from '../../sdk'; @@ -191,12 +192,12 @@ describe('AuthService', () => { expect(calls).not.toContain('fetchUser'); }); - it('does not clobber a session a verb established while the restore fetch was in flight', async () => { + it('does not clobber a session an auth action established while the restore fetch was in flight', async () => { let releaseRestoreFetch = (): void => undefined; const restoreFetchGate = new Promise((resolve) => { releaseRestoreFetch = resolve; }); - const verbUser = { ...fullUser, id: 'user-verb' }; + const actionUser = { ...fullUser, id: 'user-action' }; let fetchCalls = 0; const { service, storage } = createService({ os: { @@ -206,7 +207,7 @@ describe('AuthService', () => { await restoreFetchGate; return { user: fullUser }; } - return { user: verbUser }; + return { user: actionUser }; }, }, }); @@ -216,18 +217,47 @@ describe('AuthService', () => { const restore = service.restoreSession(); // Flush microtasks so the restore's gated fetchUser is in flight first. await new Promise((resolve) => setTimeout(resolve, 0)); - await service.signIn('verb@b.c', 'pw'); + await service.signIn('action@b.c', 'pw'); releaseRestoreFetch(); await restore; expect(service.getSession()).toEqual({ isLoggedIn: true, - user: verbUser, + user: actionUser, }); service.teardown(); }); - it('does not repeat the user fetch when a verb already established the session', async () => { + it('does not resurrect a session signed out while the restore fetch was in flight', async () => { + let releaseRestoreFetch = (): void => undefined; + const restoreFetchGate = new Promise((resolve) => { + releaseRestoreFetch = resolve; + }); + const { service, storage } = createService({ + os: { + fetchUser: async () => { + await restoreFetchGate; + return { user: fullUser }; + }, + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + const restore = service.restoreSession(); + // Flush microtasks so the restore's gated fetchUser is in flight first. + await new Promise((resolve) => setTimeout(resolve, 0)); + await service.signOut(); + releaseRestoreFetch(); + await restore; + + // The sign-out aborted the restore's session scope, so its late apply is + // fenced out rather than resurrecting the signed-out session. + expect(service.getSession().isLoggedIn).toBe(false); + service.teardown(); + }); + + it('does not repeat the user fetch when an auth action already established the session', async () => { const { service, calls } = createService(); await service.signIn('a@b.c', 'pw'); @@ -257,8 +287,8 @@ describe('AuthService', () => { throw new Error('slow network failure'); } if (fetchCalls === 2) { - // the verb's snapshot refresh: ends the session, un-memoizes A - throw new Error('verb fetch failed'); + // the auth action's snapshot refresh: ends the session, un-memoizes A + throw new Error('auth action fetch failed'); } // restore B await thirdFetchGate; @@ -664,4 +694,18 @@ describe('AuthService', () => { expect(service.getSession()).toEqual({ isLoggedIn: true, user: fullUser }); service.teardown(); }); + + describe('dispose', () => { + it('throws when an action is invoked after teardown', async () => { + const { service } = createService(); + service.teardown(); + + await expect(service.signIn('a@b.c', 'pw')).rejects.toBeInstanceOf( + DisposedError, + ); + await expect(service.restoreSession()).rejects.toBeInstanceOf( + DisposedError, + ); + }); + }); }); diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index 73fd39269..dcb6f9bbd 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -9,6 +9,7 @@ import { safeJwtDecode, setLongTimeout, } from '@agicash/utils'; +import { DisposedError } from '../../lib/error'; import type { WalletEventEmitter } from '../../lib/events'; import type { AuthApi, AuthSession, AuthStorage, Logger } from '../../sdk'; import type { GuestAccountStorage } from './guest-account-storage'; @@ -63,10 +64,11 @@ export class AuthService implements AuthApi { private expiryTimeout: LongTimeout | undefined; // Survives endSession() deliberately — see applySessionFromServer. private lastUserId: string | undefined; - // Bumped on every session transition (login apply or session end). A - // restore captures it before its user fetch; a result from a generation - // that has passed must not apply, or it would clobber the newer session. - private sessionGeneration = 0; + // Aborted and replaced on every session transition (a login apply or a + // session end). An in-flight restore holds the signal it started under; once + // that signal is aborted its result belongs to a session that no longer + // exists and must not be applied. + private sessionScope = new AbortController(); private disposed = false; constructor(private readonly deps: AuthServiceDeps) {} @@ -80,7 +82,8 @@ export class AuthService implements AuthApi { * a state, not a failure. A rejection (unreadable storage, failed user * fetch with tokens present) is not memoized, so a retry can recover. */ - restoreSession(): Promise { + async restoreSession(): Promise { + this.assertNotDisposed(); if (!this.restorePromise) { const restorePromise: Promise = this.doRestore().catch((error) => { // Un-memoize only our own memo: a session end may already have @@ -97,9 +100,9 @@ export class AuthService implements AuthApi { private async doRestore(): Promise { if (this.session.isLoggedIn) { - // A verb already established the session (and a previous session end - // un-memoized the restore); booting from storage would only repeat the - // verb's user fetch. + // 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. return; } const [accessToken, refreshToken] = await Promise.all([ @@ -110,24 +113,24 @@ export class AuthService implements AuthApi { return; } if (!safeJwtDecode(refreshToken)?.exp) { - // An undecodable (or exp-less) refresh token can't arm the expiry - // machinery and can't be refreshed — the restored session would be - // unmanaged and die unrecoverably mid-use. Restore anonymous instead. + // A refresh token with no readable expiry can't be scheduled for renewal + // and can't be refreshed, so the session would run until the token + // silently expired and then break with no way to recover. Stay anonymous + // instead of entering a session that can't be kept alive. return; } - const generation = this.sessionGeneration; + const scope = this.sessionScope.signal; try { - await this.applySessionFromServer({ expectedGeneration: generation }); + await this.applySessionFromServer({ scope }); } catch (error) { if (this.session.isLoggedIn) { - // An auth verb established a session while this restore was in - // flight; the restore result is moot. + // An auth action established a session while this restore's fetch was + // in flight; the restore result is moot. return; } - if (this.sessionGeneration !== generation) { - // Another transition (a verb, sign-out, or a newer restore's apply) - // owns the session state now; ending the session here would bump the - // generation again and fence that owner's in-flight apply out. + if (scope.aborted) { + // A sign-out or a newer session apply owns the session state now; + // ending the session here would abort that owner's scope too. throw error; } // Contract: init() rejects on refresh errors (tokens exist but can't @@ -139,11 +142,13 @@ export class AuthService implements AuthApi { } async signUp(email: string, password: string): Promise { + this.assertNotDisposed(); await this.deps.os.signUp(email, password, ''); await this.refreshSessionSnapshot('sign up'); } async signUpGuest(): Promise { + this.assertNotDisposed(); await this.signInGuestAccount(); await this.refreshSessionSnapshot('guest sign up'); } @@ -187,11 +192,13 @@ export class AuthService implements AuthApi { } async signIn(email: string, password: string): Promise { + this.assertNotDisposed(); await this.deps.os.signIn(email, password); await this.refreshSessionSnapshot('sign in'); } async signOut(): Promise { + this.assertNotDisposed(); try { await this.deps.os.signOut(); } finally { @@ -200,11 +207,13 @@ export class AuthService implements AuthApi { } async verifyEmail(code: string): Promise { + this.assertNotDisposed(); await this.deps.os.verifyEmail(code); await this.refreshSessionSnapshot('email verification'); } - requestNewVerificationCode(): Promise { + async requestNewVerificationCode(): Promise { + this.assertNotDisposed(); return this.deps.os.requestNewVerificationCode(); } @@ -212,12 +221,14 @@ export class AuthService implements AuthApi { email: string, password: string, ): Promise { + this.assertNotDisposed(); await this.deps.os.convertGuestToUserAccount(email, password); await this.deps.guestAccountStorage.clear(); await this.refreshSessionSnapshot('guest conversion'); } async initiateGoogleAuth(): Promise<{ authUrl: string }> { + this.assertNotDisposed(); const response = await this.deps.os.initiateGoogleAuth(''); return { authUrl: response.auth_url }; } @@ -226,45 +237,48 @@ export class AuthService implements AuthApi { code: string; state: string; }): Promise { + this.assertNotDisposed(); await this.deps.os.handleGoogleCallback(params.code, params.state, ''); await this.refreshSessionSnapshot('google auth'); } /** - * Terminally disarms the expiry machinery: cancels the timer and prevents - * in-flight continuations (a restore, a verb, a fired timer) from re-arming - * it. Auth verbs still work afterwards — disposal is not logout. + * Stops the expiry timer permanently and prevents in-flight continuations (a + * restore, an auth action, a fired timer) from starting it again. Auth + * actions still work afterwards — disposal is not sign-out. */ teardown(): void { this.disposed = true; - this.disarmExpiryTimer(); + this.clearExpiryTimer(); + } + + private assertNotDisposed(): void { + if (this.disposed) { + throw new DisposedError(); + } } private async refreshSessionSnapshot(context: string): Promise { try { await this.applySessionFromServer(); } catch (error) { - // Swallowed for parity: a verb whose fetchUser fails leaves an - // anonymous session the host discovers on its next read, like the old - // web glue. endSession (not a bare snapshot clear) so the per-session - // caches die with the session — the Supabase token cache in particular - // must never outlive it. + // An auth action whose fetchUser fails leaves an anonymous session the + // host discovers on its next read. endSession (not a bare snapshot clear) + // runs so the per-session caches die with the session — the Supabase + // token cache in particular must never outlive it. this.deps.logger.error(`Failed to fetch user (${context})`, error); this.endSession(); } } private async applySessionFromServer(options?: { - /** Apply only while the session generation still matches; a speculative caller (restore) passes the generation it observed. */ - expectedGeneration?: number; + /** Skip the apply if this scope was aborted while the fetch was in flight. */ + scope?: AbortSignal; }): Promise { const response = await this.deps.os.fetchUser(); - if ( - options?.expectedGeneration !== undefined && - options.expectedGeneration !== this.sessionGeneration - ) { - // A verb or session end won while this fetch was in flight; the stale - // result must not overwrite the newer session state. + if (options?.scope?.aborted) { + // A sign-out or another session apply won while this fetch was in + // flight; the stale result must not overwrite the newer session state. return; } // Compared against the last seen user rather than the live session: a @@ -275,40 +289,47 @@ export class AuthService implements AuthApi { this.deps.onSessionEnded?.(); } this.lastUserId = response.user.id; - this.sessionGeneration += 1; + this.startNewSessionScope(); this.session = { isLoggedIn: true, user: response.user }; - await this.armExpiryTimer(); + await this.setExpiryTimer(); } private endSession(): void { - this.sessionGeneration += 1; + this.startNewSessionScope(); this.session = { isLoggedIn: false }; - this.disarmExpiryTimer(); - // Un-memoize the restore so the next init() re-evaluates from storage — - // a verb whose post-login fetchUser failed leaves tokens behind, and the - // next invalidation can then recover the session like the old glue did. + this.clearExpiryTimer(); + // Un-memoize the restore so the next init() re-evaluates from storage: an + // auth action whose post-login fetchUser failed leaves tokens behind, and + // the next invalidation can then recover the session. this.restorePromise = undefined; this.deps.onSessionEnded?.(); } - private async armExpiryTimer(): Promise { + // Aborts the current session scope, fencing any in-flight restore or apply + // out, and installs a fresh scope for the session that is starting or ending. + private startNewSessionScope(): void { + this.sessionScope.abort(); + this.sessionScope = new AbortController(); + } + + private async setExpiryTimer(): Promise { const remaining = await this.getRemainingSessionTimeMs(); - // Disarm only after the await, so disarm+check+assign form one - // synchronous block — two overlapping arms can't interleave and orphan a - // timer, and a teardown during the await can't be re-armed past. - this.disarmExpiryTimer(); + // Clear only after the await, so clear + check + assign run as one + // synchronous block: two overlapping calls can't interleave and orphan a + // timer, and a teardown during the await can't be overridden. + this.clearExpiryTimer(); if (this.disposed || remaining === null) { return; } this.expiryTimeout = setLongTimeout(() => { - void this.handleSessionExpiry(); + this.handleSessionExpiry(); }, remaining); } /** - * Milliseconds until the stored refresh token is treated as expired (5s - * before actual expiry, matching the previous web behavior), floored at 0. - * Null when the token is absent or undecodable. + * Milliseconds until the stored refresh token is treated as expired, floored + * at 0. The 5s margin treats the token as expired early so a refresh runs + * before the real expiry. Null when the token is absent or undecodable. */ private async getRemainingSessionTimeMs(): Promise { const refreshToken = @@ -323,7 +344,7 @@ export class AuthService implements AuthApi { return Math.max((decoded.exp - 5) * 1000 - Date.now(), 0); } - private disarmExpiryTimer(): void { + private clearExpiryTimer(): void { if (this.expiryTimeout) { clearLongTimeout(this.expiryTimeout); this.expiryTimeout = undefined; @@ -341,25 +362,26 @@ export class AuthService implements AuthApi { // guest sessions only. private async handleSessionExpiry(): Promise { const session = this.session; - const generation = this.sessionGeneration; + const scope = this.sessionScope.signal; if (this.disposed || !session.isLoggedIn) { return; } - // The Open Secret SDK rotates the refresh token during its internal - // refresh flow, so the expiry this timer was armed for may have moved. - // Re-check the stored token and re-arm instead of expiring a live session. + // Open Secret rotates the refresh token during its own internal refresh, so + // the expiry this timer was set for may have moved later. Re-read the + // stored token and, if it now expires further out, set the timer again + // instead of ending a session that is still alive. const remaining = await this.getRemainingSessionTimeMs(); if (remaining !== null && remaining > 0) { - await this.armExpiryTimer(); + await this.setExpiryTimer(); return; } const isGuest = !session.user.email; if (isGuest) { try { // Re-signing-in the stored guest account gets fresh tokens; the - // snapshot below re-arms the timer so the host never observes expiry. + // snapshot below re-sets the timer so the host never observes expiry. await this.signInGuestAccount(); - if (this.disposed || this.sessionGeneration !== generation) { + if (this.disposed || scope.aborted) { // A transition won the race while the re-sign-in was in flight. If a // sign-out ended the session, the re-sign-in's fresh tokens would // resurrect it on the next restore, so clear them again and revoke diff --git a/packages/wallet-sdk/lib/error.ts b/packages/wallet-sdk/lib/error.ts index 46fcdf671..9962f26bd 100644 --- a/packages/wallet-sdk/lib/error.ts +++ b/packages/wallet-sdk/lib/error.ts @@ -38,3 +38,11 @@ export class NoSessionError extends SdkError { this.name = 'NoSessionError'; } } + +/** Thrown when a method is called on an SDK instance that has been disposed. */ +export class DisposedError extends SdkError { + constructor() { + super('The SDK instance has been disposed'); + this.name = 'DisposedError'; + } +} diff --git a/packages/wallet-sdk/sdk/events.ts b/packages/wallet-sdk/sdk/events.ts index 8a1c20384..7f818b797 100644 --- a/packages/wallet-sdk/sdk/events.ts +++ b/packages/wallet-sdk/sdk/events.ts @@ -2,7 +2,6 @@ import type { Money } from '@agicash/money'; import type { User } from '../domain/user/user'; import type { SdkError } from '../lib/error'; import type { Account } from './accounts'; -import type { TaskProcessorState } from './task-processor'; import type { Contact } from './contacts'; import type { CashuReceiveQuote, @@ -10,21 +9,23 @@ import type { SparkReceiveQuote, } from './receive'; import type { CashuSendQuote, CashuSendSwap, SparkSendQuote } from './send'; +import type { TaskProcessorState } from './task-processor'; import type { Transaction } from './transactions'; /** - * Payloads are decrypted domain objects. Naming: `.`, verbs per - * entity; terminal transitions arrive as `updated` with the new state on the - * payload. Adding events is non-breaking; renaming is breaking. + * Payloads are decrypted domain objects. Naming: `.` (e.g. + * `created`, `updated`) per entity; terminal transitions arrive as `updated` + * with the new state on the payload. Adding events is non-breaking; renaming + * is breaking. */ export type WalletEventMap = { /** The session died without a `signOut()` call (expiry / failed refresh). */ 'auth.session-expired': Record; /** - * The SDK refreshed the session without a host-initiated verb — today: - * guest auto-extension at refresh-token expiry. Host-initiated verbs never - * fire it (the host knows its own actions). Hosts re-sync session-derived - * state from it (the web: auth query + session-hint cookie). + * The SDK refreshed the session on its own — today: the guest auto-extension + * at refresh-token expiry. It never fires for a sign-in, sign-out, or other + * host-called auth method (the host already knows about those). Hosts re-sync + * session-derived state from it (the web: auth query + session-hint cookie). */ 'auth.session-refreshed': Record; 'user.updated': { user: User }; diff --git a/packages/wallet-sdk/sdk/index.ts b/packages/wallet-sdk/sdk/index.ts index 7caac3c64..ae9875957 100644 --- a/packages/wallet-sdk/sdk/index.ts +++ b/packages/wallet-sdk/sdk/index.ts @@ -7,12 +7,12 @@ import type { SparkNetwork } from '../db/json-models/spark-account-details-db-data'; import type { AccountsApi } from './accounts'; import type { AuthApi, AuthStorage } from './auth'; -import type { TaskProcessorApi } from './task-processor'; import type { ContactsApi } from './contacts'; import type { WalletEvents } from './events'; import type { FeatureFlagsApi } from './feature-flags'; import type { ReceiveApi } from './receive'; import type { SendApi } from './send'; +import type { TaskProcessorApi } from './task-processor'; import type { TransactionsApi } from './transactions'; import type { TransferApi } from './transfer'; import type { UserApi } from './user'; From 15f4f4fa9ceb2d523233034b5368aba23d80983e Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 05:42:07 -0700 Subject: [PATCH 34/49] docs(wallet-sdk): accurate disposal + intentional-exposure notes (#1166) From the self-review pass (Opus adversarial + codex-review) on the r3 diff: - teardown's doc states that actions throw DisposedError after disposal and that stored tokens + the session snapshot are left intact for a successor instance. - receive.ts / send.ts note that the public types are the domain entities for now (the apps only read them; proofs/userId ride along until a later slice narrows the surface, #1164). - an auth-service test comment describes the fencing as aborting the session scope, matching the code. Co-Authored-By: Claude Opus 4.8 --- packages/wallet-sdk/domain/user/auth-service.test.ts | 4 ++-- packages/wallet-sdk/domain/user/auth-service.ts | 8 +++++--- packages/wallet-sdk/sdk/receive.ts | 3 +++ packages/wallet-sdk/sdk/send.ts | 3 +++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts index b6379647f..2183887cc 100644 --- a/packages/wallet-sdk/domain/user/auth-service.test.ts +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -309,8 +309,8 @@ describe('AuthService', () => { releaseThirdFetch(); await restoreB; - // A's late failure neither bumped the generation (which would fence - // B's apply out) nor clobbered B's memo + // A's late failure neither aborted B's scope (which would fence its + // apply out) nor clobbered B's memo expect(service.getSession().isLoggedIn).toBe(true); await service.restoreSession(); expect(fetchCalls).toBe(3); diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index dcb6f9bbd..e1d279317 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -243,9 +243,11 @@ export class AuthService implements AuthApi { } /** - * Stops the expiry timer permanently and prevents in-flight continuations (a - * restore, an auth action, a fired timer) from starting it again. Auth - * actions still work afterwards — disposal is not sign-out. + * Marks the instance disposed and stops the expiry timer permanently: a fired + * timer or an in-flight restore's apply can't set it again. Auth actions + * called after this throw DisposedError. Stored tokens and the last session + * snapshot are left intact — disposal is not sign-out, so a successor instance + * (e.g. after a hot reload) can restore from them. */ teardown(): void { this.disposed = true; diff --git a/packages/wallet-sdk/sdk/receive.ts b/packages/wallet-sdk/sdk/receive.ts index f92a06ca0..33d946f5b 100644 --- a/packages/wallet-sdk/sdk/receive.ts +++ b/packages/wallet-sdk/sdk/receive.ts @@ -3,6 +3,9 @@ import type { CashuReceiveLightningQuote } from '../domain/receive/cashu-receive import type { SparkReceiveQuote } from '../domain/receive/spark-receive-quote'; import type { SparkReceiveLightningQuote } from '../domain/receive/spark-receive-quote-core'; +// The public receive types are the domain entities for now: only the apps +// consume the SDK and they just read these shapes, so the extra domain fields +// (e.g. proofs) ride along until a later slice narrows the surface (#1164). export type { CashuReceiveSwap } from '../domain/receive/cashu-receive-swap'; export type { CashuReceiveQuote, SparkReceiveQuote }; diff --git a/packages/wallet-sdk/sdk/send.ts b/packages/wallet-sdk/sdk/send.ts index 85c43172c..c788fa508 100644 --- a/packages/wallet-sdk/sdk/send.ts +++ b/packages/wallet-sdk/sdk/send.ts @@ -3,6 +3,9 @@ import type { CashuSwapQuote } from '../domain/send/cashu-send-swap-service'; import type { SparkLightningQuote } from '../domain/send/spark-send-quote-service'; import type { DestinationDetails } from '../lib/send-destination'; +// The public send types are the domain entities for now: only the apps consume +// the SDK and they just read these shapes, so fields like proofs and userId +// ride along until a later slice narrows the surface (#1164). export type { CashuSendQuote } from '../domain/send/cashu-send-quote'; export type { CashuSendSwap } from '../domain/send/cashu-send-swap'; export type { SparkSendQuote } from '../domain/send/spark-send-quote'; From 57f3f6bfa9244cf754de638da21c54e5ed628e18 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 05:54:02 -0700 Subject: [PATCH 35/49] refactor(wallet-sdk): setDefaultAccount takes userId; guest storage factory to lib (#1166) - setDefaultAccount takes `userId: string` (the SDK path has only the session user id; a full domain User would reintroduce the account read step 6 removed) and the full domain `account`. - The guest account storage factory lives in lib/ (a runtime adapter, not domain wiring) and stays exported so its unit tests and the auth-service tests keep using it. AuthService builds its own guest storage from the storage + logger it already receives, so the composition root no longer wires it in. Threads: [21] [2] Co-Authored-By: Claude Opus 4.8 --- packages/wallet-sdk/agicash-sdk.ts | 5 ----- .../domain/user/auth-service.test.ts | 5 ----- .../wallet-sdk/domain/user/auth-service.ts | 20 +++++++++++++------ packages/wallet-sdk/domain/user/user-api.ts | 2 +- .../wallet-sdk/domain/user/user-service.ts | 6 +++--- .../guest-account-storage.test.ts | 4 ++-- .../user => lib}/guest-account-storage.ts | 2 +- 7 files changed, 21 insertions(+), 23 deletions(-) rename packages/wallet-sdk/{domain/user => lib}/guest-account-storage.test.ts (94%) rename packages/wallet-sdk/{domain/user => lib}/guest-account-storage.ts (95%) diff --git a/packages/wallet-sdk/agicash-sdk.ts b/packages/wallet-sdk/agicash-sdk.ts index 63ead53a1..4cd4ee3e0 100644 --- a/packages/wallet-sdk/agicash-sdk.ts +++ b/packages/wallet-sdk/agicash-sdk.ts @@ -2,7 +2,6 @@ import * as openSecret from '@agicash/opensecret'; import { createAgicashDbClient } from './db/client'; import { createSupabaseSessionTokenGetter } from './db/supabase-session'; import { AuthService } from './domain/user/auth-service'; -import { createGuestAccountStorage } from './domain/user/guest-account-storage'; import { createUserApi } from './domain/user/user-api'; import { clearAgicashMintAuthToken } from './lib/agicash-mint-auth-provider'; import { WalletEventEmitter } from './lib/events'; @@ -52,10 +51,6 @@ export class AgicashSdk this.authService = new AuthService({ os: openSecret, storage: config.auth.storage, - guestAccountStorage: createGuestAccountStorage( - config.auth.storage.persistent, - config.logger, - ), generateGuestPassword: async () => (await config.auth.generateGuestPassword?.()) ?? generateRandomPassword(32), diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts index 2183887cc..36e3f4d14 100644 --- a/packages/wallet-sdk/domain/user/auth-service.test.ts +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -4,7 +4,6 @@ import { WalletEventEmitter } from '../../lib/events'; import { nullLogger } from '../../lib/logger'; import type { AuthKeyValueStore, AuthStorage } from '../../sdk'; import { AuthService, type OpenSecretAuthApi } from './auth-service'; -import { createGuestAccountStorage } from './guest-account-storage'; const createMemoryStore = (): AuthKeyValueStore & { data: Map; @@ -109,10 +108,6 @@ const createService = ( const service = new AuthService({ os, storage, - guestAccountStorage: createGuestAccountStorage( - storage.persistent, - nullLogger, - ), generateGuestPassword: async () => 'generated-pw', events, onSessionEnded: options.onSessionEnded, diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index e1d279317..403232d47 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -11,8 +11,11 @@ import { } from '@agicash/utils'; import { DisposedError } from '../../lib/error'; import type { WalletEventEmitter } from '../../lib/events'; +import { + type GuestAccountStorage, + createGuestAccountStorage, +} from '../../lib/guest-account-storage'; import type { AuthApi, AuthSession, AuthStorage, Logger } from '../../sdk'; -import type { GuestAccountStorage } from './guest-account-storage'; // Keys are owned by @agicash/opensecret's token persistence; the service reads // them (never writes) for session detection and expiry math. @@ -50,7 +53,6 @@ export type OpenSecretAuthApi = { type AuthServiceDeps = { os: OpenSecretAuthApi; storage: AuthStorage; - guestAccountStorage: GuestAccountStorage; generateGuestPassword: () => Promise; events: WalletEventEmitter; /** Per-session cache cleanup on any session end (sign-out or expiry). */ @@ -70,8 +72,14 @@ export class AuthService implements AuthApi { // exists and must not be applied. private sessionScope = new AbortController(); private disposed = false; + private readonly guestAccountStorage: GuestAccountStorage; - constructor(private readonly deps: AuthServiceDeps) {} + constructor(private readonly deps: AuthServiceDeps) { + this.guestAccountStorage = createGuestAccountStorage( + deps.storage.persistent, + deps.logger, + ); + } getSession(): AuthSession { return this.session; @@ -159,7 +167,7 @@ export class AuthService implements AuthApi { * session snapshot is not touched. */ private async signInGuestAccount(): Promise { - const existingGuestAccount = await this.deps.guestAccountStorage.get(); + const existingGuestAccount = await this.guestAccountStorage.get(); if (existingGuestAccount) { await this.deps.os.signInGuest( existingGuestAccount.id, @@ -170,7 +178,7 @@ export class AuthService implements AuthApi { const password = await this.deps.generateGuestPassword(); const guestAccount = await this.deps.os.signUpGuest(password, ''); try { - await this.deps.guestAccountStorage.store({ + await this.guestAccountStorage.store({ id: guestAccount.id, password, }); @@ -223,7 +231,7 @@ export class AuthService implements AuthApi { ): Promise { this.assertNotDisposed(); await this.deps.os.convertGuestToUserAccount(email, password); - await this.deps.guestAccountStorage.clear(); + await this.guestAccountStorage.clear(); await this.refreshSessionSnapshot('guest conversion'); } diff --git a/packages/wallet-sdk/domain/user/user-api.ts b/packages/wallet-sdk/domain/user/user-api.ts index ad3f66412..c5033de4a 100644 --- a/packages/wallet-sdk/domain/user/user-api.ts +++ b/packages/wallet-sdk/domain/user/user-api.ts @@ -40,7 +40,7 @@ export function createUserApi(deps: Deps): UserApi { defaultCurrency: params.currency, }), setDefaultAccount: async (params) => - userService.setDefaultAccount({ id: requireUserId() }, params.account, { + userService.setDefaultAccount(requireUserId(), params.account, { setDefaultCurrency: params.setDefaultCurrency, }), }; diff --git a/packages/wallet-sdk/domain/user/user-service.ts b/packages/wallet-sdk/domain/user/user-service.ts index 3e415ab6e..ec1a5e157 100644 --- a/packages/wallet-sdk/domain/user/user-service.ts +++ b/packages/wallet-sdk/domain/user/user-service.ts @@ -49,8 +49,8 @@ export class UserService { * defaults can't be clobbered by stale caller state. */ async setDefaultAccount( - user: Pick, - account: Pick, + userId: string, + account: Account, options: SetDefaultAccountOptions = { setDefaultCurrency: false, }, @@ -60,7 +60,7 @@ export class UserService { } return this.userRepository.update( - user.id, + userId, { ...(account.currency === 'BTC' ? { defaultBtcAccountId: account.id } diff --git a/packages/wallet-sdk/domain/user/guest-account-storage.test.ts b/packages/wallet-sdk/lib/guest-account-storage.test.ts similarity index 94% rename from packages/wallet-sdk/domain/user/guest-account-storage.test.ts rename to packages/wallet-sdk/lib/guest-account-storage.test.ts index 15c7c3507..b1cd1da39 100644 --- a/packages/wallet-sdk/domain/user/guest-account-storage.test.ts +++ b/packages/wallet-sdk/lib/guest-account-storage.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test'; -import { nullLogger } from '../../lib/logger'; -import type { AuthKeyValueStore } from '../../sdk'; +import type { AuthKeyValueStore } from '../sdk'; import { createGuestAccountStorage } from './guest-account-storage'; +import { nullLogger } from './logger'; const createMemoryStore = (): AuthKeyValueStore & { data: Map; diff --git a/packages/wallet-sdk/domain/user/guest-account-storage.ts b/packages/wallet-sdk/lib/guest-account-storage.ts similarity index 95% rename from packages/wallet-sdk/domain/user/guest-account-storage.ts rename to packages/wallet-sdk/lib/guest-account-storage.ts index 8405927d2..520afd929 100644 --- a/packages/wallet-sdk/domain/user/guest-account-storage.ts +++ b/packages/wallet-sdk/lib/guest-account-storage.ts @@ -1,6 +1,6 @@ import { safeJsonParse } from '@agicash/utils'; import { z } from 'zod/mini'; -import type { AuthKeyValueStore, Logger } from '../../sdk'; +import type { AuthKeyValueStore, Logger } from '../sdk'; // Key predates the SDK move — existing devices have guest credentials stored // under it, so it must not change. From f469c99e5a011c688e00b904ee058fef86dd9a50 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 06:37:55 -0700 Subject: [PATCH 36/49] refactor(wallet-sdk): cross-user cache wipe via the live session; realtime + init doc (#1166) - applySessionFromServer wipes the previous user's per-session caches when a different user's session starts over a live one (a login without a sign-out in between), keyed off the live session. Each memo cleared by onSessionEnded already fences its own in-flight writes, so a direct cross-user login is the only case that needs this check; agicash-sdk documents that contract at the onSessionEnded wiring. Tests cover the cross-user wipe, no double-wipe after sign-out, and same-user warmth. - events: the connection.changed doc says it is the Supabase realtime data channel (the per-user row-change subscription), not the auth/network connection. - web: the sdk.init() failure log reads "Failed to initialize sdk", tracking the method name as init grows. Threads: [33] [7] [44] Co-Authored-By: Claude Opus 4.8 --- apps/web-wallet/app/features/user/auth.ts | 2 +- packages/wallet-sdk/agicash-sdk.ts | 5 +- .../domain/user/auth-service.test.ts | 56 +++++++++++++++++-- .../wallet-sdk/domain/user/auth-service.ts | 11 +--- packages/wallet-sdk/sdk/events.ts | 8 ++- 5 files changed, 65 insertions(+), 17 deletions(-) diff --git a/apps/web-wallet/app/features/user/auth.ts b/apps/web-wallet/app/features/user/auth.ts index dbcd0d6f5..493cdb603 100644 --- a/apps/web-wallet/app/features/user/auth.ts +++ b/apps/web-wallet/app/features/user/auth.ts @@ -67,7 +67,7 @@ export const authQueryOptions = () => // Restore failed with tokens present (e.g. a network blip at boot). // Boot anonymous; init()'s rejection is not memoized, so a later // invalidateAuthQueries() retries the restore. - console.error('Failed to restore session', { cause: error }); + console.error('Failed to initialize sdk', { cause: error }); Sentry.setUser(null); sessionHintCookie.clear(); return { isLoggedIn: false }; diff --git a/packages/wallet-sdk/agicash-sdk.ts b/packages/wallet-sdk/agicash-sdk.ts index 4cd4ee3e0..d2a3c2df3 100644 --- a/packages/wallet-sdk/agicash-sdk.ts +++ b/packages/wallet-sdk/agicash-sdk.ts @@ -57,7 +57,10 @@ export class AgicashSdk events, onSessionEnded: () => { // The token cache must die with the session: a token minted for one - // user must never serve the next login's queries. + // user must never serve the next login's queries. Anything wiped here + // must fence its own in-flight writes (a generation counter or abort + // scope) so a write resolving after this reset can't repopulate it — + // there is no cross-user backstop beyond each memo's own fence. sessionToken.reset(); clearSparkWallets(); clearAgicashMintAuthToken(); diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts index 36e3f4d14..30d7dafc8 100644 --- a/packages/wallet-sdk/domain/user/auth-service.test.ts +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -440,10 +440,32 @@ describe('AuthService', () => { expect(storage.persistent.data.has('guestAccount')).toBe(true); }); - it('wipes per-session caches again when a different user signs in after sign-out', async () => { + it('wipes the previous user caches when a different user logs in without signing out', async () => { let sessionEndedCount = 0; + let fetchCalls = 0; const userIds = ['user-a', 'user-b']; + const { service } = createService({ + os: { + fetchUser: async () => ({ + user: { ...fullUser, id: userIds[Math.min(fetchCalls++, 1)] }, + }), + }, + onSessionEnded: () => { + sessionEndedCount += 1; + }, + }); + + await service.signIn('a@b.c', 'pw'); // no previous live session → no wipe + await service.signIn('b@b.c', 'pw'); // different user over a live session → wipe + + expect(sessionEndedCount).toBe(1); + service.teardown(); + }); + + it('does not wipe again when a different user logs in after signing out', async () => { + let sessionEndedCount = 0; let fetchCalls = 0; + const userIds = ['user-a', 'user-b']; const { service } = createService({ os: { fetchUser: async () => ({ @@ -456,10 +478,36 @@ describe('AuthService', () => { }); await service.signIn('a@b.c', 'pw'); - await service.signOut(); // ends user-a's session → 1 - await service.signIn('b@b.c', 'pw'); // different user → wiped again → 2 + await service.signOut(); // ends user-a's session → wipe once + await service.signIn('b@b.c', 'pw'); // session already anonymous → no second wipe + + expect(sessionEndedCount).toBe(1); + service.teardown(); + }); - expect(sessionEndedCount).toBe(2); + it('keeps caches warm when the same user re-applies via guest extension', async () => { + let sessionEndedCount = 0; + const { service, storage, events } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + onSessionEnded: () => { + sessionEndedCount += 1; + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const refreshed: unknown[] = []; + events.on('auth.session-refreshed', (payload) => refreshed.push(payload)); + + await service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // the extension re-signs-in the SAME guest, so no cross-user wipe fires + expect(refreshed).toHaveLength(1); + expect(sessionEndedCount).toBe(0); service.teardown(); }); }); diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index 403232d47..f18f2294b 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -64,8 +64,6 @@ export class AuthService implements AuthApi { private session: AuthSession = { isLoggedIn: false }; private restorePromise: Promise | undefined; private expiryTimeout: LongTimeout | undefined; - // Survives endSession() deliberately — see applySessionFromServer. - private lastUserId: string | undefined; // Aborted and replaced on every session transition (a login apply or a // session end). An in-flight restore holds the signal it started under; once // that signal is aborted its result belongs to a session that no longer @@ -291,14 +289,11 @@ export class AuthService implements AuthApi { // flight; the stale result must not overwrite the newer session state. return; } - // Compared against the last seen user rather than the live session: a - // memo repopulated by a request that resolved after sign-out must still - // be wiped when a DIFFERENT user's session begins, and by then the - // session is anonymous. Same-user re-login keeps its memos warm. - if (this.lastUserId && this.lastUserId !== response.user.id) { + // A different user's session is starting over a live one (login without + // sign-out) — wipe the previous user's per-session caches. + if (this.session.isLoggedIn && this.session.user.id !== response.user.id) { this.deps.onSessionEnded?.(); } - this.lastUserId = response.user.id; this.startNewSessionScope(); this.session = { isLoggedIn: true, user: response.user }; await this.setExpiryTimer(); diff --git a/packages/wallet-sdk/sdk/events.ts b/packages/wallet-sdk/sdk/events.ts index 7f818b797..cbc8d65f7 100644 --- a/packages/wallet-sdk/sdk/events.ts +++ b/packages/wallet-sdk/sdk/events.ts @@ -51,9 +51,11 @@ export type WalletEventMap = { 'spark-send-quote.created': { quote: SparkSendQuote }; 'spark-send-quote.updated': { quote: SparkSendQuote }; /** - * Emits on every transition into `connected`, including the initial - * connection — the invalidate-all signal. `error` is terminal: the channel - * is dead after retries exhaust, distinct from a long `reconnecting`. + * The realtime data channel to Supabase — the per-user subscription that + * streams row changes, not the auth or network connection. `connected` fires + * on every transition into the connected state (including the first) and is + * the invalidate-all / refetch-after-a-gap signal; `error` is terminal after + * retries exhaust, distinct from a long `reconnecting`. */ 'connection.changed': { state: 'connected' | 'reconnecting' | 'error' }; /** From d12f5f8f087fbabb0768ef1b6f6d53ad693496f1 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 07:09:58 -0700 Subject: [PATCH 37/49] fix(wallet-sdk): fence in-flight session applies against disposal and mid-extension sign-out (#1166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two session-scope gaps from the cross-model review: - teardown() aborts the session scope and applySessionFromServer skips its writes when disposed, so an in-flight restore that resolves after dispose no longer writes a session or runs onSessionEnded — onSessionEnded clears process-wide caches (spark wallets, mint CAT) that a hot-reload successor instance may already own. - The guest auto-extension threads its session scope through the post-re-sign-in apply. applySessionFromServer reports whether it applied, so a sign-out that wins while that apply is in flight skips it (no resurrection of the ended session) and the handler bails instead of running its death path over the transition that already owns the state. Tests: dispose-mid-restore is fenced (no session write, no onSessionEnded); sign-out during the post-extension apply does not resurrect the session. Threads: round-3c H1, H3 Co-Authored-By: Claude Opus 4.8 --- .../domain/user/auth-service.test.ts | 77 +++++++++++++++++-- .../wallet-sdk/domain/user/auth-service.ts | 70 +++++++++++------ 2 files changed, 118 insertions(+), 29 deletions(-) diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts index 30d7dafc8..253ad545d 100644 --- a/packages/wallet-sdk/domain/user/auth-service.test.ts +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -323,11 +323,12 @@ describe('AuthService', () => { service.teardown(); }); - it('does not re-arm the expiry timer when the restore resolves after teardown', async () => { + it('fences an in-flight restore that resolves after teardown', async () => { let releaseRestoreFetch = (): void => undefined; const restoreFetchGate = new Promise((resolve) => { releaseRestoreFetch = resolve; }); + let sessionEndedCount = 0; const { service, storage, events } = createService({ os: { fetchUser: async () => { @@ -335,11 +336,12 @@ describe('AuthService', () => { return { user: fullUser }; }, }, + onSessionEnded: () => { + sessionEndedCount += 1; + }, }); storage.persistent.data.set('access_token', createJwt(600)); - // Refresh token already inside the exp-5s window: a timer armed by the - // late-resolving restore would fire immediately and expire the session. - storage.persistent.data.set('refresh_token', createJwt(4)); + storage.persistent.data.set('refresh_token', createJwt(3600)); const expired: unknown[] = []; events.on('auth.session-expired', (payload) => expired.push(payload)); @@ -351,10 +353,12 @@ describe('AuthService', () => { await restore; await new Promise((resolve) => setTimeout(resolve, 10)); + // Disposal aborts the session scope, so the late apply is skipped: the + // disposed instance never becomes logged-in, arms a timer, or runs + // onSessionEnded (which clears process-wide caches a successor may own). + expect(service.getSession().isLoggedIn).toBe(false); expect(expired).toHaveLength(0); - // The stale apply still lands (teardown is not logout); only the - // expiry machinery stays off. - expect(service.getSession().isLoggedIn).toBe(true); + expect(sessionEndedCount).toBe(0); }); }); @@ -652,6 +656,65 @@ describe('AuthService', () => { expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); }); + // A sign-out that lands after the first scope check, while the extension's + // own apply is in flight, must not resurrect the ended session — the apply + // is scope-guarded (PR #1166 review, round-3c H3). + it('does not resurrect a signed-out session while the post-extension apply is in flight', async () => { + let fetchCalls = 0; + let releaseExtensionFetch = (): void => undefined; + const extensionFetchGate = new Promise((resolve) => { + releaseExtensionFetch = resolve; + }); + const storage = createStorage(); + const { service, events } = createService({ + storage, + os: { + fetchUser: async () => { + fetchCalls += 1; + // Hold the post-extension refresh apply so a sign-out can race it. + if (fetchCalls === 2) { + await extensionFetchGate; + } + return { user: guestUser }; + }, + signInGuest: async () => { + storage.persistent.data.set( + 'access_token', + createJwt(600, 'guest-1'), + ); + storage.persistent.data.set( + 'refresh_token', + createJwt(3600, 'guest-1'), + ); + return { id: 'guest-1', access_token: 'a', refresh_token: 'r' }; + }, + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const refreshed: unknown[] = []; + events.on('auth.session-refreshed', (payload) => refreshed.push(payload)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // let the timer fire and park the extension on the gated post-apply fetch + await new Promise((resolve) => setTimeout(resolve, 10)); + await service.signOut(); + releaseExtensionFetch(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // the scoped extension apply is skipped, so the sign-out stands + expect(service.getSession().isLoggedIn).toBe(false); + expect(refreshed).toHaveLength(0); + expect(expired).toHaveLength(0); + expect(storage.persistent.data.has('refresh_token')).toBe(false); + }); + // The SDK-lifecycle variant: teardown() lands mid-handler, so the death // path bails before signing out — tokens survive for a successor instance // (PR #1166 review, finding 1). diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index f18f2294b..4aa3f6f26 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -249,14 +249,17 @@ export class AuthService implements AuthApi { } /** - * Marks the instance disposed and stops the expiry timer permanently: a fired - * timer or an in-flight restore's apply can't set it again. Auth actions - * called after this throw DisposedError. Stored tokens and the last session - * snapshot are left intact — disposal is not sign-out, so a successor instance - * (e.g. after a hot reload) can restore from them. + * Marks the instance disposed, aborts the session scope, and stops the expiry + * timer permanently. An in-flight restore is fenced: its late apply is + * skipped, so a disposed instance never writes a session or runs + * onSessionEnded — which clears process-wide caches a successor instance may + * already own. Auth actions called after this throw DisposedError. Stored + * tokens and the last session snapshot are left intact — disposal is not + * sign-out, so a successor instance (e.g. after a hot reload) can restore. */ teardown(): void { this.disposed = true; + this.sessionScope.abort(); this.clearExpiryTimer(); } @@ -266,28 +269,39 @@ export class AuthService implements AuthApi { } } - private async refreshSessionSnapshot(context: string): Promise { + private async refreshSessionSnapshot( + context: string, + scope?: AbortSignal, + ): Promise { try { - await this.applySessionFromServer(); + return 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) // runs so the per-session caches die with the session — the Supabase - // token cache in particular must never outlive it. + // token cache in particular must never outlive it. The session was + // resolved (to anonymous) here, so this counts as applied, not skipped. this.deps.logger.error(`Failed to fetch user (${context})`, error); this.endSession(); + return true; } } + /** + * Fetches the user and applies the session, returning whether it applied. + * Returns false without writing when the instance was disposed or `scope` was + * aborted while the fetch was in flight (a newer transition owns the state). + */ private async applySessionFromServer(options?: { /** Skip the apply if this scope was aborted while the fetch was in flight. */ scope?: AbortSignal; - }): Promise { + }): Promise { const response = await this.deps.os.fetchUser(); - if (options?.scope?.aborted) { - // A sign-out or another session apply won while this fetch was in - // flight; the stale result must not overwrite the newer session state. - return; + if (this.disposed || options?.scope?.aborted) { + // The instance was disposed, or a sign-out / newer apply won, while this + // fetch was in flight; the stale result must not write a session or run + // onSessionEnded. + return false; } // A different user's session is starting over a live one (login without // sign-out) — wipe the previous user's per-session caches. @@ -297,6 +311,7 @@ export class AuthService implements AuthApi { this.startNewSessionScope(); this.session = { isLoggedIn: true, user: response.user }; await this.setExpiryTimer(); + return true; } private endSession(): void { @@ -357,14 +372,16 @@ export class AuthService implements AuthApi { } // Best-effort against concurrent session transitions, not race-free - // (PR #1166 review, finding 1). When a sign-out wins the race while the - // guest re-sign-in below is in flight, the handler compensates by clearing - // the fresh tokens again; teardown() is likewise checked before the - // re-sign-in's tokens or the death path are acted on. Residual windows: a - // sign-out landing just after the re-sign-in's apply still resurrects the - // session, and the cross-instance (HMR successor) case keeps a narrow gap. - // Both require the timer to fire inside a sign-out's network round trip, on - // guest sessions only. + // (PR #1166 review, finding 1). A sign-out that wins while the guest + // re-sign-in below is in flight is fenced two ways: the scope check right + // after the re-sign-in compensates by clearing the fresh tokens, and the + // extension's apply is scope-guarded so a sign-out during it can't resurrect + // the session the sign-out ended. teardown() is likewise checked before the + // re-sign-in's tokens or the death path are acted on. Residual windows: the + // token write inside os.signInGuest can still interleave with a sign-out's + // storage clear, and the cross-instance (HMR successor) case keeps a narrow + // gap — both require the timer to fire inside a sign-out's network round trip, + // on guest sessions only. private async handleSessionExpiry(): Promise { const session = this.session; const scope = this.sessionScope.signal; @@ -405,7 +422,16 @@ export class AuthService implements AuthApi { } return; } - await this.refreshSessionSnapshot('guest session extension'); + const applied = await this.refreshSessionSnapshot( + 'guest session extension', + scope, + ); + if (!applied) { + // A sign-out (or newer login) won while the extension's apply was in + // flight, so the apply skipped and that transition already owns the + // session state. Don't run the death path over it. + return; + } const extendedRemaining = await this.getRemainingSessionTimeMs(); if ( extendedRemaining !== null && From 1b4f7e51e3af19480187550128e354059339ed4e Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 08:32:10 -0700 Subject: [PATCH 38/49] refactor(wallet-sdk): full Sdk contract with not-implemented stubs; sdk/ into domain/ (#1166) - AgicashSdk implements the full Sdk; the eight not-yet-migrated namespaces are getters that throw NotImplementedError (auth/user/events + init/dispose stay live) - move sdk/ to domain/sdk/ and rename agicash-sdk.ts to domain/sdk/sdk.ts (and its test), with imports updated - generateRandomPassword is synchronous (its body has no awaits), fixing the missing-await at the call site and the redundant awaits in its test - drop web-app references from the events and config contract docs Co-Authored-By: Claude Opus 4.8 --- .../wallet-sdk/{ => domain}/sdk/accounts.ts | 2 +- packages/wallet-sdk/{ => domain}/sdk/auth.ts | 0 .../wallet-sdk/{ => domain}/sdk/contacts.ts | 2 +- .../wallet-sdk/{ => domain}/sdk/events.ts | 6 +- .../{ => domain}/sdk/feature-flags.ts | 2 +- packages/wallet-sdk/{ => domain}/sdk/index.ts | 6 +- .../wallet-sdk/{ => domain}/sdk/receive.ts | 10 +-- .../sdk/sdk.test.ts} | 6 +- .../{agicash-sdk.ts => domain/sdk/sdk.ts} | 68 +++++++++++++++---- packages/wallet-sdk/{ => domain}/sdk/send.ts | 14 ++-- .../wallet-sdk/{ => domain}/sdk/server.ts | 2 +- .../{ => domain}/sdk/task-processor.ts | 0 .../{ => domain}/sdk/transactions.ts | 4 +- .../wallet-sdk/{ => domain}/sdk/transfer.ts | 2 +- packages/wallet-sdk/{ => domain}/sdk/user.ts | 4 +- .../domain/user/auth-service.test.ts | 2 +- .../wallet-sdk/domain/user/auth-service.ts | 2 +- .../wallet-sdk/domain/user/user-api.test.ts | 2 +- packages/wallet-sdk/domain/user/user-api.ts | 2 +- packages/wallet-sdk/index.ts | 10 +-- packages/wallet-sdk/lib/error.ts | 8 +++ packages/wallet-sdk/lib/events.ts | 2 +- .../lib/guest-account-storage.test.ts | 2 +- .../wallet-sdk/lib/guest-account-storage.ts | 2 +- packages/wallet-sdk/lib/logger.ts | 2 +- packages/wallet-sdk/lib/password.test.ts | 26 +++---- packages/wallet-sdk/lib/password.ts | 4 +- 27 files changed, 119 insertions(+), 73 deletions(-) rename packages/wallet-sdk/{ => domain}/sdk/accounts.ts (95%) rename packages/wallet-sdk/{ => domain}/sdk/auth.ts (100%) rename packages/wallet-sdk/{ => domain}/sdk/contacts.ts (83%) rename packages/wallet-sdk/{ => domain}/sdk/events.ts (95%) rename packages/wallet-sdk/{ => domain}/sdk/feature-flags.ts (78%) rename packages/wallet-sdk/{ => domain}/sdk/index.ts (94%) rename packages/wallet-sdk/{ => domain}/sdk/receive.ts (82%) rename packages/wallet-sdk/{agicash-sdk.test.ts => domain/sdk/sdk.test.ts} (88%) rename packages/wallet-sdk/{agicash-sdk.ts => domain/sdk/sdk.ts} (66%) rename packages/wallet-sdk/{ => domain}/sdk/send.ts (74%) rename packages/wallet-sdk/{ => domain}/sdk/server.ts (93%) rename packages/wallet-sdk/{ => domain}/sdk/task-processor.ts (100%) rename packages/wallet-sdk/{ => domain}/sdk/transactions.ts (74%) rename packages/wallet-sdk/{ => domain}/sdk/transfer.ts (84%) rename packages/wallet-sdk/{ => domain}/sdk/user.ts (86%) diff --git a/packages/wallet-sdk/sdk/accounts.ts b/packages/wallet-sdk/domain/sdk/accounts.ts similarity index 95% rename from packages/wallet-sdk/sdk/accounts.ts rename to packages/wallet-sdk/domain/sdk/accounts.ts index 4978d2c67..15481acea 100644 --- a/packages/wallet-sdk/sdk/accounts.ts +++ b/packages/wallet-sdk/domain/sdk/accounts.ts @@ -2,7 +2,7 @@ import type { Money } from '@agicash/money'; import type { CashuAccount as DomainCashuAccount, SparkAccount as DomainSparkAccount, -} from '../domain/accounts/account'; +} from '../accounts/account'; /** Carries `balance` on every rail, never a raw wallet handle or proof material. */ export type CashuAccount = Omit< diff --git a/packages/wallet-sdk/sdk/auth.ts b/packages/wallet-sdk/domain/sdk/auth.ts similarity index 100% rename from packages/wallet-sdk/sdk/auth.ts rename to packages/wallet-sdk/domain/sdk/auth.ts diff --git a/packages/wallet-sdk/sdk/contacts.ts b/packages/wallet-sdk/domain/sdk/contacts.ts similarity index 83% rename from packages/wallet-sdk/sdk/contacts.ts rename to packages/wallet-sdk/domain/sdk/contacts.ts index 48d631239..0c4604f74 100644 --- a/packages/wallet-sdk/sdk/contacts.ts +++ b/packages/wallet-sdk/domain/sdk/contacts.ts @@ -1,4 +1,4 @@ -import type { Contact as DomainContact } from '../domain/contacts/contact'; +import type { Contact as DomainContact } from '../contacts/contact'; export type Contact = Omit; diff --git a/packages/wallet-sdk/sdk/events.ts b/packages/wallet-sdk/domain/sdk/events.ts similarity index 95% rename from packages/wallet-sdk/sdk/events.ts rename to packages/wallet-sdk/domain/sdk/events.ts index cbc8d65f7..a74d0a988 100644 --- a/packages/wallet-sdk/sdk/events.ts +++ b/packages/wallet-sdk/domain/sdk/events.ts @@ -1,6 +1,6 @@ import type { Money } from '@agicash/money'; -import type { User } from '../domain/user/user'; -import type { SdkError } from '../lib/error'; +import type { SdkError } from '../../lib/error'; +import type { User } from '../user/user'; import type { Account } from './accounts'; import type { Contact } from './contacts'; import type { @@ -25,7 +25,7 @@ export type WalletEventMap = { * The SDK refreshed the session on its own — today: the guest auto-extension * at refresh-token expiry. It never fires for a sign-in, sign-out, or other * host-called auth method (the host already knows about those). Hosts re-sync - * session-derived state from it (the web: auth query + session-hint cookie). + * session-derived state from it. */ 'auth.session-refreshed': Record; 'user.updated': { user: User }; diff --git a/packages/wallet-sdk/sdk/feature-flags.ts b/packages/wallet-sdk/domain/sdk/feature-flags.ts similarity index 78% rename from packages/wallet-sdk/sdk/feature-flags.ts rename to packages/wallet-sdk/domain/sdk/feature-flags.ts index 4ce1fa153..033dc8c3e 100644 --- a/packages/wallet-sdk/sdk/feature-flags.ts +++ b/packages/wallet-sdk/domain/sdk/feature-flags.ts @@ -1,4 +1,4 @@ -import type { FeatureFlag } from '../lib/feature-flag-service'; +import type { FeatureFlag } from '../../lib/feature-flag-service'; /** Flags are a process-local cached read — the one no-cache exception. */ export type FeatureFlagsApi = { diff --git a/packages/wallet-sdk/sdk/index.ts b/packages/wallet-sdk/domain/sdk/index.ts similarity index 94% rename from packages/wallet-sdk/sdk/index.ts rename to packages/wallet-sdk/domain/sdk/index.ts index ae9875957..b62039e7a 100644 --- a/packages/wallet-sdk/sdk/index.ts +++ b/packages/wallet-sdk/domain/sdk/index.ts @@ -4,7 +4,7 @@ // The entity types the namespaces expose are public projections of the domain // entities: `userId`/`ownerId` are implicit from the session; raw wallet // handles and proof material stay internal. -import type { SparkNetwork } from '../db/json-models/spark-account-details-db-data'; +import type { SparkNetwork } from '../../db/json-models/spark-account-details-db-data'; import type { AccountsApi } from './accounts'; import type { AuthApi, AuthStorage } from './auth'; import type { ContactsApi } from './contacts'; @@ -49,8 +49,8 @@ export type SdkConfig = { storage: AuthStorage; /** * Host override for guest credential generation; resolve null to use the - * SDK's CSPRNG generator. Test seam (the web bridges its e2e password - * mock through it). + * SDK's CSPRNG generator. Test seam for host-supplied guest credentials + * (e.g. an e2e password mock). */ generateGuestPassword?: () => Promise; }; diff --git a/packages/wallet-sdk/sdk/receive.ts b/packages/wallet-sdk/domain/sdk/receive.ts similarity index 82% rename from packages/wallet-sdk/sdk/receive.ts rename to packages/wallet-sdk/domain/sdk/receive.ts index 33d946f5b..df3b14843 100644 --- a/packages/wallet-sdk/sdk/receive.ts +++ b/packages/wallet-sdk/domain/sdk/receive.ts @@ -1,12 +1,12 @@ -import type { CashuReceiveQuote } from '../domain/receive/cashu-receive-quote'; -import type { CashuReceiveLightningQuote } from '../domain/receive/cashu-receive-quote-core'; -import type { SparkReceiveQuote } from '../domain/receive/spark-receive-quote'; -import type { SparkReceiveLightningQuote } from '../domain/receive/spark-receive-quote-core'; +import type { CashuReceiveQuote } from '../receive/cashu-receive-quote'; +import type { CashuReceiveLightningQuote } from '../receive/cashu-receive-quote-core'; +import type { SparkReceiveQuote } from '../receive/spark-receive-quote'; +import type { SparkReceiveLightningQuote } from '../receive/spark-receive-quote-core'; // The public receive types are the domain entities for now: only the apps // consume the SDK and they just read these shapes, so the extra domain fields // (e.g. proofs) ride along until a later slice narrows the surface (#1164). -export type { CashuReceiveSwap } from '../domain/receive/cashu-receive-swap'; +export type { CashuReceiveSwap } from '../receive/cashu-receive-swap'; export type { CashuReceiveQuote, SparkReceiveQuote }; /** diff --git a/packages/wallet-sdk/agicash-sdk.test.ts b/packages/wallet-sdk/domain/sdk/sdk.test.ts similarity index 88% rename from packages/wallet-sdk/agicash-sdk.test.ts rename to packages/wallet-sdk/domain/sdk/sdk.test.ts index c30a12ae1..e701f1334 100644 --- a/packages/wallet-sdk/agicash-sdk.test.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test'; -import { AgicashSdk } from './agicash-sdk'; -import { nullLogger } from './lib/logger'; -import type { AuthKeyValueStore, SdkConfig } from './sdk'; +import type { AuthKeyValueStore, SdkConfig } from '.'; +import { nullLogger } from '../../lib/logger'; +import { AgicashSdk } from './sdk'; const createMemoryStore = (): AuthKeyValueStore => { const data = new Map(); diff --git a/packages/wallet-sdk/agicash-sdk.ts b/packages/wallet-sdk/domain/sdk/sdk.ts similarity index 66% rename from packages/wallet-sdk/agicash-sdk.ts rename to packages/wallet-sdk/domain/sdk/sdk.ts index d2a3c2df3..57dc5b93c 100644 --- a/packages/wallet-sdk/agicash-sdk.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.ts @@ -1,13 +1,28 @@ import * as openSecret from '@agicash/opensecret'; -import { createAgicashDbClient } from './db/client'; -import { createSupabaseSessionTokenGetter } from './db/supabase-session'; -import { AuthService } from './domain/user/auth-service'; -import { createUserApi } from './domain/user/user-api'; -import { clearAgicashMintAuthToken } from './lib/agicash-mint-auth-provider'; -import { WalletEventEmitter } from './lib/events'; -import { generateRandomPassword } from './lib/password'; -import { clearSparkWallets } from './lib/spark/wallet'; -import type { AuthApi, Sdk, SdkConfig, UserApi, WalletEvents } from './sdk'; +import type { + AccountsApi, + AuthApi, + ContactsApi, + FeatureFlagsApi, + ReceiveApi, + Sdk, + SdkConfig, + SendApi, + TaskProcessorApi, + TransactionsApi, + TransferApi, + UserApi, + WalletEvents, +} from '.'; +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 { WalletEventEmitter } from '../../lib/events'; +import { generateRandomPassword } from '../../lib/password'; +import { clearSparkWallets } from '../../lib/spark/wallet'; +import { AuthService } from '../user/auth-service'; +import { createUserApi } from '../user/user-api'; // Makes the one-instance-per-process constraint (see the constructor note) // self-enforcing: create() refuses to run while an undisposed instance holds @@ -15,17 +30,40 @@ import type { AuthApi, Sdk, SdkConfig, UserApi, WalletEvents } from './sdk'; let liveInstance: AgicashSdk | undefined; /** - * Runtime implementation of the SDK contract, filled namespace-by-namespace - * as the migration slices land (auth/user/events since step 5). Each slice - * adds its namespace to the `Pick` until it collapses to the full `Sdk`. + * 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`. */ -export class AgicashSdk - implements Pick -{ +export class AgicashSdk implements Sdk { readonly auth: AuthApi; readonly user: UserApi; readonly events: WalletEvents; + get accounts(): AccountsApi { + throw new NotImplementedError('accounts'); + } + get contacts(): ContactsApi { + throw new NotImplementedError('contacts'); + } + get transactions(): TransactionsApi { + throw new NotImplementedError('transactions'); + } + get receive(): ReceiveApi { + throw new NotImplementedError('receive'); + } + get send(): SendApi { + throw new NotImplementedError('send'); + } + get transfer(): TransferApi { + throw new NotImplementedError('transfer'); + } + get featureFlags(): FeatureFlagsApi { + throw new NotImplementedError('featureFlags'); + } + get taskProcessor(): TaskProcessorApi { + throw new NotImplementedError('taskProcessor'); + } + private readonly authService: AuthService; private constructor(config: SdkConfig) { diff --git a/packages/wallet-sdk/sdk/send.ts b/packages/wallet-sdk/domain/sdk/send.ts similarity index 74% rename from packages/wallet-sdk/sdk/send.ts rename to packages/wallet-sdk/domain/sdk/send.ts index c788fa508..d6dc898b4 100644 --- a/packages/wallet-sdk/sdk/send.ts +++ b/packages/wallet-sdk/domain/sdk/send.ts @@ -1,14 +1,14 @@ -import type { CashuLightningQuote } from '../domain/send/cashu-send-quote-service'; -import type { CashuSwapQuote } from '../domain/send/cashu-send-swap-service'; -import type { SparkLightningQuote } from '../domain/send/spark-send-quote-service'; -import type { DestinationDetails } from '../lib/send-destination'; +import type { DestinationDetails } from '../../lib/send-destination'; +import type { CashuLightningQuote } from '../send/cashu-send-quote-service'; +import type { CashuSwapQuote } from '../send/cashu-send-swap-service'; +import type { SparkLightningQuote } from '../send/spark-send-quote-service'; // The public send types are the domain entities for now: only the apps consume // the SDK and they just read these shapes, so fields like proofs and userId // ride along until a later slice narrows the surface (#1164). -export type { CashuSendQuote } from '../domain/send/cashu-send-quote'; -export type { CashuSendSwap } from '../domain/send/cashu-send-swap'; -export type { SparkSendQuote } from '../domain/send/spark-send-quote'; +export type { CashuSendQuote } from '../send/cashu-send-quote'; +export type { CashuSendSwap } from '../send/cashu-send-swap'; +export type { SparkSendQuote } from '../send/spark-send-quote'; export type SendApi = { resolveDestination(input: string): Promise; diff --git a/packages/wallet-sdk/sdk/server.ts b/packages/wallet-sdk/domain/sdk/server.ts similarity index 93% rename from packages/wallet-sdk/sdk/server.ts rename to packages/wallet-sdk/domain/sdk/server.ts index 538148b45..6e6bb87b2 100644 --- a/packages/wallet-sdk/sdk/server.ts +++ b/packages/wallet-sdk/domain/sdk/server.ts @@ -5,7 +5,7 @@ import type { LNURLVerifyResult, } from '@agicash/lnurl'; import type { Money } from '@agicash/money'; -import type { SparkNetwork } from '../db/json-models/spark-account-details-db-data'; +import type { SparkNetwork } from '../../db/json-models/spark-account-details-db-data'; /** * Server-side trust model: service-role key, no user session, per-request diff --git a/packages/wallet-sdk/sdk/task-processor.ts b/packages/wallet-sdk/domain/sdk/task-processor.ts similarity index 100% rename from packages/wallet-sdk/sdk/task-processor.ts rename to packages/wallet-sdk/domain/sdk/task-processor.ts diff --git a/packages/wallet-sdk/sdk/transactions.ts b/packages/wallet-sdk/domain/sdk/transactions.ts similarity index 74% rename from packages/wallet-sdk/sdk/transactions.ts rename to packages/wallet-sdk/domain/sdk/transactions.ts index 5d9820af4..49aced82f 100644 --- a/packages/wallet-sdk/sdk/transactions.ts +++ b/packages/wallet-sdk/domain/sdk/transactions.ts @@ -1,5 +1,5 @@ -import type { Transaction as DomainTransaction } from '../domain/transactions/transaction'; -import type { Cursor } from '../domain/transactions/transaction-repository'; +import type { Transaction as DomainTransaction } from '../transactions/transaction'; +import type { Cursor } from '../transactions/transaction-repository'; export type { Cursor }; diff --git a/packages/wallet-sdk/sdk/transfer.ts b/packages/wallet-sdk/domain/sdk/transfer.ts similarity index 84% rename from packages/wallet-sdk/sdk/transfer.ts rename to packages/wallet-sdk/domain/sdk/transfer.ts index 4b58b42da..10670d915 100644 --- a/packages/wallet-sdk/sdk/transfer.ts +++ b/packages/wallet-sdk/domain/sdk/transfer.ts @@ -1,4 +1,4 @@ -import type { TransferQuote } from '../domain/transfer/transfer-service'; +import type { TransferQuote } from '../transfer/transfer-service'; export type TransferApi = { /** Stateless preview. */ diff --git a/packages/wallet-sdk/sdk/user.ts b/packages/wallet-sdk/domain/sdk/user.ts similarity index 86% rename from packages/wallet-sdk/sdk/user.ts rename to packages/wallet-sdk/domain/sdk/user.ts index 82a1629ba..343715399 100644 --- a/packages/wallet-sdk/sdk/user.ts +++ b/packages/wallet-sdk/domain/sdk/user.ts @@ -1,6 +1,6 @@ import type { Currency } from '@agicash/money'; -import type { Account } from '../domain/accounts/account'; -import type { User } from '../domain/user/user'; +import type { Account } from '../accounts/account'; +import type { User } from '../user/user'; export type UserApi = { get(): Promise; diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts index 253ad545d..e1449ea09 100644 --- a/packages/wallet-sdk/domain/user/auth-service.test.ts +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test'; import { DisposedError } from '../../lib/error'; import { WalletEventEmitter } from '../../lib/events'; import { nullLogger } from '../../lib/logger'; -import type { AuthKeyValueStore, AuthStorage } from '../../sdk'; +import type { AuthKeyValueStore, AuthStorage } from '../sdk'; import { AuthService, type OpenSecretAuthApi } from './auth-service'; const createMemoryStore = (): AuthKeyValueStore & { diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index 4aa3f6f26..246aa5503 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -15,7 +15,7 @@ import { type GuestAccountStorage, createGuestAccountStorage, } from '../../lib/guest-account-storage'; -import type { AuthApi, AuthSession, AuthStorage, Logger } from '../../sdk'; +import type { AuthApi, AuthSession, AuthStorage, Logger } from '../sdk'; // Keys are owned by @agicash/opensecret's token persistence; the service reads // them (never writes) for session detection and expiry math. diff --git a/packages/wallet-sdk/domain/user/user-api.test.ts b/packages/wallet-sdk/domain/user/user-api.test.ts index f5da829f5..2b28a6289 100644 --- a/packages/wallet-sdk/domain/user/user-api.test.ts +++ b/packages/wallet-sdk/domain/user/user-api.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'bun:test'; import type { Currency } from '@agicash/money'; import type { AgicashDb } from '../../db/database'; -import type { AuthUser } from '../../sdk'; import type { Account } from '../accounts/account'; +import type { AuthUser } from '../sdk'; import { createUserApi } from './user-api'; const authUser = (id: string): AuthUser => diff --git a/packages/wallet-sdk/domain/user/user-api.ts b/packages/wallet-sdk/domain/user/user-api.ts index c5033de4a..f7889f30b 100644 --- a/packages/wallet-sdk/domain/user/user-api.ts +++ b/packages/wallet-sdk/domain/user/user-api.ts @@ -1,6 +1,6 @@ import type { AgicashDb } from '../../db/database'; import { NoSessionError } from '../../lib/error'; -import type { AuthSession, UserApi } from '../../sdk'; +import type { AuthSession, UserApi } from '../sdk'; import { ReadUserRepository, UpdateUserRepository } from './user-repository'; import { UserService } from './user-service'; diff --git a/packages/wallet-sdk/index.ts b/packages/wallet-sdk/index.ts index 91d593744..712080076 100644 --- a/packages/wallet-sdk/index.ts +++ b/packages/wallet-sdk/index.ts @@ -1,14 +1,14 @@ -// @agicash/wallet-sdk — public surface: the SDK contract (sdk.ts) + domain -// types + typed errors. Repositories, services, and the wallet DB layer are +// @agicash/wallet-sdk — public surface: the SDK contract + domain types + +// typed errors. Repositories, services, and the wallet DB layer are // SDK-internal; during the migration they're re-exported from // '@agicash/wallet-sdk/temporary' instead, so that deleting /temporary at the // end compiler-enforces the boundary. // The explicit domain-type exports below SHADOW the same-named contract -// projections from './sdk' (an explicit export beats `export *`); each slice +// projections from './domain/sdk' (an explicit export beats `export *`); each slice // deletes its names here when it flips the web imports, surfacing the // projections. -export * from './sdk'; -export { AgicashSdk } from './agicash-sdk'; +export * from './domain/sdk'; +export { AgicashSdk } from './domain/sdk/sdk'; export { nullLogger } from './lib/logger'; export { ConcurrencyError, diff --git a/packages/wallet-sdk/lib/error.ts b/packages/wallet-sdk/lib/error.ts index 9962f26bd..946d23d72 100644 --- a/packages/wallet-sdk/lib/error.ts +++ b/packages/wallet-sdk/lib/error.ts @@ -46,3 +46,11 @@ export class DisposedError extends SdkError { this.name = 'DisposedError'; } } + +/** Thrown when a namespace is accessed before its migration slice has landed. */ +export class NotImplementedError extends SdkError { + constructor(namespace: string) { + super(`${namespace} is not implemented yet.`); + this.name = 'NotImplementedError'; + } +} diff --git a/packages/wallet-sdk/lib/events.ts b/packages/wallet-sdk/lib/events.ts index 4038f6a2e..818844f8d 100644 --- a/packages/wallet-sdk/lib/events.ts +++ b/packages/wallet-sdk/lib/events.ts @@ -1,4 +1,4 @@ -import type { Logger, WalletEventMap, WalletEvents } from '../sdk'; +import type { Logger, WalletEventMap, WalletEvents } from '../domain/sdk'; type Handler = (payload: never) => void; diff --git a/packages/wallet-sdk/lib/guest-account-storage.test.ts b/packages/wallet-sdk/lib/guest-account-storage.test.ts index b1cd1da39..bf4170bd3 100644 --- a/packages/wallet-sdk/lib/guest-account-storage.test.ts +++ b/packages/wallet-sdk/lib/guest-account-storage.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import type { AuthKeyValueStore } from '../sdk'; +import type { AuthKeyValueStore } from '../domain/sdk'; import { createGuestAccountStorage } from './guest-account-storage'; import { nullLogger } from './logger'; diff --git a/packages/wallet-sdk/lib/guest-account-storage.ts b/packages/wallet-sdk/lib/guest-account-storage.ts index 520afd929..93a5406ae 100644 --- a/packages/wallet-sdk/lib/guest-account-storage.ts +++ b/packages/wallet-sdk/lib/guest-account-storage.ts @@ -1,6 +1,6 @@ import { safeJsonParse } from '@agicash/utils'; import { z } from 'zod/mini'; -import type { AuthKeyValueStore, Logger } from '../sdk'; +import type { AuthKeyValueStore, Logger } from '../domain/sdk'; // Key predates the SDK move — existing devices have guest credentials stored // under it, so it must not change. diff --git a/packages/wallet-sdk/lib/logger.ts b/packages/wallet-sdk/lib/logger.ts index 95afd1950..98829dd48 100644 --- a/packages/wallet-sdk/lib/logger.ts +++ b/packages/wallet-sdk/lib/logger.ts @@ -1,4 +1,4 @@ -import type { Logger } from '../sdk'; +import type { Logger } from '../domain/sdk'; const noop = () => undefined; diff --git a/packages/wallet-sdk/lib/password.test.ts b/packages/wallet-sdk/lib/password.test.ts index 63e286597..e113c4d58 100644 --- a/packages/wallet-sdk/lib/password.test.ts +++ b/packages/wallet-sdk/lib/password.test.ts @@ -2,23 +2,23 @@ import { describe, expect, it } from 'bun:test'; import { generateRandomPassword } from './password'; describe('generateRandomPassword', () => { - it('generates the requested length', async () => { - const password = await generateRandomPassword(32); + it('generates the requested length', () => { + const password = generateRandomPassword(32); expect(password).toHaveLength(32); }); - it('throws when no character set is selected', async () => { - await expect( + it('throws when no character set is selected', () => { + expect(() => generateRandomPassword(16, { letters: false, numbers: false, special: false, }), - ).rejects.toThrow(); + ).toThrow(); }); - it('uses only letters when letters is the sole enabled set', async () => { - const password = await generateRandomPassword(128, { + it('uses only letters when letters is the sole enabled set', () => { + const password = generateRandomPassword(128, { letters: true, numbers: false, special: false, @@ -26,8 +26,8 @@ describe('generateRandomPassword', () => { expect(password).toMatch(/^[a-zA-Z]+$/); }); - it('uses only digits when numbers is the sole enabled set', async () => { - const password = await generateRandomPassword(128, { + it('uses only digits when numbers is the sole enabled set', () => { + const password = generateRandomPassword(128, { letters: false, numbers: true, special: false, @@ -35,8 +35,8 @@ describe('generateRandomPassword', () => { expect(password).toMatch(/^[0-9]+$/); }); - it('uses only special characters when special is the sole enabled set', async () => { - const password = await generateRandomPassword(128, { + it('uses only special characters when special is the sole enabled set', () => { + const password = generateRandomPassword(128, { letters: false, numbers: false, special: true, @@ -44,8 +44,8 @@ describe('generateRandomPassword', () => { expect(password).toMatch(/^[!@#$%^&*()_+~]+$/); }); - it('mixes letters and digits when both are enabled', async () => { - const password = await generateRandomPassword(128, { + it('mixes letters and digits when both are enabled', () => { + const password = generateRandomPassword(128, { letters: true, numbers: true, special: false, diff --git a/packages/wallet-sdk/lib/password.ts b/packages/wallet-sdk/lib/password.ts index 35bf89965..292098825 100644 --- a/packages/wallet-sdk/lib/password.ts +++ b/packages/wallet-sdk/lib/password.ts @@ -4,10 +4,10 @@ type PasswordOptions = { special?: boolean; }; -export async function generateRandomPassword( +export function generateRandomPassword( length = 24, options: PasswordOptions = { letters: true, numbers: true, special: true }, -): Promise { +): string { let charset = ''; if (options.letters) From d5d2f18aaa94ef4ca04b497396eeb266e284ba1c Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 08:43:28 -0700 Subject: [PATCH 39/49] refactor(wallet-sdk): expose domain account types from the accounts contract (#1166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the projected CashuAccount/SparkAccount (the omit + computed balance) and re-export the domain Account/CashuAccount/SparkAccount directly, matching the receive/send contracts (#1164). Cashu accounts carry no balance field — consumers sum the now-exposed proofs. Co-Authored-By: Claude Opus 4.8 --- packages/wallet-sdk/domain/sdk/accounts.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/wallet-sdk/domain/sdk/accounts.ts b/packages/wallet-sdk/domain/sdk/accounts.ts index 15481acea..d0cd783e7 100644 --- a/packages/wallet-sdk/domain/sdk/accounts.ts +++ b/packages/wallet-sdk/domain/sdk/accounts.ts @@ -1,16 +1,11 @@ -import type { Money } from '@agicash/money'; -import type { - CashuAccount as DomainCashuAccount, - SparkAccount as DomainSparkAccount, -} from '../accounts/account'; +import type { Account, CashuAccount, SparkAccount } from '../accounts/account'; -/** Carries `balance` on every rail, never a raw wallet handle or proof material. */ -export type CashuAccount = Omit< - DomainCashuAccount, - 'keysetCounters' | 'proofs' | 'wallet' -> & { balance: Money | null }; -export type SparkAccount = Omit; -export type Account = CashuAccount | SparkAccount; +// 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, +// keysetCounters, and wallet ride along until a later slice narrows the surface +// (#1164). Cashu accounts carry no balance field — consumers sum the exposed +// proofs (getAccountBalance does this). +export type { Account, CashuAccount, SparkAccount }; export type AccountsApi = { get(id: string): Promise; From ceca5f575552780a2ba115a782eab5462972bca4 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 09:31:14 -0700 Subject: [PATCH 40/49] chore(wallet-sdk): nest the supabase project under db/ (#1166) Move packages/wallet-sdk/supabase to packages/wallet-sdk/db/supabase and retarget every reference: the Supabase CLI --workdir becomes packages/wallet-sdk/db, and the generated-types output, biome ignore, both tsconfig path-alias targets, the CI migration/types paths, and the live docs (README + supabase skill) follow. The `supabase/database.types` import alias is unchanged. The self-signed TLS cert paths gain one `../` (ci.yml + web-wallet-e2e/.env.test) because Supabase resolves config.toml paths relative to the now-deeper project folder; the web app is unaffected (it loads its cert via a hardcoded path). Co-Authored-By: Claude Opus 4.8 --- .claude/settings.json | 2 +- .claude/skills/supabase-database/SKILL.md | 2 +- .../supabase-database/references/migrations.md | 2 +- .github/workflows/ci.yml | 12 ++++++------ README.md | 6 +++--- apps/web-wallet-e2e/.env.test | 4 ++-- apps/web-wallet/tsconfig.json | 2 +- biome.jsonc | 2 +- package.json | 4 ++-- packages/wallet-sdk/{ => db}/supabase/.env | 0 packages/wallet-sdk/{ => db}/supabase/.gitignore | 0 packages/wallet-sdk/{ => db}/supabase/config.toml | 0 .../wallet-sdk/{ => db}/supabase/database.types.ts | 0 .../migrations/20260112150000_initial_db.sql | 0 .../20260202120000_make_terms_nullable.sql | 0 .../migrations/20260223193702_feature_flags.sql | 0 .../20260225000000_add_debug_logging_flag.sql | 0 ...20260225120000_enforce_gift_card_feature_flag.sql | 0 .../20260302120000_add_version_to_transactions.sql | 0 ...20000_add_transaction_purpose_and_transfer_id.sql | 0 .../20260310120000_move_transfer_id_to_jsonb.sql | 0 .../20260312163752_transfer_id_text_to_uuid.sql | 0 .../20260320120000_add_offer_account_purpose.sql | 0 .../migrations/20260325120000_add_account_state.sql | 0 .../20260325130000_default_account_no_expiry.sql | 0 .../20260413222901_generic_event_system.sql | 0 .../20260415180000_add_gift_card_mint_terms.sql | 0 ...420152512_denormalize_account_on_transactions.sql | 0 ...0260420192806_fix_emit_event_pg_net_signature.sql | 0 ...2259_fix_fail_cashu_receive_quote_state_guard.sql | 0 ...43_tighten_spark_send_payment_hash_uniqueness.sql | 0 ...20260505222417_remove_gift_cards_feature_flag.sql | 0 .../20260522185431_add_users_broadcast_trigger.sql | 0 packages/wallet-sdk/{ => db}/supabase/seed.sql | 0 packages/wallet-sdk/tsconfig.json | 2 +- 35 files changed, 19 insertions(+), 19 deletions(-) rename packages/wallet-sdk/{ => db}/supabase/.env (100%) rename packages/wallet-sdk/{ => db}/supabase/.gitignore (100%) rename packages/wallet-sdk/{ => db}/supabase/config.toml (100%) rename packages/wallet-sdk/{ => db}/supabase/database.types.ts (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260112150000_initial_db.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260202120000_make_terms_nullable.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260223193702_feature_flags.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260225000000_add_debug_logging_flag.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260225120000_enforce_gift_card_feature_flag.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260302120000_add_version_to_transactions.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260306120000_add_transaction_purpose_and_transfer_id.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260310120000_move_transfer_id_to_jsonb.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260312163752_transfer_id_text_to_uuid.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260320120000_add_offer_account_purpose.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260325120000_add_account_state.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260325130000_default_account_no_expiry.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260413222901_generic_event_system.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260415180000_add_gift_card_mint_terms.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260420152512_denormalize_account_on_transactions.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260420192806_fix_emit_event_pg_net_signature.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260422142259_fix_fail_cashu_receive_quote_state_guard.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260425181643_tighten_spark_send_payment_hash_uniqueness.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260505222417_remove_gift_cards_feature_flag.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260522185431_add_users_broadcast_trigger.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/seed.sql (100%) diff --git a/.claude/settings.json b/.claude/settings.json index 97a99e797..2a976d65c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -27,7 +27,7 @@ "Bash(pnpm:*)", "Read(.env)", "Read(.env.*)", - "Read(supabase/.env)" + "Read(packages/wallet-sdk/db/supabase/.env)" ] }, "hooks": { diff --git a/.claude/skills/supabase-database/SKILL.md b/.claude/skills/supabase-database/SKILL.md index 2ac43e744..6148f6060 100644 --- a/.claude/skills/supabase-database/SKILL.md +++ b/.claude/skills/supabase-database/SKILL.md @@ -21,6 +21,6 @@ Expert guidance for Postgres database work in a Supabase environment. - Write all SQL in lowercase - Always enable RLS on new tables - Separate RLS policies per operation (select/insert/update/delete) and per role (anon/authenticated) -- Migration files: `YYYYMMDDHHmmss_short_description.sql` in `packages/wallet-sdk/supabase/migrations/` +- Migration files: `YYYYMMDDHHmmss_short_description.sql` in `packages/wallet-sdk/db/supabase/migrations/` - Functions default to `SECURITY INVOKER` with `set search_path = ''` - Use fully qualified names (e.g., `public.table_name`) in functions diff --git a/.claude/skills/supabase-database/references/migrations.md b/.claude/skills/supabase-database/references/migrations.md index 0691b921f..098db9227 100644 --- a/.claude/skills/supabase-database/references/migrations.md +++ b/.claude/skills/supabase-database/references/migrations.md @@ -4,7 +4,7 @@ This project uses the migrations provided by the Supabase CLI. ## Creating a migration file -Create migration files inside `packages/wallet-sdk/supabase/migrations/`. +Create migration files inside `packages/wallet-sdk/db/supabase/migrations/`. The file MUST be named in the format `YYYYMMDDHHmmss_short_description.sql` with proper casing for months, minutes, and seconds in UTC time: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58e2812e1..dd8d68539 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,8 @@ on: pull_request: env: - SELF_SIGNED_CERT_PATH: "../../../certs/ci-localhost-cert.pem" - SELF_SIGNED_CERT_KEY_PATH: "../../../certs/ci-localhost-key.pem" + SELF_SIGNED_CERT_PATH: "../../../../certs/ci-localhost-cert.pem" + SELF_SIGNED_CERT_KEY_PATH: "../../../../certs/ci-localhost-key.pem" jobs: code-checks: @@ -28,7 +28,7 @@ jobs: MIGRATIONS_CHANGED=false CLI_VERSION_CHANGED=false - if ! git diff --quiet origin/${{ github.base_ref }}..HEAD -- packages/wallet-sdk/supabase/migrations/; then + if ! git diff --quiet origin/${{ github.base_ref }}..HEAD -- packages/wallet-sdk/db/supabase/migrations/; then MIGRATIONS_CHANGED=true fi @@ -50,9 +50,9 @@ jobs: fi echo "Checking types..." - bun supabase db start --workdir packages/wallet-sdk - bun supabase gen types typescript --local --schema wallet --workdir packages/wallet-sdk > packages/wallet-sdk/supabase/database.types.ts - if ! git diff --ignore-space-at-eol --exit-code --quiet packages/wallet-sdk/supabase/database.types.ts; then + bun supabase db start --workdir packages/wallet-sdk/db + bun supabase gen types typescript --local --schema wallet --workdir packages/wallet-sdk/db > packages/wallet-sdk/db/supabase/database.types.ts + if ! git diff --ignore-space-at-eol --exit-code --quiet packages/wallet-sdk/db/supabase/database.types.ts; then echo "Detected uncommitted changes in database types. This means that db was changed but types were not updated. To fix regenerate the types and commit the changes. See missing types below:" git diff exit 1 diff --git a/README.md b/README.md index d3c5bda2d..266b33baf 100644 --- a/README.md +++ b/README.md @@ -178,18 +178,18 @@ hosted on Supabase. Sensitive data is encrypted with the key from the Open Secre logged-in user. While developing locally, the local Supabase stack is used. To start it, run `bun run supabase start` command. To stop it, run `bun run supabase stop` command. The start command will start the database and realtime service, plus other services useful for development, like Supabase Studio. You can use Supabase Studio to inspect the database and run queries. -Supabase is configured in the `packages/wallet-sdk/supabase/config.toml` file. +Supabase is configured in the `packages/wallet-sdk/db/supabase/config.toml` file. ### Database migrations -Schema changes to the Postgres database should be done using migrations. Migrations are stored in the `packages/wallet-sdk/supabase/migrations` +Schema changes to the Postgres database should be done using migrations. Migrations are stored in the `packages/wallet-sdk/db/supabase/migrations` folder. Always try to make the schema changes in a backward compatible way. Database migrations can be done in two ways: 1. Using Supabase Studio. With this approach you can make db changes directly to your local database in the Studio UI and then run `bun supabase db diff --file ` to create a migration file for the changes. 2. Using `bun supabase migration new `. This command will create a new empty migration file in the - `packages/wallet-sdk/supabase/migrations` folder where you can then write the SQL commands to make the changes. To apply the migration to + `packages/wallet-sdk/db/supabase/migrations` folder where you can then write the SQL commands to make the changes. To apply the migration to the local database run `bun supabase db push`. To keep the db TypeScript types in sync with the database schema run `bun run db:generate-types` command. If you forget diff --git a/apps/web-wallet-e2e/.env.test b/apps/web-wallet-e2e/.env.test index 26c7d7847..567fe89fe 100644 --- a/apps/web-wallet-e2e/.env.test +++ b/apps/web-wallet-e2e/.env.test @@ -8,8 +8,8 @@ VITE_SENTRY_HOST='o4509706567680000.ingest.us.sentry.io' VITE_SENTRY_PROJECT_ID='4509707316690944' VITE_SENTRY_DSN='https://3e5837ba4db251e806915155170ef71b@${VITE_SENTRY_HOST}/${VITE_SENTRY_PROJECT_ID}' VITE_ENVIRONMENT='local' -SELF_SIGNED_CERT_PATH='../../../certs/localhost-cert.pem' -SELF_SIGNED_CERT_KEY_PATH='../../../certs/localhost-key.pem' +SELF_SIGNED_CERT_PATH='../../../../certs/localhost-cert.pem' +SELF_SIGNED_CERT_KEY_PATH='../../../../certs/localhost-key.pem' NODE_TLS_REJECT_UNAUTHORIZED=0 LNURL_SERVER_SPARK_MNEMONIC='half vanish taxi again drill detail neglect park harsh setup involve dawn' LNURL_SERVER_ENCRYPTION_KEY="a8861cc3e5b3caf5573fbba2da2851a4e608a836eef10074be36ae0ca147b518" diff --git a/apps/web-wallet/tsconfig.json b/apps/web-wallet/tsconfig.json index 746f3718c..ce5755423 100644 --- a/apps/web-wallet/tsconfig.json +++ b/apps/web-wallet/tsconfig.json @@ -18,7 +18,7 @@ "paths": { "~/*": ["./app/*"], "supabase/database.types": [ - "../../packages/wallet-sdk/supabase/database.types.ts" + "../../packages/wallet-sdk/db/supabase/database.types.ts" ] }, diff --git a/biome.jsonc b/biome.jsonc index 57bbcea0b..895ef9a20 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -8,7 +8,7 @@ }, "files": { "ignoreUnknown": true, - "ignore": ["packages/wallet-sdk/supabase/database.types.ts"] + "ignore": ["packages/wallet-sdk/db/supabase/database.types.ts"] }, "formatter": { "enabled": true, diff --git a/package.json b/package.json index aa1f87aac..47b6ca783 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,8 @@ "fix:all": "biome check --write --verbose", "fix:staged": "biome check --write --staged --verbose", "check:all": "run-p typecheck lint:check format:check", - "supabase": "supabase --workdir packages/wallet-sdk", - "db:generate-types": "supabase gen types typescript --local --schema wallet --workdir packages/wallet-sdk > packages/wallet-sdk/supabase/database.types.ts" + "supabase": "supabase --workdir packages/wallet-sdk/db", + "db:generate-types": "supabase gen types typescript --local --schema wallet --workdir packages/wallet-sdk/db > packages/wallet-sdk/db/supabase/database.types.ts" }, "scripts:comments": { "dev": "Delegates to apps/web-wallet. The web dev server uses tsx instead of bun because with bun the server hangs and eventually errors with 'ELOOP: too many symbolic links encountered...'.", diff --git a/packages/wallet-sdk/supabase/.env b/packages/wallet-sdk/db/supabase/.env similarity index 100% rename from packages/wallet-sdk/supabase/.env rename to packages/wallet-sdk/db/supabase/.env diff --git a/packages/wallet-sdk/supabase/.gitignore b/packages/wallet-sdk/db/supabase/.gitignore similarity index 100% rename from packages/wallet-sdk/supabase/.gitignore rename to packages/wallet-sdk/db/supabase/.gitignore diff --git a/packages/wallet-sdk/supabase/config.toml b/packages/wallet-sdk/db/supabase/config.toml similarity index 100% rename from packages/wallet-sdk/supabase/config.toml rename to packages/wallet-sdk/db/supabase/config.toml diff --git a/packages/wallet-sdk/supabase/database.types.ts b/packages/wallet-sdk/db/supabase/database.types.ts similarity index 100% rename from packages/wallet-sdk/supabase/database.types.ts rename to packages/wallet-sdk/db/supabase/database.types.ts diff --git a/packages/wallet-sdk/supabase/migrations/20260112150000_initial_db.sql b/packages/wallet-sdk/db/supabase/migrations/20260112150000_initial_db.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260112150000_initial_db.sql rename to packages/wallet-sdk/db/supabase/migrations/20260112150000_initial_db.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260202120000_make_terms_nullable.sql b/packages/wallet-sdk/db/supabase/migrations/20260202120000_make_terms_nullable.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260202120000_make_terms_nullable.sql rename to packages/wallet-sdk/db/supabase/migrations/20260202120000_make_terms_nullable.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260223193702_feature_flags.sql b/packages/wallet-sdk/db/supabase/migrations/20260223193702_feature_flags.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260223193702_feature_flags.sql rename to packages/wallet-sdk/db/supabase/migrations/20260223193702_feature_flags.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260225000000_add_debug_logging_flag.sql b/packages/wallet-sdk/db/supabase/migrations/20260225000000_add_debug_logging_flag.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260225000000_add_debug_logging_flag.sql rename to packages/wallet-sdk/db/supabase/migrations/20260225000000_add_debug_logging_flag.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260225120000_enforce_gift_card_feature_flag.sql b/packages/wallet-sdk/db/supabase/migrations/20260225120000_enforce_gift_card_feature_flag.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260225120000_enforce_gift_card_feature_flag.sql rename to packages/wallet-sdk/db/supabase/migrations/20260225120000_enforce_gift_card_feature_flag.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260302120000_add_version_to_transactions.sql b/packages/wallet-sdk/db/supabase/migrations/20260302120000_add_version_to_transactions.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260302120000_add_version_to_transactions.sql rename to packages/wallet-sdk/db/supabase/migrations/20260302120000_add_version_to_transactions.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260306120000_add_transaction_purpose_and_transfer_id.sql b/packages/wallet-sdk/db/supabase/migrations/20260306120000_add_transaction_purpose_and_transfer_id.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260306120000_add_transaction_purpose_and_transfer_id.sql rename to packages/wallet-sdk/db/supabase/migrations/20260306120000_add_transaction_purpose_and_transfer_id.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260310120000_move_transfer_id_to_jsonb.sql b/packages/wallet-sdk/db/supabase/migrations/20260310120000_move_transfer_id_to_jsonb.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260310120000_move_transfer_id_to_jsonb.sql rename to packages/wallet-sdk/db/supabase/migrations/20260310120000_move_transfer_id_to_jsonb.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260312163752_transfer_id_text_to_uuid.sql b/packages/wallet-sdk/db/supabase/migrations/20260312163752_transfer_id_text_to_uuid.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260312163752_transfer_id_text_to_uuid.sql rename to packages/wallet-sdk/db/supabase/migrations/20260312163752_transfer_id_text_to_uuid.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260320120000_add_offer_account_purpose.sql b/packages/wallet-sdk/db/supabase/migrations/20260320120000_add_offer_account_purpose.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260320120000_add_offer_account_purpose.sql rename to packages/wallet-sdk/db/supabase/migrations/20260320120000_add_offer_account_purpose.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260325120000_add_account_state.sql b/packages/wallet-sdk/db/supabase/migrations/20260325120000_add_account_state.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260325120000_add_account_state.sql rename to packages/wallet-sdk/db/supabase/migrations/20260325120000_add_account_state.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260325130000_default_account_no_expiry.sql b/packages/wallet-sdk/db/supabase/migrations/20260325130000_default_account_no_expiry.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260325130000_default_account_no_expiry.sql rename to packages/wallet-sdk/db/supabase/migrations/20260325130000_default_account_no_expiry.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260413222901_generic_event_system.sql b/packages/wallet-sdk/db/supabase/migrations/20260413222901_generic_event_system.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260413222901_generic_event_system.sql rename to packages/wallet-sdk/db/supabase/migrations/20260413222901_generic_event_system.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260415180000_add_gift_card_mint_terms.sql b/packages/wallet-sdk/db/supabase/migrations/20260415180000_add_gift_card_mint_terms.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260415180000_add_gift_card_mint_terms.sql rename to packages/wallet-sdk/db/supabase/migrations/20260415180000_add_gift_card_mint_terms.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260420152512_denormalize_account_on_transactions.sql b/packages/wallet-sdk/db/supabase/migrations/20260420152512_denormalize_account_on_transactions.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260420152512_denormalize_account_on_transactions.sql rename to packages/wallet-sdk/db/supabase/migrations/20260420152512_denormalize_account_on_transactions.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260420192806_fix_emit_event_pg_net_signature.sql b/packages/wallet-sdk/db/supabase/migrations/20260420192806_fix_emit_event_pg_net_signature.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260420192806_fix_emit_event_pg_net_signature.sql rename to packages/wallet-sdk/db/supabase/migrations/20260420192806_fix_emit_event_pg_net_signature.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260422142259_fix_fail_cashu_receive_quote_state_guard.sql b/packages/wallet-sdk/db/supabase/migrations/20260422142259_fix_fail_cashu_receive_quote_state_guard.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260422142259_fix_fail_cashu_receive_quote_state_guard.sql rename to packages/wallet-sdk/db/supabase/migrations/20260422142259_fix_fail_cashu_receive_quote_state_guard.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260425181643_tighten_spark_send_payment_hash_uniqueness.sql b/packages/wallet-sdk/db/supabase/migrations/20260425181643_tighten_spark_send_payment_hash_uniqueness.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260425181643_tighten_spark_send_payment_hash_uniqueness.sql rename to packages/wallet-sdk/db/supabase/migrations/20260425181643_tighten_spark_send_payment_hash_uniqueness.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260505222417_remove_gift_cards_feature_flag.sql b/packages/wallet-sdk/db/supabase/migrations/20260505222417_remove_gift_cards_feature_flag.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260505222417_remove_gift_cards_feature_flag.sql rename to packages/wallet-sdk/db/supabase/migrations/20260505222417_remove_gift_cards_feature_flag.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260522185431_add_users_broadcast_trigger.sql b/packages/wallet-sdk/db/supabase/migrations/20260522185431_add_users_broadcast_trigger.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260522185431_add_users_broadcast_trigger.sql rename to packages/wallet-sdk/db/supabase/migrations/20260522185431_add_users_broadcast_trigger.sql diff --git a/packages/wallet-sdk/supabase/seed.sql b/packages/wallet-sdk/db/supabase/seed.sql similarity index 100% rename from packages/wallet-sdk/supabase/seed.sql rename to packages/wallet-sdk/db/supabase/seed.sql diff --git a/packages/wallet-sdk/tsconfig.json b/packages/wallet-sdk/tsconfig.json index 76351bec9..eb1740ad8 100644 --- a/packages/wallet-sdk/tsconfig.json +++ b/packages/wallet-sdk/tsconfig.json @@ -6,7 +6,7 @@ "lib": ["ES2022"], "types": ["bun"], "baseUrl": ".", - "paths": { "supabase/database.types": ["./supabase/database.types.ts"] }, + "paths": { "supabase/database.types": ["./db/supabase/database.types.ts"] }, "noEmit": true } } From 6a656d9622ba75c619e4d16b5e2c76392a2032c2 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 09:39:15 -0700 Subject: [PATCH 41/49] refactor(wallet-sdk): co-locate the event emitter with its contract; drop receive schema churn (#1166) - move WalletEventEmitter from lib/events.ts into domain/sdk/events.ts, next to the WalletEventMap/WalletEvents types it implements, and move its test with it - revert the schema-extraction refactor in the cashu-receive-quote, cashu-receive-swap, and spark-receive-quote domain files (unnecessary here; back to master) Co-Authored-By: Claude Opus 4.8 --- .../domain/receive/cashu-receive-quote.ts | 23 +++++------ .../domain/receive/cashu-receive-swap.ts | 12 +++--- .../domain/receive/spark-receive-quote.ts | 23 +++++------ .../{lib => domain/sdk}/events.test.ts | 2 +- packages/wallet-sdk/domain/sdk/events.ts | 40 +++++++++++++++++++ packages/wallet-sdk/domain/sdk/sdk.ts | 2 +- .../domain/user/auth-service.test.ts | 2 +- .../wallet-sdk/domain/user/auth-service.ts | 2 +- packages/wallet-sdk/lib/events.ts | 40 ------------------- 9 files changed, 71 insertions(+), 75 deletions(-) rename packages/wallet-sdk/{lib => domain/sdk}/events.test.ts (97%) delete mode 100644 packages/wallet-sdk/lib/events.ts diff --git a/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts b/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts index 60ba91651..e9c3d0b6e 100644 --- a/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts +++ b/packages/wallet-sdk/domain/receive/cashu-receive-quote.ts @@ -138,23 +138,22 @@ const CashuReceiveQuoteFailedStateSchema = z.object({ failureReason: z.string(), }); -const CashuReceiveQuoteTypeSchema = z.union([ - CashuReceiveQuoteLightningTypeSchema, - CashuReceiveQuoteCashuTokenTypeSchema, -]); - -const CashuReceiveQuoteStateSchema = z.union([ - CashuReceiveQuoteUnpaidExpiredStateSchema, - CashuReceiveQuotePaidCompletedStateSchema, - CashuReceiveQuoteFailedStateSchema, -]); - /** * Schema for cashu receive quote. */ export const CashuReceiveQuoteSchema = z.intersection( CashuReceiveQuoteBaseSchema, - z.intersection(CashuReceiveQuoteTypeSchema, CashuReceiveQuoteStateSchema), + z.intersection( + z.union([ + CashuReceiveQuoteLightningTypeSchema, + CashuReceiveQuoteCashuTokenTypeSchema, + ]), + z.union([ + CashuReceiveQuoteUnpaidExpiredStateSchema, + CashuReceiveQuotePaidCompletedStateSchema, + CashuReceiveQuoteFailedStateSchema, + ]), + ), ); export type CashuReceiveQuote = z.infer; diff --git a/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts b/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts index 01ce0b972..c2ac3e3dc 100644 --- a/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts +++ b/packages/wallet-sdk/domain/receive/cashu-receive-swap.ts @@ -102,18 +102,16 @@ const CashuReceiveSwapFailedStateSchema = z.object({ failureReason: z.string(), }); -const CashuReceiveSwapStateSchema = z.union([ - CashuReceiveSwapPendingStateSchema, - CashuReceiveSwapCompletedStateSchema, - CashuReceiveSwapFailedStateSchema, -]); - /** * Schema for cashu receive swap. */ export const CashuReceiveSwapSchema = z.intersection( CashuReceiveSwapBaseSchema, - CashuReceiveSwapStateSchema, + z.union([ + CashuReceiveSwapPendingStateSchema, + CashuReceiveSwapCompletedStateSchema, + CashuReceiveSwapFailedStateSchema, + ]), ); export type CashuReceiveSwap = z.infer; diff --git a/packages/wallet-sdk/domain/receive/spark-receive-quote.ts b/packages/wallet-sdk/domain/receive/spark-receive-quote.ts index bb2fbae63..f3c9efe36 100644 --- a/packages/wallet-sdk/domain/receive/spark-receive-quote.ts +++ b/packages/wallet-sdk/domain/receive/spark-receive-quote.ts @@ -125,23 +125,22 @@ const SparkReceiveQuoteFailedStateSchema = z.object({ failureReason: z.string(), }); -const SparkReceiveQuoteTypeSchema = z.union([ - SparkReceiveQuoteLightningTypeSchema, - SparkReceiveQuoteCashuTokenTypeSchema, -]); - -const SparkReceiveQuoteStateSchema = z.union([ - SparkReceiveQuoteUnpaidExpiredStateSchema, - SparkReceiveQuotePaidStateSchema, - SparkReceiveQuoteFailedStateSchema, -]); - /** * Schema for Spark receive quote. */ export const SparkReceiveQuoteSchema = z.intersection( SparkReceiveQuoteBaseSchema, - z.intersection(SparkReceiveQuoteTypeSchema, SparkReceiveQuoteStateSchema), + z.intersection( + z.union([ + SparkReceiveQuoteLightningTypeSchema, + SparkReceiveQuoteCashuTokenTypeSchema, + ]), + z.union([ + SparkReceiveQuoteUnpaidExpiredStateSchema, + SparkReceiveQuotePaidStateSchema, + SparkReceiveQuoteFailedStateSchema, + ]), + ), ); export type SparkReceiveQuote = z.infer; diff --git a/packages/wallet-sdk/lib/events.test.ts b/packages/wallet-sdk/domain/sdk/events.test.ts similarity index 97% rename from packages/wallet-sdk/lib/events.test.ts rename to packages/wallet-sdk/domain/sdk/events.test.ts index 6678d8b4f..ac291c233 100644 --- a/packages/wallet-sdk/lib/events.test.ts +++ b/packages/wallet-sdk/domain/sdk/events.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; +import { nullLogger } from '../../lib/logger'; import { WalletEventEmitter } from './events'; -import { nullLogger } from './logger'; describe('WalletEventEmitter', () => { it('delivers payloads to subscribed handlers', () => { diff --git a/packages/wallet-sdk/domain/sdk/events.ts b/packages/wallet-sdk/domain/sdk/events.ts index a74d0a988..214d3fab7 100644 --- a/packages/wallet-sdk/domain/sdk/events.ts +++ b/packages/wallet-sdk/domain/sdk/events.ts @@ -1,4 +1,5 @@ import type { Money } from '@agicash/money'; +import type { Logger } from '.'; import type { SdkError } from '../../lib/error'; import type { User } from '../user/user'; import type { Account } from './accounts'; @@ -79,3 +80,42 @@ export type WalletEvents = { handler: (payload: WalletEventMap[K]) => void, ): () => void; }; + +type Handler = (payload: never) => void; + +export class WalletEventEmitter implements WalletEvents { + private readonly handlers = new Map>(); + + constructor(private readonly logger: Logger) {} + + on( + event: K, + handler: (payload: WalletEventMap[K]) => void, + ): () => void { + const set = this.handlers.get(event) ?? new Set(); + set.add(handler as Handler); + this.handlers.set(event, set); + return () => { + set.delete(handler as Handler); + }; + } + + emit( + event: K, + payload: WalletEventMap[K], + ): void { + const set = this.handlers.get(event); + if (!set) { + return; + } + // Snapshot: a handler that (un)subscribes mid-emit must not change the + // current dispatch. + for (const handler of [...set]) { + try { + (handler as (payload: WalletEventMap[K]) => void)(payload); + } catch (error) { + this.logger.error(`Event handler for ${event} threw`, error); + } + } + } +} diff --git a/packages/wallet-sdk/domain/sdk/sdk.ts b/packages/wallet-sdk/domain/sdk/sdk.ts index 57dc5b93c..a0c780b24 100644 --- a/packages/wallet-sdk/domain/sdk/sdk.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.ts @@ -18,11 +18,11 @@ 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 { WalletEventEmitter } from '../../lib/events'; import { generateRandomPassword } from '../../lib/password'; import { clearSparkWallets } from '../../lib/spark/wallet'; import { AuthService } from '../user/auth-service'; import { createUserApi } from '../user/user-api'; +import { WalletEventEmitter } from './events'; // Makes the one-instance-per-process constraint (see the constructor note) // self-enforcing: create() refuses to run while an undisposed instance holds diff --git a/packages/wallet-sdk/domain/user/auth-service.test.ts b/packages/wallet-sdk/domain/user/auth-service.test.ts index e1449ea09..79f124d7c 100644 --- a/packages/wallet-sdk/domain/user/auth-service.test.ts +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'bun:test'; import { DisposedError } from '../../lib/error'; -import { WalletEventEmitter } from '../../lib/events'; import { nullLogger } from '../../lib/logger'; import type { AuthKeyValueStore, AuthStorage } from '../sdk'; +import { WalletEventEmitter } from '../sdk/events'; import { AuthService, type OpenSecretAuthApi } from './auth-service'; const createMemoryStore = (): AuthKeyValueStore & { diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts index 246aa5503..71e558e92 100644 --- a/packages/wallet-sdk/domain/user/auth-service.ts +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -10,12 +10,12 @@ import { setLongTimeout, } from '@agicash/utils'; import { DisposedError } from '../../lib/error'; -import type { WalletEventEmitter } from '../../lib/events'; import { type GuestAccountStorage, createGuestAccountStorage, } from '../../lib/guest-account-storage'; import type { AuthApi, AuthSession, AuthStorage, Logger } from '../sdk'; +import type { WalletEventEmitter } from '../sdk/events'; // Keys are owned by @agicash/opensecret's token persistence; the service reads // them (never writes) for session detection and expiry math. diff --git a/packages/wallet-sdk/lib/events.ts b/packages/wallet-sdk/lib/events.ts deleted file mode 100644 index 818844f8d..000000000 --- a/packages/wallet-sdk/lib/events.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Logger, WalletEventMap, WalletEvents } from '../domain/sdk'; - -type Handler = (payload: never) => void; - -export class WalletEventEmitter implements WalletEvents { - private readonly handlers = new Map>(); - - constructor(private readonly logger: Logger) {} - - on( - event: K, - handler: (payload: WalletEventMap[K]) => void, - ): () => void { - const set = this.handlers.get(event) ?? new Set(); - set.add(handler as Handler); - this.handlers.set(event, set); - return () => { - set.delete(handler as Handler); - }; - } - - emit( - event: K, - payload: WalletEventMap[K], - ): void { - const set = this.handlers.get(event); - if (!set) { - return; - } - // Snapshot: a handler that (un)subscribes mid-emit must not change the - // current dispatch. - for (const handler of [...set]) { - try { - (handler as (payload: WalletEventMap[K]) => void)(payload); - } catch (error) { - this.logger.error(`Event handler for ${event} threw`, error); - } - } - } -} From 898583490c0bac764c9fdee394c1ec2c88b63079 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 08:59:26 -0700 Subject: [PATCH 42/49] feat(wallet-sdk): session-scoped key plumbing Add createSessionKeys: per-session memoized getters for the encryption keypair, cashu seed, spark mnemonic, cashu locking xpub, and spark identity public key, derived from Open Secret. Each memo is generation-fenced so a derivation started before reset() cannot repopulate the cache for the next session, and rejections are not cached so a retry can recover. Wire keys.reset() into the AgicashSdk onSessionEnded teardown alongside the existing session-token, spark-wallet, and mint-auth-token clears, so a signed-in user's key material never survives into the next login. The accounts and user namespaces consume these getters in later commits. Co-Authored-By: Claude Fable 5 --- packages/wallet-sdk/domain/sdk/sdk.ts | 4 + packages/wallet-sdk/session-keys.test.ts | 80 +++++++++++++ packages/wallet-sdk/session-keys.ts | 138 +++++++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 packages/wallet-sdk/session-keys.test.ts create mode 100644 packages/wallet-sdk/session-keys.ts diff --git a/packages/wallet-sdk/domain/sdk/sdk.ts b/packages/wallet-sdk/domain/sdk/sdk.ts index a0c780b24..c1e513624 100644 --- a/packages/wallet-sdk/domain/sdk/sdk.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.ts @@ -20,6 +20,7 @@ import { clearAgicashMintAuthToken } from '../../lib/agicash-mint-auth-provider' import { NotImplementedError } from '../../lib/error'; import { generateRandomPassword } from '../../lib/password'; import { clearSparkWallets } from '../../lib/spark/wallet'; +import { createSessionKeys } from '../../session-keys'; import { AuthService } from '../user/auth-service'; import { createUserApi } from '../user/user-api'; import { WalletEventEmitter } from './events'; @@ -79,6 +80,8 @@ export class AgicashSdk implements Sdk { const events = new WalletEventEmitter(config.logger); + const keys = createSessionKeys(); + // Created before authService — the isLoggedIn closure dereferences it // lazily at request time, after the constructor has assigned it. const sessionToken = createSupabaseSessionTokenGetter({ @@ -102,6 +105,7 @@ export class AgicashSdk implements Sdk { sessionToken.reset(); clearSparkWallets(); clearAgicashMintAuthToken(); + keys.reset(); }, logger: config.logger, }); diff --git a/packages/wallet-sdk/session-keys.test.ts b/packages/wallet-sdk/session-keys.test.ts new file mode 100644 index 000000000..f0ddc1b0a --- /dev/null +++ b/packages/wallet-sdk/session-keys.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'bun:test'; +import { BASE_CASHU_LOCKING_DERIVATION_PATH } from './lib/cashu'; +import { deriveCashuXpub } from './lib/cryptography'; +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('does not let a derivation in flight at reset populate the next session', 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 the fetch under session a still resolves to its + // own session's value... + expect(await inFlight).toBe('mnemonic a'); + // ...but that stale resolution must not have populated the cache, so the + // next read derives session b fresh. + expect(await keys.getSparkMnemonic()).toBe('mnemonic b'); + }); +}); diff --git a/packages/wallet-sdk/session-keys.ts b/packages/wallet-sdk/session-keys.ts new file mode 100644 index 000000000..455b22ec0 --- /dev/null +++ b/packages/wallet-sdk/session-keys.ts @@ -0,0 +1,138 @@ +import { getPrivateKeyBytes, getPublicKey } from '@agicash/opensecret'; +import { hexToBytes } from '@noble/hashes/utils'; +import { BASE_CASHU_LOCKING_DERIVATION_PATH, getCashuSeed } from './lib/cashu'; +import { deriveCashuXpub } from './lib/cryptography'; +import { type Encryption, getEncryption } from './lib/encryption'; +import { + getSparkIdentityPublicKeyFromMnemonic, + getSparkMnemonic, +} from './lib/spark/wallet'; + +// 10111099 is 'enc' (for encryption) in ascii +const encryptionKeyDerivationPath = `m/10111099'/0'`; + +/** + * The per-session key material the accounts and user namespaces derive from + * Open Secret. Every getter is memoized for the life of the session and + * cleared by {@link SessionKeys.reset}; a getter never resolves one user's key + * into the next user's session. + */ +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; + /** Clears every memo. Call on session end so the next user derives fresh keys. */ + reset(): 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, generation-fenced so a fetch started before + * {@link clear} cannot populate the cache afterwards — its value belongs to the + * ended session. A rejection is not cached, so a retry can recover. + */ +function createMemo(fetcher: () => Promise) { + let cached: { value: T } | undefined; + let inFlight: Promise | undefined; + let generation = 0; + + return { + clear: () => { + generation += 1; + cached = undefined; + inFlight = undefined; + }, + get: (): Promise => { + if (cached) { + return Promise.resolve(cached.value); + } + if (!inFlight) { + const startedGeneration = generation; + inFlight = (async () => { + try { + const value = await fetcher(); + if (generation === startedGeneration) { + cached = { value }; + } + return value; + } finally { + if (generation === startedGeneration) { + inFlight = undefined; + } + } + })(); + } + return inFlight; + }, + }; +} + +const readEncryptionPrivateKey = (): Promise => + getPrivateKeyBytes({ + private_key_derivation_path: encryptionKeyDerivationPath, + }).then((response) => hexToBytes(response.private_key)); + +const readEncryptionPublicKey = (): Promise => + getPublicKey('schnorr', { + private_key_derivation_path: encryptionKeyDerivationPath, + }).then((response) => response.public_key); + +export function createSessionKeys(deps: SessionKeysDeps = {}): SessionKeys { + const encryptionPrivateKey = createMemo( + deps.readEncryptionPrivateKey ?? readEncryptionPrivateKey, + ); + const encryptionPublicKey = createMemo( + deps.readEncryptionPublicKey ?? readEncryptionPublicKey, + ); + const cashuSeed = createMemo(deps.readCashuSeed ?? getCashuSeed); + const sparkMnemonic = createMemo(deps.readSparkMnemonic ?? getSparkMnemonic); + const cashuLockingXpub = createMemo(async () => + deriveCashuXpub(await cashuSeed.get(), BASE_CASHU_LOCKING_DERIVATION_PATH), + ); + const sparkIdentityPublicKey = createMemo(async () => + // FLAG(step-6 plan): master hardcodes MAINNET here (_protected.tsx TODO + // "how to handle this network? We specify the network on the account + // creation."); ported as-is rather than reading config.spark.network. + getSparkIdentityPublicKeyFromMnemonic(await sparkMnemonic.get(), 'mainnet'), + ); + + return { + getEncryption: async () => + getEncryption( + await encryptionPrivateKey.get(), + await encryptionPublicKey.get(), + ), + getEncryptionPublicKey: encryptionPublicKey.get, + getCashuSeed: cashuSeed.get, + getSparkMnemonic: sparkMnemonic.get, + getCashuLockingXpub: cashuLockingXpub.get, + getSparkIdentityPublicKey: sparkIdentityPublicKey.get, + reset: () => { + encryptionPrivateKey.clear(); + encryptionPublicKey.clear(); + cashuSeed.clear(); + sparkMnemonic.clear(); + cashuLockingXpub.clear(); + sparkIdentityPublicKey.clear(); + }, + }; +} From 3105484799fa381f88ab1f11dcab50bb81d4ccbf Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 09:03:50 -0700 Subject: [PATCH 43/49] feat(wallet-sdk): accounts namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement createAccountsApi: get(id), list() (session-gated for the userId), and cashu.add(params) over an internal AccountRepository built from the db, session keys, and spark config. The namespace returns the domain account types directly per the accounts contract (#1166) — no projection mapping. cashu.add re-injects type:'cashu' and the session userId before the service call, and list()/add gate on the session while get() relies on RLS (repository posture). getRepository is exposed alongside the api so the /temporary bridge and the user namespace's ensure() build the repository through one path. Pin AddCashuAccountParams to { name, mintUrl, currency, purpose } and wire the accounts field into AgicashSdk, replacing the not-implemented getter. Co-Authored-By: Claude Fable 5 --- .../domain/accounts/accounts-api.test.ts | 208 ++++++++++++++++++ .../domain/accounts/accounts-api.ts | 73 ++++++ packages/wallet-sdk/domain/sdk/accounts.ts | 15 +- packages/wallet-sdk/domain/sdk/sdk.ts | 26 ++- 4 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 packages/wallet-sdk/domain/accounts/accounts-api.test.ts create mode 100644 packages/wallet-sdk/domain/accounts/accounts-api.ts 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..f709a1779 --- /dev/null +++ b/packages/wallet-sdk/domain/accounts/accounts-api.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'bun:test'; +import { Money } from '@agicash/money'; +import type { AgicashDb } from '../../db/database'; +import { NoSessionError } from '../../lib/error'; +import type { SparkWalletConfig } from '../../lib/spark/wallet'; +import { createSessionKeys } from '../../session-keys'; +import type { AddCashuAccountParams, AuthSession, AuthUser } from '../sdk'; +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); + }); + }); + + 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([150, 42]); + }); + + it('throws NoSessionError without a session', async () => { + const { api } = makeApi({ session: { isLoggedIn: false } }); + await expect(api.list()).rejects.toBeInstanceOf(NoSessionError); + }); + }); + + 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..8711416aa --- /dev/null +++ b/packages/wallet-sdk/domain/accounts/accounts-api.ts @@ -0,0 +1,73 @@ +import type { AgicashDb } from '../../db/database'; +import { NoSessionError } from '../../lib/error'; +import type { SparkWalletConfig } from '../../lib/spark/wallet'; +import type { SessionKeys } from '../../session-keys'; +import type { AccountsApi, AuthSession, CashuAccount } from '../sdk'; +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 repository = await getRepository(); + return repository.get(id); + }, + list: async () => { + const userId = requireUserId(); + const repository = await getRepository(); + return repository.getAllActive(userId); + }, + cashu: { + add: async (params): Promise => { + const userId = requireUserId(); + const repository = await getRepository(); + const service = new AccountService(repository); + return service.addCashuAccount({ + userId, + account: { ...params, type: 'cashu' }, + }); + }, + }, + }, + }; +} 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/sdk.ts b/packages/wallet-sdk/domain/sdk/sdk.ts index c1e513624..c50648950 100644 --- a/packages/wallet-sdk/domain/sdk/sdk.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.ts @@ -19,8 +19,12 @@ import { createSupabaseSessionTokenGetter } from '../../db/supabase-session'; import { clearAgicashMintAuthToken } from '../../lib/agicash-mint-auth-provider'; import { NotImplementedError } from '../../lib/error'; import { generateRandomPassword } from '../../lib/password'; -import { clearSparkWallets } from '../../lib/spark/wallet'; +import { + type SparkWalletConfig, + clearSparkWallets, +} from '../../lib/spark/wallet'; import { createSessionKeys } from '../../session-keys'; +import { createAccountsApi } from '../accounts/accounts-api'; import { AuthService } from '../user/auth-service'; import { createUserApi } from '../user/user-api'; import { WalletEventEmitter } from './events'; @@ -32,17 +36,15 @@ let liveInstance: 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'); } @@ -116,11 +118,23 @@ export class AgicashSdk implements Sdk { accessToken: sessionToken.getToken, }); + const sparkConfig: SparkWalletConfig = { + storageDir: config.spark.storageDir ?? './.spark-data', + apiKey: config.spark.breezApiKey, + }; + const accounts = createAccountsApi({ + db, + getSession: () => this.authService.getSession(), + keys, + sparkConfig, + }); + this.auth = this.authService; this.user = createUserApi({ db, getSession: () => this.authService.getSession(), }); + this.accounts = accounts.api; this.events = events; } From f2b5c5e22acf26ca5f70fb394178338226e760af Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 09:07:37 -0700 Subject: [PATCH 44/49] feat(wallet-sdk): /temporary bridge for the accounts repository Expose getInternalAccountRepository through '@agicash/wallet-sdk/temporary': a module-scoped accessor that hands unmigrated receive/send flows and realtime row mapping the live instance's internal domain accounts repository, built through the same path as sdk.accounts.*. It throws 'No live AgicashSdk instance' before create and after dispose (the module reference is cleared on dispose alongside liveInstance), so the domain repository never leaks onto the public AgicashSdk surface. Removed at step 18 when those flows read from the SDK. Co-Authored-By: Claude Fable 5 --- packages/wallet-sdk/domain/sdk/sdk.test.ts | 30 +++++++++++++++++++++- packages/wallet-sdk/domain/sdk/sdk.ts | 24 +++++++++++++++++ packages/wallet-sdk/temporary.ts | 4 +++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/wallet-sdk/domain/sdk/sdk.test.ts b/packages/wallet-sdk/domain/sdk/sdk.test.ts index e701f1334..b111a9ce5 100644 --- a/packages/wallet-sdk/domain/sdk/sdk.test.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test'; import type { AuthKeyValueStore, SdkConfig } from '.'; import { nullLogger } from '../../lib/logger'; -import { AgicashSdk } from './sdk'; +import { AgicashSdk, getInternalAccountRepository } from './sdk'; const createMemoryStore = (): AuthKeyValueStore => { const data = new Map(); @@ -43,3 +43,31 @@ describe('AgicashSdk.create', () => { await next.dispose(); }); }); + +describe('getInternalAccountRepository', () => { + it('throws when no instance is live', () => { + expect(() => getInternalAccountRepository()).toThrow( + 'No live AgicashSdk instance', + ); + }); + + it('resolves through the live instance and clears on dispose', async () => { + const sdk = AgicashSdk.create(createConfig()); + + // While live the guard passes and the accessor returns the repository + // promise. Its key derivation is exercised in the accounts-api tests; here + // we only assert the bridge is wired, so the derivation is left to settle. + const pending = getInternalAccountRepository(); + expect(pending).toBeInstanceOf(Promise); + pending.catch(() => { + // The derivation reaches Open Secret, which this env cannot; the bridge + // wiring is all we assert here, so let the rejection settle unobserved. + }); + + await sdk.dispose(); + + expect(() => getInternalAccountRepository()).toThrow( + 'No live AgicashSdk instance', + ); + }); +}); diff --git a/packages/wallet-sdk/domain/sdk/sdk.ts b/packages/wallet-sdk/domain/sdk/sdk.ts index c50648950..a125f55d0 100644 --- a/packages/wallet-sdk/domain/sdk/sdk.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.ts @@ -24,6 +24,7 @@ import { clearSparkWallets, } from '../../lib/spark/wallet'; import { createSessionKeys } from '../../session-keys'; +import type { AccountRepository } from '../accounts/account-repository'; import { createAccountsApi } from '../accounts/accounts-api'; import { AuthService } from '../user/auth-service'; import { createUserApi } from '../user/user-api'; @@ -34,6 +35,12 @@ import { WalletEventEmitter } from './events'; // the module-global Open Secret configuration. let liveInstance: AgicashSdk | undefined; +// The live instance's internal accounts repository builder, reached only +// through the '@agicash/wallet-sdk/temporary' bridge (removed at step 18). +// Module-scoped like liveInstance so the bridge never exposes the domain +// repository on the public AgicashSdk surface. +let liveAccountRepository: (() => Promise) | undefined; + /** * Runtime implementation of the SDK contract. Namespaces land slice by slice — * auth, user, accounts, and events so far; accessing a namespace whose migration @@ -136,6 +143,7 @@ export class AgicashSdk implements Sdk { }); this.accounts = accounts.api; this.events = events; + liveAccountRepository = accounts.getRepository; } /** Sync; no I/O. Throws when an undisposed instance already exists (see the constructor note). */ @@ -163,6 +171,22 @@ export class AgicashSdk implements Sdk { this.authService.teardown(); if (liveInstance === this) { liveInstance = undefined; + liveAccountRepository = undefined; } } } + +/** + * The live instance's internal domain accounts repository, for unmigrated web + * flows (receive/send repo construction, realtime row mapping) that still read + * wallet/proofs. Re-exported from '@agicash/wallet-sdk/temporary'; not on the + * public surface. + * + * @remarks Removed at step 18 when those flows read wallet/proofs from the SDK. + */ +export function getInternalAccountRepository(): Promise { + if (!liveAccountRepository) { + throw new Error('No live AgicashSdk instance'); + } + return liveAccountRepository(); +} diff --git a/packages/wallet-sdk/temporary.ts b/packages/wallet-sdk/temporary.ts index f4f8c9635..6b687d752 100644 --- a/packages/wallet-sdk/temporary.ts +++ b/packages/wallet-sdk/temporary.ts @@ -105,6 +105,10 @@ export { export { CashuProofSchema, toProof } from './domain/accounts/cashu-account'; export { AccountRepository } from './domain/accounts/account-repository'; export { AccountService } from './domain/accounts/account-service'; +// Accounts-slice bridge (step 6): hands unmigrated receive/send flows and +// realtime row mapping the live instance's internal domain accounts repository. +// Removed at step 18 when those flows read wallet/proofs from the SDK. +export { getInternalAccountRepository } from './domain/sdk/sdk'; export { ReadUserDefaultAccountRepository, ReadUserRepository, From b9d02800477e96e8f60d8a1cb4ee71c59a14c7b2 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 09:11:56 -0700 Subject: [PATCH 45/49] refactor(utils): move withRetry and delay into @agicash/utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK's user.ensure() needs the retry helper the web owned, so lift withRetry and delay from apps/web-wallet/app/lib into @agicash/utils (named exports, barrel re-exported) and delete the web copies. Flip the remaining consumers — the cashu receive-quote hook, the protected-route bootstrap, and the e2e Open Secret fixture — onto @agicash/utils, and add the workspace dependency to the e2e package. Co-Authored-By: Claude Fable 5 --- apps/web-wallet-e2e/e2e/fixtures/open-secret/fixture.ts | 2 +- apps/web-wallet-e2e/package.json | 1 + .../app/features/receive/cashu-receive-quote-hooks.ts | 2 +- apps/web-wallet/app/routes/_protected.tsx | 2 +- bun.lock | 1 + {apps/web-wallet/app/lib => packages/utils/src}/delay.ts | 2 +- packages/utils/src/index.ts | 2 ++ {apps/web-wallet/app/lib => packages/utils/src}/with-retry.ts | 2 +- 8 files changed, 9 insertions(+), 5 deletions(-) rename {apps/web-wallet/app/lib => packages/utils/src}/delay.ts (95%) rename {apps/web-wallet/app/lib => packages/utils/src}/with-retry.ts (98%) 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/features/receive/cashu-receive-quote-hooks.ts b/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts index ad7f9276d..f270f1271 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, @@ -40,7 +41,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useOnMeltQuoteStateChange } from '~/lib/cashu/melt-quote-subscription'; import { MintQuoteSubscriptionManager } from '~/lib/cashu/mint-quote-subscription-manager'; import { useLatest } from '~/lib/use-latest'; -import { withRetry } from '~/lib/with-retry'; import { useGetCashuAccount, useGetCashuAccountByMintUrlAndCurrency, diff --git a/apps/web-wallet/app/routes/_protected.tsx b/apps/web-wallet/app/routes/_protected.tsx index 5fb4e3cb7..75b11cdd9 100644 --- a/apps/web-wallet/app/routes/_protected.tsx +++ b/apps/web-wallet/app/routes/_protected.tsx @@ -1,3 +1,4 @@ +import { withRetry } from '@agicash/utils'; import type { User } from '@agicash/wallet-sdk'; import { shouldAcceptTerms } from '@agicash/wallet-sdk'; import type { AuthUser } from '@agicash/wallet-sdk'; @@ -41,7 +42,6 @@ import { } 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) => { 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/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. From 0bcb94124a0acc393229a78bf829e32ab584f587 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 09:17:23 -0700 Subject: [PATCH 46/49] feat(wallet-sdk): sdk.user.ensure() bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add user.ensure(params) to the contract and implement it on the user namespace: it derives the encryption public key, cashu locking xpub, and spark identity public key, upserts the user row (creating the default accounts and persisting the keys on first sign-in) through the base UpsertUserRepository, and returns { user, accounts } domain-typed for the host to seed its caches. The upsert returns domain accounts already, so ensure returns them as-is — no mapping. The key-derivation batch and the upsert each run under withRetry (matching the resilience the host's query layer gave master); the upsert retry is Zod-aware so a validation error fails fast. EnsureUserParams carries the replayed pending-terms acceptance timestamps, distinct from acceptTerms' in-session boolean stamps. ensure builds its repository through the accounts namespace's getRepository, so the whole instance shares one construction path. Co-Authored-By: Claude Fable 5 --- packages/wallet-sdk/domain/sdk/sdk.ts | 2 + packages/wallet-sdk/domain/sdk/user.ts | 26 ++ .../wallet-sdk/domain/user/user-api.test.ts | 261 +++++++++++++++++- packages/wallet-sdk/domain/user/user-api.ts | 102 ++++++- 4 files changed, 386 insertions(+), 5 deletions(-) diff --git a/packages/wallet-sdk/domain/sdk/sdk.ts b/packages/wallet-sdk/domain/sdk/sdk.ts index a125f55d0..820b32d61 100644 --- a/packages/wallet-sdk/domain/sdk/sdk.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.ts @@ -140,6 +140,8 @@ export class AgicashSdk implements Sdk { this.user = createUserApi({ db, getSession: () => this.authService.getSession(), + keys, + getAccountRepository: accounts.getRepository, }); this.accounts = accounts.api; this.events = events; diff --git a/packages/wallet-sdk/domain/sdk/user.ts b/packages/wallet-sdk/domain/sdk/user.ts index 343715399..d4449bbc5 100644 --- a/packages/wallet-sdk/domain/sdk/user.ts +++ b/packages/wallet-sdk/domain/sdk/user.ts @@ -8,6 +8,32 @@ export type UserApi = { acceptTerms(params: AcceptTermsParams): Promise; setDefaultAccount(params: SetDefaultAccountParams): Promise; setDefaultCurrency(params: SetDefaultCurrencyParams): Promise; + /** + * Bootstraps the signed-in user: upserts the user row (creating default + * accounts and persisting the derived public keys on first sign-in) and + * returns the user with their accounts for the host to seed its caches. + * Idempotent — performs the upsert on every call with the given params; the + * host owns caching and call-frequency (the web gates it behind its own + * user-cache short-circuit). + */ + ensure( + params: EnsureUserParams, + ): Promise<{ user: User; accounts: Account[] }>; +}; + +export type EnsureUserParams = { + /** + * ISO 8601 timestamp replayed from the host's pending-terms storage — the + * time the user accepted wallet terms before the user row existed, not + * "now". `acceptTerms` is the in-session boolean verb that stamps the time + * itself. + */ + termsAcceptedAt?: string; + /** + * ISO 8601 timestamp replayed from the host's pending-terms storage — the + * time the user accepted gift-card-mint terms before the user row existed. + */ + giftCardMintTermsAcceptedAt?: string; }; export type AcceptTermsParams = { diff --git a/packages/wallet-sdk/domain/user/user-api.test.ts b/packages/wallet-sdk/domain/user/user-api.test.ts index 2b28a6289..1e1fd3f64 100644 --- a/packages/wallet-sdk/domain/user/user-api.test.ts +++ b/packages/wallet-sdk/domain/user/user-api.test.ts @@ -1,7 +1,15 @@ 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 type { SessionKeys } from '../../session-keys'; +import { + type Account, + type CashuAccount as DomainCashuAccount, + getAccountBalance, +} from '../accounts/account'; +import type { AccountRepository } from '../accounts/account-repository'; import type { AuthUser } from '../sdk'; import { createUserApi } from './user-api'; @@ -36,12 +44,25 @@ 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 = (): SessionKeys => ({ + getEncryption: async () => ({}) as Encryption, + getEncryptionPublicKey: async () => 'enc-pub', + getCashuSeed: async () => new Uint8Array(64), + getSparkMnemonic: async () => 'mnemonic', + getCashuLockingXpub: async () => 'xpub', + getSparkIdentityPublicKey: async () => 'spark-id', + 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 +93,40 @@ const createDbFake = ( return { from } as unknown as AgicashDb; }; +/** Fake covering the single `upsert_user_with_accounts` RPC ensure 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); + if (outcome && 'reject' in outcome) { + return Promise.reject(outcome.reject); + } + return 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 +138,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 +148,200 @@ describe('createUserApi', () => { expect(updatedData.default_btc_account_id).toBe('acct-1'); }); }); + + describe('ensure', () => { + it('upserts with the derived keys and terms, 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.ensure({ + termsAcceptedAt: '2026-01-02T00:00:00Z', + }); + + 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(calls[0]?.p_terms_accepted_at).toBe('2026-01-02T00:00:00Z'); + 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.ensure({}); + + 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 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.ensure({}); + const second = await api.ensure({}); + + expect(calls).toHaveLength(2); + expect(first.user.username).toBe('name'); + expect(second.user.username).toBe('renamed'); + }); + + it('carries the terms params into the upsert on every call', async () => { + const { db, calls } = createUpsertDbFake([{ ok: true }]); + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, + }); + + await api.ensure({ termsAcceptedAt: 't1' }); + await api.ensure({ + termsAcceptedAt: 't2', + giftCardMintTermsAcceptedAt: 'g2', + }); + + expect(calls).toHaveLength(2); + expect(calls[0]?.p_terms_accepted_at).toBe('t1'); + expect(calls[0]?.p_gift_card_mint_terms_accepted_at).toBeUndefined(); + expect(calls[1]?.p_terms_accepted_at).toBe('t2'); + expect(calls[1]?.p_gift_card_mint_terms_accepted_at).toBe('g2'); + }); + + 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.ensure({}); + + 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.ensure({}); + + 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.ensure({})).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.ensure({})).rejects.toThrow(); + }); + }); }); diff --git a/packages/wallet-sdk/domain/user/user-api.ts b/packages/wallet-sdk/domain/user/user-api.ts index f7889f30b..ff80f5a95 100644 --- a/packages/wallet-sdk/domain/user/user-api.ts +++ b/packages/wallet-sdk/domain/user/user-api.ts @@ -1,14 +1,63 @@ +import { withRetry } from '@agicash/utils'; +import { core } from 'zod/mini'; import type { AgicashDb } from '../../db/database'; import { NoSessionError } from '../../lib/error'; +import type { SessionKeys } from '../../session-keys'; +import type { AccountRepository } from '../accounts/account-repository'; import type { AuthSession, UserApi } from '../sdk'; -import { ReadUserRepository, UpdateUserRepository } from './user-repository'; +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; + export function createUserApi(deps: Deps): UserApi { const readRepository = new ReadUserRepository(deps.db); const updateRepository = new UpdateUserRepository(deps.db); @@ -43,5 +92,56 @@ export function createUserApi(deps: Deps): UserApi { userService.setDefaultAccount(requireUserId(), params.account, { setDefaultCurrency: params.setDefaultCurrency, }), + ensure: async (params) => { + const session = deps.getSession(); + if (!session.isLoggedIn) { + throw new NoSessionError(); + } + const authUser = session.user; + + // Master derived these through the host's query layer, which retried + // transient failures; the memoized getters make each retry re-fetch only + // the keys that failed. + const [ + encryptionPublicKey, + cashuLockingXpub, + sparkIdentityPublicKey, + accountRepository, + ] = await withRetry({ + fn: () => + Promise.all([ + deps.keys.getEncryptionPublicKey(), + deps.keys.getCashuLockingXpub(), + deps.keys.getSparkIdentityPublicKey(), + deps.getAccountRepository(), + ]), + }); + + const upsertRepository = new UpsertUserRepository( + deps.db, + accountRepository, + ); + + return withRetry({ + fn: () => + upsertRepository.upsert({ + id: authUser.id, + email: authUser.email, + emailVerified: authUser.email_verified, + accounts: [...defaultAccounts], + cashuLockingXpub, + encryptionPublicKey, + sparkIdentityPublicKey, + termsAcceptedAt: params.termsAcceptedAt, + giftCardMintTermsAcceptedAt: params.giftCardMintTermsAcceptedAt, + }), + retry: (attemptIndex, error) => { + if (error instanceof core.$ZodError) { + return false; + } + return attemptIndex < 2; + }, + }); + }, }; } From 3b52a5043eb06e4bf4fd835c6588e604fbeadb91 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 09:29:25 -0700 Subject: [PATCH 47/49] feat(web-wallet): source the accounts feature from sdk.accounts.* Flip the accounts data path onto the SDK namespace: accountsQueryOptions' queryFn calls sdk.accounts.list(), useAccountOrNull's lazy fetch calls sdk.accounts.get(id), useAddCashuAccount calls sdk.accounts.cashu.add (callers drop the now-implicit type: 'cashu'), and the realtime ACCOUNT_CREATED/UPDATED handlers map rows through getInternalAccountRepository().toAccount. The cache still holds domain accounts, so every consumer (getAccountBalance, wallet/proofs readers) is unchanged. account-service-hooks is deleted (the service lives behind sdk.accounts.cashu.add) and account-repository-hooks moves to features/receive, its only remaining consumers being the unmigrated cashu receive-quote and receive-swap repos. Extract getExtendedAccounts and isDefaultAccount from UserService into standalone pure functions exported from the package root, and flip their consumers (the accounts hooks, the claim route, and the SDK-internal claim service). Co-Authored-By: Claude Fable 5 --- .../app/features/accounts/account-hooks.ts | 53 +++++++-------- .../accounts/account-service-hooks.ts | 7 -- .../app/features/gift-cards/add-gift-card.tsx | 1 - .../account-repository-hooks.ts | 0 .../receive/cashu-receive-quote-hooks.ts | 2 +- .../receive/cashu-receive-swap-hooks.ts | 2 +- .../settings/accounts/add-mint-form.tsx | 1 - .../_protected.receive.cashu_.token.tsx | 12 ++-- .../receive/claim-cashu-token-service.ts | 4 +- .../wallet-sdk/domain/user/user-service.ts | 64 ++++++++++--------- packages/wallet-sdk/index.ts | 4 ++ 11 files changed, 70 insertions(+), 80 deletions(-) delete mode 100644 apps/web-wallet/app/features/accounts/account-service-hooks.ts rename apps/web-wallet/app/features/{accounts => receive}/account-repository-hooks.ts (100%) diff --git a/apps/web-wallet/app/features/accounts/account-hooks.ts b/apps/web-wallet/app/features/accounts/account-hooks.ts index 09c45c275..5d97692f3 100644 --- a/apps/web-wallet/app/features/accounts/account-hooks.ts +++ b/apps/web-wallet/app/features/accounts/account-hooks.ts @@ -1,20 +1,19 @@ 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, + getInternalAccountRepository, sparkDebugLog, } from '@agicash/wallet-sdk/temporary'; import { @@ -26,9 +25,8 @@ 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,13 +115,13 @@ export function useAccountsCache() { * Hook that returns an account change handlers. */ export function useAccountChangeHandlers() { - const accountRepository = useAccountRepository(); const accountCache = useAccountsCache(); return [ { event: 'ACCOUNT_CREATED', handleEvent: async (payload: AgicashDbAccountWithProofs) => { + const accountRepository = await getInternalAccountRepository(); const addedAccount = await accountRepository.toAccount(payload); accountCache.upsert(addedAccount); }, @@ -131,6 +129,7 @@ export function useAccountChangeHandlers() { { event: 'ACCOUNT_UPDATED', handleEvent: async (payload: AgicashDbAccountWithProofs) => { + const accountRepository = await getInternalAccountRepository(); const updatedAccount = await accountRepository.toAccount(payload); accountCache.upsert(updatedAccount); }, @@ -138,13 +137,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 +249,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 +325,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 +462,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/accounts/account-repository-hooks.ts b/apps/web-wallet/app/features/receive/account-repository-hooks.ts similarity index 100% rename from apps/web-wallet/app/features/accounts/account-repository-hooks.ts rename to apps/web-wallet/app/features/receive/account-repository-hooks.ts 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 f270f1271..1a8f086ed 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 @@ -46,12 +46,12 @@ import { 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'; import { useTransactionsCache } from '../transactions/transaction-hooks'; import { useUser } from '../user/user-hooks'; +import { useAccountRepository } from './account-repository-hooks'; type CreateProps = { account: CashuAccount; 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..faf490de9 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 @@ -17,10 +17,10 @@ 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'; +import { useAccountRepository } from './account-repository-hooks'; type CreateProps = { token: Token; 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/routes/_protected.receive.cashu_.token.tsx b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx index 68d8af895..8ce4f411a 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,7 +13,6 @@ import { ReceiveCashuTokenService, SparkReceiveQuoteRepository, SparkReceiveQuoteService, - UserService, decodeCashuToken, getEncryption, } from '@agicash/wallet-sdk/temporary'; @@ -99,7 +99,7 @@ const getServices = async () => { (ticker) => getExchangeRate(queryClient, ticker), ); - return { claimCashuTokenService, accountRepository }; + return { claimCashuTokenService }; }; /** @@ -115,7 +115,7 @@ async function trySetReceiveAccountAsDefault( ): Promise { if ( account.currency === user.defaultCurrency && - UserService.isDefaultAccount(user, account) + isDefaultAccount(user, account) ) { return; } @@ -167,11 +167,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/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts b/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts index 63386b274..cf1fdcb9d 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 { Ticker } from '../../lib/exchange-rate'; import type { Account, CashuAccount, SparkAccount } from '../accounts/account'; import type { AccountService } from '../accounts/account-service'; 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/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 712080076..82272e9aa 100644 --- a/packages/wallet-sdk/index.ts +++ b/packages/wallet-sdk/index.ts @@ -46,6 +46,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, From aa3ec029de77a35c7f402df99ac1d004c44482d4 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 09:35:53 -0700 Subject: [PATCH 48/49] feat(web-wallet): bootstrap and key queries from the SDK session keys Route _protected's bootstrap through sdk.user.ensure(): it replaces the inline key-derivation + upsert, seeds the user and accounts caches from the public return, and keeps master's structure (cache short-circuit, session-token warm, ensureBreezWasm placement, conditional seed on the upsert branch). Source the web-side key queries from the live instance's session keys via a new getInternalSessionKeys /temporary accessor: useEncryption collapses to a single ['encryption'] query over getEncryption(), and the cashu-seed and spark-mnemonic query fns delegate to the same getters. The per-key encryption query options and their hooks are deleted (only encryption-hooks and the two route warms read them), sparkIdentityPublicKeyQueryOptions is dropped (its only reader was the ensure warm), and xpubQueryOptions is untouched. The bootstrap warms encryption, seed, and mnemonic concurrently with ensure() so the unmigrated receive/send/claim repos keep master's warm-cache and fail-in-the-middleware behavior. The web defaultAccounts copy is removed now that ensure() owns it SDK-side. Co-Authored-By: Claude Fable 5 --- .../features/shared/cashu-query-options.ts | 4 +- .../app/features/shared/encryption-hooks.ts | 46 +------- .../features/shared/spark-query-options.ts | 23 +--- .../app/features/user/user-hooks.tsx | 38 ------- .../_protected.receive.cashu_.token.tsx | 14 +-- apps/web-wallet/app/routes/_protected.tsx | 106 ++++-------------- packages/wallet-sdk/domain/sdk/sdk.test.ts | 30 ++++- packages/wallet-sdk/domain/sdk/sdk.ts | 25 ++++- packages/wallet-sdk/temporary.ts | 13 ++- 9 files changed, 97 insertions(+), 202 deletions(-) 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..c2ac9c7a8 100644 --- a/apps/web-wallet/app/features/shared/cashu-query-options.ts +++ b/apps/web-wallet/app/features/shared/cashu-query-options.ts @@ -3,7 +3,7 @@ import { deriveCashuXpub, getAllMintKeysets, getCashuPrivateKey, - getCashuSeed, + getInternalSessionKeys, getMintInfo, } from '@agicash/wallet-sdk/temporary'; import { type QueryClient, queryOptions } from '@tanstack/react-query'; @@ -11,7 +11,7 @@ import { type QueryClient, queryOptions } from '@tanstack/react-query'; export const seedQueryOptions = () => queryOptions({ queryKey: ['cashu-seed'], - queryFn: () => getCashuSeed(), + queryFn: () => getInternalSessionKeys().getCashuSeed(), 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..53dc01e64 100644 --- a/apps/web-wallet/app/features/shared/encryption-hooks.ts +++ b/apps/web-wallet/app/features/shared/encryption-hooks.ts @@ -1,43 +1,14 @@ -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 { getInternalSessionKeys } from '@agicash/wallet-sdk/temporary'; import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; -// 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: ['encryption'], + queryFn: () => getInternalSessionKeys().getEncryption(), 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 +19,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/spark-query-options.ts b/apps/web-wallet/app/features/shared/spark-query-options.ts index 92b53f7dd..df1d0aa89 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,9 @@ -import type { SparkNetwork } from '@agicash/wallet-sdk'; -import { - getSparkIdentityPublicKeyFromMnemonic, - getSparkMnemonic, -} from '@agicash/wallet-sdk/temporary'; -import { type QueryClient, queryOptions } from '@tanstack/react-query'; +import { getInternalSessionKeys } from '@agicash/wallet-sdk/temporary'; +import { queryOptions } from '@tanstack/react-query'; export const sparkMnemonicQueryOptions = () => queryOptions({ queryKey: ['spark-mnemonic'], - queryFn: () => getSparkMnemonic(), + queryFn: () => getInternalSessionKeys().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/user-hooks.tsx b/apps/web-wallet/app/features/user/user-hooks.tsx index 693d97601..ae4ee0439 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); 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 8ce4f411a..60db5fc59 100644 --- a/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx +++ b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx @@ -14,7 +14,6 @@ import { SparkReceiveQuoteRepository, SparkReceiveQuoteService, 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, diff --git a/apps/web-wallet/app/routes/_protected.tsx b/apps/web-wallet/app/routes/_protected.tsx index 75b11cdd9..a8f05552c 100644 --- a/apps/web-wallet/app/routes/_protected.tsx +++ b/apps/web-wallet/app/routes/_protected.tsx @@ -1,47 +1,24 @@ -import { withRetry } from '@agicash/utils'; -import type { User } from '@agicash/wallet-sdk'; +import type { AuthUser, 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 { ensureBreezWasm } from '@agicash/wallet-sdk/temporary'; import type { QueryClient } from '@tanstack/react-query'; 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 type { Route } from './+types/_protected'; const shouldUserVerifyEmail = (user: AuthUser) => { @@ -85,64 +62,21 @@ const ensureUserData = async ( } 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' }), - ), + // ensure() derives the session keys SDK-side; these warms populate the + // web-side cache entries the unmigrated receive/send/claim repos still read + // from that same single derivation (the query fns delegate to the SDK). + // Keeping them here preserves master's fail-in-the-middleware property + // instead of deferring derivation failure to first Wallet render. Removed as + // those domains migrate into the SDK (steps 8–16). + const [{ user: upsertedUser, accounts }] = await Promise.all([ + sdk.user.ensure({ + termsAcceptedAt, + giftCardMintTermsAcceptedAt, + }), + queryClient.ensureQueryData(encryptionQueryOptions()), queryClient.ensureQueryData(sparkMnemonicQueryOptions()), - queryClient.ensureQueryData(cashuSeedQueryOptions()), + queryClient.ensureQueryData(seedQueryOptions()), ]); - 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); diff --git a/packages/wallet-sdk/domain/sdk/sdk.test.ts b/packages/wallet-sdk/domain/sdk/sdk.test.ts index b111a9ce5..e8a04ecbc 100644 --- a/packages/wallet-sdk/domain/sdk/sdk.test.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'bun:test'; import type { AuthKeyValueStore, SdkConfig } from '.'; import { nullLogger } from '../../lib/logger'; -import { AgicashSdk, getInternalAccountRepository } from './sdk'; +import { + AgicashSdk, + getInternalAccountRepository, + getInternalSessionKeys, +} from './sdk'; const createMemoryStore = (): AuthKeyValueStore => { const data = new Map(); @@ -71,3 +75,27 @@ describe('getInternalAccountRepository', () => { ); }); }); + +describe('getInternalSessionKeys', () => { + it('throws when no instance is live', () => { + expect(() => getInternalSessionKeys()).toThrow( + 'No live AgicashSdk instance', + ); + }); + + it('returns the live instance session keys and clears them on dispose', async () => { + const sdk = AgicashSdk.create(createConfig()); + + const keys = getInternalSessionKeys(); + // Same reference every call: the accessor hands back the instance's single + // session-keys object, not a fresh derivation per call. + expect(getInternalSessionKeys()).toBe(keys); + expect(typeof keys.getEncryption).toBe('function'); + + await sdk.dispose(); + + expect(() => getInternalSessionKeys()).toThrow( + 'No live AgicashSdk instance', + ); + }); +}); diff --git a/packages/wallet-sdk/domain/sdk/sdk.ts b/packages/wallet-sdk/domain/sdk/sdk.ts index 820b32d61..9dea275ff 100644 --- a/packages/wallet-sdk/domain/sdk/sdk.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.ts @@ -23,7 +23,7 @@ import { type SparkWalletConfig, clearSparkWallets, } from '../../lib/spark/wallet'; -import { createSessionKeys } from '../../session-keys'; +import { type SessionKeys, createSessionKeys } from '../../session-keys'; import type { AccountRepository } from '../accounts/account-repository'; import { createAccountsApi } from '../accounts/accounts-api'; import { AuthService } from '../user/auth-service'; @@ -41,6 +41,12 @@ let liveInstance: AgicashSdk | undefined; // repository on the public AgicashSdk surface. let liveAccountRepository: (() => Promise) | undefined; +// The live instance's session keys, reached only through the +// '@agicash/wallet-sdk/temporary' bridge (removed at step 18). Module-scoped +// like liveInstance so the bridge never exposes the key derivations on the +// public AgicashSdk surface. +let liveSessionKeys: SessionKeys | undefined; + /** * Runtime implementation of the SDK contract. Namespaces land slice by slice — * auth, user, accounts, and events so far; accessing a namespace whose migration @@ -146,6 +152,7 @@ export class AgicashSdk implements Sdk { this.accounts = accounts.api; this.events = events; liveAccountRepository = accounts.getRepository; + liveSessionKeys = keys; } /** Sync; no I/O. Throws when an undisposed instance already exists (see the constructor note). */ @@ -174,6 +181,7 @@ export class AgicashSdk implements Sdk { if (liveInstance === this) { liveInstance = undefined; liveAccountRepository = undefined; + liveSessionKeys = undefined; } } } @@ -192,3 +200,18 @@ export function getInternalAccountRepository(): Promise { } return liveAccountRepository(); } + +/** + * The live instance's session keys, for the web-side key queries the unmigrated + * receive/send/claim flows still read (encryption, cashu seed, spark mnemonic). + * Re-exported from '@agicash/wallet-sdk/temporary'; not on the public surface. + * + * @remarks Removed at step 18 when those flows source their keys from the SDK + * and the web-side key queries die with them. + */ +export function getInternalSessionKeys(): SessionKeys { + if (!liveSessionKeys) { + throw new Error('No live AgicashSdk instance'); + } + return liveSessionKeys; +} diff --git a/packages/wallet-sdk/temporary.ts b/packages/wallet-sdk/temporary.ts index 6b687d752..89fd0b278 100644 --- a/packages/wallet-sdk/temporary.ts +++ b/packages/wallet-sdk/temporary.ts @@ -105,10 +105,15 @@ export { export { CashuProofSchema, toProof } from './domain/accounts/cashu-account'; export { AccountRepository } from './domain/accounts/account-repository'; export { AccountService } from './domain/accounts/account-service'; -// Accounts-slice bridge (step 6): hands unmigrated receive/send flows and -// realtime row mapping the live instance's internal domain accounts repository. -// Removed at step 18 when those flows read wallet/proofs from the SDK. -export { getInternalAccountRepository } from './domain/sdk/sdk'; +// Accounts-slice bridge (step 6): the internal-repo accessor hands unmigrated +// receive/send flows and realtime row mapping the live instance's domain +// accounts repository, and the session-keys accessor feeds the web-side key +// queries the unmigrated flows still read. Removed at step 18 when those flows +// read from the SDK. +export { + getInternalAccountRepository, + getInternalSessionKeys, +} from './domain/sdk/sdk'; export { ReadUserDefaultAccountRepository, ReadUserRepository, From d9a1fa265ad7d7a0abeb4e35c619e93ecb014a4f Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 09:37:46 -0700 Subject: [PATCH 49/49] docs(wallet-sdk): accounts slice plan + domain-types supersession Bring the step-6 decision record onto the branch and append a supersession entry: the maintainer's contract-level domain-types ruling (d5d2f18a, #1166) superseded B1's projection apparatus after the slice was first built, so sdk.accounts.* returns domain types directly (no mapper, no checked cast), B5 balance reads revert to getAccountBalance, and the runtime-fat reality-class record and the step-18 physical strip retire as moot. The record's prior entries stand as the decision trail. Co-Authored-By: Claude Fable 5 --- .../2026-07-13-wallet-sdk-accounts-slice.md | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-wallet-sdk-accounts-slice.md 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..a47e01581 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-wallet-sdk-accounts-slice.md @@ -0,0 +1,197 @@ +# 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. + +## 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.