Files
IdP/apps/frontend/lib/api.ts
2026-06-30 11:28:10 +03:00

1509 lines
45 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.
const API_ORIGIN_PROXY_PREFIX = '/idp-api';
const configuredApiUrl = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000').replace(/\/$/, '');
const configuredWsUrl = (process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws').replace(/\/$/, '');
function getServerApiUrl(): string {
return (process.env.INTERNAL_API_URL ?? configuredApiUrl).replace(/\/$/, '');
}
/** Same-origin /idp-api только если PUBLIC API URL явно задан как прокси-путь на фронтенде. */
function usesSameOriginApiProxy(): boolean {
if (configuredApiUrl.startsWith('/')) {
return true;
}
return /\/idp-api\/?$/i.test(configuredApiUrl) || configuredApiUrl.includes('/idp-api/');
}
function usesSameOriginWsProxy(): boolean {
if (configuredWsUrl.startsWith('/')) {
return true;
}
return configuredWsUrl.includes('/idp-api/');
}
function isLocalDevApiUrl(url: string): boolean {
return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(url);
}
function resolveBrowserApiBaseUrl(): string {
// SSR: внутренний URL Docker-сети (api-gateway), не публичный домен.
if (typeof window === 'undefined') {
return getServerApiUrl();
}
if (configuredApiUrl.startsWith('/')) {
return configuredApiUrl.replace(/\/+$/, '') || API_ORIGIN_PROXY_PREFIX;
}
if (usesSameOriginApiProxy()) {
return API_ORIGIN_PROXY_PREFIX;
}
// Split-domain (SSO на sso.*, API на api.*): браузер SPA ходит same-origin через
// /idp-api на домене SSO. PUBLIC_API_URL=https://api.* остаётся для OAuth issuer и
// внешних клиентов — cross-origin fetch из SPA не нужен и ломается (CORS/502).
if (!isLocalDevApiUrl(configuredApiUrl)) {
return API_ORIGIN_PROXY_PREFIX;
}
return configuredApiUrl;
}
function resolveBrowserWsBaseUrl(): string {
if (typeof window === 'undefined') {
return configuredWsUrl;
}
if (usesSameOriginWsProxy()) {
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`;
}
if (!/^wss?:\/\/(localhost|127\.0\.0\.1)/i.test(configuredWsUrl)) {
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${scheme}//${window.location.host}${API_ORIGIN_PROXY_PREFIX}/ws`;
}
return configuredWsUrl;
}
export function getApiUrl(): string {
return resolveBrowserApiBaseUrl();
}
export function getWsUrl(): string {
return resolveBrowserWsBaseUrl();
}
/** @deprecated Используйте getApiUrl() — в браузере URL зависит от режима (split-domain или /idp-api) */
export const API_URL = configuredApiUrl;
export interface PublicUser {
id: string;
email?: string | null;
phone?: string | null;
backupEmail?: string | null;
backupPhone?: string | null;
displayName: string;
username?: string | null;
avatarUrl?: string | null;
hasAvatar?: boolean;
isSuperAdmin: boolean;
status: string;
roles?: string[];
permissions?: string[];
canAccessAdmin?: boolean;
canManageRoles?: boolean;
canViewOAuth?: boolean;
canManageOAuth?: boolean;
canManageAllOAuth?: boolean;
canManageUsers?: boolean;
canManageAllUsers?: boolean;
canManageSettings?: boolean;
canViewUsers?: boolean;
canViewUserDocuments?: boolean;
hasPassword?: boolean;
isVerified?: boolean;
verificationIcon?: string | null;
canVerifyUsers?: boolean;
canManageBots?: boolean;
}
export interface AdminUser {
id: string;
email?: string | null;
phone?: string | null;
displayName: string;
username?: string | null;
isSuperAdmin: boolean;
status: string;
createdAt: string;
roles: string[];
directPermissions?: string[];
isVerified?: boolean;
verificationIcon?: string | null;
isBot?: boolean;
linkedBotUsername?: string | null;
isSystemBot?: boolean;
}
export interface AdminRole {
id: string;
slug: string;
name: string;
description?: string;
isSystem?: boolean;
isDefault?: boolean;
permissions: { id: string; slug: string; name: string; description?: string }[];
}
export interface AdminPermission {
id: string;
slug: string;
name: string;
description?: string;
}
export interface OAuthScope {
id: string;
slug: string;
name: string;
description?: string;
}
export interface OAuthConsentScopeInfo {
slug: string;
name: string;
description?: string;
}
export interface OAuthConsentCheckResponse {
granted: boolean;
client?: {
clientId: string;
name: string;
};
requestedScopes?: OAuthConsentScopeInfo[];
}
export interface OAuthUserConsent {
id: string;
clientId: string;
clientName: string;
scopes: OAuthConsentScopeInfo[];
grantedAt: string;
updatedAt: string;
}
export interface OAuthClient {
id: string;
name: string;
clientId: string;
redirectUris: string[];
scopes: string[];
isActive: boolean;
type: string;
clientSecret?: string;
createdById?: string | null;
createdByDisplayName?: string | null;
canManage?: boolean;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresAt: string;
pinVerified: boolean;
user: PublicUser;
sessionId: string;
requiresTotp?: boolean;
totpChallengeToken?: string;
}
export interface UserProfileResponse {
birthDate?: string | null;
email?: string | null;
phone?: string | null;
backupEmail?: string | null;
backupPhone?: string | null;
displayName?: string;
firstName?: string | null;
lastName?: string | null;
}
export interface QrLoginSession {
sessionId: string;
status: 'PENDING' | 'APPROVED' | 'EXPIRED';
expiresAt: string;
qrPayload?: string;
auth?: AuthTokens & { user?: PublicUser };
}
export async function createQrLoginSession(deviceName?: string) {
const { buildAuthDevicePayload } = await import('@/lib/device-client');
const device = buildAuthDevicePayload();
return apiFetch<QrLoginSession>('/auth/advanced/qr/session', {
method: 'POST',
body: JSON.stringify({
...device,
deviceName: deviceName?.trim() || device.deviceName
})
});
}
export async function pollQrLoginSession(sessionId: string) {
return apiFetch<QrLoginSession>(`/auth/advanced/qr/session/${sessionId}`);
}
export interface TotpSetupResponse {
secret: string;
otpauthUrl: string;
}
export interface TotpStatusResponse {
isEnabled: boolean;
}
export interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
expiresAt?: string;
uploadToken?: string;
}
export async function uploadMediaObject(
presigned: PresignedUploadResponse,
file: Blob,
contentType: string
) {
if (presigned.uploadToken) {
const apiBase = getApiUrl().replace(/\/$/, '');
const formData = new FormData();
formData.append('file', file, file instanceof File ? file.name : 'upload.bin');
const response = await fetch(`${apiBase}/media/upload`, {
method: 'POST',
headers: { 'X-Upload-Token': presigned.uploadToken },
body: formData
});
if (!response.ok) {
throw new Error('Не удалось загрузить файл');
}
return;
}
const response = await fetch(presigned.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: file
});
if (!response.ok) {
throw new Error('Не удалось загрузить файл');
}
}
export interface FamilyInviteCandidate {
id: string;
displayName: string;
email?: string | null;
phone?: string | null;
username?: string | null;
hasAvatar?: boolean;
isVerified?: boolean;
verificationIcon?: string | null;
isBot?: boolean;
botUsername?: string;
botId?: string;
ownerDisplayName?: string;
}
export async function searchFamilyInviteUsers(groupId: string, query: string, token?: string | null) {
return apiFetch<{ users?: FamilyInviteCandidate[] }>(
`/family/groups/${groupId}/invite-search?q=${encodeURIComponent(query)}`,
{},
token
);
}
export interface PasswordlessAuthResponse {
requiresPassword: boolean;
requiresTotp?: boolean;
totpChallengeToken?: string;
tempAuthToken?: string;
auth?: AuthTokens;
}
export interface OtpChannel {
channel: 'email' | 'phone' | 'backupEmail' | 'backupPhone' | string;
masked: string;
}
export interface LoginMethod {
kind: 'password' | 'otp';
channel: string;
masked: string;
}
export interface IdentifyResponse {
exists: boolean;
hasPassword: boolean;
isPinEnabled: boolean;
isTotpEnabled?: boolean;
otpChannels?: OtpChannel[];
otpBackupChannels?: OtpChannel[];
methods?: LoginMethod[];
}
export interface BeginTotpLoginResponse {
totpChallengeToken: string;
}
export interface OtpSendResponse {
sent: boolean;
expiresAt: string;
maskedTarget: string;
resendAvailableAt?: string;
}
export interface PinVerificationResponse {
accessToken: string;
pinVerified: boolean;
}
export interface RefreshSessionResponse {
requiresPin: boolean;
accessToken?: string;
sessionId?: string;
pinVerified: boolean;
user?: PublicUser;
}
export interface AuthSessionResponse {
requiresPin: boolean;
sessionId?: string | null;
pinVerified: boolean;
user: PublicUser;
}
export const AUTH_TOKEN_KEY = 'lendry_access_token';
export const AUTH_REFRESH_KEY = 'lendry_refresh_token';
export const AUTH_SESSION_KEY = 'lendry_session_id';
export const AUTH_USER_CACHE_KEY = 'lendry_cached_user';
/** Актуальный access token из localStorage (не полагается на React state). */
export function getAccessToken(): string | null {
if (typeof window === 'undefined') return null;
const value = window.localStorage.getItem(AUTH_TOKEN_KEY)?.trim();
return value || null;
}
function resolveAccessToken(explicit?: string | null): string | null {
const trimmed = explicit?.trim();
if (trimmed) return trimmed;
return getAccessToken();
}
export class ApiError extends Error {
status: number;
code?: string;
sessionId?: string;
constructor(message: string, status: number, code?: string, sessionId?: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.sessionId = sessionId;
}
}
function mapFetchError(error: unknown): ApiError {
if (error instanceof TypeError) {
const message = error.message.toLowerCase();
if (message.includes('failed to fetch') || message.includes('networkerror')) {
return new ApiError(
'Нет связи с сервером. При самоподписанном HTTPS примите сертификат сайта или выполните ./install.sh --fix-proxy на сервере.',
0,
'NETWORK_ERROR'
);
}
}
if (error instanceof ApiError) {
return error;
}
return new ApiError(error instanceof Error ? error.message : 'Не удалось выполнить запрос', 0, 'UNKNOWN');
}
type PinRequiredHandler = (sessionId: string) => void;
export function setPinRequiredHandler(handler: PinRequiredHandler | null) {
pinRequiredHandler = handler;
if (!handler) {
pinNotificationSent = false;
}
}
interface ErrorBody {
message?: string | string[];
error?: string;
code?: string;
sessionId?: string;
}
async function parseErrorResponse(response: Response): Promise<ApiError> {
try {
const body = (await response.clone().json()) as ErrorBody;
const message = Array.isArray(body.message)
? body.message.join('\n')
: (body.message ?? body.error ?? 'Не удалось выполнить запрос');
const pinRequired =
body.code === 'PIN_REQUIRED' ||
(response.status === 403 && typeof message === 'string' && message.toLowerCase().includes('pin'));
return new ApiError(message, response.status, pinRequired ? 'PIN_REQUIRED' : body.code, body.sessionId);
} catch {
return new ApiError('Не удалось выполнить запрос', response.status);
}
}
export function isPinRequiredError(error: unknown): error is ApiError {
return error instanceof ApiError && error.code === 'PIN_REQUIRED';
}
export function isGatewayUnavailableError(error: unknown): boolean {
return error instanceof ApiError && [502, 503, 504].includes(error.status);
}
export function getApiErrorMessage(error: unknown, fallback: string): string | null {
if (isPinRequiredError(error)) return null;
if (isGatewayUnavailableError(error)) return null;
if (error instanceof ApiError && error.code === 'NO_TOKEN') return null;
if (error instanceof ApiError && error.status === 403 && error.message.toLowerCase().includes('pin')) {
return null;
}
return error instanceof Error ? error.message : fallback;
}
let pinRequiredHandler: PinRequiredHandler | null = null;
let pinNotificationSent = false;
export function resetPinRequiredNotification() {
pinNotificationSent = false;
}
function notifyPinRequired(sessionId: string) {
if (!pinRequiredHandler || pinNotificationSent) return;
pinNotificationSent = true;
pinRequiredHandler(sessionId);
}
export async function fetchAuthSession(token?: string | null): Promise<AuthSessionResponse> {
return apiFetch<AuthSessionResponse>('/auth/session', {}, token);
}
export async function approveOAuthAuthorization(params: URLSearchParams, token: string) {
const body = {
client_id: params.get('client_id') ?? params.get('clientId') ?? undefined,
redirect_uri: params.get('redirect_uri') ?? params.get('redirectUri') ?? undefined,
scope: params.get('scope') ?? 'openid profile',
state: params.get('state') ?? undefined,
nonce: params.get('nonce') ?? undefined
};
return apiFetch<{ redirectUrl?: string }>('/oauth/consent/approve', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}, token);
}
export async function checkOAuthConsent(params: URLSearchParams, token: string) {
const query = new URLSearchParams();
const clientId = params.get('client_id') ?? params.get('clientId');
const scope = params.get('scope') ?? 'openid profile';
if (clientId) query.set('client_id', clientId);
query.set('scope', scope);
return apiFetch<OAuthConsentCheckResponse>(`/oauth/consent/check?${query.toString()}`, {}, token);
}
export async function fetchOAuthClientPublicInfo(clientId: string) {
return apiFetch<{ clientId: string; name: string }>(`/oauth/clients/${encodeURIComponent(clientId)}/public`);
}
export async function fetchUserOAuthConsents(userId: string, token: string) {
return apiFetch<{ consents?: OAuthUserConsent[] }>(`/oauth/consents/users/${userId}`, {}, token);
}
export async function revokeOAuthConsent(userId: string, consentId: string, token: string) {
return apiFetch(`/oauth/consents/users/${userId}/${consentId}`, { method: 'DELETE' }, token);
}
export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
if (typeof window === 'undefined') {
throw new ApiError('Refresh token недоступен на сервере', 401, 'NO_REFRESH');
}
await ensureApiGatewayReady();
const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY);
const sessionId = window.localStorage.getItem(AUTH_SESSION_KEY);
if (!refreshToken) {
throw new ApiError('Refresh token отсутствует', 401, 'NO_REFRESH');
}
const response = await fetchWithGatewayRetry(`${getApiUrl()}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken, sessionId: sessionId ?? undefined })
});
if (!response.ok) {
throw await parseErrorResponse(response);
}
return response.json() as Promise<RefreshSessionResponse>;
}
export async function syncFedcmSession(accessToken?: string | null) {
const token = resolveAccessToken(accessToken);
if (typeof window === 'undefined' || !token) return;
if (!isApiGatewayReady()) {
await ensureApiGatewayReady();
}
try {
await apiFetch<{ synced: boolean }>(
'/fedcm/session/sync',
{
method: 'POST',
credentials: 'include'
},
token
);
} catch {
// фоновая синхронизация FedCM cookie
}
}
let fedcmSyncTimer: ReturnType<typeof setTimeout> | null = null;
let fedcmSyncInFlight = false;
/** Откладывает FedCM sync после OAuth/bootstrap, чтобы не конкурировать с consent/session. */
export function scheduleFedcmSessionSync(accessToken?: string | null, delayMs = 1500) {
if (typeof window === 'undefined') return;
if (window.location.pathname.startsWith('/auth/oauth/authorize')) return;
if (fedcmSyncTimer) {
clearTimeout(fedcmSyncTimer);
}
fedcmSyncTimer = setTimeout(() => {
fedcmSyncTimer = null;
if (fedcmSyncInFlight) return;
fedcmSyncInFlight = true;
void syncFedcmSession(accessToken).finally(() => {
fedcmSyncInFlight = false;
});
}, delayMs);
}
async function trySilentTokenRefresh(): Promise<RefreshSessionResponse | null> {
try {
const refreshed = await refreshAuthSession();
if (refreshed.requiresPin) {
notifyPinRequired(refreshed.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return refreshed;
}
if (refreshed.accessToken) {
window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken);
if (refreshed.sessionId) {
window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId);
}
if (refreshed.user) {
window.dispatchEvent(new CustomEvent('lendry:auth-refreshed', { detail: refreshed }));
}
}
return refreshed;
} catch {
return null;
}
}
export interface UserDocument {
id: string;
userId: string;
type: string;
number: string;
issuedAt?: string;
expiresAt?: string;
metadataJson?: string;
}
export interface SystemSetting {
id: string;
key: string;
value: string;
description?: string | null;
isSecret: boolean;
}
export interface SocialProvider {
id: string;
providerName: string;
clientId: string;
isEnabled: boolean;
}
export interface ActiveDevice {
id: string;
name: string;
type: string;
lastIp?: string | null;
lastSeenAt: string;
trusted: boolean;
}
export interface SignInEvent {
id: string;
success: boolean;
reason?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
deviceName?: string | null;
createdAt: string;
}
export interface ActiveSession {
id: string;
userId: string;
deviceId?: string | null;
deviceName?: string | null;
deviceType?: string | null;
pinVerified: boolean;
status: string;
ipAddress?: string | null;
userAgent?: string | null;
expiresAt: string;
createdAt: string;
updatedAt: string;
}
async function parseError(response: Response): Promise<string> {
const error = await parseErrorResponse(response);
return error.message;
}
export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | 'value' | 'description' | 'isSecret'>) {
return {
key: setting.key,
value: setting.value,
description: setting.description ?? undefined,
isSecret: setting.isSecret
};
}
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
const GATEWAY_RETRY_ATTEMPTS = 4;
const GATEWAY_PROBE_MAX_ATTEMPTS = 48;
const GATEWAY_PROBE_BASE_DELAY_MS = 450;
const GATEWAY_STABLE_PROBES = 3;
const API_MAX_CONCURRENT = 6;
const API_BURST_MAX_CONCURRENT = 2;
const API_BURST_WINDOW_MS = 5000;
const API_READY_EVENT = 'idp-api-ready';
const API_NOT_READY_EVENT = 'idp-api-not-ready';
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
let gatewayReadyResolved = false;
let gatewayReadyPromise: Promise<void> | null = null;
let gatewayReadyAt = 0;
let activeApiRequests = 0;
const apiRequestWaiters: Array<() => void> = [];
export function resetApiGatewayWarmup() {
gatewayReadyResolved = false;
gatewayReadyPromise = null;
}
export function invalidateApiGatewayReady() {
gatewayReadyResolved = false;
gatewayReadyAt = 0;
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(API_NOT_READY_EVENT));
}
}
export function isApiGatewayReady() {
return gatewayReadyResolved;
}
export function markApiGatewayReady() {
if (gatewayReadyResolved) return;
gatewayReadyResolved = true;
gatewayReadyAt = Date.now();
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(API_READY_EVENT));
}
}
export function subscribeApiReady(listener: () => void): () => void {
if (typeof window === 'undefined') return () => undefined;
if (gatewayReadyResolved) {
listener();
return () => undefined;
}
window.addEventListener(API_READY_EVENT, listener);
return () => window.removeEventListener(API_READY_EVENT, listener);
}
export function subscribeApiNotReady(listener: () => void): () => void {
if (typeof window === 'undefined') return () => undefined;
window.addEventListener(API_NOT_READY_EVENT, listener);
return () => window.removeEventListener(API_NOT_READY_EVENT, listener);
}
/** Несколько подряд успешных /health через nginx — защита от «один OK, потом 502». */
export async function waitForStableGateway(stableProbes = GATEWAY_STABLE_PROBES): Promise<void> {
if (typeof window === 'undefined') return;
gatewayReadyResolved = false;
gatewayReadyAt = 0;
let streak = 0;
for (let attempt = 0; attempt < 40 && streak < stableProbes; attempt += 1) {
if (await probeGatewayAlive()) {
streak += 1;
if (streak >= stableProbes) {
markApiGatewayReady();
return;
}
await sleep(400);
} else {
streak = 0;
await sleep(Math.min(500 + attempt * 220, 2200));
}
}
throw new ApiError('Сервер API временно недоступен. Попробуйте обновить страницу.', 503, 'GATEWAY_UNAVAILABLE');
}
async function acquireApiSlot(): Promise<void> {
const inBurst = gatewayReadyAt > 0 && Date.now() - gatewayReadyAt < API_BURST_WINDOW_MS;
const limit = inBurst ? API_BURST_MAX_CONCURRENT : API_MAX_CONCURRENT;
while (activeApiRequests >= limit) {
await new Promise<void>((resolve) => {
apiRequestWaiters.push(resolve);
});
}
activeApiRequests += 1;
}
function releaseApiSlot(): void {
activeApiRequests = Math.max(0, activeApiRequests - 1);
const next = apiRequestWaiters.shift();
if (next) next();
}
/** Liveness: nginx → api-gateway HTTP. */
async function probeGatewayAlive(): Promise<boolean> {
try {
const response = await fetch(`${getApiUrl()}/health`, {
method: 'GET',
cache: 'no-store',
signal: AbortSignal.timeout(4500)
});
return response.ok;
} catch {
return false;
}
}
async function waitForGatewayAlive(): Promise<void> {
if (gatewayReadyResolved) return;
for (let attempt = 0; attempt < GATEWAY_PROBE_MAX_ATTEMPTS; attempt += 1) {
if (await probeGatewayAlive()) {
markApiGatewayReady();
return;
}
if (attempt + 1 < GATEWAY_PROBE_MAX_ATTEMPTS) {
await sleep(Math.min(GATEWAY_PROBE_BASE_DELAY_MS + attempt * 280, 2800));
}
}
throw new ApiError('Сервер API временно недоступен. Попробуйте обновить страницу.', 503, 'GATEWAY_UNAVAILABLE');
}
/** Один общий прогрев: все apiFetch ждут успешный /health, без раннего «fail-open». */
export async function ensureApiGatewayReady(force = false): Promise<void> {
if (typeof window === 'undefined') return;
if (gatewayReadyResolved && !force) return;
if (gatewayReadyPromise) return gatewayReadyPromise;
if (force) {
gatewayReadyResolved = false;
}
gatewayReadyPromise = waitForGatewayAlive().finally(() => {
gatewayReadyPromise = null;
});
return gatewayReadyPromise;
}
function gatewayRetryDelay(attempt: number): number {
return 800 + attempt * 700;
}
/** Повтор при кратковременном 502/503/504 (nginx не достучался до api-gateway после перезапуска). */
async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Response> {
let lastNetworkError: unknown;
for (let attempt = 0; attempt < GATEWAY_RETRY_ATTEMPTS; attempt++) {
try {
const response = await fetch(url, init);
if (response.ok) {
markApiGatewayReady();
}
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
invalidateApiGatewayReady();
try {
await waitForStableGateway(2);
} catch {
await sleep(gatewayRetryDelay(attempt));
}
continue;
}
return response;
} catch (error) {
lastNetworkError = error;
if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
invalidateApiGatewayReady();
try {
await waitForStableGateway(2);
} catch {
await sleep(gatewayRetryDelay(attempt));
}
continue;
}
throw mapFetchError(error);
}
}
throw mapFetchError(lastNetworkError);
}
export async function apiFetch<T>(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise<T> {
if (typeof window !== 'undefined' && !gatewayReadyResolved && !path.startsWith('/health')) {
await ensureApiGatewayReady();
}
await acquireApiSlot();
const resolvedToken = resolveAccessToken(token);
const method = (options.method ?? 'GET').toUpperCase();
const hasBody = options.body !== undefined && options.body !== null;
const headers: Record<string, string> = {
...(hasBody || method === 'POST' || method === 'PUT' || method === 'PATCH'
? { 'Content-Type': 'application/json' }
: {}),
...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}),
...(options.headers as Record<string, string> | undefined)
};
let response: Response;
try {
try {
response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, {
...options,
headers
});
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw mapFetchError(error);
}
if (!response.ok) {
const error = await parseErrorResponse(response);
if (error.code === 'PIN_REQUIRED') {
notifyPinRequired(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
throw error;
}
if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') {
const refreshed = await trySilentTokenRefresh();
if (refreshed?.accessToken) {
releaseApiSlot();
return apiFetch<T>(path, options, refreshed.accessToken, false);
}
if (refreshed?.requiresPin) {
throw error;
}
}
throw error;
}
markApiGatewayReady();
return response.json() as Promise<T>;
} finally {
releaseApiSlot();
}
}
export { getDeviceFingerprint, buildAuthDevicePayload, resolveDeviceLabel, formatUserAgentLabel } from '@/lib/device-client';
export interface MediaAccessResponse {
accessUrl: string;
expiresAt: string;
}
export async function fetchDocumentPhotoUrl(userId: string, storageKey: string, token?: string | null) {
const query = new URLSearchParams({ storageKey });
return apiFetch<MediaAccessResponse>(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token);
}
export interface AccountDeletionStatus {
pending: boolean;
deletionRequestedAt?: string;
effectiveAt?: string;
graceDays: number;
}
export async function requestAccountDeletion(userId: string, token?: string | null) {
return apiFetch<AccountDeletionStatus & { userId: string }>(
`/profile/users/${userId}/self-delete`,
{ method: 'POST' },
token
);
}
export async function cancelAccountDeletion(userId: string, token?: string | null) {
return apiFetch<{ userId: string; cancelled: boolean }>(
`/profile/users/${userId}/self-delete/cancel`,
{ method: 'POST' },
token
);
}
export async function fetchAccountDeletionStatus(userId: string, token?: string | null) {
return apiFetch<AccountDeletionStatus>(`/profile/users/${userId}/self-delete/status`, {}, token);
}
/** @deprecated Используйте requestAccountDeletion — удаление теперь отложенное */
export async function softDeleteProfile(userId: string, token?: string | null) {
return requestAccountDeletion(userId, token);
}
export interface UserAddress {
id: string;
userId: string;
label: 'HOME' | 'WORK' | 'OTHER';
city: string;
street: string;
house: string;
apartment?: string;
comment?: string;
latitude?: number;
longitude?: number;
fullAddress?: string;
createdAt: string;
updatedAt: string;
}
export async function fetchUserAddresses(userId: string, token?: string | null) {
return apiFetch<{ addresses?: UserAddress[] }>(`/addresses/users/${userId}`, {}, token);
}
export async function upsertUserAddress(
userId: string,
body: {
addressId?: string;
label: UserAddress['label'];
city: string;
street: string;
house: string;
apartment?: string;
comment?: string;
latitude?: number;
longitude?: number;
fullAddress?: string;
},
token?: string | null
) {
return apiFetch<UserAddress>(`/addresses/users/${userId}`, { method: 'POST', body: JSON.stringify(body) }, token);
}
export async function deleteUserAddress(userId: string, addressId: string, token?: string | null) {
return apiFetch<{ count: number }>(`/addresses/users/${userId}/${addressId}`, { method: 'DELETE' }, token);
}
export interface FamilyMember {
id: string;
userId: string;
role: string;
displayName: string;
hasAvatar: boolean;
isVerified?: boolean;
verificationIcon?: string | null;
isBot?: boolean;
botUsername?: string;
}
export interface FamilyGroup {
id: string;
ownerId: string;
name: string;
hasAvatar: boolean;
members?: FamilyMember[];
botFatherAvailable?: boolean;
getMyIdBotAvailable?: boolean;
}
export interface BotChatMessage {
id: string;
direction: 'in' | 'out';
scope?: 'private' | 'broadcast';
text: string;
messageType: string;
messageId: number;
createdAt: string;
replyMarkupJson?: string | null;
isPinned?: boolean;
pinnedAt?: string | null;
}
export interface BotChatMeta {
botId?: string;
botOwnerId?: string;
botUsername?: string;
botDisplayName?: string;
composerWebAppUrl?: string | null;
composerMenuButtonJson?: string | null;
manageWebAppUrl?: string;
}
export interface FamilyInvite {
id: string;
groupId: string;
groupName: string;
inviterId: string;
inviterName: string;
inviteeUserId?: string;
targetEmail?: string;
targetPhone?: string;
status: string;
expiresAt: string;
}
export interface AppNotification {
id: string;
type: string;
title: string;
message: string;
payloadJson?: string;
isRead: boolean;
createdAt: string;
}
export interface ChatPollOption {
id: string;
text: string;
voteCount: number;
votedByMe: boolean;
}
export interface ChatMessageReaction {
emoji: string;
count: number;
reactedByMe: boolean;
}
export interface ChatMessage {
id: string;
roomId: string;
senderId: string;
senderName: string;
senderHasAvatar: boolean;
senderIsVerified?: boolean;
senderVerificationIcon?: string | null;
type: string;
content?: string;
isEncrypted?: boolean;
replyToId?: string;
storageKey?: string;
mimeType?: string;
metadataJson?: string;
createdAt: string;
editedAt?: string;
isDeleted?: boolean;
isPinned?: boolean;
pinnedAt?: string;
pinnedById?: string;
readByAll?: boolean;
poll?: {
id: string;
question: string;
allowsMultiple: boolean;
isAnonymous: boolean;
options: ChatPollOption[];
};
reactions?: ChatMessageReaction[];
}
export interface FamilyPresenceMember {
userId: string;
displayName: string;
online: boolean;
lastSeenAt?: string;
}
export interface FamilyPresence {
members: FamilyPresenceMember[];
onlineCount: number;
}
export interface ChatRoomMember {
id: string;
userId: string;
displayName: string;
hasAvatar: boolean;
isVerified?: boolean;
verificationIcon?: string | null;
notificationsMuted: boolean;
isPinned?: boolean;
pinnedAt?: string;
familyRole?: string;
isChatCreator?: boolean;
inFamily?: boolean;
}
export interface ChatRoom {
id: string;
groupId: string;
type: string;
name: string;
peerUserId?: string;
botUsername?: string;
isE2E?: boolean;
hasAvatar: boolean;
createdById?: string;
updatedAt: string;
isPinned?: boolean;
pinnedAt?: string;
peerLeftFamily?: boolean;
members: ChatRoomMember[];
lastMessage?: ChatMessage;
}
export async function fetchFamilyGroups(userId: string, token?: string | null) {
const accessToken = resolveAccessToken(token);
if (!accessToken) {
throw new ApiError('Не авторизован', 401, 'NO_TOKEN');
}
return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, accessToken);
}
export async function fetchFamilyGroup(groupId: string, token?: string | null) {
return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, {}, token);
}
export async function updateFamilyGroup(groupId: string, name: string, token?: string | null) {
return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, { method: 'PATCH', body: JSON.stringify({ name }) }, token);
}
export async function deleteFamilyGroup(groupId: string, token?: string | null) {
return apiFetch<{ count: number }>(`/family/groups/${groupId}`, { method: 'DELETE' }, token);
}
export async function sendFamilyInvite(groupId: string, payload: { inviteeUserId: string; target?: string }, token?: string | null) {
return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token);
}
export async function fetchBotChatMessages(botRef: string, token?: string | null, roomId?: string) {
const suffix = roomId ? `?roomId=${encodeURIComponent(roomId)}` : '';
return apiFetch<{
messages?: BotChatMessage[];
botUsername?: string;
botDisplayName?: string;
botId?: string;
botOwnerId?: string;
composerWebAppUrl?: string;
composerMenuButtonJson?: string;
manageWebAppUrl?: string;
}>(`/bots/by-username/${encodeURIComponent(botRef)}/messages${suffix}`, {}, token);
}
export async function sendBotMessage(botRef: string, text: string, token?: string | null, roomId?: string) {
return apiFetch<{ updateId?: number; messageId?: number; chatId?: string }>(
`/bots/by-username/${encodeURIComponent(botRef)}/messages`,
{ method: 'POST', body: JSON.stringify({ text, roomId }) },
token
);
}
export async function submitBotCallback(botRef: string, messageId: number, callbackData: string, token?: string | null) {
return apiFetch<{ updateId?: number; callbackQueryId?: string; messageId?: number }>(
`/bots/by-username/${encodeURIComponent(botRef)}/callback`,
{ method: 'POST', body: JSON.stringify({ messageId, callbackData }) },
token
);
}
export async function fetchFamilyInvites(token?: string | null) {
return apiFetch<{ invites?: FamilyInvite[] }>('/family/invites', {}, token);
}
export async function respondFamilyInvite(inviteId: string, accept: boolean, token?: string | null) {
return apiFetch<FamilyInvite>(`/family/invites/${inviteId}/respond`, { method: 'POST', body: JSON.stringify({ accept }) }, token);
}
export async function fetchNotifications(token?: string | null, unreadOnly = false) {
return apiFetch<{ notifications?: AppNotification[] }>(`/notifications?unreadOnly=${unreadOnly}`, {}, token);
}
export async function fetchUnreadNotificationCount(token?: string | null) {
return apiFetch<{ count: number }>('/notifications/unread-count', {}, token);
}
export async function markNotificationRead(notificationId: string, token?: string | null) {
return apiFetch(`/notifications/${notificationId}/read`, { method: 'PATCH', body: '{}' }, token);
}
export async function markAllNotificationsRead(token?: string | null) {
return apiFetch('/notifications/read-all', { method: 'POST' }, token);
}
export async function fetchChatRooms(groupId: string, token?: string | null) {
return apiFetch<{ rooms?: ChatRoom[] }>(`/chat/groups/${groupId}/rooms`, {}, token);
}
export async function createChatRoom(groupId: string, name: string, memberUserIds: string[], token?: string | null) {
return apiFetch<ChatRoom>(`/chat/groups/${groupId}/rooms`, {
method: 'POST',
body: JSON.stringify({ name, memberUserIds })
}, token);
}
export async function addChatRoomMember(roomId: string, memberUserId: string, token?: string | null) {
return apiFetch<ChatRoom>(`/chat/rooms/${roomId}/members`, {
method: 'POST',
body: JSON.stringify({ memberUserId })
}, token);
}
export async function removeChatRoomMember(roomId: string, memberUserId: string, token?: string | null) {
return apiFetch<ChatRoom>(`/chat/rooms/${roomId}/members/${memberUserId}`, { method: 'DELETE' }, token);
}
export async function removeFamilyMember(memberId: string, token?: string | null) {
return apiFetch<{ count: number }>(`/family/members/${memberId}`, { method: 'DELETE' }, token);
}
export async function leaveFamilyGroup(groupId: string, token?: string | null, newOwnerUserId?: string) {
return apiFetch<{ count: number; deleted?: boolean }>(
`/family/groups/${groupId}/leave`,
{
method: 'POST',
body: JSON.stringify(newOwnerUserId ? { newOwnerUserId } : {})
},
token
);
}
export async function transferFamilyOwnership(groupId: string, newOwnerUserId: string, token?: string | null) {
return apiFetch<FamilyGroup>(`/family/groups/${groupId}/transfer-ownership`, {
method: 'POST',
body: JSON.stringify({ newOwnerUserId })
}, token);
}
export async function fetchChatMessages(roomId: string, token?: string | null, beforeMessageId?: string) {
const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : '';
return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${roomId}/messages${query}`, {}, token);
}
export async function sendChatMessage(
roomId: string,
body: {
type: string;
content?: string;
isEncrypted?: boolean;
replyToId?: string;
storageKey?: string;
mimeType?: string;
metadataJson?: string;
poll?: { question: string; options: string[]; allowsMultiple?: boolean; isAnonymous?: boolean };
},
token?: string | null
) {
return apiFetch<ChatMessage>(`/chat/rooms/${roomId}/messages`, { method: 'POST', body: JSON.stringify(body) }, token);
}
export async function fetchUserE2EPublicKey(userId: string, token?: string | null) {
return apiFetch<{ userId?: string; e2ePublicKey?: string }>(`/profile/users/${userId}/e2e-public-key`, {}, token);
}
export async function setUserE2EPublicKey(userId: string, publicKey: string, token?: string | null) {
return apiFetch<{ userId?: string; e2ePublicKey?: string }>(
`/profile/users/${userId}/e2e-public-key`,
{ method: 'PATCH', body: JSON.stringify({ publicKey }) },
token
);
}
export interface ManagedBot {
id: string;
name: string;
username: string;
tokenPrefix?: string;
description?: string | null;
aboutText?: string | null;
botPicUrl?: string | null;
menuButtonJson?: string | null;
webAppUrl?: string | null;
isActive?: boolean;
isSystemBot?: boolean;
ownerId?: string;
createdAt?: string;
updatedAt?: string;
owner?: { id: string; displayName: string; username?: string };
messageCount?: number;
chatCount?: number;
}
export interface AdminBotMetrics {
totalBots?: number;
activeBots?: number;
blockedBots?: number;
totalMessages?: number;
totalChats?: number;
}
export async function fetchAdminBots(
params: { search?: string; page?: number; limit?: number },
token?: string | null
) {
const query = new URLSearchParams();
if (params.search?.trim()) query.set('search', params.search.trim());
if (params.page) query.set('page', String(params.page));
if (params.limit) query.set('limit', String(params.limit));
const suffix = query.toString() ? `?${query.toString()}` : '';
return apiFetch<{ bots?: ManagedBot[]; total?: number }>(`/admin/bots${suffix}`, {}, token);
}
export async function fetchAdminBotMetrics(token?: string | null) {
return apiFetch<AdminBotMetrics>('/admin/bots/metrics', {}, token);
}
export async function fetchAdminBotAccounts(search: string | undefined, token?: string | null) {
const suffix = search?.trim() ? `?search=${encodeURIComponent(search.trim())}` : '';
return apiFetch<{ users: AdminUser[] }>(`/admin/users/bot-accounts${suffix}`, {}, token);
}
export async function fetchUserTotpStatus(userId: string, token?: string | null) {
return apiFetch<TotpStatusResponse>(`/security/users/${userId}/totp/status`, {}, token);
}
export async function adminDisableUserTotp(userId: string, token?: string | null) {
return apiFetch<TotpStatusResponse>(`/admin/users/${userId}/totp/admin-disable`, { method: 'POST' }, token);
}
export async function setAdminBotActive(botId: string, isActive: boolean, token?: string | null) {
return apiFetch<ManagedBot>(`/admin/bots/${botId}/active`, {
method: 'PATCH',
body: JSON.stringify({ isActive })
}, token);
}
export async function createE2ERoom(groupId: string, peerUserId: string, token?: string | null) {
return apiFetch<ChatRoom>(`/chat/groups/${groupId}/e2e-rooms`, {
method: 'POST',
body: JSON.stringify({ peerUserId })
}, token);
}
export async function fetchMyBots(token?: string | null) {
return apiFetch<{ bots?: ManagedBot[]; total?: number }>('/bots', {}, token);
}
export async function createManagedBot(body: { name: string; username: string }, token?: string | null) {
return apiFetch<{ bot?: ManagedBot; token?: string }>('/bots', { method: 'POST', body: JSON.stringify(body) }, token);
}
export async function fetchBot(botId: string, token?: string | null) {
return apiFetch<ManagedBot>(`/bots/${botId}`, {}, token);
}
export async function updateManagedBot(botId: string, body: { name?: string; username?: string }, token?: string | null) {
return apiFetch<ManagedBot>(`/bots/${botId}`, { method: 'PATCH', body: JSON.stringify(body) }, token);
}
export async function updateManagedBotProfile(
botId: string,
body: {
description?: string;
aboutText?: string;
botPicUrl?: string;
menuButtonUrl?: string;
menuButtonText?: string;
menuButtonJson?: string;
},
token?: string | null
) {
return apiFetch<ManagedBot>(`/bots/${botId}/profile`, { method: 'PATCH', body: JSON.stringify(body) }, token);
}
export async function revokeManagedBotToken(botId: string, token?: string | null) {
return apiFetch<{ bot?: ManagedBot; token?: string }>(`/bots/${botId}/revoke-token`, { method: 'POST' }, token);
}
export async function voteChatPoll(messageId: string, optionIds: string[], token?: string | null) {
return apiFetch<ChatMessage>(`/chat/messages/${messageId}/vote`, { method: 'POST', body: JSON.stringify({ optionIds }) }, token);
}
export async function setChatRoomMuted(roomId: string, muted: boolean, token?: string | null) {
return apiFetch(`/chat/rooms/${roomId}/mute`, { method: 'POST', body: JSON.stringify({ muted }) }, token);
}
export async function setChatRoomPinned(roomId: string, pinned: boolean, token?: string | null) {
return apiFetch<ChatRoom>(`/chat/rooms/${roomId}`, {
method: 'PATCH',
body: JSON.stringify({ pinned })
}, token);
}
export async function editChatMessage(messageId: string, content: string, token?: string | null) {
return apiFetch<ChatMessage>(`/chat/messages/${messageId}`, { method: 'PATCH', body: JSON.stringify({ content }) }, token);
}
export async function deleteChatMessage(messageId: string, token?: string | null) {
return apiFetch<ChatMessage>(`/chat/messages/${messageId}`, { method: 'DELETE' }, token);
}
export async function toggleChatMessageReaction(messageId: string, emoji: string, token?: string | null) {
return apiFetch<ChatMessage>(`/chat/messages/${messageId}/reactions`, {
method: 'POST',
body: JSON.stringify({ emoji })
}, token);
}
export async function setChatMessagePinned(messageId: string, pinned: boolean, token?: string | null) {
return apiFetch<ChatMessage>(`/chat/messages/${messageId}/pin`, {
method: 'POST',
body: JSON.stringify({ pinned })
}, token);
}
export async function forwardChatMessages(targetRoomId: string, messageIds: string[], token?: string | null) {
return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${targetRoomId}/forward`, {
method: 'POST',
body: JSON.stringify({ messageIds })
}, token);
}
export async function deleteChatRoom(roomId: string, token?: string | null) {
return apiFetch<{ count?: number }>(`/chat/rooms/${roomId}`, { method: 'DELETE' }, token);
}
export async function markChatRoomRead(roomId: string, lastMessageId: string | undefined, token?: string | null) {
return apiFetch<{ count: number }>(`/chat/rooms/${roomId}/read`, {
method: 'POST',
body: JSON.stringify({ lastMessageId })
}, token);
}
export async function reportChatTyping(roomId: string, token?: string | null) {
return apiFetch<{ success?: boolean }>(`/chat/rooms/${roomId}/typing`, { method: 'POST' }, token);
}
export async function fetchFamilyPresence(groupId: string, token?: string | null) {
const accessToken = resolveAccessToken(token);
if (!accessToken) {
throw new ApiError('Не авторизован', 401, 'NO_TOKEN');
}
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, accessToken);
}
/** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */
export const WS_URL = configuredWsUrl;