Afina HTTP API#
Локальный HTTP-сервер для внешних скриптов, автоматизации и AI-агентов через MCP. Стартует вместе с десктоп-приложением Afina.
- Base URL:
http://127.0.0.1:50778 - Default port:
50778; если занят, сервер пробует следующие порты. - JSON: все ответы
application/json, кроме/api/tasks/logsи/api/scripts/run-logs(text/plain). - Source of truth:
src-tauri/src/browser/server.rs.
Аутентификация#
Все /api/* требуют x-api-key из Настройки → Основные → API ключ. Если ключ не задан или неверный: 401 Unauthorized / тихое закрытие соединения.
Без ключа доступны только GET /api/health, GET /, GET /oauth/callback?code=.... CORS разрешён только для localhost-origin (http://localhost, http://127.0.0.1, tauri://); методы GET, POST, DELETE, OPTIONS; headers Content-Type, X-API-Key.
Конвенции#
id= numeric PK в SQLite;accountId= UUID профиля. Большинство write-endpoints принимают оба.- Boolean:
true/falseили0/1; datetime: ISO-8601, defaultdatetime('now'). */delete= soft delete (isDeleted=1);*/hard-delete= физическое удаление из БД/диска.*/update= PATCH только переданных полей.- Write-endpoints эмитят
reload-eventдля UI refresh.
Health#
GET /api/health
Heartbeat без API key. Ответ: { "status": "ok", "running": 2 }, где running = число запущенных браузеров.
Profiles - аккаунты (CRUD)#
GET /api/profiles/list
Список аккаунтов; к каждому добавляются isRunning, tags:[{id,name,color}], groups:[{id,name}].
Query params (опциональны, AND):
| Параметр | Тип | Описание |
|---|---|---|
isRunning | true/false | Запущенные/остановленные |
groupId | int | Фильтр по группе |
tagId | int | Фильтр по тегу |
Ответ: { message, count, accounts:[{ id, accountId, name, ua, proxyId, os, macChip, isRunning, tags, groups }] }.
GET /api/profiles/get / POST /api/profiles/get
Один аккаунт по UUID: query/body { accountId }. Ответ: { message, profile:{...} }.
POST /api/profiles/create
Создаёт профиль. fingerprint: не передан → генерируется; полный → берётся как есть с нормализацией; частичный → генерируется база и мерджится incoming-wins.
Канон-поля fingerprint: userAgent, platform, platformVersion, vendor, productSub, CPUcores, deviceMemory, language, languages, AcceptLanguage, Brand, BrandVersion, BrandFullVersion, WebGLRenderer, WebGLVendor, Arch, Bitness, wow64, плюс NoiseCanvas/Audio/Rects/GL.
Игнорируются всегда:
| Поле | Почему |
|---|---|
userAgent | Должен совпадать с Afina-Chromium build |
BrandFullVersion | Константа build, связана с UA |
deviceMemory | При ua<147 браузер отдаёт максимум 8; 16/32 допустимы с ua>=147 |
Алиасы fingerprint:
| Прислал | Стало |
|---|---|
uaPlatformVersion, platform_version | platformVersion |
uaArchitecture, architecture | Arch |
uaBitness | Bitness |
webGLVendor, webglVendor, unmasked_vendor | WebGLVendor |
webGLRenderer, webglRenderer, unmasked_renderer | WebGLRenderer |
hardwareConcurrency, hardware_concurrency, cpuCores | CPUcores |
device_memory | deviceMemory |
user_agent | userAgent |
product_sub | productSub |
accept_language, acceptLanguage | AcceptLanguage |
brandVersion / brandFullVersion / uaFullVersion | BrandVersion / BrandFullVersion |
ua_full_version | BrandFullVersion |
uaберётся из локального{data_dir}/browser/UA*; клиентское значение игнорируется.osдля генерации: payload →fingerprint.platform→macos; допустимоmacos,windows10,windows11.osв БД:Windows10/Windows11/x86/arm64; Intel WebGL даётos="x86",macChip="",platform="x86",Arch="".macChip: WebGLRenderer (Apple M2→m2) →payload.chip/macChip→fp.chip→"".osArch:arm64/x86для macOS; Windows игнорирует.
Остальные параметры (name, proxy*, tags*, settings, шумы, screenSize и т.д.) перечислены ниже.
Body (всё опционально, кроме требований конкретного сценария):
| Поле | Тип | Описание |
|---|---|---|
name | string | Имя |
accountId | string | Форсировать UUID |
os | macos/windows10 | Генерация FP; default macos |
osArch | arm64/x86 | Арх macOS; default arm64 |
chip | string | m1/m2/m3/m4/intel |
ua | string | Игнорируется; берётся локальный UA |
browserType | string | afina/mimic/octo/vision/ads/dolphin; default afina |
note | string | Заметка |
proxyId | int | Saved proxy; взаимоискл. с proxyData |
proxyType | saved/set/without_proxy | Тип прокси |
proxyData | object | Inline proxy. Required: host, port, type.Optional: username, password, changeIpUrl, remark, country, countryCode, timezone, visible_ip, isActive, isUDPSupported |
tagIds / tagNames | array | Теги по id/name; новые создаются |
accountGroupIds / accountGroupNames | array | Группы по id/name |
languages | array | Override языков |
language / timezone | string | Если *_from_ip=false |
timezone_from_ip / language_from_ip / languages_from_ip | bool | Авто из IP; default true |
languageInterfaceType | from_language/real_value | Default real_value |
screenSize | string | 1920x1080; avail авто |
useScreenDefault | bool | Дефолтный экран |
availWidth/availHeight/colorDepth/pixelDepth | int | Экран |
blockedPorts | [int] | `--afina-fp |
blockOnProxyCountryChange | bool/null | null=global, true=block, false=allow |
localCacheMode | default/no_cache | no_cache → --disk-cache-size=0 |
startupUrls | [string] | Открыть при первом запуске |
extraArgs | string | CLI args Chromium |
settings | object | Per-account KV ${key} |
isNoiseCanvasEnabled / isNoiseAudioEnabled / isNoiseRectsEnabled / isNoiseGLEnabled | bool | FP noise |
fingerprint | object | Полный/частичный FP |
teamUuid | string | Команда лицензии |
Успех: { "status": "success", "id": 50, "accountId": "<uuid>", "account": {...} }.
POST /api/profiles/update
PATCH по { id } или { accountId }. Принимает поля create, isDeleted, skipServerSync, теги tagIds + selection(replace|append|delete|clear), группы accountGroupIds + selectionGroups(replaceGroup|appendGroup|deleteGroup|clearGroup).
POST /api/profiles/delete
Soft-delete. Body { id } или { accountId } (UUID-string). Успех: { "message": "Account successfully deleted" }.
POST /api/profiles/hard-delete
Безвозвратно удаляет account/profile/junction, proxy_usage, файлы профиля; синхронизирует Afina server DELETE /profiles/:uuid; перед удалением закрывает браузер. Body { id } / { ids:[...] } или { accountId } / { accountIds:[...] }. Успех: { "status":"success","deleted":3 }.
Browser control - управление браузером#
POST /api/profiles/start
Запускает браузер. Body { "profileId": "<UUID>" }. Ответ содержит wsEndpoint и data.port; если уже запущен: alreadyRunning:true. wsEndpoint пригоден для Puppeteer/Playwright/CDP.
POST /api/profiles/stop
Закрывает запущенный браузер через CDP Browser.close, затем graceful kill по таймауту. Body { "profileId": "<UUID>" }. 404, если профиль не запущен.
One-time profiles - одноразовые профили#
POST /api/profiles/one-time
Создаёт disposable profile, сразу запускает и hard-delete после остановки. Body = поля /api/profiles/create; name default one-time-<ts>.
- Создание идёт через
/api/profiles/createс partial-fingerprint merge и внутренним__oneTime_internal__. - Возвращает
id,accountId,wsEndpoint,port,isOneTime:true. - Остановка:
POST /api/profiles/stop,browser.close()или crash; после этого профиль исчезает из/api/profiles/listи Trash. - Если
start_browserупал, созданный one-time профиль удаляется синхронно. - RPM/RPH: один вызов = create + start + stop + delete; не чаще примерно 1/сек.
isOneTime нельзя выставить через /api/profiles/create или /api/profiles/update; hard-delete в exit-handler срабатывает только после SQL-проверки account.isOneTime=1.
Browser introspection - eval / screenshot#
POST /api/profiles/eval
Выполняет JS в текущей видимой вкладке запущенного профиля. Body { profileId, code }; код оборачивается в IIFE, promises await, результат returnByValue. 404, если браузер не запущен. Пример ответа: { "value": "https://example.com" }.
POST /api/profiles/screenshot
Скриншот текущей видимой вкладки. Body { profileId, format:"png" }; ответ { mimeType:"image/png", data:"iVBORw0..." }.
Cookies - импорт/экспорт куков#
POST /api/profiles/cookies/set
Кладёт cookies в очередь импорта {data_dir}/cookies/{uuid}/cookies_{ts}.json; при следующем старте браузера сервер заливает их через CDP. Непринятый cookies_*.json перезаписывается.
Body:
| Поле | Тип | Описание |
|---|---|---|
accountId | int/UUID | account.id или UUID; алиас id |
cookies | array | Cookie objects: domain/name/value/path/expirationDate/secure/httpOnly/sameSite/... |
Успех: { "status":"success", "data": { "count": 1 } }.
Аккаунт не должен быть запущен: куки применятся на следующем /api/profiles/start. Для горячего ввода используйте CDP Network.setCookies через /api/profiles/eval или wsEndpoint.
POST /api/profiles/cookies/export
Экспорт расшифрованных cookies из открытой папки профиля, затем .zip, затем .afbk. Формат Chrome extensions: domain/name/value/path/expirationDate/hostOnly/httpOnly/sameSite/secure/session/storeId.
Body:
| Поле | Тип | Описание |
|---|---|---|
id | int | Один account.id |
ids | [int] | Bulk ids |
accountId | UUID | Один UUID |
accountIds | [UUID] | Bulk UUID |
path | string | Файл или папка |
- Single без
path→ cookies вdata.cookies. - Single +
pathна.json→ один файл. - Bulk или
pathбез.json→ папка; файлыcookie_{accountName}_{ts}.json. - Ответ:
{ status:"success", data:{ id?, accountName?, count?, cookies?, path?, exported?, errors? } }.
Нужна разлоченная сессия: cookie key берётся из vault. Иначе 500 Cookie key not available - decrypt keys first.
Account vars - переменные аккаунта#
Переменные доступны в RPA как ${key}:
plain=account.settings, JSON без шифрования.encrypted=account_data_blob, sealed-box; требует master-password, executor расшифровывает перед запуском.
GET /api/accounts/vars?accountId=N (или ?accountUuid=UUID)
Возвращает { accountId, plain:{...}, encrypted:{...} }.
POST /api/accounts/vars/set
Один ключ. Body { accountId | accountUuid, key, value, encrypted?:bool }; encrypted=false пишет в account.settings и регистрирует ключ в catalog; encrypted=true decrypt → merge → encrypt. value может быть string/number/bool/object/array.
POST /api/accounts/vars/delete
Удаляет один ключ. Body { accountId | accountUuid, key, encrypted?:bool }; ответ содержит removed.
Proxies - прокси#
POST /api/proxies/check
Проверяет прокси аккаунтов тем же checker (ipapicom/ipinfoio), socks5 дополнительно UDP. При успехе обновляет proxy (visible_ip, country, timezone, UDP, active) и proxy_usage per-account; учитывает country-block.
Body (фильтры AND):
| Поле | Тип | Описание |
|---|---|---|
accountIds | [int] | account.id |
groupId | int | Аккаунты группы |
tagId | int | Аккаунты тега |
| (ничего) | - | Все non-deleted с proxy |
Ответ: { message, checked, results:[{ accountId, accountName, accountUuid, proxyId, host, port, type, result }] }; result.status = success / error / no_proxy.
POST /api/proxies/check-all
Проверяет все записи proxy; обновляет proxy и proxy_usage всех аккаунтов, использующих прокси. Ответ: { message, checked, results:[...] }.
POST /api/proxies/add
Добавляет proxy после прогрев-проверки; при fail не сохраняет. Body { host, port, type?, username?, password?, remark?, changeIpUrl? }, type default http. Ответ success { added:true, proxyId, result } или fail { added:false, result:{status:"error",message} }.
Databases - подключения к БД#
Подключения для RPA-блока database; таблица connections.
GET /api/databases/list
Список БД: { message, count, databases:[{ id, name, type, filePath?|host?|port?|user?|database?|ssl? }] }.
GET /api/databases/get?id=N
Одна БД: { message, database:{...} }.
POST /api/databases/create
Body:
| Поле | Тип | Описание |
|---|---|---|
name | string | Имя |
type | sqlite/postgres/mysql/mssql/mongodb/redis | Default sqlite |
filePath | string | .db для sqlite |
host/port/user/password/database | - | Сетевые БД |
ssl | bool | TLS |
uri | string | Connection URI override |
folderId | int/null | UI-папка |
isCreateFile | bool | Для sqlite создать .db в <dataDir>/databases/ |
Успех: { "status":"success", "data": { "id":3, "name":"scratch", "type":"sqlite", "filePath":"..." } }.
POST /api/databases/update
PATCH по id; поля create + isFavorite, isDeleted, tagIds + selection.
POST /api/databases/delete
Soft-delete. Body { id } или { ids:[...] }.
POST /api/databases/hard-delete
Удаляет connections, connections_tags_tag и файл с диска, если есть filePath. Body { id } или { ids:[...] }.
Global vars - глобальные переменные#
Name/value из Настройки → Переменные окружения, доступны в RPA как ${name}; таблица settings.
GET /api/global-vars/list
Ответ: { message, count, vars:[{ id, name, value, enable, isRestricted, owner }] }.
POST /api/global-vars/create
Body { name, value }; оба должны быть уникальны. Дубликаты: setting.error.duplicate_name / setting.error.duplicate_value.
POST /api/global-vars/update
Body { id, name?, value? }; те же правила уникальности.
POST /api/global-vars/delete
Body { id } / { ids:[...] } / { settingIds:[...] }.
Key catalog - каталог ключей#
key_entity хранит имена ключей из account.settings и account_data_blob; значения лежат в /api/accounts/vars.
GET /api/keys/list
Ответ: { message, count, keys:[{ id, key, createdAt }] }.
POST /api/keys/delete
Удаляет только реестр имён, не значения аккаунтов. Body { ids:[...] } или { globalKeyIds:[...] }.
Scripts - RPA-скрипты#
GET /api/scripts/list
Все non-deleted скрипты с деревом settings и form. Ответ: { message, count, scripts:[{ id, name, hash, form, settings, isFavorite }] }. form = поля input/select/checkbox, передаются в задачах через additionalData.
GET /api/scripts/get?id=N
Полная структура одного скрипта: { message, script:{ id, name, settings, form } }.
POST /api/scripts/create
Создаёт скрипт. Body { name, settings, form?, tab?, browser?, headlessMode?, noBrowser?, extraArgs? }.
Критичный формат settings:
elements[]:{ id, start, type, left, top, label:"", hash:"", note:"", settings:{} };left/topна элементе, неposition.- Ровно один
start:true;settings.startElement= его id. connections[]:{ sourceId, targetId, sourcePosition:"bottom|right|left", targetPosition:"top|left|right" };targetPositionобязателен.visualGroups:[]опционально.
Успех: { status:"success", data:{ id, hash, name, settings } }.
POST /api/scripts/update
PATCH по id: name, settings (полная замена), form, tab, browser, headlessMode, noBrowser, isFavorite, folderId, tagIds, extraArgs.
POST /api/scripts/run
Прямой запуск скрипта на профиле без task-group/tasks. Body { profileId:"<UUID>", scriptId:<numeric|hash>, closeBrowserAfter?:bool }; ответ { status:"success", uuid:"<task-uuid>" }.
GET /api/scripts/run-logs?uuid=UUID (или ?taskUuid=UUID)
Лог прямого запуска (text/plain), аналог /api/tasks/logs для /api/scripts/run.
POST /api/scripts/stop
Остановить running script по task uuid. Body { uuid:"<task-uuid>" } или { taskUuid }; executor прерывается на ближайшем await.
Modules - RPA-модули (executeModule)#
Модуль = JS-код для блока executeModule: ряд module + папка (index.js, utils_<id>.js, package.json, settings.json).
Workflow: POST /api/modules/create → редактировать файлы в moduleDirAbs (index.js без IPC-блока process.on('message',...) и process.send({status:'ready'}), settings.json, package.json) → POST /api/modules/resign. Без свежей Ed25519-подписи executor вернёт modules.error.signature_invalid.
GET /api/modules/list
Ответ: { message, count, modules:[{ id, hash, name, sig, moduleDir, moduleDirAbs }] }.
GET /api/modules/get?id=N (или ?hash=UUID)
Возвращает ряд + moduleDirAbs + топ-уровень files.
POST /api/modules/create
Body:
| Поле | Тип | Описание |
|---|---|---|
name | string | required |
hash | string | Форсировать UUID |
code | string | UI-мирор index.js |
settings | object | { type:"module", fields:[{name,label,type:"text"|"number"|"checkbox"|"select",default,options?,groupId?}] } |
folderId | int/null | Папка |
tagIds | [int] | Теги |
allowedFunctions | [string] | Node API whitelist |
useCustomFolder | bool | Использовать customFolder |
customFolder | string | Существующая папка |
Успех: { status:"success", data:{ id, hash, moduleDir, moduleDirAbs } }.
После create сервер в фоне запускает npm install и подписывает модуль. После правки файлов всегда вызывайте /api/modules/resign.
POST /api/modules/update
PATCH ряда БД, не файлов: id, name, code, moduleDir, warnReason, settings, allowedFunctions, hashes, warningFindings, flags isFavorite/isDirty/isMigrated/isWarn/requiresReview/isDeleted, folderId, tagIds + selection.
POST /api/modules/resign
Пересчитать подпись папки. Body { id }; успех { status:"success", sig:"base64-ed25519-signature..." }.
POST /api/modules/delete
Soft-delete. Body { id } или { ids:[...] }; файлы остаются.
POST /api/modules/hard-delete
Удаляет ряд module, module_tags_tag и папку. Body { id } или { ids:[...] }.
Task Groups - группы задач#
Группа задач = контейнер расписания/повторов/таймаута/параллелизма. active=1 запускает scheduler.
| Поле | Тип | Описание |
|---|---|---|
schedule | bool | Окно timeFrom/timeTo |
timeFrom / timeTo | string HH:MM | Требует schedule=true |
scheduleTime | bool | Окно startHour/endHour |
startHour / endHour | int 0-23 | Требует scheduleTime=true |
isRepeatable | bool | Повторять группу |
repeatCount | int | Кол-во повторов |
timeout | int sec | 0 = нет |
activeSession | int | Параллелизм; 0 = unlimited |
waitForOtherTaskCompletion | bool | Ждать другие группы |
folderId | int/null | UI-папка |
GET /api/task-groups/list
Ответ: { message, count, groups:[{ id, tag, active, schedule, timeFrom, timeTo, isRepeatable, timeout, activeSession }] }.
GET /api/task-groups/get?id=N
Группа + задачи: { message, group:{...}, tasks:[{ id, uuid, scriptId, accountId, status, executeAt, additionalData }], tasksCount }.
GET /api/task-groups/tasks?groupId=N
Только задачи группы. Статусы: waiting, working, finished, error, stop, stopWithError.
POST /api/task-groups/create
Создаёт пустую группу. Body { tag?:string, name?:string, active?:bool, ...scheduleFields }. Рекомендуется active:false, затем создать задачи и вызвать start.
POST /api/task-groups/update
PATCH по id: schedule-поля, tag, active, isFavorite, isDeleted, isRescheduled.
POST /api/task-groups/start
Body { id } или { groupId }. Ставит active=1; waiting-задачи подхватывает scheduler. Идемпотентно, завершённые не ресетит.
POST /api/task-groups/restart
Body { id }. Ставит active=1 и переводит finished/error/stop/stopWithError обратно в waiting с executeAt=now().
POST /api/task-groups/stop
Body { id }. Ставит active=0, переводит working в stopWithError; браузеры не закрывает.
POST /api/task-groups/delete или DELETE /api/task-groups/delete?id=N
Soft-delete группы и её задач (isDeleted=1, UI deleteGroup). POST body { id }.
POST /api/task-groups/hard-delete
Удаляет задачи группы и ряд группы. Body { id } или { ids:[...] }; ответ содержит deletedGroups, deletedTasks, ids.
Tasks - задачи#
Задача = scriptId × accountId с executeAt, status, additionalData, sort.
POST /api/tasks/create
Создаёт одну/несколько задач в группе одной транзакцией. Body { groupId:int, tasks:[...] }; ответ { message, created, requested, errors }.
Поле task:
| Поле | Тип | Описание |
|---|---|---|
accountId | int | required, account.id |
scriptId | int/string | required |
additionalData | object | form-поля скрипта; иначе defaultValue |
executeAt | ISO string | default now |
tag | string | Метка |
sort | int | Порядок |
additionalData критично, если у скрипта есть form-поля.
POST /api/tasks/update
PATCH по id или taskId: status, tag, description, executeAt, sort, additionalData.
GET /api/tasks/list
Плоский список задач; фильтры AND.
Query params:
| Параметр | Тип | Описание |
|---|---|---|
status | string | CSV: working,waiting,finished,error,stop,stopWithError |
groupId | int | Группа |
accountId | int | account.id |
scriptId | int/string | Скрипт |
limit | int | Default 500, max 5000 |
Ответ: { message, count, tasks:[{ id, uuid, status, groupId, accountId, scriptId, executeAt }] }.
GET /api/tasks/active
Все working задачи с account:{id,name,accountId} и script:{id,name}.
POST /api/tasks/delete
Безвозвратное удаление задач. Body { id } или { ids:[...] }.
POST /api/tasks/stop
Останавливает working/waiting: status → stop + abort executor по uuid; closeBrowser:true дополнительно закрывает браузер аккаунта. Body { id } / { ids:[...] } / [{ id, uuid? }, ...], плюс closeBrowser?:bool.
Logs#
GET /api/tasks/logs?taskUuid=UUID (или ?taskId=UUID)
Текстовый лог по task.uuid (не numeric task.id), Content-Type: text/plain; charset=utf-8; можно читать во время выполнения. 404, если файл ещё не создан или удалён.
GET /api/scripts/run-logs?uuid=UUID
То же для прямых запусков через /api/scripts/run.
Emails - IMAP-учётные данные#
GET /api/emails/list
Список IMAP-учётных данных; пароли не возвращаются. Ответ: { message, count, emails:[{ id, email, imapServer, port, isActive, mailboxes }] }.
POST /api/emails/toggle
Включает/выключает IMAP-мониторинг: обновляет isActive и синхронно открывает/закрывает соединение. Body { email:"user@gmail.com", isActive:true }.
Error format#
Ошибка: HTTP 4xx/5xx + { "error": "Описание ошибки" }.
- 400 - required-поля / JSON.
- 401 - неверный/нет
x-api-key(соединение закрывается). - 404 - ресурс не найден.
- 500 - DB/внутренняя ошибка.
- Некоторые бизнес-ошибки идут HTTP 200 с
{ status:"error", code:"<i18n-key>", message:"..." }, например duplicate global vars.
Полный пример: запустить скрипт на аккаунтах с расписанием#
Сценарий: MintNFT (scriptId=12) с walletPassword, mintCount на аккаунтах 42/43/44, окно 08:00-20:00, repeat 2, параллелизм 5, старт 2026-05-11T09:00:00.000Z.
- Найти IDs:
GET /api/scripts/list;GET /api/profiles/list?groupId=5. - Проверить/прогреть прокси:
POST /api/proxies/check, body{ accountIds:[42,43,44] }. - Создать группу:
POST /api/task-groups/create, body{ tag:"MintNFT run", active:false, schedule:true, timeFrom:"08:00", timeTo:"20:00", isRepeatable:true, repeatCount:2, activeSession:5 }; сохранитьgroup.id. - Создать задачи:
POST /api/tasks/create, body{ groupId:7, tasks:[{accountId:42,scriptId:12,additionalData:{walletPassword:"pwd1",mintCount:"1"},executeAt:"2026-05-11T09:00:00.000Z"}, ...] }. - Активировать:
POST /api/task-groups/start, body{ id:7 }. - Мониторить:
GET /api/task-groups/get?id=7, затемGET /api/tasks/logs?taskUuid=<uuid>. - Управление:
GET /api/tasks/active;POST /api/tasks/stopwith{ ids:[100], closeBrowser:true };POST /api/task-groups/restart;POST /api/task-groups/hard-delete.
Прямое подключение к браузеру через CDP#
После POST /api/profiles/start используйте wsEndpoint.
Python pyppeteer: browser = await connect(browserWSEndpoint=ws_endpoint). Без CDP-клиента используйте POST /api/profiles/eval и POST /api/profiles/screenshot.
OAuth callback#
GET / или GET /oauth/callback?code=AUTH_CODE не требует api-key; используется Google Drive OAuth flow, передаёт code во внутренний канал и закрывает окно.
Source of truth#
Реализация: src-tauri/src/browser/server.rs. Если поведение endpoint'а отличается от документации, source code is source of truth.