From 5f1f66031abdf5910da6267994fb4dc40cf2b03a Mon Sep 17 00:00:00 2001 From: Petar Milic Date: Thu, 9 Jul 2026 19:49:54 +0200 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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