# @gamecore-api/sdk — Agent Brief

> **For AI agents reading this:** this file is the short-path orientation. If
> the user is asking you to build with this SDK, read this first, then the
> [README](./README.md), then look at [`examples/`](./examples/) for working
> code you can adapt.

## What this is

Browser- and Node-safe TypeScript client for the GameCore API — a multi-tenant
BaaS for game-currency / digital-key storefronts. Zero runtime dependencies.

```ts
import { GameCoreClient } from "@gamecore-api/sdk";

const gc = new GameCoreClient({
  apiKey: "gc_live_…",
  baseUrl: "https://api.gamecore-api.tech",
  locale: "en", // ← drives Accept-Language; "ru" or "en"
});
```

## Required setup (don't forget any of these)

| Option | Where it comes from | What happens if omitted |
|---|---|---|
| `apiKey` | Server env (`GAMECORE_API_KEY`) — **never** ship to browser bundles | All calls 401 |
| `baseUrl` | `https://api.gamecore-api.tech` (prod) or self-hosted | Constructor throws |
| `locale` | Optional. `"ru"` default, `"en"` for English storefronts | Storefront stays Russian |
| `currency` | Optional ISO-4217. `"USD"`, `"EUR"`, `"KZT"`, etc. | Server returns prices in RUB |
| `onAuthError` | Optional callback to handle JWT expiry | Caller has to catch 401s manually |

`apiKey` is a **site-level** key. Each tenant ("site") has its own key. Don't
share keys across tenants.

## Namespaces (what to reach for)

| Namespace | Purpose | Read first |
|---|---|---|
| `gc.catalog` | Games, products, search, categories, recommendations, letter-index, bulk sitemap routes | `examples/01-quickstart.ts` |
| `gc.cart` | Server-synced cart for logged-in users | `examples/01-quickstart.ts` |
| `gc.checkout` | Create payment, get gateway redirect URL, persist guest contact for 54-ФЗ receipts, preview what the wallet covers before Pay (`preview`, since 0.67.0) | `examples/01-quickstart.ts` |
| `gc.orders` | Order list, detail, status polling, cancels, item code-actions (`clientReady` / `requestRetry` — render by `OrderItem.codeAffordance`, since 0.56.0), per-key marks (`setKeyState` — render from `OrderItem.keys`, write by `keyRef`, since 0.61.0), one-click reorder of a failed item (`reorderItem` — render by `OrderItem.reorderEligible`, quote `reorderCurrentPrice`, since 0.66.0) | `examples/03-error-handling.ts` |
| `gc.profile` | User profile, balance, address book, daily bonus, quests, review proofs (URL + file upload) | — |
| `gc.auth` | Telegram login, email/password, JWT refresh | — |
| `gc.favorites` | Wishlist endpoints | — |
| `gc.coupons` | Apply / list coupons (NOT via `CheckoutRequest.couponCode`) | — |
| `gc.referrals` | Referral links, commissions, withdrawals, public click beacon (`trackClick`, since 0.46.0) | — |
| `gc.reviews` | Public reviews, submit + media proof, optional delivery/support ratings and guest submit via emailed token (`create` options form, `createGuest`, since 0.62.0) | — |
| `gc.topup` | Balance top-up (optional `idempotencyKey` since 0.67.0) | — |
| `gc.announcements` | Storefront banners | — |
| `gc.analytics` | Client-side game view ping | — |
| `gc.seo` | SEO content + JSON-LD per page (`getContent`, `getSchema`) | — |
| `gc.sse` | Server-Sent Events stream (order updates) | — |
| `gc.site` | Site config, theme, banners, sitemap data, social proof, recent purchases | — |
| `gc.support` | In-app chat thread + public guest support form (since 0.28.0) | — |
| `gc.superpasses` | Public Roblox SuperPass catalog (list / get / verifyUser, since 0.28.0) | — |
| `gc.packRequests` | Vendoria custom pack requests — «Собери свой пак»: listGames, upload screenshot, create, pay, cancel (auth-only, module `vendoria_pack_requests`, since 0.55.0) | — |

