Skip to content

feat(auth): route all backend-v2 calls through the authed fetch chokepoint (#2879)#2880

Draft
piyalbasu wants to merge 21 commits into
feat/2769-derive-auth-keypairfrom
feat/2879-backend-v2-authed-chokepoint
Draft

feat(auth): route all backend-v2 calls through the authed fetch chokepoint (#2879)#2880
piyalbasu wants to merge 21 commits into
feat/2769-derive-auth-keypairfrom
feat/2879-backend-v2-authed-chokepoint

Conversation

@piyalbasu

Copy link
Copy Markdown
Contributor

TL;DR

Routes every Freighter-extension call to freighter-backend-v2 through a single background "chokepoint" that attaches the user's per-request auth token when the wallet is unlocked, and sends anonymously otherwise (#2879). This is the client groundwork so the backend can attribute requests to a user and, eventually, require authentication.

It's non-breaking: the backend stays permissive, so old clients (and locked/onboarding states) keep working — updated clients just start including the token, which shows up in the backend's auth metrics as adoption signal. No user-facing change.

Stacked on #2770 (PR #2877) — its base is feat/2770-per-request-backend-jwt, so this diff shows only the #2879 work. Merge order: #2876#2877 → this.

Design doc lives in wallet-eng-monorepo (design-docs/contact-lists/Freighter Backend-V2 Authed Fetch Chokepoint Design Doc.md).

Draft for review of the chokepoint architecture + the message-routing of the migrated call sites.

Implementation details (for agents/reviewers)

The chokepointextension/src/background/helpers/callBackendV2.ts: derives the auth keypair on-demand from the unlocked session mnemonic (else null → anonymous), and either delegates to authedFetch (#2770, JWT + retry-once-on-401) or does a plain fetch. Returns { status, body }.

  • Signed path correctness: INDEXER_V2_URL already includes /api/v1, so the JWT's methodAndPath is derived from the resolved URL's pathname + search (full server request-target incl. query, e.g. POST /api/v1/ledger-key/accounts?network=PUBLIC) — matching the server's r.URL.RequestURI(). Unit-tested by decoding the JWT claim.

Wiring (option A — route through the background): popup callers reach the chokepoint via a new FETCH_BACKEND_V2 message (SERVICE_TYPES + message-request.ts union + a popupMessageListener case gated by isFromExtensionPage so content scripts can't trigger authed calls). The seed never leaves the background; only { status, body } returns to the popup; the base URL is server-fixed (not an open proxy).

Migrated call sites:

  • getDiscoverDataGET /protocols (popup → message)
  • getLedgerKeyAccountsPOST /ledger-key/accounts?network= (popup → message)
  • fetchCollectiblesPOST /collectibles?network= (popup → message)
  • rpc-health (account.ts) → GET /rpc-health?network= (background → direct callBackendV2)

Notable: routing rpc-health introduced a circular import (ducks/session → account.ts → callBackendV2 → helpers/session → store → ducks/session); fixed by lazy-importing callBackendV2 inside getIsRpcHealthy. npx madge --circular confirms zero cycles remain.

Verification: yarn jest @shared/api 76/76; yarn jest extension/src/background 33 suites / 0 failed. New unit tests cover the chokepoint (unlocked/locked, POST body+Content-Type, full signed query path, non-2xx) and each migrated wrapper's message shape + mapping. Reviewed per-task + a whole-branch review (verdict: ready to merge).

Scope / follow-ups (out of scope here):

  • Backend permissive→required flip (freighter-backend-v2#114), gated on extension+mobile adoption metrics + a pre-flip audit that none of these endpoints is hit by a locked/watch-only account.
  • Mobile parity (freighter-mobile#865).
  • The two translation.json keys in the diff are the husky i18next-scanner hook backfilling a pre-existing master gap — unrelated.

@github-actions

Copy link
Copy Markdown
Contributor

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

@piyalbasu piyalbasu force-pushed the feat/2770-per-request-backend-jwt branch from 65143fd to 9594e94 Compare June 29, 2026 20:29
@piyalbasu piyalbasu force-pushed the feat/2879-backend-v2-authed-chokepoint branch from 00c2a9e to 4e522b0 Compare June 29, 2026 20:30
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 and others added 10 commits June 29, 2026 21:27
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…KEND_V2 (#2879)

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

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>
#2879)

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
…est 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>
@piyalbasu piyalbasu force-pushed the feat/2879-backend-v2-authed-chokepoint branch from 4e522b0 to 88c629a Compare June 30, 2026 01:27
Base automatically changed from feat/2770-per-request-backend-jwt to feat/2769-derive-auth-keypair June 30, 2026 20:26
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.

1 participant