diff --git a/SKILL.md b/SKILL.md index 5d628ed84..028368dee 100644 --- a/SKILL.md +++ b/SKILL.md @@ -150,9 +150,9 @@ const stored = await memwal.waitForRememberJob(accepted.job_id, { | Method | Description | Returns | |---|---|---| -| `remember(text, namespace?)` | Accept one memory job immediately | `{ job_id, status }` | +| `remember(text, namespaceOrOptions?, options?)` | Accept one memory job immediately. A plain object in the namespace slot is options (`{ idempotencyKey }`), not a namespace | `{ job_id, status }` | | `rememberAndWait(text, namespace?, opts?)` | Store one memory and wait for completion | `{ id, job_id, blob_id, owner, namespace }` | -| `recall({ query, limit?, topK?, namespace?, maxDistance? })` *(preferred)* or `recall(query, limit?, namespace?)` | Semantic search for memories | `{ results: [{ blob_id, text, distance }], total }` | +| `recall({ query, limit?, topK?, namespace?, maxDistance?, sort?, scoringWeights? })` *(preferred)* or `recall(query, limit?, namespace?)` | Semantic search for memories | `{ results: [{ blob_id, text, distance }], total }` | | `analyze(text, namespace?)` | Extract facts and accept one memory job per fact | `{ job_ids, facts, fact_count, status, owner }` | | `analyzeAndWait(text, namespace?, opts?)` | Extract facts and wait for all fact jobs to complete | `{ results, facts, total, succeeded, failed, owner }` | | `restore(namespace, limit?)` | Rebuild missing index entries from Walrus | `{ restored, skipped, failed, total, namespace, owner, truncated }` | @@ -208,9 +208,19 @@ interface RecallOptions { topK?: number; // alias of limit; if both are set, topK wins namespace?: string; maxDistance?: number; + sort?: "relevance" | "recent"; + scoringWeights?: ScoringWeights; // sent as scoring_weights; omit to keep cosine order +} + +interface ScoringWeights { + semantic?: number; // default 1 + recency?: number; // default 0 + recencyHalfLifeDays?: number; // default 30 + importance?: number; // default 0 } // `topK` and `limit` are aliases for the same value; if both are provided, `topK` takes precedence. +// `sort: "recent"` is newest-wins (over-fetch then write-time). `scoringWeights` only re-ranks the cosine window. interface RememberBulkAcceptedResult { job_ids: string[]; @@ -236,6 +246,7 @@ interface AnalyzeResult { interface RememberBulkStatusItem { job_id: string; status: "pending" | "running" | "uploaded" | "done" | "failed" | "not_found"; + namespace?: string; // present when the job row exists; omitted for not_found blob_id?: string; error?: string; } @@ -574,7 +585,7 @@ Lifecycle hooks run automatically: | `health()` returns error | Check relayer URL is correct and reachable | | `recall()` returns empty | Verify namespace matches what was used in `remember()` | | `recall()` returns unrelated filler | Recall is top-K without a default relevance threshold; filter by `distance`, for example `distance < 0.8`, and calibrate the cutoff against your data | -| `401 Unauthorized` | Usually wrong `MEMWAL_PRIVATE_KEY`, key not registered on the account, account ID mismatch, or staging/mainnet mismatch. Check `.env.local` and dashboard credentials | +| `401 Unauthorized` | Usually wrong `MEMWAL_PRIVATE_KEY`, key not registered on the account, account ID mismatch (a wallet address is not a MemWal account ID), or staging/mainnet mismatch. Check `.env.local` and dashboard credentials | | SDK import errors | Run `pnpm add @mysten-incubation/memwal` — check Node.js ≥ 18 | | Manual client errors | Install peer deps: `@mysten/sui @mysten/seal @mysten/walrus` | | Direct Sui reads fail or examples look stale | Prefer `SuiGrpcClient` from `@mysten/sui/grpc`; JSON-RPC snippets using `SuiClient` / `getFullnodeUrl` may be stale | diff --git a/apps/app/src/index.css b/apps/app/src/index.css index f26280567..72c4e9314 100644 --- a/apps/app/src/index.css +++ b/apps/app/src/index.css @@ -14827,6 +14827,22 @@ body:has(.signin-proposed) { overflow: hidden; background: #000000; color: #faf8f5; + /* The artboard is 966 tall, but the laptop tile ends near 857. Fit to + 880 so the empty tail does not shrink the page. Centered when shorter + than the window. From 1.9:1 upward the size stays what it is at 1.9 + for this width, and the page scrolls. Spare width pulls the laptop + until 100px stays clipped. */ + --signin-frame-height: 880px; + --signin-scale: min(100vw / 1440px, 100vh / var(--signin-frame-height)); + --signin-stage-left: calc(100vw - 1440px * var(--signin-scale)); + --signin-pull: max(0px, min(var(--signin-stage-left), 191px * var(--signin-scale) - 100px)); + --signin-frame-top: max(0px, (100vh - var(--signin-frame-height) * var(--signin-scale)) / 2); +} + +@media (min-width: 720px) and (aspect-ratio >= 1.9) { + .signin-proposed { + --signin-scale: calc(100vw / (1.9 * var(--signin-frame-height))); + } } .signin-fit { @@ -14836,8 +14852,11 @@ body:has(.signin-proposed) { } .signin-fit--desktop { - width: min(100vw, calc(100vh * 1440 / 966)); - height: min(100vh, calc(100vw * 966 / 1440)); + width: 100vw; + height: calc(var(--signin-frame-height) * var(--signin-scale)); + margin-top: var(--signin-frame-top); + margin-left: 0; + margin-right: 0; } .signin-fit--mobile { @@ -14857,9 +14876,9 @@ body:has(.signin-proposed) { } .signin-stage--desktop { - width: 1440px; + width: calc(100vw / var(--signin-scale)); height: 966px; - transform: scale(calc(100cqw / 1440px)); + transform: scale(var(--signin-scale)); } .signin-stage--mobile { @@ -14928,7 +14947,32 @@ body:has(.signin-proposed) { display: none; } +.signin-scroll { + height: 100%; + overflow: hidden; +} + +.signin-sheet { + position: relative; + display: flow-root; + width: 100%; + min-height: 100%; + overflow: clip; +} + @media (min-width: 720px) { + .signin-proposed { + align-items: stretch; + justify-content: flex-start; + } + + .signin-scroll { + display: block; + width: 100%; + height: 100%; + overflow: hidden; + } + .signin-stage--desktop { background: transparent; } @@ -14938,108 +14982,67 @@ body:has(.signin-proposed) { display: none; } + .signin-fit--desktop { + z-index: 1; + } + .signin-glow--pin { display: block; - position: fixed; + position: absolute; z-index: 0; + top: calc(var(--signin-frame-top) - 252px * var(--signin-scale)); + left: calc(var(--signin-stage-left) - var(--signin-pull) - 187px * var(--signin-scale)); + width: calc(1814px * var(--signin-scale)); + height: calc(776px * var(--signin-scale)); background-image: url('/signin/glow-desktop.png'); } - .signin-fit--desktop { - z-index: 1; - } - .signin-logo--pin { display: block; - position: fixed; - top: 28px; + position: absolute; + top: calc(var(--signin-frame-top) + 28px * var(--signin-scale)); + left: calc(50px * var(--signin-scale)); z-index: 30; - width: calc(117px * min(100cqw, 100cqh * 1440 / 966) / 1440px); - height: calc(53.929px * min(100cqw, 100cqh * 1440 / 966) / 1440px); + width: calc(117px * var(--signin-scale)); + height: calc(53.929px * var(--signin-scale)); + } + + /* The stage spans the viewport. Slide the laptop group toward the right, + then back left by the spare width so more of the clipped screen shows. */ + .signin-stage--desktop .signin-hero { + position: absolute; + inset: 0; + translate: calc((var(--signin-stage-left) - var(--signin-pull)) / var(--signin-scale)) 0; } /* Same ellipse as Figma, but outside the stage so the blur is not cut off at the stage edge and the purple continues into the right corner. */ .signin-bloom--pin { display: block; - position: fixed; + position: absolute; z-index: 0; - width: calc(368px * min(100cqw, 100cqh * 1440 / 966) / 1440px); - height: calc(480px * min(100cqw, 100cqh * 1440 / 966) / 1440px); - top: calc( - (100cqh - min(100cqh, 100cqw * 966 / 1440)) / 2 - + 224px * min(100cqw, 100cqh * 1440 / 966) / 1440px - ); + top: calc(var(--signin-frame-top) + 224px * var(--signin-scale)); + left: calc(var(--signin-stage-left) - var(--signin-pull) + 882px * var(--signin-scale)); + width: calc(368px * var(--signin-scale)); + height: calc(480px * var(--signin-scale)); border-radius: 50%; background: linear-gradient(to bottom, #2f43cb, #9550c2); - filter: blur(calc(387px * min(100cqw, 100cqh * 1440 / 966) / 1440px)); + filter: blur(calc(387px * var(--signin-scale))); pointer-events: none; } } -/* Pin right only while the window is at least as tall as a 14-inch fullscreen - (1512×982). The spare width there is about 48px, so the laptop still meets - the edge. */ -@media (min-width: 720px) and (aspect-ratio <= 1512 / 982) { - .signin-fit--desktop { - margin-left: auto; - } - - .signin-glow--pin { - top: calc(-252px * 100cqw / 1440px); - left: calc(-187px * 100cqw / 1440px); - width: calc(1814px * 100cqw / 1440px); - height: calc(776px * 100cqw / 1440px); - } - - .signin-logo--pin { - left: calc( - (100cqw - min(100cqw, 100cqh * 1440 / 966)) - + 50px * min(100cqw, 100cqh * 1440 / 966) / 1440px - ); - } - - .signin-bloom--pin { - left: calc( - (100cqw - min(100cqw, 100cqh * 1440 / 966)) - + 882px * min(100cqw, 100cqh * 1440 / 966) / 1440px - ); - } -} - -/* Shorter than a 14-inch window: grow the whole stage to the window width - so the laptop meets the right edge. The bottom of the artboard clips. */ -@media (min-width: 720px) and (aspect-ratio > 1512 / 982) { - .signin-proposed { - align-items: flex-start; - } - - .signin-fit--desktop { - width: 100vw; - height: calc(100vw * 966 / 1440); - margin-left: 0; - } - - .signin-glow--pin { - top: calc(-252px * 100cqw / 1440px); - left: calc(-187px * 100cqw / 1440px); - width: calc(1814px * 100cqw / 1440px); - height: calc(776px * 100cqw / 1440px); - } - - .signin-logo--pin { - top: calc(28px * 100cqw / 1440px); - left: calc(50px * 100cqw / 1440px); - width: calc(117px * 100cqw / 1440px); - height: calc(53.929px * 100cqw / 1440px); +@media (min-width: 720px) and (aspect-ratio >= 1.9) { + .signin-scroll { + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: none; } - .signin-bloom--pin { - top: calc(224px * 100cqw / 1440px); - left: calc(882px * 100cqw / 1440px); - width: calc(368px * 100cqw / 1440px); - height: calc(480px * 100cqw / 1440px); - filter: blur(calc(387px * 100cqw / 1440px)); + .signin-scroll::-webkit-scrollbar { + display: none; + width: 0; + height: 0; } } diff --git a/apps/app/src/pages/ConnectClaude.test.tsx b/apps/app/src/pages/ConnectClaude.test.tsx new file mode 100644 index 000000000..697fabc18 --- /dev/null +++ b/apps/app/src/pages/ConnectClaude.test.tsx @@ -0,0 +1,370 @@ +/** + * WALM-605 recovery path. + * + * A Sui outage during `POST /complete` leaves the authorization session + * `pending` instead of burning it. That only helps if the consent UI can + * re-send *that one request*: re-running the whole flow re-asks the wallet to + * sign and re-submits `add_delegate_key` for a key already on chain. + * + * The duplicate submission is decided server-side, by `/account`'s on-chain + * check. It cannot be decided here: this page signs through the sponsor proxy, + * and `sponsor::mask_upstream` replaces every upstream 5xx with a bare 502 that + * carries no Move abort, so the consent UI never sees which abort fired. + */ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + address: '0xowner', + accountId: '0xaccount', + signAndExecute: vi.fn(), + signPersonalMessage: vi.fn(), +})) + +vi.mock('../config', () => ({ + config: { + memwalServerUrl: 'https://relayer.test', + memwalPackageId: '0xpkg', + memwalRegistryId: '0xreg', + suiNetwork: 'testnet', + docsUrl: 'https://docs.test', + }, +})) +vi.mock('@mysten/dapp-kit', () => ({ + ConnectModal: () => null, + useCurrentAccount: () => ({ address: mocks.address }), + useSuiClient: () => ({ waitForTransaction: vi.fn().mockResolvedValue({}) }), + useSignPersonalMessage: () => ({ mutateAsync: mocks.signPersonalMessage }), +})) +vi.mock('../hooks/useSponsoredTransaction', () => ({ + useSponsoredTransaction: () => ({ mutateAsync: mocks.signAndExecute }), +})) +vi.mock('../utils/suiClientCompat', () => ({ + fetchAccountIdForOwner: vi.fn().mockResolvedValue(mocks.accountId), +})) +vi.mock('../utils/analytics', () => ({ + trackEvent: vi.fn(), + getAnalyticsErrorType: () => 'unknown', +})) + +import ConnectClaude from './ConnectClaude' + +const SESSION = `mws_${'a'.repeat(24)}` + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +const SESSION_VIEW = { + client_name: 'Claude', + redirect_host: 'claude.ai', + scopes: ['memory.read'], + delegate_public_key: 'ab'.repeat(32), + delegate_sui_address: '0xdelegate', + expires_at: new Date(Date.now() + 600_000).toISOString(), +} + +/** + * Serves the session GET and the `/account` preflight, and hands each + * `/complete` POST to the next entry of `completeResponses`. Returns a counter + * of how many times each endpoint was called. + */ +function stubRelayer( + completeResponses: (() => Response | Promise)[], + { + needsOnchainRegistration = false, + resumePendingRegistration = false, + accountResponse, + }: { + needsOnchainRegistration?: boolean + resumePendingRegistration?: boolean + accountResponse?: () => Response + } = {}, +) { + const calls = { account: 0, complete: 0, completeBodies: [] as string[] } + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith(`/session/${SESSION}`)) return json(SESSION_VIEW) + if (url.endsWith('/account')) { + calls.account += 1 + if (accountResponse) return accountResponse() + // Default: reused delegate, so no add_delegate_key transaction + // is needed and the test isolates the /complete round-trip. + return json({ + needs_onchain_registration: needsOnchainRegistration, + delegate_public_key: SESSION_VIEW.delegate_public_key, + delegate_sui_address: SESSION_VIEW.delegate_sui_address, + resume_pending_registration: resumePendingRegistration, + }) + } + if (url.endsWith('/complete')) { + const next = completeResponses[calls.complete] ?? completeResponses.at(-1) + calls.complete += 1 + calls.completeBodies.push(String(init?.body ?? '')) + return next!() + } + throw new Error(`unexpected fetch: ${url}`) + }), + ) + return calls +} + +async function approve() { + render( + + + , + ) + await userEvent.click(await screen.findByRole('button', { name: 'Approve' })) +} + +beforeEach(() => { + vi.clearAllMocks() + sessionStorage.clear() + mocks.signPersonalMessage.mockResolvedValue({ signature: 'sig' }) + // jsdom refuses a real navigation; the page only needs the call to not throw. + vi.stubGlobal('location', { ...window.location, replace: vi.fn() }) +}) + +describe('ConnectClaude /complete recovery', () => { + it('offers a retry of /complete alone when Sui is unavailable', async () => { + const calls = stubRelayer([ + () => + json( + { + error: 'temporarily_unavailable', + error_description: 'could not verify the delegate key on-chain right now', + }, + 503, + ), + () => json({ redirect_url: 'https://claude.ai/callback?code=abc' }), + ]) + + await approve() + + // Not the terminal error state: the session is still pending. + const retry = await screen.findByRole('button', { name: /finish connecting/i }) + expect(screen.queryByText('Something went wrong')).toBeNull() + + await userEvent.click(retry) + + await waitFor(() => expect(calls.complete).toBe(2)) + // The retry re-sent /complete only — no second preflight, so no second + // add_delegate_key submission. + expect(calls.account).toBe(1) + expect(mocks.signAndExecute).not.toHaveBeenCalled() + await screen.findByText(/Redirecting you back/) + }) + + it('a reload mid-recovery resumes at the retry step, not at consent', async () => { + // The ref does not survive a remount. Without the sessionStorage copy + // the user lands back on consent, and a first-time connect re-asks the + // wallet and re-submits add_delegate_key for a key already on chain. + const calls = stubRelayer([ + () => json({ error: 'temporarily_unavailable', error_description: 'sui unavailable' }, 503), + () => json({ redirect_url: 'https://claude.ai/callback?code=abc' }), + ]) + + await approve() + await screen.findByRole('button', { name: /finish connecting/i }) + expect(calls.account).toBe(1) + + // Remount, as a browser reload would. + cleanup() + render( + + + , + ) + + const retry = await screen.findByRole('button', { name: /finish connecting/i }) + await userEvent.click(retry) + + await waitFor(() => expect(calls.complete).toBe(2)) + // Still no second preflight and no second transaction after the reload. + expect(calls.account).toBe(1) + expect(mocks.signAndExecute).not.toHaveBeenCalled() + }) + + it('a definitive rejection leaves nothing to resume after a reload', async () => { + // The session is spent on a 400, so a reload must land on the error and + // not loop on the "Sui is busy" retry screen. + stubRelayer([ + () => json({ error: 'invalid_request', error_description: 'delegate key is not registered on-chain' }, 400), + ]) + + await approve() + await screen.findByText('Something went wrong') + + cleanup() + render( + + + , + ) + + await screen.findByRole('button', { name: 'Approve' }) + expect(screen.queryByRole('button', { name: /finish connecting/i })).toBeNull() + }) + + it('does not replay a stored payload against a different session', async () => { + sessionStorage.setItem( + 'memwal_claude_pending_complete', + JSON.stringify({ + session: 'mws_' + 'z'.repeat(24), + payload: { account_id: '0xa', owner_address: '0xb', owner_signature: 'sig', tx_digest: 'd', reused_delegate: true }, + }), + ) + stubRelayer([() => json({ redirect_url: 'https://claude.ai/callback?code=abc' })]) + + render( + + + , + ) + + // Lands on consent, not on someone else's half-finished authorization. + await screen.findByRole('button', { name: 'Approve' }) + expect(screen.queryByRole('button', { name: /finish connecting/i })).toBeNull() + }) + + it('a reload while /complete is in flight resumes at the retry step', async () => { + // The gap a ref cannot cover, and the reason the crumb is written + // before the request rather than after it fails: /complete never + // answered, so there is no 503 to react to, yet the transaction above + // has already landed and the wallet has already signed. + // Never settles: a reload destroys the context that issued it, so this + // response is never processed by anyone. Resolving it here instead + // would let the dead page run its success path and clear the crumb, + // which no real reload can do. + const inFlight = new Promise(() => {}) + const calls = stubRelayer( + [() => inFlight, () => json({ redirect_url: 'https://claude.ai/callback?code=abc' })], + { needsOnchainRegistration: true }, + ) + mocks.signAndExecute.mockResolvedValue({ digest: '0xdigest' }) + + await approve() + await waitFor(() => expect(calls.complete).toBe(1)) + expect(mocks.signAndExecute).toHaveBeenCalledTimes(1) + + // Reload before the relayer answers. + cleanup() + render( + + + , + ) + + const retry = await screen.findByRole('button', { name: /finish connecting/i }) + await userEvent.click(retry) + + await waitFor(() => expect(calls.complete).toBe(2)) + // No second preflight, and above all no second wallet signature or + // second add_delegate_key for a key the chain already took. + expect(calls.account).toBe(1) + expect(mocks.signAndExecute).toHaveBeenCalledTimes(1) + }) + + it('skips add_delegate_key when /account says this session key is already on chain', async () => { + // The WALM-605 journey: an earlier attempt landed the transaction and + // then hit a 429 during verify. The chain, not a transaction error, is + // what tells us there is nothing left to submit. + const calls = stubRelayer([() => json({ redirect_url: 'https://claude.ai/callback?code=abc' })], { + needsOnchainRegistration: false, + resumePendingRegistration: true, + }) + + await approve() + + await screen.findByText(/Redirecting you back/) + expect(mocks.signAndExecute).not.toHaveBeenCalled() + expect(calls.complete).toBe(1) + // Named honestly: this is not a delegate reused from an earlier grant. + expect(JSON.parse(calls.completeBodies[0]!).tx_digest).toBe('already-on-chain') + }) + + it('does not read a duplicate-key abort out of the masked sponsor 502', async () => { + // Built from the real exports, so the fixture cannot drift from the + // wrapper the way a hand-written 'MoveAbort ... abort code: 0' string + // did: that string is not what production throws, so a test using it + // passes while the user dead-ends. + const { SponsorHttpError, sponsorFailureMessage } = await vi.importActual< + typeof import('../hooks/useSponsoredTransaction') + >('../hooks/useSponsoredTransaction') + const masked = sponsorFailureMessage( + new SponsorHttpError( + 'sponsor', + 502, + JSON.stringify({ + error: 'Sponsor service error', + code: 'sponsor_upstream_error', + traceId: 'tr_1', + }), + ), + ) + // The premise: an Enoki dry-run abort reaches the SPA with no Move text. + expect(masked).not.toMatch(/abort code/) + expect(masked).not.toMatch(/add_delegate_key/) + + const calls = stubRelayer([() => json({ redirect_url: 'https://claude.ai/callback?code=abc' })], { + needsOnchainRegistration: true, + }) + mocks.signAndExecute.mockRejectedValue(new Error(masked)) + + await approve() + + await screen.findByText('Something went wrong') + // The load-bearing assertion: a sponsor 502 must not be treated as + // "already registered" and POSTed to /complete. ENotOwner, + // ETooManyDelegateKeys and EDelegateKeyAlreadyExists are + // indistinguishable here, and guessing the last one burns the session + // as Unregistered for the other two. + expect(calls.complete).toBe(0) + }) + + it('returns to consent when the /account preflight cannot reach Sui', async () => { + const calls = stubRelayer([() => json({ redirect_url: 'https://claude.ai/callback?code=abc' })], { + accountResponse: () => + json( + { error: 'temporarily_unavailable', error_description: 'sui unavailable' }, + 503, + ), + }) + + await approve() + + // The session is still pending. The terminal error has no way back, + // so the user must be able to start this request again. + expect(screen.queryByText('Something went wrong')).toBeNull() + expect(await screen.findByText('sui unavailable')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy() + expect(mocks.signAndExecute).not.toHaveBeenCalled() + expect(calls.complete).toBe(0) + }) + + it('still fails terminally on a definitive rejection', async () => { + stubRelayer([ + () => + json( + { + error: 'invalid_request', + error_description: 'delegate key is not registered on-chain', + }, + 400, + ), + ]) + + await approve() + + await screen.findByText('Something went wrong') + expect(screen.queryByRole('button', { name: /finish connecting/i })).toBeNull() + }) +}) diff --git a/apps/app/src/pages/ConnectClaude.tsx b/apps/app/src/pages/ConnectClaude.tsx index 0a1f1af8e..c01c7c09f 100644 --- a/apps/app/src/pages/ConnectClaude.tsx +++ b/apps/app/src/pages/ConnectClaude.tsx @@ -28,7 +28,7 @@ * 6. `window.location.replace(redirect_url)` — hands control back to * Claude (or whatever OAuth client started the flow). */ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ConnectModal, useCurrentAccount, @@ -44,11 +44,46 @@ import { fetchAccountIdForOwner } from '../utils/suiClientCompat' +/** The `POST /complete` body, plus the analytics flag that goes with it. */ +type CompletePayload = { + account_id: string + owner_address: string + owner_signature: string + tx_digest: string + reused_delegate: boolean +} + +/** Where the in-flight `POST /complete` payload survives a reload. */ +const PENDING_COMPLETE_KEY = 'memwal_claude_pending_complete' + +/** + * The saved `POST /complete` payload for this session, if any. + * + * Session-scoped on purpose: one authorization's wallet signature must never be + * replayed against another. + */ +function loadStoredComplete(sessionId: string): CompletePayload | null { + try { + const raw = sessionStorage.getItem(PENDING_COMPLETE_KEY) + if (!raw) return null + const saved = JSON.parse(raw) as { session?: string; payload?: CompletePayload } + if (saved.session !== sessionId || !saved.payload?.owner_signature) return null + return saved.payload + } catch { + return null + } +} + +function hasStoredComplete(sessionId: string): boolean { + return loadStoredComplete(sessionId) !== null +} + type Step = | 'loading' | 'consent' | 'signing' | 'finishing' + | 'retry-complete' | 'redirecting' | 'no-account' | 'error' @@ -82,6 +117,8 @@ async function resolveAccountId( } } +type OAuthRequestError = Error & { status: number; oauthError?: string } + async function fetchJson(url: string, init?: RequestInit): Promise { const res = await fetch(url, { ...init, @@ -89,15 +126,31 @@ async function fetchJson(url: string, init?: RequestInit): Promise { }) const body = await res.json().catch(() => null) if (!res.ok) { + const envelope = body && typeof body === 'object' ? (body as Record) : null const description = - body && typeof body === 'object' && 'error_description' in body - ? String((body as { error_description: unknown }).error_description) + envelope && 'error_description' in envelope + ? String(envelope.error_description) : `request failed (${res.status})` - throw new Error(description) + const err = new Error(description) as OAuthRequestError + err.status = res.status + if (envelope && typeof envelope.error === 'string') err.oauthError = envelope.error + throw err } return body as T } +/** + * The relayer could not reach Sui, so it left the authorization session + * `pending` instead of burning it (WALM-605). Re-POSTing `/complete` alone is + * the recovery: the wallet signature and the on-chain registration from the + * first attempt are both still good, so restarting `handleConnect` would only + * re-ask the wallet and re-submit a transaction the chain has already taken. + */ +function isRetryableComplete(err: unknown): boolean { + const e = err as Partial | null + return e?.oauthError === 'temporarily_unavailable' || e?.status === 503 +} + export default function ConnectClaude() { const [params] = useSearchParams() const sessionId = params.get('session') ?? '' @@ -126,7 +179,10 @@ export default function ConnectClaude() { .then((view) => { if (cancelled) return setSession(view) - setStep('consent') + // A reload mid-recovery resumes at the retry step. Landing on + // consent would re-run handleConnect, which re-asks the wallet + // to sign and re-sends a transaction the chain already took. + setStep(hasStoredComplete(sessionId) ? 'retry-complete' : 'consent') }) .catch((err) => { if (cancelled) return @@ -146,6 +202,109 @@ export default function ConnectClaude() { sessionStorage.setItem('memwal_claude_connect', JSON.stringify({ session: sessionId })) }, [sessionValid, sessionId]) + + // Everything `POST /complete` needs, kept so a retry re-sends exactly this + // request instead of re-running `handleConnect`, which would re-ask the + // wallet for a signature and re-submit `add_delegate_key` for a key that + // is already on chain. + // + // A ref does not survive a reload, and a reload is the obvious thing to try + // when the relayer says "retry", so the payload also goes to sessionStorage. + // It gets its own key rather than joining `memwal_claude_connect`, which + // the mount effect rewrites to `{ session }` on every load and would drop + // the payload. + const pendingComplete = useRef(null) + + const forgetPendingComplete = useCallback(() => { + pendingComplete.current = null + try { + sessionStorage.removeItem(PENDING_COMPLETE_KEY) + } catch { + // Nothing to clean up if storage is unavailable. + } + }, []) + + const rememberPendingComplete = useCallback( + (payload: CompletePayload) => { + pendingComplete.current = payload + try { + sessionStorage.setItem( + PENDING_COMPLETE_KEY, + JSON.stringify({ session: sessionId, payload }), + ) + } catch { + // Private mode or a full quota: the in-page button still works. + } + }, + [sessionId], + ) + + const readPendingComplete = useCallback((): CompletePayload | null => { + if (pendingComplete.current) return pendingComplete.current + const saved = loadStoredComplete(sessionId) + if (saved) pendingComplete.current = saved + return saved + }, [sessionId]) + + const completeSession = useCallback(async (payload: CompletePayload) => { + // Durable before the request, not after it fails. A reload while + // /complete is still in flight is the gap a ref cannot cover, and on + // the first-time path it is the expensive one: without the crumb the + // page lands on consent, re-runs handleConnect, and re-submits + // add_delegate_key for a key the tx above already put on chain. That + // no longer dead-ends now that /account detects it, but it still costs + // a wallet round-trip and a sponsored transaction. + // + // Writing early is safe because every terminal exit clears it: success + // below, and a definitive 400 in the catch. A spent session therefore + // cannot leave a crumb that loops the user on "Sui is busy". + rememberPendingComplete(payload) + setStep('finishing') + try { + const { redirect_url } = await fetchJson<{ redirect_url: string }>( + `${apiBase}/api/oauth/session/${sessionId}/complete`, + { + method: 'POST', + body: JSON.stringify({ + account_id: payload.account_id, + owner_address: payload.owner_address, + owner_signature: payload.owner_signature, + tx_digest: payload.tx_digest, + }), + }, + ) + sessionStorage.removeItem('memwal_claude_connect') + sessionStorage.removeItem(PENDING_COMPLETE_KEY) + setStep('redirecting') + trackEvent('claude_connect_complete', { reused_delegate: payload.reused_delegate }) + window.location.replace(redirect_url) + } catch (err) { + if (!isRetryableComplete(err)) { + // Definitive: the session is spent. Clear the crumb so a reload + // lands on the error rather than looping on "Sui is busy". + forgetPendingComplete() + throw err + } + // The session is still pending, so offer this one request again + // rather than dropping into the terminal error state. The crumb + // written above is what the retry step and a reload both read. + setErrorMsg(err instanceof Error ? err.message : String(err)) + setStep('retry-complete') + trackEvent('claude_connect_failed', { error_type: 'sui_unavailable' }) + } + }, [apiBase, sessionId, rememberPendingComplete, forgetPendingComplete]) + + const handleRetryComplete = useCallback(async () => { + const payload = readPendingComplete() + if (!payload) return + try { + await completeSession(payload) + } catch (err) { + setErrorMsg(err instanceof Error ? err.message : String(err)) + setStep('error') + trackEvent('claude_connect_failed', { error_type: getAnalyticsErrorType(err) }) + } + }, [completeSession, readPendingComplete]) const handleConnect = useCallback(async () => { if (!session) return if (!currentAccount) { @@ -168,6 +327,9 @@ export default function ConnectClaude() { needs_onchain_registration: boolean delegate_public_key: string delegate_sui_address: string + // Absent on a relayer older than WALM-605, which is the + // pre-existing behaviour: no resume, just the first-time path. + resume_pending_registration?: boolean }>(`${apiBase}/api/oauth/session/${sessionId}/account`, { method: 'POST', body: JSON.stringify({ account_id: accountId, owner_address: currentAccount.address }), @@ -187,30 +349,18 @@ export default function ConnectClaude() { tx.object('0x6'), ], }) - let result - try { - result = await signAndExecute({ transaction: tx }) - } catch (txErr: unknown) { - const m = txErr instanceof Error ? txErr.message : String(txErr) - if (m.includes('abort code: 0') && m.includes('add_delegate_key')) { - setErrorMsg( - `This wallet (${currentAccount.address.slice(0, 10)}…${currentAccount.address.slice(-6)}) is not the owner of Walrus Memory account ${accountId.slice(0, 10)}…${accountId.slice(-6)}. ` + - `Switch to the wallet that created this account, or run /setup for a new one.` - ) - trackEvent('claude_connect_failed', { error_type: 'owner_mismatch' }) - setStep('error') - return - } - if (m.includes('abort code: 2') && m.includes('add_delegate_key')) { - setErrorMsg( - `This account already has the maximum number of delegate keys (20). Go to /dashboard and revoke an unused key, then try again.` - ) - trackEvent('claude_connect_failed', { error_type: 'max_delegate_keys' }) - setStep('error') - return - } - throw txErr - } + // Deliberately no `account.move` abort-code branches here. + // This page signs through `useSponsoredTransaction`, which + // throws `new Error(sponsorFailureMessage(...))`, and the + // relayer's `sponsor::mask_upstream` collapses any upstream + // 5xx into a bare 502 `sponsor_upstream_error` that never + // echoes the Move abort. `EDelegateKeyAlreadyExists` (0), + // `ETooManyDelegateKeys` (2) and `ENotOwner` (4) therefore all + // reach us as the same opaque sponsor string; matching on them + // read like recovery while never firing. The duplicate-key + // case is settled before this point, by `/account`'s on-chain + // check (`resume_pending_registration`). + const result = await signAndExecute({ transaction: tx }) await suiClient.waitForTransaction({ digest: result.digest }) txDigest = result.digest } @@ -220,28 +370,31 @@ export default function ConnectClaude() { `Walrus Memory OAuth authorization\nsession:${sessionId}\naccount:${accountId.toLowerCase()}\nowner:${currentAccount.address.toLowerCase()}`, ) const { signature: ownerSignature } = await signPersonalMessage({ message: proofMessage }) - const { redirect_url } = await fetchJson<{ redirect_url: string }>( - `${apiBase}/api/oauth/session/${sessionId}/complete`, - { - method: 'POST', - body: JSON.stringify({ - account_id: accountId, - owner_address: currentAccount.address, - owner_signature: ownerSignature, - tx_digest: txDigest || 'reused-delegate', - }), - }, - ) - sessionStorage.removeItem('memwal_claude_connect') - setStep('redirecting') - trackEvent('claude_connect_complete', { reused_delegate: !preflight.needs_onchain_registration }) - window.location.replace(redirect_url) + const resuming = preflight.resume_pending_registration === true + await completeSession({ + account_id: accountId, + owner_address: currentAccount.address, + owner_signature: ownerSignature, + // `/complete` requires a non-empty digest. When no tx was sent + // this call names why, rather than claiming a reused delegate + // for a key this session registered on an earlier attempt. + tx_digest: txDigest || (resuming ? 'already-on-chain' : 'reused-delegate'), + reused_delegate: !preflight.needs_onchain_registration && !resuming, + }) } catch (err) { setErrorMsg(err instanceof Error ? err.message : String(err)) + if (isRetryableComplete(err)) { + // The session is still pending. The terminal error has no + // button back, so return to consent and let the user start + // this request again. + setStep('consent') + trackEvent('claude_connect_failed', { error_type: 'sui_unavailable' }) + return + } setStep('error') trackEvent('claude_connect_failed', { error_type: getAnalyticsErrorType(err) }) } - }, [session, currentAccount, suiClient, signAndExecute, signPersonalMessage, apiBase, sessionId]) + }, [session, currentAccount, suiClient, signAndExecute, signPersonalMessage, completeSession]) const handleCancel = useCallback(async () => { try { @@ -260,6 +413,7 @@ export default function ConnectClaude() { // Auto-proceed once the wallet popup resolves. useEffect(() => { if (!walletPickerOpen && currentAccount && step === 'consent') { + if (hasStoredComplete(sessionId)) return void handleConnect() } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -284,7 +438,7 @@ export default function ConnectClaude() { )} {step === 'consent' && session && ( - + )} {step === 'signing' && ( @@ -322,6 +476,23 @@ export default function ConnectClaude() { )} + {step === 'retry-complete' && ( +
+

Sui is busy — try again

+

+ We couldn't reach Sui to verify your delegate key, so this authorization is + still pending rather than spent. Nothing needs redoing — press the button to + finish. +

+

{errorMsg}

+
+ +
+
+ )} + {step === 'error' && (

Something went wrong

@@ -339,11 +510,13 @@ export default function ConnectClaude() { function ConsentCard({ session, wallet, + error, onConnect, onCancel, }: { session: SessionView wallet: string | null + error?: string onConnect: () => void onCancel: () => void }) { @@ -381,6 +554,7 @@ function ConsentCard({
+ {error ?

{error}

: null}