Skip to content

feat(auth): per-request backend JWT + E2E round-trip (#2770)#2877

Merged
piyalbasu merged 11 commits into
feat/2769-derive-auth-keypairfrom
feat/2770-per-request-backend-jwt
Jun 30, 2026
Merged

feat(auth): per-request backend JWT + E2E round-trip (#2770)#2877
piyalbasu merged 11 commits into
feat/2769-derive-auth-keypairfrom
feat/2770-per-request-backend-jwt

Conversation

@piyalbasu

@piyalbasu piyalbasu commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Builds the client side of Freighter's stateless backend auth: a function that mints a fresh, short-lived signed token for each request to freighter-backend-v2, signed with the anonymous auth key derived in #2769 — so the backend can verify who's calling without the wallet ever doing a signing prompt or revealing its Stellar address. Includes a tiny request wrapper that retries once if a token is rejected, and a gated Playwright integration test that proves the whole round-trip works against a live backend.

This is the piece that lets you test the auth middleware end-to-end (#2770). It's still scoped narrowly: it does not yet wire auth into the real BE v2 requests — that's a later ticket. Nothing user-facing changes yet.

Stacked on #2769 (PR #2876) — review/merge that first; this PR's base is feat/2769-derive-auth-keypair, so the diff here is only the #2770 work.

Design doc lives in wallet-eng-monorepo (design-docs/contact-lists/Freighter Per-Request Backend JWT Design Doc.md).

Draft: opening for early review of the JWT contract and the E2E approach.

Implementation details (for agents/reviewers)

What changed (all new, under @shared/api/helpers/ + a gated integration test):

  • buildAuthJwt.ts — mints a compact EdDSA JWS per request. Header {alg:"EdDSA",typ:"JWT"}; claims sub (lowercase-hex auth pubkey = userId), iss ("freighter-extension"), iat, exp (iat+15s), bodyHash (hex SHA-256 of raw body via crypto.subtle; empty-bytes hash for no body), methodAndPath ("<METHOD> <path>", method upper-cased, path includes query — bound to the server's r.URL.RequestURI()). Signs b64url(header).b64url(payload) with Keypair.sign(); url-safe base64, no padding. now is injectable for deterministic tests.
  • authedFetch.ts — attaches Authorization: Bearer <jwt>, builds a fresh JWT per call, and on 401 rebuilds a fresh JWT and retries exactly once. Defaults Content-Type: application/json for non-GET. Normalizes the method to upper-case on the wire so it always matches the upper-cased method baked into the JWT's methodAndPath claim (fetch only auto-uppercases the standard verbs, not PATCH/custom — the mismatch would be a silent 401). baseUrl trailing slash normalized so the fetched URL can't diverge from the signed methodAndPath. (No custom-header passthrough yet — the auth middleware only reads Authorization and the backend enforces no request Content-Type, so it's unneeded until a real endpoint requires it.)
  • extension/e2e-tests/integration-tests/authJwt.test.ts — a gated Playwright integration test (request/APIRequestContext fixture, pure HTTP — no browser/extension launch). Derives a keypair from the [Extension] Derive auth keypair from seed for backend authentication #2769 vector mnemonic (known userId) and hits GET /api/v1/auth/whoami, asserting: valid→200+matching userId; tampered bodyHash→401; flipped-signature→401; expired→401. Gated behind IS_INTEGRATION_MODE=true via test.skip, so it never runs in the normal stubbed e2e/CI pass.
  • @shared/api/helpers/__tests__/buildAuthJwt.test.ts (7), authedFetch.test.ts (6) — unit tests.

Why a gated Playwright test (not a jest integration test): it lives beside the other integration-tests/, reuses the request fixture for a real HTTP round-trip against a locally-run or staging backend, and stays skipped unless IS_INTEGRATION_MODE=true so CI never depends on a live backend. whoami is GET-only, so the four cases are mapped onto it: tampered body = a JWT carrying a non-empty bodyHash while the GET body is empty; wrong key = a flipped signature byte; expired = a backdated now.

How to run the E2E round-trip:

# point at a live backend (defaults to staging if BACKEND_V2_URL is unset):
#   freighter-backend-v2: make run   # serves :3002, then set BACKEND_V2_URL=http://localhost:3002
IS_INTEGRATION_MODE=true yarn workspace extension test:e2e authJwt

Env: BACKEND_V2_URL (default https://freighter-backend-v2-stg.stellar.org), AUTH_E2E_MNEMONIC (default: the #2769 vector mnemonic, known userId).

The staging deploy must run an image that includes the whoami/auth route; an older image returns 404 and the valid case fails.

Verification: yarn jest @shared/api → 10 suites / 84 tests pass under jest-fixed-jsdom (real WebCrypto). The integration test is test.skip-gated, so without IS_INTEGRATION_MODE it reports as skipped rather than failing. Reviewed per-task + a whole-branch review.

Notes:

  • One prior review suggestion (assert the two retry JWTs differ) was rejected: iat is whole-seconds and Ed25519 is deterministic, so within-second retries are byte-identical — that assertion would be flaky and the property is behaviorally moot. The "fresh per request" guarantee is structural (buildAuthJwt is rebuilt inside the retry).
  • The two translation.json keys in the diff are the husky i18next-scanner hook backfilling a pre-existing master gap — unrelated to this feature.

Follow-ups (out of scope): wire authedFetch into the real background contacts path; optionally run the gated integration test in CI against staging once stable; freighter-mobile builds the equivalent against the same contract.

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-f40a2047c614474fe4b0 (SDF collaborators only — install instructions in the release description)

@piyalbasu piyalbasu force-pushed the feat/2769-derive-auth-keypair branch from 6245cdd to b6e0804 Compare June 29, 2026 20:28
@piyalbasu piyalbasu force-pushed the feat/2770-per-request-backend-jwt branch from 65143fd to 9594e94 Compare June 29, 2026 20:29
@piyalbasu piyalbasu marked this pull request as ready for review June 29, 2026 20:34

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9594e947b3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread @shared/api/helpers/authedFetch.ts Outdated

@JakeUrban JakeUrban left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think codex found a bug, and I'm skeptical of the need for the retry logic in authedFetch, but other than that this LGTM.

Comment thread @shared/api/helpers/authedFetch.ts Outdated
Comment on lines +30 to +33
// Strip a trailing slash so the fetched URL can't diverge from the `path`
// baked into the JWT's methodAndPath claim (a "//api/..." vs "/api/..." split
// would be a silent 401).
const url = `${baseUrl.replace(/\/+$/, "")}${path}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the example provided in the comment correct? The "trailing slash" description makes it sound like what we're normalizing is /api/ vs /api.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — the original wording was muddled. Clarified in ac942ce/fae6da34. The trailing-slash strip only prevents a // when baseUrl ends in / and path starts with /; it isn't what guarantees the signed path. The actual correctness now comes from deriving the signed methodAndPath from new URL(url).pathname + search, so the signed claim and the wire request can't diverge regardless of slashes or where the /api/v1 prefix sits.

Comment on lines +49 to +51
const first = await send();
if (first.status !== 401) return first;
return send();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are we guarding against with this retry? My understanding is that 401 is returned either because the token is invalid or the user is truly unauthorized. If the first token is invalid, why would the second not be?

@piyalbasu piyalbasu Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It guards the clock-skew / in-flight-expiry case from the design doc's Failure Modes table ("Client clock skew → JWT expires before reaching server → Client retries with fresh JWT on 401 → Transient; retry succeeds"). The JWT has a 15s lifetime, so a token that's valid when signed can age out between signing and the server validating it (latency, or client clock drift beyond the server's ±5s leeway). Regenerating produces a fresh iat/exp that recovers that transient case.

You're right that a token invalid for a structural reason (wrong key, bad bodyHash/methodAndPath) will 401 again on retry — that's expected, and we just surface the error after the single retry. The retry isn't meant to fix those.

The reason we retry automatically rather than rely on the caller: many of these requests will be non-interactive — e.g. the background polling of /account-balances — so there's no user in the loop to manually re-trigger after a transient clock-skew 401. The auto-retry handles it transparently. Clock skew should be rare, so the cost is an occasional extra request, capped at one.

@piyalbasu piyalbasu Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That being said - we could also add this as a per-endpoint configuration if we'd rather limit how often we do this

piyalbasu and others added 11 commits June 29, 2026 21:27
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…2770)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n script (#2770)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ndPath

buildAuthJwt bakes method.toUpperCase() into the methodAndPath claim, but
authedFetch sent the raw-case method on the wire. fetch only auto-uppercases
the standard verbs (GET/POST/...), not PATCH or custom methods — so a
lower-case non-standard method would leave the server's r.Method mismatching
the signed claim and yield a silent 401. Normalize the method once and use it
for both the JWT and the request. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…path

The backend verifies methodAndPath against r.URL.RequestURI() (the full
path+query). authedFetch signed the caller's `path` fragment alone, but the
backend base URL (INDEXER_V2_URL) carries an "/api/v1" prefix and helpers
append the endpoint suffix — so base "<host>/api/v1" + path "/contacts" fetched
"/api/v1/contacts" while signing "/contacts", a guaranteed 401 once wired into
the real path. Derive the signed target from the final URL's pathname+search so
it always matches the wire request regardless of where the prefix lives. Adds
prefix + query-string regression tests.

Addresses Codex review (P2) on PR #2877.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@piyalbasu piyalbasu force-pushed the feat/2770-per-request-backend-jwt branch from 9594e94 to ac942ce Compare June 30, 2026 01:27
@piyalbasu piyalbasu merged commit 9867443 into feat/2769-derive-auth-keypair Jun 30, 2026
5 checks passed
@piyalbasu piyalbasu deleted the feat/2770-per-request-backend-jwt branch June 30, 2026 20:26
piyalbasu added a commit that referenced this pull request Jul 14, 2026
* docs(auth): spec for deriving backend auth keypair from seed (#2769)

Design doc for the extension-side derivation primitive:
HMAC-SHA256(seedBytes, "freighter-auth-v1") -> Ed25519 keypair, hex
pubkey = anonymous backend user ID. Covers scope, threat model, crypto
choices (crypto.subtle + stellar-sdk, zero new deps), exact algorithm,
session-timeout lifecycle, verified cross-platform test vectors, and a
reworded acceptance #2 (cryptographic independence, not "invalid G addr").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(auth): HMAC auth-seed derivation + cross-platform vectors (#2769)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): declare bip39 dep, drop unused stellar-hd-wallet in @shared/api (#2769)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(auth): align spec/plan with bip39-direct derivation (jest interop) (#2769)

* docs(auth): align plan Task 1 with bip39-direct implementation (#2769)

* feat(auth): derive Ed25519 auth keypair + userId from seed (#2769)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(auth): mark deriveAuthSeed @internal; clarify purity test comment (#2769)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): move authKeypairVectors fixture out of __tests__ (CI test collection) (#2769)

* chore(auth): keep PR code-only — spec moved to wallet-eng-monorepo, plan untracked (#2769)

* fix(auth): correct fixture import path after moving it out of __tests__ (#2769)

* docs(auth): note authKeypairVectors is a test fixture (PR #2876 review)

* feat(auth): per-request backend JWT + E2E round-trip (#2770) (#2877)

* feat(auth): per-request EdDSA JWT builder in @shared/api (#2770)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(auth): authedFetch wrapper with retry-once-on-401 (#2770)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(auth): cover authedFetch Content-Type default + override (#2770)

* feat(auth): runnable E2E script for backend JWT round-trip (#2770)

* fix(auth): E2E script records per-case failures instead of aborting (#2770)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): uppercase signed method, normalize baseUrl join, pin tsx in script (#2770)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(auth): narrow JWT body to string, drop unused authedFetch headers override (#2770)

* test(auth): replace E2E script with gated Playwright integration test (#2770)

* test(auth): add gated Playwright auth e2e test (dropped from the replace-script commit) (#2770)

* fix(auth): upper-case authedFetch wire method to match signed methodAndPath

buildAuthJwt bakes method.toUpperCase() into the methodAndPath claim, but
authedFetch sent the raw-case method on the wire. fetch only auto-uppercases
the standard verbs (GET/POST/...), not PATCH or custom methods — so a
lower-case non-standard method would leave the server's r.Method mismatching
the signed claim and yield a silent 401. Normalize the method once and use it
for both the JWT and the request. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): sign the full request target in authedFetch, not the bare path

The backend verifies methodAndPath against r.URL.RequestURI() (the full
path+query). authedFetch signed the caller's `path` fragment alone, but the
backend base URL (INDEXER_V2_URL) carries an "/api/v1" prefix and helpers
append the endpoint suffix — so base "<host>/api/v1" + path "/contacts" fetched
"/api/v1/contacts" while signing "/contacts", a guaranteed 401 once wired into
the real path. Derive the signed target from the final URL's pathname+search so
it always matches the wire request regardless of where the prefix lives. Adds
prefix + query-string regression tests.

Addresses Codex review (P2) on PR #2877.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): pin english wordlist, drop redundant Buffer wrap

Address review feedback on deriveAuthSeed:
- Pin validateMnemonic to wordlists.english instead of relying on
  bip39's implicit require-order default, matching the rest of the
  wallet's mnemonic paths (StellarHDWallet.validateMnemonic(m, "english")).
- Drop the redundant Buffer.from() around mnemonicToSeedSync, which
  already returns a Buffer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
piyalbasu added a commit that referenced this pull request Jul 14, 2026
…point (#2879) (#2880)

* docs(auth): spec for deriving backend auth keypair from seed (#2769)

Design doc for the extension-side derivation primitive:
HMAC-SHA256(seedBytes, "freighter-auth-v1") -> Ed25519 keypair, hex
pubkey = anonymous backend user ID. Covers scope, threat model, crypto
choices (crypto.subtle + stellar-sdk, zero new deps), exact algorithm,
session-timeout lifecycle, verified cross-platform test vectors, and a
reworded acceptance #2 (cryptographic independence, not "invalid G addr").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(auth): HMAC auth-seed derivation + cross-platform vectors (#2769)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): declare bip39 dep, drop unused stellar-hd-wallet in @shared/api (#2769)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(auth): align spec/plan with bip39-direct derivation (jest interop) (#2769)

* docs(auth): align plan Task 1 with bip39-direct implementation (#2769)

* feat(auth): derive Ed25519 auth keypair + userId from seed (#2769)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(auth): mark deriveAuthSeed @internal; clarify purity test comment (#2769)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): move authKeypairVectors fixture out of __tests__ (CI test collection) (#2769)

* chore(auth): keep PR code-only — spec moved to wallet-eng-monorepo, plan untracked (#2769)

* fix(auth): correct fixture import path after moving it out of __tests__ (#2769)

* docs(auth): note authKeypairVectors is a test fixture (PR #2876 review)

* feat(auth): per-request EdDSA JWT builder in @shared/api (#2770)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(auth): authedFetch wrapper with retry-once-on-401 (#2770)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(auth): cover authedFetch Content-Type default + override (#2770)

* feat(auth): runnable E2E script for backend JWT round-trip (#2770)

* fix(auth): E2E script records per-case failures instead of aborting (#2770)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): uppercase signed method, normalize baseUrl join, pin tsx in script (#2770)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(auth): narrow JWT body to string, drop unused authedFetch headers override (#2770)

* test(auth): replace E2E script with gated Playwright integration test (#2770)

* test(auth): add gated Playwright auth e2e test (dropped from the replace-script commit) (#2770)

* fix(auth): upper-case authedFetch wire method to match signed methodAndPath

buildAuthJwt bakes method.toUpperCase() into the methodAndPath claim, but
authedFetch sent the raw-case method on the wire. fetch only auto-uppercases
the standard verbs (GET/POST/...), not PATCH or custom methods — so a
lower-case non-standard method would leave the server's r.Method mismatching
the signed claim and yield a silent 401. Normalize the method once and use it
for both the JWT and the request. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): sign the full request target in authedFetch, not the bare path

The backend verifies methodAndPath against r.URL.RequestURI() (the full
path+query). authedFetch signed the caller's `path` fragment alone, but the
backend base URL (INDEXER_V2_URL) carries an "/api/v1" prefix and helpers
append the endpoint suffix — so base "<host>/api/v1" + path "/contacts" fetched
"/api/v1/contacts" while signing "/contacts", a guaranteed 401 once wired into
the real path. Derive the signed target from the final URL's pathname+search so
it always matches the wire request regardless of where the prefix lives. Adds
prefix + query-string regression tests.

Addresses Codex review (P2) on PR #2877.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(auth): callBackendV2 chokepoint for backend-v2 requests (#2879)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(auth): cover callBackendV2 authed-POST headers + signed query path (#2879)

* feat(auth): route getDiscoverData through callBackendV2 via FETCH_BACKEND_V2 (#2879)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): gate FETCH_BACKEND_V2 behind isFromExtensionPage (#2879)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(auth): route getLedgerKeyAccounts through callBackendV2 (#2879)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): add type param to sendMessageToBackground in getLedgerKeyAccounts

Resolves TS2339 errors from the webpack pre-commit hook — the generic
defaults to the web Response type; pass { status: number; body: unknown }
so TypeScript resolves the destructure correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(auth): cover getLedgerKeyAccounts non-200 path + richer error log (#2879)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(auth): route collectibles fetch through FETCH_BACKEND_V2 message (#2879)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(auth): route rpc-health through callBackendV2 (#2879)

Thread sessionStore into getIsRpcHealthy / loadBackendSettings and swap
the direct fetch for callBackendV2 so the JWT chokepoint covers /rpc-health.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): break circular import exposing sessionSlice; fix account.test types (#2879)

Remove the top-level `import { callBackendV2 }` from account.ts and replace it
with a deferred dynamic import() inside getIsRpcHealthy. This breaks the
module-eval-time cycle: ducks/session → account → callBackendV2 → session.ts
→ store → ducks/session, which caused sessionSlice to resolve as undefined in
~11 background test suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): route v2 getTokenPrices through the chokepoint (#2879)

getTokenPrices' v2 branch still did a raw fetch to ${INDEXER_V2_URL}/token-prices
(added to master by #2870 after this branch diverged), while this branch had
already dropped the INDEXER_V2_URL import from internal.ts — breaking the build
(TS2552: Cannot find name 'INDEXER_V2_URL') and leaving a backend-v2 call site
outside the chokepoint, contrary to the ticket's acceptance criteria.

Route the v2 path through the FETCH_BACKEND_V2 message (query-in-path so the
signed JWT's methodAndPath matches the server's request-target), matching the
other migrated call sites. The v1 (useV2=false) path stays a direct fetch to
the legacy indexer.

Contract parity with getDiscoverData: treat a 200 without a `data` payload as a
failure (throw) rather than returning undefined, and keep the response body in
the Sentry message. Rewrite the v2 tests to assert on the background message
instead of a fetch spy, and add coverage for the return value and the error
path (non-200 and 200-without-data both throw).

INDEXER_V2_URL is now referenced only by the chokepoint (callBackendV2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(auth): stub backend-v2 message calls in AssetDetail test (#2879)

AssetDetail.test.tsx rendered an issued asset, whose domain gate awaits
getAssetDomains → getLedgerKeyAccounts. That call was migrated to the
FETCH_BACKEND_V2 background message earlier in this branch, and the test env
has no listener — so the message never resolves *or rejects* and the view is
stuck on <Loading /> (a plain fetch failure used to reject and clear the gate).

These two failures were latent on the branch, masked by the TS2552 compile
error that killed the test job before it ran; fixing the compile error
surfaced them. Stub getAssetDomains (and getTokenPrices, same message-hang
class) to resolve empty, mirroring Send.test.tsx. Full suite: 137 suites / 0
failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(auth): intercept collectibles e2e fetch on context, not page (#2879)

fetchCollectibles was migrated to the FETCH_BACKEND_V2 background message
earlier in this branch, so the /collectibles request is now made by the MV3
service worker. The collectibles e2e specs mocked it with page.route, which
only intercepts the popup page — so the mock never fired, no collectibles
rendered, and the tests timed out (addCollectible failed all 5 CI retries).

Switch the /collectibles interception to context.route (intercepts the SW),
matching how token-prices and account-history are already mocked. Fixed in the
shared stubCollectibles / stubCollectiblesUnsuccessfulMetadata helpers (now take
a context param) plus the inline overrides in addCollectible/hideCollectible.
tokenMetadata stays on page.route — it's a genuine popup fetch.

Like the AssetDetail unit failure, these were latent on the branch, masked by
the TS2552 compile error that killed the test job before e2e ran. Verified
locally: 19/19 collectible-tagged e2e tests pass (addCollectible, hideCollectible,
loadAccount, sendCollectible, reviewTxFees, sendPayment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(auth): route protocols/token-prices e2e stubs on the SW context (#2879)

Same background-service-worker mocking gap as the collectibles fix, for the
other endpoints migrated to the FETCH_BACKEND_V2 chokepoint:

- /protocols (getDiscoverData): stubDiscoverProtocols / stubDiscoverProtocolsError
  and discover.test.ts's unroute now use page.context().route/unroute so the SW
  request is intercepted. Fixes the 3 discover CI failures.
- /token-prices (getTokenPrices, migrated in this PR): stubTokenPrices normalizes
  its Page|BrowserContext arg to the context before routing, and loadAccount's
  batching test counts calls via context.on("request") instead of page.on — the
  request is now issued by the service worker, not the popup.

Verified locally: discover.test.ts 7/7, loadAccount token-prices batching test,
and the full collectibles set all pass. (Unrelated: the freighterApiIntegration
signing tests fail only against a real backend URL locally; they pass in CI and
don't touch these endpoints.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(auth): skip JWT for rpc-health, it's never auth-gated (#2879)

rpc-health went through callBackendV2's authed path, so an unlocked wallet
derived the auth keypair (PBKDF2) and signed a JWT on every health check. That
endpoint will never be auth-gated, so the token is pointless work.

Add a skipAuth option to callBackendV2 that bypasses keypair derivation and
always sends an anonymous fetch, and set it on the rpc-health call. Keeps
rpc-health flowing through the single chokepoint (per the ticket) while avoiding
the JWT and its PBKDF2 cost. Test proves skipAuth never reads the session or
derives a keypair even when unlocked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(auth): correct getTokenPrices comment; fast-fail skipAuth test (#2879)

- getTokenPrices: the v2 comment said the chokepoint "sends anonymously
  otherwise". token-prices is only ever fetched from an unlocked wallet (a
  locked wallet shows the login screen), so it always carries the JWT — never
  anonymous. Reword to match.
- callBackendV2 skipAuth test: give the deriveAuthKeypair spy a mock impl so a
  regression fails fast instead of running real PBKDF2 before the assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): address backend-v2 chokepoint review (#2879)

Resolves review feedback on #2880:

- popupMessageListener: DEV_SERVER carve-out on the FETCH_BACKEND_V2 gate. Under
  the webpack dev server the popup relays through the content script, so
  isFromExtensionPage is false and every v2 call (Discover, prices, collectibles,
  ledger-key import) returned Unauthorized in local dev. Gate stays intact in
  production (DEV_SERVER=false).
- callBackendV2: parse the response body on non-2xx too, so a server error
  payload reaches Sentry instead of being flattened to null.
- callBackendV2: captureException on unexpected key-derivation failures
  (corrupted temporaryStoreExtra / WebCrypto error) instead of silently
  downgrading to anonymous with no telemetry; corrected the catch comment.
- Extract a shared fetchBackendV2() popup helper owning the FETCH_BACKEND_V2
  message shape + typed result (incl. the { error } reply, normalized to a 401),
  replacing the ~10-line block copy-pasted across getDiscoverData,
  getTokenPrices, fetchCollectibles, and getLedgerKeyAccounts.

PBKDF2-per-request (deriveAuthKeypair on the SW thread) is deferred to #2897 as
a deliberate follow-up: derivation is deterministic, so a session-scoped
in-memory memo would be equally secure — kept out of this routing PR to avoid a
rushed keypair-lifecycle change.

New tests: JSON error-body preserved, capture-on-derive-failure, and
fetchBackendV2 error-reply normalization. Full suite 138 suites / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): address Copilot review on v2 fetch helpers

- fetchBackendV2: only map the sender-rejection error ("Unauthorized") to
  401; any other { error } reply (e.g. "Message type not supported") maps to
  500 so callers don't misread an unrelated failure as an auth problem. Guard
  a falsy/undefined message-channel response into a defined 500 result instead
  of returning it and breaking { status, body } destructuring.
- callBackendV2: capture the original exception (preserving the stack) with
  the context in `extra`, rather than JSON.stringify(e) — stringifying an Error
  yields "{}" and JSON.stringify can throw on non-serializable values.
- Tests: cover non-auth error → 500, missing response → 500, and assert the
  original Error + extra context reach captureException.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): back crypto.subtle byte inputs with ArrayBuffer

TS 6.0 types Uint8Array/Buffer as generic over ArrayBufferLike, which
is not assignable to crypto.subtle's BufferSource (ArrayBufferView<ArrayBuffer>).
Copy the digest input and the bip39 seed into ArrayBuffer-backed
Uint8Arrays, matching the existing session.ts idiom. Fixes the production
build (TS2345/TS2769).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants