ویکی بازینس
English
راهنماها

مهاجرت به کد اتصال و توکن‌های کوتاه‌عمر

پلتفرم توکن‌های بلندعمر کاربر را با توکن‌های ۱۵ دقیقه‌ای و کدهای اتصال یک‌بارمصرف جایگزین می‌کند. چه چیزی در سرور و کلاینت شما عوض می‌شود، قدم‌به‌قدم، و تا کی.

دانلود Markdown PDF

چه چیزی عوض می‌شود و چرا

تا الان توکن کاربر بازینس ۱۰ سال اعتبار داشت. توی آدرس‌ها هم جابه‌جا می‌شد:

  • بازی شما صفحه‌های بازینس را با ?linkedToken=<token> باز می‌کرد؛
  • اپ بازینس بازی شما را با ?linkedToken=<token> باز می‌کرد.

آدرس‌ها در لاگ سرورها، ابزارهای آنالیتیکس، تاریخچه‌ی مرورگر و هدر Referer ثبت می‌شوند. توکنی که از آن‌جا پیدا می‌شد، تا ده سال به سکه‌ها و حساب بازیکن دسترسی می‌داد و خروج از حساب هم جلویش را نمی‌گرفت.

مدل جدید:

قبل حالا
توکن دسترسی (چیزی که کلاینت به‌عنوان Bearer می‌فرستد) ۱۰ سال اعتبار ۱۵ دقیقه اعتبار
توکن تمدید (refresh token) — توکن دسترسی را تمدید می‌کند؛ روی سرور شما نگه داشته می‌شود، هیچ‌وقت روی دستگاه؛ یک‌بارمصرف است و هر تمدید یک توکن تازه برمی‌گرداند
جابه‌جا کردن بازیکن بین اپ‌ها ?linkedToken=<token> ?linkCode=<code>: یک کد یک‌بارمصرف با حدود ۲ دقیقه اعتبار که بعد از اولین استفاده بی‌اثر است

مهم

در دوره‌ی مهاجرت هر دو مدل کنار هم کار می‌کنند. endpointهای قدیمی و ?linkedToken= کار می‌کنند و اپ بازینس هم ?linkCode= و هم ?linkedToken= را برای بازی‌ها می‌فرستد. تیم پلتفرم یک تاریخ پایان اعلام می‌کند: بعد از آن، توکن‌های قدیمی و linkedToken دیگر کار نمی‌کنند. مهاجرت را قبل از آن تاریخ انجام دهید.

جریان بعد از مهاجرت:

sequenceDiagram
  autonumber
  participant C as کلاینت بازی
  participant S as سرور بازی
  participant K as API پلتفرم
  participant W as صفحه‌ی بازینس (شیت)
  C->>S: POST /api/platform/token { deviceId, linkCode? }
  alt بار اول، یا ورود با ?linkCode=
    S->>K: POST v2/client/create-user { deviceId, linkCode }
  else بازیکن آشنا
    S->>K: POST auth/refresh { refreshToken ذخیره‌شده }
  end
  K-->>S: { userId, isLinkedToAccount, accessToken (۱۵ دقیقه), refreshToken }
  Note over S: refreshToken جدید را روی بازیکن ذخیره کن
  S-->>C: { token: accessToken, tokenExpiresAt, bazinessUserId, isLinked }
  C->>S: POST /api/platform/link-code (قبل از باز کردن صفحه)
  S->>K: POST v2/client/link-code/{userId}
  K-->>S: { code } (یک‌بارمصرف، حدود ۲ دقیقه)
  S-->>C: { linkCode }
  C->>W: باز کردن صفحه (sdkType=js, linkCode, …)
  W-->>C: postMessage { type: "login", data: "<token>" }
  C->>S: POST /api/platform/login { deviceId, token }
  S->>K: POST client/validate-user-token { token }
  S-->>C: { ok } — یا { ok, switchToDeviceId }

تغییرات در یک نگاه