Server-only helpers live under the `/server` entry point:

```ts
import { verifyWebhookSignature } from "@gamecore-api/sdk/server";
```

(See `examples/04-webhook-verify.ts`.)

## Locale (since 0.25.0)

- Pass `locale: "ru" | "en"` in constructor → SDK sends `Accept-Language` automatically on every request.
- Override at runtime: `gc.setLocale("en")` / `gc.getLocale()`.
- The backend overlays **game names**, short descriptions, and descriptions in the requested locale. Slugs / IDs never change.
- Original-language responses: just don't set `locale`.

## Display currency (since 0.27.0)

- Pass `currency: "USD" | "EUR" | "KZT" | "UAH" | "TRY" | …` in constructor → SDK sends `X-Currency` automatically on every catalog request.
- Override at runtime: `gc.setCurrency("EUR")` / `gc.getCurrency()`.
- Per-call override: `gc.catalog.getProducts(slug, { currency: "USD" })`.
- Every product response carries `product.currency` so the storefront can render the right symbol without tracking the requested code.
- Server applies **live FX rates** (open.er-api.com, 30-min cache) and rounds per currency convention (whole KZT/UAH/TRY/RUB, 2-decimal USD/EUR/GBP).
- Default is RUB — existing storefronts keep working unchanged.
- **Checkout is unrelated**: payments still settle in RUB or USD via the chosen gateway. Display currency is purely catalog-side.
- See `examples/05-currency-switching.ts`.

## Payment fees (since 0.43.0)

- `PaymentMethod`/`TopupMethod` carry `feePercent`, `feeFixed`, `feeMode` (`FeeMode` = `"included" | "absorb" | "surcharge"`). Only `"surcharge"` adds to the charge.
- Preview the surcharge line at method-select with `estimateSurcharge({ goods, feePercent, feeFixed, feeMode }) → { applies, fee, gross }` — a bit-exact mirror of the server (a parity fuzz test guarantees it). Preview-only.
- The **authoritative** fee is on the checkout response: `payment.fee: { mode, amount, goodsTotal }`. `payment.total` is **GROSS** (goods + surcharge) — never re-add `fee.amount`.
- **Topups are always net** — never draw a surcharge line on a top-up even if `feeMode === "surcharge"`.
- Errors: `GameCoreError.details` carries the full parsed body; use `isMethodAmountLimitError(e)` for typed min/max limits.
- See `examples/06-payment-fees.ts`.

## Guest checkout (since 0.28.0 — 54-ФЗ ст.1.2)

Russian fiscal law (54-ФЗ ст.1.2) requires the storefront to know how to
deliver the fiscal receipt BEFORE the user pays. If the user closes the tab
right after clicking "pay", the receipt and post-payment notifications still
need to land somewhere.

Two-step flow:

```ts
// Step 1 — persist contact channel + cart BEFORE the user pays.
await gc.checkout.beginGuestSession({
  preferredChannel: "email",
  email: "buyer@example.com",
  cartItems: [{ productId: 42, quantity: 1 }],
});

// Step 2 — create the actual payment as usual.
const checkout = await gc.checkout.create({ email: "buyer@example.com", items: [...] });
```

`preferredChannel` is discriminated: `"email"` requires `email`, `"sms"`
requires `phone`. Server-side validator mirrors the DB CHECK constraint.
7-day TTL on the row; `expiresAt` in the response is when the local copy
can be dropped.

## Coupons (NOT via checkout body)

`gc.coupons.apply(code)` writes a row into `user_active_coupons`. The
backend reads the active coupon during checkout from that table — sending
`couponCode` in `CheckoutRequest` is **silently ignored**. The field is
`@deprecated` and will be dropped in 0.29.0.

## Pitfalls

- **Don't put `apiKey` in client-side JS bundles** — proxy through your own
  backend (Next.js API routes, ElysiaJS, etc.) so the key stays server-side.
- **Catch `GameCoreError`** — every namespace throws this class on non-2xx.
  See `examples/03-error-handling.ts` for the shape.
