feat(wallet-sdk): auth & user slice (step 5)#1166
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hUser, user params) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ssword generator Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…chinery Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ken getter Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…h; add createUserApi Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nts namespaces Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ret config into it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…codable refresh tokens A restore whose fetchUser resolved after a concurrent auth verb (or session end) could clobber the newer session with the stale result; a session generation counter now gates the apply. A present-but-undecodable refresh token now restores anonymous instead of establishing a session the expiry machinery can neither time out nor refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dd a reload fallback The SDK arms its expiry timer during init(), so an expiry can fire before Wallet's event subscription mounts and emit to no subscribers; the hook now detects the already-dead SDK session on mount and runs the expiry handling. The handler also gets a hard window.location.reload() fallback so an unexpected failure mid-reset can't strand the user on a blank page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each migration slice now settles its types in its own sdk/<namespace>.ts instead of growing one contract file (19 placeholders across steps 6-16 remain to land); cross-cutting pieces (SdkConfig, Sdk, Logger) stay in sdk/index.ts. Pure move — the exported surface is unchanged, and existing './sdk' import specifiers resolve to the directory index as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ick<Sdk> The compiler now checks the implemented subset (auth, user, events, init, dispose) against the Sdk contract; each slice adds its namespace to the Pick until it collapses to the full Sdk. Unimplemented namespaces stay absent rather than stubbed, so consuming them early remains a compile error instead of a runtime crash. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A host that wants no diagnostics now passes the exported no-op nullLogger instead of omitting the field, so silence is an explicit choice. Internal components take a required Logger too — forgetting to forward one is now a compile error rather than a silently diagnostics-less component. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…chinery A fetchUser continuation resolving after teardown (restore or verb) could re-arm the expiry timer on a disposed instance, resurrecting the zombie timer the HMR dispose hook exists to prevent. A disposed flag now blocks re-arming and a late-firing timer callback; verbs and the session snapshot are unaffected — disposal is not logout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Vite awaits a promise returned from hot.dispose, so the old SDK's teardown is guaranteed to complete before the replacement module constructs the new instance — which matters once dispose() gains async teardown work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nOut and the expiry handler Both ran the same order-sensitive sequence (flags reset, auth invalidation, navigate/revalidate, Sentry clear, queryClient.clear) with the ordering rationale documented on only one of them; useSessionEndCleanup now owns the sequence and its invariants, while each caller keeps its own edges (the sdk.auth.signOut() call and redirect target vs the toast and reload fallback). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fail-soft decode idiom (a corrupt STORED token must degrade to null, never throw) was duplicated in the SDK's auth service and the web's auth glue, and missing from shared/auth.ts's isLoggedIn — where a corrupt refresh token would throw through the DB client's token getter into every query; it now reads as logged out. Tokens freshly minted by a server keep decoding with jwtDecode directly so a malformed one fails loudly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry leak Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Automated cross-model review — 4 lanes, consolidatedScope: PR head Verdict: high-quality slice; the concurrency machinery is sound where applied, the headless constraint holds everywhere, the contract split is lossless, the web glue is parity-or-better, and all four lanes independently confirmed the 7 declared behavior deltas are honest. One HIGH-class race (all three model lanes converged on it independently), two MEDs, and a batch of LOWs — plus two inherited #1164 contract defects surfaced by the review that deserve a fix while this surface is warm. 1. HIGH — sign-out can be silently undone by an in-flight guest auto-extend
Interleaving: guest timer fires → Inherited, not a regression — master's 2. MED — public receive projections expose Cashu proof material (inherited from #1164)
Both lines are byte-identical on master — born in #1164, where the projection sweep stripped proofs from the send entities but wrongly concluded the receive entities carried none. Nothing at runtime serves these objects yet (receive params/returns are still step‑9/12 placeholders), so this is a contract-level defect today — which is exactly why it's cheap to fix now, before hosts or the step‑12 implementation build against the leaking shape. Fix: omit 3. MED —
|
…transitions A fired expiry timer can't be cancelled, so its handler raced every other session transition. Worst case: a sign-out landing while the guest auto-extension's re-sign-in was in flight got silently undone — the re-sign-in wrote fresh tokens over the sign-out's clear and the unfenced snapshot apply flipped the session back to logged-in (money access after a believed sign-out on a shared device). An in-flight handler also survived teardown(), letting a disposed instance clear tokens its successor had just restored. The handler now captures the session generation at entry and re-checks it plus the disposed flag after every await: a raced re-sign-in's tokens are cleared again instead of resurrecting the session, the extension's apply is generation-fenced, and the death path (sign-out, end, expired event) only runs while the handler still owns the session it started from. Finding 1 (HIGH) of the cross-model review on PR #1166. Both regression tests fail against the previous code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three LOW findings from the PR #1166 cross-model review: - A slow-failing restore cleared the restore memo unconditionally and its endSession bumped the generation, fencing out a newer restore's apply — an anonymous boot despite valid tokens. The rejection now un-memoizes only its own memo, and the failure path leaves the session alone when another transition owns it. - A restore after a same-lifetime sign-out/sign-in repeated the verb's user fetch; doRestore now returns early when a verb already established the session. - A guest sign-up whose credential persist failed left a live session whose account would strand (with any funds) at its first expiry. The sign-up is now undone and the verb rejects, so the retry lands on a recoverable account. All three regression tests fail against the previous code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd collapsing variants The receive domain entities are intersections over variant unions, and the contract projected them with a bare `Omit`. TypeScript collapses each union to its shared keys under `keyof`, so the projections simultaneously leaked the swap's top-level `tokenProofs` (spendable Cashu proof material, also via the receive events' payload types) and silently dropped every variant-only field (`tokenReceiveData`, `failureReason`, keyset fields) along with discriminant narrowing. The domain schemas now name their variant unions once (feeding both the schema and an exported variant type), and the projections omit base keys only, re-apply the variant unions, and strip proof material at both levels — top-level `tokenProofs` and the melt data's nested `tokenProofs`. The implementing slices (steps 9/11/12) must strip the same fields at runtime. Inherited from #1164 (the projection sweep missed the receive entities); finding 2 of the cross-model review on PR #1166. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The verb derived the user from the live session twice — once inside the account-ownership read and again, after that await, for the row update. A session switch between the two wrote the previous user's account id onto the next user's row (the schema has no same-user constraint on the default account FKs, so the broken pointer persists; RLS still hides the other user's data). The session is now captured once at verb entry and threaded through, so the validating user and the written user cannot diverge. Finding 3 of the cross-model review on PR #1166. The regression test fails against the previous code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lose two expiry-path gaps Three findings from the PR #1166 cross-model review: - A throwing sdk.auth.signOut() skipped the whole web cleanup while the SDK had already ended the local session (its own finally), leaving query caches, the Sentry identity, and the hint cookie serving a dead session — with the sign-out button wedged in its loading state. The cleanup and the loading reset now run in finally. - A guest auto-extension during init() fires auth.session-refreshed before the events hook subscribes, so the auth query and session-hint cookie stayed pinned to the old expiry. The hook now re-syncs on mount when the stored refresh-token expiry moved past what the query captured. - The expiry hard-fallback reload had no guard; it is now throttled through a sessionStorage timestamp so a persistently failing cleanup can't reload-loop the tab. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- generateRandomPassword now rejection-samples instead of a bare modulo, so every charset character is exactly equally likely — this string is the sole credential of funded guest accounts. - WalletEventEmitter.emit dispatches over a snapshot, so a handler that (un)subscribes mid-emit can't alter the current dispatch. - AgicashSdk.create() now throws while an undisposed instance exists, making the one-instance-per-process constraint (module-global Open Secret state) self-enforcing; dispose() releases the slot, so the HMR dispose-then-recreate cycle still works. LOW findings of the cross-model review on PR #1166; each regression test fails against the previous code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drops the rejection sampling added in 7d3bc1c (review LOW finding), returning the generator to master's algorithm verbatim. The modulo skew is ~1 in 5.7e7 per character over a 32-character password — cryptographically negligible — and master parity is preferred for the slice. The redraw test goes with it; the length sanity test stays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> revert(wallet-sdk): unfence the expiry handler, park its races as a known issue Reverts the generation fencing of handleSessionExpiry from 1e6a627 (review finding 1). The per-await fence pattern is too subtle to maintain — the plan is to fix the race class structurally by serializing all session transitions through a single command lane (single-writer) instead. The handler returns to the shape inherited from master's expiry hook, with a KNOWN ISSUE note: a sign-out landing while the guest re-sign-in is in flight is silently undone, and an in-flight handler survives teardown() (HMR). The two race regression tests stay as it.todo — they are the spec the command-lane refactor must satisfy. Kept from the reverted commit: the signInGuestAccount extraction (the guest-persist undo lives there) and the test fake's real-SDK sign-out fidelity (clears token keys), which later tests rely on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> fix(wallet-sdk): tighten three lib-level invariants from the review - generateRandomPassword now rejection-samples instead of a bare modulo, so every charset character is exactly equally likely — this string is the sole credential of funded guest accounts. - WalletEventEmitter.emit dispatches over a snapshot, so a handler that (un)subscribes mid-emit can't alter the current dispatch. - AgicashSdk.create() now throws while an undisposed instance exists, making the one-instance-per-process constraint (module-global Open Secret state) self-enforcing; dispose() releases the slot, so the HMR dispose-then-recreate cycle still works. LOW findings of the cross-model review on PR #1166; each regression test fails against the previous code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> fix(web-wallet): make the session-end web cleanup unconditional and close two expiry-path gaps Three findings from the PR #1166 cross-model review: - A throwing sdk.auth.signOut() skipped the whole web cleanup while the SDK had already ended the local session (its own finally), leaving query caches, the Sentry identity, and the hint cookie serving a dead session — with the sign-out button wedged in its loading state. The cleanup and the loading reset now run in finally. - A guest auto-extension during init() fires auth.session-refreshed before the events hook subscribes, so the auth query and session-hint cookie stayed pinned to the old expiry. The hook now re-syncs on mount when the stored refresh-token expiry moved past what the query captured. - The expiry hard-fallback reload had no guard; it is now throttled through a sessionStorage timestamp so a persistently failing cleanup can't reload-loop the tab. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> fix(wallet-sdk): read the session once per setDefaultAccount verb The verb derived the user from the live session twice — once inside the account-ownership read and again, after that await, for the row update. A session switch between the two wrote the previous user's account id onto the next user's row (the schema has no same-user constraint on the default account FKs, so the broken pointer persists; RLS still hides the other user's data). The session is now captured once at verb entry and threaded through, so the validating user and the written user cannot diverge. Finding 3 of the cross-model review on PR #1166. The regression test fails against the previous code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> fix(wallet-sdk): stop the public receive projections leaking proofs and collapsing variants The receive domain entities are intersections over variant unions, and the contract projected them with a bare `Omit`. TypeScript collapses each union to its shared keys under `keyof`, so the projections simultaneously leaked the swap's top-level `tokenProofs` (spendable Cashu proof material, also via the receive events' payload types) and silently dropped every variant-only field (`tokenReceiveData`, `failureReason`, keyset fields) along with discriminant narrowing. The domain schemas now name their variant unions once (feeding both the schema and an exported variant type), and the projections omit base keys only, re-apply the variant unions, and strip proof material at both levels — top-level `tokenProofs` and the melt data's nested `tokenProofs`. The implementing slices (steps 9/11/12) must strip the same fields at runtime. Inherited from #1164 (the projection sweep missed the receive entities); finding 2 of the cross-model review on PR #1166. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> fix(wallet-sdk): harden the restore memo and guest sign-up edge paths Three LOW findings from the PR #1166 cross-model review: - A slow-failing restore cleared the restore memo unconditionally and its endSession bumped the generation, fencing out a newer restore's apply — an anonymous boot despite valid tokens. The rejection now un-memoizes only its own memo, and the failure path leaves the session alone when another transition owns it. - A restore after a same-lifetime sign-out/sign-in repeated the verb's user fetch; doRestore now returns early when a verb already established the session. - A guest sign-up whose credential persist failed left a live session whose account would strand (with any funds) at its first expiry. The sign-up is now undone and the verb rejects, so the retry lands on a recoverable account. All three regression tests fail against the previous code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> fix(wallet-sdk): fence the expiry handler against concurrent session transitions A fired expiry timer can't be cancelled, so its handler raced every other session transition. Worst case: a sign-out landing while the guest auto-extension's re-sign-in was in flight got silently undone — the re-sign-in wrote fresh tokens over the sign-out's clear and the unfenced snapshot apply flipped the session back to logged-in (money access after a believed sign-out on a shared device). An in-flight handler also survived teardown(), letting a disposed instance clear tokens its successor had just restored. The handler now captures the session generation at entry and re-checks it plus the disposed flag after every await: a raced re-sign-in's tokens are cleared again instead of resurrecting the session, the extension's apply is generation-fenced, and the death path (sign-out, end, expired event) only runs while the handler still owns the session it started from. Finding 1 (HIGH) of the cross-model review on PR #1166. Both regression tests fail against the previous code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4fd3ded to
937e23a
Compare
Summary
Step 5 of the no-cache SDK migration: the first runtime
Sdkclass —AgicashSdk.create(config)with workingauth,user, andeventsnamespaces — and the web app's auth + user flows flipped from/temporary/ direct Open Secret usage tosdk.*.Docs: contract spec · implementation plan (Decision Record A1–A13, the 7 accepted behavior deltas, and the deferred list live there).
SDK (
@agicash/wallet-sdk)AgicashSdk.create(config)— sync construction; configures Open Secret (module-global, so one instance per process); builds its own Supabase client with an internal, generation-fenced third-party-token getter (A6).init()= memoized single-flight session restore (A3: Breez WASM stays host-side until the first Spark slice).AuthService implements AuthApi— in-memory session snapshot, all auth verbs, and the expiry machinery (A2): guest auto-extend emitsauth.session-refreshed(A13), full-account death emitsauth.session-expired; restore applies are fenced by a session generation against concurrent verbs;teardown()is terminal so a disposed instance can't re-arm timers.createUserApi—sdk.user.*withuserIdimplicit from the session;setDefaultAccountreads the account row and writes column-minimally (delta 7); A9 freesWriteUserRepositoryfrom the accounts graph.WalletEventEmitter; port-backed guest-account storage (sameguestAccountkey — existing devices keep their credentials); CSPRNG password generator with a host-override port (A4 — the e2e password mock seam survives with zero e2e changes).@agicash/opensecret@1.0.0-rc.0; step-5 contract types settled (AuthStoragebinds RC-verbatim,AuthUser, A10 param types).sdk.tssplit into per-namespacesdk/files (each future slice settles types in its own file);AgicashSdk implements Pick<Sdk, …>grows per slice until it collapses toimplements Sdk; theloggerport is required, with an exportednullLoggerfor hosts that want silence.Web
features/shared/sdk.client.tssingleton — config assembly, the two-linewindow.getMockPassworde2e bridge, HMR dispose (awaited by Vite).auth.tsrewritten as thin glue: snapshot-drivenauthQueryOptions(staleTime pinned to fetch-time refresh expiry — the re-sync fallback for pages where the session-events hook isn't mounted),useAuthActionsoversdk.auth, anduseHandleSessionEvents(expiry toast, a mount-time catch-up for expiries that fire before the subscription exists, and a hard-reload fallback) sharinguseSessionEndCleanupwithsignOut.sdk.user.*; verify-email, the OAuth callback, and the token-claim default-account write (A11) flipped.safeJwtDecodeextracted to@agicash/utilsand adopted at every stored-token read — also fixing a latent throw inshared/auth.tson corrupt tokens;setLongTimeout/clearLongTimeoutmoved to@agicash/utils.Deliberately unchanged (deferred):
ensureUserDatastays on/temporary(A1 → step 6), encryption plumbing (step 6),sdk.featureFlagswiring (A12); full list in the plan.Behavior
Parity with master for every auth flow (email login/signup, guest signup + re-signin, Google OAuth, verify email, convert guest, sign out, session expiry, boot restore) except the 7 documented deltas — notably: guest-extend failure now shows a toast + graceful redirect instead of a hard reload (delta 1), the expiry timer arms wherever a session exists (delta 6), and
setDefaultAccountwrites are lost-update-proof (delta 7).Deltas added by the cross-model review round (deliberate improvements over master, all tested):
AgicashSdk.create()throws while an undisposed instance exists, making the one-instance-per-process constraint self-enforcing.Known issue (accepted deliberately — review finding 1): the expiry handler races concurrent session transitions — a sign-out landing while the guest auto-extension's re-sign-in is in flight is silently undone, and an in-flight handler survives
teardown()(HMR). Inherited from master'suseHandleSessionExpiry, not a regression. It was fixed with a generation fence (1e6a6272) and then reverted (68b41e2e) by owner decision: per-await fencing is too subtle to maintain. The planned fix is structural — serialize all session transitions through a single command lane (single-writer). The two parkedit.todotests inauth-service.test.tsare the spec that refactor must satisfy; aKNOWN ISSUEnote marks the handler inauth-service.ts.Verification
it.todospecs), 1 declined to keep master's generator verbatim (the guest-password modulo skew — ~1 in 5.7×10⁷ per character, cryptographically negligible), 1 recorded as deltas 8–11 above, 1 deferred to the typed-error pass (steps 17/19), 3 record-only confirmations — incl. verifying against the OpenSecret 1.0.0 source thatsignOutclears its local keys unconditionally and swallows the network logout failure.guestAccount→ guest re-signup lands the same account → username/default-account/currency edits persist → the Google button redirects to accounts.google.com withstate.sessionId→ exactly two Supabase token exchanges (web + SDK clients, per A6) andwallet.usersreads via the SDK client; no new console errors.🤖 Generated with Claude Code