امروز بعد از مهاجرت کجا
client/create-user با linkedToken v2/client/create-user با linkCode؛ tokens.refreshToken را ذخیره کنید سرور
client/get-user-token/{id} در هر بار اجرا auth/refresh با توکن تمدید ذخیره‌شده؛ v2/client/get-user-token/{id} فقط وقتی نشستی (session) نیست سرور
باز کردن صفحه‌ها با linkedToken=<token> باز کردن صفحه‌ها با linkCode=<code>؛ برای هر صفحه یک کد تازه از v2/client/link-code/{id} سرور + کلاینت
اپ بازینس بازی شما را با ?linkedToken= باز می‌کند ?linkCode=: مثل توکن قبلی آن را به سرورتان بدهید کلاینت
GET client/validate-user-token/{token} POST client/validate-user-token با { token } (توکن دیگر در آدرس نیست) سرور
کلاینت توکنش را سال‌ها نگه می‌دارد کلاینت فقط توکن دسترسی را نگه می‌دارد؛ وقتی منقضی شد دوباره از سرور خودتان می‌گیرد کلاینت

باقی چیزها عوض نمی‌شوند: پیام‌های صفحه (login، token، close، logout)، سکه‌ها، جایزه‌ها و پرداخت مثل قبل کار می‌کنند.

سرور، قدم‌به‌قدم

۱. برای هر بازیکن یک توکن تمدید نگه دارید

یک ستون به رکورد بازیکن اضافه کنید، مثلاً baziness_refresh_token. با آن مثل رمز عبور رفتار کنید:

  • هیچ‌وقت لاگش نکنید؛
  • هیچ‌وقت به کلاینت نفرستید؛
  • اگر رازهای دیگر را رمزنگاری می‌کنید، این را هم رمزنگاری کنید.

همیشه با جدیدترین توکن جایگزینش کنید. هر create-user، get-user-token و refresh یک توکن تمدید تازه برمی‌گرداند و توکن قبلی دیگر کار نمی‌کند.

۲. /api/platform/token: به‌جای get-user-token تمدید کنید

این endpointی است که کلاینت در شروع اجرا صدا می‌زند. کلاینت حالا به‌جای linkedToken، linkCode می‌فرستد.

  1. کلاینت linkCode فرستاده، یا بازیکن هنوز کاربر بازینس ندارد: v2/client/create-user را با deviceId و linkCode صدا بزنید. بعد userId برگشتی را مثل امروز بررسی کنید:
    • userId مال بازیکن دیگری از بازیکنان شماست ← switchToDeviceId برگردانید (همان آدم روی دستگاه دیگر).
    • بازیکن از قبل userId دیگری دارد ← یک حساب بازینس دیگر رسیده است. آن را به‌عنوان بازیکن جدا شروع کنید و switchToDeviceId برگردانید (همان قاعده‌ی قدیمی «داده‌های محلی را پاک کن و از نو شروع کن»).
    • در غیر این صورت ← userId و توکن تمدید را روی همین بازیکن ذخیره کنید.
    • اگر کد منقضی یا مصرف‌شده بود (400 invalid_link_code)، طوری ادامه دهید که انگار کدی نیامده.
  2. بازیکن آشنا: auth/refresh را با توکن تمدید ذخیره‌شده صدا بزنید و توکن تازه را ذخیره کنید. جواب isLinkedToAccount را هم دارد، پس همچنان می‌فهمید بازیکن کی وارد حسابش شده.
    • 401 refresh_token_rotated: درخواست دیگری از همین بازیکن لحظه‌ای قبل تمدید کرده. توکن تمدید را دوباره از دیتابیس بخوانید و یک بار دیگر امتحان کنید.
    • هر 401 دیگر (session_revoked، refresh_token_expired، invalid_refresh_token، refresh_token_reused): نشست تمام شده. با v2/client/get-user-token/{userId} نشست تازه بگیرید و توکن تمدیدش را ذخیره کنید.
    • 403 account_suspended: حساب مسدود است.
  3. به کلاینت توکن دسترسی و زمان انقضایش را بدهید: { token, tokenExpiresAt, bazinessUserId, isLinkedToAccount }. توکن تمدید را هرگز نفرستید.

پیشنهاد

بازیکن‌هایی که از قبل دارید هنوز توکن تمدید ندارند. قدم ۲ خودش آن‌ها را درست می‌کند: توکن تمدید ذخیره‌شده‌ای نیست ← یک بار v2/client/get-user-token، و از آن به بعد auth/refresh.

