From 48ea92340e5610c71fa55d447717a8a9d75832cf Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Jul 2026 10:32:33 -0700 Subject: [PATCH] =?UTF-8?q?feat(wallet-sdk):=20auth=20slice=20=E2=80=94=20?= =?UTF-8?q?auth/user/events=20behind=20the=20SDK=20contract=20(#1166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the wallet-sdk extraction. Introduces the AgicashSdk runtime (one instance per process) exposing auth, user, and events behind a typed, per-namespace Sdk contract, and routes the web app's auth and user flows through it. - AuthService: session snapshot + expiry machinery, with the session scoped by an AbortController so an in-flight restore is fenced against disposal, sign-out, and cross-user re-login - SDK-internal Supabase client + session-token getter; port-backed guest-account storage; CSPRNG password generator - typed wallet event emitter (auth.session-expired / auth.session-refreshed) - adopts the React-agnostic @agicash/opensecret 1.0 - package layout: domain/ (entities, services, the sdk contract) vs lib/ (internal helpers/adapters); the /temporary boundary re-exports what the web still imports directly until later slices; the Supabase project nests under db/ Co-authored-by: Petar Milic Co-Authored-By: Claude Opus 4.8 --- .claude/settings.json | 2 +- .claude/skills/supabase-database/SKILL.md | 2 +- .../references/migrations.md | 2 +- .github/workflows/ci.yml | 12 +- README.md | 6 +- apps/web-wallet-e2e/.env.test | 4 +- apps/web-wallet/app/entry.client.tsx | 23 +- .../features/agicash-db/database.client.ts | 29 +- .../receive/cashu-receive-quote-hooks.ts | 10 +- apps/web-wallet/app/features/shared/auth.ts | 15 +- .../app/features/shared/sdk.client.ts | 83 + .../app/features/signup/verify-email.ts | 4 +- apps/web-wallet/app/features/user/auth.ts | 416 ++- .../features/user/guest-account-storage.ts | 44 - .../app/features/user/user-hooks.tsx | 93 +- .../features/user/user-repository-hooks.ts | 15 - .../app/features/user/user-service-hooks.ts | 7 - .../web-wallet/app/features/wallet/wallet.tsx | 7 +- .../web-wallet/app/hooks/use-exchange-rate.ts | 5 +- apps/web-wallet/app/hooks/use-long-timeout.ts | 45 - apps/web-wallet/app/hooks/use-money-input.ts | 2 +- .../app/lib/cashu/melt-quote-subscription.ts | 6 +- .../app/routes/_auth.oauth.$provider.tsx | 4 +- .../_protected.receive.cashu_.token.tsx | 18 +- apps/web-wallet/app/routes/_protected.tsx | 13 +- apps/web-wallet/package.json | 2 +- apps/web-wallet/tsconfig.json | 2 +- biome.jsonc | 2 +- bun.lock | 10 +- .../plans/2026-07-09-wallet-sdk-auth-slice.md | 2740 +++++++++++++++++ ...2026-07-02-wallet-sdk-contract-proposal.md | 8 +- package.json | 7 +- packages/utils/package.json | 1 + packages/utils/src/index.ts | 2 + packages/utils/src/jwt.ts | 15 + .../app/lib => packages/utils/src}/timeout.ts | 19 +- packages/utils/tsconfig.json | 2 +- packages/wallet-sdk/db/client.ts | 21 + .../cashu-lightning-send-db-data.ts | 2 +- .../wallet-sdk/db/supabase-session.test.ts | 141 + packages/wallet-sdk/db/supabase-session.ts | 75 + packages/wallet-sdk/{ => db}/supabase/.env | 0 .../wallet-sdk/{ => db}/supabase/.gitignore | 0 .../wallet-sdk/{ => db}/supabase/config.toml | 0 .../{ => db}/supabase/database.types.ts | 0 .../migrations/20260112150000_initial_db.sql | 0 .../20260202120000_make_terms_nullable.sql | 0 .../20260223193702_feature_flags.sql | 0 .../20260225000000_add_debug_logging_flag.sql | 0 ...5120000_enforce_gift_card_feature_flag.sql | 0 ...0302120000_add_version_to_transactions.sql | 0 ...dd_transaction_purpose_and_transfer_id.sql | 0 ...260310120000_move_transfer_id_to_jsonb.sql | 0 ...0260312163752_transfer_id_text_to_uuid.sql | 0 ...260320120000_add_offer_account_purpose.sql | 0 .../20260325120000_add_account_state.sql | 0 ...260325130000_default_account_no_expiry.sql | 0 .../20260413222901_generic_event_system.sql | 0 ...0260415180000_add_gift_card_mint_terms.sql | 0 ...12_denormalize_account_on_transactions.sql | 0 ...192806_fix_emit_event_pg_net_signature.sql | 0 ...x_fail_cashu_receive_quote_state_guard.sql | 0 ...ten_spark_send_payment_hash_uniqueness.sql | 0 ...5222417_remove_gift_cards_feature_flag.sql | 0 ...0522185431_add_users_broadcast_trigger.sql | 0 .../wallet-sdk/{ => db}/supabase/seed.sql | 0 .../wallet-sdk/{lib => domain}/currencies.ts | 0 .../exchange-rate-service.test.ts | 0 .../exchange-rate/exchange-rate-service.ts | 0 .../{lib => domain}/exchange-rate/index.ts | 0 .../exchange-rate/providers/coinbase.ts | 0 .../exchange-rate/providers/coingecko.ts | 0 .../exchange-rate/providers/mempool-space.ts | 0 .../exchange-rate/providers/types.ts | 0 .../feature-flag-service.test.ts | 2 +- .../feature-flags}/feature-flag-service.ts | 2 +- .../receive/claim-cashu-token-service.ts | 2 +- .../receive/lightning-address-service.ts | 2 +- packages/wallet-sdk/domain/sdk/accounts.ts | 19 + packages/wallet-sdk/domain/sdk/auth.ts | 52 + packages/wallet-sdk/domain/sdk/contacts.ts | 13 + packages/wallet-sdk/domain/sdk/events.test.ts | 73 + packages/wallet-sdk/domain/sdk/events.ts | 121 + .../wallet-sdk/domain/sdk/feature-flags.ts | 8 + packages/wallet-sdk/domain/sdk/index.ts | 105 + packages/wallet-sdk/domain/sdk/receive.ts | 51 + packages/wallet-sdk/domain/sdk/sdk.test.ts | 45 + packages/wallet-sdk/domain/sdk/sdk.ts | 150 + packages/wallet-sdk/domain/sdk/send.ts | 42 + packages/wallet-sdk/domain/sdk/server.ts | 48 + .../wallet-sdk/domain/sdk/task-processor.ts | 21 + .../wallet-sdk/domain/sdk/transactions.ts | 18 + packages/wallet-sdk/domain/sdk/transfer.ts | 10 + packages/wallet-sdk/domain/sdk/user.ts | 26 + .../domain/send/cashu-send-quote-service.ts | 2 +- .../domain/send/cashu-send-quote.ts | 4 +- .../domain/send/cashu-send-swap-service.ts | 2 +- .../{lib => domain/send}/send-destination.ts | 0 ...ashu-lightning-send-transaction-details.ts | 2 +- .../domain/user/auth-service.test.ts | 817 +++++ .../wallet-sdk/domain/user/auth-service.ts | 470 +++ .../wallet-sdk/domain/user/user-api.test.ts | 94 + packages/wallet-sdk/domain/user/user-api.ts | 47 + .../wallet-sdk/domain/user/user-repository.ts | 14 +- .../wallet-sdk/domain/user/user-service.ts | 23 +- packages/wallet-sdk/index.ts | 20 +- packages/wallet-sdk/lib/error.ts | 24 + .../lib/guest-account-storage.test.ts | 57 + .../wallet-sdk/lib/guest-account-storage.ts | 52 + packages/wallet-sdk/lib/logger.ts | 11 + packages/wallet-sdk/lib/password.test.ts | 55 + .../wallet-sdk/lib/password.ts | 20 +- packages/wallet-sdk/lib/spark/wallet.ts | 2 +- packages/wallet-sdk/package.json | 2 +- packages/wallet-sdk/sdk.ts | 405 --- packages/wallet-sdk/temporary.ts | 8 +- packages/wallet-sdk/tsconfig.json | 2 +- 117 files changed, 5872 insertions(+), 987 deletions(-) create mode 100644 apps/web-wallet/app/features/shared/sdk.client.ts 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 delete mode 100644 apps/web-wallet/app/hooks/use-long-timeout.ts create mode 100644 docs/superpowers/plans/2026-07-09-wallet-sdk-auth-slice.md create mode 100644 packages/utils/src/jwt.ts rename {apps/web-wallet/app/lib => packages/utils/src}/timeout.ts (63%) 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 rename packages/wallet-sdk/{ => db}/supabase/.env (100%) rename packages/wallet-sdk/{ => db}/supabase/.gitignore (100%) rename packages/wallet-sdk/{ => db}/supabase/config.toml (100%) rename packages/wallet-sdk/{ => db}/supabase/database.types.ts (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260112150000_initial_db.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260202120000_make_terms_nullable.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260223193702_feature_flags.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260225000000_add_debug_logging_flag.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260225120000_enforce_gift_card_feature_flag.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260302120000_add_version_to_transactions.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260306120000_add_transaction_purpose_and_transfer_id.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260310120000_move_transfer_id_to_jsonb.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260312163752_transfer_id_text_to_uuid.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260320120000_add_offer_account_purpose.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260325120000_add_account_state.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260325130000_default_account_no_expiry.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260413222901_generic_event_system.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260415180000_add_gift_card_mint_terms.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260420152512_denormalize_account_on_transactions.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260420192806_fix_emit_event_pg_net_signature.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260422142259_fix_fail_cashu_receive_quote_state_guard.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260425181643_tighten_spark_send_payment_hash_uniqueness.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260505222417_remove_gift_cards_feature_flag.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/migrations/20260522185431_add_users_broadcast_trigger.sql (100%) rename packages/wallet-sdk/{ => db}/supabase/seed.sql (100%) rename packages/wallet-sdk/{lib => domain}/currencies.ts (100%) rename packages/wallet-sdk/{lib => domain}/exchange-rate/exchange-rate-service.test.ts (100%) rename packages/wallet-sdk/{lib => domain}/exchange-rate/exchange-rate-service.ts (100%) rename packages/wallet-sdk/{lib => domain}/exchange-rate/index.ts (100%) rename packages/wallet-sdk/{lib => domain}/exchange-rate/providers/coinbase.ts (100%) rename packages/wallet-sdk/{lib => domain}/exchange-rate/providers/coingecko.ts (100%) rename packages/wallet-sdk/{lib => domain}/exchange-rate/providers/mempool-space.ts (100%) rename packages/wallet-sdk/{lib => domain}/exchange-rate/providers/types.ts (100%) rename packages/wallet-sdk/{lib => domain/feature-flags}/feature-flag-service.test.ts (98%) rename packages/wallet-sdk/{lib => domain/feature-flags}/feature-flag-service.ts (98%) create mode 100644 packages/wallet-sdk/domain/sdk/accounts.ts create mode 100644 packages/wallet-sdk/domain/sdk/auth.ts create mode 100644 packages/wallet-sdk/domain/sdk/contacts.ts create mode 100644 packages/wallet-sdk/domain/sdk/events.test.ts create mode 100644 packages/wallet-sdk/domain/sdk/events.ts create mode 100644 packages/wallet-sdk/domain/sdk/feature-flags.ts create mode 100644 packages/wallet-sdk/domain/sdk/index.ts create mode 100644 packages/wallet-sdk/domain/sdk/receive.ts create mode 100644 packages/wallet-sdk/domain/sdk/sdk.test.ts create mode 100644 packages/wallet-sdk/domain/sdk/sdk.ts create mode 100644 packages/wallet-sdk/domain/sdk/send.ts create mode 100644 packages/wallet-sdk/domain/sdk/server.ts create mode 100644 packages/wallet-sdk/domain/sdk/task-processor.ts create mode 100644 packages/wallet-sdk/domain/sdk/transactions.ts create mode 100644 packages/wallet-sdk/domain/sdk/transfer.ts create mode 100644 packages/wallet-sdk/domain/sdk/user.ts rename packages/wallet-sdk/{lib => domain/send}/send-destination.ts (100%) create mode 100644 packages/wallet-sdk/domain/user/auth-service.test.ts create mode 100644 packages/wallet-sdk/domain/user/auth-service.ts create mode 100644 packages/wallet-sdk/domain/user/user-api.test.ts create mode 100644 packages/wallet-sdk/domain/user/user-api.ts create mode 100644 packages/wallet-sdk/lib/guest-account-storage.test.ts create mode 100644 packages/wallet-sdk/lib/guest-account-storage.ts create mode 100644 packages/wallet-sdk/lib/logger.ts create mode 100644 packages/wallet-sdk/lib/password.test.ts rename apps/web-wallet/app/lib/password-generator.ts => packages/wallet-sdk/lib/password.ts (63%) delete mode 100644 packages/wallet-sdk/sdk.ts diff --git a/.claude/settings.json b/.claude/settings.json index 97a99e797..2a976d65c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -27,7 +27,7 @@ "Bash(pnpm:*)", "Read(.env)", "Read(.env.*)", - "Read(supabase/.env)" + "Read(packages/wallet-sdk/db/supabase/.env)" ] }, "hooks": { diff --git a/.claude/skills/supabase-database/SKILL.md b/.claude/skills/supabase-database/SKILL.md index 2ac43e744..6148f6060 100644 --- a/.claude/skills/supabase-database/SKILL.md +++ b/.claude/skills/supabase-database/SKILL.md @@ -21,6 +21,6 @@ Expert guidance for Postgres database work in a Supabase environment. - Write all SQL in lowercase - Always enable RLS on new tables - Separate RLS policies per operation (select/insert/update/delete) and per role (anon/authenticated) -- Migration files: `YYYYMMDDHHmmss_short_description.sql` in `packages/wallet-sdk/supabase/migrations/` +- Migration files: `YYYYMMDDHHmmss_short_description.sql` in `packages/wallet-sdk/db/supabase/migrations/` - Functions default to `SECURITY INVOKER` with `set search_path = ''` - Use fully qualified names (e.g., `public.table_name`) in functions diff --git a/.claude/skills/supabase-database/references/migrations.md b/.claude/skills/supabase-database/references/migrations.md index 0691b921f..098db9227 100644 --- a/.claude/skills/supabase-database/references/migrations.md +++ b/.claude/skills/supabase-database/references/migrations.md @@ -4,7 +4,7 @@ This project uses the migrations provided by the Supabase CLI. ## Creating a migration file -Create migration files inside `packages/wallet-sdk/supabase/migrations/`. +Create migration files inside `packages/wallet-sdk/db/supabase/migrations/`. The file MUST be named in the format `YYYYMMDDHHmmss_short_description.sql` with proper casing for months, minutes, and seconds in UTC time: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58e2812e1..dd8d68539 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,8 @@ on: pull_request: env: - SELF_SIGNED_CERT_PATH: "../../../certs/ci-localhost-cert.pem" - SELF_SIGNED_CERT_KEY_PATH: "../../../certs/ci-localhost-key.pem" + SELF_SIGNED_CERT_PATH: "../../../../certs/ci-localhost-cert.pem" + SELF_SIGNED_CERT_KEY_PATH: "../../../../certs/ci-localhost-key.pem" jobs: code-checks: @@ -28,7 +28,7 @@ jobs: MIGRATIONS_CHANGED=false CLI_VERSION_CHANGED=false - if ! git diff --quiet origin/${{ github.base_ref }}..HEAD -- packages/wallet-sdk/supabase/migrations/; then + if ! git diff --quiet origin/${{ github.base_ref }}..HEAD -- packages/wallet-sdk/db/supabase/migrations/; then MIGRATIONS_CHANGED=true fi @@ -50,9 +50,9 @@ jobs: fi echo "Checking types..." - bun supabase db start --workdir packages/wallet-sdk - bun supabase gen types typescript --local --schema wallet --workdir packages/wallet-sdk > packages/wallet-sdk/supabase/database.types.ts - if ! git diff --ignore-space-at-eol --exit-code --quiet packages/wallet-sdk/supabase/database.types.ts; then + bun supabase db start --workdir packages/wallet-sdk/db + bun supabase gen types typescript --local --schema wallet --workdir packages/wallet-sdk/db > packages/wallet-sdk/db/supabase/database.types.ts + if ! git diff --ignore-space-at-eol --exit-code --quiet packages/wallet-sdk/db/supabase/database.types.ts; then echo "Detected uncommitted changes in database types. This means that db was changed but types were not updated. To fix regenerate the types and commit the changes. See missing types below:" git diff exit 1 diff --git a/README.md b/README.md index d3c5bda2d..266b33baf 100644 --- a/README.md +++ b/README.md @@ -178,18 +178,18 @@ hosted on Supabase. Sensitive data is encrypted with the key from the Open Secre logged-in user. While developing locally, the local Supabase stack is used. To start it, run `bun run supabase start` command. To stop it, run `bun run supabase stop` command. The start command will start the database and realtime service, plus other services useful for development, like Supabase Studio. You can use Supabase Studio to inspect the database and run queries. -Supabase is configured in the `packages/wallet-sdk/supabase/config.toml` file. +Supabase is configured in the `packages/wallet-sdk/db/supabase/config.toml` file. ### Database migrations -Schema changes to the Postgres database should be done using migrations. Migrations are stored in the `packages/wallet-sdk/supabase/migrations` +Schema changes to the Postgres database should be done using migrations. Migrations are stored in the `packages/wallet-sdk/db/supabase/migrations` folder. Always try to make the schema changes in a backward compatible way. Database migrations can be done in two ways: 1. Using Supabase Studio. With this approach you can make db changes directly to your local database in the Studio UI and then run `bun supabase db diff --file ` to create a migration file for the changes. 2. Using `bun supabase migration new `. This command will create a new empty migration file in the - `packages/wallet-sdk/supabase/migrations` folder where you can then write the SQL commands to make the changes. To apply the migration to + `packages/wallet-sdk/db/supabase/migrations` folder where you can then write the SQL commands to make the changes. To apply the migration to the local database run `bun supabase db push`. To keep the db TypeScript types in sync with the database schema run `bun run db:generate-types` command. If you forget diff --git a/apps/web-wallet-e2e/.env.test b/apps/web-wallet-e2e/.env.test index 26c7d7847..567fe89fe 100644 --- a/apps/web-wallet-e2e/.env.test +++ b/apps/web-wallet-e2e/.env.test @@ -8,8 +8,8 @@ VITE_SENTRY_HOST='o4509706567680000.ingest.us.sentry.io' VITE_SENTRY_PROJECT_ID='4509707316690944' VITE_SENTRY_DSN='https://3e5837ba4db251e806915155170ef71b@${VITE_SENTRY_HOST}/${VITE_SENTRY_PROJECT_ID}' VITE_ENVIRONMENT='local' -SELF_SIGNED_CERT_PATH='../../../certs/localhost-cert.pem' -SELF_SIGNED_CERT_KEY_PATH='../../../certs/localhost-key.pem' +SELF_SIGNED_CERT_PATH='../../../../certs/localhost-cert.pem' +SELF_SIGNED_CERT_KEY_PATH='../../../../certs/localhost-key.pem' NODE_TLS_REJECT_UNAUTHORIZED=0 LNURL_SERVER_SPARK_MNEMONIC='half vanish taxi again drill detail neglect park harsh setup involve dawn' LNURL_SERVER_ENCRYPTION_KEY="a8861cc3e5b3caf5573fbba2da2851a4e608a836eef10074be36ae0ca147b518" diff --git a/apps/web-wallet/app/entry.client.tsx b/apps/web-wallet/app/entry.client.tsx index d75a4f689..f91b2e6bb 100644 --- a/apps/web-wallet/app/entry.client.tsx +++ b/apps/web-wallet/app/entry.client.tsx @@ -1,4 +1,3 @@ -import { configure } from '@agicash/opensecret'; import { configureFeatureFlags, ensureBreezWasm, @@ -15,6 +14,13 @@ 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'; +// Side-effect import: evaluating this module constructs the SDK, which +// configures Open Secret. loadFeatureFlags() below relies on that — for a +// returning user it fetches user-targeted flags with a token minted through +// Open Secret — so the construction has to run before the body. Temporary: a +// later slice constructs the SDK explicitly at boot and moves feature flags +// onto the instance, dropping this ordering dependency (PR #1166). +import './features/shared/sdk.client'; import { registerMoneyDevToolsFormatter } from './lib/money-devtools-formatter'; import { getTracesSampleRate, sanitizeUrl } from './tracing-utils'; @@ -23,21 +29,6 @@ if (process.env.NODE_ENV === 'development') { registerMoneyDevToolsFormatter(); } -const openSecretApiUrl = import.meta.env.VITE_OPEN_SECRET_API_URL ?? ''; -if (!openSecretApiUrl) { - throw new Error('VITE_OPEN_SECRET_API_URL is not set'); -} - -const openSecretClientId = import.meta.env.VITE_OPEN_SECRET_CLIENT_ID ?? ''; -if (!openSecretClientId) { - throw new Error('VITE_OPEN_SECRET_CLIENT_ID is not set'); -} - -configure({ - apiUrl: openSecretApiUrl, - clientId: openSecretClientId, -}); - // Start Breez WASM fetch/compile as early as possible so it overlaps with // hydration, Sentry init, and the auth query — by the time the _protected // middleware awaits it, init is often already done. diff --git a/apps/web-wallet/app/features/agicash-db/database.client.ts b/apps/web-wallet/app/features/agicash-db/database.client.ts index ae18659f7..d724a35c4 100644 --- a/apps/web-wallet/app/features/agicash-db/database.client.ts +++ b/apps/web-wallet/app/features/agicash-db/database.client.ts @@ -1,36 +1,9 @@ import type { Database } from '@agicash/wallet-sdk/temporary'; import { createClient } from '@supabase/supabase-js'; +import { supabaseAnonKey, supabaseUrl } from '~/features/shared/sdk.client'; import { SupabaseRealtimeManager } from '~/lib/supabase'; import { getSupabaseSessionToken } from './supabase-session'; -const getSupabaseUrl = () => { - const supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? ''; - if (!supabaseUrl) { - throw new Error('VITE_SUPABASE_URL is not set'); - } - - if ( - supabaseUrl.includes('127.0.0.1') && - typeof window !== 'undefined' && - window.location.protocol === 'https:' && - (window.location.hostname.endsWith('.local') || - window.location.hostname.startsWith('192.168.') || - window.location.hostname.startsWith('10.') || - /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(window.location.hostname)) - ) { - return supabaseUrl.replace('127.0.0.1', window.location.hostname); - } - - return supabaseUrl; -}; - -const supabaseUrl = getSupabaseUrl(); - -const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? ''; -if (!supabaseAnonKey) { - throw new Error('VITE_SUPABASE_ANON_KEY is not set'); -} - /** * The client-side Supabase database client. * If you need to use a client on the server, which bypasses RLS, use `agicashDbServer` instead. diff --git a/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts b/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts index beccc2f19..ad7f9276d 100644 --- a/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts +++ b/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts @@ -5,6 +5,11 @@ import { sumProofs, } from '@agicash/cashu'; import type { Money } from '@agicash/money'; +import { + type LongTimeout, + clearLongTimeout, + setLongTimeout, +} from '@agicash/utils'; import type { CashuAccount, CashuReceiveQuote, @@ -34,11 +39,6 @@ import { import { useCallback, useEffect, useMemo, useState } from 'react'; import { useOnMeltQuoteStateChange } from '~/lib/cashu/melt-quote-subscription'; import { MintQuoteSubscriptionManager } from '~/lib/cashu/mint-quote-subscription-manager'; -import { - type LongTimeout, - clearLongTimeout, - setLongTimeout, -} from '~/lib/timeout'; import { useLatest } from '~/lib/use-latest'; import { withRetry } from '~/lib/with-retry'; import { diff --git a/apps/web-wallet/app/features/shared/auth.ts b/apps/web-wallet/app/features/shared/auth.ts index 69f52f6ae..64ecf0404 100644 --- a/apps/web-wallet/app/features/shared/auth.ts +++ b/apps/web-wallet/app/features/shared/auth.ts @@ -1,12 +1,19 @@ -import { jwtDecode } from 'jwt-decode'; +import { safeJwtDecode } from '@agicash/utils'; -/** Check if the user is logged in by verifying localStorage tokens. */ +/** + * Check if the user is logged in by verifying localStorage tokens. A corrupt + * stored token counts as logged out — this feeds the DB client's token + * getter, so it must never throw into every query. + * + * Temporary leak: reads Open Secret's storage keys directly until the late + * SDK-migration steps retire the web's own DB client. + */ export const isLoggedIn = (): boolean => { const accessToken = window.localStorage.getItem('access_token'); const refreshToken = window.localStorage.getItem('refresh_token'); if (!accessToken || !refreshToken) { return false; } - const decoded = jwtDecode(refreshToken); - return !!decoded.exp && decoded.exp * 1000 > Date.now(); + const decoded = safeJwtDecode(refreshToken); + return !!decoded?.exp && decoded.exp * 1000 > Date.now(); }; diff --git a/apps/web-wallet/app/features/shared/sdk.client.ts b/apps/web-wallet/app/features/shared/sdk.client.ts new file mode 100644 index 000000000..46c979c57 --- /dev/null +++ b/apps/web-wallet/app/features/shared/sdk.client.ts @@ -0,0 +1,83 @@ +import { browserStorage } from '@agicash/opensecret'; +import { AgicashSdk } from '@agicash/wallet-sdk'; +import { breezApiKey } from '~/lib/breez'; + +const getSupabaseUrl = () => { + const supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? ''; + if (!supabaseUrl) { + throw new Error('VITE_SUPABASE_URL is not set'); + } + + if ( + supabaseUrl.includes('127.0.0.1') && + typeof window !== 'undefined' && + window.location.protocol === 'https:' && + (window.location.hostname.endsWith('.local') || + window.location.hostname.startsWith('192.168.') || + window.location.hostname.startsWith('10.') || + /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(window.location.hostname)) + ) { + return supabaseUrl.replace('127.0.0.1', window.location.hostname); + } + + return supabaseUrl; +}; + +export const supabaseUrl = getSupabaseUrl(); + +export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? ''; +if (!supabaseAnonKey) { + throw new Error('VITE_SUPABASE_ANON_KEY is not set'); +} + +const openSecretApiUrl = import.meta.env.VITE_OPEN_SECRET_API_URL ?? ''; +if (!openSecretApiUrl) { + throw new Error('VITE_OPEN_SECRET_API_URL is not set'); +} + +const openSecretClientId = import.meta.env.VITE_OPEN_SECRET_CLIENT_ID ?? ''; +if (!openSecretClientId) { + throw new Error('VITE_OPEN_SECRET_CLIENT_ID is not set'); +} + +// console.debug(message, undefined) prints a trailing "undefined". Most calls +// pass no meta, so the second argument is omitted unless meta is provided. +const consoleLogger = { + debug: (message: string, meta?: unknown) => + meta === undefined ? console.debug(message) : console.debug(message, meta), + info: (message: string, meta?: unknown) => + meta === undefined ? console.info(message) : console.info(message, meta), + warn: (message: string, meta?: unknown) => + meta === undefined ? console.warn(message) : console.warn(message, meta), + error: (message: string, meta?: unknown) => + meta === undefined ? console.error(message) : console.error(message, meta), +}; + +export const sdk = AgicashSdk.create({ + db: { + url: supabaseUrl, + anonKey: supabaseAnonKey, + }, + auth: { + apiUrl: openSecretApiUrl, + clientId: openSecretClientId, + storage: browserStorage, + // e2e bridge: the Playwright fixture arms window.getMockPassword; in + // production it's absent, so this resolves null and the SDK generates. + generateGuestPassword: async () => + (await window.getMockPassword?.()) ?? null, + }, + spark: { + breezApiKey, + network: 'MAINNET', + }, + lightningAddressDomain: window.location.host, + logger: consoleLogger, +}); + +if (import.meta.hot) { + // A hot reload of this module constructs a second SDK; dispose the old one + // so its expiry timer doesn't leak. Returning the promise makes Vite await + // the teardown before evaluating the replacement module. + import.meta.hot.dispose(() => sdk.dispose()); +} diff --git a/apps/web-wallet/app/features/signup/verify-email.ts b/apps/web-wallet/app/features/signup/verify-email.ts index c619e8a4a..a48559078 100644 --- a/apps/web-wallet/app/features/signup/verify-email.ts +++ b/apps/web-wallet/app/features/signup/verify-email.ts @@ -1,8 +1,8 @@ -import { verifyEmail as osVerifyEmail } from '@agicash/opensecret'; import type { FullUser } from '@agicash/wallet-sdk'; import { shouldVerifyEmail } from '@agicash/wallet-sdk'; import { useState } from 'react'; import { createContext, redirect } from 'react-router'; +import { sdk } from '~/features/shared/sdk.client'; import { useToast } from '~/hooks/use-toast'; import type { Route } from '../../routes/+types/_protected.verify-email.($code)'; import { invalidateAuthQueries } from '../user/auth'; @@ -37,7 +37,7 @@ export const verifyEmail = async ( code: string, ): Promise<{ verified: true } | { verified: false; error: Error }> => { try { - await osVerifyEmail(code); + await sdk.auth.verifyEmail(code); await invalidateAuthQueries(); return { verified: true }; } catch (e) { diff --git a/apps/web-wallet/app/features/user/auth.ts b/apps/web-wallet/app/features/user/auth.ts index dee12f0dd..493cdb603 100644 --- a/apps/web-wallet/app/features/user/auth.ts +++ b/apps/web-wallet/app/features/user/auth.ts @@ -1,19 +1,5 @@ -import { - type UserResponse, - fetchUser, - convertGuestToUserAccount as osConvertGuestToFullAccount, - initiateGoogleAuth as osInitiateGoogleAuth, - signIn as osSignIn, - signInGuest as osSignInGuest, - signOut as osSignOut, - signUp as osSignUp, - signUpGuest as osSignUpGuest, - verifyEmail as osVerifyEmail, -} from '@agicash/opensecret'; -import { - clearAgicashMintAuthToken, - clearSparkWallets, -} from '@agicash/wallet-sdk/temporary'; +import { safeJwtDecode } from '@agicash/utils'; +import type { AuthUser } from '@agicash/wallet-sdk'; import * as Sentry from '@sentry/react-router'; import { decodeURLSafe, encodeURLSafe } from '@stablelib/base64'; import { @@ -21,26 +7,26 @@ import { useQueryClient, useSuspenseQuery, } from '@tanstack/react-query'; -import { jwtDecode } from 'jwt-decode'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useNavigate, useRevalidator } from 'react-router'; import { loadFeatureFlags, resetFeatureFlags, } from '~/features/shared/feature-flags'; import { getQueryClient } from '~/features/shared/query-client'; -import { useLongTimeout } from '~/hooks/use-long-timeout'; -import { generateRandomPassword } from '~/lib/password-generator'; -import { guestAccountStorage } from './guest-account-storage'; +import { sdk } from '~/features/shared/sdk.client'; +import { useLatest } from '~/lib/use-latest'; import { oauthLoginSessionStorage } from './oauth-login-session-storage'; import { sessionHintCookie } from './session-hint-cookie'; -export type AuthUser = UserResponse['user']; +export type { AuthUser }; type AuthState = | { isLoggedIn: true; user: AuthUser; + /** Unix seconds, captured at fetch time; drives the hint-cookie lifetime and query staleness. */ + refreshTokenExpiresAt: number | null; } | { isLoggedIn: false; @@ -49,43 +35,82 @@ type AuthState = export const authStateQueryKey = 'auth-state'; +// The stored token may be corrupt — safeJwtDecode keeps it from throwing out +// of the queryFn or staleTime callback (that would error-page every route, +// /login included). +// Reads Open Secret's token key straight from local storage: the web app owns +// the storage it hands the SDK, so the storage backend is known here. +const getRefreshTokenExpiry = (): number | null => { + const refreshToken = window.localStorage.getItem('refresh_token'); + if (!refreshToken) { + 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, by reading Open Secret's token key straight + // from local storage (the web app owns the storage it hands the SDK). + const accessToken = window.localStorage.getItem('access_token'); + const sub = accessToken ? safeJwtDecode(accessToken)?.sub : undefined; + if (sub) { + 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 initialize sdk', { cause: error }); + Sentry.setUser(null); + sessionHintCookie.clear(); + return { isLoggedIn: false }; + } + const session = sdk.auth.getSession(); - const response = await fetchUser(); + if (!session.isLoggedIn) { + Sentry.setUser(null); + sessionHintCookie.clear(); + return { isLoggedIn: false }; + } - // Set Sentry user again to include the isGuest flag - Sentry.setUser({ id: response.user.id, isGuest: !response.user.email }); + Sentry.setUser({ id: session.user.id, isGuest: !session.user.email }); - // Mirror auth state into a hint cookie so the server can short-circuit - // SSR for unauthenticated visits. Lifetime matches the refresh token - // so we don't leave a stale "logged in" hint after the session - // genuinely expires. - const { exp } = jwtDecode(refresh_token); + // Mirror auth state into a hint cookie so the server can short-circuit + // SSR for unauthenticated visits. Lifetime matches the refresh token + // so we don't leave a stale "logged in" hint after the session + // genuinely expires. + const exp = getRefreshTokenExpiry(); + if (exp) { sessionHintCookie.set(exp - Math.floor(Date.now() / 1000)); + } - return { isLoggedIn: true, user: response.user } as const; - } catch (error) { - console.error('Failed to fetch user', { cause: error }); - Sentry.setUser(null); - sessionHintCookie.clear(); - return { isLoggedIn: false } as const; + return { ...session, refreshTokenExpiresAt: exp }; + }, + // Logged-in state is fresh until the refresh token expires; a refetch + // after that point re-reads the (SDK-extended or ended) session and + // re-syncs the hint cookie. Anonymous state only changes through explicit + // invalidation. Staleness is pinned to the expiry captured AT FETCH TIME + // (not re-read from storage), so an SDK-internal guest extension can't + // slide freshness forward and postpone the cookie re-sync forever. + staleTime: ({ state: { data, dataUpdatedAt } }) => { + if (!data?.isLoggedIn) { + return Number.POSITIVE_INFINITY; } + if (!data.refreshTokenExpiresAt) { + return 0; + } + return Math.max( + (data.refreshTokenExpiresAt - 5) * 1000 - dataUpdatedAt, + 0, + ); }, - staleTime: Number.POSITIVE_INFINITY, }); /** @@ -108,6 +133,36 @@ export const useAuthState = (): AuthState => { return data; }; +/** + * The web-side counterpart of the SDK's session end: forgets all + * session-derived web state after a session ends (sign-out or expiry). + * Ordering is load-bearing: the flags reset first, so the previous user's + * flags are gone even if the anonymous re-fetch fails and can't clobber its + * result; queryClient.clear() runs last, once navigation/revalidation has + * settled, so the still-mounted protected tree doesn't lose its suspended + * query data mid-transition. + */ +const useSessionEndCleanup = () => { + const queryClient = useQueryClient(); + const { revalidate } = useRevalidator(); + const navigate = useNavigate(); + + return useCallback( + async ({ redirectTo }: { redirectTo?: string } = {}) => { + resetFeatureFlags(); + await invalidateAuthQueries(); + if (redirectTo) { + await navigate(redirectTo); + } else { + await revalidate(); + } + Sentry.setUser(null); + queryClient.clear(); + }, + [navigate, revalidate, queryClient], + ); +}; + type SignOutOptions = { /** * The URL to redirect to after signing out. If not provided, the user will be redirected to the singup page by the protected layout. @@ -116,69 +171,24 @@ type SignOutOptions = { }; type AuthActions = { - /** - * Creates a new full user account. Automatically signs in the user after sign up. - * @param email - * @param password - */ signUp: (email: string, password: string) => Promise; - - /** - * Creates a new guest user account. If the user has already signed up as a guest on the same device before, the sign - * in to that account will be performed instead. Automatically signs in the user after sign up. - */ signUpGuest: () => Promise; - - /** - * Signs in the existing user - * @param email - * @param password - */ signIn: (email: string, password: string) => Promise; - - /** - * Signs out the current user - * @param options Options for the sign out - */ signOut: (options?: SignOutOptions) => Promise; - - /** - * Initiates a Google authentication flow - * Returns the auth URL to redirect the user to - */ - initiateGoogleAuth: () => Promise<{ - /** - * The auth URL to redirect the user to to perform the Google authentication flow - */ - authUrl: string; - }>; - - /** - * Verifies the email address - * @param code The code from the email verification - */ + initiateGoogleAuth: () => Promise<{ authUrl: string }>; verifyEmail: (code: string) => Promise; - - /** - * Converts a guest account to a full account - * @param email The email address of the user - * @param password The password of the user - */ convertGuestToFullAccount: (email: string, password: string) => Promise; }; /** - * A hook that provides authentication actions by wrapping functionalities from the OpenSecret SDK. - * The actions include user signing up, signing in, and signing out. - * References for these actions are memoized to ensure consistent references across renders, - * improving performance and preventing unnecessary re-renders or function evaluations. - * - * @returns {AuthActions} + * Authentication actions backed by the wallet SDK, wrapped with the web + * concerns the SDK doesn't own: query invalidation, navigation, Sentry user + * tracking, and the OAuth deep-link session. */ export const useAuthActions = (): AuthActions => { - const queryClient = useQueryClient(); const { revalidate } = useRevalidator(); const navigate = useNavigate(); + const endSessionCleanup = useSessionEndCleanup(); const refreshSession = useCallback( async (redirectTo?: string) => { @@ -194,7 +204,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 +212,37 @@ export const useAuthActions = (): AuthActions => { const signIn = useCallback( async (email: string, password: string) => { - await osSignIn(email, password); + await sdk.auth.signIn(email, password); await refreshSession(); }, [refreshSession], ); - const signInGuest = useCallback( - async (id: string, password: string) => { - await osSignInGuest(id, password); - await refreshSession(); - }, - [refreshSession], - ); + const signUpGuest = useCallback(async () => { + await sdk.auth.signUpGuest(); + await refreshSession(); + }, [refreshSession]); const signOut = useCallback( async (options: SignOutOptions = {}) => { - await osSignOut(); - // Before the refresh below so the previous user's flags are gone even if - // the anon re-fetch fails, and so its result isn't clobbered afterwards. - resetFeatureFlags(); - await refreshSession(options.redirectTo); - Sentry.setUser(null); - queryClient.clear(); - // The SDK's module-level memos (spark wallet connections, agicash-mint - // CAT) live outside the query cache, so clear them alongside it so the - // next session starts fresh. - clearSparkWallets(); - clearAgicashMintAuthToken(); + try { + await sdk.auth.signOut(); + } finally { + // The SDK ends the local session even when the sign-out throws, so + // the web state must be reset regardless — otherwise a dead session + // keeps a live-looking wallet on screen. + await endSessionCleanup({ redirectTo: options.redirectTo }); + } }, - [refreshSession, queryClient], + [endSessionCleanup], ); const initiateGoogleAuth = useCallback(async () => { - const response = await osInitiateGoogleAuth(''); + const { authUrl } = await sdk.auth.initiateGoogleAuth(); - const authLocation = new URL(response.auth_url); + // Stash the current location under a session id and thread it through the + // OAuth state param, so the callback route can restore the deep link. + const authLocation = new URL(authUrl); const stateParam = authLocation.searchParams.get('state'); const state = stateParam ? JSON.parse(new TextDecoder().decode(decodeURLSafe(stateParam))) @@ -257,28 +262,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 +272,7 @@ export const useAuthActions = (): AuthActions => { const convertGuestToFullAccount = useCallback( async (email: string, password: string) => { - await osConvertGuestToFullAccount(email, password); + await sdk.auth.convertGuestToFullAccount(email, password); await refreshSession(); }, [refreshSession], @@ -309,100 +295,74 @@ export const useSignOut = () => { const handleSignOut = async () => { setLoading(true); - await signOut({ redirectTo: '/home' }); - setLoading(false); + try { + await signOut({ redirectTo: '/home' }); + } finally { + setLoading(false); + } }; return { isSigningOut: loading, signOut: handleSignOut }; }; -type OpenSecretJwt = { - /** - * Token expiration time. It's a unix timestamp in seconds - */ - exp: number; - - /** - * Time when the token was issues. It's a unix timestamp in seconds - */ - iat: number; - - /** - * ID of the logged-in user - */ - sub: string; - - /** - * Audience - */ - aud: 'access' | 'refresh'; -}; - -const accessTokenKey = 'access_token'; -const refreshTokenKey = 'refresh_token'; - -const getJwt = (key: string): OpenSecretJwt | null => { - const jwt = localStorage.getItem(key); - if (!jwt) { - return null; - } - return jwtDecode(jwt); -}; - -const removeKeys = () => { - localStorage.removeItem(accessTokenKey); - localStorage.removeItem(refreshTokenKey); - sessionHintCookie.clear(); -}; - -const getRefreshToken = () => getJwt(refreshTokenKey); - -const getRemainingSessionTimeInMs = ( - token: OpenSecretJwt | null, -): number | null => { - if (!token) { - return null; - } - // We are treating the session as expired 5 seconds before the actual expiry just in case - const fiveSecondsBeforeExpiry = token.exp - 5; - const fiveSecondsBeforeExpiryInMs = fiveSecondsBeforeExpiry * 1000; - const remainingTime = fiveSecondsBeforeExpiryInMs - Date.now(); - return Math.max(remainingTime, 0); -}; - -type HandleSessionExpiryProps = { - isGuestAccount: boolean; - onLogout: () => void; -}; +/** + * 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, +}: { + onSessionExpired: () => void; +}) => { + const queryClient = useQueryClient(); + const endSessionCleanup = useSessionEndCleanup(); + const onSessionExpiredRef = useLatest(onSessionExpired); + + useEffect(() => { + const handleSessionExpired = () => { + onSessionExpiredRef.current(); + void endSessionCleanup().catch((error) => { + // The SDK has already ended the session, so nothing sensitive + // survives a failed web-side cleanup — the worst case is stale + // logged-in UI until the user reloads (PR #1166 review). + console.error('Failed to handle session expiry', { cause: error }); + Sentry.captureException(error); + }); + }; -export const useHandleSessionExpiry = ({ - isGuestAccount, - onLogout, -}: HandleSessionExpiryProps) => { - const { signUpGuest: extendGuestSession, signOut } = useAuthActions(); - const refreshToken = getRefreshToken(); - const remainingSessionTime = getRemainingSessionTimeInMs(refreshToken); + const unsubscribeExpired = sdk.events.on( + 'auth.session-expired', + handleSessionExpired, + ); + const unsubscribeRefreshed = sdk.events.on('auth.session-refreshed', () => { + void invalidateAuthQueries(); + }); - const handleSessionExpiry = async () => { - try { - if (isGuestAccount) { - // Extend guest session will get new extended access and refresh token from Open Secret. The OS code can be seen - // here https://github.com/OpenSecretCloud/OpenSecret-SDK/blob/master/src/lib/main.tsx#L441. Because setState is - // called after this method is executed the new render will be triggered and useHandleSessionExpiry will be - // executed again which will result in new session expiry timeout being set. - await extendGuestSession(); - } else { - onLogout(); - // Open secret is already handling potential errors in signOut method and removes the keys from the storage so - // in that case our catch should never be triggered, which is fine. We are leaving it there for the guest use - // case and just in case. - await signOut(); + // The SDK arms its expiry timer during init(), before this subscription + // exists; an expiry firing in that window emitted to no subscribers. + // This hook mounts only under an authenticated layout, so an already-dead + // SDK session here means exactly that missed event — handle it now. + if (!sdk.auth.getSession().isLoggedIn) { + handleSessionExpired(); + } else { + // The mirror gap for guests: an auto-extension during init() missed its + // auth.session-refreshed the same way. If the stored expiry moved past + // what the auth query captured, re-sync the query (and with it the + // session-hint cookie) now. + const authState = queryClient.getQueryData(authQueryOptions().queryKey); + if ( + authState?.isLoggedIn && + authState.refreshTokenExpiresAt !== getRefreshTokenExpiry() + ) { + void invalidateAuthQueries(); } - } catch (e) { - console.error('Failed to handle session expiry', { cause: e }); - removeKeys(); - window.location.reload(); } - }; - useLongTimeout(handleSessionExpiry, remainingSessionTime); + return () => { + unsubscribeExpired(); + unsubscribeRefreshed(); + }; + }, [endSessionCleanup, queryClient]); }; diff --git a/apps/web-wallet/app/features/user/guest-account-storage.ts b/apps/web-wallet/app/features/user/guest-account-storage.ts deleted file mode 100644 index b856f5c09..000000000 --- a/apps/web-wallet/app/features/user/guest-account-storage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { safeJsonParse } from '@agicash/utils'; -import { z } from 'zod/mini'; - -const storageKey = 'guestAccount'; - -const GuestAccountDetailsSchema = z.object({ - id: z.string(), - password: z.string(), -}); - -type GuestAccountDetails = z.infer; - -const getGuestAccount = (): GuestAccountDetails | null => { - const dataString = localStorage.getItem(storageKey); - if (!dataString) { - return null; - } - const parseResult = safeJsonParse(dataString); - if (!parseResult.success) { - return null; - } - const validationResult = GuestAccountDetailsSchema.safeParse( - parseResult.data, - ); - if (!validationResult.success) { - console.warn('Invalid guest account data found in the storage'); - return null; - } - return validationResult.data; -}; - -const storeGuestAccount = (data: GuestAccountDetails) => { - localStorage.setItem(storageKey, JSON.stringify(data)); -}; - -const removeGuestAccount = () => { - localStorage.removeItem(storageKey); -}; - -export const guestAccountStorage = { - get: getGuestAccount, - store: storeGuestAccount, - clear: removeGuestAccount, -}; diff --git a/apps/web-wallet/app/features/user/user-hooks.tsx b/apps/web-wallet/app/features/user/user-hooks.tsx index 16edb1c30..693d97601 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({ account }), + ); return mutateAsync; }; export const useUpdateUsername = () => { - const { mutateAsync: updateUser } = useUpdateUser(); - - return useCallback( - (username: string) => updateUser({ username }), - [updateUser], + const { mutateAsync } = useUserUpdatingMutation((username: string) => + sdk.user.updateUsername(username), ); + + return mutateAsync; }; export const useAcceptTerms = () => { - const { mutateAsync: updateUser } = useUpdateUser(); - - return useCallback( - ({ - walletTerms, - giftCardTerms, - }: { walletTerms?: boolean; giftCardTerms?: boolean }) => { - const now = new Date().toISOString(); - const updates: UpdateUser = {}; - if (walletTerms) updates.termsAcceptedAt = now; - if (giftCardTerms) updates.giftCardMintTermsAcceptedAt = now; - return updateUser(updates); - }, - [updateUser], + const { mutateAsync } = useUserUpdatingMutation( + (params: { walletTerms?: boolean; giftCardTerms?: boolean }) => + sdk.user.acceptTerms(params), ); + + return mutateAsync; }; diff --git a/apps/web-wallet/app/features/user/user-repository-hooks.ts b/apps/web-wallet/app/features/user/user-repository-hooks.ts deleted file mode 100644 index 157793783..000000000 --- a/apps/web-wallet/app/features/user/user-repository-hooks.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { - ReadUserRepository, - WriteUserRepository, -} from '@agicash/wallet-sdk/temporary'; -import { useAccountRepository } from '~/features/accounts/account-repository-hooks'; -import { agicashDbClient } from '~/features/agicash-db/database.client'; - -export function useReadUserRepository() { - return new ReadUserRepository(agicashDbClient); -} - -export function useWriteUserRepository() { - const accountRepository = useAccountRepository(); - return new WriteUserRepository(agicashDbClient, accountRepository); -} diff --git a/apps/web-wallet/app/features/user/user-service-hooks.ts b/apps/web-wallet/app/features/user/user-service-hooks.ts deleted file mode 100644 index 192d9a080..000000000 --- a/apps/web-wallet/app/features/user/user-service-hooks.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { UserService } from '@agicash/wallet-sdk/temporary'; -import { useWriteUserRepository } from './user-repository-hooks'; - -export function useUserService() { - const userRepository = useWriteUserRepository(); - return new UserService(userRepository); -} diff --git a/apps/web-wallet/app/features/wallet/wallet.tsx b/apps/web-wallet/app/features/wallet/wallet.tsx index a76549083..eec622d0c 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,9 +38,8 @@ export const Wallet = ({ children }: PropsWithChildren) => { // Logout handles clearing Sentry user on actual logout. }, [user]); - useHandleSessionExpiry({ - isGuestAccount: user.isGuest, - onLogout: () => { + useHandleSessionEvents({ + onSessionExpired: () => { toast({ title: 'Session expired', description: diff --git a/apps/web-wallet/app/hooks/use-exchange-rate.ts b/apps/web-wallet/app/hooks/use-exchange-rate.ts index ad74a5779..57a530772 100644 --- a/apps/web-wallet/app/hooks/use-exchange-rate.ts +++ b/apps/web-wallet/app/hooks/use-exchange-rate.ts @@ -1,7 +1,4 @@ -import { - type Ticker, - exchangeRateService, -} from '@agicash/wallet-sdk/temporary'; +import { type Ticker, exchangeRateService } from '@agicash/wallet-sdk'; import { type QueryClient, queryOptions, diff --git a/apps/web-wallet/app/hooks/use-long-timeout.ts b/apps/web-wallet/app/hooks/use-long-timeout.ts deleted file mode 100644 index 9b15a5f53..000000000 --- a/apps/web-wallet/app/hooks/use-long-timeout.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useEffect } from 'react'; -import { clearLongTimeout, setLongTimeout } from '~/lib/timeout'; -import { useLatest } from '~/lib/use-latest'; - -/** - * Custom hook that handles long timeouts in React components using the `setLongTimeout API`. - * The code of useLongTimeout is copied from [`usehooks-ts` lib](https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useTimeout/useTimeout.ts) - * and calls to setTimeout and clearTimeout were replaced with long timeout alternatives. - * Support arbitrary delays. - * @param {() => void} callback - The function to be executed when the timeout elapses. - * @param {number | null} delay - The duration (in milliseconds) for the timeout. Set to `null` to clear the timeout. - * @returns {void} This hook does not return anything. - * @public - * @example - * ```tsx - * // Usage of useLongTimeout hook - * useLongTimeout(() => { - * // Code to be executed after the specified delay - * }, 1000); // Set a timeout of 1000 milliseconds (1 second) - * ``` - */ -export function useLongTimeout( - callback: () => void, - delay: number | null, -): void { - // Remember the latest callback if it changes. - const savedCallback = useLatest(callback); - - // Set up the timeout. - useEffect(() => { - // Don't schedule if no delay is specified. - // Note: 0 is a valid value for delay. - if (!delay && delay !== 0) { - return; - } - - const id = setLongTimeout(() => { - savedCallback.current(); - }, delay); - - return () => { - clearLongTimeout(id); - }; - }, [delay]); -} diff --git a/apps/web-wallet/app/hooks/use-money-input.ts b/apps/web-wallet/app/hooks/use-money-input.ts index dd137d1ce..914a31ad3 100644 --- a/apps/web-wallet/app/hooks/use-money-input.ts +++ b/apps/web-wallet/app/hooks/use-money-input.ts @@ -1,7 +1,7 @@ import type { Currency } from '@agicash/money'; import { Money } from '@agicash/money'; import { getDefaultUnit } from '@agicash/wallet-sdk'; -import type { Ticker } from '@agicash/wallet-sdk/temporary'; +import type { Ticker } from '@agicash/wallet-sdk'; import { useEffect, useState } from 'react'; import type { NumpadButton } from '~/components/numpad'; import { getLocaleDecimalSeparator } from '~/lib/locale'; diff --git a/apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts b/apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts index 03764794b..823009d3e 100644 --- a/apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts +++ b/apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts @@ -1,9 +1,13 @@ import type { ExtendedCashuWallet } from '@agicash/cashu'; import type { Currency } from '@agicash/money'; +import { + type LongTimeout, + clearLongTimeout, + setLongTimeout, +} from '@agicash/utils'; import { type MeltQuoteBolt11Response, MeltQuoteState } from '@cashu/cashu-ts'; import { useMutation } from '@tanstack/react-query'; import { useCallback, useEffect, useState } from 'react'; -import { type LongTimeout, clearLongTimeout, setLongTimeout } from '../timeout'; import { useLatest } from '../use-latest'; import { MeltQuoteSubscriptionManager } from './melt-quote-subscription-manager'; diff --git a/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx b/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx index 9dbf0a371..16f1a8614 100644 --- a/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx +++ b/apps/web-wallet/app/routes/_auth.oauth.$provider.tsx @@ -1,7 +1,7 @@ -import { handleGoogleCallback } from '@agicash/opensecret'; import { decodeURLSafe } from '@stablelib/base64'; import { redirect } from 'react-router'; import { LoadingScreen } from '~/features/loading/LoadingScreen'; +import { sdk } from '~/features/shared/sdk.client'; import { invalidateAuthQueries } from '~/features/user/auth'; import { oauthLoginSessionStorage } from '~/features/user/oauth-login-session-storage'; import { toast } from '~/hooks/use-toast'; @@ -38,7 +38,7 @@ export async function clientLoader({ try { switch (provider) { case 'google': - await handleGoogleCallback(code, state, ''); + await sdk.auth.completeGoogleAuth({ code, state }); break; default: { throw new UnsupportedOAuthProviderError( diff --git a/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx index 6d4e35295..68d8af895 100644 --- a/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx +++ b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx @@ -13,7 +13,6 @@ import { SparkReceiveQuoteRepository, SparkReceiveQuoteService, UserService, - WriteUserRepository, decodeCashuToken, getEncryption, } from '@agicash/wallet-sdk/temporary'; @@ -39,6 +38,7 @@ import { encryptionPublicKeyQueryOptions, } from '~/features/shared/encryption-hooks'; import { getQueryClient } from '~/features/shared/query-client'; +import { sdk } from '~/features/shared/sdk.client'; import { sparkMnemonicQueryOptions } from '~/features/shared/spark-query-options'; import { UserCache, getUserFromCacheOrThrow } from '~/features/user/user-hooks'; import { getExchangeRate } from '~/hooks/use-exchange-rate'; @@ -89,12 +89,6 @@ const getServices = async () => { cashuReceiveQuoteService, sparkReceiveQuoteService, ); - const userRepository = new WriteUserRepository( - agicashDbClient, - accountRepository, - ); - const userService = new UserService(userRepository); - const claimCashuTokenService = new ClaimCashuTokenService( accountService, receiveSwapService, @@ -105,7 +99,7 @@ const getServices = async () => { (ticker) => getExchangeRate(queryClient, ticker), ); - return { claimCashuTokenService, accountRepository, userService }; + return { claimCashuTokenService, accountRepository }; }; /** @@ -115,7 +109,6 @@ const getServices = async () => { * UX, so it lives here rather than in the claim service. */ async function trySetReceiveAccountAsDefault( - userService: UserService, queryClient: QueryClient, user: User, account: Account, @@ -127,7 +120,8 @@ async function trySetReceiveAccountAsDefault( return; } try { - const updatedUser = await userService.setDefaultAccount(user, account, { + const updatedUser = await sdk.user.setDefaultAccount({ + account, setDefaultCurrency: true, }); new UserCache(queryClient).set(updatedUser); @@ -173,8 +167,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { if (claimTo) { const user = getUserFromCacheOrThrow(); - const { claimCashuTokenService, accountRepository, userService } = - await getServices(); + const { claimCashuTokenService, accountRepository } = await getServices(); const queryClient = getQueryClient(); const accounts = await queryClient.fetchQuery( accountsQueryOptions({ userId: user.id, accountRepository }), @@ -195,7 +188,6 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { accountsCache.upsert(account); } await trySetReceiveAccountAsDefault( - userService, queryClient, user, result.receiveAccount, diff --git a/apps/web-wallet/app/routes/_protected.tsx b/apps/web-wallet/app/routes/_protected.tsx index d0214f41c..5fb4e3cb7 100644 --- a/apps/web-wallet/app/routes/_protected.tsx +++ b/apps/web-wallet/app/routes/_protected.tsx @@ -1,9 +1,10 @@ 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, - WriteUserRepository, + UpsertUserRepository, ensureBreezWasm, getEncryption, } from '@agicash/wallet-sdk/temporary'; @@ -27,11 +28,7 @@ import { sparkIdentityPublicKeyQueryOptions, sparkMnemonicQueryOptions, } from '~/features/shared/spark-query-options'; -import { - type AuthUser, - authQueryOptions, - useAuthState, -} from '~/features/user/auth'; +import { authQueryOptions, useAuthState } from '~/features/user/auth'; import { pendingGiftCardMintTermsStorage, pendingWalletTermsStorage, @@ -121,14 +118,14 @@ const ensureUserData = async ( getSparkWalletMnemonic, { storageDir: './.spark-data', apiKey: breezApiKey }, ); - const writeUserRepository = new WriteUserRepository( + const upsertUserRepository = new UpsertUserRepository( agicashDbClient, accountRepository, ); const { user: upsertedUser, accounts } = await withRetry({ fn: () => - writeUserRepository.upsert({ + upsertUserRepository.upsert({ id: authUser.id, email: authUser.email, emailVerified: authUser.email_verified, 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/apps/web-wallet/tsconfig.json b/apps/web-wallet/tsconfig.json index 746f3718c..ce5755423 100644 --- a/apps/web-wallet/tsconfig.json +++ b/apps/web-wallet/tsconfig.json @@ -18,7 +18,7 @@ "paths": { "~/*": ["./app/*"], "supabase/database.types": [ - "../../packages/wallet-sdk/supabase/database.types.ts" + "../../packages/wallet-sdk/db/supabase/database.types.ts" ] }, diff --git a/biome.jsonc b/biome.jsonc index 57bbcea0b..895ef9a20 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -8,7 +8,7 @@ }, "files": { "ignoreUnknown": true, - "ignore": ["packages/wallet-sdk/supabase/database.types.ts"] + "ignore": ["packages/wallet-sdk/db/supabase/database.types.ts"] }, "formatter": { "enabled": true, diff --git a/bun.lock b/bun.lock index 061a01f8e..b16b90afe 100644 --- a/bun.lock +++ b/bun.lock @@ -60,7 +60,7 @@ "embla-carousel-react": "8.5.2", "express": "5.2.1", "isbot": "5.1.34", - "jwt-decode": "4.0.0", + "jwt-decode": "catalog:", "ky": "catalog:", "lucide-react": "0.468.0", "qrcode.react": "4.2.0", @@ -180,6 +180,7 @@ "dependencies": { "@noble/ciphers": "catalog:", "@noble/hashes": "catalog:", + "jwt-decode": "catalog:", "type-fest": "catalog:", "zod": "catalog:", }, @@ -207,7 +208,7 @@ "@stablelib/base64": "catalog:", "@supabase/supabase-js": "2.95.2", "big.js": "catalog:", - "jwt-decode": "4.0.0", + "jwt-decode": "catalog:", "ky": "catalog:", "type-fest": "catalog:", "zod": "catalog:", @@ -224,7 +225,7 @@ "@sentry/core@10.42.0": "patches/@sentry%2Fcore@10.42.0.patch", }, "catalog": { - "@agicash/opensecret": "0.1.0", + "@agicash/opensecret": "1.0.0-rc.0", "@cashu/cashu-ts": "3.6.1", "@noble/ciphers": "1.3.0", "@noble/curves": "1.9.7", @@ -236,6 +237,7 @@ "@types/bun": "1.3.11", "big.js": "7.0.1", "dotenv": "16.4.7", + "jwt-decode": "4.0.0", "jwt-encode": "1.0.1", "ky": "1.14.3", "type-fest": "5.4.3", @@ -257,7 +259,7 @@ "@agicash/money": ["@agicash/money@workspace:packages/money"], - "@agicash/opensecret": ["@agicash/opensecret@0.1.0", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-5cbr7hgKlrY3aLm2RPrk9+xfserhEgG60V+zxtABcD0ZG7Gw6AgeMUuijWyp8mAqZ7G+bZr4Us0ZvGq63ISzog=="], + "@agicash/opensecret": ["@agicash/opensecret@1.0.0-rc.0", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" } }, "sha512-BbXdFQp1NTug5jA7SHcZDP0a9qRmwl36pF+bgWv69ohAZMdYH7k4WiEbLgRBLoYXGj2VyyUEene0n3GIbDmHMQ=="], "@agicash/qr-scanner": ["@agicash/qr-scanner@0.1.2", "", { "dependencies": { "zxing-wasm": "2.2.4" } }, "sha512-7qNJoDRqBbHSm12qZ1l0O2JDof0sXNiooebsCXGZOsJpCLBYF9bAJXuy/4Q/jFwbiH0knFZq8myRI5v6IFqpEQ=="], diff --git a/docs/superpowers/plans/2026-07-09-wallet-sdk-auth-slice.md b/docs/superpowers/plans/2026-07-09-wallet-sdk-auth-slice.md new file mode 100644 index 000000000..ea5fccfb9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-wallet-sdk-auth-slice.md @@ -0,0 +1,2740 @@ +# Wallet SDK Auth & User Slice (Step 5) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement step 5 of the no-cache SDK migration: the first runtime `Sdk` class (`AgicashSdk.create(config)`) with working `auth`, `user`, and `events` namespaces, adopt the React-agnostic `@agicash/opensecret@1.0.0-rc.0`, settle the step-5 contract placeholders, and flip the web app's auth + user imports from `/temporary` to `sdk.*`. + +**Architecture:** The SDK configures Open Secret itself inside `create(config)` (host passes `apiUrl`/`clientId`/storage adapter), builds its own Supabase client with an internal Open Secret → Supabase token getter, and keeps an in-memory `AuthSession` snapshot maintained by `AuthService` (restore on `init()`, refresh after every auth verb, refresh-token expiry timer with guest auto-extend and `auth.session-expired` emission). The web keeps thin glue: TanStack `authQueryOptions` wrapping `sdk.init()` + `sdk.auth.getSession()`, plus Sentry/session-hint-cookie/navigation concerns. Specs: `docs/superpowers/specs/2026-06-24-wallet-sdk-no-cache-production-design.md` (parent) and `docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md` (contract). + +**Tech Stack:** TypeScript (bun workspace monorepo), React Router v7, TanStack Query v5 (web only), `@agicash/opensecret@1.0.0-rc.0`, `@supabase/supabase-js`, `jwt-decode`, `zod/mini`, `bun test` for SDK unit tests. + +## Global Constraints + +- **SDK is React-agnostic and headless-safe.** No file under `packages/wallet-sdk/` may import `react`, `@tanstack/react-query`, or read `window`/`document`/`localStorage`/`sessionStorage`/cookies. Host state enters only via `create(config)` ports (`config.auth.storage`). +- **`@agicash/opensecret` is pinned exact** in the root `workspaces.catalog`: `"1.0.0-rc.0"` after Task 1. Verified: the RC's storage keys are unchanged (`access_token`, `refresh_token`), `browserStorage` maps `persistent`→`localStorage` and `session`→`sessionStorage` lazily, and the package has **no React peer deps**. Existing user sessions survive the upgrade. +- **The contract types in `packages/wallet-sdk/sdk.ts` are the source of truth.** `AuthStorage` binds *verbatim* to the RC's `StorageProvider` shape (scopes `persistent`/`session`, methods `getItem`/`setItem`/`removeItem`, sync-or-async returns) so the SDK ships no adapter over it. +- **`create()` is sync with no I/O; `init()` is memoized single-flight.** In this slice `init()` = session restore only. The web keeps calling `ensureBreezWasm()` from `/temporary` (entry.client + `_protected` middleware) — WASM folds into `init()` when the first Spark namespace lands (decision, see Decision Record). +- **Web-only concerns stay in the web glue:** Sentry user tracking, session-hint cookie, TanStack invalidation, navigation/revalidation, feature-flag load/reset, OAuth deep-link restore (`oauthLoginSessionStorage`), pending-terms storage. +- **Package manager is `bun`; never npm/npx/yarn/pnpm.** In this environment `bun` is NOT on PATH — prefix commands with `export PATH="$PWD/.devenv/profile/bin:$PATH"` (run from the repo root). +- **Branch `sdk/auth-slice` off `master`.** Conventional commits (`feat(wallet-sdk):`, `refactor(web-wallet):`, …). Run `bun run fix:all && bun run typecheck` before every commit — `fix:all` is biome lint/format ONLY (it does not typecheck, despite CLAUDE.md's description); `typecheck` runs each package's `tsc`. +- **Behavior parity is the review bar.** Every auth flow (email login/signup, guest signup + re-signin, Google OAuth, verify email, convert guest, sign out, session expiry) must behave as on `master`; deltas are listed in "Accepted behavior deltas" below and nothing else may change. + +## Decision Record (resolved with maintainer, 2026-07-09) + +| # | Decision | +|---|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| A1 | **`ensureUserData` (user + default-accounts upsert in `_protected.tsx`) stays in the web via `/temporary` this slice.** It constructs `AccountRepository` (accounts domain); the accounts slice (step 6) gives it a contract home. Step 5 flips every other user-domain consumer. | +| A2 | **Session-expiry machinery moves into the SDK now.** `AuthService` arms a long-timeout at refresh-token expiry−5s: guests are auto-re-signed-in internally; full accounts get the session cleared + `auth.session-expired` emitted. This requires the minimal typed event emitter now (`auth.session-expired` is explicitly not realtime-backed). Web's `useHandleSessionExpiry` is replaced by an event subscription. | +| A3 | **`init()` = session restore only** this slice (see Global Constraints). Rationale: gating `authQueryOptions` on a combined restore+WASM `init()` would newly break login pages under WASM-unavailable (iOS Lockdown Mode) — a regression. No Spark ops exist on the contract until steps 11/15. | +| A4 | **Guest password generation: SDK default + optional host override.** `SdkConfig.auth.generateGuestPassword?: () => Promise` — resolving null falls through to the SDK's internal CSPRNG generator, which is the ONLY generator (the web's `~/lib/password-generator.ts` is deleted). The web passes a two-line bridge to `window.getMockPassword`, preserving the e2e seam with zero e2e changes. *(Refined 2026-07-09 from a full-replacement port, removing the duplicated generator body.)* | +| A5 | **`AuthUser` settles to Open Secret's user shape verbatim:** `export type AuthUser = UserResponse['user']` (id, name, email?, email_verified, login_method, created_at, updated_at). The web already consumes exactly these fields (`_protected.tsx`, `_auth.tsx`). | +| A6 | **The SDK builds its own Supabase client** (contract decision #1 from #1164) with an internal memoized third-party-token getter. Two Supabase clients coexist during migration: web's (`database.client.ts` — unmigrated domains + realtime) and the SDK's (auth/user namespaces). Accepted transitional cost; each caches its own token. | +| A7 | **`guestAccountStorage` moves into the SDK** behind `config.auth.storage.persistent` (same `guestAccount` localStorage key → existing guest creds survive). `oauth-login-session-storage`, `pending-terms-storage`, `session-hint-cookie`, and `shared/auth.ts` (`isLoggedIn` for the web's own DB client) **stay in the web**. | +| A8 | **`setLongTimeout`/`clearLongTimeout` move from `~/lib/timeout` to `@agicash/utils`** (needed by both web and SDK). | +| A9 | **`WriteUserRepository` drops `accountRepository` from its constructor**; `upsert()` takes it as a parameter (only `upsert` uses it). Lets the SDK construct the user namespace without the accounts graph. `UserService.setDefaultAccount` loosens `account` to `Pick`. | +| A10 | Step-5 params settle as: `AcceptTermsParams = { walletTerms?: boolean; giftCardTerms?: boolean }`, `SetDefaultAccountParams = { accountId: string; setDefaultCurrency?: boolean }`, `SetDefaultCurrencyParams = { currency: Currency }`. | +| A11 | The `_protected.receive.cashu_.token.tsx` default-account write flips to `sdk.user.setDefaultAccount({ accountId, setDefaultCurrency: true })` now (it is user-domain surface). `UserService`/`ReadUserRepository` remain exported from `/temporary` for their **static** helpers (`isDefaultAccount`, `getExtendedAccounts`, `toUser` for the realtime row mapper) until steps 6/18. | +| A12 | `sdk.featureFlags` namespace is **not** wired this slice (flags stay web-configured via `configureFeatureFlags(agicashDbClient)`); it needs no auth work and has no assigned step — it lands alongside a later slice. | +| A13 | **`auth.session-refreshed` added to the event map** (2026-07-09, maintainer-approved): fires only on SDK-initiated session refreshes — today exactly the guest auto-extension; host-initiated verbs never fire it (same principle as `auth.session-expired`'s "the host knows its own logout"). Rationale trail: PR #1164 floated `auth.changed` and landed the narrower `auth.session-expired`, but the recorded reasoning covered sign-out, not SDK-internal extension, and the hint-cookie consequence was never discussed there — an unconsidered gap, and the contract states adding events is non-breaking. The web's session-events hook invalidates the auth query on it, restoring master's cookie freshness after a guest auto-extend. | + +## Accepted behavior deltas (everything else is parity) + +1. **Guest extend failure:** master does `removeKeys()` + `window.location.reload()`; now the SDK falls through to the death path (session cleared + `auth.session-expired`) and the web shows the toast + redirects. Strictly better UX, same terminal state. +2. **Session-hint cookie refresh after guest auto-extend:** restored to master behavior via `auth.session-refreshed` (A13) — the web's session-events hook invalidates the auth query, whose refetch re-sets the cookie with the extended expiry, exactly like master's extend-through-invalidation. Residual: on pages where the hook isn't mounted (public/marketing — where master never armed an extension timer at all, see delta 6), the `staleTime` pinned to fetch-time expiry re-syncs the cookie on the next focus/mount refetch after the old expiry; a background tab there can still hit one cold-load `/home` bounce. Auth itself is unaffected (the cookie is a non-authoritative SSR hint). +3. **Sentry `setUser` timing:** master sets `{id: sub}` from the raw JWT before `fetchUser`; the glue still does this, then upgrades to `{id, isGuest}` after restore — unchanged ordering, but the fetch itself now happens inside `sdk.init()`. +4. **Sign-out memo-clear ordering:** master clears the SDK module memos (`clearSparkWallets`, `clearAgicashMintAuthToken`) last, after `queryClient.clear()`; now they clear at session end inside `sdk.auth.signOut()`. Post-clear repopulation by an in-flight request remains possible for the spark-wallet and mint-CAT memos **under either ordering** (master's clear-last only narrowed the window) but is harmless by construction: the Supabase token cache is generation-fenced (cannot cache post-reset), spark-wallet entries are keyed by the user's mnemonic (never served cross-user), and the mint CAT is wiped again when a *different* user's session begins (the `lastUserId` guard in `applySessionFromServer`). Residual: a same-user memo staying warm across their own sign-out/sign-in (benign), and one theoretical sliver — a CAT fetch from the previous session resolving *after* the next user's sign-in — tracked in Deferred. +5. **`authQueryOptions` is snapshot-driven:** master's queryFn called `fetchUser()` on every refetch; now a refetch awaits the memoized `sdk.init()` and reads the in-memory session snapshot. Every existing invalidation site is preceded by an SDK auth verb that already refreshed the snapshot, so reads stay fresh — but future code calling `invalidateAuthQueries()` with no preceding auth verb reads the snapshot, not the server. (A session-ending failure clears the restore memo, so the next invalidation re-restores from storage — a transient post-login `fetchUser` blip recovers on the glue's own invalidation, like master.) +6. **Expiry-timer scope:** master armed the refresh-expiry timer only under the protected layout's `` (which wraps every protected page, verify-email and accept-terms included); the SDK arms it whenever a session exists — now also on public/marketing pages that read auth state. A full-account expiry there ends the session in place without a toast (the subscriber lives in `Wallet`); master had no timer on those pages and logged out on the next protected navigation. Same terminal state. +7. **`setDefaultAccount` reads the account row (and writes column-minimally):** master passed the cached user + account objects and wrote all three default columns, echoing unchanged fields from the cache — which could revert a concurrent change from another device. Now the update writes only the changed columns (no user read, no echo, lost-update-proof) and reads just the account row by PK to derive the per-currency column server-truthfully. One extra lightweight read on the settings and token-claim paths; strictly safer writes. + +## Deferred (tracked, out of scope) + +- `ensureUserData` bootstrap → step 6 (A1). `_protected.tsx` keeps `/temporary` imports for it. +- `getEncryption` + the encryption/cryptography key plumbing (`encryption-hooks.ts`, `cryptography-hooks.ts`, `/temporary`'s `getEncryption`): the contract's migration mapping assigns these "internal (auth slice)", but their remaining web consumers are the not-yet-migrated domains and `ensureUserData` — they go SDK-internal with the accounts slice (step 6, alongside A1) at the earliest. +- `ReadUserDefaultAccountRepository` has NO web consumers (its only caller is the SDK-internal `lightning-address-service`); it stays on `/temporary`'s export list untouched and gets dropped there in step 17/19. +- `ReadUserRepository.toUser` in `useUserChangeHandlers` (realtime row mapping) → step 18. +- Breez WASM into `init()` → first Spark slice (A3). +- `sdk.featureFlags` wiring (A12). +- Deleting `UserService`/user repos from `/temporary` → step 6/18 once the statics find contract homes. +- Exposing refresh-token expiry on `getSession()` (so the web glue stops reading Open Secret's storage keys for the hint cookie and `staleTime`) → revisit with the step-18 session/events work. +- SDK-thrown error typing: the user repos throw plain `Error` today, so `sdk.user.*` doesn't yet honor the contract's "everything the SDK throws extends `SdkError`". Wrapping repo errors is a cross-domain pass — tracked for step 17/19. (Web behavior is unaffected: unknown errors already get the generic destructive toast.) +- Generation-fencing the mint-CAT memo (like the Supabase token getter): closes the last theoretical repopulation sliver — a CAT fetch from the previous session resolving after the next user's sign-in (i.e., surviving both the sign-out and the sign-in). Pre-existing on master with the same window; sub-second across two user actions. (Cancellation is not an alternative: the fetch happens inside `@agicash/opensecret`'s encrypted tunnel, which exposes no AbortSignal — and an abort landing after the response is queued can't prevent the write anyway; the fence is the correctness mechanism.) +- Session-scoped AbortController for SDK-internal reads: created per session, aborted on `endSession()`, signal threaded into the namespaces' Supabase queries (the repos already accept `abortSignal`), so in-flight namespace promises reject at session end instead of resolving into a dead session. Implements the contract's `dispose()` teardown semantics ("still-pending namespace promises reject with a typed `SdkError`") → step-18/dispose work. + +--- + +## File Structure + +**Created (packages/):** +- `packages/wallet-sdk/lib/events.ts` — `WalletEventEmitter` (typed `on`/`emit`) +- `packages/wallet-sdk/lib/events.test.ts` +- `packages/wallet-sdk/lib/password.ts` — CSPRNG `generateRandomPassword` +- `packages/wallet-sdk/domain/user/guest-account-storage.ts` — port-backed guest creds +- `packages/wallet-sdk/domain/user/guest-account-storage.test.ts` +- `packages/wallet-sdk/domain/user/auth-service.ts` — `AuthService implements AuthApi` +- `packages/wallet-sdk/domain/user/auth-service.test.ts` +- `packages/wallet-sdk/domain/user/user-api.ts` — `createUserApi(deps): UserApi` +- `packages/wallet-sdk/db/client.ts` — `createAgicashDbClient` +- `packages/wallet-sdk/db/supabase-session.ts` — memoized third-party-token getter +- `packages/wallet-sdk/db/supabase-session.test.ts` +- `packages/wallet-sdk/agicash-sdk.ts` — `class AgicashSdk` +- `packages/utils/src/timeout.ts` (moved from web) + +**Modified (SDK):** +- `packages/wallet-sdk/sdk.ts` — settle `AuthStorage`, `AuthUser`, 3 param types; add `generateGuestPassword` port +- `packages/wallet-sdk/index.ts` — export `AgicashSdk` +- `packages/wallet-sdk/lib/error.ts` — add internal `NoSessionError` +- `packages/wallet-sdk/domain/user/user-repository.ts` — A9 refactor +- `packages/wallet-sdk/domain/user/user-service.ts` — A9 param loosening +- Root `package.json` — catalog bump to `1.0.0-rc.0` + +**Created (web):** +- `apps/web-wallet/app/features/shared/sdk.client.ts` — config assembly + `sdk` singleton + +**Modified (web):** +- `apps/web-wallet/app/entry.client.tsx` — drop `configure()`, import `sdk` +- `apps/web-wallet/app/features/agicash-db/database.client.ts` — export `supabaseUrl`/`supabaseAnonKey` +- `apps/web-wallet/app/features/user/auth.ts` — rewrite as thin glue over `sdk.auth` +- `apps/web-wallet/app/features/user/user-hooks.tsx` — flip to `sdk.user`/`sdk.auth` +- `apps/web-wallet/app/features/wallet/wallet.tsx` — expiry hook swap +- `apps/web-wallet/app/routes/_protected.tsx` — `AuthUser` import + A9 call-site +- `apps/web-wallet/app/routes/_auth.oauth.$provider.tsx` — `completeGoogleAuth` +- `apps/web-wallet/app/features/signup/verify-email.ts` — `sdk.auth.verifyEmail` +- `apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx` — A11 +- `apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts`, `apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts`, `apps/web-wallet/app/hooks/use-long-timeout.ts` — timeout import repoint (the hook is later deleted in Task 11) + +**Deleted (web):** +- `apps/web-wallet/app/features/user/guest-account-storage.ts` (→ SDK) +- `apps/web-wallet/app/features/user/user-repository-hooks.ts` +- `apps/web-wallet/app/features/user/user-service-hooks.ts` +- `apps/web-wallet/app/lib/timeout.ts` (→ `@agicash/utils`) +- `apps/web-wallet/app/hooks/use-long-timeout.ts` (its only consumer is the old `useHandleSessionExpiry`; dead after Task 11) +- `apps/web-wallet/app/lib/password-generator.ts` (→ SDK `lib/password.ts` as the only generator; the web keeps just the `window.getMockPassword` bridge in `sdk.client.ts` — A4) + +--- + +### Task 1: Adopt `@agicash/opensecret@1.0.0-rc.0` + +**Files:** +- Modify: `package.json` (repo root, `workspaces.catalog`) +- Modify: `apps/web-wallet/package.json`, `packages/wallet-sdk/package.json` (`jwt-decode` → `catalog:`) +- Modify: `apps/web-wallet/app/entry.client.tsx:36-39` (temporary `storage` arg to stay green) + +**Interfaces:** +- Produces: the RC API for all later tasks — `configure({ apiUrl, clientId, storage })`, `browserStorage`, `StorageProvider`/`KeyValueStore` types, and the unchanged fn set (`signIn`, `signUp`, `signUpGuest`, `signInGuest`, `signOut`, `fetchUser`, `verifyEmail`, `requestNewVerificationCode`, `convertGuestToUserAccount`, `initiateGoogleAuth`, `handleGoogleCallback`, `generateThirdPartyToken`, `getPrivateKey`, `getPrivateKeyBytes`, `getPublicKey`, `signMessage`). + +- [ ] **Step 1: Bump the catalog version (+ fold `jwt-decode` into the catalog)** + +In root `package.json`, change: + +```json +"@agicash/opensecret": "0.1.0", +``` + +to: + +```json +"@agicash/opensecret": "1.0.0-rc.0", +``` + +and add `"jwt-decode": "4.0.0"` to the same `workspaces.catalog` block, switching the two consumers (`apps/web-wallet/package.json`, `packages/wallet-sdk/package.json`) from `"jwt-decode": "4.0.0"` to `"jwt-decode": "catalog:"` — repo rule: a dep shared by ≥2 packages lives in the catalog. + +- [ ] **Step 2: Install** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun install` +Expected: lockfile updates; no peer-dep warnings (RC has no React peer deps). + +- [ ] **Step 3: Satisfy the now-required `storage` option in `entry.client.tsx`** + +The RC's `configure()` requires `storage`. In `apps/web-wallet/app/entry.client.tsx`, change: + +```ts +import { configure } from '@agicash/opensecret'; +``` +```ts +configure({ + apiUrl: openSecretApiUrl, + clientId: openSecretClientId, +}); +``` + +to: + +```ts +import { browserStorage, configure } from '@agicash/opensecret'; +``` +```ts +configure({ + apiUrl: openSecretApiUrl, + clientId: openSecretClientId, + storage: browserStorage, +}); +``` + +(This is transitional; Task 10 moves `configure()` into `AgicashSdk.create()` and reverts this file further.) + +- [ ] **Step 4: Verify types + boot** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` +Expected: PASS (all used fn signatures are unchanged in the RC). + +Smoke: `bun run dev`, open `http://127.0.0.1:3000`, log in (or guest signup), confirm wallet loads. A pre-existing session in localStorage must still be logged in (keys unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add package.json bun.lock apps/web-wallet/package.json packages/wallet-sdk/package.json apps/web-wallet/app/entry.client.tsx +git commit -m "feat(deps): adopt React-agnostic @agicash/opensecret 1.0.0-rc.0" +``` + +--- + +### Task 2: Move the long-timeout util to `@agicash/utils` + +**Files:** +- Create: `packages/utils/src/timeout.ts` (content = current `apps/web-wallet/app/lib/timeout.ts`, verbatim) +- Modify: `packages/utils/src/index.ts` (add `export * from './timeout';`) +- Delete: `apps/web-wallet/app/lib/timeout.ts` +- Modify: `apps/web-wallet/app/hooks/use-long-timeout.ts` (import `{ clearLongTimeout, setLongTimeout }` from `@agicash/utils`) +- Modify: `apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts:~41` (same repoint) +- Modify: `apps/web-wallet/app/lib/cashu/melt-quote-subscription.ts:6` (imports the util RELATIVELY: `from '../timeout'` → `from '@agicash/utils'`) + +**Interfaces:** +- Produces: `setLongTimeout(callback: () => void, delay: number): LongTimeout`, `clearLongTimeout(timeout: LongTimeout): void`, `type LongTimeout` from `@agicash/utils` — consumed by Task 6's `AuthService`. + +- [ ] **Step 1: Move the file** — `git mv` semantics: create `packages/utils/src/timeout.ts` with the exact current content of `apps/web-wallet/app/lib/timeout.ts`, delete the web file, add the barrel export. + +- [ ] **Step 2: Repoint the three web importers** — `use-long-timeout.ts` and `cashu-receive-quote-hooks.ts` import via the `~/lib/timeout` alias; `lib/cashu/melt-quote-subscription.ts:6` imports via the RELATIVE specifier `'../timeout'`. Replace all three with `@agicash/utils`. Keep imported names identical (`type LongTimeout`, `clearLongTimeout`, `setLongTimeout`). + +- [ ] **Step 3: Verify** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` +Expected: PASS. `grep -rn "from '.*timeout'" apps/web-wallet/app | grep -v '@agicash/utils'` returns nothing (catches alias AND relative specifiers). + +- [ ] **Step 4: Commit** + +```bash +git add packages/utils apps/web-wallet +git commit -m "refactor(utils): move setLongTimeout/clearLongTimeout to @agicash/utils" +``` + +--- + +### Task 3: Settle the step-5 contract types in `sdk.ts` + +**Files:** +- Modify: `packages/wallet-sdk/sdk.ts` + +**Interfaces:** +- Produces (consumed by every later task): + - `AuthKeyValueStore` = `{ getItem(key): string | null | Promise; setItem(key, value): void | Promise; removeItem(key): void | Promise }` + - `AuthStorage` = `{ persistent: AuthKeyValueStore; session: AuthKeyValueStore }` + - `SdkConfig['auth']` gains `generateGuestPassword?: () => Promise` + - `AuthUser = UserResponse['user']` (from `@agicash/opensecret`) + - `AcceptTermsParams`, `SetDefaultAccountParams`, `SetDefaultCurrencyParams` per A10. + +- [ ] **Step 1: Replace the `AuthStorage` placeholder** (`sdk.ts:60-65`) with the RC-verbatim shape: + +```ts +/** + * Minimal key/value store — the Web Storage API subset the SDK persists auth + * state through. Methods may be sync (window.localStorage) or async (React + * Native AsyncStorage, SQLite); the SDK always awaits results. Matches the + * @agicash/opensecret StorageProvider interface verbatim, so one host object + * backs both. + */ +export type AuthKeyValueStore = { + getItem(key: string): string | null | Promise; + setItem(key: string, value: string): void | Promise; + removeItem(key: string): void | Promise; +}; + +/** + * Host-backed session persistence. `persistent` must survive restarts (auth + * tokens, guest credentials); `session` is per-app-session (attestation + * handshake material). Browser hosts map them to localStorage/sessionStorage. + */ +export type AuthStorage = { + persistent: AuthKeyValueStore; + session: AuthKeyValueStore; +}; +``` + +- [ ] **Step 2: Add the guest-password port** to `SdkConfig.auth`: + +```ts + auth: { + apiUrl: string; + clientId: string; + storage: AuthStorage; + /** + * Host override for guest credential generation; resolve null to use the + * SDK's CSPRNG generator. Test seam (the web bridges its e2e password + * mock through it). + */ + generateGuestPassword?: () => Promise; + }; +``` + +- [ ] **Step 3: Settle `AuthUser`** — replace `export type AuthUser = unknown; // settles in step 5 (auth & user)` with: + +```ts +import type { UserResponse } from '@agicash/opensecret'; +// … +export type AuthUser = UserResponse['user']; +``` + +(import goes at the top with the other imports). + +- [ ] **Step 4: Settle the three param placeholders** — replace the `unknown` lines at the bottom: + +```ts +export type AcceptTermsParams = { + walletTerms?: boolean; + giftCardTerms?: boolean; +}; + +export type SetDefaultAccountParams = { + accountId: string; + /** Also switch the user's default currency to the account's currency. */ + setDefaultCurrency?: boolean; +}; + +export type SetDefaultCurrencyParams = { + currency: Currency; +}; +``` + +Add `Currency` to the existing `@agicash/money` type import (`import type { Currency, Money } from '@agicash/money';`). + +- [ ] **Step 5: Add `auth.session-refreshed` to `WalletEventMap`** (contract addition per A13 — adding events is non-breaking per the map's own invariant). Insert directly after the `'auth.session-expired'` entry in `sdk.ts`: + +```ts + /** + * The SDK refreshed the session without a host-initiated verb — today: + * guest auto-extension at refresh-token expiry. Host-initiated verbs never + * fire it (the host knows its own actions). Hosts re-sync session-derived + * state from it (the web: auth query + session-hint cookie). + */ + 'auth.session-refreshed': Record; +``` + +Append the same entry with a one-line A13 note to the event map in `docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md`, so the prose contract stays in sync with `sdk.ts`. + +- [ ] **Step 6: Annotate `init()`'s contract JSDoc for the migration window** — the doc-comment on `Sdk['init']` promises session restore AND the Breez WASM load, but step 5 ships restore-only (A3). Append one line to that JSDoc so contract readers aren't misled mid-migration: + +```ts + * Migration note: until the first Spark slice lands, `init()` performs + * session restore only — the WASM load still runs host-side. +``` + +- [ ] **Step 7: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. + +```bash +git add packages/wallet-sdk/sdk.ts +git commit -m "feat(wallet-sdk): settle the step-5 contract types (auth storage, AuthUser, user params)" +``` + +--- + +### Task 4: Typed event emitter (`lib/events.ts`) + +**Files:** +- Create: `packages/wallet-sdk/lib/events.ts` +- Test: `packages/wallet-sdk/lib/events.test.ts` + +**Interfaces:** +- Consumes: `WalletEventMap`, `WalletEvents`, `Logger` types from `../sdk`. +- Produces: `class WalletEventEmitter implements WalletEvents` with `on(event, handler): () => void` and `emit(event, payload): void` — consumed by Tasks 6 and 8. + +- [ ] **Step 1: Write the failing tests** + +```ts +// packages/wallet-sdk/lib/events.test.ts +import { describe, expect, it } from 'bun:test'; +import { WalletEventEmitter } from './events'; + +describe('WalletEventEmitter', () => { + it('delivers payloads to subscribed handlers', () => { + const emitter = new WalletEventEmitter(); + const received: unknown[] = []; + emitter.on('auth.session-expired', (payload) => received.push(payload)); + + emitter.emit('auth.session-expired', {}); + + expect(received).toEqual([{}]); + }); + + it('stops delivering after unsubscribe', () => { + const emitter = new WalletEventEmitter(); + let calls = 0; + const unsubscribe = emitter.on('auth.session-expired', () => { + calls += 1; + }); + + unsubscribe(); + emitter.emit('auth.session-expired', {}); + + expect(calls).toBe(0); + }); + + it('isolates a throwing handler and reports it to the logger', () => { + const errors: string[] = []; + const emitter = new WalletEventEmitter({ + debug: () => {}, + info: () => {}, + warn: () => {}, + error: (message) => { + errors.push(message); + }, + }); + let secondHandlerRan = false; + emitter.on('auth.session-expired', () => { + throw new Error('boom'); + }); + emitter.on('auth.session-expired', () => { + secondHandlerRan = true; + }); + + emitter.emit('auth.session-expired', {}); + + expect(secondHandlerRan).toBe(true); + expect(errors).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && cd packages/wallet-sdk && bun test lib/events.test.ts` +Expected: FAIL — `events.ts` does not exist. + +- [ ] **Step 3: Implement** + +```ts +// packages/wallet-sdk/lib/events.ts +import type { Logger, WalletEventMap, WalletEvents } from '../sdk'; + +type Handler = (payload: never) => void; + +export class WalletEventEmitter implements WalletEvents { + private readonly handlers = new Map>(); + + constructor(private readonly logger?: Logger) {} + + on( + event: K, + handler: (payload: WalletEventMap[K]) => void, + ): () => void { + const set = this.handlers.get(event) ?? new Set(); + set.add(handler as Handler); + this.handlers.set(event, set); + return () => { + set.delete(handler as Handler); + }; + } + + emit( + event: K, + payload: WalletEventMap[K], + ): void { + const set = this.handlers.get(event); + if (!set) { + return; + } + for (const handler of set) { + try { + (handler as (payload: WalletEventMap[K]) => void)(payload); + } catch (error) { + this.logger?.error(`Event handler for ${event} threw`, error); + } + } + } +} +``` + +- [ ] **Step 4: Run tests** — same command, expected: 3 pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/wallet-sdk/lib/events.ts packages/wallet-sdk/lib/events.test.ts +git commit -m "feat(wallet-sdk): add the typed wallet event emitter" +``` + +--- + +### Task 5: SDK password generator + guest-account storage + +**Files:** +- Create: `packages/wallet-sdk/lib/password.ts` +- Create: `packages/wallet-sdk/domain/user/guest-account-storage.ts` +- Test: `packages/wallet-sdk/domain/user/guest-account-storage.test.ts` + +**Interfaces:** +- Consumes: `AuthKeyValueStore`, `Logger` from `../../sdk`; `safeJsonParse` from `@agicash/utils`. +- Produces: + - `generateRandomPassword(length?: number): Promise` (CSPRNG, no window access) + - `type GuestAccountDetails = { id: string; password: string }` + - `createGuestAccountStorage(store: AuthKeyValueStore, logger?: Logger): GuestAccountStorage` where `GuestAccountStorage = { get(): Promise; store(details): Promise; clear(): Promise }` — consumed by Task 6. + +- [ ] **Step 1: Write the failing storage tests** + +```ts +// packages/wallet-sdk/domain/user/guest-account-storage.test.ts +import { describe, expect, it } from 'bun:test'; +import type { AuthKeyValueStore } from '../../sdk'; +import { createGuestAccountStorage } from './guest-account-storage'; + +const createMemoryStore = (): AuthKeyValueStore & { data: Map } => { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +describe('guestAccountStorage', () => { + it('round-trips guest account details under the legacy key', async () => { + const store = createMemoryStore(); + const storage = createGuestAccountStorage(store); + + await storage.store({ id: 'guest-1', password: 'pw' }); + + expect(store.data.has('guestAccount')).toBe(true); + expect(await storage.get()).toEqual({ id: 'guest-1', password: 'pw' }); + }); + + it('returns null when nothing is stored', async () => { + const storage = createGuestAccountStorage(createMemoryStore()); + expect(await storage.get()).toBeNull(); + }); + + it('returns null for corrupt or invalid data', async () => { + const store = createMemoryStore(); + store.data.set('guestAccount', 'not-json'); + const storage = createGuestAccountStorage(store); + expect(await storage.get()).toBeNull(); + + store.data.set('guestAccount', JSON.stringify({ id: 42 })); + expect(await storage.get()).toBeNull(); + }); + + it('clear removes the stored account', async () => { + const store = createMemoryStore(); + const storage = createGuestAccountStorage(store); + await storage.store({ id: 'guest-1', password: 'pw' }); + + await storage.clear(); + + expect(await storage.get()).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `cd packages/wallet-sdk && bun test domain/user/guest-account-storage.test.ts` → FAIL (module missing). + +- [ ] **Step 3: Implement storage** + +```ts +// packages/wallet-sdk/domain/user/guest-account-storage.ts +import { safeJsonParse } from '@agicash/utils'; +import { z } from 'zod/mini'; +import type { AuthKeyValueStore, Logger } from '../../sdk'; + +// Key predates the SDK move — existing devices have guest credentials stored +// under it, so it must not change. +const storageKey = 'guestAccount'; + +const GuestAccountDetailsSchema = z.object({ + id: z.string(), + password: z.string(), +}); + +export type GuestAccountDetails = z.infer; + +export type GuestAccountStorage = { + get(): Promise; + store(details: GuestAccountDetails): Promise; + clear(): Promise; +}; + +export function createGuestAccountStorage( + store: AuthKeyValueStore, + logger?: Logger, +): GuestAccountStorage { + return { + async get() { + const dataString = await store.getItem(storageKey); + if (!dataString) { + return null; + } + const parseResult = safeJsonParse(dataString); + if (!parseResult.success) { + return null; + } + const validationResult = GuestAccountDetailsSchema.safeParse( + parseResult.data, + ); + if (!validationResult.success) { + logger?.warn('Invalid guest account data found in the storage'); + return null; + } + return validationResult.data; + }, + async store(details) { + await store.setItem(storageKey, JSON.stringify(details)); + }, + async clear() { + await store.removeItem(storageKey); + }, + }; +} +``` + +- [ ] **Step 4: Implement the password generator** (moved from `apps/web-wallet/app/lib/password-generator.ts`, minus the `window.getMockPassword` hook and `window.` prefixes — this becomes the ONLY generator; the web file is deleted in Task 11 once its last importer, the old `auth.ts`, is rewritten): + +```ts +// packages/wallet-sdk/lib/password.ts +type PasswordOptions = { + letters?: boolean; + numbers?: boolean; + special?: boolean; +}; + +export async function generateRandomPassword( + length = 24, + options: PasswordOptions = { letters: true, numbers: true, special: true }, +): Promise { + let charset = ''; + + if (options.letters) + charset += 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if (options.numbers) charset += '0123456789'; + if (options.special) charset += '!@#$%^&*()_+~'; + + if (!charset) { + throw new Error( + 'At least one character set (letters, numbers, special) must be selected.', + ); + } + + const password: string[] = []; + + for (let i = 0; i < length; i++) { + const randomIndex = + globalThis.crypto.getRandomValues(new Uint32Array(1))[0] % charset.length; + password.push(charset[randomIndex]); + } + + return password.join(''); +} +``` + +- [ ] **Step 5: Run tests** — storage tests pass; `bun run fix:all && bun run typecheck` passes. + +- [ ] **Step 6: Commit** + +```bash +git add packages/wallet-sdk/lib/password.ts packages/wallet-sdk/domain/user +git commit -m "feat(wallet-sdk): add port-backed guest-account storage and CSPRNG password generator" +``` + +---### Task 6: `AuthService` — session state, verbs, expiry machinery + +**Files:** +- Create: `packages/wallet-sdk/domain/user/auth-service.ts` +- Test: `packages/wallet-sdk/domain/user/auth-service.test.ts` +- Modify: `packages/wallet-sdk/lib/error.ts` (add `NoSessionError`) + +**Interfaces:** +- Consumes: `AuthApi`, `AuthSession`, `AuthStorage`, `Logger` from `../../sdk`; `WalletEventEmitter` from `../../lib/events`; `GuestAccountStorage` from `./guest-account-storage`; `setLongTimeout`/`clearLongTimeout`/`LongTimeout` from `@agicash/utils`; `jwtDecode` from `jwt-decode`. +- Produces: `class AuthService implements AuthApi` with constructor `new AuthService(deps: AuthServiceDeps)`, plus `restoreSession(): Promise` and `teardown(): void` beyond the contract surface. `type OpenSecretAuthApi` naming the 11 Open Secret fns it uses (so `import * as openSecret` satisfies it structurally). Consumed by Task 8. + +- [ ] **Step 1: Add the internal error class** to `packages/wallet-sdk/lib/error.ts`: + +```ts +/** Thrown when a namespace method requiring an authenticated session runs without one. */ +export class NoSessionError extends SdkError { + constructor() { + super('No authenticated session'); + this.name = 'NoSessionError'; + } +} +``` + +(Not added to `index.ts`/`temporary.ts` — hosts catch it via the exported `SdkError` base.) + +- [ ] **Step 2: Write the failing tests.** Use fake deps throughout — no module mocking. Helper to mint JWTs with a chosen `exp` so timer math is real: + +```ts +// packages/wallet-sdk/domain/user/auth-service.test.ts +import { describe, expect, it } from 'bun:test'; +import type { AuthKeyValueStore, AuthStorage } from '../../sdk'; +import { WalletEventEmitter } from '../../lib/events'; +import { AuthService, type OpenSecretAuthApi } from './auth-service'; +import { createGuestAccountStorage } from './guest-account-storage'; + +const createMemoryStore = (): AuthKeyValueStore & { data: Map } => { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +const createStorage = (): AuthStorage & { persistent: ReturnType } => ({ + persistent: createMemoryStore(), + session: createMemoryStore(), +}); + +const toBase64Url = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + +const createJwt = (expSecondsFromNow: number, sub = 'user-1') => + `${toBase64Url({ alg: 'none' })}.${toBase64Url({ + sub, + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + })}.sig`; + +const fullUser = { + id: 'user-1', + name: null, + email: 'a@b.c', + email_verified: true, + login_method: 'email', + created_at: '2026-01-01', + updated_at: '2026-01-01', +}; + +const guestUser = { ...fullUser, email: undefined }; + +const createOsFake = ( + tokenStore: ReturnType, + overrides: Partial = {}, +) => { + const calls: string[] = []; + // The real Open Secret SDK persists fresh tokens on every login path; the + // fakes mirror that so a timer re-arm after login reads a live refresh token. + const login = (id: string) => { + tokenStore.data.set('access_token', createJwt(600, id)); + tokenStore.data.set('refresh_token', createJwt(3600, id)); + return { id, access_token: 'a', refresh_token: 'r' }; + }; + const os: OpenSecretAuthApi = { + fetchUser: async () => ({ user: fullUser }), + signIn: async () => login('user-1'), + signUp: async () => login('user-1'), + signUpGuest: async () => login('guest-1'), + signInGuest: async () => login('guest-1'), + signOut: async () => {}, + verifyEmail: async () => {}, + requestNewVerificationCode: async () => {}, + convertGuestToUserAccount: async () => {}, + initiateGoogleAuth: async () => ({ auth_url: 'https://accounts.google/x', csrf_token: 'c' }), + handleGoogleCallback: async () => login('user-1'), + ...overrides, + }; + // wrap every fn to record invocation order + for (const key of Object.keys(os) as (keyof OpenSecretAuthApi)[]) { + const original = os[key] as (...args: unknown[]) => unknown; + // biome-ignore lint/suspicious/noExplicitAny: test instrumentation + (os as any)[key] = (...args: unknown[]) => { + calls.push(key); + return original(...args); + }; + } + return { os, calls }; +}; + +const createService = (options: { + os?: Partial; + storage?: ReturnType; + onSessionEnded?: () => void; +} = {}) => { + const storage = options.storage ?? createStorage(); + const { os, calls } = createOsFake(storage.persistent, options.os); + const events = new WalletEventEmitter(); + const service = new AuthService({ + os, + storage, + guestAccountStorage: createGuestAccountStorage(storage.persistent), + generateGuestPassword: async () => 'generated-pw', + events, + onSessionEnded: options.onSessionEnded, + }); + return { service, storage, calls, events }; +}; + +describe('AuthService', () => { + it('starts anonymous', () => { + const { service } = createService(); + expect(service.getSession()).toEqual({ isLoggedIn: false }); + }); + + describe('restoreSession', () => { + it('stays anonymous without stored tokens and does not call fetchUser', async () => { + const { service, calls } = createService(); + await service.restoreSession(); + expect(service.getSession().isLoggedIn).toBe(false); + expect(calls).not.toContain('fetchUser'); + }); + + it('restores a session from stored tokens', async () => { + const { service, storage } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await service.restoreSession(); + + expect(service.getSession()).toEqual({ isLoggedIn: true, user: fullUser }); + service.teardown(); + }); + + it('rejects when tokens exist but the user fetch fails, then recovers on retry', async () => { + let sessionEnded = false; + let failFetch = true; + const { service, storage } = createService({ + os: { + fetchUser: async () => { + if (failFetch) { + throw new Error('network'); + } + return { user: fullUser }; + }, + }, + onSessionEnded: () => { + sessionEnded = true; + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await expect(service.restoreSession()).rejects.toThrow('network'); + // per-session caches were torn down with the failed restore + expect(sessionEnded).toBe(true); + expect(service.getSession().isLoggedIn).toBe(false); + + // the rejection is not memoized — a retry can succeed + failFetch = false; + await service.restoreSession(); + + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('is single-flight', async () => { + const { service, storage, calls } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await Promise.all([service.restoreSession(), service.restoreSession()]); + + expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); + service.teardown(); + }); + }); + + describe('signUpGuest', () => { + it('creates a new guest account and stores the credentials', async () => { + const { service, storage, calls } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + + await service.signUpGuest(); + + expect(calls).toContain('signUpGuest'); + expect(JSON.parse(storage.persistent.data.get('guestAccount') ?? '')).toEqual({ + id: 'guest-1', + password: 'generated-pw', + }); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('re-signs-in the stored guest account instead of creating a new one', async () => { + const { service, storage, calls } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'stored-pw' }), + ); + + await service.signUpGuest(); + + expect(calls).toContain('signInGuest'); + expect(calls).not.toContain('signUpGuest'); + service.teardown(); + }); + }); + + describe('signOut', () => { + it('clears the session, keeps guest credentials, and runs onSessionEnded', async () => { + let sessionEnded = false; + const { service, storage } = createService({ + onSessionEnded: () => { + sessionEnded = true; + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + await service.restoreSession(); + + await service.signOut(); + + expect(service.getSession().isLoggedIn).toBe(false); + expect(sessionEnded).toBe(true); + expect(storage.persistent.data.has('guestAccount')).toBe(true); + }); + + it('wipes per-session caches again when a different user signs in after sign-out', async () => { + let sessionEndedCount = 0; + const userIds = ['user-a', 'user-b']; + let fetchCalls = 0; + const { service } = createService({ + os: { + fetchUser: async () => ({ + user: { ...fullUser, id: userIds[Math.min(fetchCalls++, 1)] }, + }), + }, + onSessionEnded: () => { + sessionEndedCount += 1; + }, + }); + + await service.signIn('a@b.c', 'pw'); + await service.signOut(); // ends user-a's session → 1 + await service.signIn('b@b.c', 'pw'); // different user → wiped again → 2 + + expect(sessionEndedCount).toBe(2); + service.teardown(); + }); + }); + + describe('convertGuestToFullAccount', () => { + it('clears the stored guest credentials', async () => { + const { service, storage } = createService(); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await service.convertGuestToFullAccount('a@b.c', 'pw2'); + + expect(storage.persistent.data.has('guestAccount')).toBe(false); + service.teardown(); + }); + }); + + describe('session expiry', () => { + it('emits auth.session-expired and ends the session for a full account', async () => { + let sessionEnded = false; + const { service, storage, events } = createService({ + onSessionEnded: () => { + sessionEnded = true; + }, + }); + // refresh token expiring "now" (exp-5s already past) → timer fires immediately + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // the 1ms-floored timer fires on the next macrotask + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(expired).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(false); + expect(sessionEnded).toBe(true); + }); + + it('auto-extends a guest session instead of expiring it', async () => { + const { service, storage, calls, events } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + const refreshed: unknown[] = []; + events.on('auth.session-refreshed', (payload) => refreshed.push(payload)); + + await service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(calls).toContain('signInGuest'); + expect(expired).toHaveLength(0); + // the host is told about the refresh it didn't initiate + expect(refreshed).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('re-arms instead of expiring when the stored refresh token was rotated forward', async () => { + const { service, storage, events } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + // (exp - 5s) is ~100ms away, so the timer fires shortly after restore + storage.persistent.data.set('refresh_token', createJwt(5.1)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // the Open Secret SDK rotates the refresh token during its internal + // refresh flow; simulate a rotation landing before the timer fires + storage.persistent.data.set('refresh_token', createJwt(3600)); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(expired).toHaveLength(0); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('ends the session when the extension cannot restore the guest user', async () => { + let fetchCalls = 0; + const { service, storage, events } = createService({ + os: { + fetchUser: async () => { + fetchCalls += 1; + // restore succeeds; the post-extend fetch fails + if (fetchCalls > 1) { + throw new Error('network'); + } + return { user: guestUser }; + }, + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // no wedged half-session: the death path ran and told the host + expect(expired).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(false); + }); + }); + + it('initiateGoogleAuth returns the raw auth url', async () => { + const { service } = createService(); + expect(await service.initiateGoogleAuth()).toEqual({ + authUrl: 'https://accounts.google/x', + }); + }); +}); +``` + +Note: the login fakes MUST write fresh tokens into the store (mirroring the real Open Secret SDK, which persists tokens on every login path) — otherwise the guest-extend test would end the session instead of extending it: the post-extend expiry check would see an unmoved expiry and fall through to the death path. (In production that check plus the 1ms timer floor is what guards against a hot extend loop when a returned token is already expired.) Bun runs all test files in one process, so every test that leaves a session (and therefore a live expiry timer) ends with `service.teardown()` to avoid leaking timers into the rest of the suite. + +- [ ] **Step 3: Run to verify failure** — `cd packages/wallet-sdk && bun test domain/user/auth-service.test.ts` → FAIL (module missing). + +- [ ] **Step 4: Implement `AuthService`** + +```ts +// packages/wallet-sdk/domain/user/auth-service.ts +import { + type LongTimeout, + clearLongTimeout, + setLongTimeout, +} from '@agicash/utils'; +import type { + GoogleAuthResponse, + LoginResponse, + UserResponse, +} from '@agicash/opensecret'; +import { jwtDecode } from 'jwt-decode'; +import type { WalletEventEmitter } from '../../lib/events'; +import type { AuthApi, AuthSession, AuthStorage, Logger } from '../../sdk'; +import type { GuestAccountStorage } from './guest-account-storage'; + +// Keys are owned by @agicash/opensecret's token persistence; the service reads +// them (never writes) for session detection and expiry math. +const accessTokenKey = 'access_token'; +const refreshTokenKey = 'refresh_token'; + +// A corrupt stored token must degrade (no timer / expiry path), never throw +// from a timer callback or a query fn. +const decodeJwt = (token: string): { exp?: number } | null => { + try { + return jwtDecode(token); + } catch { + return null; + } +}; + +/** The subset of @agicash/opensecret the auth service drives. `import * as openSecret` satisfies it. */ +export type OpenSecretAuthApi = { + fetchUser(): Promise; + signIn(email: string, password: string): Promise; + signUp( + email: string, + password: string, + inviteCode: string, + name?: string | null, + ): Promise; + signUpGuest(password: string, inviteCode: string): Promise; + signInGuest(id: string, password: string): Promise; + signOut(): Promise; + verifyEmail(code: string): Promise; + requestNewVerificationCode(): Promise; + convertGuestToUserAccount( + email: string, + password: string, + name?: string | null, + ): Promise; + initiateGoogleAuth(inviteCode?: string): Promise; + handleGoogleCallback( + code: string, + state: string, + inviteCode: string, + ): Promise; +}; + +type AuthServiceDeps = { + os: OpenSecretAuthApi; + storage: AuthStorage; + guestAccountStorage: GuestAccountStorage; + generateGuestPassword: () => Promise; + events: WalletEventEmitter; + /** Instance-memo cleanup on any session end (sign-out or expiry). */ + onSessionEnded?: () => void; + logger?: Logger; +}; + +export class AuthService implements AuthApi { + private session: AuthSession = { isLoggedIn: false }; + private restorePromise: Promise | undefined; + private expiryTimeout: LongTimeout | undefined; + // Survives endSession() deliberately — see applySessionFromServer. + private lastUserId: string | undefined; + + constructor(private readonly deps: AuthServiceDeps) {} + + getSession(): AuthSession { + return this.session; + } + + /** + * Idempotent session restore from the storage port; resolving anonymous is + * a state, not a failure. A rejection (unreadable storage) is not memoized, + * so the host's query retries can recover. + */ + restoreSession(): Promise { + this.restorePromise ??= this.doRestore().catch((error) => { + this.restorePromise = undefined; + throw error; + }); + return this.restorePromise; + } + + private async doRestore(): Promise { + const [accessToken, refreshToken] = await Promise.all([ + this.deps.storage.persistent.getItem(accessTokenKey), + this.deps.storage.persistent.getItem(refreshTokenKey), + ]); + if (!accessToken || !refreshToken) { + return; + } + try { + await this.applySessionFromServer(); + } catch (error) { + if (this.session.isLoggedIn) { + // An auth verb established a session while this restore was in + // flight; the restore result is moot. + return; + } + // Contract: init() rejects on refresh errors (tokens exist but can't + // be validated). endSession keeps the instance consistent; the + // rejection is un-memoized by restoreSession, so a retry can succeed. + this.endSession(); + throw error; + } + } + + async signUp(email: string, password: string): Promise { + await this.deps.os.signUp(email, password, ''); + await this.refreshSessionSnapshot('sign up'); + } + + async signUpGuest(): Promise { + const existingGuestAccount = await this.deps.guestAccountStorage.get(); + if (existingGuestAccount) { + await this.deps.os.signInGuest( + existingGuestAccount.id, + existingGuestAccount.password, + ); + } else { + const password = await this.deps.generateGuestPassword(); + const guestAccount = await this.deps.os.signUpGuest(password, ''); + await this.deps.guestAccountStorage.store({ + id: guestAccount.id, + password, + }); + } + await this.refreshSessionSnapshot('guest sign up'); + } + + async signIn(email: string, password: string): Promise { + await this.deps.os.signIn(email, password); + await this.refreshSessionSnapshot('sign in'); + } + + async signOut(): Promise { + try { + await this.deps.os.signOut(); + } finally { + this.endSession(); + } + } + + async verifyEmail(code: string): Promise { + await this.deps.os.verifyEmail(code); + await this.refreshSessionSnapshot('email verification'); + } + + requestNewVerificationCode(): Promise { + return this.deps.os.requestNewVerificationCode(); + } + + async convertGuestToFullAccount( + email: string, + password: string, + ): Promise { + await this.deps.os.convertGuestToUserAccount(email, password); + await this.deps.guestAccountStorage.clear(); + await this.refreshSessionSnapshot('guest conversion'); + } + + async initiateGoogleAuth(): Promise<{ authUrl: string }> { + const response = await this.deps.os.initiateGoogleAuth(''); + return { authUrl: response.auth_url }; + } + + async completeGoogleAuth(params: { + code: string; + state: string; + }): Promise { + await this.deps.os.handleGoogleCallback(params.code, params.state, ''); + await this.refreshSessionSnapshot('google auth'); + } + + /** Cancels the expiry timer; the instance stays usable. */ + teardown(): void { + this.disarmExpiryTimer(); + } + + private async refreshSessionSnapshot(context: string): Promise { + try { + await this.applySessionFromServer(); + } catch (error) { + // Swallowed for parity: a verb whose fetchUser fails leaves an + // anonymous session the host discovers on its next read, like the old + // web glue. endSession (not a bare snapshot clear) so the per-session + // caches die with the session — the Supabase token cache in particular + // must never outlive it. + this.deps.logger?.error(`Failed to fetch user (${context})`, error); + this.endSession(); + } + } + + private async applySessionFromServer(): Promise { + const response = await this.deps.os.fetchUser(); + // Compared against the last seen user rather than the live session: a + // memo repopulated by a request that resolved after sign-out must still + // be wiped when a DIFFERENT user's session begins, and by then the + // session is anonymous. Same-user re-login keeps its memos warm. + if (this.lastUserId && this.lastUserId !== response.user.id) { + this.deps.onSessionEnded?.(); + } + this.lastUserId = response.user.id; + this.session = { isLoggedIn: true, user: response.user }; + await this.armExpiryTimer(); + } + + private endSession(): void { + this.session = { isLoggedIn: false }; + this.disarmExpiryTimer(); + // Un-memoize the restore so the next init() re-evaluates from storage — + // a verb whose post-login fetchUser failed leaves tokens behind, and the + // next invalidation can then recover the session like the old glue did. + this.restorePromise = undefined; + this.deps.onSessionEnded?.(); + } + + private async armExpiryTimer(): Promise { + const remaining = await this.getRemainingSessionTimeMs(); + // Disarm only after the await, so disarm+assign form one synchronous + // block — two overlapping arms can't interleave and orphan a timer. + this.disarmExpiryTimer(); + if (remaining === null) { + return; + } + // Floor of 1ms: setLongTimeout fires synchronously at delay 0, which + // would recurse into handleSessionExpiry from inside a login verb. + this.expiryTimeout = setLongTimeout( + () => { + void this.handleSessionExpiry(); + }, + Math.max(remaining, 1), + ); + } + + /** + * Milliseconds until the stored refresh token is treated as expired (5s + * before actual expiry, matching the previous web behavior), floored at 0. + * Null when the token is absent or undecodable. + */ + private async getRemainingSessionTimeMs(): Promise { + const refreshToken = + await this.deps.storage.persistent.getItem(refreshTokenKey); + if (!refreshToken) { + return null; + } + const decoded = decodeJwt(refreshToken); + if (!decoded?.exp) { + return null; + } + return Math.max((decoded.exp - 5) * 1000 - Date.now(), 0); + } + + private disarmExpiryTimer(): void { + if (this.expiryTimeout) { + clearLongTimeout(this.expiryTimeout); + this.expiryTimeout = undefined; + } + } + + private async handleSessionExpiry(): Promise { + const session = this.session; + if (!session.isLoggedIn) { + return; + } + // The Open Secret SDK rotates the refresh token during its internal + // refresh flow, so the expiry this timer was armed for may have moved. + // Re-check the stored token and re-arm instead of expiring a live session. + const remaining = await this.getRemainingSessionTimeMs(); + if (remaining !== null && remaining > 0) { + await this.armExpiryTimer(); + return; + } + const isGuest = !session.user.email; + if (isGuest) { + try { + // Re-signing-in the stored guest account gets fresh tokens and re-arms + // the timer; the host never observes the expiry. + await this.signUpGuest(); + const extendedRemaining = await this.getRemainingSessionTimeMs(); + if ( + extendedRemaining !== null && + extendedRemaining > 0 && + this.session.isLoggedIn + ) { + // The host didn't initiate this refresh, so it must be told — + // the web re-syncs its auth query + session-hint cookie from it. + this.deps.events.emit('auth.session-refreshed', {}); + return; + } + // Falls through when the extension produced no live session (already- + // expired token — also guards a hot extend loop — or a failed + // post-extend user fetch), so the death path emits the event instead + // of leaving a wedged half-session. + this.deps.logger?.warn( + 'Guest session extension did not produce a live session; ending it', + ); + } catch (error) { + this.deps.logger?.error('Failed to extend guest session', error); + } + } + try { + await this.deps.os.signOut(); + } catch (error) { + this.deps.logger?.warn('Sign out during session expiry failed', error); + } + this.endSession(); + this.deps.events.emit('auth.session-expired', {}); + } +} +``` + +- [ ] **Step 5: Run tests** — all `auth-service.test.ts` tests pass; run `bun test` (whole SDK) and `bun run fix:all && bun run typecheck` → PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/wallet-sdk/domain/user/auth-service.ts packages/wallet-sdk/domain/user/auth-service.test.ts packages/wallet-sdk/lib/error.ts +git commit -m "feat(wallet-sdk): add AuthService with session snapshot and expiry machinery" +``` + +--- + +### Task 7: SDK Supabase client + internal session-token getter + +**Files:** +- Create: `packages/wallet-sdk/db/client.ts` +- Create: `packages/wallet-sdk/db/supabase-session.ts` +- Test: `packages/wallet-sdk/db/supabase-session.test.ts` + +**Interfaces:** +- Consumes: `Database`, `AgicashDb` types from `./database`; `generateThirdPartyToken` from `@agicash/opensecret`. +- Produces: + - `createSupabaseSessionTokenGetter(deps: { isLoggedIn: () => boolean; generateToken?: () => Promise<{ token: string }> }): SupabaseSessionTokenSource` where `SupabaseSessionTokenSource = { getToken: () => Promise; reset: () => void }`. `reset` MUST be invoked on session end (Task 9 wires it into `onSessionEnded`) — the cache is otherwise only re-validated by expiry, and a token minted for one user must never survive into another user's session (sign out → sign in as a different user would otherwise query with the old JWT until it expires). + - `createAgicashDbClient(config: { url: string; anonKey: string; accessToken: () => Promise }): AgicashDb` — consumed by Task 9. + +- [ ] **Step 1: Write the failing token-getter tests** + +```ts +// packages/wallet-sdk/db/supabase-session.test.ts +import { describe, expect, it } from 'bun:test'; +import { createSupabaseSessionTokenGetter } from './supabase-session'; + +const toBase64Url = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + +const createToken = (expSecondsFromNow: number) => + `${toBase64Url({ alg: 'none' })}.${toBase64Url({ + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + })}.sig`; + +describe('createSupabaseSessionTokenGetter', () => { + it('returns null and skips token generation when logged out', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => false, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + expect(await getToken()).toBeNull(); + expect(generated).toBe(0); + }); + + it('memoizes the token until close to expiry', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + const first = await getToken(); + const second = await getToken(); + + expect(first).toBe(second as string); + expect(generated).toBe(1); + }); + + it('re-generates once the cached token is within 5s of expiry', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + // expires in 3s → refreshAt is already in the past + return { token: createToken(3) }; + }, + }); + + await getToken(); + await getToken(); + + expect(generated).toBe(2); + }); + + it('shares one in-flight request between concurrent callers', async () => { + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + await new Promise((resolve) => setTimeout(resolve, 5)); + return { token: createToken(3600) }; + }, + }); + + await Promise.all([getToken(), getToken(), getToken()]); + + expect(generated).toBe(1); + }); + + it('drops the cache when the session ends', async () => { + let loggedIn = true; + let generated = 0; + const { getToken } = createSupabaseSessionTokenGetter({ + isLoggedIn: () => loggedIn, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + await getToken(); + loggedIn = false; + expect(await getToken()).toBeNull(); + loggedIn = true; + await getToken(); + + expect(generated).toBe(2); + }); + + it('reset drops the cached token so the next session cannot reuse it', async () => { + let generated = 0; + const source = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + return { token: createToken(3600) }; + }, + }); + + await source.getToken(); + source.reset(); + await source.getToken(); + + expect(generated).toBe(2); + }); + + it('does not cache a token that resolves after reset', async () => { + let generated = 0; + let release: (() => void) | undefined; + const source = createSupabaseSessionTokenGetter({ + isLoggedIn: () => true, + generateToken: async () => { + generated += 1; + if (generated === 1) { + await new Promise((resolve) => { + release = resolve; + }); + } + return { token: createToken(3600) }; + }, + }); + + const firstCall = source.getToken(); + // session ends while the first exchange is still in flight + source.reset(); + release?.(); + await firstCall; + + await source.getToken(); + + // the stale in-flight token was not cached; the new session exchanged fresh + expect(generated).toBe(2); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `cd packages/wallet-sdk && bun test db/supabase-session.test.ts` → FAIL. + +- [ ] **Step 3: Implement** + +```ts +// packages/wallet-sdk/db/supabase-session.ts +import { generateThirdPartyToken } from '@agicash/opensecret'; +import { jwtDecode } from 'jwt-decode'; + +type Deps = { + isLoggedIn: () => boolean; + /** Test seam; defaults to Open Secret's generateThirdPartyToken. */ + generateToken?: () => Promise<{ token: string }>; +}; + +export type SupabaseSessionTokenSource = { + /** Supabase `accessToken` callback; null selects the anon key. */ + getToken: () => Promise; + /** + * Drops the cached token. Must be called when the session ends — the cache + * is otherwise only re-validated by expiry, and a token minted for one user + * must never survive into another user's session. + */ + reset: () => void; +}; + +/** + * Builds the Supabase `accessToken` source: exchanges the Open Secret JWT + * for a Supabase third-party token and memoizes it until 5 seconds before its + * expiry. Concurrent callers share one in-flight exchange. Returns null when + * no session exists (the client then uses the anon key). + */ +export function createSupabaseSessionTokenGetter( + deps: Deps, +): SupabaseSessionTokenSource { + const generateToken = deps.generateToken ?? (() => generateThirdPartyToken()); + let cached: { token: string; refreshAtMs: number } | undefined; + let inFlight: Promise | undefined; + // Incremented by reset(); an exchange started under an older generation + // must not populate the cache — its token belongs to the ended session. + let generation = 0; + + const invalidate = () => { + generation += 1; + cached = undefined; + inFlight = undefined; + }; + + return { + reset: invalidate, + getToken: async () => { + if (!deps.isLoggedIn()) { + // Same full invalidation as reset(): an exchange in flight when the + // session ended must not populate the cache either. + invalidate(); + return null; + } + if (cached && Date.now() < cached.refreshAtMs) { + return cached.token; + } + if (!inFlight) { + const startedGeneration = generation; + inFlight = (async () => { + try { + const { token } = await generateToken(); + if (generation === startedGeneration) { + const { exp } = jwtDecode(token); + cached = { token, refreshAtMs: exp ? (exp - 5) * 1000 : 0 }; + } + return token; + } finally { + if (generation === startedGeneration) { + inFlight = undefined; + } + } + })(); + } + return inFlight; + }, + }; +} +``` + +```ts +// packages/wallet-sdk/db/client.ts +import { createClient } from '@supabase/supabase-js'; +import type { AgicashDb, Database } from './database'; + +type AgicashDbClientConfig = { + url: string; + anonKey: string; + /** Resolves the Supabase session JWT; null selects the anon key. */ + accessToken: () => Promise; +}; + +/** Builds the SDK's own Supabase client (wallet schema). */ +export function createAgicashDbClient(config: AgicashDbClientConfig): AgicashDb { + return createClient(config.url, config.anonKey, { + accessToken: config.accessToken, + db: { + schema: 'wallet', + }, + }); +} +``` + +If `AgicashDb` in `db/database.ts` is not exactly the return type of this `createClient` call, type the function's return as `AgicashDb` only if it assigns cleanly — otherwise return the inferred type and alias it; check `db/database.ts`'s `AgicashDb` definition first and match it. + +- [ ] **Step 4: Run tests** — token tests pass; `bun run fix:all && bun run typecheck` passes. + +- [ ] **Step 5: Commit** + +```bash +git add packages/wallet-sdk/db +git commit -m "feat(wallet-sdk): add the SDK-internal Supabase client and session token getter" +``` + +--- + +### Task 8: `WriteUserRepository`/`UserService` refactor (A9) + `createUserApi` + +**Files:** +- Modify: `packages/wallet-sdk/domain/user/user-repository.ts:56-60,107-201` +- Modify: `packages/wallet-sdk/domain/user/user-service.ts:49-73` +- Modify: `apps/web-wallet/app/routes/_protected.tsx:124-127` (call-site) +- Modify: `apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx:92-95` (call-site — there are THREE `new WriteUserRepository(...)` sites, not two) +- Modify: `apps/web-wallet/app/features/user/user-repository-hooks.ts` (drop accountRepository arg) +- Create: `packages/wallet-sdk/domain/user/user-api.ts` + +**Interfaces:** +- Consumes: `ReadUserRepository`, `WriteUserRepository`, `UserService` (existing), `NoSessionError` (Task 6), `AgicashDb`, contract types from `../../sdk`. +- Produces: + - `new WriteUserRepository(db)` (constructor loses `accountRepository`); `upsert(user, accountRepository: AccountRepository, options?)` gains it as a param. + - `UserService.setDefaultAccount(user: Pick, account: Pick, options?)` — column-minimal update; no field echo. + - `createUserApi(deps: { db: AgicashDb; getSession: () => AuthSession }): UserApi` — consumed by Task 9. + +- [ ] **Step 1: Refactor `WriteUserRepository`** + +```ts +export class WriteUserRepository { + constructor(private readonly db: AgicashDb) {} +``` + +and change `upsert`'s signature (body unchanged except `this.accountRepository` → `accountRepository`): + +```ts + async upsert( + user: { + // ... existing param docs unchanged ... + }, + accountRepository: AccountRepository, + options?: Options, + ): Promise<{ user: User; accounts: Account[] }> { +``` + +- [ ] **Step 2: Loosen `UserService.setDefaultAccount`'s params and make the update column-minimal** + +```ts + async setDefaultAccount( + user: Pick, + account: Pick, + options: SetDefaultAccountOptions = { + setDefaultCurrency: false, + }, + ): Promise { + if (!['BTC', 'USD'].includes(account.currency)) { + throw new Error('Unsupported currency'); + } + + return this.userRepository.update( + user.id, + { + ...(account.currency === 'BTC' + ? { defaultBtcAccountId: account.id } + : { defaultUsdAccountId: account.id }), + ...(options.setDefaultCurrency + ? { defaultCurrency: account.currency } + : {}), + }, + { abortSignal: options.abortSignal }, + ); + } +``` + +Only the changed columns are written — `WriteUserRepository.update` passes `undefined` for the omitted fields and supabase-js drops them (behavior the `acceptTerms` path already relies on in production), so the untouched defaults can't be clobbered by stale caller state under concurrency. `user` narrows to `Pick` because the echo of unchanged fields is gone — the service no longer needs the rest of the user. (Existing callers pass full `User`/`Account` objects — assignable, no further changes.) + +- [ ] **Step 3: Update the three web call-sites** + +`apps/web-wallet/app/routes/_protected.tsx` (`ensureUserData`): + +```ts + const writeUserRepository = new WriteUserRepository(agicashDbClient); + + const { user: upsertedUser, accounts } = await withRetry({ + fn: () => + writeUserRepository.upsert( + { + id: authUser.id, + email: authUser.email, + emailVerified: authUser.email_verified, + accounts: [...defaultAccounts], + cashuLockingXpub, + encryptionPublicKey, + sparkIdentityPublicKey, + termsAcceptedAt, + giftCardMintTermsAcceptedAt, + }, + accountRepository, + ), +``` + +`apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx:92-95` — the route never calls `upsert`, so the `accountRepository` arg simply goes: + +```ts + const userRepository = new WriteUserRepository(agicashDbClient); +``` + +`apps/web-wallet/app/features/user/user-repository-hooks.ts`: + +```ts +export function useWriteUserRepository() { + return new WriteUserRepository(agicashDbClient); +} +``` + +(and drop the now-unused `useAccountRepository` import; the whole file is deleted in Task 12.) + +- [ ] **Step 4: Create `createUserApi`** + +```ts +// packages/wallet-sdk/domain/user/user-api.ts +import type { Currency } from '@agicash/money'; +import type { AgicashDb } from '../../db/database'; +import { NoSessionError } from '../../lib/error'; +import type { AuthSession, UserApi } from '../../sdk'; +import { ReadUserRepository, WriteUserRepository } from './user-repository'; +import { UserService } from './user-service'; + +type Deps = { + db: AgicashDb; + getSession: () => AuthSession; +}; + +export function createUserApi(deps: Deps): UserApi { + const readRepository = new ReadUserRepository(deps.db); + const writeRepository = new WriteUserRepository(deps.db); + const userService = new UserService(writeRepository); + + const requireUserId = (): string => { + const session = deps.getSession(); + if (!session.isLoggedIn) { + throw new NoSessionError(); + } + return session.user.id; + }; + + const getAccountRef = async ( + accountId: string, + ): Promise<{ id: string; currency: Currency }> => { + const { data, error } = await deps.db + .from('accounts') + .select('id, currency') + .eq('id', accountId) + // RLS already scopes rows to the user; this is defense-in-depth per the + // "userId implicit from session" convention. + .eq('user_id', requireUserId()) + .single(); + if (error) { + throw new Error('Failed to get account', { cause: error }); + } + return data; + }; + + // Methods are async so a missing session surfaces as a rejection, matching + // the Promise-returning contract, not a synchronous throw. + return { + get: async () => readRepository.get(requireUserId()), + updateUsername: async (username) => + writeRepository.update(requireUserId(), { username }), + acceptTerms: async (params) => { + const now = new Date().toISOString(); + return writeRepository.update(requireUserId(), { + termsAcceptedAt: params.walletTerms ? now : undefined, + giftCardMintTermsAcceptedAt: params.giftCardTerms ? now : undefined, + }); + }, + setDefaultCurrency: async (params) => + writeRepository.update(requireUserId(), { + defaultCurrency: params.currency, + }), + setDefaultAccount: async (params) => { + // One read, not two: the account row is fetched to derive the + // per-currency column server-truthfully; the user row isn't needed + // because the update only writes the changed columns. + const account = await getAccountRef(params.accountId); + return userService.setDefaultAccount({ id: requireUserId() }, account, { + setDefaultCurrency: params.setDefaultCurrency, + }); + }, + }; +} +``` + +(`accounts.currency` resolves to the `wallet` schema's currency enum, `'BTC' | 'USD'`, which is exactly `Currency` — `data` assigns cleanly.) + +- [ ] **Step 5: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. + +```bash +git add packages/wallet-sdk/domain/user apps/web-wallet/app/routes/_protected.tsx "apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx" apps/web-wallet/app/features/user/user-repository-hooks.ts +git commit -m "refactor(wallet-sdk): free WriteUserRepository from the accounts graph; add createUserApi" +``` + +--- + +### Task 9: `AgicashSdk` class + +**Files:** +- Create: `packages/wallet-sdk/agicash-sdk.ts` +- Modify: `packages/wallet-sdk/index.ts` (export it) + +**Interfaces:** +- Consumes: everything produced by Tasks 3–8; `configure` + module fns from `@agicash/opensecret`; `clearSparkWallets` from `./lib/spark/wallet`; `clearAgicashMintAuthToken` from `./lib/agicash-mint-auth-provider`. +- Produces: `class AgicashSdk` with `static create(config: SdkConfig): AgicashSdk`, `readonly auth: AuthApi`, `readonly user: UserApi`, `readonly events: WalletEvents`, `init(): Promise`, `dispose(): Promise` — consumed by the web in Task 10. Exported from `@agicash/wallet-sdk`. + +- [ ] **Step 1: Implement** + +```ts +// packages/wallet-sdk/agicash-sdk.ts +import * as openSecret from '@agicash/opensecret'; +import { createAgicashDbClient } from './db/client'; +import { createSupabaseSessionTokenGetter } from './db/supabase-session'; +import { AuthService } from './domain/user/auth-service'; +import { createGuestAccountStorage } from './domain/user/guest-account-storage'; +import { createUserApi } from './domain/user/user-api'; +import { clearAgicashMintAuthToken } from './lib/agicash-mint-auth-provider'; +import { WalletEventEmitter } from './lib/events'; +import { generateRandomPassword } from './lib/password'; +import { clearSparkWallets } from './lib/spark/wallet'; +import type { AuthApi, SdkConfig, UserApi, WalletEvents } from './sdk'; + +/** + * Runtime implementation of the SDK contract, filled namespace-by-namespace + * as the migration slices land (auth/user/events since step 5). It will + * declare `implements Sdk` once every namespace exists. + */ +export class AgicashSdk { + readonly auth: AuthApi; + readonly user: UserApi; + readonly events: WalletEvents; + + private readonly authService: AuthService; + + private constructor(config: SdkConfig) { + // The Open Secret client is module-scoped in @agicash/opensecret, so auth + // configuration is process-global: a second AgicashSdk instance would + // re-configure it. One instance per process until the library ships an + // instance API. + openSecret.configure({ + apiUrl: config.auth.apiUrl, + clientId: config.auth.clientId, + storage: config.auth.storage, + }); + + const events = new WalletEventEmitter(config.logger); + + // Created before authService — the isLoggedIn closure dereferences it + // lazily at request time, after the constructor has assigned it. + const sessionToken = createSupabaseSessionTokenGetter({ + isLoggedIn: () => this.authService.getSession().isLoggedIn, + }); + + this.authService = new AuthService({ + os: openSecret, + storage: config.auth.storage, + guestAccountStorage: createGuestAccountStorage( + config.auth.storage.persistent, + config.logger, + ), + generateGuestPassword: async () => + (await config.auth.generateGuestPassword?.()) ?? + generateRandomPassword(32), + events, + onSessionEnded: () => { + // The token cache must die with the session: a token minted for one + // user must never serve the next login's queries. + sessionToken.reset(); + clearSparkWallets(); + clearAgicashMintAuthToken(); + }, + logger: config.logger, + }); + + const db = createAgicashDbClient({ + url: config.db.url, + anonKey: config.db.anonKey, + accessToken: sessionToken.getToken, + }); + + this.auth = this.authService; + this.user = createUserApi({ + db, + getSession: () => this.authService.getSession(), + }); + this.events = events; + } + + /** Sync; no I/O. */ + static create(config: SdkConfig): AgicashSdk { + return new AgicashSdk(config); + } + + /** + * Session restore only for now — the Breez WASM load folds in when the + * first Spark namespace lands. Resolves when no session exists. Delegates + * to the auth service, which is single-flight and memoizes success but + * clears a rejection, so the host's query retries can recover. + */ + init(): Promise { + return this.authService.restoreSession(); + } + + async dispose(): Promise { + this.authService.teardown(); + } +} +``` + +Check the actual export site of `clearSparkWallets`: it lives in `lib/spark/wallet.ts:150`; import from `'./lib/spark'` if the barrel re-exports it (temporary.ts does `export * from './lib/spark'`), otherwise from `'./lib/spark/wallet'`. + +- [ ] **Step 2: Export from `index.ts`** — add below the `export * from './sdk';` line: + +```ts +export { AgicashSdk } from './agicash-sdk'; +``` + +(The root export is the contract-mandated surface ("Runtime public surface = the `Sdk` class"). `agicash-sdk.ts` has no top-level side effects, so bundles that never touch the class tree-shake it; its spark/supabase graph is already server-import-safe via `/temporary`'s use in `_protected.tsx`.) + +- [ ] **Step 3: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck && cd packages/wallet-sdk && bun test` → PASS. +Then from the repo root: `bun run build` → PASS. (`fix:all` won't catch an SSR-bundle break; this is the first task that changes what the root export pulls into consumers, so exercise the real client + server build now, not only in Task 13.) + +```bash +git add packages/wallet-sdk/agicash-sdk.ts packages/wallet-sdk/index.ts +git commit -m "feat(wallet-sdk): add the AgicashSdk runtime with auth, user, and events namespaces" +``` + +--- + +### Task 10: Web config assembly (`sdk.client.ts`) + entry wiring + +**Files:** +- Create: `apps/web-wallet/app/features/shared/sdk.client.ts` +- Modify: `apps/web-wallet/app/features/agicash-db/database.client.ts` (export url/key consts) +- Modify: `apps/web-wallet/app/entry.client.tsx` (drop `configure`, import `sdk`) + +**Interfaces:** +- Consumes: `AgicashSdk` from `@agicash/wallet-sdk`; `browserStorage` from `@agicash/opensecret`; `breezApiKey` from `~/lib/breez`; the `window.getMockPassword` global (declared in `vite-env.d.ts`, armed only by the Playwright fixture). +- Produces: `export const sdk: AgicashSdk` — the web's singleton, imported by Tasks 11–12. (`.client.ts` suffix keeps it out of the server module graph, like `database.client.ts`.) + +- [ ] **Step 1: Export the resolved Supabase config from `database.client.ts`** — change the two consts to exported: + +```ts +export const supabaseUrl = getSupabaseUrl(); +``` +```ts +export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? ''; +``` + +(only the `const` declarations gain `export`; everything else unchanged). + +- [ ] **Step 2: Create `sdk.client.ts`** + +```ts +// apps/web-wallet/app/features/shared/sdk.client.ts +import { browserStorage } from '@agicash/opensecret'; +import { AgicashSdk } from '@agicash/wallet-sdk'; +import { + supabaseAnonKey, + supabaseUrl, +} from '~/features/agicash-db/database.client'; +import { breezApiKey } from '~/lib/breez'; + +const openSecretApiUrl = import.meta.env.VITE_OPEN_SECRET_API_URL ?? ''; +if (!openSecretApiUrl) { + throw new Error('VITE_OPEN_SECRET_API_URL is not set'); +} + +const openSecretClientId = import.meta.env.VITE_OPEN_SECRET_CLIENT_ID ?? ''; +if (!openSecretClientId) { + throw new Error('VITE_OPEN_SECRET_CLIENT_ID is not set'); +} + +const consoleLogger = { + debug: (message: string, meta?: unknown) => + meta === undefined ? console.debug(message) : console.debug(message, meta), + info: (message: string, meta?: unknown) => + meta === undefined ? console.info(message) : console.info(message, meta), + warn: (message: string, meta?: unknown) => + meta === undefined ? console.warn(message) : console.warn(message, meta), + error: (message: string, meta?: unknown) => + meta === undefined ? console.error(message) : console.error(message, meta), +}; + +export const sdk = AgicashSdk.create({ + db: { + url: supabaseUrl, + anonKey: supabaseAnonKey, + }, + auth: { + apiUrl: openSecretApiUrl, + clientId: openSecretClientId, + storage: browserStorage, + // e2e bridge: the Playwright fixture arms window.getMockPassword; in + // production it's absent, so this resolves null and the SDK generates. + generateGuestPassword: async () => + (await window.getMockPassword?.()) ?? null, + }, + spark: { + breezApiKey, + network: 'MAINNET', + }, + lightningAddressDomain: window.location.host, + logger: consoleLogger, +}); + +if (import.meta.hot) { + // A hot reload of this module constructs a second SDK; dispose the old one + // so its expiry timer doesn't leak. + import.meta.hot.dispose(() => void sdk.dispose()); +} +``` + +(`window.location.host` includes the port, matching what `useLocationData` derives for the settings screen today; nothing consumes the value until the contacts/receive slices.) + +- [ ] **Step 3: Rewire `entry.client.tsx`** — remove the `configure` import and call (added in Task 1), remove the now-unused env reads, and import the sdk module first so Open Secret is configured before any consumer runs: + +```ts +import { + configureFeatureFlags, + ensureBreezWasm, +} from '@agicash/wallet-sdk/temporary'; +// ... existing imports ... +import { agicashDbClient } from './features/agicash-db/database.client'; +// Importing the module constructs the SDK, which configures Open Secret as an +// import-evaluation side effect — before any body code below runs. +import './features/shared/sdk.client'; +``` + +Delete these lines: the `import { browserStorage, configure } from '@agicash/opensecret';`, the `openSecretApiUrl`/`openSecretClientId` env-read blocks, and the `configure({ ... })` call. Everything else (Breez WASM kickoff, feature flags, Sentry) stays. + +- [ ] **Step 4: Verify** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. +Smoke: `bun run dev` → app boots; existing session still logged in; network tab shows Open Secret calls working (attestation/session handshake unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web-wallet/app/features/shared/sdk.client.ts apps/web-wallet/app/features/agicash-db/database.client.ts apps/web-wallet/app/entry.client.tsx +git commit -m "feat(web-wallet): construct the wallet SDK instance and move Open Secret config into it" +``` + +--- + +### Task 11: Web auth glue flip + +**Files:** +- Modify: `apps/web-wallet/app/features/user/auth.ts` (full rewrite below) +- Modify: `apps/web-wallet/app/features/wallet/wallet.tsx` +- Modify: `apps/web-wallet/app/routes/_protected.tsx:30-34` (`AuthUser` import) +- Modify: `apps/web-wallet/app/routes/_auth.oauth.$provider.tsx` +- Modify: `apps/web-wallet/app/features/signup/verify-email.ts` +- Delete: `apps/web-wallet/app/hooks/use-long-timeout.ts` (its only consumer is the old `useHandleSessionExpiry`, which this task removes) +- Delete: `apps/web-wallet/app/lib/password-generator.ts` (its last importer is the old `auth.ts`; the SDK's `lib/password.ts` is now the only generator — A4) + +**Interfaces:** +- Consumes: `sdk` from `~/features/shared/sdk.client`; `AuthSession`, `AuthUser` from `@agicash/wallet-sdk`. +- Produces (same names as today so forms/pages don't change): `authQueryOptions`, `authStateQueryKey`, `invalidateAuthQueries`, `useAuthState`, `useAuthActions` (same verb signatures incl. `signOut(options?: { redirectTo?: string })`), `useSignOut`, `type AuthUser`; NEW `useHandleSessionEvents(onSessionExpired: () => void)` replacing `useHandleSessionExpiry` (subscribes to both `auth.session-expired` and `auth.session-refreshed`). + +- [ ] **Step 1: Rewrite `features/user/auth.ts`** + +```ts +import type { AuthUser } from '@agicash/wallet-sdk'; +import * as Sentry from '@sentry/react-router'; +import { decodeURLSafe, encodeURLSafe } from '@stablelib/base64'; +import { + queryOptions, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import { jwtDecode } from 'jwt-decode'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useRevalidator } from 'react-router'; +import { + loadFeatureFlags, + resetFeatureFlags, +} from '~/features/shared/feature-flags'; +import { getQueryClient } from '~/features/shared/query-client'; +import { sdk } from '~/features/shared/sdk.client'; +import { useLatest } from '~/lib/use-latest'; +import { oauthLoginSessionStorage } from './oauth-login-session-storage'; +import { sessionHintCookie } from './session-hint-cookie'; + +export type { AuthUser }; + +type AuthState = + | { + isLoggedIn: true; + user: AuthUser; + /** Unix seconds, captured at fetch time; drives the hint-cookie lifetime and query staleness. */ + refreshTokenExpiresAt: number | null; + } + | { + isLoggedIn: false; + user?: undefined; + }; + +export const authStateQueryKey = 'auth-state'; + +// A corrupt stored token must degrade to "no value", never throw from a query +// fn or staleTime callback (that would error-page every route, /login included). +const safeJwtDecode = ( + token: string, +): { exp?: number; sub?: string } | null => { + try { + return jwtDecode(token); + } catch { + return null; + } +}; + +const getRefreshTokenExpiry = (): number | null => { + const refreshToken = window.localStorage.getItem('refresh_token'); + if (!refreshToken) { + return null; + } + return safeJwtDecode(refreshToken)?.exp ?? null; +}; + +export const authQueryOptions = () => + queryOptions({ + queryKey: [authStateQueryKey], + queryFn: async (): Promise => { + // Associate Sentry events with the user as early as possible, before + // session restore completes. + const accessToken = window.localStorage.getItem('access_token'); + const sub = accessToken ? safeJwtDecode(accessToken)?.sub : undefined; + if (sub) { + Sentry.setUser({ id: sub }); + } + + try { + await sdk.init(); + } catch (error) { + // Restore failed with tokens present (e.g. a network blip at boot). + // Boot anonymous; init()'s rejection is not memoized, so a later + // invalidateAuthQueries() retries the restore. + console.error('Failed to restore session', { cause: error }); + Sentry.setUser(null); + sessionHintCookie.clear(); + return { isLoggedIn: false }; + } + const session = sdk.auth.getSession(); + + if (!session.isLoggedIn) { + Sentry.setUser(null); + sessionHintCookie.clear(); + return { isLoggedIn: false }; + } + + Sentry.setUser({ id: session.user.id, isGuest: !session.user.email }); + + // Mirror auth state into a hint cookie so the server can short-circuit + // SSR for unauthenticated visits. Lifetime matches the refresh token + // so we don't leave a stale "logged in" hint after the session + // genuinely expires. + const exp = getRefreshTokenExpiry(); + if (exp) { + sessionHintCookie.set(exp - Math.floor(Date.now() / 1000)); + } + + return { ...session, refreshTokenExpiresAt: exp }; + }, + // Logged-in state is fresh until the refresh token expires; a refetch + // after that point re-reads the (SDK-extended or ended) session and + // re-syncs the hint cookie. Anonymous state only changes through explicit + // invalidation. Staleness is pinned to the expiry captured AT FETCH TIME + // (not re-read from storage), so an SDK-internal guest extension can't + // slide freshness forward and postpone the cookie re-sync forever. + staleTime: ({ state: { data, dataUpdatedAt } }) => { + if (!data?.isLoggedIn) { + return Number.POSITIVE_INFINITY; + } + if (!data.refreshTokenExpiresAt) { + return 0; + } + return Math.max( + (data.refreshTokenExpiresAt - 5) * 1000 - dataUpdatedAt, + 0, + ); + }, + }); + +/** + * Invalidates all queries that depend on the current auth session. + * Call after any auth state change (login, logout, email verification, etc.) + */ +export const invalidateAuthQueries = async () => { + const queryClient = getQueryClient(); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [authStateQueryKey], + refetchType: 'all', + }), + loadFeatureFlags(), + ]); +}; + +export const useAuthState = (): AuthState => { + const { data } = useSuspenseQuery(authQueryOptions()); + return data; +}; + +type SignOutOptions = { + /** + * The URL to redirect to after signing out. If not provided, the user will be redirected to the singup page by the protected layout. + */ + redirectTo?: string; +}; + +type AuthActions = { + signUp: (email: string, password: string) => Promise; + signUpGuest: () => Promise; + signIn: (email: string, password: string) => Promise; + signOut: (options?: SignOutOptions) => Promise; + initiateGoogleAuth: () => Promise<{ authUrl: string }>; + verifyEmail: (code: string) => Promise; + convertGuestToFullAccount: (email: string, password: string) => Promise; +}; + +/** + * Authentication actions backed by the wallet SDK, wrapped with the web + * concerns the SDK doesn't own: query invalidation, navigation, Sentry user + * tracking, and the OAuth deep-link session. + */ +export const useAuthActions = (): AuthActions => { + const queryClient = useQueryClient(); + const { revalidate } = useRevalidator(); + const navigate = useNavigate(); + + const refreshSession = useCallback( + async (redirectTo?: string) => { + await invalidateAuthQueries(); + if (redirectTo) { + await navigate(redirectTo); + } else { + await revalidate(); + } + }, + [navigate, revalidate], + ); + + const signUp = useCallback( + async (email: string, password: string) => { + await sdk.auth.signUp(email, password); + await refreshSession(); + }, + [refreshSession], + ); + + const signIn = useCallback( + async (email: string, password: string) => { + await sdk.auth.signIn(email, password); + await refreshSession(); + }, + [refreshSession], + ); + + const signUpGuest = useCallback(async () => { + await sdk.auth.signUpGuest(); + await refreshSession(); + }, [refreshSession]); + + const signOut = useCallback( + async (options: SignOutOptions = {}) => { + await sdk.auth.signOut(); + // Before the refresh below so the previous user's flags are gone even if + // the anon re-fetch fails, and so its result isn't clobbered afterwards. + resetFeatureFlags(); + await refreshSession(options.redirectTo); + Sentry.setUser(null); + queryClient.clear(); + }, + [refreshSession, queryClient], + ); + + const initiateGoogleAuth = useCallback(async () => { + const { authUrl } = await sdk.auth.initiateGoogleAuth(); + + // Stash the current location under a session id and thread it through the + // OAuth state param, so the callback route can restore the deep link. + const authLocation = new URL(authUrl); + const stateParam = authLocation.searchParams.get('state'); + const state = stateParam + ? JSON.parse(new TextDecoder().decode(decodeURLSafe(stateParam))) + : {}; + + const oauthLoginSession = oauthLoginSessionStorage.create({ + search: location.search, + hash: location.hash, + }); + state.sessionId = oauthLoginSession.sessionId; + + const stateEncoded = encodeURLSafe( + new TextEncoder().encode(JSON.stringify(state)), + ); + authLocation.searchParams.set('state', stateEncoded); + + return { authUrl: authLocation.href }; + }, []); + + const verifyEmail = useCallback( + async (code: string) => { + await sdk.auth.verifyEmail(code); + await refreshSession(); + }, + [refreshSession], + ); + + const convertGuestToFullAccount = useCallback( + async (email: string, password: string) => { + await sdk.auth.convertGuestToFullAccount(email, password); + await refreshSession(); + }, + [refreshSession], + ); + + return { + signUp, + signUpGuest, + signIn, + signOut, + initiateGoogleAuth, + verifyEmail, + convertGuestToFullAccount, + }; +}; + +export const useSignOut = () => { + const { signOut } = useAuthActions(); + const [loading, setLoading] = useState(false); + + const handleSignOut = async () => { + setLoading(true); + await signOut({ redirectTo: '/home' }); + setLoading(false); + }; + return { isSigningOut: loading, signOut: handleSignOut }; +}; + +/** + * Reacts to SDK-initiated session transitions the host didn't trigger. + * Expiry (refresh-token death with failed/impossible extension): notifies the + * user and resets the web session state. Refresh (guest auto-extension): + * re-runs the auth query so the session-hint cookie picks up the new expiry, + * matching master's extend-through-invalidation behavior. + */ +export const useHandleSessionEvents = (onSessionExpired: () => void) => { + const queryClient = useQueryClient(); + const { revalidate } = useRevalidator(); + const onSessionExpiredRef = useLatest(onSessionExpired); + + useEffect(() => { + const unsubscribeExpired = sdk.events.on('auth.session-expired', () => { + void (async () => { + onSessionExpiredRef.current(); + resetFeatureFlags(); + await invalidateAuthQueries(); + await revalidate(); + Sentry.setUser(null); + queryClient.clear(); + })(); + }); + const unsubscribeRefreshed = sdk.events.on('auth.session-refreshed', () => { + void invalidateAuthQueries(); + }); + return () => { + unsubscribeExpired(); + unsubscribeRefreshed(); + }; + }, [queryClient, revalidate, onSessionExpiredRef]); +}; +``` + +Deleted vs master: `useHandleSessionExpiry`, the `OpenSecretJwt` helpers (`getJwt`, `removeKeys`, `getRefreshToken`, `getRemainingSessionTimeInMs`), the direct `@agicash/opensecret` + `/temporary` imports, `guestAccountStorage` usage, `generateRandomPassword` usage (now the SDK's concern via the config port). + +- [ ] **Step 2: Update `wallet.tsx`** — replace the `useHandleSessionExpiry` import + call: + +```ts +import { useHandleSessionEvents } from '../user/auth'; +``` +```ts + useHandleSessionEvents(() => { + toast({ + title: 'Session expired', + description: + 'The session has expired. You will be redirected to the login page.', + }); + }); +``` + +(the `isGuestAccount` prop disappears — guests are auto-extended inside the SDK). + +- [ ] **Step 3: Repoint `AuthUser` in `_protected.tsx`** + +```ts +import type { AuthUser } from '@agicash/wallet-sdk'; +import { authQueryOptions, useAuthState } from '~/features/user/auth'; +``` + +- [ ] **Step 4: Flip the OAuth callback route** (`_auth.oauth.$provider.tsx`) — replace the `handleGoogleCallback` import with the sdk and change the call: + +```ts +import { sdk } from '~/features/shared/sdk.client'; +``` +```ts + switch (provider) { + case 'google': + await sdk.auth.completeGoogleAuth({ code, state }); + break; +``` + +- [ ] **Step 5: Flip `features/signup/verify-email.ts`** — replace `import { verifyEmail as osVerifyEmail } from '@agicash/opensecret';` with the sdk import and change the call: + +```ts +import { sdk } from '~/features/shared/sdk.client'; +``` +```ts + await sdk.auth.verifyEmail(code); + await invalidateAuthQueries(); +``` + +- [ ] **Step 6: Delete `apps/web-wallet/app/hooks/use-long-timeout.ts` and `apps/web-wallet/app/lib/password-generator.ts`** — their only consumers were the removed `useHandleSessionExpiry` and the old guest-signup path (the SDK's generator + the `window.getMockPassword` bridge in `sdk.client.ts` replace the latter). Verify: `grep -rn "useLongTimeout\|password-generator" apps/web-wallet/app` returns nothing. + +- [ ] **Step 7: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. + +```bash +git add apps/web-wallet +git commit -m "refactor(web-wallet): route auth flows through sdk.auth" +``` + +--- + +### Task 12: Web user-domain flip + +**Files:** +- Modify: `apps/web-wallet/app/features/user/user-hooks.tsx` +- Delete: `apps/web-wallet/app/features/user/user-repository-hooks.ts` +- Delete: `apps/web-wallet/app/features/user/user-service-hooks.ts` +- Delete: `apps/web-wallet/app/features/user/guest-account-storage.ts` (moved into the SDK in Task 5; `user-hooks.tsx` is its last importer and stops using it in Step 2) +- Modify: `apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx` + +**Interfaces:** +- Consumes: `sdk.user`, `sdk.auth` from `~/features/shared/sdk.client`. +- Produces: unchanged hook names/signatures (`useUser`, `useUserRef`, `useUpgradeGuestToFullAccount`, `useRequestNewEmailVerificationCode`, `useVerifyEmail`, `useSetDefaultCurrency`, `useSetDefaultAccount`, `useUpdateUsername`, `useAcceptTerms`, `UserCache`, `useUserCache`, `useUserChangeHandlers`, `getUserFromCache`, `getUserFromCacheOrThrow`, `defaultAccounts`). + +- [ ] **Step 1: Flip `useUser`** — drop the repository plumbing: + +```ts +const userQueryOptions = ({ + select, +}: { + select?: (data: User) => TData; +}) => ({ + queryKey: [UserCache.Key], + queryFn: () => sdk.user.get(), + select, +}); + +export const useUser = ( + select?: (data: User) => TData, +): TData => { + const authState = useAuthState(); + if (!authState.user) { + throw new Error('Cannot use useUser hook in anonymous context'); + } + + const { data } = useSuspenseQuery(userQueryOptions({ select })); + + return data; +}; +``` + +(imports: add `import { sdk } from '~/features/shared/sdk.client';`, drop `ReadUserRepository`, `useReadUserRepository`, `useWriteUserRepository`, `useUserService`, `requestNewVerificationCode`, `guestAccountStorage`; keep the `ReadUserRepository` import **only** via `@agicash/wallet-sdk/temporary` for `useUserChangeHandlers`'s static `toUser` — that usage stays.) + +- [ ] **Step 2: Flip the mutations** + +```ts +export const useUpgradeGuestToFullAccount = (): (( + email: string, + password: string, +) => Promise) => { + const userRef = useUserRef(); + const { convertGuestToFullAccount } = useAuthActions(); + + const { mutateAsync } = useMutation({ + mutationKey: ['upgrade-guest-to-full-account'], + mutationFn: (variables: { email: string; password: string }) => { + if (!userRef.current.isGuest) { + throw new Error('User already has a full account'); + } + + return convertGuestToFullAccount(variables.email, variables.password); + }, + scope: { + id: 'upgrade-guest-to-full-account', + }, + }); + + return useCallback( + (email: string, password: string) => mutateAsync({ email, password }), + [mutateAsync], + ); +}; +``` + +(the `guestAccountStorage.clear()` follow-up is gone — the SDK clears it inside `convertGuestToFullAccount`). + +```ts +export const useRequestNewEmailVerificationCode = (): (() => Promise) => { + const userRef = useUserRef(); + + const { mutateAsync } = useMutation({ + mutationKey: ['request-new-email-verification-code'], + mutationFn: () => { + if (userRef.current.isGuest) { + throw new Error('Cannot request email verification for guest account'); + } + if (userRef.current.emailVerified) { + throw new Error('Email is already verified'); + } + + return sdk.auth.requestNewVerificationCode(); + }, + scope: { + id: 'request-new-email-verification-code', + }, + }); + + return mutateAsync; +}; +``` + +Replace `useUpdateUser` + the three wrappers with direct sdk calls (the `UpdateUser` type import from `/temporary` goes away): + +```ts +const useUserUpdatingMutation = ( + mutationFn: (variables: TVariables) => Promise, +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn, + onSuccess: (data) => { + queryClient.setQueryData([UserCache.Key], data); + }, + }); +}; + +export const useSetDefaultCurrency = () => { + const { mutateAsync } = useUserUpdatingMutation((currency: Currency) => + sdk.user.setDefaultCurrency({ currency }), + ); + return mutateAsync; +}; + +export const useSetDefaultAccount = () => { + const { mutateAsync } = useUserUpdatingMutation((account: Account) => + sdk.user.setDefaultAccount({ accountId: account.id }), + ); + return mutateAsync; +}; + +export const useUpdateUsername = () => { + const { mutateAsync } = useUserUpdatingMutation((username: string) => + sdk.user.updateUsername(username), + ); + return mutateAsync; +}; + +export const useAcceptTerms = () => { + const { mutateAsync } = useUserUpdatingMutation( + (params: { walletTerms?: boolean; giftCardTerms?: boolean }) => + sdk.user.acceptTerms(params), + ); + return mutateAsync; +}; +``` + +(`useVerifyEmail` is unchanged — it already goes through `useAuthActions`.) + +- [ ] **Step 3: Delete `user-repository-hooks.ts`, `user-service-hooks.ts`, and `guest-account-storage.ts`.** Verify no importers remain: `grep -rn "user-repository-hooks\|user-service-hooks\|user/guest-account-storage" apps/` → empty. + +- [ ] **Step 4: Flip the receive-token route** (`_protected.receive.cashu_.token.tsx`) — in `trySetReceiveAccountAsDefault` (line 117), replace the `userService.setDefaultAccount(user, account, { setDefaultCurrency: true })` call with: + +```ts + const updatedUser = await sdk.user.setDefaultAccount({ + accountId: account.id, + setDefaultCurrency: true, + }); + new UserCache(queryClient).set(updatedUser); +``` + +Concretely, five removals or the file won't compile clean: (1) drop `userService` and the `userRepository` construction feeding it from `getServices()` (construction + return object); (2) remove `userService` from the destructure at ~line 176; (3) remove the `userService: UserService` parameter from `trySetReceiveAccountAsDefault` (line ~117-122); (4) remove the corresponding argument at its call site (~line 197); (5) drop the now-unused `WriteUserRepository` import. Keep the `UserService.isDefaultAccount` static usage (its `/temporary` import stays). Add the `sdk` import. + +- [ ] **Step 5: Verify + commit** + +Run: `export PATH="$PWD/.devenv/profile/bin:$PATH" && bun run fix:all && bun run typecheck` → PASS. +Confirm the only remaining web importers of user-domain classes from `/temporary` are: `_protected.tsx` (`WriteUserRepository` for `ensureUserData`, A1), `user-hooks.tsx` (`ReadUserRepository.toUser` static), the receive route + any accounts/transactions files (`UserService` statics, `ReadUserDefaultAccountRepository`) — run `grep -rn "UserRepository\|UserService" apps/web-wallet/app --include='*.ts*'` and check the list matches the Deferred section. + +```bash +git add apps/web-wallet +git commit -m "refactor(web-wallet): route user reads and writes through sdk.user" +``` + +--- + +### Task 13: Full verification + +- [ ] **Step 1: Static + unit + production build** + +```bash +export PATH="$PWD/.devenv/profile/bin:$PATH" +bun run fix:all && bun run typecheck +bun run test +bun run build +``` +Expected: all PASS. The build step exercises the real client + server bundles (the dev server alone doesn't), confirming the root `AgicashSdk` export is server-bundle-safe. + +- [ ] **Step 2: Browser smoke (dev server + Chrome MCP or manual)** — `bun run dev`, then walk: + +1. `/home` (marketing, anonymous) → Sign Up → **Create wallet as Guest** → wallet home renders (dev auto-creates Testnut accounts). +2. Reload → still signed in (session restore through `sdk.init()`). +3. Settings → Sign Out → back at signup; localStorage keeps `guestAccount`. +4. **Create wallet as Guest** again → re-signs into the SAME guest account (no new account). +5. Settings → edit username → persists after reload (`sdk.user.updateUsername` + cache). +6. Switch default account/currency in settings → theme flips (USD/BTC), persists. +7. Google login page renders and the Google button redirects to an accounts.google.com URL with a `state` containing `sessionId` (don't complete externally). +8. DevTools: no console errors; `wallet.users` reads go through the SDK client (two Supabase token exchanges are expected — web + SDK clients). + +- [ ] **Step 3: E2E (ask the user before running)** + +```bash +cd apps/web-wallet-e2e && bun run test:e2e -- --grep "signup|login|verify" +``` +(Run from the package dir — arg forwarding through the root script's `bun --filter` isn't guaranteed to reach playwright, and a silently-unfiltered full run is the failure mode.) +Expected: signup.spec, login.spec, verify-email.spec pass unchanged (the RC talks to the same endpoints; the password mock still applies through the config port). + +- [ ] **Step 4: Existing-session upgrade check** — with a session created on `master` (localStorage tokens present), switch to the branch, reload: still logged in. + +- [ ] **Step 5: Commit any fixes; then push and open the PR** + +```bash +git push -u origin sdk/auth-slice +``` + +PR: base `master`, title `feat(wallet-sdk): auth & user slice (step 5)`, description listing: contract placeholders settled, opensecret RC adoption, AgicashSdk runtime, web flips, Decision Record A1–A12, deferred items. + +--- + +## Self-Review Checklist (run after Task 13) + +1. **Spec coverage:** step-5 line items — contract methods wrapped (AuthApi ✓ Task 6/9, UserApi ✓ Task 8/9), web imports flipped (✓ Tasks 11–12 minus documented deferrals), storage-adapter port settled (✓ Task 3), React-agnostic opensecret adopted (✓ Task 1), port shapes settled (✓ Tasks 3–5). +2. **Placeholder scan:** no TBDs; every step has code or exact commands. +3. **Type consistency:** `AuthKeyValueStore`/`AuthStorage` (Task 3) = what `guest-account-storage.ts` (Task 5), `AuthService` (Task 6), and `browserStorage` (Task 10) consume; `OpenSecretAuthApi` fn names = RC exports; `SetDefaultAccountParams.setDefaultCurrency` used in Task 12 Step 4. +4. **Parity scan:** every master behavior either preserved or listed under "Accepted behavior deltas". diff --git a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md index 9e020ceff..19af95815 100644 --- a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +++ b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md @@ -44,7 +44,10 @@ type SdkConfig = { storageDir?: string; // node hosts; browser default applies }; lightningAddressDomain: string; // lud16 domain for contacts/display - logger?: Logger; // diagnostic sink; MCP stdio hosts route to stderr + logger: Logger; // diagnostic sink; MCP stdio hosts route to stderr. + // Required; hosts that want no logging pass the + // exported `nullLogger` (explicit choice over a + // silently-absent default). }; // Illustrative shape — binds to the React-agnostic @agicash/opensecret release's @@ -332,6 +335,7 @@ only an id — the asymmetry is intentional, not an oversight. ```ts type WalletEventMap = { 'auth.session-expired': Record; // session died without signOut() (expiry / failed refresh) + 'auth.session-refreshed': Record; // SDK-initiated refresh (guest auto-extend); host verbs never fire it — added by the auth slice (step-5 plan, A13) 'user.updated': { user: User }; 'account.created' | 'account.updated': { account: Account }; 'account.balance-changed': { accountId: string; balance: Money }; // both rails; no version @@ -459,6 +463,8 @@ helpers the web consumes that need no instance state: WebAssembly is unavailable; web `instanceof`-checks it for the fallback UI). Subclass semantics are contract: `DomainError.message` is the only user-displayable message; `ConcurrencyError` always means retry. +- logging: `nullLogger` — the no-op `Logger` hosts pass when they want no + diagnostics (the `logger` config port is required) - exchange rate: `exchangeRate` — provider fallback chain (mempool → coingecko → coinbase); holds no instance state or ports, so a rate lookup needs no `Sdk`. diff --git a/package.json b/package.json index 20b311b4c..47b6ca783 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", @@ -38,8 +39,8 @@ "fix:all": "biome check --write --verbose", "fix:staged": "biome check --write --staged --verbose", "check:all": "run-p typecheck lint:check format:check", - "supabase": "supabase --workdir packages/wallet-sdk", - "db:generate-types": "supabase gen types typescript --local --schema wallet --workdir packages/wallet-sdk > packages/wallet-sdk/supabase/database.types.ts" + "supabase": "supabase --workdir packages/wallet-sdk/db", + "db:generate-types": "supabase gen types typescript --local --schema wallet --workdir packages/wallet-sdk/db > packages/wallet-sdk/db/supabase/database.types.ts" }, "scripts:comments": { "dev": "Delegates to apps/web-wallet. The web dev server uses tsx instead of bun because with bun the server hangs and eventually errors with 'ELOOP: too many symbolic links encountered...'.", diff --git a/packages/utils/package.json b/packages/utils/package.json index 14b06b138..b75e69879 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -12,6 +12,7 @@ "dependencies": { "@noble/ciphers": "catalog:", "@noble/hashes": "catalog:", + "jwt-decode": "catalog:", "type-fest": "catalog:", "zod": "catalog:" }, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 5fae18a36..73992e90b 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,6 +1,8 @@ export * from './collections'; export * from './json'; +export * from './jwt'; export * from './zod'; export * from './type-utils'; export * from './sha256'; +export * from './timeout'; export * from './xchacha20poly1305'; diff --git a/packages/utils/src/jwt.ts b/packages/utils/src/jwt.ts new file mode 100644 index 000000000..ecbdf1f59 --- /dev/null +++ b/packages/utils/src/jwt.ts @@ -0,0 +1,15 @@ +import { type JwtPayload, jwtDecode } from 'jwt-decode'; + +/** + * Decodes a JWT's payload, returning null for an undecodable token instead of + * throwing. For tokens read from storage, which may be corrupt. A token just + * minted by a server should be decoded with `jwtDecode` directly, so a + * malformed one fails its operation loudly instead of passing as absent. + */ +export const safeJwtDecode = (token: string): JwtPayload | null => { + try { + return jwtDecode(token); + } catch { + return null; + } +}; diff --git a/apps/web-wallet/app/lib/timeout.ts b/packages/utils/src/timeout.ts similarity index 63% rename from apps/web-wallet/app/lib/timeout.ts rename to packages/utils/src/timeout.ts index cc4a18d41..7c677a410 100644 --- a/apps/web-wallet/app/lib/timeout.ts +++ b/packages/utils/src/timeout.ts @@ -7,9 +7,11 @@ export type LongTimeout = { }; /** - * setTimeout alternative that supports bigger delays. Default setTimeout only supports delay up to ~24.8 days. + * setTimeout alternative that supports delays beyond setTimeout's ~24.8 day + * cap. Like setTimeout, the callback always runs asynchronously: a delay of 0 + * or less schedules it on the next tick rather than invoking it inline. * @param callback Callback to be invoked after the delay - * @param delay Delay after which callback should be executed + * @param delay Delay in milliseconds after which the callback runs * @returns {LongTimeout}. To clear the long timeout use `clearLongTimeout` function */ export function setLongTimeout( @@ -21,16 +23,11 @@ export function setLongTimeout( const longTimeout: LongTimeout = { id: null }; function scheduleNext() { - const elapsed = Date.now() - start; - - if (elapsed >= delay) { - callback(); + const remaining = delay - (Date.now() - start); + if (remaining > maxSetTimeoutDelay) { + longTimeout.id = setTimeout(scheduleNext, maxSetTimeoutDelay); } else { - const remaining = delay - elapsed; - longTimeout.id = setTimeout( - scheduleNext, - Math.min(remaining, maxSetTimeoutDelay), - ); + longTimeout.id = setTimeout(callback, Math.max(remaining, 0)); } } diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index 0814fa583..efe3145de 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -4,7 +4,7 @@ "exclude": ["node_modules"], "compilerOptions": { "lib": ["ES2022"], - "types": [], + "types": ["bun"], "noEmit": true } } diff --git a/packages/wallet-sdk/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/json-models/cashu-lightning-send-db-data.ts b/packages/wallet-sdk/db/json-models/cashu-lightning-send-db-data.ts index 53009038e..b3d7b0f4c 100644 --- a/packages/wallet-sdk/db/json-models/cashu-lightning-send-db-data.ts +++ b/packages/wallet-sdk/db/json-models/cashu-lightning-send-db-data.ts @@ -1,6 +1,6 @@ import { Money } from '@agicash/money'; import { z } from 'zod/mini'; -import { DestinationDetailsSchema } from '../../lib/send-destination'; +import { DestinationDetailsSchema } from '../../domain/send/send-destination'; /** * Schema for cashu lightning send db data. 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..5aeddb26c --- /dev/null +++ b/packages/wallet-sdk/db/supabase-session.ts @@ -0,0 +1,75 @@ +import { jwtDecode } from 'jwt-decode'; + +type Deps = { + isLoggedIn: () => boolean; + /** Exchanges the Open Secret JWT for a Supabase third-party token. */ + 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; + let cached: { token: string; refreshAtMs: number } | undefined; + let inFlight: Promise | undefined; + // Incremented on invalidation; an exchange started under an older + // generation must not populate the cache — its token belongs to the ended + // session. + let generation = 0; + + const invalidate = () => { + generation += 1; + cached = undefined; + inFlight = undefined; + }; + + return { + reset: invalidate, + getToken: async () => { + if (!deps.isLoggedIn()) { + // Same full invalidation as reset(): an exchange in flight when the + // session ended must not populate the cache either. + invalidate(); + return null; + } + if (cached && Date.now() < cached.refreshAtMs) { + return cached.token; + } + if (!inFlight) { + const startedGeneration = generation; + inFlight = (async () => { + try { + const { token } = await generateToken(); + if (generation === startedGeneration) { + const { exp } = jwtDecode(token); + cached = { token, refreshAtMs: exp ? (exp - 5) * 1000 : 0 }; + } + return token; + } finally { + if (generation === startedGeneration) { + inFlight = undefined; + } + } + })(); + } + return inFlight; + }, + }; +} diff --git a/packages/wallet-sdk/supabase/.env b/packages/wallet-sdk/db/supabase/.env similarity index 100% rename from packages/wallet-sdk/supabase/.env rename to packages/wallet-sdk/db/supabase/.env diff --git a/packages/wallet-sdk/supabase/.gitignore b/packages/wallet-sdk/db/supabase/.gitignore similarity index 100% rename from packages/wallet-sdk/supabase/.gitignore rename to packages/wallet-sdk/db/supabase/.gitignore diff --git a/packages/wallet-sdk/supabase/config.toml b/packages/wallet-sdk/db/supabase/config.toml similarity index 100% rename from packages/wallet-sdk/supabase/config.toml rename to packages/wallet-sdk/db/supabase/config.toml diff --git a/packages/wallet-sdk/supabase/database.types.ts b/packages/wallet-sdk/db/supabase/database.types.ts similarity index 100% rename from packages/wallet-sdk/supabase/database.types.ts rename to packages/wallet-sdk/db/supabase/database.types.ts diff --git a/packages/wallet-sdk/supabase/migrations/20260112150000_initial_db.sql b/packages/wallet-sdk/db/supabase/migrations/20260112150000_initial_db.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260112150000_initial_db.sql rename to packages/wallet-sdk/db/supabase/migrations/20260112150000_initial_db.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260202120000_make_terms_nullable.sql b/packages/wallet-sdk/db/supabase/migrations/20260202120000_make_terms_nullable.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260202120000_make_terms_nullable.sql rename to packages/wallet-sdk/db/supabase/migrations/20260202120000_make_terms_nullable.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260223193702_feature_flags.sql b/packages/wallet-sdk/db/supabase/migrations/20260223193702_feature_flags.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260223193702_feature_flags.sql rename to packages/wallet-sdk/db/supabase/migrations/20260223193702_feature_flags.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260225000000_add_debug_logging_flag.sql b/packages/wallet-sdk/db/supabase/migrations/20260225000000_add_debug_logging_flag.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260225000000_add_debug_logging_flag.sql rename to packages/wallet-sdk/db/supabase/migrations/20260225000000_add_debug_logging_flag.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260225120000_enforce_gift_card_feature_flag.sql b/packages/wallet-sdk/db/supabase/migrations/20260225120000_enforce_gift_card_feature_flag.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260225120000_enforce_gift_card_feature_flag.sql rename to packages/wallet-sdk/db/supabase/migrations/20260225120000_enforce_gift_card_feature_flag.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260302120000_add_version_to_transactions.sql b/packages/wallet-sdk/db/supabase/migrations/20260302120000_add_version_to_transactions.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260302120000_add_version_to_transactions.sql rename to packages/wallet-sdk/db/supabase/migrations/20260302120000_add_version_to_transactions.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260306120000_add_transaction_purpose_and_transfer_id.sql b/packages/wallet-sdk/db/supabase/migrations/20260306120000_add_transaction_purpose_and_transfer_id.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260306120000_add_transaction_purpose_and_transfer_id.sql rename to packages/wallet-sdk/db/supabase/migrations/20260306120000_add_transaction_purpose_and_transfer_id.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260310120000_move_transfer_id_to_jsonb.sql b/packages/wallet-sdk/db/supabase/migrations/20260310120000_move_transfer_id_to_jsonb.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260310120000_move_transfer_id_to_jsonb.sql rename to packages/wallet-sdk/db/supabase/migrations/20260310120000_move_transfer_id_to_jsonb.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260312163752_transfer_id_text_to_uuid.sql b/packages/wallet-sdk/db/supabase/migrations/20260312163752_transfer_id_text_to_uuid.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260312163752_transfer_id_text_to_uuid.sql rename to packages/wallet-sdk/db/supabase/migrations/20260312163752_transfer_id_text_to_uuid.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260320120000_add_offer_account_purpose.sql b/packages/wallet-sdk/db/supabase/migrations/20260320120000_add_offer_account_purpose.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260320120000_add_offer_account_purpose.sql rename to packages/wallet-sdk/db/supabase/migrations/20260320120000_add_offer_account_purpose.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260325120000_add_account_state.sql b/packages/wallet-sdk/db/supabase/migrations/20260325120000_add_account_state.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260325120000_add_account_state.sql rename to packages/wallet-sdk/db/supabase/migrations/20260325120000_add_account_state.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260325130000_default_account_no_expiry.sql b/packages/wallet-sdk/db/supabase/migrations/20260325130000_default_account_no_expiry.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260325130000_default_account_no_expiry.sql rename to packages/wallet-sdk/db/supabase/migrations/20260325130000_default_account_no_expiry.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260413222901_generic_event_system.sql b/packages/wallet-sdk/db/supabase/migrations/20260413222901_generic_event_system.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260413222901_generic_event_system.sql rename to packages/wallet-sdk/db/supabase/migrations/20260413222901_generic_event_system.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260415180000_add_gift_card_mint_terms.sql b/packages/wallet-sdk/db/supabase/migrations/20260415180000_add_gift_card_mint_terms.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260415180000_add_gift_card_mint_terms.sql rename to packages/wallet-sdk/db/supabase/migrations/20260415180000_add_gift_card_mint_terms.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260420152512_denormalize_account_on_transactions.sql b/packages/wallet-sdk/db/supabase/migrations/20260420152512_denormalize_account_on_transactions.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260420152512_denormalize_account_on_transactions.sql rename to packages/wallet-sdk/db/supabase/migrations/20260420152512_denormalize_account_on_transactions.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260420192806_fix_emit_event_pg_net_signature.sql b/packages/wallet-sdk/db/supabase/migrations/20260420192806_fix_emit_event_pg_net_signature.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260420192806_fix_emit_event_pg_net_signature.sql rename to packages/wallet-sdk/db/supabase/migrations/20260420192806_fix_emit_event_pg_net_signature.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260422142259_fix_fail_cashu_receive_quote_state_guard.sql b/packages/wallet-sdk/db/supabase/migrations/20260422142259_fix_fail_cashu_receive_quote_state_guard.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260422142259_fix_fail_cashu_receive_quote_state_guard.sql rename to packages/wallet-sdk/db/supabase/migrations/20260422142259_fix_fail_cashu_receive_quote_state_guard.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260425181643_tighten_spark_send_payment_hash_uniqueness.sql b/packages/wallet-sdk/db/supabase/migrations/20260425181643_tighten_spark_send_payment_hash_uniqueness.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260425181643_tighten_spark_send_payment_hash_uniqueness.sql rename to packages/wallet-sdk/db/supabase/migrations/20260425181643_tighten_spark_send_payment_hash_uniqueness.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260505222417_remove_gift_cards_feature_flag.sql b/packages/wallet-sdk/db/supabase/migrations/20260505222417_remove_gift_cards_feature_flag.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260505222417_remove_gift_cards_feature_flag.sql rename to packages/wallet-sdk/db/supabase/migrations/20260505222417_remove_gift_cards_feature_flag.sql diff --git a/packages/wallet-sdk/supabase/migrations/20260522185431_add_users_broadcast_trigger.sql b/packages/wallet-sdk/db/supabase/migrations/20260522185431_add_users_broadcast_trigger.sql similarity index 100% rename from packages/wallet-sdk/supabase/migrations/20260522185431_add_users_broadcast_trigger.sql rename to packages/wallet-sdk/db/supabase/migrations/20260522185431_add_users_broadcast_trigger.sql diff --git a/packages/wallet-sdk/supabase/seed.sql b/packages/wallet-sdk/db/supabase/seed.sql similarity index 100% rename from packages/wallet-sdk/supabase/seed.sql rename to packages/wallet-sdk/db/supabase/seed.sql diff --git a/packages/wallet-sdk/lib/currencies.ts b/packages/wallet-sdk/domain/currencies.ts similarity index 100% rename from packages/wallet-sdk/lib/currencies.ts rename to packages/wallet-sdk/domain/currencies.ts diff --git a/packages/wallet-sdk/lib/exchange-rate/exchange-rate-service.test.ts b/packages/wallet-sdk/domain/exchange-rate/exchange-rate-service.test.ts similarity index 100% rename from packages/wallet-sdk/lib/exchange-rate/exchange-rate-service.test.ts rename to packages/wallet-sdk/domain/exchange-rate/exchange-rate-service.test.ts diff --git a/packages/wallet-sdk/lib/exchange-rate/exchange-rate-service.ts b/packages/wallet-sdk/domain/exchange-rate/exchange-rate-service.ts similarity index 100% rename from packages/wallet-sdk/lib/exchange-rate/exchange-rate-service.ts rename to packages/wallet-sdk/domain/exchange-rate/exchange-rate-service.ts diff --git a/packages/wallet-sdk/lib/exchange-rate/index.ts b/packages/wallet-sdk/domain/exchange-rate/index.ts similarity index 100% rename from packages/wallet-sdk/lib/exchange-rate/index.ts rename to packages/wallet-sdk/domain/exchange-rate/index.ts diff --git a/packages/wallet-sdk/lib/exchange-rate/providers/coinbase.ts b/packages/wallet-sdk/domain/exchange-rate/providers/coinbase.ts similarity index 100% rename from packages/wallet-sdk/lib/exchange-rate/providers/coinbase.ts rename to packages/wallet-sdk/domain/exchange-rate/providers/coinbase.ts diff --git a/packages/wallet-sdk/lib/exchange-rate/providers/coingecko.ts b/packages/wallet-sdk/domain/exchange-rate/providers/coingecko.ts similarity index 100% rename from packages/wallet-sdk/lib/exchange-rate/providers/coingecko.ts rename to packages/wallet-sdk/domain/exchange-rate/providers/coingecko.ts diff --git a/packages/wallet-sdk/lib/exchange-rate/providers/mempool-space.ts b/packages/wallet-sdk/domain/exchange-rate/providers/mempool-space.ts similarity index 100% rename from packages/wallet-sdk/lib/exchange-rate/providers/mempool-space.ts rename to packages/wallet-sdk/domain/exchange-rate/providers/mempool-space.ts diff --git a/packages/wallet-sdk/lib/exchange-rate/providers/types.ts b/packages/wallet-sdk/domain/exchange-rate/providers/types.ts similarity index 100% rename from packages/wallet-sdk/lib/exchange-rate/providers/types.ts rename to packages/wallet-sdk/domain/exchange-rate/providers/types.ts diff --git a/packages/wallet-sdk/lib/feature-flag-service.test.ts b/packages/wallet-sdk/domain/feature-flags/feature-flag-service.test.ts similarity index 98% rename from packages/wallet-sdk/lib/feature-flag-service.test.ts rename to packages/wallet-sdk/domain/feature-flags/feature-flag-service.test.ts index 70359879f..422838db4 100644 --- a/packages/wallet-sdk/lib/feature-flag-service.test.ts +++ b/packages/wallet-sdk/domain/feature-flags/feature-flag-service.test.ts @@ -1,5 +1,5 @@ import { describe, expect, mock, test } from 'bun:test'; -import type { AgicashDb } from '../db/database'; +import type { AgicashDb } from '../../db/database'; import { FEATURE_FLAG_DEFAULTS, configureFeatureFlags, diff --git a/packages/wallet-sdk/lib/feature-flag-service.ts b/packages/wallet-sdk/domain/feature-flags/feature-flag-service.ts similarity index 98% rename from packages/wallet-sdk/lib/feature-flag-service.ts rename to packages/wallet-sdk/domain/feature-flags/feature-flag-service.ts index a3da005ce..3a9cb2dc1 100644 --- a/packages/wallet-sdk/lib/feature-flag-service.ts +++ b/packages/wallet-sdk/domain/feature-flags/feature-flag-service.ts @@ -1,4 +1,4 @@ -import type { AgicashDb } from '../db/database'; +import type { AgicashDb } from '../../db/database'; export type FeatureFlag = 'GUEST_SIGNUP' | 'DEBUG_LOGGING_SPARK'; export type FeatureFlags = Record; diff --git a/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts b/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts index 63386b274..ca79d7630 100644 --- a/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts +++ b/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts @@ -1,9 +1,9 @@ import type { Payment } from '@agicash/breez-sdk-spark'; import type { Token } from '@cashu/cashu-ts'; import { DomainError } from '../../lib/error'; -import type { Ticker } from '../../lib/exchange-rate'; import type { Account, CashuAccount, SparkAccount } from '../accounts/account'; import type { AccountService } from '../accounts/account-service'; +import type { Ticker } from '../exchange-rate'; import type { User } from '../user/user'; import { UserService } from '../user/user-service'; import type { CashuReceiveQuoteService } from './cashu-receive-quote-service'; diff --git a/packages/wallet-sdk/domain/receive/lightning-address-service.ts b/packages/wallet-sdk/domain/receive/lightning-address-service.ts index b766a1f9b..b152cb565 100644 --- a/packages/wallet-sdk/domain/receive/lightning-address-service.ts +++ b/packages/wallet-sdk/domain/receive/lightning-address-service.ts @@ -16,8 +16,8 @@ import { base64url } from '@scure/base'; import { z } from 'zod/mini'; import type { AgicashDb } from '../../db/database'; import { NotFoundError } from '../../lib/error'; -import { ExchangeRateService } from '../../lib/exchange-rate'; import { type SparkWalletConfig, getSparkWallet } from '../../lib/spark/wallet'; +import { ExchangeRateService } from '../exchange-rate'; import { ReadUserDefaultAccountRepository, ReadUserRepository, diff --git a/packages/wallet-sdk/domain/sdk/accounts.ts b/packages/wallet-sdk/domain/sdk/accounts.ts new file mode 100644 index 000000000..d0cd783e7 --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/accounts.ts @@ -0,0 +1,19 @@ +import type { Account, CashuAccount, SparkAccount } from '../accounts/account'; + +// The public account types are the domain entities for now: only the apps +// consume the SDK and they just read these shapes, so fields like proofs, +// keysetCounters, and wallet ride along until a later slice narrows the surface +// (#1164). Cashu accounts carry no balance field — consumers sum the exposed +// proofs (getAccountBalance does this). +export type { Account, CashuAccount, SparkAccount }; + +export type AccountsApi = { + get(id: string): Promise; + /** 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/domain/sdk/auth.ts b/packages/wallet-sdk/domain/sdk/auth.ts new file mode 100644 index 000000000..dec70a557 --- /dev/null +++ b/packages/wallet-sdk/domain/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 the task processor, 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/domain/sdk/contacts.ts b/packages/wallet-sdk/domain/sdk/contacts.ts new file mode 100644 index 000000000..0c4604f74 --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/contacts.ts @@ -0,0 +1,13 @@ +import type { Contact as DomainContact } from '../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/domain/sdk/events.test.ts b/packages/wallet-sdk/domain/sdk/events.test.ts new file mode 100644 index 000000000..ac291c233 --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/events.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'bun:test'; +import { nullLogger } from '../../lib/logger'; +import { WalletEventEmitter } from './events'; + +describe('WalletEventEmitter', () => { + it('delivers payloads to subscribed handlers', () => { + const emitter = new WalletEventEmitter(nullLogger); + const received: unknown[] = []; + emitter.on('auth.session-expired', (payload) => received.push(payload)); + + emitter.emit('auth.session-expired', {}); + + expect(received).toEqual([{}]); + }); + + it('stops delivering after unsubscribe', () => { + const emitter = new WalletEventEmitter(nullLogger); + let calls = 0; + const unsubscribe = emitter.on('auth.session-expired', () => { + calls += 1; + }); + + unsubscribe(); + emitter.emit('auth.session-expired', {}); + + expect(calls).toBe(0); + }); + + it('does not deliver the current emit to a handler subscribed mid-emit', () => { + const emitter = new WalletEventEmitter(nullLogger); + const order: string[] = []; + let lateHandlerSubscribed = false; + emitter.on('auth.session-expired', () => { + order.push('first'); + if (!lateHandlerSubscribed) { + lateHandlerSubscribed = true; + emitter.on('auth.session-expired', () => { + order.push('late'); + }); + } + }); + + emitter.emit('auth.session-expired', {}); + expect(order).toEqual(['first']); + + emitter.emit('auth.session-expired', {}); + expect(order).toEqual(['first', 'first', 'late']); + }); + + it('isolates a throwing handler and reports it to the logger', () => { + const errors: string[] = []; + const emitter = new WalletEventEmitter({ + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: (message) => { + errors.push(message); + }, + }); + let secondHandlerRan = false; + emitter.on('auth.session-expired', () => { + throw new Error('boom'); + }); + emitter.on('auth.session-expired', () => { + secondHandlerRan = true; + }); + + emitter.emit('auth.session-expired', {}); + + expect(secondHandlerRan).toBe(true); + expect(errors).toHaveLength(1); + }); +}); diff --git a/packages/wallet-sdk/domain/sdk/events.ts b/packages/wallet-sdk/domain/sdk/events.ts new file mode 100644 index 000000000..214d3fab7 --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/events.ts @@ -0,0 +1,121 @@ +import type { Money } from '@agicash/money'; +import type { Logger } from '.'; +import type { SdkError } from '../../lib/error'; +import type { User } from '../user/user'; +import type { Account } from './accounts'; +import type { Contact } from './contacts'; +import type { + CashuReceiveQuote, + CashuReceiveSwap, + SparkReceiveQuote, +} from './receive'; +import type { CashuSendQuote, CashuSendSwap, SparkSendQuote } from './send'; +import type { TaskProcessorState } from './task-processor'; +import type { Transaction } from './transactions'; + +/** + * Payloads are decrypted domain objects. Naming: `.` (e.g. + * `created`, `updated`) per entity; terminal transitions arrive as `updated` + * with the new state on the payload. Adding events is non-breaking; renaming + * is breaking. + */ +export type WalletEventMap = { + /** The session died without a `signOut()` call (expiry / failed refresh). */ + 'auth.session-expired': Record; + /** + * The SDK refreshed the session on its own — today: the guest auto-extension + * at refresh-token expiry. It never fires for a sign-in, sign-out, or other + * host-called auth method (the host already knows about those). Hosts re-sync + * session-derived state from it. + */ + '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 }; + /** + * The realtime data channel to Supabase — the per-user subscription that + * streams row changes, not the auth or network connection. `connected` fires + * on every transition into the connected state (including the first) and is + * the invalidate-all / refetch-after-a-gap signal; `error` is terminal after + * retries exhaust, distinct from a long `reconnecting`. + */ + 'connection.changed': { state: 'connected' | 'reconnecting' | 'error' }; + /** + * Fires on every `state` transition; `error` set on transitions into + * `'error'`. Per-task errors never change state, so they never fire it. + */ + 'task-processor.state-changed': { + state: TaskProcessorState; + 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; +}; + +type Handler = (payload: never) => void; + +export class WalletEventEmitter implements WalletEvents { + private readonly handlers = new Map>(); + + constructor(private readonly logger: Logger) {} + + on( + event: K, + handler: (payload: WalletEventMap[K]) => void, + ): () => void { + const set = this.handlers.get(event) ?? new Set(); + set.add(handler as Handler); + this.handlers.set(event, set); + return () => { + set.delete(handler as Handler); + }; + } + + emit( + event: K, + payload: WalletEventMap[K], + ): void { + const set = this.handlers.get(event); + if (!set) { + return; + } + // Snapshot: a handler that (un)subscribes mid-emit must not change the + // current dispatch. + for (const handler of [...set]) { + try { + (handler as (payload: WalletEventMap[K]) => void)(payload); + } catch (error) { + this.logger.error(`Event handler for ${event} threw`, error); + } + } + } +} diff --git a/packages/wallet-sdk/domain/sdk/feature-flags.ts b/packages/wallet-sdk/domain/sdk/feature-flags.ts new file mode 100644 index 000000000..de7fee44f --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/feature-flags.ts @@ -0,0 +1,8 @@ +import type { FeatureFlag } from '../feature-flags/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/domain/sdk/index.ts b/packages/wallet-sdk/domain/sdk/index.ts new file mode 100644 index 000000000..b62039e7a --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/index.ts @@ -0,0 +1,105 @@ +// Public contract of @agicash/wallet-sdk, one file per namespace. Prose +// contract: docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +// +// The entity types the namespaces expose are public projections of the domain +// entities: `userId`/`ownerId` are implicit from the session; raw wallet +// handles and proof material stay internal. +import type { SparkNetwork } from '../../db/json-models/spark-account-details-db-data'; +import type { AccountsApi } from './accounts'; +import type { AuthApi, AuthStorage } from './auth'; +import type { ContactsApi } from './contacts'; +import type { WalletEvents } from './events'; +import type { FeatureFlagsApi } from './feature-flags'; +import type { ReceiveApi } from './receive'; +import type { SendApi } from './send'; +import type { TaskProcessorApi } from './task-processor'; +import type { TransactionsApi } from './transactions'; +import type { TransferApi } from './transfer'; +import type { UserApi } from './user'; + +export * from './accounts'; +export * from './auth'; +export * from './contacts'; +export * from './events'; +export * from './feature-flags'; +export * from './receive'; +export * from './send'; +export * from './server'; +export * from './task-processor'; +export * from './transactions'; +export * from './transfer'; +export * from './user'; + +/** 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 for host-supplied guest credentials + * (e.g. an e2e password mock). + */ + generateGuestPassword?: () => Promise; + }; + spark: { + breezApiKey: string; + /** Default for account creation; the persisted per-account value is authoritative. */ + network: SparkNetwork; + /** Node hosts; browser default applies. */ + storageDir?: string; + }; + /** lud16 domain. */ + lightningAddressDomain: string; + /** Diagnostic sink; hosts that want no logging pass the package's `nullLogger` export. */ + logger: Logger; +}; + +export type Sdk = { + readonly auth: AuthApi; + readonly user: UserApi; + readonly accounts: AccountsApi; + readonly contacts: ContactsApi; + readonly transactions: TransactionsApi; + readonly receive: ReceiveApi; + readonly send: SendApi; + readonly transfer: TransferApi; + readonly featureFlags: FeatureFlagsApi; + readonly events: WalletEvents; + readonly taskProcessor: TaskProcessorApi; + /** + * Front-loads session restore and the Breez WASM load. Resolves when no + * session exists (a state, not a failure); rejects on actual failures, + * 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 task-processor transitions to their next checkpoint, then + * tears down realtime + the task processor; still-pending namespace promises + * reject with a typed `SdkError`. + */ + dispose(): Promise; +}; + +/** `create` is sync; no I/O. */ +export type SdkConstructor = { + create(config: SdkConfig): Sdk; +}; diff --git a/packages/wallet-sdk/domain/sdk/receive.ts b/packages/wallet-sdk/domain/sdk/receive.ts new file mode 100644 index 000000000..df3b14843 --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/receive.ts @@ -0,0 +1,51 @@ +import type { CashuReceiveQuote } from '../receive/cashu-receive-quote'; +import type { CashuReceiveLightningQuote } from '../receive/cashu-receive-quote-core'; +import type { SparkReceiveQuote } from '../receive/spark-receive-quote'; +import type { SparkReceiveLightningQuote } from '../receive/spark-receive-quote-core'; + +// The public receive types are the domain entities for now: only the apps +// consume the SDK and they just read these shapes, so the extra domain fields +// (e.g. proofs) ride along until a later slice narrows the surface (#1164). +export type { CashuReceiveSwap } from '../receive/cashu-receive-swap'; +export type { CashuReceiveQuote, SparkReceiveQuote }; + +/** + * `get*` methods are stateless previews; `create*` methods persist and enter + * the entity into the task-processor 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/domain/sdk/sdk.test.ts b/packages/wallet-sdk/domain/sdk/sdk.test.ts new file mode 100644 index 000000000..e701f1334 --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/sdk.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'bun:test'; +import type { AuthKeyValueStore, SdkConfig } from '.'; +import { nullLogger } from '../../lib/logger'; +import { AgicashSdk } 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/domain/sdk/sdk.ts b/packages/wallet-sdk/domain/sdk/sdk.ts new file mode 100644 index 000000000..a0c780b24 --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/sdk.ts @@ -0,0 +1,150 @@ +import * as openSecret from '@agicash/opensecret'; +import type { + AccountsApi, + AuthApi, + ContactsApi, + FeatureFlagsApi, + ReceiveApi, + Sdk, + SdkConfig, + SendApi, + TaskProcessorApi, + TransactionsApi, + TransferApi, + UserApi, + WalletEvents, +} from '.'; +import { createAgicashDbClient } from '../../db/client'; +import { createSupabaseSessionTokenGetter } from '../../db/supabase-session'; +import { clearAgicashMintAuthToken } from '../../lib/agicash-mint-auth-provider'; +import { NotImplementedError } from '../../lib/error'; +import { generateRandomPassword } from '../../lib/password'; +import { clearSparkWallets } from '../../lib/spark/wallet'; +import { AuthService } from '../user/auth-service'; +import { createUserApi } from '../user/user-api'; +import { WalletEventEmitter } from './events'; + +// Makes the one-instance-per-process constraint (see the constructor note) +// self-enforcing: create() refuses to run while an undisposed instance holds +// the module-global Open Secret configuration. +let liveInstance: AgicashSdk | undefined; + +/** + * Runtime implementation of the SDK contract. Namespaces land slice by slice — + * auth, user, and events so far; accessing a namespace whose migration slice + * hasn't landed throws `NotImplementedError`. + */ +export class AgicashSdk implements Sdk { + readonly auth: AuthApi; + readonly user: UserApi; + readonly events: WalletEvents; + + get accounts(): AccountsApi { + throw new NotImplementedError('accounts'); + } + get contacts(): ContactsApi { + throw new NotImplementedError('contacts'); + } + get transactions(): TransactionsApi { + throw new NotImplementedError('transactions'); + } + get receive(): ReceiveApi { + throw new NotImplementedError('receive'); + } + get send(): SendApi { + throw new NotImplementedError('send'); + } + get transfer(): TransferApi { + throw new NotImplementedError('transfer'); + } + get featureFlags(): FeatureFlagsApi { + throw new NotImplementedError('featureFlags'); + } + get taskProcessor(): TaskProcessorApi { + throw new NotImplementedError('taskProcessor'); + } + + private readonly authService: AuthService; + + private constructor(config: SdkConfig) { + // 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, + generateToken: () => openSecret.generateThirdPartyToken(), + }); + + this.authService = new AuthService({ + os: openSecret, + storage: config.auth.storage, + 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. Anything wiped here + // must fence its own in-flight writes (a generation counter or abort + // scope) so a write resolving after this reset can't repopulate it — + // there is no cross-user backstop beyond each memo's own fence. + sessionToken.reset(); + clearSparkWallets(); + clearAgicashMintAuthToken(); + }, + logger: config.logger, + }); + + const db = createAgicashDbClient({ + url: config.db.url, + anonKey: config.db.anonKey, + accessToken: sessionToken.getToken, + }); + + this.auth = this.authService; + this.user = createUserApi({ + db, + getSession: () => this.authService.getSession(), + }); + this.events = events; + } + + /** Sync; no I/O. Throws when an undisposed instance already exists (see the constructor note). */ + static create(config: SdkConfig): AgicashSdk { + if (liveInstance) { + throw new Error( + 'An AgicashSdk instance already exists in this process. @agicash/opensecret holds module-global auth state, so dispose() the previous instance before creating another.', + ); + } + liveInstance = new AgicashSdk(config); + return liveInstance; + } + + /** + * Session restore only for now — the Breez WASM load folds in when the + * first Spark slice lands. Resolves when no session exists. Delegates + * to the auth service, which is single-flight and memoizes success but + * clears a rejection, so the host's query retries can recover. + */ + init(): Promise { + return this.authService.restoreSession(); + } + + async dispose(): Promise { + this.authService.teardown(); + if (liveInstance === this) { + liveInstance = undefined; + } + } +} diff --git a/packages/wallet-sdk/domain/sdk/send.ts b/packages/wallet-sdk/domain/sdk/send.ts new file mode 100644 index 000000000..0c600953e --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/send.ts @@ -0,0 +1,42 @@ +import type { CashuLightningQuote } from '../send/cashu-send-quote-service'; +import type { CashuSwapQuote } from '../send/cashu-send-swap-service'; +import type { DestinationDetails } from '../send/send-destination'; +import type { SparkLightningQuote } from '../send/spark-send-quote-service'; + +// The public send types are the domain entities for now: only the apps consume +// the SDK and they just read these shapes, so fields like proofs and userId +// ride along until a later slice narrows the surface (#1164). +export type { CashuSendQuote } from '../send/cashu-send-quote'; +export type { CashuSendSwap } from '../send/cashu-send-swap'; +export type { SparkSendQuote } from '../send/spark-send-quote'; + +export type SendApi = { + resolveDestination(input: string): Promise; + 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/domain/sdk/server.ts b/packages/wallet-sdk/domain/sdk/server.ts new file mode 100644 index 000000000..6e6bb87b2 --- /dev/null +++ b/packages/wallet-sdk/domain/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 `taskProcessor`. + */ +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/domain/sdk/task-processor.ts b/packages/wallet-sdk/domain/sdk/task-processor.ts new file mode 100644 index 000000000..f8c46aac5 --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/task-processor.ts @@ -0,0 +1,21 @@ +export type TaskProcessorState = 'stopped' | 'follower' | 'leader' | 'error'; + +/** + * Runs the money-moving work queue. A host must run `start()` somewhere or + * nothing moves money. The executing instance may differ from the initiating + * one (the leader lock is per-user across devices). + */ +export type TaskProcessorApi = { + /** + * 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: TaskProcessorState; +}; diff --git a/packages/wallet-sdk/domain/sdk/transactions.ts b/packages/wallet-sdk/domain/sdk/transactions.ts new file mode 100644 index 000000000..49aced82f --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/transactions.ts @@ -0,0 +1,18 @@ +import type { Transaction as DomainTransaction } from '../transactions/transaction'; +import type { Cursor } from '../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/domain/sdk/transfer.ts b/packages/wallet-sdk/domain/sdk/transfer.ts new file mode 100644 index 000000000..10670d915 --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/transfer.ts @@ -0,0 +1,10 @@ +import type { TransferQuote } from '../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/domain/sdk/user.ts b/packages/wallet-sdk/domain/sdk/user.ts new file mode 100644 index 000000000..343715399 --- /dev/null +++ b/packages/wallet-sdk/domain/sdk/user.ts @@ -0,0 +1,26 @@ +import type { Currency } from '@agicash/money'; +import type { Account } from '../accounts/account'; +import type { User } from '../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 = { + account: Account; + /** Also switch the user's default currency to the account's currency. */ + setDefaultCurrency?: boolean; +}; + +export type SetDefaultCurrencyParams = { + currency: Currency; +}; diff --git a/packages/wallet-sdk/domain/send/cashu-send-quote-service.ts b/packages/wallet-sdk/domain/send/cashu-send-quote-service.ts index f1d03dfe0..51a2851a9 100644 --- a/packages/wallet-sdk/domain/send/cashu-send-quote-service.ts +++ b/packages/wallet-sdk/domain/send/cashu-send-quote-service.ts @@ -11,10 +11,10 @@ import { OutputData, } from '@cashu/cashu-ts'; import type { Big } from 'big.js'; -import { getDefaultUnit } from '../../lib/currencies'; import { DomainError } from '../../lib/error'; import type { CashuAccount } from '../accounts/account'; import { type CashuProof, toProof } from '../accounts/cashu-account'; +import { getDefaultUnit } from '../currencies'; import type { TransactionPurpose } from '../transactions/transaction-enums'; import type { CashuSendQuote, DestinationDetails } from './cashu-send-quote'; import type { CashuSendQuoteRepository } from './cashu-send-quote-repository'; diff --git a/packages/wallet-sdk/domain/send/cashu-send-quote.ts b/packages/wallet-sdk/domain/send/cashu-send-quote.ts index 975978b9e..80202ecf1 100644 --- a/packages/wallet-sdk/domain/send/cashu-send-quote.ts +++ b/packages/wallet-sdk/domain/send/cashu-send-quote.ts @@ -1,9 +1,9 @@ import { Money } from '@agicash/money'; import { z } from 'zod/mini'; -import { DestinationDetailsSchema } from '../../lib/send-destination'; import { CashuProofSchema } from '../accounts/cashu-account'; +import { DestinationDetailsSchema } from './send-destination'; -export type { DestinationDetails } from '../../lib/send-destination'; +export type { DestinationDetails } from './send-destination'; /** * Base schema for cashu send quote. diff --git a/packages/wallet-sdk/domain/send/cashu-send-swap-service.ts b/packages/wallet-sdk/domain/send/cashu-send-swap-service.ts index 3f4cb4578..cd634276d 100644 --- a/packages/wallet-sdk/domain/send/cashu-send-swap-service.ts +++ b/packages/wallet-sdk/domain/send/cashu-send-swap-service.ts @@ -14,10 +14,10 @@ import { splitAmount, } from '@cashu/cashu-ts'; import { getTokenHash } from '../../lib/cashu'; -import { getDefaultUnit } from '../../lib/currencies'; import { DomainError } from '../../lib/error'; import type { CashuAccount } from '../accounts/account'; import { type CashuProof, toProof } from '../accounts/cashu-account'; +import { getDefaultUnit } from '../currencies'; import type { CashuReceiveSwapService } from '../receive/cashu-receive-swap-service'; import type { CashuSendSwap } from './cashu-send-swap'; import type { CashuSendSwapRepository } from './cashu-send-swap-repository'; diff --git a/packages/wallet-sdk/lib/send-destination.ts b/packages/wallet-sdk/domain/send/send-destination.ts similarity index 100% rename from packages/wallet-sdk/lib/send-destination.ts rename to packages/wallet-sdk/domain/send/send-destination.ts diff --git a/packages/wallet-sdk/domain/transactions/transaction-details/cashu-lightning-send-transaction-details.ts b/packages/wallet-sdk/domain/transactions/transaction-details/cashu-lightning-send-transaction-details.ts index a6015350e..fdc9a9be2 100644 --- a/packages/wallet-sdk/domain/transactions/transaction-details/cashu-lightning-send-transaction-details.ts +++ b/packages/wallet-sdk/domain/transactions/transaction-details/cashu-lightning-send-transaction-details.ts @@ -1,7 +1,7 @@ import { Money } from '@agicash/money'; import { z } from 'zod/mini'; import { CashuLightningSendDbDataSchema } from '../../../db/json-models/cashu-lightning-send-db-data'; -import { DestinationDetailsSchema } from '../../../lib/send-destination'; +import { DestinationDetailsSchema } from '../../send/send-destination'; import { TransactionStateSchema } from '../transaction-enums'; import type { TransactionDetailsParserShape } from './transaction-details-types'; 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..79f124d7c --- /dev/null +++ b/packages/wallet-sdk/domain/user/auth-service.test.ts @@ -0,0 +1,817 @@ +import { describe, expect, it } from 'bun:test'; +import { DisposedError } from '../../lib/error'; +import { nullLogger } from '../../lib/logger'; +import type { AuthKeyValueStore, AuthStorage } from '../sdk'; +import { WalletEventEmitter } from '../sdk/events'; +import { AuthService, type OpenSecretAuthApi } from './auth-service'; + +const createMemoryStore = (): AuthKeyValueStore & { + data: Map; +} => { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +const createStorage = (): AuthStorage & { + persistent: ReturnType; +} => ({ + persistent: createMemoryStore(), + session: createMemoryStore(), +}); + +const toBase64Url = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + +const createJwt = (expSecondsFromNow: number, sub = 'user-1') => + `${toBase64Url({ alg: 'none' })}.${toBase64Url({ + sub, + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + })}.sig`; + +const fullUser = { + id: 'user-1', + name: null, + email: 'a@b.c', + email_verified: true, + login_method: 'email', + created_at: '2026-01-01', + updated_at: '2026-01-01', +}; + +const guestUser = { ...fullUser, email: undefined }; + +const createOsFake = ( + tokenStore: ReturnType, + overrides: Partial = {}, +) => { + const calls: string[] = []; + // The real Open Secret SDK persists fresh tokens on every login path; the + // fakes mirror that so a timer re-arm after login reads a live refresh token. + const login = (id: string) => { + tokenStore.data.set('access_token', createJwt(600, id)); + tokenStore.data.set('refresh_token', createJwt(3600, id)); + return { id, access_token: 'a', refresh_token: 'r' }; + }; + const os: OpenSecretAuthApi = { + fetchUser: async () => ({ user: fullUser }), + signIn: async () => login('user-1'), + signUp: async () => login('user-1'), + signUpGuest: async () => login('guest-1'), + signInGuest: async () => login('guest-1'), + // The real SDK swallows the network logout failure and clears its token + // keys unconditionally. + signOut: async () => { + tokenStore.data.delete('access_token'); + tokenStore.data.delete('refresh_token'); + }, + verifyEmail: async () => undefined, + requestNewVerificationCode: async () => undefined, + convertGuestToUserAccount: async () => undefined, + initiateGoogleAuth: async () => ({ + auth_url: 'https://accounts.google/x', + csrf_token: 'c', + }), + handleGoogleCallback: async () => login('user-1'), + ...overrides, + }; + // wrap every fn to record invocation order + for (const key of Object.keys(os) as (keyof OpenSecretAuthApi)[]) { + const original = os[key] as (...args: unknown[]) => unknown; + // biome-ignore lint/suspicious/noExplicitAny: test instrumentation + (os as any)[key] = (...args: unknown[]) => { + calls.push(key); + return original(...args); + }; + } + return { os, calls }; +}; + +const createService = ( + options: { + os?: Partial; + storage?: ReturnType; + onSessionEnded?: () => void; + } = {}, +) => { + const storage = options.storage ?? createStorage(); + const { os, calls } = createOsFake(storage.persistent, options.os); + const events = new WalletEventEmitter(nullLogger); + const service = new AuthService({ + os, + storage, + generateGuestPassword: async () => 'generated-pw', + events, + onSessionEnded: options.onSessionEnded, + logger: nullLogger, + }); + return { service, storage, calls, events }; +}; + +describe('AuthService', () => { + it('starts anonymous', () => { + const { service } = createService(); + expect(service.getSession()).toEqual({ isLoggedIn: false }); + }); + + describe('restoreSession', () => { + it('stays anonymous without stored tokens and does not call fetchUser', async () => { + const { service, calls } = createService(); + await service.restoreSession(); + expect(service.getSession().isLoggedIn).toBe(false); + expect(calls).not.toContain('fetchUser'); + }); + + it('restores a session from stored tokens', async () => { + const { service, storage } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await service.restoreSession(); + + expect(service.getSession()).toEqual({ + isLoggedIn: true, + user: fullUser, + }); + service.teardown(); + }); + + it('rejects when tokens exist but the user fetch fails, then recovers on retry', async () => { + let sessionEnded = false; + let failFetch = true; + const { service, storage } = createService({ + os: { + fetchUser: async () => { + if (failFetch) { + throw new Error('network'); + } + return { user: fullUser }; + }, + }, + onSessionEnded: () => { + sessionEnded = true; + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await expect(service.restoreSession()).rejects.toThrow('network'); + // per-session caches were torn down with the failed restore + expect(sessionEnded).toBe(true); + expect(service.getSession().isLoggedIn).toBe(false); + + // the rejection is not memoized — a retry can succeed + failFetch = false; + await service.restoreSession(); + + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('stays anonymous when the stored refresh token is undecodable', async () => { + const { service, storage, calls } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', 'not-a-jwt'); + + await service.restoreSession(); + + expect(service.getSession().isLoggedIn).toBe(false); + expect(calls).not.toContain('fetchUser'); + }); + + it('does not clobber a session an auth action established while the restore fetch was in flight', async () => { + let releaseRestoreFetch = (): void => undefined; + const restoreFetchGate = new Promise((resolve) => { + releaseRestoreFetch = resolve; + }); + const actionUser = { ...fullUser, id: 'user-action' }; + let fetchCalls = 0; + const { service, storage } = createService({ + os: { + fetchUser: async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + await restoreFetchGate; + return { user: fullUser }; + } + return { user: actionUser }; + }, + }, + }); + 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('action@b.c', 'pw'); + releaseRestoreFetch(); + await restore; + + expect(service.getSession()).toEqual({ + isLoggedIn: true, + user: actionUser, + }); + service.teardown(); + }); + + it('does not resurrect a session signed out while the restore fetch was in flight', async () => { + let releaseRestoreFetch = (): void => undefined; + const restoreFetchGate = new Promise((resolve) => { + releaseRestoreFetch = resolve; + }); + const { service, storage } = createService({ + os: { + fetchUser: async () => { + await restoreFetchGate; + return { user: fullUser }; + }, + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + const restore = service.restoreSession(); + // Flush microtasks so the restore's gated fetchUser is in flight first. + await new Promise((resolve) => setTimeout(resolve, 0)); + await service.signOut(); + releaseRestoreFetch(); + await restore; + + // The sign-out aborted the restore's session scope, so its late apply is + // fenced out rather than resurrecting the signed-out session. + expect(service.getSession().isLoggedIn).toBe(false); + service.teardown(); + }); + + it('does not repeat the user fetch when an auth action already established the session', async () => { + const { service, calls } = createService(); + + await service.signIn('a@b.c', 'pw'); + 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 auth action's snapshot refresh: ends the session, un-memoizes A + throw new Error('auth action 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 aborted B's scope (which would fence its + // apply out) nor clobbered B's memo + expect(service.getSession().isLoggedIn).toBe(true); + await service.restoreSession(); + expect(fetchCalls).toBe(3); + service.teardown(); + }); + + it('is single-flight', async () => { + const { service, storage, calls } = createService(); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + + await Promise.all([service.restoreSession(), service.restoreSession()]); + + expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); + service.teardown(); + }); + + it('fences an in-flight restore that resolves after teardown', async () => { + let releaseRestoreFetch = (): void => undefined; + const restoreFetchGate = new Promise((resolve) => { + releaseRestoreFetch = resolve; + }); + let sessionEndedCount = 0; + const { service, storage, events } = createService({ + os: { + fetchUser: async () => { + await restoreFetchGate; + return { user: fullUser }; + }, + }, + onSessionEnded: () => { + sessionEndedCount += 1; + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + 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)); + + // Disposal aborts the session scope, so the late apply is skipped: the + // disposed instance never becomes logged-in, arms a timer, or runs + // onSessionEnded (which clears process-wide caches a successor may own). + expect(service.getSession().isLoggedIn).toBe(false); + expect(expired).toHaveLength(0); + expect(sessionEndedCount).toBe(0); + }); + }); + + describe('signUpGuest', () => { + it('creates a new guest account and stores the credentials', async () => { + const { service, storage, calls } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + + await service.signUpGuest(); + + expect(calls).toContain('signUpGuest'); + expect( + JSON.parse(storage.persistent.data.get('guestAccount') ?? ''), + ).toEqual({ + id: 'guest-1', + password: 'generated-pw', + }); + expect(service.getSession().isLoggedIn).toBe(true); + service.teardown(); + }); + + it('undoes the guest sign-up when persisting the credentials fails', async () => { + const storage = createStorage(); + const write = storage.persistent.setItem; + storage.persistent.setItem = (key, value) => { + if (key === 'guestAccount') { + throw new Error('quota exceeded'); + } + write(key, value); + }; + const { service, calls } = createService({ + storage, + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + + await expect(service.signUpGuest()).rejects.toThrow('quota exceeded'); + + // no live session on unpersistable credentials — it would strand the + // account (and any funds) at its first expiry + expect(calls).toContain('signOut'); + expect(service.getSession().isLoggedIn).toBe(false); + expect(storage.persistent.data.has('refresh_token')).toBe(false); + }); + + it('re-signs-in the stored guest account instead of creating a new one', async () => { + const { service, storage, calls } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'stored-pw' }), + ); + + await service.signUpGuest(); + + expect(calls).toContain('signInGuest'); + expect(calls).not.toContain('signUpGuest'); + service.teardown(); + }); + }); + + describe('signOut', () => { + it('clears the session, keeps guest credentials, and runs onSessionEnded', async () => { + let sessionEnded = false; + const { service, storage } = createService({ + onSessionEnded: () => { + sessionEnded = true; + }, + }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(3600)); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + await service.restoreSession(); + + await service.signOut(); + + expect(service.getSession().isLoggedIn).toBe(false); + expect(sessionEnded).toBe(true); + expect(storage.persistent.data.has('guestAccount')).toBe(true); + }); + + it('wipes the previous user caches when a different user logs in without signing out', async () => { + let sessionEndedCount = 0; + let fetchCalls = 0; + const userIds = ['user-a', 'user-b']; + const { service } = createService({ + os: { + fetchUser: async () => ({ + user: { ...fullUser, id: userIds[Math.min(fetchCalls++, 1)] }, + }), + }, + onSessionEnded: () => { + sessionEndedCount += 1; + }, + }); + + await service.signIn('a@b.c', 'pw'); // no previous live session → no wipe + await service.signIn('b@b.c', 'pw'); // different user over a live session → wipe + + expect(sessionEndedCount).toBe(1); + service.teardown(); + }); + + it('does not wipe again when a different user logs in after signing out', async () => { + let sessionEndedCount = 0; + let fetchCalls = 0; + const userIds = ['user-a', 'user-b']; + const { service } = createService({ + os: { + fetchUser: async () => ({ + 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 → wipe once + await service.signIn('b@b.c', 'pw'); // session already anonymous → no second wipe + + expect(sessionEndedCount).toBe(1); + service.teardown(); + }); + + it('keeps caches warm when the same user re-applies via guest extension', async () => { + let sessionEndedCount = 0; + const { service, storage, events } = createService({ + os: { fetchUser: async () => ({ user: guestUser }) }, + onSessionEnded: () => { + sessionEndedCount += 1; + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const refreshed: unknown[] = []; + events.on('auth.session-refreshed', (payload) => refreshed.push(payload)); + + await service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // the extension re-signs-in the SAME guest, so no cross-user wipe fires + expect(refreshed).toHaveLength(1); + expect(sessionEndedCount).toBe(0); + service.teardown(); + }); + }); + + 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 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(); + }); + + // A sign-out that lands while the guest extension's re-sign-in is in + // flight must not resurrect the session: the handler compensates by + // clearing the fresh tokens again (PR #1166 review, finding 1). + it('does not resurrect a session that was signed out while a guest extension was in flight', async () => { + let releaseSignInGuest = (): void => undefined; + const signInGuestGate = new Promise((resolve) => { + releaseSignInGuest = resolve; + }); + const storage = createStorage(); + const { service, calls, events } = createService({ + storage, + os: { + fetchUser: async () => ({ user: guestUser }), + signInGuest: async () => { + // The extension's fresh tokens land only once the gate opens — + // i.e. after the concurrent sign-out already cleared storage. + await signInGuestGate; + storage.persistent.data.set( + 'access_token', + createJwt(600, 'guest-1'), + ); + storage.persistent.data.set( + 'refresh_token', + createJwt(3600, 'guest-1'), + ); + return { id: 'guest-1', access_token: 'a', refresh_token: 'r' }; + }, + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const refreshed: unknown[] = []; + events.on('auth.session-refreshed', (payload) => refreshed.push(payload)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // let the expiry timer fire and park the extension on the gated sign-in + await new Promise((resolve) => setTimeout(resolve, 10)); + await service.signOut(); + releaseSignInGuest(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(service.getSession().isLoggedIn).toBe(false); + expect(refreshed).toHaveLength(0); + expect(expired).toHaveLength(0); + // the extension's freshly written tokens were cleared again instead of + // being left to resurrect the signed-out session on the next boot + expect(storage.persistent.data.has('refresh_token')).toBe(false); + expect(calls.filter((c) => c === 'fetchUser')).toHaveLength(1); + }); + + // A sign-out that lands after the first scope check, while the extension's + // own apply is in flight, must not resurrect the ended session — the apply + // is scope-guarded (PR #1166 review, round-3c H3). + it('does not resurrect a signed-out session while the post-extension apply is in flight', async () => { + let fetchCalls = 0; + let releaseExtensionFetch = (): void => undefined; + const extensionFetchGate = new Promise((resolve) => { + releaseExtensionFetch = resolve; + }); + const storage = createStorage(); + const { service, events } = createService({ + storage, + os: { + fetchUser: async () => { + fetchCalls += 1; + // Hold the post-extension refresh apply so a sign-out can race it. + if (fetchCalls === 2) { + await extensionFetchGate; + } + return { user: guestUser }; + }, + signInGuest: async () => { + storage.persistent.data.set( + 'access_token', + createJwt(600, 'guest-1'), + ); + storage.persistent.data.set( + 'refresh_token', + createJwt(3600, 'guest-1'), + ); + return { id: 'guest-1', access_token: 'a', refresh_token: 'r' }; + }, + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const refreshed: unknown[] = []; + events.on('auth.session-refreshed', (payload) => refreshed.push(payload)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // let the timer fire and park the extension on the gated post-apply fetch + await new Promise((resolve) => setTimeout(resolve, 10)); + await service.signOut(); + releaseExtensionFetch(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // the scoped extension apply is skipped, so the sign-out stands + expect(service.getSession().isLoggedIn).toBe(false); + expect(refreshed).toHaveLength(0); + expect(expired).toHaveLength(0); + expect(storage.persistent.data.has('refresh_token')).toBe(false); + }); + + // The SDK-lifecycle variant: teardown() lands mid-handler, so the death + // path bails before signing out — tokens survive for a successor instance + // (PR #1166 review, finding 1). + it('stops an in-flight expiry handler at teardown without clearing tokens', async () => { + let gateReads = false; + let releaseTokenRead = (): void => undefined; + const readGate = new Promise((resolve) => { + releaseTokenRead = resolve; + }); + const storage = createStorage(); + const readValue = storage.persistent.getItem; + storage.persistent.getItem = async (key) => { + if (gateReads) { + await readGate; + } + return readValue(key); + }; + const { service, calls, events } = createService({ storage }); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + // gate the handler's token re-read so teardown lands mid-handler + gateReads = true; + await new Promise((resolve) => setTimeout(resolve, 10)); + service.teardown(); + releaseTokenRead(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(calls).not.toContain('signOut'); + expect(expired).toHaveLength(0); + // teardown is not logout: tokens survive for a successor instance + expect(storage.persistent.data.has('refresh_token')).toBe(true); + }); + + it('ends the session when the extension cannot restore the guest user', async () => { + let fetchCalls = 0; + const { service, storage, events } = createService({ + os: { + fetchUser: async () => { + fetchCalls += 1; + // restore succeeds; the post-extend fetch fails + if (fetchCalls > 1) { + throw new Error('network'); + } + return { user: guestUser }; + }, + }, + }); + storage.persistent.data.set( + 'guestAccount', + JSON.stringify({ id: 'guest-1', password: 'pw' }), + ); + storage.persistent.data.set('access_token', createJwt(600)); + storage.persistent.data.set('refresh_token', createJwt(4)); + const expired: unknown[] = []; + events.on('auth.session-expired', (payload) => expired.push(payload)); + + await service.restoreSession(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // no wedged half-session: the death path ran and told the host + expect(expired).toHaveLength(1); + expect(service.getSession().isLoggedIn).toBe(false); + }); + }); + + it('initiateGoogleAuth returns the raw auth url', async () => { + const { service } = createService(); + expect(await service.initiateGoogleAuth()).toEqual({ + authUrl: 'https://accounts.google/x', + }); + }); + + it('completeGoogleAuth establishes the session', async () => { + const { service, calls } = createService(); + + await service.completeGoogleAuth({ code: 'auth-code', state: 'state' }); + + expect(calls).toContain('handleGoogleCallback'); + expect(service.getSession()).toEqual({ isLoggedIn: true, user: fullUser }); + service.teardown(); + }); + + describe('dispose', () => { + it('throws when an action is invoked after teardown', async () => { + const { service } = createService(); + service.teardown(); + + await expect(service.signIn('a@b.c', 'pw')).rejects.toBeInstanceOf( + DisposedError, + ); + await expect(service.restoreSession()).rejects.toBeInstanceOf( + DisposedError, + ); + }); + }); +}); diff --git a/packages/wallet-sdk/domain/user/auth-service.ts b/packages/wallet-sdk/domain/user/auth-service.ts new file mode 100644 index 000000000..71e558e92 --- /dev/null +++ b/packages/wallet-sdk/domain/user/auth-service.ts @@ -0,0 +1,470 @@ +import type { + GoogleAuthResponse, + LoginResponse, + UserResponse, +} from '@agicash/opensecret'; +import { + type LongTimeout, + clearLongTimeout, + safeJwtDecode, + setLongTimeout, +} from '@agicash/utils'; +import { DisposedError } from '../../lib/error'; +import { + type GuestAccountStorage, + createGuestAccountStorage, +} from '../../lib/guest-account-storage'; +import type { AuthApi, AuthSession, AuthStorage, Logger } from '../sdk'; +import type { WalletEventEmitter } from '../sdk/events'; + +// Keys are owned by @agicash/opensecret's token persistence; the service reads +// them (never writes) for session detection and expiry math. +const accessTokenKey = 'access_token'; +const refreshTokenKey = 'refresh_token'; + +/** The subset of @agicash/opensecret the auth service drives. `import * as openSecret` satisfies it. */ +export type OpenSecretAuthApi = { + fetchUser(): Promise; + signIn(email: string, password: string): Promise; + signUp( + email: string, + password: string, + inviteCode: string, + name?: string | null, + ): Promise; + signUpGuest(password: string, inviteCode: string): Promise; + signInGuest(id: string, password: string): Promise; + signOut(): Promise; + verifyEmail(code: string): Promise; + requestNewVerificationCode(): Promise; + convertGuestToUserAccount( + email: string, + password: string, + name?: string | null, + ): Promise; + initiateGoogleAuth(inviteCode?: string): Promise; + handleGoogleCallback( + code: string, + state: string, + inviteCode: string, + ): Promise; +}; + +type AuthServiceDeps = { + os: OpenSecretAuthApi; + storage: AuthStorage; + 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; + // Aborted and replaced on every session transition (a login apply or a + // session end). An in-flight restore holds the signal it started under; once + // that signal is aborted its result belongs to a session that no longer + // exists and must not be applied. + private sessionScope = new AbortController(); + private disposed = false; + private readonly guestAccountStorage: GuestAccountStorage; + + constructor(private readonly deps: AuthServiceDeps) { + this.guestAccountStorage = createGuestAccountStorage( + deps.storage.persistent, + deps.logger, + ); + } + + getSession(): AuthSession { + return this.session; + } + + /** + * Idempotent session restore from storage; resolving anonymous is + * a state, not a failure. A rejection (unreadable storage, failed user + * fetch with tokens present) is not memoized, so a retry can recover. + */ + async restoreSession(): Promise { + this.assertNotDisposed(); + if (!this.restorePromise) { + const restorePromise: Promise = this.doRestore().catch((error) => { + // Un-memoize only our own memo: a session end may already have + // 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 sign-in (or another auth action) already established the session and + // a preceding session end un-memoized the restore; booting from storage + // now would only repeat the user fetch that action already did. + return; + } + const [accessToken, refreshToken] = await Promise.all([ + this.deps.storage.persistent.getItem(accessTokenKey), + this.deps.storage.persistent.getItem(refreshTokenKey), + ]); + if (!accessToken || !refreshToken) { + return; + } + if (!safeJwtDecode(refreshToken)?.exp) { + // A refresh token with no readable expiry can't be scheduled for renewal + // and can't be refreshed, so the session would run until the token + // silently expired and then break with no way to recover. Stay anonymous + // instead of entering a session that can't be kept alive. + return; + } + const scope = this.sessionScope.signal; + try { + await this.applySessionFromServer({ scope }); + } catch (error) { + if (this.session.isLoggedIn) { + // An auth action established a session while this restore's fetch was + // in flight; the restore result is moot. + return; + } + if (scope.aborted) { + // A sign-out or a newer session apply owns the session state now; + // ending the session here would abort that owner's scope too. + throw error; + } + // Contract: init() rejects on refresh errors (tokens exist but can't + // 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 { + this.assertNotDisposed(); + await this.deps.os.signUp(email, password, ''); + await this.refreshSessionSnapshot('sign up'); + } + + async signUpGuest(): Promise { + this.assertNotDisposed(); + await this.signInGuestAccount(); + await this.refreshSessionSnapshot('guest sign up'); + } + + /** + * 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.guestAccountStorage.get(); + if (existingGuestAccount) { + await this.deps.os.signInGuest( + existingGuestAccount.id, + existingGuestAccount.password, + ); + return; + } + const password = await this.deps.generateGuestPassword(); + const guestAccount = await this.deps.os.signUpGuest(password, ''); + try { + await this.guestAccountStorage.store({ + id: guestAccount.id, + password, + }); + } catch (error) { + // Credentials that can't be persisted strand the account at its first + // expiry (the extension would mint a fresh guest and the funds would be + // unreachable). Undo the sign-up and fail loudly so the retry lands on + // a recoverable account. + try { + await this.deps.os.signOut(); + } catch (undoError) { + this.deps.logger.warn( + 'Failed to sign out a guest account with unpersisted credentials', + undoError, + ); + } + throw error; + } + } + + async signIn(email: string, password: string): Promise { + this.assertNotDisposed(); + await this.deps.os.signIn(email, password); + await this.refreshSessionSnapshot('sign in'); + } + + async signOut(): Promise { + this.assertNotDisposed(); + try { + await this.deps.os.signOut(); + } finally { + this.endSession(); + } + } + + async verifyEmail(code: string): Promise { + this.assertNotDisposed(); + await this.deps.os.verifyEmail(code); + await this.refreshSessionSnapshot('email verification'); + } + + async requestNewVerificationCode(): Promise { + this.assertNotDisposed(); + return this.deps.os.requestNewVerificationCode(); + } + + async convertGuestToFullAccount( + email: string, + password: string, + ): Promise { + this.assertNotDisposed(); + await this.deps.os.convertGuestToUserAccount(email, password); + await this.guestAccountStorage.clear(); + await this.refreshSessionSnapshot('guest conversion'); + } + + async initiateGoogleAuth(): Promise<{ authUrl: string }> { + this.assertNotDisposed(); + const response = await this.deps.os.initiateGoogleAuth(''); + return { authUrl: response.auth_url }; + } + + async completeGoogleAuth(params: { + code: string; + state: string; + }): Promise { + this.assertNotDisposed(); + await this.deps.os.handleGoogleCallback(params.code, params.state, ''); + await this.refreshSessionSnapshot('google auth'); + } + + /** + * Marks the instance disposed, aborts the session scope, and stops the expiry + * timer permanently. An in-flight restore is fenced: its late apply is + * skipped, so a disposed instance never writes a session or runs + * onSessionEnded — which clears process-wide caches a successor instance may + * already own. Auth actions called after this throw DisposedError. Stored + * tokens and the last session snapshot are left intact — disposal is not + * sign-out, so a successor instance (e.g. after a hot reload) can restore. + */ + teardown(): void { + this.disposed = true; + this.sessionScope.abort(); + this.clearExpiryTimer(); + } + + private assertNotDisposed(): void { + if (this.disposed) { + throw new DisposedError(); + } + } + + private async refreshSessionSnapshot( + context: string, + scope?: AbortSignal, + ): Promise { + try { + return await this.applySessionFromServer(scope ? { scope } : undefined); + } catch (error) { + // An auth action whose fetchUser fails leaves an anonymous session the + // host discovers on its next read. endSession (not a bare snapshot clear) + // runs so the per-session caches die with the session — the Supabase + // token cache in particular must never outlive it. The session was + // resolved (to anonymous) here, so this counts as applied, not skipped. + this.deps.logger.error(`Failed to fetch user (${context})`, error); + this.endSession(); + return true; + } + } + + /** + * Fetches the user and applies the session, returning whether it applied. + * Returns false without writing when the instance was disposed or `scope` was + * aborted while the fetch was in flight (a newer transition owns the state). + */ + private async applySessionFromServer(options?: { + /** Skip the apply if this scope was aborted while the fetch was in flight. */ + scope?: AbortSignal; + }): Promise { + const response = await this.deps.os.fetchUser(); + if (this.disposed || options?.scope?.aborted) { + // The instance was disposed, or a sign-out / newer apply won, while this + // fetch was in flight; the stale result must not write a session or run + // onSessionEnded. + return false; + } + // A different user's session is starting over a live one (login without + // sign-out) — wipe the previous user's per-session caches. + if (this.session.isLoggedIn && this.session.user.id !== response.user.id) { + this.deps.onSessionEnded?.(); + } + this.startNewSessionScope(); + this.session = { isLoggedIn: true, user: response.user }; + await this.setExpiryTimer(); + return true; + } + + private endSession(): void { + this.startNewSessionScope(); + this.session = { isLoggedIn: false }; + this.clearExpiryTimer(); + // Un-memoize the restore so the next init() re-evaluates from storage: an + // auth action whose post-login fetchUser failed leaves tokens behind, and + // the next invalidation can then recover the session. + this.restorePromise = undefined; + this.deps.onSessionEnded?.(); + } + + // Aborts the current session scope, fencing any in-flight restore or apply + // out, and installs a fresh scope for the session that is starting or ending. + private startNewSessionScope(): void { + this.sessionScope.abort(); + this.sessionScope = new AbortController(); + } + + private async setExpiryTimer(): Promise { + const remaining = await this.getRemainingSessionTimeMs(); + // Clear only after the await, so clear + check + assign run as one + // synchronous block: two overlapping calls can't interleave and orphan a + // timer, and a teardown during the await can't be overridden. + this.clearExpiryTimer(); + if (this.disposed || remaining === null) { + return; + } + this.expiryTimeout = setLongTimeout(() => { + this.handleSessionExpiry(); + }, remaining); + } + + /** + * Milliseconds until the stored refresh token is treated as expired, floored + * at 0. The 5s margin treats the token as expired early so a refresh runs + * before the real expiry. Null when the token is absent or undecodable. + */ + private async getRemainingSessionTimeMs(): Promise { + const refreshToken = + await this.deps.storage.persistent.getItem(refreshTokenKey); + if (!refreshToken) { + return null; + } + const decoded = safeJwtDecode(refreshToken); + if (!decoded?.exp) { + return null; + } + return Math.max((decoded.exp - 5) * 1000 - Date.now(), 0); + } + + private clearExpiryTimer(): void { + if (this.expiryTimeout) { + clearLongTimeout(this.expiryTimeout); + this.expiryTimeout = undefined; + } + } + + // Best-effort against concurrent session transitions, not race-free + // (PR #1166 review, finding 1). A sign-out that wins while the guest + // re-sign-in below is in flight is fenced two ways: the scope check right + // after the re-sign-in compensates by clearing the fresh tokens, and the + // extension's apply is scope-guarded so a sign-out during it can't resurrect + // the session the sign-out ended. teardown() is likewise checked before the + // re-sign-in's tokens or the death path are acted on. Residual windows: the + // token write inside os.signInGuest can still interleave with a sign-out's + // storage clear, and the cross-instance (HMR successor) case keeps a narrow + // gap — both require the timer to fire inside a sign-out's network round trip, + // on guest sessions only. + private async handleSessionExpiry(): Promise { + const session = this.session; + const scope = this.sessionScope.signal; + if (this.disposed || !session.isLoggedIn) { + return; + } + // Open Secret rotates the refresh token during its own internal refresh, so + // the expiry this timer was set for may have moved later. Re-read the + // stored token and, if it now expires further out, set the timer again + // instead of ending a session that is still alive. + const remaining = await this.getRemainingSessionTimeMs(); + if (remaining !== null && remaining > 0) { + await this.setExpiryTimer(); + return; + } + const isGuest = !session.user.email; + if (isGuest) { + try { + // Re-signing-in the stored guest account gets fresh tokens; the + // snapshot below re-sets the timer so the host never observes expiry. + await this.signInGuestAccount(); + if (this.disposed || scope.aborted) { + // A transition won the race while the re-sign-in was in flight. If a + // sign-out ended the session, the re-sign-in's fresh tokens would + // resurrect it on the next restore, so clear them again and revoke + // server-side (opensecret owns the keys, so its signOut is the whole + // cleanup). After teardown the tokens are left for the successor + // instance; a live session means another login won and owns them. + if (!this.disposed && !this.session.isLoggedIn) { + try { + await this.deps.os.signOut(); + } catch (error) { + this.deps.logger.warn( + 'Sign out compensating a raced guest extension failed', + error, + ); + } + } + return; + } + const applied = await this.refreshSessionSnapshot( + 'guest session extension', + scope, + ); + if (!applied) { + // A sign-out (or newer login) won while the extension's apply was in + // flight, so the apply skipped and that transition already owns the + // session state. Don't run the death path over it. + return; + } + const extendedRemaining = await this.getRemainingSessionTimeMs(); + if ( + extendedRemaining !== null && + 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); + } + } + if (this.disposed) { + // teardown() won the race: a successor instance may already own the + // stored session, so this dead instance must not sign out or emit. + return; + } + try { + await this.deps.os.signOut(); + } catch (error) { + 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/user-api.test.ts b/packages/wallet-sdk/domain/user/user-api.test.ts new file mode 100644 index 000000000..2b28a6289 --- /dev/null +++ b/packages/wallet-sdk/domain/user/user-api.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'bun:test'; +import type { Currency } from '@agicash/money'; +import type { AgicashDb } from '../../db/database'; +import type { Account } from '../accounts/account'; +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, +}); + +const account = (id: string, currency: Currency): Account => + ({ id, currency }) as unknown as Account; + +type Filters = Record; + +/** + * Fake covering the one query setDefaultAccount now issues: the users row + * update. A read from any other table would mean the api regressed to fetching + * the account instead of trusting the cached one the caller passes. + */ +const createDbFake = ( + 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 !== 'users') { + throw new Error(`unexpected read from "${table}"`); + } + 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 the cached account onto the session user, keyed by its currency', async () => { + let updatedUserId: unknown; + let updatedData: Record = {}; + const api = createUserApi({ + db: createDbFake((filters, data) => { + updatedUserId = filters.id; + updatedData = data; + }), + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + }); + + await api.setDefaultAccount({ account: account('acct-1', 'BTC') }); + + expect(updatedUserId).toBe('user-a'); + expect(updatedData.default_btc_account_id).toBe('acct-1'); + }); + }); +}); diff --git a/packages/wallet-sdk/domain/user/user-api.ts b/packages/wallet-sdk/domain/user/user-api.ts new file mode 100644 index 000000000..f7889f30b --- /dev/null +++ b/packages/wallet-sdk/domain/user/user-api.ts @@ -0,0 +1,47 @@ +import type { AgicashDb } from '../../db/database'; +import { NoSessionError } from '../../lib/error'; +import type { AuthSession, UserApi } from '../sdk'; +import { ReadUserRepository, UpdateUserRepository } 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 updateRepository = new UpdateUserRepository(deps.db); + const userService = new UserService(updateRepository); + + const requireUserId = (): string => { + const session = deps.getSession(); + if (!session.isLoggedIn) { + throw new NoSessionError(); + } + return session.user.id; + }; + + // 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) => + updateRepository.update(requireUserId(), { username }), + acceptTerms: async (params) => { + const now = new Date().toISOString(); + return updateRepository.update(requireUserId(), { + termsAcceptedAt: params.walletTerms ? now : undefined, + giftCardMintTermsAcceptedAt: params.giftCardTerms ? now : undefined, + }); + }, + setDefaultCurrency: async (params) => + updateRepository.update(requireUserId(), { + defaultCurrency: params.currency, + }), + setDefaultAccount: async (params) => + userService.setDefaultAccount(requireUserId(), params.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..87698641e 100644 --- a/packages/wallet-sdk/domain/user/user-repository.ts +++ b/packages/wallet-sdk/domain/user/user-repository.ts @@ -53,11 +53,8 @@ type AccountInput = { | 'state' >; -export class WriteUserRepository { - constructor( - private readonly db: AgicashDb, - private readonly accountRepository: AccountRepository, - ) {} +export class UpdateUserRepository { + constructor(private readonly db: AgicashDb) {} /** * Updates a user in the database. @@ -98,6 +95,13 @@ export class WriteUserRepository { return ReadUserRepository.toUser(updatedUser); } +} + +export class UpsertUserRepository { + constructor( + private readonly db: AgicashDb, + private readonly accountRepository: AccountRepository, + ) {} /** * Inserts a user into the database. If the user already exists, it updates the user. diff --git a/packages/wallet-sdk/domain/user/user-service.ts b/packages/wallet-sdk/domain/user/user-service.ts index 54b3ca7d0..ec1a5e157 100644 --- a/packages/wallet-sdk/domain/user/user-service.ts +++ b/packages/wallet-sdk/domain/user/user-service.ts @@ -1,6 +1,6 @@ import type { Account, ExtendedAccount } from '../accounts/account'; import type { User } from './user'; -import type { WriteUserRepository } from './user-repository'; +import type { UpdateUserRepository } from './user-repository'; type SetDefaultAccountOptions = { /** @@ -11,7 +11,7 @@ type SetDefaultAccountOptions = { }; export class UserService { - constructor(private readonly userRepository: WriteUserRepository) {} + constructor(private readonly userRepository: UpdateUserRepository) {} /** * Returns true if the account is the user's default account for the respective currency. @@ -45,9 +45,11 @@ 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, + userId: string, account: Account, options: SetDefaultAccountOptions = { setDefaultCurrency: false, @@ -58,15 +60,14 @@ export class UserService { } return this.userRepository.update( - user.id, + userId, { - defaultCurrency: options.setDefaultCurrency - ? account.currency - : user.defaultCurrency, - defaultBtcAccountId: - account.currency === 'BTC' ? account.id : user.defaultBtcAccountId, - defaultUsdAccountId: - account.currency === 'USD' ? account.id : user.defaultUsdAccountId, + ...(account.currency === 'BTC' + ? { defaultBtcAccountId: account.id } + : { defaultUsdAccountId: account.id }), + ...(options.setDefaultCurrency + ? { defaultCurrency: account.currency } + : {}), }, { abortSignal: options.abortSignal }, ); diff --git a/packages/wallet-sdk/index.ts b/packages/wallet-sdk/index.ts index f24a1472a..a759d3e89 100644 --- a/packages/wallet-sdk/index.ts +++ b/packages/wallet-sdk/index.ts @@ -1,13 +1,16 @@ -// @agicash/wallet-sdk — public surface: the SDK contract (sdk.ts) + domain -// types + typed errors. Repositories, services, and the wallet DB layer are +// @agicash/wallet-sdk — public surface: the SDK contract + domain types + +// typed errors. Repositories, services, and the wallet DB layer are // SDK-internal; during the migration they're re-exported from // '@agicash/wallet-sdk/temporary' instead, so that deleting /temporary at the // end compiler-enforces the boundary. // The explicit domain-type exports below SHADOW the same-named contract -// projections from './sdk' (an explicit export beats `export *`); each slice +// projections from './domain/sdk' (an explicit export beats `export *`); each slice // deletes its names here when it flips the web imports, surfacing the // projections. -export * from './sdk'; +export * from './domain/sdk'; +export { AgicashSdk } from './domain/sdk/sdk'; +export { nullLogger } from './lib/logger'; +export * from './domain/exchange-rate'; export { ConcurrencyError, DomainError, @@ -16,9 +19,12 @@ export { UniqueConstraintError, } from './lib/error'; export { WebAssemblyUnavailableError } from './lib/spark/errors'; -export type { DestinationDetails } from './lib/send-destination'; +export type { DestinationDetails } from './domain/send/send-destination'; export type { SparkNetwork } from './db/json-models/spark-account-details-db-data'; -export type { FeatureFlag, FeatureFlags } from './lib/feature-flag-service'; +export type { + FeatureFlag, + FeatureFlags, +} from './domain/feature-flags/feature-flag-service'; export type { AccountType, AccountState, @@ -91,7 +97,7 @@ export type { GiftCardInfo, } from './domain/gift-cards/gift-card-config'; export { GiftCardConfigSchema } from './domain/gift-cards/gift-card-config'; -export { getDefaultUnit } from './lib/currencies'; +export { getDefaultUnit } from './domain/currencies'; export type { CashuSendQuote } from './domain/send/cashu-send-quote'; export type { GetCashuLightningQuoteOptions, diff --git a/packages/wallet-sdk/lib/error.ts b/packages/wallet-sdk/lib/error.ts index 628d366b4..946d23d72 100644 --- a/packages/wallet-sdk/lib/error.ts +++ b/packages/wallet-sdk/lib/error.ts @@ -30,3 +30,27 @@ 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'; + } +} + +/** Thrown when a method is called on an SDK instance that has been disposed. */ +export class DisposedError extends SdkError { + constructor() { + super('The SDK instance has been disposed'); + this.name = 'DisposedError'; + } +} + +/** Thrown when a namespace is accessed before its migration slice has landed. */ +export class NotImplementedError extends SdkError { + constructor(namespace: string) { + super(`${namespace} is not implemented yet.`); + this.name = 'NotImplementedError'; + } +} diff --git a/packages/wallet-sdk/lib/guest-account-storage.test.ts b/packages/wallet-sdk/lib/guest-account-storage.test.ts new file mode 100644 index 000000000..bf4170bd3 --- /dev/null +++ b/packages/wallet-sdk/lib/guest-account-storage.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'bun:test'; +import type { AuthKeyValueStore } from '../domain/sdk'; +import { createGuestAccountStorage } from './guest-account-storage'; +import { nullLogger } from './logger'; + +const createMemoryStore = (): AuthKeyValueStore & { + data: Map; +} => { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +}; + +describe('guestAccountStorage', () => { + it('round-trips guest account details under the legacy key', async () => { + const store = createMemoryStore(); + const storage = createGuestAccountStorage(store, nullLogger); + + await storage.store({ id: 'guest-1', password: 'pw' }); + + expect(store.data.has('guestAccount')).toBe(true); + expect(await storage.get()).toEqual({ id: 'guest-1', password: 'pw' }); + }); + + it('returns null when nothing is stored', async () => { + const storage = createGuestAccountStorage(createMemoryStore(), nullLogger); + expect(await storage.get()).toBeNull(); + }); + + it('returns null for corrupt or invalid data', async () => { + const store = createMemoryStore(); + store.data.set('guestAccount', 'not-json'); + const storage = createGuestAccountStorage(store, nullLogger); + expect(await storage.get()).toBeNull(); + + store.data.set('guestAccount', JSON.stringify({ id: 42 })); + expect(await storage.get()).toBeNull(); + }); + + it('clear removes the stored account', async () => { + const store = createMemoryStore(); + const storage = createGuestAccountStorage(store, nullLogger); + await storage.store({ id: 'guest-1', password: 'pw' }); + + await storage.clear(); + + expect(await storage.get()).toBeNull(); + }); +}); diff --git a/packages/wallet-sdk/lib/guest-account-storage.ts b/packages/wallet-sdk/lib/guest-account-storage.ts new file mode 100644 index 000000000..93a5406ae --- /dev/null +++ b/packages/wallet-sdk/lib/guest-account-storage.ts @@ -0,0 +1,52 @@ +import { safeJsonParse } from '@agicash/utils'; +import { z } from 'zod/mini'; +import type { AuthKeyValueStore, Logger } from '../domain/sdk'; + +// Key predates the SDK move — existing devices have guest credentials stored +// under it, so it must not change. +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/logger.ts b/packages/wallet-sdk/lib/logger.ts new file mode 100644 index 000000000..98829dd48 --- /dev/null +++ b/packages/wallet-sdk/lib/logger.ts @@ -0,0 +1,11 @@ +import type { Logger } from '../domain/sdk'; + +const noop = () => undefined; + +/** Discards all diagnostics — for hosts that want no logging. */ +export const nullLogger: Logger = { + debug: noop, + info: noop, + warn: noop, + error: noop, +}; diff --git a/packages/wallet-sdk/lib/password.test.ts b/packages/wallet-sdk/lib/password.test.ts new file mode 100644 index 000000000..e113c4d58 --- /dev/null +++ b/packages/wallet-sdk/lib/password.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'bun:test'; +import { generateRandomPassword } from './password'; + +describe('generateRandomPassword', () => { + it('generates the requested length', () => { + const password = generateRandomPassword(32); + expect(password).toHaveLength(32); + }); + + it('throws when no character set is selected', () => { + expect(() => + generateRandomPassword(16, { + letters: false, + numbers: false, + special: false, + }), + ).toThrow(); + }); + + it('uses only letters when letters is the sole enabled set', () => { + const password = generateRandomPassword(128, { + letters: true, + numbers: false, + special: false, + }); + expect(password).toMatch(/^[a-zA-Z]+$/); + }); + + it('uses only digits when numbers is the sole enabled set', () => { + const password = generateRandomPassword(128, { + letters: false, + numbers: true, + special: false, + }); + expect(password).toMatch(/^[0-9]+$/); + }); + + it('uses only special characters when special is the sole enabled set', () => { + const password = generateRandomPassword(128, { + letters: false, + numbers: false, + special: true, + }); + expect(password).toMatch(/^[!@#$%^&*()_+~]+$/); + }); + + it('mixes letters and digits when both are enabled', () => { + const password = generateRandomPassword(128, { + letters: true, + numbers: true, + special: false, + }); + expect(password).toMatch(/^[a-zA-Z0-9]+$/); + }); +}); diff --git a/apps/web-wallet/app/lib/password-generator.ts b/packages/wallet-sdk/lib/password.ts similarity index 63% rename from apps/web-wallet/app/lib/password-generator.ts rename to packages/wallet-sdk/lib/password.ts index 326e42871..292098825 100644 --- a/apps/web-wallet/app/lib/password-generator.ts +++ b/packages/wallet-sdk/lib/password.ts @@ -1,20 +1,13 @@ -interface PasswordOptions { +type PasswordOptions = { letters?: boolean; numbers?: boolean; special?: boolean; -} +}; -export async function generateRandomPassword( +export 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; - } - } - +): string { let charset = ''; if (options.letters) @@ -30,9 +23,12 @@ export async function generateRandomPassword( const password: string[] = []; + // globalThis.crypto is the Web Crypto API, present in the browser, Node >=20, + // and Bun. There is no isomorphic import for it: node:crypto is Node-only and + // breaks browser bundling, so the global is the portable handle. for (let i = 0; i < length; i++) { const randomIndex = - window.crypto.getRandomValues(new Uint32Array(1))[0] % charset.length; + globalThis.crypto.getRandomValues(new Uint32Array(1))[0] % charset.length; password.push(charset[randomIndex]); } diff --git a/packages/wallet-sdk/lib/spark/wallet.ts b/packages/wallet-sdk/lib/spark/wallet.ts index 8ccd100b5..060245730 100644 --- a/packages/wallet-sdk/lib/spark/wallet.ts +++ b/packages/wallet-sdk/lib/spark/wallet.ts @@ -10,8 +10,8 @@ import { getPrivateKey as getMnemonic } from '@agicash/opensecret'; import { computeSHA256 } from '@agicash/utils'; import { bytesToHex } from '@noble/hashes/utils'; import type { SparkNetwork } from '../../db/json-models/spark-account-details-db-data'; +import { getFeatureFlag } from '../../domain/feature-flags/feature-flag-service'; import { getSeedPhraseDerivationPath } from '../cryptography'; -import { getFeatureFlag } from '../feature-flag-service'; /** Host-provided Spark/Breez configuration. */ export type SparkWalletConfig = { diff --git a/packages/wallet-sdk/package.json b/packages/wallet-sdk/package.json index ab40f6f48..7c6e3736c 100644 --- a/packages/wallet-sdk/package.json +++ b/packages/wallet-sdk/package.json @@ -35,7 +35,7 @@ "@stablelib/base64": "catalog:", "@supabase/supabase-js": "2.95.2", "big.js": "catalog:", - "jwt-decode": "4.0.0", + "jwt-decode": "catalog:", "ky": "catalog:", "type-fest": "catalog:", "zod": "catalog:" diff --git a/packages/wallet-sdk/sdk.ts b/packages/wallet-sdk/sdk.ts deleted file mode 100644 index faf0742be..000000000 --- a/packages/wallet-sdk/sdk.ts +++ /dev/null @@ -1,405 +0,0 @@ -// Public contract of @agicash/wallet-sdk. Prose contract: -// docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md -import type { - LNURLError, - LNURLPayParams, - LNURLPayResult, - LNURLVerifyResult, -} from '@agicash/lnurl'; -import type { Money } from '@agicash/money'; -import type { SparkNetwork } from './db/json-models/spark-account-details-db-data'; -import type { - CashuAccount as DomainCashuAccount, - SparkAccount as DomainSparkAccount, -} from './domain/accounts/account'; -import type { Contact as DomainContact } from './domain/contacts/contact'; -import type { CashuReceiveQuote as DomainCashuReceiveQuote } from './domain/receive/cashu-receive-quote'; -import type { CashuReceiveLightningQuote } from './domain/receive/cashu-receive-quote-core'; -import type { CashuReceiveSwap as DomainCashuReceiveSwap } from './domain/receive/cashu-receive-swap'; -import type { SparkReceiveQuote as DomainSparkReceiveQuote } from './domain/receive/spark-receive-quote'; -import type { SparkReceiveLightningQuote } from './domain/receive/spark-receive-quote-core'; -import type { CashuSendQuote as DomainCashuSendQuote } from './domain/send/cashu-send-quote'; -import type { CashuLightningQuote } from './domain/send/cashu-send-quote-service'; -import type { CashuSendSwap as DomainCashuSendSwap } from './domain/send/cashu-send-swap'; -import type { CashuSwapQuote } from './domain/send/cashu-send-swap-service'; -import type { SparkSendQuote as DomainSparkSendQuote } from './domain/send/spark-send-quote'; -import type { SparkLightningQuote } from './domain/send/spark-send-quote-service'; -import type { Transaction as DomainTransaction } from './domain/transactions/transaction'; -import type { Cursor } from './domain/transactions/transaction-repository'; -import type { TransferQuote } from './domain/transfer/transfer-service'; -import type { User } from './domain/user/user'; -import type { SdkError } from './lib/error'; -import type { FeatureFlag } from './lib/feature-flag-service'; -import type { DestinationDetails } from './lib/send-destination'; - -export type { Cursor }; - -// Public projections of the domain entities: `userId`/`ownerId` are implicit -// from the session; raw wallet handles and proof material stay internal. - -/** Carries `balance` on every rail, never a raw wallet handle or proof material. */ -export type CashuAccount = Omit< - DomainCashuAccount, - 'keysetCounters' | 'proofs' | 'wallet' -> & { balance: Money | null }; -export type SparkAccount = Omit; -export type Account = CashuAccount | SparkAccount; - -export type Contact = Omit; -export type Transaction = Omit; -export type CashuReceiveQuote = Omit; -export type SparkReceiveQuote = Omit; -export type CashuReceiveSwap = Omit; -export type CashuSendQuote = Omit; -export type CashuSendSwap = Omit< - DomainCashuSendSwap, - 'inputProofs' | 'proofsToSend' | 'userId' ->; -export type SparkSendQuote = Omit; - -/** Host-backed session persistence. */ -export type AuthStorage = { - get(key: string): Promise; - set(key: string, value: string): Promise; - remove(key: string): Promise; -}; - -/** Diagnostic sink; the SDK never writes to the console directly. */ -export type Logger = { - debug(message: string, meta?: unknown): void; - info(message: string, meta?: unknown): void; - warn(message: string, meta?: unknown): void; - error(message: string, meta?: unknown): void; -}; - -export type SdkConfig = { - db: { - url: string; - anonKey: string; - }; - auth: { - apiUrl: string; - clientId: string; - storage: AuthStorage; - }; - spark: { - breezApiKey: string; - /** Default for account creation; the persisted per-account value is authoritative. */ - network: SparkNetwork; - /** Node hosts; browser default applies. */ - storageDir?: string; - }; - /** lud16 domain. */ - lightningAddressDomain: string; - logger?: Logger; -}; - -export type Sdk = { - readonly auth: AuthApi; - readonly user: UserApi; - readonly accounts: AccountsApi; - readonly contacts: ContactsApi; - readonly transactions: TransactionsApi; - readonly receive: ReceiveApi; - readonly send: SendApi; - readonly transfer: TransferApi; - readonly featureFlags: FeatureFlagsApi; - readonly events: WalletEvents; - readonly background: BackgroundApi; - /** - * Front-loads session restore and the Breez WASM load. Resolves when no - * session exists (a state, not a failure); rejects on actual failures, - * e.g. `WebAssemblyUnavailableError`. Required before any Spark operation — - * the SDK does not lazy-load the WASM, so Spark calls without a completed - * `init()` throw a typed `SdkError`. Non-Spark usage lazy-initializes on - * first use. - */ - init(): Promise; - /** - * Awaits in-flight background transitions to their next checkpoint, then - * tears down realtime + background; still-pending namespace promises reject - * with a typed `SdkError`. - */ - dispose(): Promise; -}; - -/** `create` is sync; no I/O. */ -export type SdkConstructor = { - create(config: SdkConfig): Sdk; -}; - -export type AuthUser = unknown; // settles in step 5 (auth & user) - -export type AuthSession = - | { isLoggedIn: true; user: AuthUser } - | { isLoggedIn: false }; - -export type AuthApi = { - /** Creates a full account and signs the user in. */ - signUp(email: string, password: string): Promise; - /** Re-signs-in this device's prior guest account if one exists. */ - signUpGuest(): Promise; - signIn(email: string, password: string): Promise; - /** - * Stops background, tears down realtime, clears the stored session; the - * instance stays usable in anonymous state. - */ - signOut(): Promise; - verifyEmail(code: string): Promise; - requestNewVerificationCode(): Promise; - convertGuestToFullAccount(email: string, password: string): Promise; - /** Returns the URL to redirect to. */ - initiateGoogleAuth(): Promise<{ authUrl: string }>; - /** OAuth callback leg. */ - completeGoogleAuth(params: { code: string; state: string }): Promise; - /** Sync snapshot; no I/O. */ - getSession(): AuthSession; -}; - -export type UserApi = { - get(): Promise; - updateUsername(username: string): Promise; - acceptTerms(params: AcceptTermsParams): Promise; - setDefaultAccount(params: SetDefaultAccountParams): Promise; - setDefaultCurrency(params: SetDefaultCurrencyParams): Promise; -}; - -export type AccountsApi = { - get(id: string): Promise; - /** Active accounts of the current user. */ - list(): Promise; - cashu: { - add(params: AddCashuAccountParams): Promise; - }; -}; - -export type ContactsApi = { - get(id: string): Promise; - list(): Promise; - create(params: CreateContactParams): Promise; - delete(id: string): Promise; - findContactCandidates(query: string): Promise; -}; - -export type TransactionsApi = { - get(id: string): Promise; - list(params: { - /** Opaque pagination token from a previous page's `nextCursor`. */ - cursor?: Cursor; - pageSize?: number; - accountId?: string; - }): Promise<{ transactions: Transaction[]; nextCursor: Cursor | null }>; - countPendingAck(): Promise; - acknowledge(transactionId: string): Promise; -}; - -/** - * `get*` methods are stateless previews; `create*` methods persist and enter - * the entity into the background lifecycle. Completion is observed via - * `events`, never called by the host. - */ -export type ReceiveApi = { - cashu: { - getLightningQuote( - params: GetCashuReceiveLightningQuoteParams, - ): Promise; - createQuote( - params: CreateCashuReceiveQuoteParams, - ): Promise; - getQuote(id: string): Promise; - }; - spark: { - getLightningQuote( - params: GetSparkReceiveLightningQuoteParams, - ): Promise; - createQuote( - params: CreateSparkReceiveQuoteParams, - ): Promise; - getQuote(id: string): Promise; - }; - cashuToken: { - getQuote( - params: GetReceiveCashuTokenQuoteParams, - ): Promise; - claim(params: ClaimCashuTokenParams): Promise; - }; -}; - -export type SendApi = { - resolveDestination(input: string): Promise; - cashu: { - getLightningQuote( - params: GetCashuSendLightningQuoteParams, - ): Promise; - createQuote( - params: CreateCashuSendQuoteParams, - ): Promise<{ transactionId: string }>; - /** Send-to-token. */ - getSwapQuote(params: GetCashuSwapQuoteParams): Promise; - createSwap(params: CreateCashuSwapParams): Promise; - }; - spark: { - getLightningQuote( - params: GetSparkSendLightningQuoteParams, - ): Promise; - createQuote( - params: CreateSparkSendQuoteParams, - ): Promise<{ transactionId: string }>; - }; -}; - -export type TransferApi = { - /** Stateless preview. */ - getQuote(params: GetTransferQuoteParams): Promise; // public projection of TransferQuote settles in step 16 - initiate(params: InitiateTransferParams): Promise<{ transactionId: string }>; -}; - -/** Flags are a process-local cached read — the one no-cache exception. */ -export type FeatureFlagsApi = { - get(flag: FeatureFlag): boolean; - /** Cache-change signal; returns unsubscribe. */ - subscribe(listener: () => void): () => void; -}; - -export type BackgroundState = 'stopped' | 'follower' | 'leader' | 'error'; - -/** - * Execution is background-only: a host must run `start()` somewhere or - * nothing moves money. The executing instance may differ from the initiating - * one (the leader lock is per-user across devices). - */ -export type BackgroundApi = { - /** - * Leader election + processors. - * @throws {SdkError} when no authenticated session exists. - */ - start(): void; - /** - * Stops claiming new work immediately, awaits in-flight iterations to their - * next checkpoint (bounded by a timeout), releases the leader lock, and - * abandons the remaining queue. - */ - stop(): Promise; - readonly state: BackgroundState; -}; - -/** - * Payloads are decrypted domain objects. Naming: `.`, verbs per - * entity; terminal transitions arrive as `updated` with the new state on the - * payload. Adding events is non-breaking; renaming is breaking. - */ -export type WalletEventMap = { - /** The session died without a `signOut()` call (expiry / failed refresh). */ - 'auth.session-expired': Record; - 'user.updated': { user: User }; - 'account.created': { account: Account }; - /** A persisted row changed; the payload carries a `version` consumers gate on. */ - 'account.updated': { account: Account }; - /** Versionless balance signal from both rails; spark's only balance path. */ - 'account.balance-changed': { accountId: string; balance: Money }; - 'contact.created': { contact: Contact }; - 'contact.deleted': { contact: Contact }; - 'transaction.created': { transaction: Transaction }; - 'transaction.updated': { transaction: Transaction }; - 'cashu-receive-quote.created': { quote: CashuReceiveQuote }; - 'cashu-receive-quote.updated': { quote: CashuReceiveQuote }; - 'cashu-receive-swap.created': { swap: CashuReceiveSwap }; - 'cashu-receive-swap.updated': { swap: CashuReceiveSwap }; - 'spark-receive-quote.created': { quote: SparkReceiveQuote }; - 'spark-receive-quote.updated': { quote: SparkReceiveQuote }; - 'cashu-send-quote.created': { quote: CashuSendQuote }; - 'cashu-send-quote.updated': { quote: CashuSendQuote }; - 'cashu-send-swap.created': { swap: CashuSendSwap }; - 'cashu-send-swap.updated': { swap: CashuSendSwap }; - 'spark-send-quote.created': { quote: SparkSendQuote }; - 'spark-send-quote.updated': { quote: SparkSendQuote }; - /** - * Emits on every transition into `connected`, including the initial - * connection — the invalidate-all signal. `error` is terminal: the channel - * is dead after retries exhaust, distinct from a long `reconnecting`. - */ - 'connection.changed': { state: 'connected' | 'reconnecting' | 'error' }; - /** - * Fires on every `state` transition; `error` set on transitions into - * `'error'`. Per-task errors never change state, so they never fire it. - */ - 'background.state-changed': { state: BackgroundState; error?: SdkError }; -}; - -/** - * `on()` only registers a handler and is callable with no session; the - * per-user realtime channel is established when a session comes into - * existence (login, or `init()` session restore). Returns unsubscribe. - */ -export type WalletEvents = { - on( - event: K, - handler: (payload: WalletEventMap[K]) => void, - ): () => void; -}; - -/** - * Server-side trust model: service-role key, no user session, per-request - * scope. No `auth`, no `events`, no `background`. - */ -export type ServerSdkConfig = { - db: { url: string; serviceRoleKey: string }; - spark: { - breezApiKey: string; - network: SparkNetwork; - mnemonic: string; - storageDir: string; - }; - /** Hex; encrypts LNURL verify payloads. */ - quoteEncryptionKey: string; -}; - -export type ServerSdk = { - readonly lightningAddress: { - handleLud16Request(params: { - username: string; - baseUrl: string; - }): Promise; - handleLnurlpCallback(params: { - userId: string; - amount: Money<'BTC'>; - baseUrl: string; - /** Per-request by design — instance state would race on the per-process singleton. */ - bypassAmountValidation?: boolean; - }): Promise; - handleLnurlpVerify(params: { - encryptedQuoteData: string; - }): Promise; - }; -}; - -/** Singleton per process. */ -export type ServerSdkConstructor = { - create(config: ServerSdkConfig): ServerSdk; -}; - -// Settles in step N — pinned by that slice PR to the public projection of -// today's service types. - -export type AcceptTermsParams = unknown; // step 5 (auth & user) -export type SetDefaultAccountParams = unknown; // step 5 (auth & user) -export type SetDefaultCurrencyParams = unknown; // step 5 (auth & user) -export type AddCashuAccountParams = unknown; // step 6 (accounts) -export type CreateContactParams = unknown; // step 7 (contacts) -export type GetCashuReceiveLightningQuoteParams = unknown; // step 9 (cashu receive quote) -export type CreateCashuReceiveQuoteParams = unknown; // step 9 (cashu receive quote) -export type GetSparkReceiveLightningQuoteParams = unknown; // step 11 (spark receive quote) -export type CreateSparkReceiveQuoteParams = unknown; // step 11 (spark receive quote) -export type GetReceiveCashuTokenQuoteParams = unknown; // step 12 (receive cashu token) -export type ReceiveCashuTokenQuote = unknown; // step 12 (receive cashu token) -export type ClaimCashuTokenParams = unknown; // step 12 (receive cashu token) -export type ClaimCashuTokenResult = unknown; // step 12 (receive cashu token) -export type GetCashuSendLightningQuoteParams = unknown; // step 13 (cashu send quote) -export type CreateCashuSendQuoteParams = unknown; // step 13 (cashu send quote) -export type GetCashuSwapQuoteParams = unknown; // step 14 (cashu send swap) -export type CreateCashuSwapParams = unknown; // step 14 (cashu send swap) -export type CreateCashuSwapResult = unknown; // step 14 (cashu send swap) -export type GetSparkSendLightningQuoteParams = unknown; // step 15 (spark send quote) -export type CreateSparkSendQuoteParams = unknown; // step 15 (spark send quote) -export type GetTransferQuoteParams = unknown; // step 16 (transfer) -export type InitiateTransferParams = unknown; // step 16 (transfer) diff --git a/packages/wallet-sdk/temporary.ts b/packages/wallet-sdk/temporary.ts index fd2296f29..e9c638cbe 100644 --- a/packages/wallet-sdk/temporary.ts +++ b/packages/wallet-sdk/temporary.ts @@ -44,14 +44,13 @@ export { getEncryption, } from './lib/encryption'; export * from './lib/spark'; -export * from './lib/exchange-rate'; export { ConcurrencyError, DomainError, NotFoundError, UniqueConstraintError, } from './lib/error'; -export { DestinationDetailsSchema } from './lib/send-destination'; +export { DestinationDetailsSchema } from './domain/send/send-destination'; export { deriveCashuXpub, derivePublicKey, @@ -92,7 +91,7 @@ export { refreshFeatureFlags, resetFeatureFlags, subscribeToFeatureFlags, -} from './lib/feature-flag-service'; +} from './domain/feature-flags/feature-flag-service'; export { AccountPurposeSchema, AccountTypeSchema, @@ -108,7 +107,8 @@ export { AccountService } from './domain/accounts/account-service'; export { ReadUserDefaultAccountRepository, ReadUserRepository, - WriteUserRepository, + UpdateUserRepository, + UpsertUserRepository, } from './domain/user/user-repository'; export { UserService } from './domain/user/user-service'; export { isContact } from './domain/contacts/contact'; diff --git a/packages/wallet-sdk/tsconfig.json b/packages/wallet-sdk/tsconfig.json index 76351bec9..eb1740ad8 100644 --- a/packages/wallet-sdk/tsconfig.json +++ b/packages/wallet-sdk/tsconfig.json @@ -6,7 +6,7 @@ "lib": ["ES2022"], "types": ["bun"], "baseUrl": ".", - "paths": { "supabase/database.types": ["./supabase/database.types.ts"] }, + "paths": { "supabase/database.types": ["./db/supabase/database.types.ts"] }, "noEmit": true } }