feat(wallet-sdk): auth & user slice (step 5)#1166
Conversation
|
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
setLongTimeout fired its callback synchronously when the delay was <= 0 (the initial scheduleNext ran inline), unlike setTimeout which always defers to a future task. It now schedules every path through setTimeout, so a non-positive delay runs on the next tick: callers can rely on the callback never running inline and can always cancel it via clearLongTimeout. Addresses a #1166 review comment: with this, the auth service's expiry arm no longer needs its 1ms floor to avoid recursing synchronously. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the reviewer's fence+compensate conclusion for finding 1, replacing the parked command-lane plan. handleSessionExpiry captures the session generation at entry; after the guest re-sign-in resolves, if a concurrent transition won the race and the session is anonymous, it compensates by signing out again, clearing the fresh tokens the re-sign-in wrote (opensecret owns the keys, so its signOut is the whole cleanup) instead of letting them resurrect the signed-out session on the next restore. teardown() is re-checked before the re-sign-in's tokens or the death path are acted on, so a disposed instance leaves tokens for its successor. The two regression tests are un-parked (both are satisfied by the compensate plus teardown guard); the KNOWN ISSUE note now describes only the residual windows. Also, from the same review: drop the "storage port" hexagonal-architecture jargon from the restoreSession doc, and remove the 1ms expiry-arm floor now that setLongTimeout defers like setTimeout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per the review, the caller passes its cached account instead of an id, so the api no longer issues the extra accounts read to derive the currency server-truthfully; the cached currency is trusted. SetDefaultAccountParams now carries `account: Account`, and the web callers pass their cached account. Ownership is still enforced by RLS on the user-row write. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- useHandleSessionEvents now takes an options object ({ onSessionExpired })
so the call site names the event the toast fires on (Petar agreed).
- Delete the expiry hard-fallback reload, its 30s lastReloadAt guard, and
the sessionStorage key: the SDK has already ended the session by the time
cleanup can throw, so nothing sensitive survives; the catch now just logs
to console + Sentry. This drops the risk of a deterministic cleanup
failure reload-looping the tab against the server.
- Keep reading Open Secret's token keys from local storage (the web app owns
the storage it hands the SDK); drop the "temporary leak / step 18" framing.
- Explain why entry.client's sdk.client side-effect import must precede the
body (SDK construction configures Open Secret, which loadFeatureFlags
needs for a returning user's token) and that the arrangement is temporary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1166) Cover the charset invariants: throws when no set is selected, and the output alphabet is restricted to the enabled sets (letters-only, digits-only, special-only, and the letters+digits mix). Add a note on lib/password.ts explaining why the Web Crypto global is used directly — there is no isomorphic import (node:crypto is Node-only and breaks browser bundling). Threads: [15] [17] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…1166) `background` did not convey what the namespace does. Rename to `task-processor` across the contract: the `taskProcessor` namespace, the `TaskProcessorApi` / `TaskProcessorState` types, the `task-processor.state-changed` event, the `task-processor.ts` file, and the related doc comments. This matches the term already used in the web app (the TaskProcessor component). Threads: [8] [13] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1166) The receive and send namespaces re-export the domain entity types directly instead of projecting them. Consumers of this SDK only read these shapes, so exposing the domain fields (proofs, userId) is acceptable for now and keeps a single source of truth; narrowing can come later. This removes the projection type aliases in sdk/receive.ts and sdk/send.ts and the variant-union type exports in the receive domain files that existed only to support them. Threads: [11] [12] [14] (accounts [0] flagged separately) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er note (#1166) - supabase-session: `generateToken` is now required and injected from the composition root, so wallet-sdk/db no longer imports @agicash/opensecret. agicash-sdk passes openSecret.generateThirdPartyToken. - Define the Supabase url + anon key in sdk.client.ts and import them into database.client.ts, which is the file being retired. - Add a note on the console logger explaining why the meta === undefined check is shaped that way (it avoids logging a trailing "undefined"). Threads: [3] [24] [26] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update-only consumers (UserService, user-api) do not need AccountRepository, so the write repository is split: UpdateUserRepository takes only `db`, and UpsertUserRepository takes `db` + `accountRepository` in its constructor rather than as a per-call parameter — consistent with how other services receive their dependencies. The web _protected upsert glue constructs UpsertUserRepository with the account repository. Threads: [22] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rd disposal (#1166) auth-service: - The session is scoped by an AbortController (`sessionScope`): every session transition aborts the current scope and installs a fresh one, and an in-flight restore or apply bails when its scope is aborted. This fences a stale restore out when a sign-out or a newer login wins the race, and clears a raced guest re-sign-in's fresh tokens when a sign-out wins. - Invoking an action after teardown throws DisposedError (new error type). - The expiry timer helpers are named setExpiryTimer / clearExpiryTimer, and the discarded-promise call site no longer needs the `void` operator. - Comments read in plain language: an auth action (sign-in, sign-up, …), with each explaining its own reason directly. - events: auth.session-refreshed and the event-naming grammar read plainly. - Tests cover the abort paths (a sign-out fences an in-flight restore) and that actions throw once the instance is disposed. Threads: [6] [27] [29] [30] [31] [32] [34] [36] [37] [38] [39] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the self-review pass (Opus adversarial + codex-review) on the r3 diff: - teardown's doc states that actions throw DisposedError after disposal and that stored tokens + the session snapshot are left intact for a successor instance. - receive.ts / send.ts note that the public types are the domain entities for now (the apps only read them; proofs/userId ride along until a later slice narrows the surface, #1164). - an auth-service test comment describes the fencing as aborting the session scope, matching the code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…actory to lib (#1166) - setDefaultAccount takes `userId: string` (the SDK path has only the session user id; a full domain User would reintroduce the account read step 6 removed) and the full domain `account`. - The guest account storage factory lives in lib/ (a runtime adapter, not domain wiring) and stays exported so its unit tests and the auth-service tests keep using it. AuthService builds its own guest storage from the storage + logger it already receives, so the composition root no longer wires it in. Threads: [21] [2] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ltime + init doc (#1166) - applySessionFromServer wipes the previous user's per-session caches when a different user's session starts over a live one (a login without a sign-out in between), keyed off the live session. Each memo cleared by onSessionEnded already fences its own in-flight writes, so a direct cross-user login is the only case that needs this check; agicash-sdk documents that contract at the onSessionEnded wiring. Tests cover the cross-user wipe, no double-wipe after sign-out, and same-user warmth. - events: the connection.changed doc says it is the Supabase realtime data channel (the per-user row-change subscription), not the auth/network connection. - web: the sdk.init() failure log reads "Failed to initialize sdk", tracking the method name as init grows. Threads: [33] [7] [44] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… mid-extension sign-out (#1166) Two session-scope gaps from the cross-model review: - teardown() aborts the session scope and applySessionFromServer skips its writes when disposed, so an in-flight restore that resolves after dispose no longer writes a session or runs onSessionEnded — onSessionEnded clears process-wide caches (spark wallets, mint CAT) that a hot-reload successor instance may already own. - The guest auto-extension threads its session scope through the post-re-sign-in apply. applySessionFromServer reports whether it applied, so a sign-out that wins while that apply is in flight skips it (no resurrection of the ended session) and the handler bails instead of running its death path over the transition that already owns the state. Tests: dispose-mid-restore is fenced (no session write, no onSessionEnded); sign-out during the post-extension apply does not resurrect the session. Threads: round-3c H1, H3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dk/ into domain/ (#1166) - AgicashSdk implements the full Sdk; the eight not-yet-migrated namespaces are getters that throw NotImplementedError (auth/user/events + init/dispose stay live) - move sdk/ to domain/sdk/ and rename agicash-sdk.ts to domain/sdk/sdk.ts (and its test), with imports updated - generateRandomPassword is synchronous (its body has no awaits), fixing the missing-await at the call site and the redundant awaits in its test - drop web-app references from the events and config contract docs Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ontract (#1166) Drop the projected CashuAccount/SparkAccount (the omit + computed balance) and re-export the domain Account/CashuAccount/SparkAccount directly, matching the receive/send contracts (#1164). Cashu accounts carry no balance field — consumers sum the now-exposed proofs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move packages/wallet-sdk/supabase to packages/wallet-sdk/db/supabase and retarget every reference: the Supabase CLI --workdir becomes packages/wallet-sdk/db, and the generated-types output, biome ignore, both tsconfig path-alias targets, the CI migration/types paths, and the live docs (README + supabase skill) follow. The `supabase/database.types` import alias is unchanged. The self-signed TLS cert paths gain one `../` (ci.yml + web-wallet-e2e/.env.test) because Supabase resolves config.toml paths relative to the now-deeper project folder; the web app is unaffected (it loads its cert via a hardcoded path). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…drop receive schema churn (#1166) - move WalletEventEmitter from lib/events.ts into domain/sdk/events.ts, next to the WalletEventMap/WalletEvents types it implements, and move its test with it - revert the schema-extraction refactor in the cashu-receive-quote, cashu-receive-swap, and spark-receive-quote domain files (unnecessary here; back to master) Co-Authored-By: Claude Opus 4.8 <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>
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>
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>
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>
…ge root, not /temporary (#1166) Exchange rates are a permanent shared utility — the SDK uses them internally and the web app fetches them for display — not a migration-internal dependency. Move the lib/exchange-rate export to index.ts, drop it from /temporary, and repoint the two app consumers (use-exchange-rate, use-money-input) to the package root. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lib/ now holds only internal helpers/utils. Move the modules that carry actual logic to domain/: exchange-rate, feature-flag-service (→ domain/feature-flags), send-destination (→ domain/send), and currencies. spark and the mint auth provider stay in lib/ as external-integration adapters. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ct (#1166) 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 <petar@agi.cash> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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