وقتی کلاینت می‌خواهد یکی از صفحه‌های بازینس را باز کند، از سرور شما کد می‌خواهد. سرور شما v2/client/link-code/{userId} را صدا می‌زند و { linkCode } را برمی‌گرداند.

  • هر کد یک بار و حدود دو دقیقه کار می‌کند: برای هر صفحه‌ای که باز می‌کنید کد تازه بگیرید.
  • فقط صفحه‌های بازینس می‌توانند از آن استفاده کنند. حتی اگر لو برود، نمی‌شود با آن وارد بازی شما شد.
  • این endpoint را مثل بقیه‌ی endpointهای بازیکن محافظت کنید: فقط خود بازیکن می‌تواند کد خودش را بگیرد.

۴. /api/platform/login: بررسی با POST

منطق عوض نمی‌شود. فقط نوع درخواست عوض می‌شود: POST client/validate-user-token با { token } در بدنه.

توکنی که در پیام login می‌رسد حالا ۱۵ دقیقه‌ای است. همان لحظه بررسی‌اش کنید، همان‌طور که امروز هم می‌کنید.

اگر توکن مال کاربر بازینسی غیر از کاربر فعلی بازیکن بود، توکن تمدید ذخیره‌شده‌ی بازیکن را پاک کنید؛ فراخوانی بعدی /token نشست تازه شروع می‌کند.

۵. endpoint جدید /api/platform/logout

وقتی صفحه logout می‌فرستد، کلاینت قبل از شروع دوباره این را صدا می‌زند. سرور شما نشست را می‌بندد (auth/logout) و توکن تمدید ذخیره‌شده را پاک می‌کند.

کد سرور

اضافه‌ها به کلاینت پلتفرم شما (کنار createUser / getUserAccessToken):

// platform/baziness.ts — ADD these next to createUser / getUserAccessToken (they reuse API_BASE and platformFetch).

/** A player's session: the access token goes to your client, the refresh token stays in your database. */
export interface PlatformSession {
  userId: number;
  isLinkedToAccount: boolean;
  accessToken: string;
  accessTokenExpiresAt: string;
  refreshToken: string;
}

const readSession = (j: Record<string, any>): PlatformSession => {
  const t = j.tokens ?? j; // v2 create-user / get-user-token nest the pair in `tokens`; auth/refresh returns it flat
  return {
    userId: Number(j.userId),
    isLinkedToAccount: Boolean(j.isLinkedToAccount),
    accessToken: String(t.accessToken),
    accessTokenExpiresAt: String(t.accessTokenExpiresAt),
    refreshToken: String(t.refreshToken),
  };
};

/** First time, or when the client brought a ?linkCode=. Throws (status 400) for an expired or used code. */
export const createUserSession = async (deviceId: string, platform: string, store: string, linkCode: string | null) =>
  readSession(await platformFetch("POST", "/v2/client/create-user", { deviceId, platform, store, linkCode }));

/** A new session for a player you know — only when you have no refresh token or the session ended. */
export const startUserSession = async (bazinessUserId: number) =>
  readSession(await platformFetch("POST", `/v2/client/get-user-token/${bazinessUserId}`));

/**
 * Renews a session. Returns the new session, "rotated" when a parallel request refreshed first
 * (reload the stored token and retry once), or null when the session is over (start a new one).
 */
export async function refreshUserSession(refreshToken: string): Promise<PlatformSession | "rotated" | null> {
  const res = await fetch(`${API_BASE}/auth/refresh`, {   // no client token: the refresh token is the credential
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refreshToken }),
  });
  if (res.ok) return readSession(await res.json());
  const error = ((await res.json().catch(() => ({}))) as { error?: string }).error;
  if (res.status === 401 && error === "refresh_token_rotated") return "rotated";
  if (res.status === 403) throw Object.assign(new Error("account_suspended"), { status: 403 });
  if (res.status === 401) return null;
  throw new Error(`auth/refresh failed (${res.status})`); // 5xx / network: keep the old session, try later
}

/** Ends a session (player signed out). Never throws. */
export async function endUserSession(refreshToken: string): Promise<void> {
  await fetch(`${API_BASE}/auth/logout`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refreshToken }),
  }).catch(() => {});
}

