diff --git a/.github/greenlight.yml b/.github/greenlight.yml new file mode 100644 index 00000000..45935ff9 --- /dev/null +++ b/.github/greenlight.yml @@ -0,0 +1,2 @@ +enabled: true +required_reviewer_checks: [] diff --git a/.github/workflows/ci-pro-frontend-jwt-auth.yml b/.github/workflows/ci-pro-frontend-jwt-auth.yml new file mode 100644 index 00000000..2d821587 --- /dev/null +++ b/.github/workflows/ci-pro-frontend-jwt-auth.yml @@ -0,0 +1,27 @@ +name: "Pro Frontend JWT Auth Example" +on: + push: + branches: + - main + pull_request: + paths: + - "pro/frontend-jwt-auth/**" + - ".github/workflows/ci-pro-frontend-jwt-auth.yml" + +jobs: + build: + name: Build + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: pro/frontend-jwt-auth + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - run: npm install --global pnpm@9.2.0 + - run: pnpm install --frozen-lockfile + - run: pnpm run test:format + - run: pnpm run test:types + - run: pnpm run build diff --git a/pro/frontend-jwt-auth/.env.example b/pro/frontend-jwt-auth/.env.example new file mode 100644 index 00000000..610c56a4 --- /dev/null +++ b/pro/frontend-jwt-auth/.env.example @@ -0,0 +1,11 @@ +# Pyth Pro API key. Contact Pyth for a Pro key +# (see https://docs.pyth.network/price-feeds/pro). +PRO_API_KEY=your_pyth_pro_api_key_here + +# Optional. Override the default Pyth Pro API base URL if you are pointed +# at a non-prod environment. Set BOTH variables together: the server mints +# tokens against PYTH_API_BASE_URL, while the browser sends history requests +# to NEXT_PUBLIC_PYTH_API_BASE_URL. Overriding only one splits the app +# across two environments. +# PYTH_API_BASE_URL=https://pyth.dourolabs.app +# NEXT_PUBLIC_PYTH_API_BASE_URL=https://pyth.dourolabs.app diff --git a/pro/frontend-jwt-auth/.gitignore b/pro/frontend-jwt-auth/.gitignore new file mode 100644 index 00000000..19ef5c08 --- /dev/null +++ b/pro/frontend-jwt-auth/.gitignore @@ -0,0 +1,8 @@ +node_modules +.next +out +.env +.env.local +.env.*.local +next-env.d.ts +*.tsbuildinfo diff --git a/pro/frontend-jwt-auth/README.md b/pro/frontend-jwt-auth/README.md new file mode 100644 index 00000000..fac6ac09 --- /dev/null +++ b/pro/frontend-jwt-auth/README.md @@ -0,0 +1,242 @@ +# Tutorial: Authenticating a Frontend Against the Pyth Pro API with JWTs + +This example is a minimal [Next.js](https://nextjs.org/) app that renders live +BTC/USD, ETH/USD, and SOL/USD candlestick charts from the +[Pyth Pro History API](https://docs.pyth.network/price-feeds/pro/api/history), +and more importantly shows the **right way to authenticate a browser app** +against it. Every History API request must carry a JWT +([announcement](https://dev-forum.pyth.network/t/action-required-pyth-pro-history-api-auth-required-starting-july-24/808/2)), +and the JWT is minted with your long-lived Pro API key, which must **never** +ship to the browser. + +![Live BTC, ETH, and SOL candlestick charts above the auth flow panel](screenshot.png) + +The finished app also renders a "How these charts authenticate" panel at the +bottom of the page with a live view of the token cache (expiry countdown, +mints this session), so you can watch the flow described below actually happen. + +## The problem this solves + +A naive frontend would call the History API with +`Authorization: Bearer $PRO_API_KEY` directly. That puts your permanent API +key in the JavaScript bundle where anyone can extract it. The +[frontend-auth flow](https://docs.pyth.network/price-feeds/pro/frontend-auth) +fixes this with a token exchange: + +1. Your **server** holds the API key and trades it for a **short-lived JWT**. +2. Your **browser** code caches that JWT and uses it for API calls. +3. When the JWT is about to expire, the browser asks your server for a new one. + +The browser only ever holds a token that expires in minutes; the key that can +mint tokens never leaves your server. + +## Architecture + +```mermaid +sequenceDiagram + participant B as Browser (charts) + participant S as Next.js server route + participant P as Pyth Pro API + + B->>S: POST /api/pyth-token + S->>P: POST /auth/token (Authorization: Bearer PRO_API_KEY) + P-->>S: { access_token, expires_at } + S-->>B: { access_token, expires_at } + Note over B: cache JWT, reuse until 30s before expiry + B->>P: GET /v1/fixed_rate@200ms/history (Authorization: Bearer JWT) + P-->>B: OHLC candles + Note over B: on 401 → invalidate, re-mint once, retry +``` + +Only the **mint** step goes through your server. History requests go straight +from the browser to the Pyth Pro API; your server never proxies price data. + +## Quick start + +Prerequisites: Node.js 18+, [pnpm](https://pnpm.io/) 9+, and a Pyth Pro API +key ([contact Pyth](https://docs.pyth.network/price-feeds/pro)). + +```bash +cd pro/frontend-jwt-auth +cp .env.example .env.local # then edit .env.local and set PRO_API_KEY +pnpm install +pnpm dev +``` + +Open . You should see three charts with the last 24 +hours of 1-minute candles, refreshing every 30 seconds. + +## Walkthrough + +The whole flow lives in four small files. Read them in this order. + +### Step 1: The server-only mint route + +[`src/app/api/pyth-token/route.ts`](src/app/api/pyth-token/route.ts) is the +only place `PRO_API_KEY` is read. It forwards `POST /api/pyth-token` to the +Pyth Pro token endpoint: + +```ts +const upstream = await fetch(`${baseUrl}/auth/token`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, // server-held PRO_API_KEY + "Content-Type": "application/json", + }, + body: JSON.stringify(upstreamBody), + cache: "no-store", +}); +``` + +Because this file is an App Router **route handler**, it runs exclusively on +the server. The key is read from `process.env.PRO_API_KEY`, never from a +`NEXT_PUBLIC_*` variable, which Next.js would inline into the browser bundle. +On upstream errors the route mirrors the status and body back to the caller, +which makes debugging (wrong key, revoked key) much easier during development. + +### Step 2: The browser token cache + +[`src/lib/pythToken.ts`](src/lib/pythToken.ts) wraps the mint route in a tiny +module-level cache: + +```ts +export async function getPythToken(): Promise { + if (cached && isFresh(cached)) { + return cached.access_token; + } + if (!inflight) { + inflight = mintToken().finally(() => { + inflight = undefined; + }); + } + const token = await inflight; + return token.access_token; +} +``` + +Two details matter here: + +- **Safety margin.** `isFresh` treats a token as stale 30 seconds _before_ + `expires_at`, so a request never leaves the browser with a token that + expires mid-flight. +- **In-flight dedup.** Three charts mount at once and all ask for a token. + Without the shared `inflight` promise each would mint its own JWT: three + upstream calls with your API key for no benefit. With it, one mint serves + all concurrent callers. + +### Step 3: Calling the History API from the browser + +[`src/lib/pythClient.ts`](src/lib/pythClient.ts) attaches the JWT as a bearer +token on every history request: + +```ts +const url = new URL(`${PYTH_API_BASE_URL}/v1/${channel}/history`); +url.searchParams.set("symbol", symbol); +url.searchParams.set("from", String(from)); +url.searchParams.set("to", String(to)); +url.searchParams.set("resolution", resolution); +return fetch(url.toString(), { + headers: { Authorization: `Bearer ${token}` }, // short-lived JWT, not the API key +}); +``` + +It also handles expiry-in-flight: + +```ts +let token = await getPythToken(); +let response = await fetchHistoryOnce(options, token); + +if (response.status === 401) { + invalidatePythToken(); // token expired or was revoked + token = await getPythToken(); // forces a fresh mint + response = await fetchHistoryOnce(options, token); +} +``` + +The `401` retry is the safety net for anything the safety margin can't catch: +a revoked token, a clock skew, a laptop waking from sleep. Invalidate, re-mint +once, retry once. If it still fails, surface the error. + +### Step 4: Rendering and refreshing the charts + +[`src/components/PythChart.tsx`](src/components/PythChart.tsx) draws the data +with [`lightweight-charts`](https://tradingview.github.io/lightweight-charts/): + +- On mount it loads the last 24 hours at 1-minute resolution and calls + `series.setData(...)`. +- Every 30 seconds it fetches just the last two minutes and applies them with + `series.update(...)`. One quirk to know: `series.update` only accepts the + chart's **last bar or newer**; passing an older bar throws + `Cannot update oldest data`. The component tracks the newest applied bar + time and skips anything older. + +### Watching it work + +The page's bottom panel (`src/components/AuthFlowPanel.tsx`) polls the token +cache once a second and shows the current JWT's expiry countdown and how many +mints have happened this session. Leave the page open for ~5 minutes and you +can watch the countdown hit the 30-second safety margin and a new mint occur. +The charts never notice. + +## Gotcha: symbol names + +The docs list symbols like `BTC/USD`, but the live `/v1/symbols` catalog +exposes crypto spot feeds under fully-qualified names like `Crypto.BTC/USD`; +the bare form returns `HTTP 404 symbol not found`. This example uses +`Crypto.{BTC,ETH,SOL}/USD` in API calls and shows the short form as the card +label. If a symbol 404s for you, check the catalog: + +```bash +curl -s "https://pyth.dourolabs.app/v1/symbols" \ + -H "Authorization: Bearer $JWT" | jq -r '.[].symbol' | grep BTC +``` + +## Security notes + +- `PRO_API_KEY` is read from `process.env` in the server route only. It is + never referenced from a `NEXT_PUBLIC_*` variable and never imported from a + client-side file, so it is not included in the browser bundle. You can + verify: `pnpm build && grep -rn PRO_API_KEY .next/static` → no matches. +- The mint route in this example has **no request auth or rate limit**. + Anyone who can reach `/api/pyth-token` can mint a JWT with your key. + **Before running this in production, add authentication (e.g. a session + cookie check) and a rate limit to `/api/pyth-token`.** +- The mint route mirrors the upstream error body verbatim for easier + debugging. In production, log the upstream response server-side and return + a generic error instead. + +## Configuration + +| Variable | Where | Default | Purpose | +| ------------------------------- | ----------- | ---------------------------- | ---------------------------------------------------------- | +| `PRO_API_KEY` | Server only | (required) | Long-lived Pyth Pro API key used to mint short-lived JWTs. | +| `PYTH_API_BASE_URL` | Server | `https://pyth.dourolabs.app` | Override for non-prod environments. | +| `NEXT_PUBLIC_PYTH_API_BASE_URL` | Browser | `https://pyth.dourolabs.app` | Override the History API base used from the browser. | + +Set the two base URL overrides together: the server mints tokens against +`PYTH_API_BASE_URL`, while the browser fetches history from +`NEXT_PUBLIC_PYTH_API_BASE_URL`. Overriding only one splits the app across +two environments. + +## Scripts + +- `pnpm dev`: Next.js dev server on `http://localhost:3000` +- `pnpm build`: Production build (does not require `PRO_API_KEY`) +- `pnpm start`: Serve the production build +- `pnpm test:format`: Prettier check +- `pnpm test:types`: `tsc --noEmit` + +## Troubleshooting + +| Symptom | Cause and fix | +| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Charts show `Failed to mint Pyth token: 500…` | `PRO_API_KEY` is not set. Copy `.env.example` to `.env.local`, set the key, restart `pnpm dev`. | +| Charts show a `401` invalid API key error | The key in `.env.local` is wrong or revoked. The route mirrors the upstream body, so this is Pyth's reply. | +| Charts show a `404` symbol not found error | Use fully-qualified symbols (`Crypto.BTC/USD`), see [Gotcha: symbol names](#gotcha-symbol-names). | +| Token env changes not picked up | Next.js reads `.env.local` at startup, so restart `pnpm dev` after editing it. | + +## References + +- [Pyth Pro frontend auth](https://docs.pyth.network/price-feeds/pro/frontend-auth) (primary reference for the JWT flow) +- [Pyth Pro History API](https://docs.pyth.network/price-feeds/pro/api/history) +- [Auth-required announcement](https://dev-forum.pyth.network/t/action-required-pyth-pro-history-api-auth-required-starting-july-24/808/2) diff --git a/pro/frontend-jwt-auth/next.config.mjs b/pro/frontend-jwt-auth/next.config.mjs new file mode 100644 index 00000000..d5456a15 --- /dev/null +++ b/pro/frontend-jwt-auth/next.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/pro/frontend-jwt-auth/package.json b/pro/frontend-jwt-auth/package.json new file mode 100644 index 00000000..6f5d46df --- /dev/null +++ b/pro/frontend-jwt-auth/package.json @@ -0,0 +1,28 @@ +{ + "name": "pyth-pro-frontend-jwt-auth-example", + "version": "1.0.0", + "description": "Next.js example: render live Pyth Pro price charts, authenticating every History API request via the JWT flow.", + "private": true, + "license": "Apache-2.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "fix:format": "prettier --write \"**/*.{ts,tsx,js,mjs,cjs,json,md,css}\"", + "test:format": "prettier --check \"**/*.{ts,tsx,js,mjs,cjs,json,md,css}\"", + "test:types": "tsc --noEmit" + }, + "dependencies": { + "lightweight-charts": "^4.2.1", + "next": "^14.2.15", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "prettier": "^3.4.1", + "typescript": "^5.7.2" + } +} diff --git a/pro/frontend-jwt-auth/pnpm-lock.yaml b/pro/frontend-jwt-auth/pnpm-lock.yaml new file mode 100644 index 00000000..90442ee5 --- /dev/null +++ b/pro/frontend-jwt-auth/pnpm-lock.yaml @@ -0,0 +1,372 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + lightweight-charts: + specifier: ^4.2.1 + version: 4.2.3 + next: + specifier: ^14.2.15 + version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/node': + specifier: ^22.10.0 + version: 22.20.1 + '@types/react': + specifier: ^18.3.12 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.31) + prettier: + specifier: ^3.4.1 + version: 3.9.6 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + +packages: + + '@next/env@14.2.35': + resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==} + + '@next/swc-darwin-arm64@14.2.33': + resolution: {integrity: sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@14.2.33': + resolution: {integrity: sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@14.2.33': + resolution: {integrity: sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@14.2.33': + resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@14.2.33': + resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@14.2.33': + resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@14.2.33': + resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-ia32-msvc@14.2.33': + resolution: {integrity: sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@next/swc-win32-x64-msvc@14.2.33': + resolution: {integrity: sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.5': + resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + fancy-canvas@2.1.0: + resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + lightweight-charts@4.2.3: + resolution: {integrity: sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@14.2.35: + resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==} + engines: {node: '>=18.17.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + sass: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + styled-jsx@5.1.1: + resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@next/env@14.2.35': {} + + '@next/swc-darwin-arm64@14.2.33': + optional: true + + '@next/swc-darwin-x64@14.2.33': + optional: true + + '@next/swc-linux-arm64-gnu@14.2.33': + optional: true + + '@next/swc-linux-arm64-musl@14.2.33': + optional: true + + '@next/swc-linux-x64-gnu@14.2.33': + optional: true + + '@next/swc-linux-x64-musl@14.2.33': + optional: true + + '@next/swc-win32-arm64-msvc@14.2.33': + optional: true + + '@next/swc-win32-ia32-msvc@14.2.33': + optional: true + + '@next/swc-win32-x64-msvc@14.2.33': + optional: true + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.5': + dependencies: + '@swc/counter': 0.1.3 + tslib: 2.8.1 + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + caniuse-lite@1.0.30001806: {} + + client-only@0.0.1: {} + + csstype@3.2.3: {} + + fancy-canvas@2.1.0: {} + + graceful-fs@4.2.11: {} + + js-tokens@4.0.0: {} + + lightweight-charts@4.2.3: + dependencies: + fancy-canvas: 2.1.0 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + nanoid@3.3.16: {} + + next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@next/env': 14.2.35 + '@swc/helpers': 0.5.5 + busboy: 1.6.0 + caniuse-lite: 1.0.30001806 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.1(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 14.2.33 + '@next/swc-darwin-x64': 14.2.33 + '@next/swc-linux-arm64-gnu': 14.2.33 + '@next/swc-linux-arm64-musl': 14.2.33 + '@next/swc-linux-x64-gnu': 14.2.33 + '@next/swc-linux-x64-musl': 14.2.33 + '@next/swc-win32-arm64-msvc': 14.2.33 + '@next/swc-win32-ia32-msvc': 14.2.33 + '@next/swc-win32-x64-msvc': 14.2.33 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + picocolors@1.1.1: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.6: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + source-map-js@1.2.1: {} + + streamsearch@1.1.0: {} + + styled-jsx@5.1.1(react@18.3.1): + dependencies: + client-only: 0.0.1 + react: 18.3.1 + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} diff --git a/pro/frontend-jwt-auth/screenshot.png b/pro/frontend-jwt-auth/screenshot.png new file mode 100644 index 00000000..3477b68f Binary files /dev/null and b/pro/frontend-jwt-auth/screenshot.png differ diff --git a/pro/frontend-jwt-auth/src/app/api/pyth-token/route.ts b/pro/frontend-jwt-auth/src/app/api/pyth-token/route.ts new file mode 100644 index 00000000..8b2be981 --- /dev/null +++ b/pro/frontend-jwt-auth/src/app/api/pyth-token/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; + +const DEFAULT_PYTH_API_BASE_URL = "https://pyth.dourolabs.app"; + +// Do not cache JWTs on the edge / Data Cache; each mint is a fresh call +// to the upstream token endpoint with the server-held `PRO_API_KEY`. +export const dynamic = "force-dynamic"; + +interface MintRequestBody { + ttl_seconds?: number; +} + +export async function POST(request: Request) { + const apiKey = process.env.PRO_API_KEY; + if (!apiKey) { + return NextResponse.json( + { + error: + "PRO_API_KEY is not set. Copy .env.example to .env.local and set PRO_API_KEY, then restart `pnpm dev`.", + }, + { status: 500 }, + ); + } + + const baseUrl = process.env.PYTH_API_BASE_URL ?? DEFAULT_PYTH_API_BASE_URL; + + let body: MintRequestBody = {}; + try { + const text = await request.text(); + if (text.length > 0) { + body = JSON.parse(text) as MintRequestBody; + } + } catch { + return NextResponse.json( + { error: "Request body must be valid JSON or empty." }, + { status: 400 }, + ); + } + + const upstreamBody: MintRequestBody = {}; + if (typeof body.ttl_seconds === "number") { + upstreamBody.ttl_seconds = body.ttl_seconds; + } + + const upstream = await fetch(`${baseUrl}/auth/token`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(upstreamBody), + cache: "no-store", + }); + + if (!upstream.ok) { + // For an example, mirroring the upstream text keeps debugging simple. + // In production, log the upstream response server-side and return a + // generic error so mint failures don't leak internal details. + const text = await upstream.text(); + return new NextResponse(text, { + status: upstream.status, + headers: { + "Content-Type": upstream.headers.get("content-type") ?? "text/plain", + }, + }); + } + + const json = (await upstream.json()) as { + access_token: string; + expires_at: string; + token_type: string; + }; + + return NextResponse.json(json, { + headers: { "Cache-Control": "no-store" }, + }); +} diff --git a/pro/frontend-jwt-auth/src/app/globals.css b/pro/frontend-jwt-auth/src/app/globals.css new file mode 100644 index 00000000..e5b8dba3 --- /dev/null +++ b/pro/frontend-jwt-auth/src/app/globals.css @@ -0,0 +1,228 @@ +:root { + color-scheme: dark; + --bg: #0a0b10; + --panel-bg: #12141b; + --panel-border: #1f2330; + --text: #e6e8ef; + --text-muted: #8a90a2; + --danger: #ef5350; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + min-height: 100%; + background: var(--bg); + color: var(--text); + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, + Cantarell, sans-serif; +} + +.page { + max-width: 1400px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +.page__header { + margin-bottom: 24px; +} + +.page__header h1 { + margin: 0 0 4px; + font-size: 24px; + font-weight: 600; +} + +.page__header p { + margin: 0; + color: var(--text-muted); + font-size: 13px; +} + +.page__grid { + display: grid; + gap: 20px; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); +} + +.chart-card { + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 10px; + padding: 16px; + display: flex; + flex-direction: column; + min-height: 360px; +} + +.chart-card__header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 12px; +} + +.chart-card__title { + margin: 0; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.chart-card__body { + flex: 1; + min-height: 300px; +} + +.chart-card__error { + margin: 12px 0 0; + color: var(--danger); + font-size: 12px; + word-break: break-word; +} + +.flow { + margin-top: 28px; + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 10px; + padding: 20px; +} + +.flow__header { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 20px; +} + +.flow__intro { + margin: 0; + max-width: 720px; + color: var(--text-muted); + font-size: 13px; + line-height: 1.65; +} + +.flow__intro strong { + color: var(--text); + font-weight: 600; +} + +.flow__title { + margin: 0; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.flow__status { + margin: 0; + color: var(--text-muted); + font-size: 12.5px; + display: flex; + align-items: center; + gap: 7px; +} + +.flow__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); + flex-shrink: 0; +} + +.flow__dot--fresh { + background: #26a69a; +} + +.flow__steps { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +.flow__step { + position: relative; + display: grid; + grid-template-columns: 28px 1fr; + column-gap: 14px; + padding-bottom: 22px; +} + +.flow__step:last-child { + padding-bottom: 0; +} + +/* Connector line between the number badges */ +.flow__step:not(:last-child)::before { + content: ""; + position: absolute; + left: 14px; + top: 32px; + bottom: 4px; + width: 1px; + background: var(--panel-border); +} + +.flow__num { + width: 28px; + height: 28px; + border-radius: 50%; + background: #1a1e2a; + border: 1px solid var(--panel-border); + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; +} + +.flow__body h3 { + margin: 5px 0 6px; + font-size: 13.5px; + font-weight: 600; +} + +.flow__body p { + margin: 0; + max-width: 660px; + color: var(--text-muted); + font-size: 13px; + line-height: 1.65; +} + +.flow__step code { + background: #1a1e2a; + border: 1px solid var(--panel-border); + border-radius: 4px; + padding: 1px 5px; + font-size: 0.92em; + color: var(--text); + word-break: break-word; +} + +.flow__footer { + margin: 20px 0 0; + padding-top: 14px; + border-top: 1px solid var(--panel-border); + color: var(--text-muted); + font-size: 12.5px; + line-height: 1.6; +} + +.flow__footer a { + color: #26a69a; + text-decoration: underline; + text-underline-offset: 2px; +} diff --git a/pro/frontend-jwt-auth/src/app/layout.tsx b/pro/frontend-jwt-auth/src/app/layout.tsx new file mode 100644 index 00000000..825d09b5 --- /dev/null +++ b/pro/frontend-jwt-auth/src/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +import "./globals.css"; + +export const metadata: Metadata = { + title: "Pyth Pro JWT Auth Example", + description: + "Next.js example: live Pyth Pro price charts with JWT-authenticated History API requests.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/pro/frontend-jwt-auth/src/app/page.tsx b/pro/frontend-jwt-auth/src/app/page.tsx new file mode 100644 index 00000000..4ea3c4aa --- /dev/null +++ b/pro/frontend-jwt-auth/src/app/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useCallback, useState } from "react"; + +import { AuthFlowPanel } from "@/components/AuthFlowPanel"; +import { PythChart } from "@/components/PythChart"; + +// `symbol` is the value the History API expects. The Pyth Pro `/v1/symbols` +// catalog exposes fully-qualified names (e.g. `Crypto.BTC/USD`); the shorter +// forms shown in the docs table (e.g. `BTC/USD`) are ambiguous today and +// return `symbol not found`; see the README for the discrepancy. +const FEEDS = [ + { symbol: "Crypto.BTC/USD", label: "BTC/USD" }, + { symbol: "Crypto.ETH/USD", label: "ETH/USD" }, + { symbol: "Crypto.SOL/USD", label: "SOL/USD" }, +]; + +export default function HomePage() { + const [lastUpdated, setLastUpdated] = useState(null); + + const handleUpdated = useCallback((at: Date) => { + setLastUpdated((previous) => (previous && previous > at ? previous : at)); + }, []); + + return ( +
+
+

