@torrow/sdk-auth 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,438 @@
1
- # Temporary Holding Version
1
+ # `@torrow/sdk-auth`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Сессия токенов поверх **`@torrow/sdk-client`**: storage, проактивный refresh, single-flight, **`refreshOnUnauthorized`** (default **true**), фасад **`TorrowAuth`**.
4
+
5
+ В snapshot файла/storage: access / refresh / срок access / **`deviceId`**. Тип устройства (`deviceType`) задаёт приложение — в snapshot не пишется.
6
+
7
+ IMAP / stdin / captcha-UI / чтение `process.env` — **не** в пакете (слой app / **`examples/node/_shared`**).
8
+
9
+ ## Выбор режима
10
+
11
+ ```mermaid
12
+ flowchart TD
13
+ need[Нужен клиент] --> kind{Сценарий}
14
+ kind -->|restore| connect["TorrowAuth.connect"]
15
+ kind -->|OTP login| login["TorrowAuth.loginWithOtp"]
16
+ kind -->|раздельные OTP-запросы| flow["TorrowAuth.createOtpFlow: start / complete"]
17
+ kind -->|Telegram deep-link| link["TorrowAuth.loginWithLink"]
18
+ kind -->|токены от хоста| fromTok["TorrowAuth.fromTokens"]
19
+ kind -->|без входа| anon["TorrowAuth.anonymous"]
20
+ connect --> handle["TorrowAuthResult: client + session"]
21
+ login --> handle
22
+ link --> handle
23
+ fromTok --> handle
24
+ anon --> clientOnly["TorrowClient"]
25
+ ```
26
+
27
+ Флоу / подсистема для агентов: **`docs/agents/auth-flow.md`**. Индекс примеров Node: **`examples/node/auth/README.md`**, **`examples/node/_shared/README.md`**.
28
+
29
+ ## Целевой клиентский код
30
+
31
+ ### Пошаговый OTP без пользовательской сессии
32
+
33
+ `TorrowAuth.createOtpFlow` выполняет один `sendCode` на start и один `requestToken` на complete. Он не хранит challenge или токены, не требует TokenStorage/callbacks, не делает polling, refresh, retry или revoke. Для полного входа с callbacks и сохранением используйте `loginWithOtp`.
34
+
35
+ ```mermaid
36
+ sequenceDiagram
37
+ actor User as Пользователь
38
+ participant App as Приложение
39
+ participant Flow as OtpFlow (@torrow/sdk-auth)
40
+ participant API as Torrow API
41
+
42
+ App->>Flow: createOtpFlow(options)
43
+ App->>Flow: start({ account, ... }, { signal })
44
+ Flow->>API: POST /auth/sendcode (Basic приложения)
45
+ API-->>Flow: SendCodeResp
46
+ Flow-->>App: OtpChallenge
47
+ Note over App,Flow: Classic — AwaitingCode, Mobile ID — Pending.<br/>Challenge не содержит токенов или OTP.
48
+ App->>App: Защитить и перенести challenge через JSON
49
+ Note over App,Flow: complete можно вызвать на новом экземпляре flow.<br/>deviceInfo, scopes и splitTokens сохраняет приложение.
50
+
51
+ loop Пока нет TokenReady / Failed и challenge не просрочен
52
+ alt challenge.phase = AwaitingCode
53
+ App->>User: Запросить OTP / SMS-код
54
+ User-->>App: code
55
+ App->>Flow: complete({ challenge, code }, { signal })
56
+ else challenge.phase = Pending
57
+ App->>App: При повторном polling дождаться интервала
58
+ App->>Flow: complete({ challenge }, { signal })
59
+ end
60
+ Note right of Flow: Проверить challenge до HTTP.<br/>Pending — wire code пустой.
61
+ Flow->>API: POST /auth/token (Basic приложения)
62
+ API-->>Flow: TokenResp
63
+ alt status = Pending
64
+ Flow-->>App: Pending + challenge (phase = Pending)<br/>+ cleanup, если есть refresh
65
+ else status = AwaitingSmsCode
66
+ Flow-->>App: SMS fallback той же Mobile ID сессии<br/>AwaitingSmsCode + challenge<br/>(новая phase = AwaitingCode, usedMethod = Sms)<br/>+ cleanup, если есть refresh
67
+ else status = TokenReady
68
+ Flow-->>App: TokenReady + tokens
69
+ App->>App: Использовать токены и решить вопрос хранения / cleanup
70
+ else status = Failed
71
+ Flow-->>App: Failed + cleanup, если есть refresh
72
+ end
73
+ end
74
+
75
+ Note over App,Flow: expiresAtMs не продлевается между шагами.<br/>Ошибки передаются приложению, скрытых повторов нет.<br/>Cleanup выполняет приложение с независимым AbortSignal.
76
+ ```
77
+
78
+ ```ts
79
+ import { TorrowAuth, OtpFlowError, type OtpChallenge } from "@torrow/sdk-auth";
80
+ import { TokenStatusValues } from "@torrow/sdk-client";
81
+
82
+ const options = {
83
+ deviceInfo: { deviceId: "my-app-device", deviceType: "Desktop" as const },
84
+ scopes: ["user_api" as const],
85
+ splitTokens: false,
86
+ };
87
+ const challenge = await TorrowAuth.createOtpFlow(options).start({
88
+ account: { accountType: "Email", accountName: "user@example.com" },
89
+ });
90
+ // Между запросами приложение защищает целостность этого JSON.
91
+ const continuation: OtpChallenge = JSON.parse(JSON.stringify(challenge));
92
+ try {
93
+ const result = await TorrowAuth.createOtpFlow(options).complete(
94
+ { challenge: continuation, code: "123456" },
95
+ { signal: AbortSignal.timeout(15000) },
96
+ );
97
+ if (result.status === TokenStatusValues.TokenReady) {
98
+ // Использовать result.tokens.accessToken в рамках текущего запроса.
99
+ // Сохранение/cleanup result.tokens.refreshToken определяет приложение.
100
+ } else {
101
+ // result.cleanup?.refreshToken доступен для отдельного cleanup.
102
+ if ("challenge" in result) {
103
+ // Защитить result.challenge для следующего шага.
104
+ }
105
+ }
106
+ } catch (error) {
107
+ if (error instanceof OtpFlowError) {
108
+ // error.code — безопасная причина; error.cleanup?.refreshToken — секрет для cleanup.
109
+ // Cleanup выполняет приложение с независимым AbortSignal.
110
+ }
111
+ throw error;
112
+ }
113
+ ```
114
+
115
+ | Значение | Действие приложения |
116
+ |---|---|
117
+ | `challenge.phase: AwaitingCode` | Для следующего шага требуется непустой код. Это первоначальный ввод при обычном OTP либо ввод SMS-кода после fallback в Mobile ID-сценарии. |
118
+ | `challenge.phase: Pending` | Продолжается ожидание результата Mobile ID. Следующий complete вызывается без кода; wire code будет пустым. |
119
+ | `result.status: Pending` | Сервер ещё не завершил аутентификацию. SDK возвращает challenge с фазой Pending; приложение планирует следующий запрос без кода. Возможен возврат к Pending после отправки SMS-кода. |
120
+ | `result.status: AwaitingSmsCode` | Сервер перевёл Mobile ID-сценарий на SMS fallback, поскольку аутентификацию через Mobile ID не удалось завершить. SDK возвращает challenge с новой фазой AwaitingCode и методом Sms; приложение запрашивает SMS-код и передаёт его в следующий complete. |
121
+ | `result.status: Failed` | Текущая попытка аутентификации завершилась отказом. Challenge для продолжения не возвращается; новый вход требует отдельного start. |
122
+ | `result.status: TokenReady` | Аутентификация завершена, токены получены и проверены SDK. Продолжение не требуется; SDK не сохраняет токены. |
123
+
124
+ `result.status` отражает серверный `TokenStatus`, а `challenge.phase` определяет следующий шаг приложения. Переход при fallback: ожидание Mobile ID (`Pending`) → серверный `AwaitingSmsCode` → ввод SMS-кода (новая фаза `AwaitingCode`, метод `Sms`). Это продолжение той же auth-сессии с новым способом подтверждения, а не продолжение фазы AwaitingCode: sessionId и deadline сохраняются, фаза меняется. При обычном Email/SMS/FlashCall/TelegramGateway-входе `start` сразу возвращает `AwaitingCode` с фактическим `usedMethod`: ввод кода требуется до первого `complete`. Статуса `AwaitingEmailCode` в текущем API нет. Предпочтительный метод `preferredMethod` является пожеланием; фактический метод определяет сервер в `usedMethod`.
125
+
126
+ Опции flow: `baseUrl`, `fetch`, `defaultHeaders`, `userAgent`, `appCredentials`, обязательный `deviceInfo`, `scopes`, `splitTokens`. SDK-default Basic и scopes такие же, как в сессии; неуказанный splitTokens не отправляется. Start принимает язык (default ru), регион (70), captcha/type, invite session и preferred/supported methods. Email/UnknownMethod поддерживается; неизвестный метод отклоняется.
127
+
128
+ **Когда использовать `splitTokens`.** Это параметр `POST /api/v1/auth/token`, который обрабатывает сервер: SDK передаёт флаг и возвращает полученные поля токенов без самостоятельного разделения или восстановления.
129
+
130
+ | Значение | Поведение сервера и сценарий |
131
+ |---|---|
132
+ | `false` | Возвращает полные access/refresh в JSON и удаляет cookies ранее разделённых токенов. Используйте для Node, MCP и приложений, которым нужны полные токены для API-вызовов, выпуска интеграционного ключа и cleanup. |
133
+ | `true` | При наличии access token разделяет access/refresh: одну часть каждого токена помещает в HttpOnly cookies, другую возвращает в JSON. Используйте только для браузерного приложения, специально настроенного на этот режим авторизации. При Mobile ID без готовых токенов разделение пропускается. |
134
+ | Не задано | Flow не отправляет поле. В текущей реализации шлюза отсутствующий boolean имеет значение `false`; для явно выбранного режима полных токенов задавайте `false`. |
135
+
136
+ В режиме `true` поля `result.tokens` содержат части токенов, а не самостоятельные полные Bearer/refresh-токены. Последующие запросы должны передавать необходимые части и cookies для восстановления токенов шлюзом. Приложение обеспечивает работу с cookies, включая `credentials` у `fetch` и разрешения CORS при запросах между origin. SDK не добавляет cookie jar или управление cookies; стандартный Node `fetch` не сохраняет и не передаёт их автоматически между запросами. Поэтому Node-примеры задают `splitTokens: false`. Тесты SDK проверяют передачу обоих значений в HTTP-тело, а не серверное разделение или браузерную работу cookies.
137
+
138
+ Challenge: `sessionId`, `accountType`, `usedMethod`, `phase`, `expiresAtMs` и проверенные подсказки `codeLength`, `pollIntervalSeconds`, `attemptsRemaining`, `nextSendAtMs`, `availableMethods`. Даты — Unix milliseconds, пригодные для JSON. Токенов/OTP в challenge нет. Проверка структуры SDK не заменяет проверку целостности приложением. Между шагами приложение сохраняет тот же deviceInfo, scopes и splitTokens; flow не связывает их криптографически с challenge.
139
+
140
+ Срок отсчитывается от начала start: серверный sessionLifeTimeInSeconds либо локальные 180 секунд при отсутствии/null. Некорректный TTL отклоняется. Локальный срок не гарантирует действие сессии на сервере и не продлевается. Ограничение 600 секунд относится к MCP. Polling hint default — 3 секунды. Некорректные необязательные подсказки пропускаются; длина кода — корректная серверная либо Email 6, Phone/SMS fallback 4. Число попыток не уменьшается самостоятельно.
141
+
142
+ Перед проверкой token-ответа нормализуются существующие aliases: AccessToken/access_token, RefreshToken/refresh_token, Expiration/expires_in, Status. Приоритет: имя OpenAPI → PascalCase → snake_case по первому значению, отличному от null/undefined. Flow требует явный известный status и непустой access token при TokenReady. Старые методы сохраняют ответы без status, только с refresh token и прежние callback-поля через внутреннюю legacy-адаптацию общей реализации.
143
+
144
+ `OtpFlowError.code`: InvalidArguments, InvalidChallenge, InvalidResponse, UnsupportedMethod. Getter `error.cleanup` передаёт обнаруженный refresh даже при повреждённом ответе; он не включён в обычную сериализацию/диагностику ошибки. Валидные незавершённые/Failed ответы передают refresh через result.cleanup. HTTP-ошибки остаются TorrowHttpError, сеть и отмена пробрасываются. Потеря ответа не разрешает слепой повтор. Signal передаётся до fetch на обоих шагах; некорректный/просроченный challenge отклоняется до upstream.
145
+
146
+ Примеры: auth-otp-flow.ts (JSON и новый flow), auth-otp-flow-mobile-id.ts (polling приложения и SMS fallback). Выпуск интеграционного ключа, подтверждение identity и cleanup выполняются приложением.
147
+
148
+ Общая база `{ tokenStorage, deviceInfo }` (+ опционально `appCredentials`) у режимов с сессией.
149
+ Без `appCredentials` — **`DEFAULT_APP_CREDENTIALS`** (`sdk.torrow.net`). Своя пара перекрывает дефолт.
150
+
151
+ ### 1. Restore + OTP при необходимости
152
+
153
+ ```ts
154
+ import { TorrowAuth } from "@torrow/sdk-auth";
155
+ import { FileTokenStorage } from "@torrow/sdk-auth/node";
156
+ import { DeviceTypeValues } from "@torrow/sdk-client";
157
+
158
+ const base = {
159
+ // appCredentials опционален — иначе DEFAULT_APP_CREDENTIALS
160
+ tokenStorage: new FileTokenStorage("./.torrow-session.json"),
161
+ deviceInfo: { deviceId: "pos-terminal-1", deviceType: DeviceTypeValues.Desktop },
162
+ userAgent: "my-app/1.0",
163
+ };
164
+
165
+ let { client, session } = await TorrowAuth.connect(base);
166
+
167
+ if (session.status !== "authenticated") {
168
+ ({ client, session } = await TorrowAuth.loginWithOtp({
169
+ ...base,
170
+ account: { accountType: "Phone", accountName: "+79001234567" },
171
+ onSendCode: (d) => showHint(d.usedMethod, d.codeLength),
172
+ getCode: (ctx) => promptUser(ctx.codeLength),
173
+ }));
174
+ }
175
+
176
+ await client.profiles.list();
177
+ ```
178
+
179
+ Чтобы задать свои креды приложения:
180
+
181
+ ```ts
182
+ appCredentials: { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET },
183
+ ```
184
+
185
+ ### 2. Mobile ID / смена канала / rate-limit
186
+
187
+ ```ts
188
+ import { OtpCodeRequestLimitExceededError } from "@torrow/sdk-auth";
189
+
190
+ try {
191
+ await TorrowAuth.loginWithOtp({
192
+ ...base,
193
+ account: { accountType: "Phone", accountName: phone },
194
+ supportedMethods: ["MtsMobileId", "Sms"],
195
+ preferredMethod: userPickedMethod,
196
+ onSendCode: (d) => {
197
+ // MtsMobileId → UI «подтвердите push»; getCode не вызовется на happy-path
198
+ renderLoginUi(d);
199
+ },
200
+ getCode: (ctx) => waitCodeInput(ctx),
201
+ });
202
+ } catch (e) {
203
+ if (e instanceof OtpCodeRequestLimitExceededError) {
204
+ showRetryTimer(e.minutes, e.seconds);
205
+ } else {
206
+ throw e;
207
+ }
208
+ }
209
+ ```
210
+
211
+ ### 3. Telegram deep-link
212
+
213
+ ```ts
214
+ const { client, session } = await TorrowAuth.loginWithLink({
215
+ ...base,
216
+ onLink: (authUrl) => showQrOrOpen(authUrl),
217
+ signal: abortController.signal,
218
+ });
219
+ ```
220
+
221
+ ### 4. Виджет: токены от хоста
222
+
223
+ ```ts
224
+ import { TorrowAuth, LocalStorageTokenStorage } from "@torrow/sdk-auth";
225
+
226
+ const { client, session } = await TorrowAuth.fromTokens({
227
+ accessToken: hostProvidedAccess,
228
+ refreshToken: hostProvidedRefresh,
229
+ appCredentials: hostProvidedAppCreds,
230
+ tokenStorage: new LocalStorageTokenStorage("torrow.widget.session"),
231
+ deviceInfo: { deviceId: widgetDeviceId(), deviceType: DeviceTypeValues.Web },
232
+ });
233
+
234
+ session.onStatusChange((s) => {
235
+ if (s !== "authenticated") widgetUi.showLoginPrompt();
236
+ });
237
+ ```
238
+
239
+ ### 5. Аноним и выход
240
+
241
+ ```ts
242
+ const publicClient = TorrowAuth.anonymous({ userAgent: "my-widget" });
243
+
244
+ await session.logout(); // revoke refresh + clear storage + onStatusChange("unauthenticated")
245
+ ```
246
+
247
+ ## Сигналы UI
248
+
249
+ | Сигнал от SDK | Что делает App |
250
+ |---------------|----------------|
251
+ | **`onSendCode({ usedMethod: Sms, codeLength, … })`** | Форма/подсказка «код в SMS»; длина поля = `codeLength` |
252
+ | **`onSendCode({ usedMethod: TelegramGateway, codeLength })`** | Форма «код в Telegram» (**не** deep-link) |
253
+ | **`onSendCode({ usedMethod: FlashCall, codeLength })`** | Подсказка «цифры из звонка» |
254
+ | **`onSendCode({ accountType: Email, codeLength })`** | Форма «код на email» |
255
+ | **`onSendCode({ usedMethod: MtsMobileId })`** | Ожидание push **без** keypad (`codeLength` часто `0`) |
256
+ | **`getCode({ codeLength })`** | Дождаться ввода; валидировать по `codeLength` (экран уже из `onSendCode`) |
257
+ | **`onLink(authUrl)`** (`loginWithLink`) | Показать QR / открыть Telegram; **код OTP не вводить** |
258
+ | **`session.onStatusChange`** | Реактивный login-state (`authenticated` / `unauthenticated` / `expired`) |
259
+
260
+ ## Sequence: OTP (`loginWithOtp`)
261
+
262
+ ```mermaid
263
+ sequenceDiagram
264
+ participant User as User
265
+ participant App
266
+ participant SdkAuth as sdk_auth
267
+ participant Auth as client.auth
268
+
269
+ User->>App: телефон или email
270
+ App->>SdkAuth: TorrowAuth.loginWithOtp
271
+ SdkAuth->>Auth: sendCode
272
+ Auth-->>SdkAuth: usedMethod sessionId codeLength
273
+ SdkAuth->>App: onSendCode account usedMethod codeLength
274
+
275
+ alt usedMethod MtsMobileId
276
+ Note over App,User: onSendCode MtsMobileId без keypad
277
+ App->>User: UI ожидание push
278
+ loop пока Pending
279
+ SdkAuth->>Auth: requestToken code empty
280
+ Auth-->>SdkAuth: status Pending
281
+ end
282
+ opt status AwaitingSmsCode
283
+ Note over SdkAuth,App: показать SMS-форму
284
+ SdkAuth->>App: getCode codeLength
285
+ App->>User: форма SMS-кода
286
+ User->>App: ввод SMS-кода
287
+ App-->>SdkAuth: code
288
+ SdkAuth->>Auth: requestToken with code
289
+ end
290
+ else classic Sms FlashCall TelegramGateway Email
291
+ Note over App,User: onSendCode usedMethod в т.ч. TelegramGateway
292
+ App->>User: форма или подсказка под usedMethod
293
+ SdkAuth->>App: getCode codeLength
294
+ User->>App: ввод кода
295
+ App-->>SdkAuth: code
296
+ SdkAuth->>Auth: requestToken with code
297
+ end
298
+
299
+ Auth-->>SdkAuth: TokenReady
300
+ SdkAuth->>SdkAuth: persist TokenStorage
301
+ SdkAuth-->>App: client plus session
302
+ App->>User: вход выполнен
303
+ ```
304
+
305
+ ## Sequence: Telegram (`loginWithLink`)
306
+
307
+ ```mermaid
308
+ sequenceDiagram
309
+ participant User as User
310
+ participant App
311
+ participant SdkAuth as sdk_auth
312
+ participant Auth as client.auth
313
+ participant Bot as TelegramBot
314
+
315
+ User->>App: войти через Telegram
316
+ App->>SdkAuth: TorrowAuth.loginWithLink
317
+ SdkAuth->>Auth: createSession
318
+ Auth-->>SdkAuth: sessionId code authUrl
319
+ SdkAuth->>App: onLink authUrl
320
+ App->>User: QR или открыть authUrl
321
+ User->>Bot: Start
322
+ Bot->>Auth: VerifyAuthSession
323
+ loop пока не ready
324
+ SdkAuth->>Auth: requestToken sessionId code
325
+ Auth-->>SdkAuth: not ready или TokenReady
326
+ end
327
+ SdkAuth->>SdkAuth: persist TokenStorage
328
+ SdkAuth-->>App: client plus session
329
+ App->>User: вход выполнен
330
+ ```
331
+
332
+ ## Sequence: connect / refresh / 401 / logout
333
+
334
+ ```mermaid
335
+ sequenceDiagram
336
+ participant User as User
337
+ participant App
338
+ participant SdkAuth as sdk_auth
339
+ participant Api as TorrowAPI
340
+ participant Auth as client.auth
341
+
342
+ App->>SdkAuth: TorrowAuth.connect
343
+ SdkAuth->>SdkAuth: TokenStorage.load
344
+ alt access свежий
345
+ SdkAuth-->>App: client plus session authenticated
346
+ else есть refreshToken
347
+ SdkAuth->>Auth: refresh
348
+ Auth-->>SdkAuth: TokenReady
349
+ SdkAuth->>SdkAuth: persist
350
+ SdkAuth-->>App: client plus session authenticated
351
+ else нет токенов
352
+ SdkAuth-->>App: session unauthenticated
353
+ Note over App: далее loginWithOtp или loginWithLink
354
+ end
355
+
356
+ User->>App: действие API
357
+ App->>Api: Bearer request
358
+ alt 401 и refreshOnUnauthorized
359
+ Api-->>SdkAuth: 401
360
+ SdkAuth->>Auth: refresh
361
+ Auth-->>SdkAuth: TokenReady
362
+ SdkAuth->>Api: retry once
363
+ Api-->>App: 200
364
+ else access истекает по skew
365
+ SdkAuth->>Auth: proactive refresh
366
+ Auth-->>SdkAuth: TokenReady
367
+ end
368
+
369
+ opt logout
370
+ App->>SdkAuth: session.logout
371
+ SdkAuth->>Auth: revoke
372
+ SdkAuth->>SdkAuth: clear TokenStorage
373
+ SdkAuth->>App: onStatusChange unauthenticated
374
+ end
375
+ ```
376
+
377
+ ## Sequence: fromTokens + onStatusChange
378
+
379
+ ```mermaid
380
+ sequenceDiagram
381
+ participant Host as HostApp
382
+ participant App as Widget
383
+ participant SdkAuth as sdk_auth
384
+
385
+ Host->>App: accessToken refreshToken optional
386
+ App->>SdkAuth: TorrowAuth.fromTokens
387
+ SdkAuth->>SdkAuth: save to TokenStorage
388
+ SdkAuth-->>App: client plus session
389
+ SdkAuth->>App: onStatusChange authenticated
390
+ Note over App,SdkAuth: дальше как connect refresh 401
391
+ opt access истёк без refresh или logout
392
+ SdkAuth->>App: onStatusChange expired or unauthenticated
393
+ App->>Host: запросить новый логин
394
+ end
395
+ ```
396
+
397
+ ## Storage
398
+
399
+ | Класс | Назначение |
400
+ |-------|------------|
401
+ | **`MemoryTokenStorage`** | Тесты, эфемерные процессы, без диска |
402
+ | **`FileTokenStorage`** | Node-only (`node:fs`): JSON-файл — **`@torrow/sdk-auth/node`** (канон для `examples/node/**`) |
403
+ | **`LocalStorageTokenStorage`** | Браузер / виджет: `localStorage` |
404
+ | custom **`TokenStorage`** | IndexedDB, SecureStore, … — `load` / `save` снаружи |
405
+
406
+ **Node vs browser:** главный вход `@torrow/sdk-auth` browser-safe. `FileTokenStorage` — только `import { FileTokenStorage } from "@torrow/sdk-auth/node"` (тянет `node:fs`).
407
+
408
+ **JWT vs integration key:** user JWT обычно с `exp` в payload; opaque/integration key — строка без JWT-формы.
409
+
410
+ - **Opaque (non-JWT-shaped):** меньше 3 сегментов через точку (не `a.b.c`); host TTL через `TorrowAuth.fromTokens({ accessExpiresAtMs })` или бессрочно без TTL
411
+ - **JWT-shaped undecodable (`a.b.c`):** `isAccessStillFresh` всегда false → proactive refresh на `open` / `ensureFresh`, пока есть `refreshToken`; после refresh **без** `expiration` merge сбрасывает snapshot TTL → opaque unlimited → дальнейший proactive refresh не идёт (не refresh-storm)
412
+ - **Valid JWT:** runtime freshness AND-ит `exp`; будущий explicit `accessExpiresAtMs` не оживит просроченный JWT
413
+ - **Opaque host TTL в skew-окне** (`accessTokenSkewMs`, default 30s): refresh без `expiration` сбрасывает snapshot TTL → бессрочный opaque до 401 (лучше refresh-storm)
414
+
415
+ Host: non-JWT-shaped opaque **или** валидный JWT с `exp`.
416
+
417
+ Канон examples: `new FileTokenStorage(path)` + `TorrowAuth.connect` / `loginWithOtp` — без ручного dual deviceInfo provider.
418
+
419
+ ## API (кратко)
420
+
421
+ | API | Роль |
422
+ |-----|------|
423
+ | **`TorrowAuth.connect`** | restore / refresh → `{ client, session }`; **без** OTP |
424
+ | **`TorrowAuth.loginWithOtp`** | sendCode → Mobile ID / classic → persist |
425
+ | **`TorrowAuth.loginWithLink`** | Telegram deep-link → poll → persist |
426
+ | **`TorrowAuth.fromTokens`** | сид access[/refresh] → connect-семантика |
427
+ | **`TorrowAuth.anonymous`** | `TorrowClient` без Bearer |
428
+ | **`TokenSession`** | `status`, `onStatusChange`, `logout`, `requestTokenWithCode`, `refresh`, `client` |
429
+ | **`refreshOnUnauthorized`** | 401 → refresh → один retry (**default true**) |
430
+ | **`onRefreshFailure`** | optional; политика при ошибке refresh (`keep` / `clear` / `throw`); default: non-HTTP / network → `keep`; HTTP 429/502–504 → `keep`; other HTTP → `clear` |
431
+
432
+ `appCredentials: { clientId, clientSecret }` (опционально; иначе **`DEFAULT_APP_CREDENTIALS`**)
433
+ внутри auth-пакета мапится на **`AuthIdentityBasic`**
434
+ (`username`/`password`) → **`TorrowClientConfig.authIdentityBasic`**. Маппер **не** публичный API:
435
+ приложениям достаточно **`appCredentials`** или дефолта; low-level `TorrowClient` собирает Basic сам
436
+ (см. **`examples/node/auth/auth-send-code.ts`**).
437
+
438
+ **401-retry** вызывает только **refresh**, не OTP.