/** One-time code for opening ONE Baziness page as this player (~2 minutes). */
export const createPageLinkCode = async (bazinessUserId: number) =>
  String((await platformFetch("POST", `/v2/client/link-code/${bazinessUserId}`)).code);

/** REPLACES the old GET version: the token now goes in the body, not in the URL. */
export async function validateUserAccessToken(token: string): Promise<number> {
  const j = await platformFetch("POST", "/client/validate-user-token", { token });
  if (!(j.isValid ?? j.IsValid)) throw new Error("invalid user token");
  return Number(j.userId ?? j.UserId ?? 0);
}

چهار endpoint:

import {
  createUserSession, startUserSession, refreshUserSession, endUserSession, createPageLinkCode,
  validateUserAccessToken, platformClientConfig, platformEnabled, type PlatformSession,
} from "./platform/baziness";
import { db } from "./db"; // your own data layer. New column on the player: baziness_refresh_token (a secret)

// What the client gets. The refresh token never leaves your server.
const forClient = (s: PlatformSession) => ({
  token: s.accessToken, tokenExpiresAt: s.accessTokenExpiresAt,
  bazinessUserId: s.userId, isLinkedToAccount: s.isLinkedToAccount,
});

async function keep(playerId: number, s: PlatformSession) {
  await db.setRefreshToken(playerId, s.refreshToken); // always the NEWEST one: the old one stops working
  return s;
}

/** A valid session for a player who already has a Baziness user. */
async function sessionFor(player: { id: number; bazinessUserId: number; bazinessRefreshToken: string | null }) {
  if (player.bazinessRefreshToken) {
    let r = await refreshUserSession(player.bazinessRefreshToken);
    if (r === "rotated") {                       // another request of this player refreshed a moment ago
      const latest = await db.getRefreshToken(player.id);
      r = latest ? await refreshUserSession(latest) : null;
      if (r === "rotated") r = null;
    }
    if (r) return keep(player.id, r);
  }
  // No refresh token yet (players from before the migration) or the session ended.
  return keep(player.id, await startUserSession(player.bazinessUserId));
}

app.post("/api/platform/token", async (req, res) => {
  const deviceId = String(req.body.deviceId ?? "").trim();
  const linkCode: string | null = req.body.linkCode ?? null;           // replaces linkedToken
  if (!deviceId) return res.status(400).json({ error: "deviceId required" });
  if (!platformEnabled()) return res.json({ enabled: false });

  const config = { enabled: true, ...platformClientConfig() };
  const me = await db.ensureUserByDevice(deviceId, { utm: req.body.utm, tag: req.body.tag });
  try {
    if (linkCode || !me.bazinessUserId) {
      // First time, or arrived from the Baziness app with ?linkCode=: which Baziness user is this?
      const s = await createUserSession(deviceId, "Web", "Direct", linkCode)
        .catch((e) => (linkCode && e.status === 400 ? null : Promise.reject(e))); // expired or used code
      if (s) {
        const owner = await db.findUserByBazinessId(s.userId);
        if (owner && owner.id !== me.id) {                  // the same person, another player of yours
          await keep(owner.id, s);
          return res.json({ ...config, switchToDeviceId: owner.deviceId });
        }
        if (me.bazinessUserId && me.bazinessUserId !== s.userId) {
          // A different Baziness account arrived on a device that already has a player:
          // it becomes its own player (the old "clear local data and start again" rule).
          const fresh = await db.createPlayerForBaziness(s.userId, s.isLinkedToAccount);
          await keep(fresh.id, s);
          return res.json({ ...config, switchToDeviceId: fresh.deviceId });
        }
        await db.linkBaziness(me.id, s.userId, s.isLinkedToAccount);
        await keep(me.id, s);
        return res.json({ ...config, ...forClient(s) });
      }
      if (!me.bazinessUserId) {                              // stale code and no Baziness user yet
        const s2 = await createUserSession(deviceId, "Web", "Direct", null);
        await db.linkBaziness(me.id, s2.userId, s2.isLinkedToAccount);
        await keep(me.id, s2);
        return res.json({ ...config, ...forClient(s2) });
      }
    }
    const s = await sessionFor(me);
    if (s.isLinkedToAccount !== me.isBazinessLinked) await db.setLinked(me.id, s.isLinkedToAccount);
    return res.json({ ...config, ...forClient(s) });
  } catch (err) {
    console.error("[platform] token failed:", (err as Error).message);
    const suspended = (err as { status?: number }).status === 403;
    return res.json({ ...config, error: suspended ? "account_suspended" : "platform_unavailable" }); // never block the boot
  }
});