- **Cart endpoints require an authenticated user**. Call `gc.auth.*` first.
- **`baseUrl` must NOT end in `/`** — the SDK strips trailing slashes but be
  consistent (`https://api.gamecore-api.tech`, not `…/`).
- **Webhook verification** is a constant-time HMAC compare — never roll your
  own with `==`. Use `verifyWebhookSignature` from `/server`.
- **Idempotency keys** — `gc.checkout.create()` mints a RANDOM
  `X-Idempotency-Key` per call. That is right for a human pressing Pay and wrong
  for an automated re-submit: the server gives a client-sent key the full 24h
  replay window, so two random keys are two payments for one cart. Pass your own
  (`create(data, "key")` or `create(data, { idempotencyKey })`) whenever the call
  is retried by code rather than by a person. `gc.topup.create()` is the mirror
  image — no key sent by default (the server derives a short-TTL one), and a
  chain must pass a FRESH key per deliberate top-up so old money is never
  reported as new.
- **Bonus rubles are capped per order** — never render `balance.total` as what
  the buyer can spend at checkout. `gc.checkout.preview(items, { useBonus })`
  returns the cap-aware `spendable`/`bonusApplied`/`shortfallAmount` (0.67.0).
  Post-failure, `getInsufficientBalanceDetails(err)` on the 402 hands you the
  refusal's OWN twins of only TWO of them — `spendableTotal`, and a
  `shortfallAmount` whose floor is 1, never 0. Related numbers, NOT the same
  shape: the 402 carries no bonus/permanent split at all (the per-order cap is
  withheld on purpose — it is a function of the order's margin), so never merge
  the two into one type. Preview is display-only: on any error show the raw
  wallet and keep Pay enabled.
- **Coupons** — apply via `gc.coupons.apply()`, NOT via
  `CheckoutRequest.couponCode` (silently dropped server-side, see above).
- **`orders.reorderItem` mixes both error styles on purpose** — 409/422/503
  RESOLVE (branch on the body: `success` → `code` → `error`), while 402
  THROWS and is read with `getInsufficientBalanceDetails(err)`, exactly like
  checkout's 402. One shared server-side builder owns that refusal body, so
  the SDK deliberately offers only one way to read it. Its 201 payload is
  `snake_case` (`order_code`, …) — the wire verbatim; renaming it to camelCase
  renders `undefined` and fails silently.
- **File uploads** (`support.uploadImage`, `profile.uploadReviewProof`)
  send `multipart/form-data` automatically when you pass a `File` / `Blob`.
  The SDK skips JSON.stringify and lets `fetch` pick the boundary.

## Where to look next

- [README.md](./README.md) — full API surface, options, browser/Node notes
- [examples/](./examples/) — runnable .ts files covering quickstart, locale, errors, webhooks
- [CHANGELOG.md](./CHANGELOG.md) — what's new per version

## CSP module (src/csp.ts)

- `./csp` subpath export: buildLegacyCsp/buildStrictCsp/generateNonce из
  одного TenantCspConfig. Контракт подключения тенанта: docs/csp.md.
- WHY hashes only in strict: хэш в директиве с 'unsafe-inline' отключает
  'unsafe-inline' (CSP2) — в legacy-политике хэшам не место.
- Выдача legacy-политики байт-запинена golden-тестом (csp.test.ts,
  ASHOP_PROD_LEGACY — референс-конфиг ashop) — меняешь источники/порядок →
  чинишь golden осознанно. С 0.53.0 это ЦЕЛЕВАЯ политика, а не копия
  задеплоенной у ashop (тенанты догоняют bump'ом SDK).
- yandexMetrika: true с 0.53.0 включает ПОЛНЫЙ официальный список Метрики
  (гео-зеркала + wss + yastatic + blob: frame-src) и меняет frame-ancestors
  с 'none' на домены интерфейса Метрики — это осознанное ослабление только
  для Yandex-origin'ов (карта кликов/вебвизор в кабинете), см. docs/csp.md.
