import { config } from "../config.js"; import { logger } from "../logger.js"; import { UserFacingError } from "../types.js"; const log = logger.child({ mod: "stoat-rest" }); export interface StoatUser { _id: string; username: string; display_name?: string | null; discriminator?: string; avatar?: { _id: string } | null; bot?: { owner: string } | null; } export interface StoatMember { _id: { server: string; user: string }; nickname?: string | null; roles?: string[] | null; } export interface StoatServer { _id: string; name: string; owner: string; roles?: Record | null; } export type LoginResult = | { kind: "success"; token: string; userId: string } | { kind: "mfa"; ticket: string; methods: string[] }; async function request( path: string, init: RequestInit & { token?: { type: "bot" | "session"; value: string } } = {}, ): Promise { const { token, headers, ...rest } = init; const finalHeaders: Record = { accept: "application/json", ...(headers as Record | undefined), }; if (token?.type === "bot") finalHeaders["x-bot-token"] = token.value; if (token?.type === "session") finalHeaders["x-session-token"] = token.value; if (rest.body && !finalHeaders["content-type"]) finalHeaders["content-type"] = "application/json"; const res = await fetch(`${config.STOAT_API_URL}${path}`, { ...rest, headers: finalHeaders }); const text = await res.text(); const body = text ? (JSON.parse(text) as unknown) : null; if (!res.ok) { log.debug({ path, status: res.status, body }, "stoat api error"); const type = (body as { type?: string } | null)?.type; throw new StoatApiError(res.status, type ?? `HTTP ${res.status}`); } return body as T; } export class StoatApiError extends Error { constructor( readonly status: number, readonly type: string, ) { super(`Stoat API error: ${type}`); this.name = "StoatApiError"; } } /** * Authenticates a panel user against the very same accounts the Stoat instance * uses. We never see or store password material: the credentials go straight to * the instance's auth endpoint and the short-lived session is revoked as soon as * we have confirmed who the user is. */ export async function loginWithPassword( email: string, password: string, mfa?: { ticket: string; totpCode?: string; recoveryCode?: string }, ): Promise { const body: Record = { friendly_name: "stoat-mbot panel" }; if (mfa) { body["mfa_ticket"] = mfa.ticket; body["mfa_response"] = mfa.totpCode ? { totp_code: mfa.totpCode } : { recovery_code: mfa.recoveryCode }; } else { body["email"] = email; body["password"] = password; } let response: { result: string; token?: string; user_id?: string; ticket?: string; allowed_methods?: string[] }; try { response = await request("/auth/session/login", { method: "POST", body: JSON.stringify(body) }); } catch (err) { if (err instanceof StoatApiError && (err.status === 401 || err.status === 400)) { throw new UserFacingError("Неверный логин или пароль"); } throw err; } if (response.result === "MFA") { return { kind: "mfa", ticket: response.ticket ?? "", methods: response.allowed_methods ?? [], }; } if (response.result === "Disabled") throw new UserFacingError("Аккаунт отключён"); if (!response.token || !response.user_id) throw new UserFacingError("Неожиданный ответ сервера авторизации"); return { kind: "success", token: response.token, userId: response.user_id }; } export function fetchSelf(sessionToken: string): Promise { return request("/users/@me", { token: { type: "session", value: sessionToken } }); } export async function revokeSession(sessionToken: string): Promise { try { await request("/auth/session/logout", { method: "POST", token: { type: "session", value: sessionToken }, }); } catch (err) { log.warn({ err }, "could not revoke temporary session"); } } export function fetchUser(userId: string): Promise { return request(`/users/${userId}`, { token: { type: "bot", value: config.STOAT_BOT_TOKEN }, }); } export function fetchServer(serverId: string): Promise { return request(`/servers/${serverId}`, { token: { type: "bot", value: config.STOAT_BOT_TOKEN }, }); } export async function fetchMember(serverId: string, userId: string): Promise { try { return await request(`/servers/${serverId}/members/${userId}`, { token: { type: "bot", value: config.STOAT_BOT_TOKEN }, }); } catch (err) { if (err instanceof StoatApiError && err.status === 404) return null; throw err; } }