// Protect it like your other player endpoints: only the player themself may ask for their code.
app.post("/api/platform/link-code", async (req, res) => {
  const me = await db.findUserByDevice(String(req.body.deviceId ?? ""));
  if (!me?.bazinessUserId) return res.status(409).json({ error: "call /api/platform/token first" });
  try {
    return res.json({ linkCode: await createPageLinkCode(me.bazinessUserId) });
  } catch {
    return res.status(503).json({ error: "platform_unavailable" });
  }
});

app.post("/api/platform/login", async (req, res) => {
  const deviceId = String(req.body.deviceId ?? "").trim();
  const token = String(req.body.token ?? "").trim();
  if (!deviceId || !token) return res.status(400).json({ error: "deviceId and token required" });
  if (!platformEnabled()) return res.json({ ok: false, reason: "disabled" });
  try {
    const bazinessUserId = await validateUserAccessToken(token);         // POST now; verify right away (15-min token)
    const owner = await db.findUserByBazinessId(bazinessUserId);
    const me = await db.ensureUserByDevice(deviceId);
    if (owner && owner.id !== me.id) return res.json({ ok: true, switchToDeviceId: owner.deviceId });
    if (me.bazinessUserId !== bazinessUserId) await db.setRefreshToken(me.id, null); // belonged to the old user
    await db.linkBaziness(me.id, bazinessUserId, true);
    return res.json({ ok: true });                                       // the client then calls /token again
  } catch {
    return res.json({ ok: false, reason: "invalid" });
  }
});

// The page sent `logout`: end the session before the client starts over as a new visitor.
app.post("/api/platform/logout", async (req, res) => {
  const me = await db.findUserByDevice(String(req.body.deviceId ?? ""));
  if (me?.bazinessRefreshToken) {
    await endUserSession(me.bazinessRefreshToken);
    await db.setRefreshToken(me.id, null);
  }
  return res.json({ ok: true });
});

کلاینت، قدم‌به‌قدم

  1. در شروع اجرا، به‌جای linkedToken، linkCode را (از ?linkCode= در آدرس یا از deep link) به /api/platform/token بفرستید. بعد از آن linkCode و linkedToken را از آدرس حذف کنید: هر کد یک بار کار می‌کند.
  2. توکن را برای اجراهای بعدی ذخیره نکنید (localStorage، PlayerPrefs). سرور شما نشست را نگه می‌دارد و با هر /api/platform/token یک توکن تازه می‌دهد.
  3. قبل از استفاده از توکن (user/get-coin) tokenExpiresAt را نگاه کنید. اگر تا یک دقیقه‌ی دیگر منقضی می‌شود، یا درخواستی 401 گرفت، دوباره /api/platform/token را صدا بزنید.
  4. برای باز کردن صفحه‌ی بازینس، از /api/platform/link-code کد بگیرید و به‌جای linkedToken، linkCode=<code> را در آدرس صفحه بگذارید. بقیه‌ی پارامترها همان قبلی‌ها هستند (صفحه‌ها).
  5. با پیام logout صفحه، /api/platform/logout را صدا بزنید و بعد مثل قبل از نو شروع کنید.
// PlatformController — the parts that change.

// 1. Boot: forward the one-time ?linkCode= (it replaces ?linkedToken=). Don't keep the token in
//    localStorage any more: your server keeps the session and gives you a fresh token on demand.
async init(): Promise<boolean> {
  const params = new URLSearchParams(location.search);
  const linkCode = params.get("linkCode");
  const res = await fetch("/api/platform/token", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ deviceId: this.deviceId, linkCode }),
  });
  const data = await res.json();
  if (data.switchToDeviceId) return this.switchTo(data.switchToDeviceId); // unchanged
  this.token = data.token ?? null;
  this.tokenExpiresAt = data.tokenExpiresAt ? Date.parse(data.tokenExpiresAt) : 0;
  if (linkCode || params.has("linkedToken")) {                              // a code works once
    params.delete("linkCode"); params.delete("linkedToken");
    const rest = params.toString();
    history.replaceState(null, "", rest ? `${location.pathname}?${rest}` : location.pathname);
  }
  return !!this.token;
}