Pyth Pro Price Charts

+

+ Last updated:{" "} + {lastUpdated ? lastUpdated.toLocaleTimeString() : "loading…"} +

+
+
+ {FEEDS.map(({ symbol, label }) => ( + + ))} +
+ +
+ ); +} diff --git a/pro/frontend-jwt-auth/src/components/AuthFlowPanel.tsx b/pro/frontend-jwt-auth/src/components/AuthFlowPanel.tsx new file mode 100644 index 00000000..82ed840d --- /dev/null +++ b/pro/frontend-jwt-auth/src/components/AuthFlowPanel.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { getTokenStatus, type TokenStatus } from "@/lib/pythToken"; + +const ANNOUNCEMENT_URL = + "https://dev-forum.pyth.network/t/action-required-pyth-pro-history-api-auth-required-starting-july-24/808/2"; +const FRONTEND_AUTH_DOCS_URL = + "https://docs.pyth.network/price-feeds/pro/frontend-auth"; + +function formatRemaining(expiresAt: string): string { + const ms = Date.parse(expiresAt) - Date.now(); + if (Number.isNaN(ms) || ms <= 0) { + return "expired"; + } + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; +} + +/** + * Teaches the JWT auth flow these charts use, with a live view of the token + * cache (expiry countdown, mints this session) so the flow is observable. + */ +export function AuthFlowPanel() { + const [status, setStatus] = useState(null); + + useEffect(() => { + const tick = () => setStatus(getTokenStatus()); + tick(); + const timer = window.setInterval(tick, 1_000); + return () => window.clearInterval(timer); + }, []); + + let statusLine = "No JWT minted yet. Waiting for the first chart request."; + let statusFresh = false; + if (status && status.expiresAt) { + statusFresh = status.isFresh; + const remaining = formatRemaining(status.expiresAt); + statusLine = status.isFresh + ? `JWT cached, expires in ${remaining}` + : `JWT expiring, re-mints on the next request`; + statusLine += ` · ${status.mintCount} mint${status.mintCount === 1 ? "" : "s"} this session`; + } + + return ( +
+
+

How these charts authenticate

+

+ The Pyth Pro API key is a long-lived secret that must never reach the + browser. Instead, the server trades it for short-lived JWTs the charts + can use safely: +

+

+ + {statusLine} +

+
+
    +
  1. + 1 +
    +

    The browser asks the backend for a token

    +

    + Charts never touch the API key. They request a JWT from this + app's backend and share one cached token. +

    +
    +
  2. +
  3. + 2 +
    +

    The backend mints a JWT

    +

    + It calls POST /auth/token with the server-held API + key, the only place the secret is ever used. +

    +
    +
  4. +
  5. + 3 +
    +

    The browser calls the Pyth Pro API directly

    +

    + Candles are fetched with{" "} + Authorization: Bearer <jwt>, with no proxy in + the data path. A leaked JWT dies in minutes. +

    +
    +
  6. +
  7. + 4 +
    +

    Tokens rotate automatically

    +

    + A new JWT is minted just before expiry (watch the countdown + above), and any 401 triggers a re-mint and retry. +

    +
    +
  8. +
+

+ From the{" "} + + announcement + {" "} + and{" "} + + frontend-auth docs + + . The README walks through the code behind each step. +

+
+ ); +} diff --git a/pro/frontend-jwt-auth/src/components/PythChart.tsx b/pro/frontend-jwt-auth/src/components/PythChart.tsx new file mode 100644 index 00000000..5bec9d7d --- /dev/null +++ b/pro/frontend-jwt-auth/src/components/PythChart.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + createChart, + ColorType, + type IChartApi, + type ISeriesApi, + type UTCTimestamp, +} from "lightweight-charts"; + +import { + candlesFromHistory, + fetchHistory, + type Candle, +} from "@/lib/pythClient"; + +const RESOLUTION_MINUTES = "1"; +const HISTORY_WINDOW_SECONDS = 24 * 60 * 60; +const POLL_INTERVAL_MS = 30_000; + +interface PythChartProps { + symbol: string; + label?: string; + onUpdated?: (updatedAt: Date) => void; +} + +function toChartCandle(c: Candle): { + time: UTCTimestamp; + open: number; + high: number; + low: number; + close: number; +} { + return { + time: c.time as UTCTimestamp, + open: c.open, + high: c.high, + low: c.low, + close: c.close, + }; +} + +export function PythChart({ symbol, label, onUpdated }: PythChartProps) { + const containerRef = useRef(null); + const chartRef = useRef(null); + const seriesRef = useRef | null>(null); + const lastBarTimeRef = useRef(0); + const [error, setError] = useState(null); + + useEffect(() => { + if (!containerRef.current) return; + + const chart = createChart(containerRef.current, { + autoSize: true, + layout: { + background: { type: ColorType.Solid, color: "#0e1015" }, + textColor: "#d1d4dc", + }, + grid: { + vertLines: { color: "#1e222d" }, + horzLines: { color: "#1e222d" }, + }, + timeScale: { + timeVisible: true, + secondsVisible: false, + }, + rightPriceScale: { + borderColor: "#2a2e39", + }, + }); + const series = chart.addCandlestickSeries({ + upColor: "#26a69a", + downColor: "#ef5350", + wickUpColor: "#26a69a", + wickDownColor: "#ef5350", + borderVisible: false, + }); + + chartRef.current = chart; + seriesRef.current = series; + + return () => { + chart.remove(); + chartRef.current = null; + seriesRef.current = null; + }; + }, []); + + useEffect(() => { + let cancelled = false; + + const load = async () => { + try { + const now = Math.floor(Date.now() / 1000); + const from = now - HISTORY_WINDOW_SECONDS; + const history = await fetchHistory({ + symbol, + from, + to: now, + resolution: RESOLUTION_MINUTES, + }); + if (cancelled) return; + + const candles = candlesFromHistory(history).map(toChartCandle); + const series = seriesRef.current; + if (!series) return; + series.setData(candles); + if (candles.length > 0) { + lastBarTimeRef.current = candles[candles.length - 1]!.time; + chartRef.current?.timeScale().fitContent(); + } + setError(null); + onUpdated?.(new Date()); + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const refresh = async () => { + try { + const now = Math.floor(Date.now() / 1000); + // Ask for the last two candles' worth of data so the current in-progress + // candle overwrites cleanly via `series.update`. + const from = now - Number.parseInt(RESOLUTION_MINUTES, 10) * 60 * 2; + const history = await fetchHistory({ + symbol, + from, + to: now, + resolution: RESOLUTION_MINUTES, + }); + if (cancelled) return; + + const series = seriesRef.current; + if (!series) return; + const latest = candlesFromHistory(history); + for (const candle of latest) { + // `series.update` only accepts the last bar or newer ones; an + // older bar throws "Cannot update oldest data". + if (candle.time < lastBarTimeRef.current) continue; + series.update(toChartCandle(candle)); + lastBarTimeRef.current = candle.time; + } + setError(null); + onUpdated?.(new Date()); + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + } + }; + + void load(); + const timer = window.setInterval(() => { + void refresh(); + }, POLL_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [symbol, onUpdated]); + + return ( +
+
+

{label ?? symbol}

+
+
+ {error &&

{error}

} +
+ ); +} diff --git a/pro/frontend-jwt-auth/src/lib/pythClient.ts b/pro/frontend-jwt-auth/src/lib/pythClient.ts new file mode 100644 index 00000000..b21c8a79 --- /dev/null +++ b/pro/frontend-jwt-auth/src/lib/pythClient.ts @@ -0,0 +1,107 @@ +import { getPythToken, invalidatePythToken } from "./pythToken"; + +// The public browser default matches the server default in +// src/app/api/pyth-token/route.ts. History requests go directly from the +// browser to the Pyth Pro API; only the mint endpoint is proxied through +// this app so `PRO_API_KEY` stays server-side. +const DEFAULT_PYTH_API_BASE_URL = "https://pyth.dourolabs.app"; + +// The `fixed_rate@200ms` channel matches the example in the docs. Change here +// to try a different channel (e.g. `real_time`). +export const PYTH_CHANNEL = "fixed_rate@200ms"; + +// Public, non-secret. `NEXT_PUBLIC_*` env vars are inlined into the browser +// bundle at build time; `PRO_API_KEY` must never be exposed this way. +const PYTH_API_BASE_URL = + process.env.NEXT_PUBLIC_PYTH_API_BASE_URL ?? DEFAULT_PYTH_API_BASE_URL; + +export interface HistoryResponse { + s: "ok" | "no_data" | "error"; + // A `no_data` (or `error`) response may omit the OHLC arrays entirely. + t?: number[]; + o?: number[]; + h?: number[]; + l?: number[]; + c?: number[]; + v?: number[]; + errmsg?: string; +} + +export interface Candle { + time: number; + open: number; + high: number; + low: number; + close: number; +} + +export function candlesFromHistory(history: HistoryResponse): Candle[] { + const { t, o, h, l, c } = history; + if (!t || !o || !h || !l || !c) { + return []; + } + const n = Math.min(t.length, o.length, h.length, l.length, c.length); + const out: Candle[] = new Array(n); + for (let i = 0; i < n; i++) { + out[i] = { + time: t[i]!, + open: o[i]!, + high: h[i]!, + low: l[i]!, + close: c[i]!, + }; + } + return out; +} + +export interface FetchHistoryOptions { + symbol: string; + from: number; + to: number; + resolution: string; + channel?: string; +} + +async function fetchHistoryOnce( + { symbol, from, to, resolution, channel = PYTH_CHANNEL }: FetchHistoryOptions, + token: string, +): Promise { + const url = new URL(`${PYTH_API_BASE_URL}/v1/${channel}/history`); + url.searchParams.set("symbol", symbol); + url.searchParams.set("from", String(from)); + url.searchParams.set("to", String(to)); + url.searchParams.set("resolution", resolution); + return fetch(url.toString(), { + headers: { Authorization: `Bearer ${token}` }, + }); +} + +export async function fetchHistory( + options: FetchHistoryOptions, +): Promise { + let token = await getPythToken(); + let response = await fetchHistoryOnce(options, token); + + if (response.status === 401) { + // Token likely expired in-flight or was revoked. Force a fresh mint and + // retry exactly once. + invalidatePythToken(); + token = await getPythToken(); + response = await fetchHistoryOnce(options, token); + } + + if (!response.ok) { + const detail = await response.text(); + throw new Error( + `Pyth history request failed: ${response.status} ${response.statusText}${ + detail ? ` (${detail})` : "" + }`, + ); + } + + const body = (await response.json()) as HistoryResponse; + if (body.s === "error") { + throw new Error(`Pyth history returned error: ${body.errmsg ?? "unknown"}`); + } + return body; +} diff --git a/pro/frontend-jwt-auth/src/lib/pythToken.ts b/pro/frontend-jwt-auth/src/lib/pythToken.ts new file mode 100644 index 00000000..7fa94f8e --- /dev/null +++ b/pro/frontend-jwt-auth/src/lib/pythToken.ts @@ -0,0 +1,66 @@ +export interface PythToken { + access_token: string; + expires_at: string; + token_type?: string; +} + +let cached: PythToken | undefined; +let inflight: Promise | undefined; +let mintCount = 0; + +const SAFETY_MARGIN_MS = 30_000; + +function isFresh(token: PythToken): boolean { + return Date.parse(token.expires_at) - Date.now() > SAFETY_MARGIN_MS; +} + +async function mintToken(): Promise { + const response = await fetch("/api/pyth-token", { method: "POST" }); + if (!response.ok) { + const detail = await response.text(); + throw new Error( + `Failed to mint Pyth token: ${response.status} ${response.statusText}${ + detail ? ` (${detail})` : "" + }`, + ); + } + + cached = (await response.json()) as PythToken; + mintCount += 1; + return cached; +} + +export interface TokenStatus { + expiresAt: string | undefined; + isFresh: boolean; + mintCount: number; +} + +// Read-only snapshot of the cache, used by the in-page auth-flow explainer. +export function getTokenStatus(): TokenStatus { + return { + expiresAt: cached?.expires_at, + isFresh: cached !== undefined && isFresh(cached), + mintCount, + }; +} + +export async function getPythToken(): Promise { + if (cached && isFresh(cached)) { + return cached.access_token; + } + + // Share one in-flight mint across concurrent callers (e.g. several charts + // mounting at once) so a page load performs a single upstream mint. + if (!inflight) { + inflight = mintToken().finally(() => { + inflight = undefined; + }); + } + const token = await inflight; + return token.access_token; +} + +export function invalidatePythToken(): void { + cached = undefined; +} diff --git a/pro/frontend-jwt-auth/tsconfig.json b/pro/frontend-jwt-auth/tsconfig.json new file mode 100644 index 00000000..07de7bc0 --- /dev/null +++ b/pro/frontend-jwt-auth/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}