Files
pipu-music/packages/core/src/auth.ts
T
mrleo1nidandClaude Opus 5.5 be34ee5361
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m7s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Canceled after 4m15s
ci / ts / ts (packages + web) (push) Canceled after 17m42s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 18m52s
feat(mobile): offline downloads, persistent event queue and offline start (M5)
- Downloads queue (2 parallel, Wi-Fi only by default) stores files under
  the app's document directory and track metadata in SQLite; Collection
  gets a Downloads tab, album and player screens get download buttons.
- Player plays downloaded files and local covers; My Wave falls back to
  a shuffle of downloaded tracks when the server is unreachable.
- Playback events that fail on the network are kept in SQLite and sent
  when the connection returns.
- AuthSession can open offline with the cached user and fetches an
  access token before the first request once online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:02:47 +03:00

240 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createApiClient, type ApiClient, type components } from '@pipu/api-client';
export type AuthUser = components['schemas']['Me'];
export type AuthTokens = components['schemas']['AuthTokens'];
export type ClientType = components['schemas']['ClientType'];
/** Ошибка API с машинным кодом (problem+json). Текст для пользователя — перевод `errors:<code>`. */
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly params: Record<string, unknown>;
readonly fields: { field: string; code: string }[];
constructor(status: number, code: string, params: Record<string, unknown> = {}, fields: ApiError['fields'] = []) {
super(code);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.params = params;
this.fields = fields;
}
/** Код ошибки конкретного поля формы, если сервер его указал. */
fieldCode(field: string): string | undefined {
return this.fields.find((f) => f.field === field)?.code;
}
}
/** Превращает ответ openapi-fetch с ошибкой в ApiError. Сетевые сбои — код `common.network`. */
export function toApiError(error: unknown, response?: Response): ApiError {
if (error instanceof ApiError) return error;
const p = (error ?? {}) as Partial<components['schemas']['Problem']>;
if (typeof p.code === 'string') {
return new ApiError(p.status ?? response?.status ?? 0, p.code, p.params ?? {}, p.errors ?? []);
}
if (response) return new ApiError(response.status, response.status >= 500 ? 'common.internal' : 'common.bad_request');
return new ApiError(0, 'common.network');
}
/** Достаёт данные из результата openapi-fetch или бросает ApiError. */
export function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): T {
if (res.error !== undefined || !res.response.ok) throw toApiError(res.error, res.response);
return res.data as T;
}
/** Хранилище refresh-токена для клиентов без cookie (Android: expo-secure-store). */
export interface RefreshStore {
get(): Promise<string | null>;
set(token: string): Promise<void>;
clear(): Promise<void>;
/** Последний вошедший пользователь — чтобы открыть приложение без сети (Android, офлайн-режим). */
getUser?(): Promise<AuthUser | null>;
setUser?(user: AuthUser): Promise<void>;
}
export type AuthState =
| { status: 'unknown' }
| { status: 'signed_out'; reason?: string }
/** offline — сервер недоступен при запуске, пользователь взят из RefreshStore; токен обновится при первом запросе. */
| { status: 'signed_in'; user: AuthUser; offline?: boolean };
export interface AuthSessionOptions {
/** Адрес сервера; пусто — тот же origin (веб). */
baseUrl?: string;
fetch?: typeof globalThis.fetch;
clientType: ClientType;
deviceName?: string;
/** Обязателен для android: веб хранит refresh в HttpOnly-cookie. */
refreshStore?: RefreshStore;
}
/** Коды 401, после которых имеет смысл обновить access-токен и повторить запрос. */
const RETRYABLE = new Set(['auth.token_expired', 'auth.token_invalid', 'auth.token_revoked']);
const RACE_RETRY_MS = 300;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Сессия пользователя: access-токен в памяти, refresh — в cookie (веб) или RefreshStore (Android).
* `client` сам подставляет Bearer и при истёкшем токене делает **один** общий refresh на все
* параллельные запросы (single-flight), после чего повторяет их.
*/
export class AuthSession {
readonly client: ApiClient;
private readonly raw: ApiClient;
private readonly opts: AuthSessionOptions;
private readonly baseFetch: typeof globalThis.fetch;
private access: string | null = null;
private refreshing: Promise<boolean> | null = null;
private networkDown = false;
private current: AuthState = { status: 'unknown' };
private readonly listeners = new Set<(s: AuthState) => void>();
constructor(opts: AuthSessionOptions) {
if (opts.clientType === 'android' && !opts.refreshStore) {
throw new Error('AuthSession: android client needs a refreshStore');
}
this.opts = opts;
this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
this.raw = createApiClient({ baseUrl: opts.baseUrl, fetch: this.baseFetch });
this.client = createApiClient({ baseUrl: opts.baseUrl, fetch: (req) => this.authFetch(req as Request) });
}
get state(): AuthState {
return this.current;
}
/** Текущий access-токен — для запросов мимо `client` (загрузка файлов на Android). */
get accessToken(): string | null {
return this.access;
}
/** Подписка на смену состояния; возвращает функцию отписки. */
subscribe(fn: (s: AuthState) => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private setState(s: AuthState) {
this.current = s;
for (const fn of this.listeners) fn(s);
}
/** Восстанавливает сессию после перезагрузки по refresh-токену. */
async restore(): Promise<AuthState> {
if (!(await this.refresh())) {
const user = this.networkDown ? await this.opts.refreshStore?.getUser?.() : null;
if (user) this.setState({ status: 'signed_in', user, offline: true });
else if (this.current.status !== 'signed_out') this.setState({ status: 'signed_out' });
}
return this.current;
}
async login(username: string, password: string): Promise<AuthUser> {
const res = await this.raw.POST('/api/v1/auth/login', {
body: { username, password, client_type: this.opts.clientType, device_name: this.opts.deviceName },
});
return this.apply(unwrap(res));
}
/** Принимает новую пару токенов (вход, смена пароля, смена учётных данных). */
async apply(tokens: AuthTokens): Promise<AuthUser> {
this.access = tokens.access_token;
if (tokens.refresh_token && this.opts.refreshStore) await this.opts.refreshStore.set(tokens.refresh_token);
await this.opts.refreshStore?.setUser?.(tokens.user);
this.setState({ status: 'signed_in', user: tokens.user });
return tokens.user;
}
/** Обновляет профиль в состоянии (после PATCH /me). */
setUser(user: AuthUser) {
if (this.current.status === 'signed_in') {
this.setState({ ...this.current, user });
void this.opts.refreshStore?.setUser?.(user);
}
}
async logout(all = false): Promise<void> {
try {
if (this.access) await this.client.POST(all ? '/api/v1/auth/logout-all' : '/api/v1/auth/logout');
} finally {
await this.signOut();
}
}
private async signOut(reason?: string) {
this.access = null;
await this.opts.refreshStore?.clear();
this.setState({ status: 'signed_out', reason });
}
/** Single-flight: параллельные вызовы ждут один и тот же запрос. */
refresh(): Promise<boolean> {
this.refreshing ??= this.doRefresh().finally(() => {
this.refreshing = null;
});
return this.refreshing;
}
private async doRefresh(attempt = 0): Promise<boolean> {
let body: { refresh_token?: string } | undefined;
if (this.opts.refreshStore) {
const token = await this.opts.refreshStore.get();
if (!token) return false;
body = { refresh_token: token };
}
let res;
try {
res = await this.raw.POST('/api/v1/auth/refresh', { body });
this.networkDown = false;
} catch {
this.networkDown = true;
return false; // сеть недоступна: сессию не трогаем, следующий запрос попробует снова
}
if (res.data) {
await this.apply(res.data);
return true;
}
const err = toApiError(res.error, res.response);
if (err.code === 'auth.refresh_race' && attempt === 0) {
// Другая вкладка только что обновила cookie — повторяем уже с новой.
await sleep(RACE_RETRY_MS);
return this.doRefresh(attempt + 1);
}
if (res.response.status === 401 || res.response.status === 403) await this.signOut(err.code);
return false;
}
private async authFetch(request: Request): Promise<Response> {
// Запущены без сети (offline): access-токена ещё нет — получаем его перед первым запросом.
if (!this.access && this.opts.refreshStore && this.current.status === 'signed_in') await this.refresh();
const retry = request.clone();
const sent = this.access;
const res = await this.baseFetch(this.withAuth(request));
if (res.status !== 401) return res;
const code = await res
.clone()
.json()
.then((p: { code?: string }) => p.code)
.catch(() => undefined);
if (!code || !RETRYABLE.has(code)) {
if (code === 'auth.user_blocked') await this.signOut(code);
return res;
}
// Пока запрос был в пути, токен уже обновил кто-то другой — повторяем без новой ротации.
const fresh = this.access !== null && this.access !== sent;
if (!fresh && !(await this.refresh())) return res;
return this.baseFetch(this.withAuth(retry));
}
private withAuth(request: Request): Request {
if (!this.access) return request;
const headers = new Headers(request.headers);
headers.set('Authorization', `Bearer ${this.access}`);
return new Request(request, { headers });
}
}