// 2. The token lives 15 minutes: ask your server again shortly before it expires, or after a 401.
async accessToken(): Promise<string | null> {
  if (!this.token || this.tokenExpiresAt - Date.now() < 60_000) await this.init();
  return this.token;
}

async getCoin(): Promise<number> {
  const call = async () => fetch(`${this.apiBase}/user/get-coin`, {
    headers: { Authorization: `Bearer ${await this.accessToken()}` },
  });
  let res = await call();
  if (res.status === 401) { this.token = null; res = await call(); }        // renew once, then give up
  return res.ok ? Number((await res.json()).amount ?? 0) : 0;
}

// 3. Pages: a fresh one-time code for every page you open, instead of the token.
async pageUrl(path: string, o: PageOptions = {}): Promise<string> {
  const res = await fetch("/api/platform/link-code", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ deviceId: this.deviceId }),
  });
  const { linkCode } = await res.json();
  const url = new URL(bazinessPageUrl(this.webBase, path, this.packageName, null, o)); // no token any more
  url.searchParams.set("linkCode", linkCode);
  return url.toString();
}

// 4. The page sent `logout`: tell your server first, then start over as before.
async onPageLogout() {
  await fetch("/api/platform/logout", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ deviceId: this.deviceId }),
  }).catch(() => {});
  this.resetAndRestart(); // unchanged: delete local data, new visitor
}

endpointهای جدید پلتفرم

همه زیر همان {api} همیشگی هستند. endpointهای client مثل قبل توکن کلاینت لازم دارند.

ساخت کاربر همراه با نشست

HTTP
POST {api}/v2/client/create-user
Authorization: Bearer <client token>
Content-Type: application/json

{ "deviceId": "8f1c2a…", "platform": "Android", "store": "CafeBazaar", "linkCode": null }

→ 200 OK
{
  "userId": 239236,
  "token": "eyJhbGciOiJIUzI1NiIs…",              // the access token (same as tokens.accessToken)
  "isLinkedToAccount": false,
  "tokens": {
    "accessToken": "eyJhbGciOiJIUzI1NiIs…",       // 15 minutes — the client uses this
    "accessTokenExpiresAt": "2026-10-22T10:15:00+00:00",
    "refreshToken": "rt1.dkUoqaDDG8YB15KAQNO1q77FE…", // keep on YOUR server only
    "refreshTokenExpiresAt": "2026-11-21T10:00:00+00:00"
  }
}

→ 400 { "success": false, "error": "invalid_link_code" }   // the code expired or was already used

تمدید نشست

توکن کلاینت لازم نیست: خود توکن تمدید مدرک است. این را از سرور خودتان صدا بزنید.

HTTP
POST {api}/auth/refresh
Content-Type: application/json

{ "refreshToken": "rt1.dkUoqaDDG8YB15KAQNO1q77FE…" }

→ 200 OK
{
  "userId": 239236,
  "isLinkedToAccount": true,
  "accessToken": "eyJhbGciOiJIUzI1NiIs…",
  "accessTokenExpiresAt": "2026-10-22T10:30:00+00:00",
  "refreshToken": "rt1.Wn0QhZ…",                  // store THIS one — the old one no longer works
  "refreshTokenExpiresAt": "2026-11-21T10:15:00+00:00"
}

→ 401 { "success": false, "error": "refresh_token_rotated" }  // another request refreshed first: reload yours, retry once
→ 401 { "success": false, "error": "session_revoked" }        // also invalid_refresh_token, refresh_token_expired,
                                                               // refresh_token_reused: get a new session
→ 403 { "success": false, "error": "account_suspended" }

نشست تازه برای بازیکن آشنا

HTTP
POST {api}/v2/client/get-user-token/239236
Authorization: Bearer <client token>

