Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web-wallet/app/entry.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { loadFeatureFlags } from './features/shared/feature-flags';
// later slice constructs the SDK explicitly at boot and moves feature flags
// onto the instance, dropping this ordering dependency (PR #1166).
import './features/shared/sdk.client';
import { registerSessionStarted } from './features/user/session-started';
import { registerMoneyDevToolsFormatter } from './lib/money-devtools-formatter';
import { getTracesSampleRate, sanitizeUrl } from './tracing-utils';

Expand Down Expand Up @@ -48,6 +49,11 @@ ensureBreezWasm().catch(() => {
configureFeatureFlags(agicashDbClient);
void loadFeatureFlags();

// Seed the user + accounts caches from the SDK's auth.session-started event once
// provisioning settles the identity. Registered before the router so the first
// protected middleware reads already-seeded caches.
registerSessionStarted();

const sentryDsn = import.meta.env.VITE_SENTRY_DSN ?? '';
if (!sentryDsn) {
throw new Error('VITE_SENTRY_DSN is not set');
Expand Down
13 changes: 10 additions & 3 deletions apps/web-wallet/app/features/user/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,16 @@ export const authQueryOptions = () =>
try {
await sdk.init();
} catch (error) {
// Restore failed with tokens present (e.g. a network blip at boot).
// Boot anonymous; init()'s rejection is not memoized, so a later
// invalidateAuthQueries() retries the restore.
// A session that established but whose provisioning threw is not an
// anonymous boot: the identity is authenticated, only provisioning as
// the settled user failed. Surface it to the error boundary rather than
// masking a half-provisioned session as logged-out. init()'s rejection
// is not memoized, so a later invalidateAuthQueries() retries.
if (sdk.auth.getSession().isLoggedIn) {
throw error;
}
// Restore genuinely failed with tokens present (e.g. a network blip at
// boot). Boot anonymous; the same un-memoized retry applies.
console.error('Failed to initialize sdk', { cause: error });
Sentry.setUser(null);
sessionHintCookie.clear();
Expand Down
20 changes: 20 additions & 0 deletions apps/web-wallet/app/features/user/session-started.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { AccountsCache } from '~/features/accounts/account-hooks';
import { getQueryClient } from '~/features/shared/query-client';
import { sdk } from '~/features/shared/sdk.client';
import { UserCache } from '~/features/user/user-hooks';

/**
* Seeds the user and accounts caches from the SDK's `auth.session-started` event
* so the provisioned identity and its accounts are in cache before the
* protected tree reads them — the host no longer provisions or fetches them
* itself. Registered at boot, before the router, so the first protected
* middleware sees the seeded caches; replay-latest delivers the most recent
* payload even when `init()` established the session before this subscribed.
* @returns The unsubscribe function.
*/
export const registerSessionStarted = (): (() => void) =>
sdk.events.on('auth.session-started', ({ user, accounts }) => {
const queryClient = getQueryClient();
queryClient.setQueryData([UserCache.Key], user);
queryClient.setQueryData([AccountsCache.Key], accounts);
});
13 changes: 11 additions & 2 deletions apps/web-wallet/app/features/user/user-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,17 @@ export const useUpdateUsername = () => {

export const useAcceptTerms = () => {
const { mutateAsync } = useUserUpdatingMutation(
(params: { walletTerms?: boolean; giftCardTerms?: boolean }) =>
sdk.user.acceptTerms(params),
(params: { walletTerms?: boolean; giftCardTerms?: boolean }) => {
// The acceptance timestamp is the moment of this click, recorded here
// rather than server-side, so it reflects when the user actually accepted.
const acceptedAt = new Date().toISOString();
return sdk.user.acceptTerms({
walletTermsAcceptedAt: params.walletTerms ? acceptedAt : undefined,
giftCardMintTermsAcceptedAt: params.giftCardTerms
? acceptedAt
: undefined,
});
},
);

return mutateAsync;
Expand Down
84 changes: 26 additions & 58 deletions apps/web-wallet/app/routes/_protected.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import type { AuthUser, User } from '@agicash/wallet-sdk';
import type { AuthUser } from '@agicash/wallet-sdk';
import { shouldAcceptTerms } from '@agicash/wallet-sdk';
import { ensureBreezWasm } from '@agicash/wallet-sdk/temporary';
import type { QueryClient } from '@tanstack/react-query';
import { Outlet, redirect } from 'react-router';
import { AccountsCache } from '~/features/accounts/account-hooks';
import { supabaseSessionTokenQuery } from '~/features/agicash-db/supabase-session';
import { LoadingScreen } from '~/features/loading/LoadingScreen';
import { seedQueryOptions } from '~/features/shared/cashu-query-options';
Expand Down Expand Up @@ -39,51 +37,6 @@ const buildRedirectWithReturnUrl = (
return redirect(`${destinationRoute}${search}${hash}`);
};

const hasUserChanged = (user: User, authUser: AuthUser) => {
const currentAuthUserEmail = authUser.email ?? null;
const currentUserEmail = user.isGuest ? null : user.email;

return (
currentUserEmail !== currentAuthUserEmail ||
user.emailVerified !== authUser.email_verified
);
};

const ensureUserData = async (
queryClient: QueryClient,
authUser: AuthUser,
termsAcceptedAt?: string,
giftCardMintTermsAcceptedAt?: string,
): Promise<User> => {
let user = getUserFromCache(queryClient);

if (!user) {
queryClient.prefetchQuery(supabaseSessionTokenQuery());
}

if (!user || hasUserChanged(user, authUser)) {
// TEMPORARY: these prefetches populate cache entries that receive/send/claim
// repositories not yet migrated into the SDK still read (encryption, seed,
// spark mnemonic). Each prefetched entry exists only for its unmigrated
// feature and is deleted when that feature migrates into the SDK — all gone
// by step 18.
const [{ user: upsertedUser, accounts }] = await Promise.all([
sdk.user.provision({
termsAcceptedAt,
giftCardMintTermsAcceptedAt,
}),
queryClient.ensureQueryData(encryptionQueryOptions()),
queryClient.ensureQueryData(sparkMnemonicQueryOptions()),
queryClient.ensureQueryData(seedQueryOptions()),
]);
user = upsertedUser;
queryClient.setQueryData([UserCache.Key], user);
queryClient.setQueryData([AccountsCache.Key], accounts);
}

return user;
};

const routeGuardMiddleware: Route.ClientMiddlewareFunction = async (
{ request },
next,
Expand Down Expand Up @@ -125,6 +78,23 @@ const routeGuardMiddleware: Route.ClientMiddlewareFunction = async (
throw redirect(`/home${search}${hash}`);
}

// TEMPORARY: these prefetches populate cache entries that receive/send/claim
// repositories not yet migrated into the SDK still read (session token,
// encryption, seed, spark mnemonic); each is deleted when its feature migrates
// into the SDK. ensureBreezWasm first: the spark mnemonic prefetch derives the
// Spark identity via defaultExternalSigner(), which requires WASM. Shared with
// entry.client.tsx so the init is typically already in-flight here.
await ensureBreezWasm();
queryClient.prefetchQuery(supabaseSessionTokenQuery());
Comment thread
jbojcic1 marked this conversation as resolved.
await Promise.all([
queryClient.ensureQueryData(encryptionQueryOptions()),
queryClient.ensureQueryData(sparkMnemonicQueryOptions()),
queryClient.ensureQueryData(seedQueryOptions()),
]);

// The provisioned user and accounts arrive via the SDK's auth.session-started
// event (seeded into cache at boot). Replay any terms accepted before this
// session existed, then gate on the result.
const pendingTermsAcceptedAt = pendingWalletTermsStorage.get();
if (pendingTermsAcceptedAt) {
pendingWalletTermsStorage.remove();
Expand All @@ -136,16 +106,14 @@ const routeGuardMiddleware: Route.ClientMiddlewareFunction = async (
pendingGiftCardMintTermsStorage.remove();
}

// ensureUserData derives the Spark identity public key via defaultExternalSigner(),
// which requires WASM to be initialized. Shared with entry.client.tsx so the init
// is typically already in-flight (or complete) by the time we await here.
await ensureBreezWasm();
const user = await ensureUserData(
Comment thread
jbojcic1 marked this conversation as resolved.
queryClient,
authUser,
pendingTermsAcceptedAt,
pendingGiftCardMintTermsAcceptedAt,
);
let user = getUserFromCache(queryClient) ?? (await sdk.user.get());
if (pendingTermsAcceptedAt || pendingGiftCardMintTermsAcceptedAt) {
user = await sdk.user.acceptTerms({
walletTermsAcceptedAt: pendingTermsAcceptedAt,
giftCardMintTermsAcceptedAt: pendingGiftCardMintTermsAcceptedAt,
});
queryClient.setQueryData([UserCache.Key], user);
}

const shouldRedirectToAcceptTerms =
shouldAcceptTerms(user) && !isAcceptTermsRoute;
Expand Down
2 changes: 1 addition & 1 deletion packages/wallet-sdk/domain/accounts/accounts-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ describe('createAccountsApi', () => {
const balances = accounts.map((account) =>
getAccountBalance(account)?.amount('sat').toNumber(),
);
expect(balances).toEqual([150, 42]);
expect(balances).toEqual(expect.arrayContaining([150, 42]));
});

it('throws NoSessionError without a session', async () => {
Expand Down
66 changes: 66 additions & 0 deletions packages/wallet-sdk/domain/sdk/events.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'bun:test';
import { nullLogger } from '../../lib/logger';
import type { Account } from '../accounts/account';
import type { User } from '../user/user';
import { WalletEventEmitter } from './events';

describe('WalletEventEmitter', () => {
Expand Down Expand Up @@ -70,4 +72,68 @@ describe('WalletEventEmitter', () => {
expect(secondHandlerRan).toBe(true);
expect(errors).toHaveLength(1);
});

describe('auth.session-started replay-latest', () => {
const startedPayload = (id: string) => ({
user: { id } as unknown as User,
accounts: [] as unknown as Account[],
});

it('replays the most recent payload to a handler subscribed after the emit', () => {
const emitter = new WalletEventEmitter(nullLogger);
const payload = startedPayload('user-1');
emitter.emit('auth.session-started', payload);

const received: unknown[] = [];
emitter.on('auth.session-started', (p) => received.push(p));

expect(received).toEqual([payload]);
});

it('replays only the latest payload', () => {
const emitter = new WalletEventEmitter(nullLogger);
emitter.emit('auth.session-started', startedPayload('user-1'));
const latest = startedPayload('user-2');
emitter.emit('auth.session-started', latest);

const received: unknown[] = [];
emitter.on('auth.session-started', (p) => received.push(p));

expect(received).toEqual([latest]);
});

it('delivers a live emit to an already-subscribed handler', () => {
const emitter = new WalletEventEmitter(nullLogger);
const received: unknown[] = [];
emitter.on('auth.session-started', (p) => received.push(p));

const payload = startedPayload('user-1');
emitter.emit('auth.session-started', payload);

expect(received).toEqual([payload]);
});

it('does not replay other event types to a late subscriber', () => {
const emitter = new WalletEventEmitter(nullLogger);
emitter.emit('auth.session-expired', {});

let calls = 0;
emitter.on('auth.session-expired', () => {
calls += 1;
});

expect(calls).toBe(0);
});

it('does not replay after the retained payload is cleared', () => {
const emitter = new WalletEventEmitter(nullLogger);
emitter.emit('auth.session-started', startedPayload('user-1'));
emitter.clear();

const received: unknown[] = [];
emitter.on('auth.session-started', (p) => received.push(p));

expect(received).toEqual([]);
});
});
});
57 changes: 52 additions & 5 deletions packages/wallet-sdk/domain/sdk/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ export type WalletEventMap = {
* session-derived state from it.
*/
'auth.session-refreshed': Record<string, never>;
/**
* A session settled onto its current user (initial login/restore or a
* mid-session identity change) and the user was provisioned — carries the
* provisioned user and their accounts so the host seeds its caches by plain
* reads. Replay-latest: a handler that subscribes after the establish still
* receives the most recent payload immediately (the common React case, where
* the tree mounts after `init()` already established the session).
*/
'auth.session-started': { user: User; accounts: Account[] };
'user.updated': { user: User };
'account.created': { account: Account };
/** A persisted row changed; the payload carries a `version` consumers gate on. */
Expand Down Expand Up @@ -85,6 +94,11 @@ type Handler = (payload: never) => void;

export class WalletEventEmitter implements WalletEvents {
private readonly handlers = new Map<keyof WalletEventMap, Set<Handler>>();
// Retained for replay-latest: the most recent auth.session-started payload,
// replayed to a handler that subscribes after the establish.
private lastSessionStarted:
| WalletEventMap['auth.session-started']
| undefined;

constructor(private readonly logger: Logger) {}

Expand All @@ -95,27 +109,60 @@ export class WalletEventEmitter implements WalletEvents {
const set = this.handlers.get(event) ?? new Set<Handler>();
set.add(handler as Handler);
this.handlers.set(event, set);
if (event === 'auth.session-started' && this.lastSessionStarted) {
this.dispatch(
handler as (payload: WalletEventMap['auth.session-started']) => void,
'auth.session-started',
this.lastSessionStarted,
);
}
return () => {
set.delete(handler as Handler);
};
}

private dispatch<K extends keyof WalletEventMap>(
handler: (payload: WalletEventMap[K]) => void,
event: K,
payload: WalletEventMap[K],
): void {
try {
handler(payload);
} catch (error) {
this.logger.error(`Event handler for ${event} threw`, error);
}
}

emit<K extends keyof WalletEventMap>(
event: K,
payload: WalletEventMap[K],
): void {
if (event === 'auth.session-started') {
this.lastSessionStarted =
payload as WalletEventMap['auth.session-started'];
}
const set = this.handlers.get(event);
if (!set) {
return;
}
// Snapshot: a handler that (un)subscribes mid-emit must not change the
// current dispatch.
for (const handler of [...set]) {
try {
(handler as (payload: WalletEventMap[K]) => void)(payload);
} catch (error) {
this.logger.error(`Event handler for ${event} threw`, error);
}
this.dispatch(
handler as (payload: WalletEventMap[K]) => void,
event,
payload,
);
}
}

/**
* Drops all retained replay-latest payloads so a subscriber that registers
* after a session end is not replayed the previous session's events (today the
* retained `auth.session-started` user and accounts; extend here as more events
* adopt replay-latest). Called by the SDK on session end.
*/
clear(): void {
this.lastSessionStarted = undefined;
}
}
Loading
Loading