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

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

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

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

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

مدل جدید:

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

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

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

```mermaid
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 }`. توکن تمدید را هرگز نفرستید.

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

### ۳. endpoint جدید `/api/platform/link-code`: یک کد برای هر صفحه
وقتی کلاینت می‌خواهد یکی از صفحه‌های بازینس را باز کند، از سرور شما کد می‌خواهد. سرور شما `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`):

```ts title="TypeScript (Node)"
// 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);
}
```
```cs title="C# (.NET)"
// BazinessPlatformClient.cs — ADD these members (they reuse settings, http, SendAsync and Json).

public record PlatformSession(long UserId, bool IsLinkedToAccount, string AccessToken,
    DateTimeOffset AccessTokenExpiresAt, string RefreshToken);

public enum RefreshOutcome { Ok, Rotated, Ended }

static PlatformSession ReadSession(JsonElement j)
{
    // v2 create-user / get-user-token nest the pair in "tokens"; auth/refresh returns it flat.
    var t = j.TryGetProperty("tokens", out var nested) ? nested : j;
    return new PlatformSession(
        j.GetProperty("userId").GetInt64(),
        j.GetProperty("isLinkedToAccount").GetBoolean(),
        t.GetProperty("accessToken").GetString()!,
        t.GetProperty("accessTokenExpiresAt").GetDateTimeOffset(),
        t.GetProperty("refreshToken").GetString()!);
}

/// <summary>First time, or when the client brought a ?linkCode=. 400 = expired or used code.</summary>
public async Task<PlatformSession> CreateUserSessionAsync(string deviceId, string platform, string store, string? linkCode) =>
    ReadSession(await SendAsync(HttpMethod.Post, "/v2/client/create-user", new { deviceId, platform, store, linkCode }));

/// <summary>A new session for a known player — only when there is no refresh token or the session ended.</summary>
public async Task<PlatformSession> StartUserSessionAsync(long bazinessUserId) =>
    ReadSession(await SendAsync(HttpMethod.Post, $"/v2/client/get-user-token/{bazinessUserId}"));

/// <summary>Renews a session. Rotated = a parallel request refreshed first (reload, retry once); Ended = start a new one.</summary>
public async Task<(RefreshOutcome Outcome, PlatformSession? Session)> RefreshUserSessionAsync(string refreshToken)
{
    // No client token here: the refresh token itself is the credential.
    var res = await http.PostAsJsonAsync($"{settings.ApiBase}/auth/refresh", new { refreshToken }, Json);
    if (res.IsSuccessStatusCode)
        return (RefreshOutcome.Ok, ReadSession(await res.Content.ReadFromJsonAsync<JsonElement>(Json)));

    string? error = null;
    var text = await res.Content.ReadAsStringAsync();
    if (!string.IsNullOrEmpty(text))
    {
        try { error = JsonDocument.Parse(text).RootElement.TryGetProperty("error", out var e) ? e.GetString() : null; }
        catch (JsonException) { /* not JSON */ }
    }
    return res.StatusCode switch
    {
        HttpStatusCode.Unauthorized when error == "refresh_token_rotated" => (RefreshOutcome.Rotated, null),
        HttpStatusCode.Unauthorized => (RefreshOutcome.Ended, null),
        HttpStatusCode.Forbidden => throw new InvalidOperationException("account_suspended"),
        _ => throw new HttpRequestException($"auth/refresh failed ({(int)res.StatusCode})") // keep the old session
    };
}

/// <summary>Ends a session (player signed out). Never throws.</summary>
public async Task EndUserSessionAsync(string refreshToken)
{
    try { await http.PostAsJsonAsync($"{settings.ApiBase}/auth/logout", new { refreshToken }, Json); }
    catch { /* best effort */ }
}

/// <summary>One-time code for opening ONE Baziness page as this player (~2 minutes).</summary>
public async Task<string> CreatePageLinkCodeAsync(long bazinessUserId) =>
    (await SendAsync(HttpMethod.Post, $"/v2/client/link-code/{bazinessUserId}")).GetProperty("code").GetString()!;

/// <summary>REPLACES the old GET version: the token now goes in the body, not in the URL.</summary>
public async Task<long> ValidateUserAccessTokenAsync(string token)
{
    var j = await SendAsync(HttpMethod.Post, "/client/validate-user-token", new { token });
    if (!j.GetProperty("isValid").GetBoolean()) throw new InvalidOperationException("invalid user token");
    return j.GetProperty("userId").GetInt64();
}
```
```py title="Python"
# baziness/platform.py — ADD these methods to BazinessPlatform (they reuse API_BASE, self._http and self._call).

    @staticmethod
    def _session(j: dict) -> dict:
        t = j.get("tokens") or j  # v2 create-user / get-user-token nest the pair; auth/refresh returns it flat
        return {"userId": int(j["userId"]), "isLinkedToAccount": bool(j["isLinkedToAccount"]),
                "accessToken": t["accessToken"], "accessTokenExpiresAt": t["accessTokenExpiresAt"],
                "refreshToken": t["refreshToken"]}

    def create_user_session(self, device_id: str, platform: str, store: str, link_code: str | None) -> dict:
        """First time, or when the client brought a ?linkCode=. HTTPError 400 = expired or used code."""
        return self._session(self._call("POST", "/v2/client/create-user", {
            "deviceId": device_id, "platform": platform, "store": store, "linkCode": link_code}))

    def start_user_session(self, baziness_user_id: int) -> dict:
        """A new session for a known player — only when there is no refresh token or the session ended."""
        return self._session(self._call("POST", f"/v2/client/get-user-token/{baziness_user_id}"))

    def refresh_user_session(self, refresh_token: str):
        """Returns the new session, "rotated" (reload the stored token, retry once) or None (session over)."""
        r = self._http.post(f"{API_BASE}/auth/refresh", json={"refreshToken": refresh_token}, timeout=10)
        if r.ok:
            return self._session(r.json())
        error = (r.json() if r.content else {}).get("error")
        if r.status_code == 401:
            return "rotated" if error == "refresh_token_rotated" else None
        if r.status_code == 403:
            raise PermissionError("account_suspended")
        r.raise_for_status()  # 5xx: keep the old session, try later

    def end_user_session(self, refresh_token: str) -> None:
        """Ends a session (player signed out). Never raises."""
        try:
            self._http.post(f"{API_BASE}/auth/logout", json={"refreshToken": refresh_token}, timeout=10)
        except requests.RequestException:
            pass

    def create_page_link_code(self, baziness_user_id: int) -> str:
        """One-time code for opening ONE Baziness page as this player (~2 minutes)."""
        return str(self._call("POST", f"/v2/client/link-code/{baziness_user_id}")["code"])

    def validate_user_access_token(self, token: str) -> int:
        """REPLACES the old GET version: the token now goes in the body, not in the URL."""
        j = self._call("POST", "/client/validate-user-token", {"token": token})
        if not _pick(j, "isValid", False):
            raise ValueError("invalid user token")
        return int(_pick(j, "userId", 0))
```
چهار endpoint:

```ts title="TypeScript (Node)"
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 });
});
```
```cs title="C# (.NET)"
// IUserStore gains: SetRefreshTokenAsync(id, token?), GetRefreshTokenAsync(id), FindByDeviceAsync(deviceId),
//                   CreatePlayerForBazinessAsync(bazinessUserId, isLinked). Store the refresh token like a password.

// What the client gets. The refresh token never leaves your server.
static object ForClient(PlatformSettings pub, PlatformSession s) => new
{
    enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName,
    token = s.AccessToken, tokenExpiresAt = s.AccessTokenExpiresAt,
    bazinessUserId = s.UserId, isLinkedToAccount = s.IsLinkedToAccount
};

static async Task<PlatformSession> SessionForAsync(Player player, BazinessPlatformClient platform, IUserStore users)
{
    var refreshToken = player.BazinessRefreshToken;
    for (var attempt = 0; refreshToken is not null && attempt < 2; attempt++)
    {
        var (outcome, session) = await platform.RefreshUserSessionAsync(refreshToken);
        if (outcome == RefreshOutcome.Ok)
        {
            await users.SetRefreshTokenAsync(player.Id, session!.RefreshToken); // always the newest one
            return session;
        }
        if (outcome == RefreshOutcome.Ended) break;
        refreshToken = await users.GetRefreshTokenAsync(player.Id);         // Rotated: a parallel request won
    }
    // No refresh token yet (players from before the migration) or the session ended.
    var fresh = await platform.StartUserSessionAsync(player.BazinessUserId!.Value);
    await users.SetRefreshTokenAsync(player.Id, fresh.RefreshToken);
    return fresh;
}

app.MapPost("/api/platform/token", async (TokenRequest body, BazinessPlatformClient platform,
    IUserStore users, PlatformSettings pub, ILogger<Program> log) =>
{
    if (string.IsNullOrWhiteSpace(body.DeviceId)) return Results.BadRequest(new { error = "deviceId required" });
    if (!pub.IsOn) return Results.Ok(new { enabled = false });

    var me = await users.EnsureByDeviceAsync(body.DeviceId);
    try
    {
        if (body.LinkCode is not null || me.BazinessUserId is null)
        {
            PlatformSession? s = null;
            try { s = await platform.CreateUserSessionAsync(body.DeviceId, "Web", "Direct", body.LinkCode); }
            catch (HttpRequestException e) when (body.LinkCode is not null && e.StatusCode == HttpStatusCode.BadRequest) { }

            if (s is not null)
            {
                var owner = await users.FindByBazinessUserIdAsync(s.UserId);
                if (owner is not null && owner.Id != me.Id)
                {
                    await users.SetRefreshTokenAsync(owner.Id, s.RefreshToken);
                    return Results.Ok(new { enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName, switchToDeviceId = owner.DeviceId });
                }
                if (me.BazinessUserId is not null && me.BazinessUserId != s.UserId)
                {
                    var created = await users.CreatePlayerForBazinessAsync(s.UserId, s.IsLinkedToAccount);
                    await users.SetRefreshTokenAsync(created.Id, s.RefreshToken);
                    return Results.Ok(new { enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName, switchToDeviceId = created.DeviceId });
                }
                await users.LinkBazinessAsync(me.Id, s.UserId, s.IsLinkedToAccount);
                await users.SetRefreshTokenAsync(me.Id, s.RefreshToken);
                return Results.Ok(ForClient(pub, s));
            }
            if (me.BazinessUserId is null)                  // stale code and no Baziness user yet
            {
                var s2 = await platform.CreateUserSessionAsync(body.DeviceId, "Web", "Direct", null);
                await users.LinkBazinessAsync(me.Id, s2.UserId, s2.IsLinkedToAccount);
                await users.SetRefreshTokenAsync(me.Id, s2.RefreshToken);
                return Results.Ok(ForClient(pub, s2));
            }
        }

        var session = await SessionForAsync(me, platform, users);
        if (session.IsLinkedToAccount != me.IsBazinessLinked) await users.SetLinkedAsync(me.Id, session.IsLinkedToAccount);
        return Results.Ok(ForClient(pub, session));
    }
    catch (Exception ex)
    {
        log.LogError(ex, "platform token failed");
        var error = ex.Message == "account_suspended" ? "account_suspended" : "platform_unavailable";
        return Results.Ok(new { enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName, error }); // never block the boot
    }
});

// Protect it like your other player endpoints: only the player themself may ask for their code.
app.MapPost("/api/platform/link-code", async (DeviceRequest body, BazinessPlatformClient platform, IUserStore users) =>
{
    var me = await users.FindByDeviceAsync(body.DeviceId);
    if (me?.BazinessUserId is null) return Results.Conflict(new { error = "call /api/platform/token first" });
    try { return Results.Ok(new { linkCode = await platform.CreatePageLinkCodeAsync(me.BazinessUserId.Value) }); }
    catch { return Results.StatusCode(503); }
});

app.MapPost("/api/platform/login", async (LoginRequest body, BazinessPlatformClient platform, IUserStore users, PlatformSettings pub) =>
{
    if (string.IsNullOrWhiteSpace(body.DeviceId) || string.IsNullOrWhiteSpace(body.Token))
        return Results.BadRequest(new { error = "deviceId and token required" });
    if (!pub.IsOn) return Results.Ok(new { ok = false, reason = "disabled" });
    try
    {
        long bazinessUserId = await platform.ValidateUserAccessTokenAsync(body.Token); // POST now; verify right away
        var owner = await users.FindByBazinessUserIdAsync(bazinessUserId);
        var me = await users.EnsureByDeviceAsync(body.DeviceId);
        if (owner is not null && owner.Id != me.Id) return Results.Ok(new { ok = true, switchToDeviceId = owner.DeviceId });
        if (me.BazinessUserId != bazinessUserId) await users.SetRefreshTokenAsync(me.Id, null); // belonged to the old user
        await users.LinkBazinessAsync(me.Id, bazinessUserId, isLinked: true);
        return Results.Ok(new { ok = true });                                           // the client then calls /token again
    }
    catch { return Results.Ok(new { ok = false, reason = "invalid" }); }
});

// The page sent `logout`: end the session before the client starts over as a new visitor.
app.MapPost("/api/platform/logout", async (DeviceRequest body, BazinessPlatformClient platform, IUserStore users) =>
{
    var me = await users.FindByDeviceAsync(body.DeviceId);
    if (me?.BazinessRefreshToken is { } refreshToken)
    {
        await platform.EndUserSessionAsync(refreshToken);
        await users.SetRefreshTokenAsync(me.Id, null);
    }
    return Results.Ok(new { ok = true });
});

public record TokenRequest(string DeviceId, string? LinkCode);
public record DeviceRequest(string DeviceId);
public record LoginRequest(string DeviceId, string Token);
```
```py title="Python"
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from requests import HTTPError
from baziness.platform import platform, platform_client_config, platform_enabled
from app import db  # your own data layer. New column on the player: baziness_refresh_token (a secret)

app = FastAPI()


class TokenRequest(BaseModel):
    deviceId: str
    linkCode: str | None = None        # replaces linkedToken


class DeviceRequest(BaseModel):
    deviceId: str


class LoginRequest(BaseModel):
    deviceId: str
    token: str


def for_client(s: dict) -> dict:  # the refresh token never leaves your server
    return {"token": s["accessToken"], "tokenExpiresAt": s["accessTokenExpiresAt"],
            "bazinessUserId": s["userId"], "isLinkedToAccount": s["isLinkedToAccount"]}


def keep(player_id: int, s: dict) -> dict:
    db.set_refresh_token(player_id, s["refreshToken"])  # always the newest one
    return s


def session_for(player) -> dict:
    token = player.baziness_refresh_token
    for _ in range(2):
        if not token:
            break
        r = platform.refresh_user_session(token)
        if isinstance(r, dict):
            return keep(player.id, r)
        if r is None:                       # the session ended
            break
        token = db.get_refresh_token(player.id)  # "rotated": a parallel request won
    # No refresh token yet (players from before the migration) or the session ended.
    return keep(player.id, platform.start_user_session(player.baziness_user_id))


@app.post("/api/platform/token")
def platform_token(body: TokenRequest):
    if not platform_enabled():
        return {"enabled": False}
    config = {"enabled": True, **platform_client_config()}
    me = db.ensure_user_by_device(body.deviceId)
    try:
        if body.linkCode or not me.baziness_user_id:
            try:
                s = platform.create_user_session(body.deviceId, "Web", "Direct", body.linkCode)
            except HTTPError as e:
                if not (body.linkCode and e.response.status_code == 400):
                    raise
                s = None                                # expired or used code
            if s:
                owner = db.find_user_by_baziness_user_id(s["userId"])
                if owner and owner.id != me.id:         # the same person, another player of yours
                    keep(owner.id, s)
                    return {**config, "switchToDeviceId": owner.device_id}
                if me.baziness_user_id and me.baziness_user_id != s["userId"]:
                    fresh = db.create_player_for_baziness(s["userId"], s["isLinkedToAccount"])
                    keep(fresh.id, s)
                    return {**config, "switchToDeviceId": fresh.device_id}
                db.link_baziness(me.id, s["userId"], s["isLinkedToAccount"])
                return {**config, **for_client(keep(me.id, s))}
            if not me.baziness_user_id:                 # stale code and no Baziness user yet
                s2 = platform.create_user_session(body.deviceId, "Web", "Direct", None)
                db.link_baziness(me.id, s2["userId"], s2["isLinkedToAccount"])
                return {**config, **for_client(keep(me.id, s2))}
        s = session_for(me)
        if s["isLinkedToAccount"] != me.is_baziness_linked:
            db.set_linked(me.id, s["isLinkedToAccount"])
        return {**config, **for_client(s)}
    except PermissionError:
        return {**config, "error": "account_suspended"}
    except Exception as err:  # never block the boot
        print("[platform] token failed:", err)
        return {**config, "error": "platform_unavailable"}


@app.post("/api/platform/link-code")  # protect it like your other player endpoints
def platform_link_code(body: DeviceRequest):
    me = db.find_user_by_device(body.deviceId)
    if not me or not me.baziness_user_id:
        raise HTTPException(409, "call /api/platform/token first")
    return {"linkCode": platform.create_page_link_code(me.baziness_user_id)}


@app.post("/api/platform/login")
def platform_login(body: LoginRequest):
    if not platform_enabled():
        return {"ok": False, "reason": "disabled"}
    try:
        baziness_user_id = platform.validate_user_access_token(body.token)  # POST now; verify right away
    except Exception:
        return {"ok": False, "reason": "invalid"}
    owner = db.find_user_by_baziness_user_id(baziness_user_id)
    me = db.ensure_user_by_device(body.deviceId)
    if owner and owner.id != me.id:
        return {"ok": True, "switchToDeviceId": owner.device_id}
    if me.baziness_user_id != baziness_user_id:
        db.set_refresh_token(me.id, None)                                    # belonged to the old user
    db.link_baziness(me.id, baziness_user_id, True)
    return {"ok": True}                                                      # the client then calls /token again


@app.post("/api/platform/logout")  # the page sent `logout`
def platform_logout(body: DeviceRequest):
    me = db.find_user_by_device(body.deviceId)
    if me and me.baziness_refresh_token:
        platform.end_user_session(me.baziness_refresh_token)
        db.set_refresh_token(me.id, None)
    return {"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>` را در آدرس صفحه بگذارید. بقیه‌ی پارامترها همان قبلی‌ها هستند ([صفحه‌ها](pages.md#parameters)).
5. **با پیام `logout` صفحه**، `/api/platform/logout` را صدا بزنید و بعد مثل قبل از نو شروع کنید.

```ts title="TypeScript"
// 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
}
```
```cs title="C# (Unity)"
// BazinessLogin — the parts that change.
public DateTimeOffset TokenExpiresAt { get; private set; }

[Serializable] class LinkCodeResponse { public string linkCode; }

// 1. Boot: forward the one-time ?linkCode= / deep-link code (it replaces linkedToken).
//    Stop saving the token in PlayerPrefs: your server keeps the session.
public IEnumerator Init(string deviceId, string linkCode = null)
{
    var body = $"{{\"deviceId\":\"{deviceId}\",\"linkCode\":{Quote(linkCode)}}}";
    using var req = Post("/api/platform/token", body);
    yield return req.SendWebRequest();
    var r = JsonUtility.FromJson<TokenResponse>(req.downloadHandler.text); // TokenResponse gains: string tokenExpiresAt
    if (!string.IsNullOrEmpty(r.switchToDeviceId)) { SwitchTo(r.switchToDeviceId); yield break; } // unchanged
    Token = r.token;
    TokenExpiresAt = DateTimeOffset.TryParse(r.tokenExpiresAt, out var t) ? t : DateTimeOffset.MinValue;
}

// 2. The token lives 15 minutes: renew it through your server before using it.
public IEnumerator EnsureFreshToken(string deviceId)
{
    if (string.IsNullOrEmpty(Token) || TokenExpiresAt - DateTimeOffset.UtcNow < TimeSpan.FromMinutes(1))
        yield return Init(deviceId);
}

// 3. Pages: a fresh one-time code for every page you open, instead of the token.
public IEnumerator OpenPage(string deviceId, string path, Action<string> open,
    string redirect = null, bool allowGuest = true, string extra = null)
{
    using var req = Post("/api/platform/link-code", $"{{\"deviceId\":\"{deviceId}\"}}");
    yield return req.SendWebRequest();
    var linkCode = JsonUtility.FromJson<LinkCodeResponse>(req.downloadHandler.text).linkCode;
    open(PageUrl(path, linkCode, redirect, allowGuest, extra));
}

public string PageUrl(string path, string linkCode, string redirect = null, bool allowGuest = true, string extra = null)
{
    var url = $"{webBase.TrimEnd('/')}{path}?packageName={Uri.EscapeDataString(packageName)}&sdkType=js";
    if (!string.IsNullOrEmpty(linkCode)) url += $"&linkCode={Uri.EscapeDataString(linkCode)}"; // was linkedToken
    if (!string.IsNullOrEmpty(redirect)) url += $"&redirect={redirect}";
    if (!allowGuest) url += "&allowGuest=false";
    if (!string.IsNullOrEmpty(extra)) url += $"&{extra}";
    return url;
}
```
## 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` و یک بار تکرار |

## چک‌لیست
روی [پلتفرم توسعه](login.md#testing-logins-on-the-dev-platform) تست کنید (هر شماره‌ای، کد `111111`):

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

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