Instance-per-identity (exploration): one SDK instance = one identity - #1169
Draft
orveth wants to merge 22 commits into
Draft
Instance-per-identity (exploration): one SDK instance = one identity#1169orveth wants to merge 22 commits into
orveth wants to merge 22 commits into
Conversation
Add createSessionKeys: per-session memoized getters for the encryption keypair, cashu seed, spark mnemonic, cashu locking xpub, and spark identity public key, derived from Open Secret. Each memo is generation-fenced so a derivation started before reset() cannot repopulate the cache for the next session, and rejections are not cached so a retry can recover. Wire keys.reset() into the AgicashSdk onSessionEnded teardown alongside the existing session-token, spark-wallet, and mint-auth-token clears, so a signed-in user's key material never survives into the next login. The accounts and user namespaces consume these getters in later commits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement createAccountsApi: get(id), list() (session-gated for the userId), and cashu.add(params) over an internal AccountRepository built from the db, session keys, and spark config. The namespace returns the domain account types directly per the accounts contract (#1166) — no projection mapping. cashu.add re-injects type:'cashu' and the session userId before the service call, and list()/add gate on the session while get() relies on RLS (repository posture). getRepository is exposed alongside the api so the /temporary bridge and the user namespace's ensure() build the repository through one path. Pin AddCashuAccountParams to { name, mintUrl, currency, purpose } and wire the accounts field into AgicashSdk, replacing the not-implemented getter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expose getInternalAccountRepository through '@agicash/wallet-sdk/temporary': a module-scoped accessor that hands unmigrated receive/send flows and realtime row mapping the live instance's internal domain accounts repository, built through the same path as sdk.accounts.*. It throws 'No live AgicashSdk instance' before create and after dispose (the module reference is cleared on dispose alongside liveInstance), so the domain repository never leaks onto the public AgicashSdk surface. Removed at step 18 when those flows read from the SDK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SDK's user.ensure() needs the retry helper the web owned, so lift withRetry and delay from apps/web-wallet/app/lib into @agicash/utils (named exports, barrel re-exported) and delete the web copies. Flip the remaining consumers — the cashu receive-quote hook, the protected-route bootstrap, and the e2e Open Secret fixture — onto @agicash/utils, and add the workspace dependency to the e2e package. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add user.ensure(params) to the contract and implement it on the user namespace:
it derives the encryption public key, cashu locking xpub, and spark identity
public key, upserts the user row (creating the default accounts and persisting
the keys on first sign-in) through the base UpsertUserRepository, and returns
{ user, accounts } domain-typed for the host to seed its caches. The upsert
returns domain accounts already, so ensure returns them as-is — no mapping.
The key-derivation batch and the upsert each run under withRetry (matching the
resilience the host's query layer gave master); the upsert retry is Zod-aware so
a validation error fails fast. EnsureUserParams carries the replayed pending-terms
acceptance timestamps, distinct from acceptTerms' in-session boolean stamps.
ensure builds its repository through the accounts namespace's getRepository, so
the whole instance shares one construction path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flip the accounts data path onto the SDK namespace: accountsQueryOptions' queryFn calls sdk.accounts.list(), useAccountOrNull's lazy fetch calls sdk.accounts.get(id), useAddCashuAccount calls sdk.accounts.cashu.add (callers drop the now-implicit type: 'cashu'), and the realtime ACCOUNT_CREATED/UPDATED handlers map rows through getInternalAccountRepository().toAccount. The cache still holds domain accounts, so every consumer (getAccountBalance, wallet/proofs readers) is unchanged. account-service-hooks is deleted (the service lives behind sdk.accounts.cashu.add) and account-repository-hooks moves to features/receive, its only remaining consumers being the unmigrated cashu receive-quote and receive-swap repos. Extract getExtendedAccounts and isDefaultAccount from UserService into standalone pure functions exported from the package root, and flip their consumers (the accounts hooks, the claim route, and the SDK-internal claim service). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Route _protected's bootstrap through sdk.user.ensure(): it replaces the inline key-derivation + upsert, seeds the user and accounts caches from the public return, and keeps master's structure (cache short-circuit, session-token warm, ensureBreezWasm placement, conditional seed on the upsert branch). Source the web-side key queries from the live instance's session keys via a new getInternalSessionKeys /temporary accessor: useEncryption collapses to a single ['encryption'] query over getEncryption(), and the cashu-seed and spark-mnemonic query fns delegate to the same getters. The per-key encryption query options and their hooks are deleted (only encryption-hooks and the two route warms read them), sparkIdentityPublicKeyQueryOptions is dropped (its only reader was the ensure warm), and xpubQueryOptions is untouched. The bootstrap warms encryption, seed, and mnemonic concurrently with ensure() so the unmigrated receive/send/claim repos keep master's warm-cache and fail-in-the-middleware behavior. The web defaultAccounts copy is removed now that ensure() owns it SDK-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bring the step-6 decision record onto the branch and append a supersession entry: the maintainer's contract-level domain-types ruling (d5d2f18, #1166) superseded B1's projection apparatus after the slice was first built, so sdk.accounts.* returns domain types directly (no mapper, no checked cast), B5 balance reads revert to getAccountBalance, and the runtime-fat reality-class record and the step-18 physical strip retire as moot. The record's prior entries stand as the decision trail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… scope 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>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…raming Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The web builds its account repository from inputs it already holds (db client, session-key getters via /temporary), the same shape the receive feature uses; the SDK-side repository accessor and its /temporary export are removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ctly The encryption/seed/mnemonic queryFns import the SDK's derivation functions via /temporary and cache once per session in TanStack; the encryption queryFn derives-then-wraps so raw key bytes stay out of the query cache. The session-keys instance accessor and its module holder are removed — session keys remain SDK-internal for provision/accounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fence Round-2 hardening after the cross-model review of the session-lifecycle fix: - createMemo checks the session signal before serving a cached value, so a getter reached while the signal is aborted — including reentrantly, from a synchronous abort listener during reset() before the memos clear — rejects with SessionEndedError instead of returning stale key material. - getEncryption returns a revocable facade: a handle a host retains keeps working while its session is live but rejects once that session ends or the instance disposes, so cached encrypt/decrypt closures can't run on a dead session's keys. - The web evicts the infinity-stale derived-key queries (encryption / cashu seed, xpub, private-key / spark mnemonic) on a login transition, so a cross-user login without a prior sign-out (sign-out already clears the cache) re-derives for the new user instead of holding a revoked facade. - reset() is a no-op after terminal dispose (the aborted signal stays); export SessionEndedError from the package root with retry guidance; offer-path adds an explicit abort check after the uncancellable mint fetch; corrected the fence wording to best-effort cancellation with a no-cross-user-result guarantee. Retires blockers 1 and 2 from the round-2 verdict. The os.signIn cross-user window (blocker 3) is an auth-service change and is client-gated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The create query receives the same abort signal, and an already-aborted signal never issues the request. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One SDK instance serves one identity — the structural kill of the cross-user session window (sol's B3 / #1166 finding [37]) rather than serializing it. - signIn/signUp/signUpGuest throw InstanceAlreadyUsedError once an identity was established on the instance; authenticating as another requires a fresh one. - signOut is terminal: it clears per-session caches then disposes the instance (requestDispose wired from AgicashSdk). - a restore/refresh resolving to a different user ends the session and emits auth.session-expired instead of re-keying in place. completeGoogleAuth is left unguarded per the exploration scope-lock (flagged in the PR body). Exploration PR for petar/jbojcic to examine the shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The SDK is one-instance-per-identity (see the wallet-sdk commit): a session end disposes the instance, so the web builds a fresh one for the next identity. - sdk.client.ts exposes the singleton as a live binding plus rebuildSdk(), which disposes the current instance and installs a fresh one. - useSessionEndCleanup rebuilds on every session end (sign-out and expiry) before the auth query re-runs init(), so the next sign-in lands on an unused instance. - the protected subtree is re-keyed on the user id so an identity change remounts it, resetting session-derived state and re-subscribing SDK event handlers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n tokens (review) Cross-model review (codex deep + adversarial Opus) found the different-user fence was incomplete: it ended the in-memory session but left the foreign identity's Open Secret tokens in storage, so the web rebuild's init() could restore that identity; and it keyed on session.isLoggedIn, so a foreign apply on a used-but-anonymous instance (reachable via the unguarded completeGoogleAuth after a full-account expiry) bypassed it. - Bind the instance to the established identity id (not a boolean); refuse any apply resolving to a different id, even once the session has gone anonymous. - Revoke the foreign tokens (os.signOut) in the refuse branch so a rebuild cannot restore that identity from storage. - Coalesce concurrent rebuildSdk() calls so a sign-out racing an expiry handler cannot throw from create(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
orveth
force-pushed
the
sdk/accounts-slice
branch
from
July 28, 2026 13:32
75a10a8 to
8203356
Compare
jbojcic1
force-pushed
the
sdk/accounts-slice
branch
from
July 28, 2026 14:48
8203356 to
cfefd30
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Instance-per-identity (exploration)
An exploration PR (petar's "examine what it looks like") of binding one SDK instance to exactly one identity. Instead of serializing the cross-user auth transition (sol's B3 / #1166 finding [37]), the instance never transitions identities at all — a session end disposes it and the host builds a fresh one. Stacked on
1167-session-fence-r4.Not a final build. Scope is the three items below; gaps are flagged as notes, not fixed.
What it shows
SDK (
packages/wallet-sdk)signIn/signUp/signUpGuestthrow a newInstanceAlreadyUsedErroronce an identity was established (assertUnused). A failed login that never established does not consume the instance.applySessionFromServerapply resolving to a different id is refused — even after the session has gone anonymous — and the foreign identity's Open Secret tokens are revoked (os.signOut) so a rebuild can't restore them.signOutis terminal: clears per-session caches (endSession) then disposes the instance (requestDispose→AgicashSdk.dispose()); every later call throwsDisposedError.Web (
apps/web-wallet)sdk.client.tsexposes the singleton as a live binding +rebuildSdk()(dispose current → create fresh; concurrent calls coalesce). All 7 consumers readsdkin-function, so they pick up the replacement with no call-site changes.useSessionEndCleanuprebuilds on every session end (sign-out and expiry), before the auth query re-runsinit()— so the next sign-in lands on an unused instance.user.id(<Wallet key={user.id}>) — an identity change remounts it, resetting session-derived state and re-subscribing SDK event handlers to the fresh instance.What it deletes / obviates
beginTransition/rollback collapses toassertUnused+ rebuild.applySessionFromServeris removed.Cross-model review (run before hand-off)
init()could restore that identity. Fixed: bind-to-id + revoke tokens + refuse-when-anonymous (regression test + red-on-base).completeGoogleAuthon a used-but-anonymous instance) was the same hole → closed by the same fix. F3 (concurrentrebuildSdkcould throw fromcreate()) → fixed (coalescing guard). Everything else held. Its sharpest correctness point is the flag below.⚠ Flag (not fixed — process-global caches)
The adversarial pass confirmed the charter's suspicion: a fresh instance does NOT sanitize process-global state.
dispose()/rebuild clears only per-instance state; the module-global caches — notablycachedCAT(lib/agicash-mint-auth-provider.ts, not keyed by user), the spark-wallet map, and the feature-flag store — are cleared only byonSessionEnded, never by dispose. So identity isolation still rests ononSessionEndedrunning before every rebuild, not on the rebuild itself. That holds today (everyrebuildSdkis reached viauseSessionEndCleanup, always preceded by a session-end that wipes those caches), but the "fresh instance = clean slate" intuition is wrong for globals: instance B shares them with disposed instance A. Recommendation:dispose()should defensively run the same process-global wipe (or we narrow the claim). Left as a note per the scope-lock — happy to fix if you want it in.Open questions for petar / jbojcic
completeGoogleAuthis unguarded (scope-lock was signIn/signUp/signUpGuest). The apply-layer fence now refuses a foreign identity it would surface (safe — user lands anonymous, tokens revoked), but the verb itself doesn't throw early. AddassertUnusedto it (a clean loud throw instead of a silent refuse), here or follow-up?user.id, so the different-user fence never trips on conversion andkey={user.id}doesn't spuriously remount. Even if it were wrong, it fails safe (session ends, no identity mix). Noted here since the design doc left it open.queryClient.clear()(sign-out) +evictDerivedKeyQueries(cross-user) handle the cache, so the rebuild doesn't touch it. Remounting<Wallet>resets React state, not the query cache — intentional. OK?getSdk()accessor would make it explicit at ~25 call-site edits. Preference?Verification
bun run fix:all→ rc 0 (2 pre-existingvite-env.d.tswarnings, untouched).assertUnused;signOutdispose; different-user fence; and the review fix's two halves (revoke-tokens + refuse-when-anonymous).Reality class: BUILT-BUT-OFF — compiles, unit-suites green, red-on-base proven; not exercised end-to-end in a running app (no e2e run; web remount + rebuild-on-signout belong in
web-wallet-e2e, out of scope for the spike).🤖 Generated with Claude Code