→ 200 OK
{ "userId": 239236, "token": "eyJhbGciOiJIUzI1NiIs…", "isLinkedToAccount": true,
  "tokens": { "accessToken": "…", "accessTokenExpiresAt": "…", "refreshToken": "rt1.…", "refreshTokenExpiresAt": "…" } }

→ 403 { "success": false, "error": "access denied" }   // the user belongs to another game
HTTP
POST {api}/v2/client/link-code/239236
Authorization: Bearer <client token>

→ 200 OK
{ "code": "q3Jc9yX2tL0…", "expiresAt": "2026-10-22T10:02:00+00:00" }   // one page, once, ~2 minutes

→ 403 { "success": false, "error": "access denied" }   // the user belongs to another game

بررسی توکن (در بدنه به‌جای آدرس)

HTTP
POST {api}/client/validate-user-token
Authorization: Bearer <client token>
Content-Type: application/json

{ "token": "eyJhbGciOiJIUzI1NiIs…" }

→ 200 OK
{ "userId": 239236, "isValid": true }

→ 200 OK   (invalid or expired: not an HTTP error)
{ "userId": 0, "isValid": false }

بستن نشست

POST {api}/auth/logout با { "refreshToken": "rt1.…" }. همیشه 200 جواب می‌دهد.

خطاها

پاسخ کجا یعنی چه کنید
401 refresh_token_rotated auth/refresh درخواست دیگری از همین بازیکن زودتر تمدید کرده توکن تمدید را دوباره بخوانید و یک بار دیگر امتحان کنید
401 session_revoked، refresh_token_expired، invalid_refresh_token، refresh_token_reused auth/refresh نشست تمام شده v2/client/get-user-token/{id} و ذخیره‌ی توکن تمدید تازه
403 account_suspended auth/refresh حساب مسدود است پیام مسدودی را نشان دهید
400 invalid_link_code v2/client/create-user کد منقضی یا مصرف شده، یا برای بازی شما نبوده بدون کد ادامه دهید
403 v2/client/link-code/{id}، v2/client/get-user-token/{id} کاربر مال بازی دیگری است userId ذخیره‌شده را بررسی کنید
401 user/get-coin (کلاینت) توکن ۱۵ دقیقه‌ای منقضی شده دوباره /api/platform/token و یک بار تکرار

چک‌لیست

روی پلتفرم توسعه تست کنید (هر شماره‌ای، کد 111111):

  • بازیکن جدید بازی را باز می‌کند: کلاینت توکن می‌گیرد و دیتابیس شما برایش توکن تمدید دارد.
  • اجرای دوباره: auth/refresh استفاده می‌شود (نه get-user-token) و توکن تمدید ذخیره‌شده عوض شده.
  • دو اجرای هم‌زمان یک بازیکن: هر دو موفق‌اند (یکی با تکرار بعد از rotated).
  • بازیکنی از قبل از مهاجرت یک بار بازی را باز می‌کند و توکن تمدید می‌گیرد.
  • همه‌ی صفحه‌های بازینس با linkCode=… و بدون linkedToken باز می‌شوند؛ صفحه‌ی ورود، پروفایل، فروشگاه جایزه و صفحه‌ی پیشنهاد همه کار می‌کنند.
  • ورود در صفحه با شماره‌ای که مال بازیکن دیگری از بازیکنان شماست، همچنان به آن بازیکن جابه‌جا می‌شود.
  • باز کردن بازی از اپ بازینس با ?linkCode= به حساب درست می‌رسد؛ همان لینک را دو بار باز کنید ← بار دوم کد نادیده گرفته می‌شود و بازی باز هم بالا می‌آید.
  • بعد از ۱۵ دقیقه بازی، موجودی سکه هنوز بارگذاری می‌شود (توکن از طریق سرور شما تمدید شده).
  • logout از صفحه نشست را می‌بندد؛ اجرای بعدی یک بازدیدکننده‌ی جدید است.
  • در لاگ‌های شما توکن تمدید نیست و هیچ توکنی در آدرس‌ها نیست.

نکته

تا تاریخ پایان، اپ بازینس ?linkedToken= را هم می‌فرستد. وقتی بازی شما linkCode را می‌خواند، linkedToken را نادیده بگیرید. بعد از تاریخ پایان دیگر فرستاده نمی‌شود و توکن‌های قدیمی هم کار نمی‌کنند.