{"components":{"schemas":{"EncryptedPoint":{"description":"Одна E2E-зашифрованная точка трека. Сервер хранит только шифрополя (iv+ciphertext+auth_tag), содержимого не видит.","properties":{"app_version_code":{"description":"сборка отправителя (необяз.)","type":"integer"},"auth_tag":{"description":"base64 тег GCM","type":"string"},"ciphertext":{"description":"base64 шифротекст","type":"string"},"group_id":{"type":"string"},"id":{"description":"клиентский UUID точки (идемпотентность по нему)","type":"string"},"iv":{"description":"base64 IV AES-GCM","type":"string"},"owner_id":{"type":"string"},"seq":{"description":"сквозной номер в треке отправителя (необяз.)","format":"int64","type":"integer"},"sig":{"description":"base64 ECDSA над каноном подписи точки (см. model.EncryptedPoint.SigMessages; каноны V1/V2/V3 — см. таблицу подписей в info.description); необязательно у клиентов ≤ build 43 или у ещё не закреплённого owner_id","type":"string"},"timestamp":{"description":"unix секунды","format":"int64","type":"integer"},"track_id":{"description":"id записанного трека (POST /api/tracks); опущено/пусто — точка вне трека. Server-side инкрементирует ТОЛЬКО tracks.point_count по этому полю, содержимого не связывает","type":"string"}},"required":["id","owner_id","group_id","timestamp","iv","ciphertext","auth_tag"],"type":"object"},"EncryptedPointV3":{"description":"Одна точка внутри EncryptedPointV3Bucket. id не передаётся — выводится из owner_id/track_id ведра и s (см. EncryptedPointV3Bucket).","properties":{"c":{"description":"base64(ciphertext || 16-байтный тег GCM) — сервер хранит их в тех же колонках ciphertext/auth_tag, что и V2 (SplitAuthTagV3/JoinAuthTagV3)","type":"string"},"iv":{"description":"base64 IV AES-GCM","type":"string"},"s":{"description":"seq — сквозной номер в треке (та же величина, что EncryptedPoint.seq)","format":"int64","type":"integer"},"sig":{"description":"base64 ECDSA над каноном point3|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003cts\u003e|\u003ctrack_id\u003e|\u003cs\u003e, id — выведенный uuid5","type":"string"},"ts":{"description":"unix секунды","format":"int64","type":"integer"}},"required":["s","ts","iv","c"],"type":"object"},"EncryptedPointV3Bucket":{"description":"Ведро V3-точек одного (group_id, track_id): заголовок один раз, точки — массивом. Сервер понимает только sv=3.","properties":{"g":{"description":"group_id","type":"string"},"o":{"description":"owner_id","type":"string"},"p":{"description":"точки этого ведра","items":{"$ref":"#/components/schemas/EncryptedPointV3"},"type":"array"},"sv":{"description":"версия конверта; сервер принимает и отдаёт только 3","type":"integer"},"t":{"description":"track_id — обязателен: V3 не кодирует точки без трека","type":"string"}},"required":["g","o","t","sv","p"],"type":"object"},"EncryptedProfile":{"description":"E2E-зашифрованный профиль участника (имя/аватар шифрованы). sig даёт членство; без sig профиль хранится, но участником не делает.","properties":{"auth_tag":{"type":"string"},"ciphertext":{"type":"string"},"group_id":{"type":"string"},"iv":{"type":"string"},"owner_id":{"type":"string"},"sig":{"description":"base64 ECDSA над «profile|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003cupdated_at\u003e|\u003cblob_fp\u003e»; без неё членство не даётся (короткий переходный канон без blob_fp — см. таблицу подписей в info.description)","type":"string"},"updated_at":{"description":"unix секунды (LWW), должно быть \u003e 0","format":"int64","type":"integer"}},"required":["owner_id","group_id","updated_at","iv","ciphertext","auth_tag"],"type":"object"},"Error":{"description":"Единая форма любой ошибки API. Ветвиться по error (стабильный машиночитаемый код), не по message.","properties":{"error":{"description":"Стабильный код: bad_request, signature_required, stale_timestamp, rate_limited и т.д. (см. info.description → Ошибки).","type":"string"},"message":{"description":"Человекочитаемое пояснение по-русски (не для ветвления).","type":"string"}},"required":["error"],"type":"object"}},"securitySchemes":{"bearerAuth":{"description":"Authorization: Bearer \u003ctoken\u003e из register/login — для /api/account/*.","scheme":"bearer","type":"http"},"ownerSig":{"description":"base64 ECDSA (P-256) над каноном маршрута; рядом идут owner_id и ts (см. таблицу подписей W732). Bearer здесь НЕ подходит (403 signature_required).","in":"query","name":"sig","type":"apiKey"}}},"info":{"description":"Человекочитаемое краткое оглавление (6 шагов quickstart + сжатая таблица подписей, W1865) — GET /api/help (HTML). Этот документ (GET /api) — полный, но большой (~130 КБ) JSON; машиночитаемая схема — GET /api/openapi.json. Сервис групповой геолокации с E2E-шифрованием. Координаты, имена групп/треков, профили — шифруются на клиенте; сервер хранит их только непрозрачными блобами и содержимого не видит. НО метаданные треков сервер держит ОТКРЫТЫМ ТЕКСТОМ (W936): started_at, ended_at, point_count у GET /api/tracks/{groupId}, плюс author и distance_m у GET /api/public-tracks. Это осознанно — по ним строится список треков и работает ретеншен, — но «только непрозрачные блобы» относится к СОДЕРЖИМОМУ (координатам/именам), а не к этим полям; они закрыты доступом (Bearer/подпись), чтобы по знанию group_id не восстанавливался социальный граф (W111), а не шифрованием. Группы создаются на клиенте (серверного «создать группу» нет) — сервер узнаёт о группе по первой загрузке точек/профиля под её group_id.\n\n## Аутентификация\nТри РАЗНЫХ способа аутентификации, не взаимозаменяемые. (1) Bearer-токен: заголовок Authorization: Bearer \u003ctoken\u003e из POST /api/account/register или /api/account/login — для /api/account/*. (2) Подпись ключом идентичности: relay-чтения (GET /api/invites/{recipientId}, /api/contact-requests/{recipientId}) и подписанные действия (удаление точек/профиля, отмена SOS) требуют параметры owner_id, ts и sig — base64 ECDSA (SHA256withECDSA, P-256) над канонической строкой, указанной в описании маршрута. Bearer здесь НЕ подходит и даёт 403 signature_required. (3) Без аутентификации: загрузка E2E-блобов (точки, профили) — сервер слеп к их содержимому. W1849 — мета группы (POST /api/groups/{groupId}/meta) СЮДА больше НЕ относится: с W1015-стадии2 подпись там безусловно обязательна (403 signature_required без sig) — это способ (2), а не (3); фраза выше была устаревшей. W1349 — сюда же относится ЧТЕНИЕ группы по знанию group_id: GET /api/points/{groupId}, /profiles/{groupId}, /head и т.п. отдают шифроблобы и открытые метаданные (owner_id, timestamp, seq, app_version_code) любому, кто знает group_id, — это ОСОЗНАННО: group_id работает как секрет-«капабилити» (см. правило про group_id ниже), расшифровать содержимое без ключа группы всё равно нельзя. Асимметрия с GET /api/tracks/{groupId} (тот под Bearer, W111) — историческая; сведение группового чтения к подписанному членству идёт поэтапно (сторожевые точки уже так, waypoints-list; для точек — фаза W1042, пока действует правило-капабилити). W1890 (userflow-аудит 06.09) — веб-ссылки (POST /api/web-shares, POST /api/web-shares/revoke) СЮДА тоже НЕ относятся, хотя раньше в каталоге были описаны как «открыто по знанию group_id»: создание и отзыв требуют Bearer вошедшего (плюс подпись у revoke, способ 2) — это способ (1)/(2), анти-грифинг-планка, а не капабилити-модель группового чтения. Открыто по знанию ТОЛЬКО одноразового токена ссылки (не group_id) — GET /api/web-shares/{token}: сервер слеп к секрету из фрагмента ссылки так же, как к содержимому group_id-чтений, но токен непредсказуем и не публикуется рядом с group_id.\n\n## Ошибки\nОшибки возвращаются единым JSON: {\"error\":\"\u003cмашиночитаемый_код\u003e\",\"message\":\"\u003cпояснение по-русски\u003e\"}. Ветвиться следует по error, не по message. Коды: bad_request, internal_error, missing_fields (в теле/параметрах нет обязательного поля — message называет какого), bad_query (не разобран параметр запроса: ?since=abc, ?limit=x), bad_signature (подпись ЕСТЬ и не сошлась), stale_timestamp (подпись могла быть верной, но метка ts разошлась со временем сервера: в теле server_time, client_ts, skew_seconds, max_skew — сверьте часы, переподписывать со старым ts бессмысленно), signature_required (нужна подпись ключом идентичности, а не Bearer), stale_update (409: на сервере уже запись новее, last-write-wins), too_many (слишком большой батч), method_not_allowed (405: путь есть, но метод не тот — напр. GET на POST-маршрут), unauthorized (Bearer отсутствует или неизвестен), session_expired (сессия БЫЛА, но истекла — «войдите снова», локальные данные НЕ стирать), account_deleted (токен от УДАЛЁННОГО аккаунта — ЕДИНСТВЕННЫЙ код, означающий «стирать локальные данные»), forbidden, not_found, rate_limited (+retry_after), invalid_credentials, nickname_taken, nickname_required, weak_password, owner_id_registered, owner_id_proof_required, key_revoked, password_required, bad_password, bad_old_password (текущий пароль в password/change не подошёл), invalid_code, too_many_attempts, cooldown (+message и заголовок Retry-After; в теле едут ОБА поля retry_after И retry_after_seconds — это ОДНО И ТО ЖЕ число секунд, W1408-алиас; ориентир — заголовок Retry-After), no_email, email_unverified, email_required, identity_change_requires_password, identity_not_changeable (W1154 — owner_id не связан НИ С ОДНИМ аккаунтом, сменить/добавить его ключ нечем подтвердить: пароля по нему нет; см. описание identity/revoke и раздел «Как правильно уйти»), revoke_not_authorised, code_unusable (410: код-приглашение отозван, или причина недоступности не установлена — см. code_expired/code_exhausted для более точных причин, W1952), code_expired (410: код-приглашение просрочен), code_exhausted (410: код-приглашение исчерпал max_uses), bad_email (адрес почты не похож на почту — проверяется при регистрации и email/set: без верной почты пароль не восстановить), owner_id_required (W1857: POST /api/invite-codes/{code}/redeem без owner_id в теле — по смыслу тот же «нет обязательного поля», что и missing_fields, но код отдельный, потому что владеет и второй ролью — идемпотентность повторного гашения ключуется по owner_id, W1347), delete_not_announced (W1900, userflow-аудит 06.09: 409 на POST /api/groups/{groupId}/ack-delete/{ownerId} ДО того, как удаление группы объявлено (нет надгробия) — группа и владелец существуют, подтверждать пока нечего; раньше на это место отвечал not_found, который читался как «нет такой группы»), invalid_email_code (W1902, userflow-аудит 06.09: ВСЕГДА 400 — неверный/просроченный/использованный 6-значный код ИЗ ПИСЬМА, POST /api/account/email/verify и .../password/reset; НЕ путать с invalid_code, который ВСЕГДА 404 и остаётся только за кодами-приглашениями группы, POST /api/invite-codes/{code}/redeem — прежде оба смысла жили за одним именем invalid_code, и клиент, ветвящийся по error, не мог их различить), code_group_deleted (W2206/W2256, userflow-аудит 15-17.09: ВСЕГДА 410 — код формально ещё существует (снимок группы не стёрт, W141/W2107), но группа уже объявлена к удалению или полностью закрыта; на GET /api/invite-codes/{code} (автору) и на POST /api/invite-codes/{code}/redeem — invalid_code сюда больше НЕ используется, он остаётся строго за 404 «кода нет»). ⚠ W1904 (userflow-аудит 06.09) — POST /api/account/register: если ОДНОВРЕМЕННО заняты и ник, и owner_id (например, ник чужой, а owner_id — уже ваш аккаунт), ответ детерминирован ограничениями таблицы (PRIMARY KEY на user_id/owner_id проверяется РАНЬШЕ UNIQUE(nickname) в схеме) — сервер ВСЕГДА вернёт owner_id_registered, а не nickname_taken, в этом случае; для интегратора это значит: owner_id_registered может прийти, даже когда сообщение кажется «про ник» — ветвитесь по error, не угадывайте по тому, что «более вероятно» задело.\n\n## Правила\n- МОДЕЛЬ ДОСТУПА ПО МАРШРУТАМ (W1751) — по знанию одного group_id разные маршруты дают РАЗНЫЙ вердикт, и это не баг, а разные модели на переходном пути. Матрица: (A) КАПАБИЛИТИ «group_id = секрет» — POST /api/points (запись точки → 202) и GET /api/points|profiles|head (чтение шифроблобов) открыты любому, кто знает group_id; расшифровать без ключа группы нельзя (E2E). (B) ПОДПИСЬ+ЧЛЕНСТВО — POST /api/sync/batch (чтение режется фазой C W1042, отказ несёт access_denied:true), GET /api/waypoints, commands-list, messages-list: нужен owner_id/ts/sig и доказанное членство. (C) BEARER — /api/account/*, GET /api/tracks/{groupId} (метаданные под токеном, W111). (D) ПОДПИСАННОЕ ДЕЙСТВИЕ — удаление точек/профиля/меты, delete-group, sos-cancel: подпись владельца. ⚠ ПРАКТИЧЕСКОЕ СЛЕДСТВИЕ (A): любой, узнавший group_id, может писать точки в группу, а убрать их сможет только их автор (удаление подписывается владельцем). Поэтому group_id — секрет (см. правило про него); сведе́ние записи точек к подписи членства — на подходе (та же фаза W1042, что уже действует для sync/batch).\n- ЧЕГО ЗДЕСЬ НЕТ И ПОЧЕМУ (W1758) — отсутствие маршрута такое же правило, как его наличие. (1) POST /api/groups (создать группу) НЕТ: группа рождается на клиенте — он сам генерирует group_id (случайный UUID) и ключ группы, а сервер узнаёт о группе по ПЕРВОЙ загрузке точки/профиля под этим group_id. Это следствие E2E: сервер не участвует в создании и не хранит ключ. (2) Серверного СПИСКА групп нет (ни «мои группы», ни «все группы»): по знанию одного group_id читаются только его блобы, перечислить группы нельзя — иначе group_id-секрет (см. правило про него) обходился бы перебором. (3) «Войти в группу» — это не запрос к серверу, а получение group_id+ключа по приглашению (invite-codes/redeem или relay-invite) и загрузка своего подписанного профиля (POST /api/profiles), после чего вы появляетесь в ростере. Ищете POST /api/groups — его не будет, начните с identity/register → invite.\n- КАНАЛ ПОДПИСИ ПО МАРШРУТАМ (W1375) — где ждут owner_id/ts/sig: в СТРОКЕ ЗАПРОСА (?owner_id=\u0026ts=\u0026sig=) — подписанные ЧТЕНИЯ (invites, contact-requests, waypoints-list) и большинство подписанных действий (удаление точек/меты/профиля, invite-codes create+revoke, web-shares/revoke); в ТЕЛЕ JSON (поле sig ИЛИ signature — принимаются оба, W1383) — delete-group, sos-cancel, identity/revoke, register-owner-key. Ошибка bad_signature/signature_required называет ПРИЧИНУ (W1405), но не канал — сверяйтесь с описанием конкретного маршрута. Частая ловушка: base64-подпись в query без URL-кодирования теряет «+» (сервер это чинит, W1382, но кодируйте у себя).\n- КАК ПРАВИЛЬНО УЙТИ (offboarding, W1398) — порядок ВАЖЕН, иначе останется мусор: (1) выйти из всех групп; (2) свою группу — DELETE /api/groups/{groupId}, затем КАЖДЫЙ участник подтверждает приём удаления через POST /api/groups/{groupId}/ack-delete/{ownerId} (канон «group-ack-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e») — иначе снимок не сотрётся физически; POST /api/groups/erased этого НЕ делает — он только читает статус и счётчик «N из M» (W1510); (3) ТОЛЬКО ПОТОМ отозвать личность — POST /api/identity/revoke (отзыв РАНЬШЕ удаления группы оставит «пустую оболочку» с занятым слотом владельца, которую уже нечем удалить, W1299); (4) удалить аккаунт — DELETE /api/account/me. ⚠ Шаги 3 и 4 РАЗНЫЕ и оба нужны: удаление аккаунта НЕ снимает TOFU-привязку owner_id↔public_key (W1385, by design — личность живёт и без аккаунта), а отзыв личности НЕ удаляет аккаунт. Начнёте с самого очевидного (DELETE /api/account/me) — оставите после себя и группу, и резолвящийся owner_id.\n- ЕДИНИЦЫ ВРЕМЕНИ ПО МАРШРУТАМ (W1399/W1818) — курсоры since и поля времени: unix-СЕКУНДЫ у points, profiles, group-meta, waypoints, tracks, sos, commands (W1793 — since сравнивается с received_at, СЕРВЕРНЫМ временем вставки, не с created_at автора), messages, account/state и contacts/book (updated_at; since там — не курсор выдачи, а «не новее» → 204, W1792), invite-codes (ts подписи); МИЛЛИСЕКУНДЫ — у overlays (совместные слои, since в мс) И у version вложений группы (PUT/GET /api/groups/{groupId}/blobs/{kind}, W770 — version это System.currentTimeMillis автора, не updated_at меты той же группы, которая рядом хранится в СЕКУНДАХ — сосед по маршруту другой единицы). ⚠ ts ПОДПИСИ (owner_id/ts/sig, таблица W732) — ВСЕГДА unix-СЕКУНДЫ, даже у маршрутов, чьи курсоры/версии в миллисекундах (overlays, group-blobs): единица подписи не наследуется от единицы данных маршрута, это два независимых поля. Спутать секунды/миллисекунды = ошибка в 1000 раз; часть маршрутов ловит это текстом 400 («since похож на миллисекунды»), но не все — сверяйтесь с этим правилом для нового поля времени, а не копируйте guard соседнего маршрута зеркально (см. gotcha-copied-guard-wrong-units, W1204/W1142).\n- СУДЬБА ЗАГРУЖЕННОЙ ТОЧКИ ПО МАРШРУТАМ (W1399) — куда что писать и как читать: POST /api/points и POST /api/sync/batch (upload) — сырые E2E-точки трека, читаются GET /api/points/{groupId} и в слотах sync/batch; POST /api/tracks — метаданные трека (id/owner/время), точки к нему — те же points, СВЯЗЬ — поле track_id точки (W1854, необязательно, см. схему EncryptedPoint), а не отдельный foreign-key маршрут; GET /api/tracks/{groupId}/{trackId} фильтрует именно по нему; POST /api/profiles — профиль участника (членство), читается manifest/GET профилей; POST /api/waypoints — сторожевые точки (метки), GET /api/waypoints (подпись). Точка, залитая в points, НЕ появляется в public-tracks без явного consent (POST /api/tracks/{id}/consent) и непустого трека.\n- owner_id — это ИДЕНТИЧНОСТЬ УСТРОЙСТВА, а не аккаунт: произвольная строка, которую клиент выводит из своего публичного ключа. Сервер её не выдаёт, а формат ФОРМАЛЬНО не диктует (owner_id — произвольная строка). ⚠ W2204 (userflow-аудит 15-17.09) — ПРЕЖДЕ здесь было написано «отвергает несовпадение»; это противоречило описанию /api/identity/register (W2083) и было НЕВЕРНО: в TOFU-регистрации сервер (cryptosig.OwnerIDForPubKey) ТОЛЬКО ЗАМЕРЯЕТ, выведен ли присланный owner_id из этой формулы (W1370, диагностика в логе), но НЕ НАВЯЗЫВАЕТ совпадение — несовпадение НЕ отвергается (enforcement отложен до решения по итогам замера). Тем не менее owner_id ОФИЦИАЛЬНОГО клиента ОБЯЗАН быть выведен именно так — гарантия сегодня держится на клиенте, не на серверной проверке. ⚠ W1958 (userflow-аудит 06.09) — ФОРМУЛА ОПУБЛИКОВАНА ВПЕРВЫЕ (раньше в каталоге было только «UUID из отпечатка», без деталей): owner_id = UUIDv3 RFC 4122 (name-based, MD5) от СЫРЫХ БАЙТ public_key ПОСЛЕ base64-декодирования (X.509 SubjectPublicKeyInfo/SPKI, тот же формат, что в W732) — то есть UUID.nameUUIDFromBytes(DER-байты ключа) на Android: MD5(DER) с выставленными битами версии (0x30 в 7-м байте) и варианта (0x80 в 9-м байте) по RFC 4122, отформатированный как 8-4-4-4-12 hex. MD5 здесь НЕ криптостойкость (соображение подделки закрывает отдельно подпись P-256) — только точное побайтовое совпадение с UUIDv3 клиента; при ошибке base64-декодирования сервер (и клиент) считают MD5 от СЫРЫХ БАЙТ строки как фолбэк, чтобы деривация совпадала бит-в-бит в этом крайнем случае тоже. Сервер лишь ЗАКРЕПЛЯЕТ пару owner_id ↔ ключ при первой регистрации (TOFU). Дальше действует правило: тем же ключом — можно переregистрировать сколько угодно (идемпотентно), ДРУГИМ ключом — нужен либо Bearer владельца аккаунта, либо подпись прежним ключом; иначе отказ. ⚠ КОД ОТКАЗА ЗАВИСИТ ОТ ПУТИ, и это стоило стороннему разработчику отладки: POST /api/identity/register при ПЕРВИЧНОЙ привязке через аккаунт отвечает owner_id_registered или owner_id_proof_required, а он же при СМЕНЕ ключа без верного пароля — identity_change_requires_password (W928: отдельного маршрута /api/relay/keys НЕ существует — им когда-то был этот). Смысл один: «этот owner_id уже закреплён за другим ключом, докажите право». Отсюда практическое следствие: выбирайте owner_id, который невозможно угадать, — занятый чужим ключом идентификатор вернуть себе нечем.\n- W732 — БАЙТОВЫЕ ФОРМАТЫ КЛЮЧА И ПОДПИСИ (как сервер их РЕАЛЬНО проверяет, см. cryptosig). public_key — открытый ключ P-256 (secp256r1) в X.509 SubjectPublicKeyInfo (PKIX/SPKI), закодированный в base64 (StdEncoding). sig/signature — ECDSA «SHA256withECDSA»: сервер хеширует каноническую строку SHA-256 и проверяет подпись как ASN.1 DER (r,s) через ecdsa.VerifyASN1 — то есть DER, НЕ «сырой» P1363/(r‖s). Кодировка подписи — base64. Любая ошибка разбора = подпись неверна (fail-closed). Проверяется по ЛЮБОМУ действующему ключу из связки owner_id (мультидевайс).\n- W732 — ТАБЛИЦА ПОДПИСЕЙ (маршрут → каноническая строка → где параметры → нужен ли свежий ts). Подписанные ДЕЙСТВИЯ (окно ±3600 с): точки-delete «points-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» (query); профиль-delete «profile-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» (query); мета-delete «meta-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» (query); удаление приглашения «invite-delete|\u003cid\u003e|\u003crecipient_owner_id\u003e|\u003cts\u003e» (query, W824 — DELETE /api/invites/{id}, owner_id обязан совпасть с получателем); выход из группы «group-leave|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» (query, окно 300 с); подтверждение удаления «group-ack-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» (query); ключ владельца «group-owner-key|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» (тело, окно 300 с, против ПРИСЛАННОГО public_key); ПЕРЕДАЧА владения (W2135/W2112) — ДВЕ подписи в теле: from «owner-transfer|\u003cgroup_id\u003e|\u003cfrom_owner_id\u003e|\u003cto_owner_id\u003e|\u003cfrom_ts\u003e» (против слота/связки from) + to «owner-accept|\u003cgroup_id\u003e|\u003cto_owner_id\u003e|\u003cto_ts\u003e» (proof-of-possession против to_public_key); вложение группы «blob|\u003cgroup_id\u003e|\u003ckind\u003e|\u003cversion\u003e|\u003cowner_id\u003e|\u003cts\u003e» (query, PUT /api/groups/{groupId}/blobs/{kind}, W770 — version в вложении МИЛЛИСЕКУНДЫ, не секунды); удаление группы «delete-group|\u003cgroup_id\u003e» (тело, БЕЗ ts); отмена SOS «sos-cancel|\u003cid\u003e|\u003cowner_id\u003e|\u003ccanceled_at\u003e» (тело); отзыв ключа «key-revoke|\u003cowner_id\u003e|\u003cpublic_key\u003e|\u003cts\u003e» (тело); создание кода «invite-create|\u003cgroup_id\u003e|\u003csender_owner_id\u003e|\u003ccode_hash\u003e|\u003ckey_blob\u003e|\u003cttl_seconds\u003e|\u003cmax_uses\u003e|\u003cts\u003e» (query); отзыв кода «invite-revoke|\u003ccode_hash\u003e|\u003cowner_id\u003e|\u003cts\u003e» (query); отзыв веб-ссылок группы «webshare-revoke|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» (query, снимает ВСЕ токены группы). Подписанные ЧТЕНИЯ (окно ±300 с): входящие приглашения «invite-list|\u003crecipient\u003e|\u003cts\u003e»; заявки в контакты «contactreq-list|\u003crecipient\u003e|\u003cts\u003e»; удаление заявки в контакты «contactreq-delete|\u003cid\u003e|\u003crecipient\u003e|\u003cts\u003e» (query, действие); связка устройств «devices|\u003cowner_id\u003e|\u003cts\u003e»; сторожевые точки «waypoints-list|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e»; поиск личности «identity-lookup|\u003cowner_id\u003e|\u003cts\u003e»; просмотр кода автором «invite-code-info|\u003ccode_hash\u003e|\u003cowner_id\u003e|\u003cts\u003e»; счётчики удаления «erased-groups|\u003cowner_id\u003e|\u003cts\u003e». Групповые чтения (окно ±3600 с, как у всей семьи групповых чтений): список обмена «sync-list|\u003cowner_id\u003e|\u003cts\u003e» (ТЕЛО POST /api/sync/batch, поля owner_id/list_sig/list_ts, W1545) — ДОКАЗЫВАЕТ owner_id читателя; при включённой фазе C фильтра чтения (W1042_ENFORCE_GROUP_READ) сервер режет разделы группы по членству доказанного владельца, а отказ несёт в ответе по группе «access_denied»:true (различимо от пустых данных); быстрые команды «commands-list|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» и отложенные сообщения «messages-list|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» (query, W1534 — без подписи 403 signature_required, не участник группы с verified-составом 403 forbidden); профили участников «profiles-list|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» (query GET /api/profiles/{groupId} и /manifest — сейчас фаза B: сервер МЕРИТ долю подписанных, ещё НЕ режет). Подписи БЕЗ ts (одноразовость обеспечена иначе): точка — ⚠ W1896 (userflow-аудит 06.09) — ТРИ КАНОНА ПОДПИСИ ТОЧКИ ПО ВЕРСИИ (см. model.EncryptedPoint.SigMessages в коде — единственный источник истины, ниже цитируется дословно), различаются РАЗНЫМ ПРЕФИКСОМ, поэтому подпись одной версии никогда не пройдёт как другая: V1 «point|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003ctimestamp\u003e» — применяется, когда у точки НЕТ track_id (общий обмен вне записи трека), и ПЕРЕХОДНО как второй кандидат для точек С track_id от старых сборок (≤1307, W1833); V2 «point|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003ctimestamp\u003e|\u003ctrack_id\u003e|\u003cseq\u003e» — ОСНОВНОЙ канон для точек С track_id (все обычные точки записи трека начиная со сборки 1307, W1733), проверяется ПЕРВЫМ, затем перебором V1; V3 «point3|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003cts\u003e|\u003ctrack_id\u003e|\u003cseq\u003e» — ЕДИНСТВЕННЫЙ канон для протокола V3 (sv=3, POST /api/sync/batch поле upload_v3, W1835/W1800), префикс «point3|» НЕ «point|» — намеренно другой байт в начале строки, id здесь не выбирается клиентом, а ВЫВОДИТСЯ как uuid5(NSSpoorPoint,«\u003cowner_id\u003e#\u003ctrack_id\u003e#\u003cseq\u003e»), см. docs/design/wire-protocol-v3.md; V3 НЕ перебирает V1/V2 — это свойство КОНТЕКСТА пачки (обычная точка через POST /api/points или upload[] всегда проверяется как V1/V2, никогда как V3), а не самой записи. сторожевая «waypoint|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003cupdated_at\u003e|\u003cdeleted\u003e»; слой «overlay|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003cupdated_at\u003e|\u003cdeleted\u003e»; команда «command|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003ccreated_at\u003e» (её отмена — «command-cancel|\u003cid\u003e|\u003cowner_id\u003e|\u003cts\u003e», СВЕЖИЙ ts, W895; старая форма без ts принимается переходно); сообщение «message|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003ccreated_at\u003e|\u003cdeliver_after\u003e|\u003cexpires_at\u003e» (его отмена — «message-cancel|\u003cid\u003e|\u003cowner_id\u003e|\u003cts\u003e», СВЕЖИЙ ts, W1110; старая форма без ts переходно); профиль «profile|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003cupdated_at\u003e|\u003cblob_fp\u003e», где blob_fp = hex(sha256(«\u003civ\u003e|\u003cciphertext\u003e|\u003cauth_tag\u003e»)) в нижнем регистре (W966; короткий канон без blob_fp принимается ПЕРЕХОДНО для сборок ≤756, будет снят); статус трека «track-status|\u003ctrack_id\u003e|\u003cowner_id\u003e|\u003cstatus\u003e|\u003cupdated_at\u003e».\n- W742 — «ОПУЩЕНО ≠ 0». ⚠ W2258(3) (userflow-аудит 15-17.09) — ПРЕЖНЯЯ ФОРМУЛИРОВКА ЗДЕСЬ БЫЛА НЕВЕРНОЙ: в точках поля seq и app_version_code помечены `omitempty` (model.EncryptedPoint) и при нулевом значении (точка БЕЗ трека, старые/импортированные записи) КЛЮЧ ИЗ JSON ПРОПАДАЕТ ПОЛНОСТЬЮ — не «сериализуется как 0». Отсутствие ключа И значение 0 (если клиент сам его прислал явным нулём) означают ОДНО и то же — «не задано/неизвестно»: если оно важно, проверяйте по наличию поля, а не полагайтесь на то, что оно «всегда придёт». То же в запросе sync/batch: опущенное *_since означает «этого не спрашиваю» и НЕ равно 0 («спрашиваю с начала»); а в ответе раздел, который не запрашивали, отсутствует, тогда как запрошенный пустой приходит как [].\n- X-GROUP-DELETED / X-GROUP-ERASED — ЕДИНЫЙ МАРКЕР НА КАЖДОМ ГРУППОВОМ ЧТЕНИИ (W1947/W1959, userflow-аудит 06.09). Заголовки аддитивны (тело ответа НЕ меняется) и стоят теперь на ВСЕХ перечисленных ниже маршрутах, а не только на points, как было раньше (см. подробный разбор семантики в описании GET /api/points/{groupId}): GET /api/points/{groupId}, GET /api/points/{groupId}/head, GET /api/profiles/{groupId}, GET /api/profiles/{groupId}/manifest, GET /api/groups/{groupId}/meta, GET /api/groups/{groupId}/owner-key, GET /api/overlays/{groupId}. X-Group-Deleted: 1 — удаление ОБЪЯВЛЕНО (надгробие есть), но снимок ещё жив (кворум ack-delete не набрался); X-Group-Erased: 1 — данные УЖЕ стёрты физически. Оба взаимоисключающие; announced=false (обычная живая группа) — НИ ОДИН заголовок не ставится. ⚠ Эта пара — НЕ то же самое, что поле state в ответе POST /api/groups/erased (то же различие deleted/erased, но в ТЕЛЕ, а не в заголовке, и только по явному запросу конкретных group_id, а не на каждом чтении).\n- Пароль аккаунта — не короче 10 символов (register, password/change, password/reset). Он же защищает резервные копии ключей групп (Argon2id), поэтому длина здесь не формальность: короткий пароль вскрывается офлайн по дампу БД.\n- Почта при регистрации необязательна, но БЕЗ неё восстановить пароль невозможно — другого канала подтверждения личности нет. Задать почту позже: POST /api/account/email/set (Bearer + пароль) + подтверждение кодом.\n- DELETE /api/account/me требует И Bearer, И пароль в теле запроса: {\"password\":\"…\"}. Токен доказывает, что телефон разблокирован, а не что это владелец. Без пароля — 400 password_required.\n- Погашение кода-приглашения (POST /api/invite-codes/{code}/redeem) НЕ выдаёт ключ группы: сервер его никогда не видит. Ключ доставляется отдельным E2E-каналом (relay-приглашение, зашифрованное ECIES на ключ получателя) либо в key_blob, зашифрованном на клиенте. Без ключа точки группы расшифровать нельзя — это и есть E2E.\n- Пустые ответы — норма, а не ошибка: POST /api/identity/lookup для неизвестных owner_id вернёт [], GET /api/account/state нового аккаунта вернёт {\"blob\":\"\",\"updated_at\":0} (ещё ничего не сохранено).\n- POST /api/account/password/forgot всегда отвечает 202 с одинаковым телом — существует аккаунт или нет. Это намеренно: иначе эндпоинт стал бы способом проверять чужие адреса.\n- Ограничение частоты (429 rate_limited + заголовок Retry-After): POST /api/points, GET /api/account/nickname-available, POST /api/account/password/forgot, POST /api/account/login.\n- Вход по никнейму регистронезависим («Ivan» и «ivan» — один аккаунт); никнейм уникален без учёта регистра.\n- Резервные копии (настройки+ключи групп, книга контактов, оригиналы треков, личный сейф) шифруются МАСТЕР-КЛЮЧОМ аккаунта, а пароль шифрует только его (POST /api/account/master-key). Поэтому смена пароля перезаворачивает 32 байта и не трогает сами копии. Сервер не видит ни мастер-ключ, ни данные.\n- УДАЛЕНИЕ СВОИХ ДАННЫХ (право на забвение) подписывается ключом идентичности — Bearer здесь не подходит. Канонические строки для sig (base64 ECDSA, SHA256withECDSA, P-256; параметры owner_id, ts, sig в query; ts — unix-секунды, принимается только свежий): точки — «points-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e»; профиль — «profile-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e»; мета группы — «meta-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e». Отмена SOS устроена так же, но её подпись передаётся В ТЕЛЕ: {owner_id, canceled_at, signature} над «sos-cancel|\u003cid\u003e|\u003cowner_id\u003e|\u003ccanceled_at\u003e». Проверка идёт по ЛЮБОМУ действующему ключу из связки owner_id (мультидевайс).\n- Параметр ?since по умолчанию 0 — это «с начала», поэтому запрос без since и с since=0 равнозначны (points, profiles, meta, tracks). Нечисловой since — 400 bad_query, а не молчаливый 0: иначе сервер незаметно переотдавал бы всю историю по лесному каналу.\n- Асимметрия «точка сохраняется без подписи, а профиль как будто нет» объясняется так: НЕподписанный профиль ХРАНИТСЯ (сервер слеп к содержимому), но не даёт членства в группе (W158) — в ответе POST /api/profiles он виден как unsigned. А невидимым в GET его делает другое: updated_at ≤ 0, потому что выборка идёт по курсору updated_at \u003e since. Ставьте реальный updated_at.\n- В ответе sync/batch раздел, который вы ЗАПРОСИЛИ, присутствует всегда — пустым массивом, если данных нет; раздел, о котором не спрашивали, отсутствует вовсе. Это зеркало правила запроса «опущено не равно 0»: иначе «в группе никого» и «мой флаг не дошёл» дают байт в байт одинаковый ответ. ИСКЛЮЧЕНИЕ (W1545): при включённой фазе C фильтра чтения (W1042_ENFORCE_GROUP_READ) читатель, не являющийся членом группы с verified-участниками, получает по этой группе НЕ пустые разделы, а маркер «access_denied»:true — так «нет доступа» отличается от «нет данных». Чтобы разделы не резались зря, шлите подпись «sync-list|\u003cowner_id\u003e|\u003cts\u003e» (см. таблицу W732): без неё owner_id не доказан и вы не пройдёте проверку членства.\n- W827/W1819 — КОДЫ УСПЕХА РАЗНЫЕ, И ЭТО НЕ СЛУЧАЙНОСТЬ, а обещание разной силы. ПРАВИЛО ВЫБОРА: 200 — «вот готовый ответ» (тело — это ИТОГ операции для ВЫЗЫВАЮЩЕГО, а не резервуар-«ресурс» для чужого URI): login/register (⚠ РЕГИСТРАЦИЯ СОЗДАЁТ АККАУНТ, но отвечает 200, не 201 — тело {token, user} это токен ДЛЯ ЭТОГО вызова, у аккаунта нет отдельного GET-URI, который стоило бы анонсировать Location; тот же довод у redeem — {joined, group_id, key_blob?}, ответ клиенту, не карточка ресурса). 201 — создано И у созданного есть самостоятельная адресуемая карточка, которую отдают целиком (invite-codes: код читается позже по GET /api/invite-codes/{code}; web-shares — токен ссылки читается по GET /api/web-shares/{token}). 202 — ПРИНЯТО К ОБРАБОТКЕ: запись сохранена, но её итог узнаётся отдельным чтением (точки, профили, мета, команды, диагностика, tracks/consent). 204 — сделано, показывать нечего (удаления). Ветвиться следует по классу (2xx), а не по конкретному числу. (W1297: прежняя шероховатость «POST /api/account/profile отвечает пустым телом» ИСПРАВЛЕНА — маршрут возвращает 202 {full_name:…}, перечитывать имя через GET /api/account/me больше не нужно.)\n- POST /api/points никогда не отвечает 403 на плохую точку: структурно негодные (пустые id/owner_id/group_id/iv/ciphertext/auth_tag или неправдоподобный timestamp) и точки с неверной подписью ОТБРАСЫВАЮТСЯ и перечисляются в rejected_ids ответа. 400 — только если СТРУКТУРНЫЙ разбор не оставил ни одной точки (все с пустыми полями или невозможным timestamp). Если точки разобрались, а отпали ПО ПОДПИСИ — ответ 202 с пустым accepted_ids и полным rejected_ids: два вида отбраковки различаются намеренно, потому что лечатся по-разному, и клиент, ждущий 400 на подделку, его не дождётся. Причина: durable-очередь клиента читает ошибку как «нет связи» и повторяет тот же батч вечно — одна кривая точка заклинила бы весь трек телефона.\n- POST /api/account/login дополнительно тормозит подбор пароля: после нескольких НЕУДАЧНЫХ попыток подряд ответ на этот никнейм задерживается (нарастающе, до ~2 с). Считаются только неудачи — успешный вход обнуляет счётчик, оборванный запрос не считается вовсе. Задержка одинакова для существующих и несуществующих ников: иначе время ответа выдавало бы наличие аккаунта.\n- group_id — ЭТО СЕКРЕТ, обращайтесь с ним как с паролем (W1036). GET /api/points/{groupId} и большинство групповых чтений не требуют ничего, кроме ЗНАНИЯ group_id: кто его узнал — читает шифроблобы группы (расшифровать без ключа группы всё равно нельзя — это и есть E2E, но метаданные и сам факт активности видны). Ротации group_id у живой группы НЕТ: сменить его нечем, утёкший идентификатор не отзывается. Поэтому клиент НЕ должен показывать group_id в интерфейсе как безобидный технический номер, класть его в логи/аналитику или ссылки. Более строгое, подписанное по членству чтение — на подходе для части каналов (сторожевые точки уже так и читаются, W1017); для точек пока действует именно это правило.\n- user_id (register/login/me, тело AuthResponse.user) == owner_id (точки/профили/подписи, поле owner_id канонических строк) == account_id (GET /api/tracks/{groupId}, поле Track.account_id) — ОДИН И ТОТ ЖЕ UUID у аккаунта, привязанного к identity по owner_id при регистрации (W113e, см. шаг 2 quickstart «пришлите owner_id»). Три ИМЕНИ существуют потому, что это три РАЗНЫХ РОЛИ одного значения, увиденные из трёх разных подсистем: user_id — субъект аккаунта (сессии/Bearer), owner_id — субъект identity-подписи (E2E, ключи), account_id — чья это запись в серверных метаданных трека (W936). Аккаунт БЕЗ owner_id на шаге регистрации получает СОБСТВЕННЫЙ случайный user_id, никак не совпадающий ни с одним owner_id, — тогда три имени называют РАЗНЫЕ значения (или account_id вовсе пуст — трек не залогиненного устройства). Переименовать поля в проводе нельзя — старые клиенты их уже читают под этими именами.\n- КОНВЕРТ У register/login/me РАЗНЫЙ, И ЭТО НЕ БАГ, А НЕ ВЫРОВНЕННЫЙ СО ВРЕМЕНЕМ КОНТРАКТ (W1861): POST /api/account/register → {token, user, multi_device_available, email_code_sent, email_code_retry_after_sec?, email_code_ttl_sec?} — поля ВОКРУГ user, потому что регистрация несёт события, которых у login/me не бывает (W1037: «второе устройство доступно?», W932: «письмо с кодом ушло?»). ⚠ W1951/W1903 (userflow-аудит 06.09) — email_code_retry_after_sec/email_code_ttl_sec ПРИСУТСТВУЮТ, ТОЛЬКО когда email_code_sent:true (то же условие, что у самого email_code_sent): называют, через сколько секунд можно повторно дёрнуть POST /api/account/email/send-code (тот же кулдаун, что вернёт САМ send-code, если его дёрнуть сразу после регистрации) и через сколько секунд код истечёт — раньше это приходилось узнавать вторым запросом или вслепую. POST /api/account/login → {token, user} — те же два базовых поля, БЕЗ регистрационных флагов (они не про сессию входа). GET /api/account/me → ГОЛЫЙ user (даже БЕЗ token — сессия уже есть, доказывать нечем). Во всех трёх user — ОДНА и та же модель AccountInfo; поля register/login специфичны для СОБЫТИЯ создания сессии, а не альтернативные представления профиля. Провод не выравниваем (сломает готовые клиенты, которые уже достают token верхним уровнем) — этот абзац и есть выравнивание документацией.\n\n## Errors (EN)\nErrors are always JSON: {\"error\":\"\u003cstable_code\u003e\",\"message\":\"\u003cRussian explanation\u003e\"}. Branch on error, never on message. Codes: bad_request (malformed JSON — see missing_fields for a well-formed body missing a field), internal_error, missing_fields (a required field is absent — message names which one), bad_query (?since=abc, ?limit=x could not be parsed), bad_signature (a signature WAS sent and did not verify), stale_timestamp (the signature may have been valid, but ts drifted from server time — body carries server_time/client_ts/skew_seconds/max_skew; re-signing with the same ts is pointless, sync your clock), signature_required (this route needs an identity-key signature, not a Bearer token), stale_update (409: a newer record already exists — last-write-wins), too_many (batch exceeds a documented bound), method_not_allowed (405: right path, wrong HTTP method), unauthorized (missing/unknown Bearer), session_expired (the session existed and expired — log in again, do NOT wipe local data), account_deleted (the token belonged to a DELETED account — the ONLY code that means wipe local data), forbidden, not_found, rate_limited (+retry_after), invalid_credentials, nickname_taken, nickname_required, weak_password, owner_id_registered, owner_id_proof_required, key_revoked, password_required, bad_password, bad_old_password, invalid_code, too_many_attempts, cooldown (+ Retry-After header; body carries retry_after AND retry_after_seconds — same number of seconds, W1408 alias), no_email, email_unverified, email_required, identity_change_requires_password, identity_not_changeable (this owner_id is not linked to any account — there is no password to prove ownership with), revoke_not_authorised, code_unusable (410: invite code revoked, or the reason could not be established), code_expired (410: invite code past its expiry), code_exhausted (410: invite code hit max_uses), bad_email, owner_id_required (POST /api/invite-codes/{code}/redeem with no owner_id in the body — same underlying cause as missing_fields, but a separate code because owner_id also drives idempotency of a repeat redeem, W1347), delete_not_announced (409 on POST /api/groups/{groupId}/ack-delete/{ownerId} BEFORE the group's deletion is announced — the group and owner exist, there is simply nothing to acknowledge yet; used to be not_found, which read as «no such group»), invalid_email_code (ALWAYS 400 — a wrong/expired/used 6-digit code from an EMAIL, on email/verify and password/reset; do NOT confuse with invalid_code, which is ALWAYS 404 and stays for group invite codes only, invite-codes/{code}/redeem — both used to share one name and a branching client could not tell them apart).\n\n## Rules (EN)\n- THREE AUTH METHODS, not interchangeable. (1) Bearer token: Authorization: Bearer \u003ctoken\u003e from POST /api/account/register or /api/account/login — for /api/account/*. (2) Identity-key signature: relay reads (GET /api/invites/{recipientId}, /api/contact-requests/{recipientId}) and signed actions (deleting points/profile, cancelling SOS, etc.) require owner_id, ts and sig — base64 ECDSA (SHA256withECDSA, P-256) over the canonical string named in the route's description. Bearer does NOT work there → 403 signature_required. (3) No auth: uploading E2E blobs (points, profiles) — the server is blind to their content. W1849 — group meta (POST /api/groups/{groupId}/meta) is NOT one of these any more: since W1015-stage2 a signature is unconditionally required (403 signature_required with no sig) — that is method (2), not (3); the old wording was stale. This method (3) also covers READING a group by knowing its group_id (GET /api/points/{groupId}, /profiles/{groupId}, /head): open to anyone who knows group_id — see the group_id-is-a-secret rule below. W1890 — web-share links (POST /api/web-shares, POST /api/web-shares/revoke) are NOT method (3) either, despite older wording: creating/revoking a share link requires Bearer (plus a signature on revoke, method 2) — only GET /api/web-shares/{token} is open by knowledge of a one-time link token (not group_id).\n- SIGNATURE STRING TABLE (W732) — route → canonical string → where the parameters travel → whether a fresh ts is required. Full table (Russian, kept as the single source of truth to avoid two copies drifting apart) is in the Rules array above; key entries translated: point upload — THREE canons by version (W1896, see model.EncryptedPoint.SigMessages): V1 'point|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003ctimestamp\u003e' (no track_id on the point, or a legacy build ≤1307 with one); V2 'point|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003ctimestamp\u003e|\u003ctrack_id\u003e|\u003cseq\u003e' (the main canon whenever the point carries a track_id, checked first, V1 tried as fallback); V3 'point3|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003cts\u003e|\u003ctrack_id\u003e|\u003cseq\u003e' (upload_v3 buckets only, sv=3, distinct prefix so no canon can be mistaken for another — id is derived as uuid5(NSSpoorPoint, owner#track#seq), see docs/design/wire-protocol-v3.md); points delete 'points-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e' (query, ±3600s window); profile 'profile|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003cupdated_at\u003e|\u003cblob_fp\u003e' where blob_fp=hex(sha256(iv|ciphertext|auth_tag)) lowercase; sync/batch read gate 'sync-list|\u003cowner_id\u003e|\u003cts\u003e' (in the BODY: owner_id/list_sig/list_ts fields, ±3600s); delete-group 'delete-group|\u003cgroup_id\u003e' (body, NO ts); sos-cancel 'sos-cancel|\u003cid\u003e|\u003cowner_id\u003e|\u003ccanceled_at\u003e' (body); group-blob 'blob|\u003cgroup_id\u003e|\u003ckind\u003e|\u003cversion\u003e|\u003cowner_id\u003e|\u003cts\u003e' (query — W1817, version is MILLISECONDS unlike most other version/updated_at fields, which are seconds).\n- SIG vs SIGNATURE (W1816) — the field NAME for a signature is not uniform across routes: points/profiles/waypoints/commands/messages/overlays use `sig`; delete-group, sos-cancel, and the signature INSIDE the SOS plain-JSON payload use `signature`. As of this audit pass the server accepts BOTH spellings as synonyms on delete-group and sos-cancel (send either — see requestBody in /api/openapi.json), but the canonical/documented name stays route-specific: check the route's description rather than assuming.\n- TIME UNITS (W1399/W1818) — unix SECONDS for points, profiles, group-meta, waypoints, tracks, sos, commands, messages, account/state, contacts/book (updated_at), invite-codes (ts of the signature); MILLISECONDS for overlays (updated_at/since) AND for the `version` field of group blobs (PUT/GET /api/groups/{groupId}/blobs/{kind}, W770 — that route's own owner_id/ts SIGNATURE is still seconds; only `version` is milliseconds, because it's the sender's System.currentTimeMillis()). Getting this wrong is a 1000x error; some routes catch a millisecond value on a seconds field with a named 400, not all of them — check this rule for any new time field before assuming the unit.\n- SUCCESS CODES (W827/W1819) — 200: the response body IS the answer for this call, not a resource with its own GET URI (login, register — registration DOES create an account, but 200 not 201, because {token,user} is a per-call answer, not a fetchable resource card; same reasoning for invite-codes/{code}/redeem). 201: created AND the created thing has its own addressable representation returned in full (invite-codes: readable later via GET /api/invite-codes/{code}; web-shares: readable via GET /api/web-shares/{token}). 202: ACCEPTED FOR PROCESSING — stored, but the outcome is read back separately (points, profiles, meta, commands, tracks/consent). 204: done, nothing to show (deletions). Branch on the status CLASS (2xx), not the exact number.\n- group_id IS A SECRET — treat it like a password. GET /api/points/{groupId} and most group reads require nothing but KNOWING group_id: whoever learns it can read the group's encrypted blobs (cannot decrypt without the group key — that is E2E — but metadata and the mere fact of activity ARE visible). There is no rotation for a live group's group_id: a leaked identifier cannot be revoked. Do not surface it as an innocuous technical number in the UI, and do not put it in logs/analytics/links.","title":"Spoor API","version":"1"},"openapi":"3.1.0","paths":{"/api/account/avatar":{"get":{"description":"СВОЙ аватар аккаунта (Bearer, W933): 200 {avatar, updated_at}. Пусто (аватар не задан) — не ошибка: {avatar:\"\", updated_at:0}. Прежде аватар был write-only","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"СВОЙ аватар аккаунта"},"post":{"description":"загрузить аватар аккаунта: {avatar} base64 JPEG (Bearer). W933: формат проверяется по сигнатуре FF D8 FF — не-JPEG → 400; пустая строка очищает аватар","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"загрузить аватар аккаунта"}},"/api/account/email/send-code":{"post":{"description":"отправить код подтверждения на уже заданную почту (Bearer). ⚠ W2329 (userflow-аудит 23.09) — если почта УЖЕ подтверждена, письмо НЕ уходит: 200 {status:\"already_verified\"} без создания нового кода (раньше уходил настоящий код на подтверждённый адрес, хотя verify его в этом состоянии не проверяет вовсе — см. /api/account/email/verify)","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"отправить код подтверждения на уже заданную почту"}},"/api/account/email/set":{"post":{"description":"задать/сменить почту: {email, password}; смена recovery-почты требует пароль (W401); непустая почта запускает код подтверждения (Bearer)","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"type":"string"},"password":{"type":"string"}},"required":["password"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"задать/сменить почту"}},"/api/account/email/verify":{"post":{"description":"подтвердить почту кодом (Bearer): {code}. 400 invalid_email_code на неверный/просроченный/использованный код несёт reason + attempts_left + resend (см. errors). ⚠ W2234 — ЧТО ПРИ attempts_left=0: блокировки по ВРЕМЕНИ здесь нет — код вместо этого СРАЗУ аннулируется (reason:\"attempts_exhausted\", отдельно от reason:\"code_invalid\", у которого попытки ещё есть); повторный ЛЮБОЙ, даже верный, код примут только после заказа нового — POST /api/account/email/send-code (см. resend в теле). ⚠ W2240 (userflow-аудит 16.09) — ПОСЛЕ аннулирования следующий запрос (код уже удалён) отвечает 400 invalid_email_code, reason:\"code_missing\" (а НЕ too_many_attempts — ветка тайм-аута недостижима, код к этому моменту снесён): читать как «кода нет, закажите новый», лечение то же — send-code. ⚠ W2259(1) (userflow-аудит 15-17.09) — ЭТО ЖЕ code_missing раньше приходило и на идемпотентный ПОВТОР вызова ПОСЛЕ успеха (успешная verify удаляет код, поэтому повтор тоже не находит его) — теперь в этом случае (почта уже подтверждена на аккаунте) ответ 200 + AccountInfo, тот же, что у штатного первого успеха, а не ошибка. ⚠ W2329 (userflow-аудит 23.09) — В ЭТОЙ ЖЕ ВЕТКЕ присланный code НЕ СМОТРИТСЯ ВООБЩЕ (сверить его уже не с чем — код удалён предыдущим успехом): ответ несёт явное дополнительное поле already_verified:true, чтобы клиент не читал это как «код проверен и совпал»","requestBody":{"content":{"application/json":{"schema":{"properties":{"code":{"type":"string"},"email":{"type":"string"}},"required":["code"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"подтвердить почту кодом"}},"/api/account/login":{"post":{"description":"вход: {nickname, password} → {token, user}. 401 invalid_credentials и на неверный пароль, и на несуществующий ник — намеренно неразличимо","requestBody":{"content":{"application/json":{"schema":{"properties":{"nickname":{"type":"string"},"password":{"type":"string"}},"required":["nickname","password"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"token":{"type":"string"},"user":{"properties":{"email":{"type":"string"},"email_verified":{"type":"boolean"},"full_name":{"type":"string"},"nickname":{"type":"string"},"recovery_available":{"description":"true ⇔ почта подтверждена → можно сбросить ПАРОЛЬ по почте. ⚠ W2286: это НЕ гарантия восстановления эскроу-бэкапа ключей групп — после сброса пароля эскроу остаётся завёрнут под прежним паролем (см. master_wrapped_stale в GET /api/account/state) и требует перезаворачивания на устройстве с живыми ключами","type":"boolean"},"user_id":{"type":"string"}},"type":"object"}},"required":["token","user"],"type":"object"}}},"description":"Успех — см. описание маршрута (description) для полной семантики полей."},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"вход"}},"/api/account/logout":{"post":{"description":"завершить ТЕКУЩУЮ сессию (Bearer): токен становится недействителен сразу. W1321: сессия ГАСИТСЯ истечением (не удаляется), поэтому дальнейшее предъявление этого токена даёт session_expired (а не unauthorized) — клиент отличит «меня разлогинили, данные НЕ стирать» от «токен неизвестен». W2230: ответ 200 {sessions_remaining:\u003cсколько ДРУГИХ входов ещё живо\u003e, logout_all:\"POST /api/account/logout?all=1\" (только если remaining\u003e0)} — выход гасит лишь ЭТО устройство, и тело честно называет остаток. ?all=1 — погасить ВСЕ сессии (включая текущую) сразу, ответ 200 {logged_out_all:true, sessions_remaining:0}; это в отличие от смены пароля, которая гасит все КРОМЕ текущей. Уже мёртвый/истёкший токен по-прежнему даёт идемпотентный 204","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"завершить ТЕКУЩУЮ сессию"}},"/api/account/master-key":{"post":{"description":"мастер-ключ эскроу (Bearer): {wrapped, password, replace}. ⚠ password ОБЯЗАТЕЛЕН ВСЕГДА (W366/W1150), не только при replace: Bearer доказывает разблокированный телефон, а не владельца, а перезапись эскроу — необратимая потеря восстановления; сверяется с хешем как у DELETE /api/account/me. Нет password → 400 password_required, неверный → 401 bad_password. wrapped = AES-GCM(Argon2id(пароль), мастер-ключ) — сервер видит только шум. replace=false: записать, ТОЛЬКО если ключа ещё нет (compare-and-set), ответ содержит действующий ключ — возможно, чужой, его и надо принять. replace=true: смена пароля, перезапись. Действующий ключ также отдаётся в GET /api/account/state (master_wrapped)","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"мастер-ключ эскроу"}},"/api/account/me":{"delete":{"description":"удалить аккаунт и личные данные: Bearer И пароль в теле {password} (иначе 400 password_required). Необратимо; групповая история остаётся по политике удаления. ОТВЕТ 200 {identity_retained (W2229 — по ФАКТУ: остались ли у owner_id действующие ключи; false, если личность УЖЕ отозвана), group_data_retained (W2259(4), userflow-аудит 15-17.09: ВСЕГДА true — это не заглушка, а факт инварианта W141: удаление аккаунта НЕ трогает данные в группах ни при каком состоянии, их снимает только PurgeGroupData по кворуму), left_groups}. ⚠ W2243 (userflow-аудит 16.09) — left_groups это число групп, из кворума ack-delete которых вас ИСКЛЮЧИЛИ (чтобы удаление такой группы не висело на вас вечно, W2209), а НЕ число групп, где стёрты ваши данные: ваш профиль и точки ОСТАЮТСЯ в ростере/выдаче (W141 — след экспедиции это лид для поиска), поэтому manifest группы может по-прежнему показывать ваш owner_id — это не рассинхрон. Хотите убрать И данные из группы — выйдите заранее через DELETE /api/groups/{groupId}/membership. ⚠ W1385: удаление АККАУНТА НЕ снимает TOFU-привязку owner_id↔public_key — это ОСОЗНАННО (аккаунт и личность разные: устройство может пользоваться личностью и без аккаунта, W1024). Чтобы owner_id перестал резолвиться в identity/lookup, отзовите ЛИЧНОСТЬ отдельно — POST /api/identity/revoke — ПОСЛЕ выхода из групп (см. «Как правильно уйти» в rules). ⚠ W2260 (userflow-аудит 17.09) — в ответе теперь есть next[]: конкретные следующие шаги ПО ФАКТУ этого аккаунта (совет отозвать личность, если она ещё резолвится; напоминание, что данные в группах остаются намеренно, W141)","requestBody":{"content":{"application/json":{"schema":{"properties":{"password":{"type":"string"}},"required":["password"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"удалить аккаунт и личные данные"},"get":{"description":"профиль текущего аккаунта (Bearer)","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"профиль текущего аккаунта"}},"/api/account/nickname-available":{"get":{"description":"проверка, свободен ли ник: ?nickname=… → {available: bool}. ⚠ W1960 (userflow-аудит 06.09) — КАРАНТИН НИКА 30 СУТОК после удаления чужого аккаунта: available:false для ника, освобождённого DELETE /api/account/me менее 30 дней назад, — иначе ник мог тут же занять кто угодно, включая злоумышленника, выдающего себя за ушедшего участника группы (опаснее всего сразу после «права на забвение»: человек стёрся, а через минуту кто-то другой отвечает от его имени). Ограничение частоты","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"проверка, свободен ли ник"}},"/api/account/password/change":{"post":{"description":"сменить пароль (тело {old_password, new_password}, Bearer + текущий пароль; new_password ≥ 10 символов). ⚠ Гасит все ОСТАЛЬНЫЕ сессии, а ТЕКУЩУЮ (токен из этого запроса) намеренно оставляет живой — телефон в руке не разлогинивает сам себя (W222). Ответ 200 {revoked_sessions:\u003cсколько чужих погашено\u003e}. То есть «выйти отовсюду, КРОМЕ этого устройства», а не со всех разом. ⚠ W2129/W2310 (security-аудит 21.09) — заодно гасит (revoked=true) ВСЕ веб-сессии кабинета (вход по QR): раньше эта ручка их не трогала вовсе, и угнанная веб-сессия переживала смену пароля без следа","requestBody":{"content":{"application/json":{"schema":{"properties":{"new_password":{"type":"string"},"old_password":{"type":"string"}},"required":["old_password","new_password"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"сменить пароль"}},"/api/account/password/forgot":{"post":{"description":"запросить код сброса пароля: {email}. ⚠ W2259(2) (userflow-аудит 15-17.09) — ПРЕЖНЯЯ ФОРМУЛИРОВКА «Всегда 202» БЫЛА НЕТОЧНА: 202 с одинаковым телом — анти-энумерация ПОСЛЕ того, как поле email непусто; пустой/отсутствующий email (или тело с другим полем, напр. {nickname} — сервер читает именно email, остальное игнорирует) отвечает 400 email_required — это НЕ про приватность чужого аккаунта (email тут вообще не назван), а про пустое тело своего запроса (W298: «клиент не задал что восстанавливать» — детектируемо без оракула, потому что не ссылается ни на один конкретный аккаунт). Письмо уходит, только если почта известна и подтверждена. Код ПРИМЕНЯЕТСЯ на POST /api/account/password/reset (см. ниже)","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"type":"string"}},"required":["email"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"запросить код сброса пароля"}},"/api/account/password/reset":{"post":{"description":"применить код сброса пароля: {email, code, new_password (≥10)} → 200 {status:\"ok\"} (W2258, userflow-аудит 15-17.09: раньше тело было ПУСТЫМ — единственное исключение среди успехов этого маршрута). Пара к password/forgot: forgot шлёт код на почту, reset его принимает и ставит новый пароль. Исходы: invalid_code (код неверен/просрочен/использован), too_many_attempts (перебор кода — подождать), weak_password (короче 10). ⚠ Меняет пароль → гасит прочие сессии, как password/change (W1412); с W2129/W2310 заодно гасит и все веб-сессии кабинета (см. password/change)","requestBody":{"content":{"application/json":{"schema":{"properties":{"code":{"type":"string"},"email":{"type":"string"},"new_password":{"type":"string"}},"required":["email","code","new_password"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"применить код сброса пароля"}},"/api/account/profile":{"post":{"description":"обновить имя профиля: {full_name} (Bearer). Патч-семантика: тело БЕЗ full_name имя не трогает. Ответ 202 {full_name:\u003cсохранённое\u003e} — имя возвращается сразу (W1297); почта задаётся отдельно — /api/account/email/set","requestBody":{"content":{"application/json":{"schema":{"properties":{"full_name":{"description":"патч: тело БЕЗ full_name имя не трогает (W1297)","type":"string"}},"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"обновить имя профиля"}},"/api/account/register":{"post":{"description":"регистрация: {nickname, password (≥10 символов), email (необязательно, но без него нет восстановления пароля; если прислан — проверяется формат, «не-почта» → 400 bad_email ДО создания аккаунта, W1147), owner_id (СИЛЬНО РЕКОМЕНДУЕТСЯ прислать СВОЙ owner_id из /api/identity/register)} → {token, user}. ⚠ owner_id — это ЕДИНСТВЕННЫЙ момент связывания аккаунта с личностью: user_id аккаунта усыновляет присланный owner_id (если ключ уже закреплён — нужен proof-of-possession: подпись «account-claim|\u003cowner_id\u003e|\u003cts\u003e» со СВЕЖИМ ts, W113e/W219 — бесподписная легаси-форма без ts снята). НЕ прислали owner_id → сервер заведёт СЛУЧАЙНЫЙ user_id, и тогда добавить второе устройство к вашему owner_id будет НЕЛЬЗЯ никогда (ветка «пришлите пароль» в identity/register станет недостижимой — пароля по этому owner_id не существует, W1411). В user есть email, email_verified и recovery_available (W1396: true ⇔ почта подтверждена; false = НЕТ восстановления пароля и эскроу-бэкапов ключей групп — предупредите пользователя СРАЗУ, а не когда пароль уже забыт). Те же поля в login и /api/account/me","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"type":"string"},"full_name":{"type":"string"},"nickname":{"type":"string"},"owner_id":{"description":"принять account.user_id = owner_id устройства (переносимость личности); при усыновлении УЖЕ закреплённого owner_id обязательны owner_sig/owner_sig_ts","type":"string"},"owner_sig":{"description":"base64 ECDSA-подпись над claim; обязательна, когда owner_id называет уже закреплённую личность (иначе чужой owner_id не примут)","type":"string"},"owner_sig_ts":{"description":"unix-секунды подписи claim (окно свежести)","format":"int64","type":"integer"},"password":{"type":"string"}},"required":["nickname","password"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"email_code_retry_after_sec":{"description":"присутствует, ТОЛЬКО когда email_code_sent=true — кулдаун до повторного /api/account/email/send-code","format":"int64","type":"integer"},"email_code_sent":{"description":"true, только если почта прислана, похожа на почту, и код успешно создан/сохранён","type":"boolean"},"email_code_ttl_sec":{"description":"присутствует, ТОЛЬКО когда email_code_sent=true — через сколько секунд код истечёт","format":"int64","type":"integer"},"multi_device_available":{"description":"заполняется ТОЛЬКО при регистрации; доступно ли второе устройство (аккаунт привязан к owner_id)","type":"boolean"},"token":{"type":"string"},"user":{"properties":{"email":{"type":"string"},"email_verified":{"type":"boolean"},"full_name":{"type":"string"},"nickname":{"type":"string"},"recovery_available":{"description":"true ⇔ почта подтверждена → можно сбросить ПАРОЛЬ по почте. ⚠ W2286: это НЕ гарантия восстановления эскроу-бэкапа ключей групп — после сброса пароля эскроу остаётся завёрнут под прежним паролем (см. master_wrapped_stale в GET /api/account/state) и требует перезаворачивания на устройстве с живыми ключами","type":"boolean"},"user_id":{"type":"string"}},"type":"object"}},"required":["token","user"],"type":"object"}}},"description":"Успех — см. описание маршрута (description) для полной семантики полей."},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"регистрация"}},"/api/account/state":{"get":{"description":"E2E-бэкап настроек аккаунта (Bearer). ⚠ W1814 — ФОРМА ТЕЛА PUT (раньше нигде не была названа): {blob (строка, base64 AES-GCM, ОБЯЗАТЕЛЬНО непустая), updated_at? (unix-секунды; 0/опущено → сервер проставит своё время, W163), avatar_blob? (W233: отдельный blob аватара, пусто = «без изменений», НЕ «стереть»)}. Тело, которое не разбирается как JSON, → 400 bad_request; РАЗОБРАВШЕЕСЯ тело без blob → 400 missing_fields — это РАЗНЫЕ ошибки с разным лечением (переслать корректный JSON vs добавить поле), прежде обе отвечали одним bad_request. PUT сравнивает updated_at: копия СТАРЕЕ сохранённой отклоняется с 409 stale_update — в блобе лежат настройки и ключи групп, и второе устройство того же аккаунта не должно затирать свежую копию своей старой (W926). GET принимает ?since=\u003cupdated_at\u003e (unix-СЕКУНДЫ, та же единица, что updated_at в теле ответа): если хранимая копия не новее since — 204 без тела, экономя полный блоб на каждый цикл сверки; нечисловой since → 400 bad_query; без параметра — как раньше (W1792)","parameters":[{"description":"с какого unix-времени отдавать (единицы — см. правило по маршрутам; по умолчанию 0)","in":"query","name":"since","required":false,"schema":{"type":"integer"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"E2E-бэкап настроек аккаунта"},"put":{"description":"E2E-бэкап настроек аккаунта (Bearer). ⚠ W1814 — ФОРМА ТЕЛА PUT (раньше нигде не была названа): {blob (строка, base64 AES-GCM, ОБЯЗАТЕЛЬНО непустая), updated_at? (unix-секунды; 0/опущено → сервер проставит своё время, W163), avatar_blob? (W233: отдельный blob аватара, пусто = «без изменений», НЕ «стереть»)}. Тело, которое не разбирается как JSON, → 400 bad_request; РАЗОБРАВШЕЕСЯ тело без blob → 400 missing_fields — это РАЗНЫЕ ошибки с разным лечением (переслать корректный JSON vs добавить поле), прежде обе отвечали одним bad_request. PUT сравнивает updated_at: копия СТАРЕЕ сохранённой отклоняется с 409 stale_update — в блобе лежат настройки и ключи групп, и второе устройство того же аккаунта не должно затирать свежую копию своей старой (W926). GET принимает ?since=\u003cupdated_at\u003e (unix-СЕКУНДЫ, та же единица, что updated_at в теле ответа): если хранимая копия не новее since — 204 без тела, экономя полный блоб на каждый цикл сверки; нечисловой since → 400 bad_query; без параметра — как раньше (W1792)","requestBody":{"content":{"application/json":{"schema":{"properties":{"avatar_blob":{"description":"W233: отдельный blob аватара; пусто на PUT значит «без изменений» (клиент шлёт только когда фото сменилось), НЕ «стереть»","type":"string"},"blob":{"description":"base64 AES-GCM — E2E-блоб настроек и ключей групп; сервер содержимого не видит","type":"string"},"updated_at":{"description":"unix секунды; 0/опущено → сервер проставит своё время (W163)","format":"int64","type":"integer"}},"required":["blob"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"E2E-бэкап настроек аккаунта"}},"/api/account/track-originals/{trackId}":{"delete":{"description":"убрать СВОЙ оригинал/область спутник-кэша с сервера (Bearer, W1632). Scoped by user_id; идемпотентно (нет записи → 204). Приложение зовёт для удаления черновика области (webarea-…), чтобы старые области не копились","parameters":[{"in":"path","name":"trackId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"убрать СВОЙ оригинал/область спутник-кэша с сервера"}},"/api/account/tracks":{"get":{"description":"СВОИ треки (Bearer), новые первыми — и personal (без group_id), и групповые. Этим клиент восстанавливает список треков после входа на новом устройстве, затем берёт точки через /api/account/tracks/{trackId}/points","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"СВОИ треки"}},"/api/account/tracks/{trackId}/points":{"get":{"description":"точки СВОЕГО трека (Bearer), курсор ?since=\u0026limit= как у /api/points. Владение проверяется: чужой трек → 404","parameters":[{"in":"path","name":"trackId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"точки СВОЕГО трека"}},"/api/app/critical-version":{"get":{"description":"минимальная критическая сборка (задаётся в БД без передеплоя): по ней клиент решает, обязательно ли обновление","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"минимальная критическая сборка"}},"/api/commands":{"post":{"description":"отправить группе быструю команду (Стоп/Опасность/Едем): массив {id, group_id, owner_id, created_at, iv, ciphertext, auth_tag, sig}. Тип команды, текст и координаты автора зашифрованы ключом группы — сервер к ним слеп. Подпись ОБЯЗАТЕЛЬНА, канон «command|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003ccreated_at\u003e». Хранится по своему ttl_seconds (Едем/Стоп 30 мин, Опасность 30 дней) и удаляется: устаревшая команда опаснее отсутствующей, её примут за новую. W1793 — НЕВАЛИДНАЯ запись массива больше НЕ обрывает разбор остальных: отбрасывается индивидуально (bad_signature/malformed/owner_mismatch/future_timestamp), разбор продолжается. ОТВЕТ 202: {accepted, rejected?, rejected_ids?} — rejected: [{id, reason}], rejected_ids — тот же список плоско (оба поля опущены, если отбросов нет). Частотный потолок по автору по-прежнему обрывает ответ целиком 429 (это общий бюджет, не приговор одной записи)","requestBody":{"content":{"application/json":{"schema":{"properties":{"auth_tag":{"type":"string"},"ciphertext":{"type":"string"},"created_at":{"format":"int64","type":"integer"},"group_id":{"type":"string"},"id":{"type":"string"},"iv":{"type":"string"},"owner_id":{"type":"string"},"sig":{"type":"string"}},"required":["id","owner_id","group_id"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"отправить группе быструю команду"}},"/api/commands/{groupId}":{"get":{"description":"команды группы новее ?since=\u003cunix\u003e (до 100). ЧТЕНИЕ ПОДПИСАНО и по членству (W1534): ?owner_id\u0026ts\u0026sig, канон «commands-list|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e»; без подписи 403 signature_required, не участник группы с verified-составом — 403 forbidden. Отдаётся по СОБСТВЕННОМУ ttl каждой команды (created_at+ttl_seconds ещё в будущем). W1793 — курсор since сравнивается с received_at (СЕРВЕРНЫМ временем фактической вставки строки), не с created_at (временем автора): отложенная «Опасность», досланная позже из офлайн-очереди отправителя, получает received_at момента вставки и потому доходит до тех, чей курсор уже ушёл вперёд её старого created_at. Каждая команда несёт своё received_at в ответе (unix-секунды). Клиент берёт команды общим обменом (commands_since — тот же received_at-курсор), этот маршрут — для внешней сверки","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}},{"description":"с какого unix-времени отдавать (единицы — см. правило по маршрутам; по умолчанию 0)","in":"query","name":"since","required":false,"schema":{"type":"integer"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"команды группы новее ?since=\u003cunix\u003e"}},"/api/commands/{id}":{"delete":{"description":"забрать СВОЮ команду (отправил «Опасность» по ошибке — не ждать полчаса до уборки). Подпись: ?owner_id=\u0026sig= над «command-cancel|\u003cid\u003e|\u003cowner_id\u003e» → 204. Чужую — 403 forbidden. Уже удалённая или истёкшая → 204, цель достигнута","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"забрать СВОЮ команду"}},"/api/contact-requests":{"post":{"description":"отправить запрос в контакты: {id, recipient_owner_id, sender_owner_id, ciphertext}. ⚠ sender_owner_id ОБЯЗАТЕЛЕН (W1150) — в отличие от /api/invites, где отправитель не требуется","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"отправить запрос в контакты"}},"/api/contact-requests/{id}":{"delete":{"description":"убрать запрос в контакты, когда он обработан (W1161). Как и у invites, удаляет ПОЛУЧАТЕЛЬ: ?owner_id=\u0026ts=\u0026sig= над «contactreq-delete|\u003cid\u003e|\u003crecipient\u003e|\u003cts\u003e», owner_id обязан совпасть с получателем. Неизвестный id (или получатель без закреплённого ключа) → 204 без действия, идемпотентно; существующий запрос с плохой подписью → 403. Парный DELETE /api/invites/{id} — рядом","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"убрать запрос в контакты, когда он обработан"}},"/api/contact-requests/{recipientId}":{"get":{"description":"входящие запросы в контакты. Нужна подпись получателя: ?owner_id=\u0026ts=\u0026sig= → иначе 403 signature_required. ⚠ КАНОН ДРУГОЙ, чем у invites (W1151): «contactreq-list|\u003crecipient_owner_id\u003e|\u003cts\u003e» (у invites — «invite-list|\u003crecipientId\u003e|\u003cts\u003e»). Выводить строку из отсылки «как у invites» нельзя — канон это часть контракта, а не иллюстрация","parameters":[{"in":"path","name":"recipientId","required":true,"schema":{"type":"string"}},{"description":"идентичность читателя (подписанное чтение)","in":"query","name":"owner_id","required":false,"schema":{"type":"string"}},{"description":"unix-секунды подписи (окно свежести)","in":"query","name":"ts","required":false,"schema":{"type":"integer"}},{"description":"base64 ECDSA над каноном чтения (см. таблицу подписей W732)","in":"query","name":"sig","required":false,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"входящие запросы в контакты"}},"/api/contacts/book":{"get":{"description":"E2E-бэкап книги контактов (Bearer). ТЕЛО PUT: {blob (строка, base64 AES-GCM, ОБЯЗАТЕЛЬНО непустая), updated_at (unix секунды)}. W1814: не-JSON тело → 400 bad_request; разобравшееся тело без blob → 400 missing_fields (разные ошибки, разное лечение). PUT — last-write-wins по updated_at: старее сохранённой → 409 с текущей копией для слияния. GET принимает ?since=\u003cupdated_at\u003e (unix-СЕКУНДЫ, как updated_at в теле ответа): хранимая копия не новее since → 204 без тела; нечисловой since → 400 bad_query; без параметра — как раньше (W1792)","parameters":[{"description":"с какого unix-времени отдавать (единицы — см. правило по маршрутам; по умолчанию 0)","in":"query","name":"since","required":false,"schema":{"type":"integer"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"E2E-бэкап книги контактов"},"put":{"description":"E2E-бэкап книги контактов (Bearer). ТЕЛО PUT: {blob (строка, base64 AES-GCM, ОБЯЗАТЕЛЬНО непустая), updated_at (unix секунды)}. W1814: не-JSON тело → 400 bad_request; разобравшееся тело без blob → 400 missing_fields (разные ошибки, разное лечение). PUT — last-write-wins по updated_at: старее сохранённой → 409 с текущей копией для слияния. GET принимает ?since=\u003cupdated_at\u003e (unix-СЕКУНДЫ, как updated_at в теле ответа): хранимая копия не новее since → 204 без тела; нечисловой since → 400 bad_query; без параметра — как раньше (W1792)","requestBody":{"content":{"application/json":{"schema":{"properties":{"blob":{"description":"base64 AES-GCM (iv+ciphertext+tag) — E2E-блоб книги контактов; сервер содержимого не видит","type":"string"},"updated_at":{"description":"unix секунды, LWW-версия (see errStaleUpdate → 409, если на сервере копия новее)","format":"int64","type":"integer"}},"required":["blob"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"E2E-бэкап книги контактов"}},"/api/groups/erased":{"post":{"description":"узнать, какие из названных групп СТЁРТЫ, и ЖИВОЙ подсчёт «приняли N из M» (W1407). ⚠ W2202 (userflow-аудит 15-17.09) — ИМЯ ПОЛЯ `erased[]` ИСТОРИЧЕСКИ ВРЁТ: оно несёт id ЛЮБОЙ группы с надгробием — и уже физически стёртой, и только что ОБЪЯВЛЕННОЙ к удалению (снимок ещё жив, W141) — переименовать нельзя (backward-compat, старые клиенты читают именно этот ключ). `announced[]` — АДДИТИВНОЕ поле с ТЕМ ЖЕ списком под честным именем для новых клиентов. Строгое «стёрто физически» проверяйте ТОЛЬКО по `purged:true` у конкретной группы в `erased_detail` — не по самому факту присутствия в erased[]/announced[]. ⚠ ТОЛЬКО ЧТЕНИЕ: ack удаления этот маршрут НЕ пишет (W1510). Подтверждение приёма делает ОТДЕЛЬНЫЙ маршрут POST /api/groups/{groupId}/ack-delete/{ownerId} с ДРУГИМ каноном подписи «group-ack-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e»; пока каждый участник его не вызвал, кворум на физическое стирание снимка не набирается. Подпись В СТРОКЕ ЗАПРОСА ?owner_id=\u0026ts=\u0026sig= (канон «erased-groups|\u003cowner_id\u003e|\u003cts\u003e»), тело — {group_ids:[…]} проверяемых групп. Ответ несёт по каждой группе {group_id, signer_owner_id, signature, acked_count?, members_count?, purged, state} (в sync/batch — свой, БЕДНЕЙШИЙ вариант без acked_count/members_count/state, они там оба нуля — живой полный счёт только здесь) и надгробие с подписью удалившего. ⚠ W1946/W1943 (userflow-аудит 06.09) — acked_count/members_count здесь ОПУСКАЮТСЯ (поля нет в JSON), когда подсчёт пропущен (нет подписи спрашивающего, ошибка запроса к БД) — раньше в этом случае поле уезжало НУЛЁМ, неотличимым от честного «у группы 0 участников»; ОТСУТСТВИЕ поля читайте как «не измерено», а не как 0. ⚠ W1947/W1959 — state — единый текстовый маркер: «deleted» (удаление ОБЪЯВЛЕНО, надгробие есть, физического стирания ещё не было) или «erased» (данные УЖЕ стёрты физически, PurgeGroupData прошла) — вместо того чтобы каждый клиент сам выводил смысл из purged. ⚠ W2241 (userflow-аудит 16.09) — КОГДА СЧЁТЧИКИ ЕСТЬ, А КОГДА НЕТ (и почему это НЕ то же, что у DELETE): здесь acked_count/members_count отдаются, ТОЛЬКО если (а) запрос подписан (owner_id/ts/sig, канон erased-groups) И (б) подписавший АФФИЛИИРОВАН с группой — подтверждённый участник ИЛИ владелец её owner-key (W967/W1984 — иначе счётчики группы стали бы соц-графом для любого, кто знает group_id). У СТЁРТОЙ группы (purged) числа финальные и отдаются всегда. Именно поэтому ответ DELETE /api/groups/{groupId} даёт счётчики даже сразу после объявления (там владение доказано подписью delete-group), а этот маршрут на той же стадии их опустит тому, кто аффилиацию не доказал — асимметрия намеренная, не баг","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"announced":{"description":"W2202 — тот же список, что erased[], под честным именем: группы с надгробием (объявлена к удалению ИЛИ уже стёрта); строгое «стёрта физически» — по erased_detail[].purged","items":{"type":"string"},"type":"array"},"erased":{"description":"устаревшее имя (BACKWARD-COMPAT) — на деле «объявленные ИЛИ стёртые», см. announced и erased_detail[].purged","items":{"type":"string"},"type":"array"},"erased_detail":{"items":{"properties":{"acked_count":{"description":"ОТСУТСТВУЕТ (не 0), когда подсчёт не измерен — не путать с честным нулём","format":"int64","type":"integer"},"group_id":{"type":"string"},"members_count":{"description":"ОТСУТСТВУЕТ (не 0), когда подсчёт не измерен — не путать с честным нулём","format":"int64","type":"integer"},"purged":{"type":"boolean"},"signature":{"type":"string"},"signer_owner_id":{"type":"string"},"state":{"description":"deleted — удаление объявлено, снимок ещё жив; erased — данные стёрты физически","enum":["deleted","erased"],"type":"string"}},"required":["group_id","signer_owner_id","signature","purged","state"],"type":"object"},"type":"array"}},"required":["erased","erased_detail"],"type":"object"}}},"description":"Успех — см. описание маршрута (description) для полной семантики полей."},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"узнать, какие из названных групп СТЁРТЫ, и ЖИВОЙ подсчёт «приняли N из M»"}},"/api/groups/{groupId}":{"delete":{"description":"объявить группу удалённой: тело {signer_owner_id, signature} (W1949 — ОБА поля принимают синоним: signature/sig, W1383; И signer_owner_id/owner_id — второе имя специально принято, потому что все ОСТАЛЬНЫЕ подписанные маршруты, включая membership, называют звонящего owner_id, и это единственный эндпоинт с другим именем; каноничные — signer_owner_id/signature, owner_id/sig — совместимые синонимы), канон «delete-group|\u003cgroup_id\u003e», подпись — ключом владельца из слота ИЛИ любым ключом его связки устройств (смена телефона). Создателю группы — см. шаг 3 quickstart (POST .../owner-key ПЕРВЫМ, иначе этот DELETE ответит 404). ⚠ W2239 (userflow-аудит 16.09) — ОТВЕТ 200 С ТЕЛОМ, НЕ 204 (прежний текст обещал 204 — устарел после пакета кворума W2107): {group_id, announced:true, members_count, acked_count (живой подсчёт «приняли N из M»), purged (true → снимок уже стёрт физически, кворум собран), state («deleted» → объявлено, снимок жив; «erased» → стёрто), next (пока не purged → «POST /api/groups/{groupId}/ack-delete/{ownerId}» — следующий шаг offboarding)}. Повторный DELETE на УЖЕ объявленной/стёртой группе тоже 200 с текущим снимком счётчиков (событие применяется однажды, но статус отдаётся всегда). ⚠ 404 (W1620) — это НЕ «сервер сломался»: у группы не зарегистрирован слот владельца, значит объявлять удаление нечем (сначала POST /api/groups/{groupId}/owner-key). Идемпотентность 204↔204 действует ПОСЛЕ первого удаления; 404 бывает у легаси-группы, чей создатель не заявил ключ, или после ухода всех участников (см. W1612). ДАННЫЕ НЕ УНИЧТОЖАЮТСЯ (W141): остаётся снимок треков и последних позиций — это то, с чего начинают поиск человека, — и надгробие, по которому участники узнают об удалении. Живые приглашения и коды вступления с W1016 гасятся НЕ здесь, а при первом подтверждении удаления настоящим участником: иначе один запрос обрывал сбор группы в поход","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"owner_id":{"description":"синоним signer_owner_id — примите ОДНО из двух полей","type":"string"},"sig":{"description":"синоним signature — примите ОДНО из двух полей","type":"string"},"signature":{"description":"base64 ECDSA над «delete-group|\u003cgroup_id\u003e» (каноническое имя поля — signature; sig принимается как синоним)","type":"string"},"signer_owner_id":{"description":"каноническое имя поля; owner_id принимается как совместимый синоним (как у остальных подписанных маршрутов)","type":"string"}},"required":["signer_owner_id","signature"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"acked_count":{"description":"живой подсчёт ДО стирания; финальный снимок ПОСЛЕ purged=true","format":"int64","type":"integer"},"announced":{"type":"boolean"},"group_id":{"type":"string"},"members_count":{"description":"живой подсчёт ДО стирания; финальный снимок ПОСЛЕ purged=true","format":"int64","type":"integer"},"next":{"description":"опущено, когда purged=true — подтверждать больше нечего","type":"string"},"purged":{"description":"true — снимок группы уже стёрт физически (кворум ack собран)","type":"boolean"},"state":{"description":"deleted — объявлено, снимок жив; erased — стёрто физически","enum":["deleted","erased"],"type":"string"}},"required":["group_id","announced","members_count","acked_count","purged","state"],"type":"object"}}},"description":"Успех — см. описание маршрута (description) для полной семантики полей."},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"объявить группу удалённой"}},"/api/groups/{groupId}/ack-delete/{ownerId}":{"post":{"description":"подтвердить, что группа удалена НА ВАШЕМ устройстве: ?ts=\u0026sig= над «group-ack-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e». ⚠ W2203/W2258 (userflow-аудит 15-17.09) — ОТВЕТ 200 С ТЕЛОМ, НЕ 204 (прежний текст здесь ошибался; см. groupDeletionStatus, тот же формат, что у DELETE /api/groups/{groupId}, W2239): {group_id, announced:true, members_count, acked_count, purged, state, next?}. Физическая чистка всех данных группы (22 таблицы, включая точки) происходит, только когда подтвердили ВСЕ подтверждённые участники. Требуется: подпись сошлась; вы — подтверждённый участник этой группы (403 forbidden); удаление объявлено, то есть надгробие есть — иначе 409 delete_not_announced «удаление группы не объявлено, подтверждать нечего» (W1900, userflow-аудит 06.09: раньше тот же отказ шёл кодом not_found, который читался как «нет такой группы/подтверждения», хотя причина другая — группа и владелец существуют, просто удаление ещё не объявлено; ветвиться теперь по отдельному коду, а не по тексту). Если подтверждённых участников в группе нет ни одного, это НЕ читается как «все подтвердили» — данные остаются (W1022: прежде такой группе хватало одного запроса от постороннего, чтобы стереть всё необратимо). Частота ограничена","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}},{"in":"path","name":"ownerId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"acked_count":{"description":"живой подсчёт ДО стирания; финальный снимок ПОСЛЕ purged=true","format":"int64","type":"integer"},"announced":{"type":"boolean"},"group_id":{"type":"string"},"members_count":{"description":"живой подсчёт ДО стирания; финальный снимок ПОСЛЕ purged=true","format":"int64","type":"integer"},"next":{"description":"опущено, когда purged=true — подтверждать больше нечего","type":"string"},"purged":{"description":"true — снимок группы уже стёрт физически (кворум ack собран)","type":"boolean"},"state":{"description":"deleted — объявлено, снимок жив; erased — стёрто физически","enum":["deleted","erased"],"type":"string"}},"required":["group_id","announced","members_count","acked_count","purged","state"],"type":"object"}}},"description":"Успех — см. описание маршрута (description) для полной семантики полей."},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"подтвердить, что группа удалена НА ВАШЕМ устройстве"}},"/api/groups/{groupId}/blobs/{kind}":{"get":{"description":"прочитать вложение группы, если версия отличается от клиентской: ?have=\u003cversion\u003e (что уже есть). Совпало → 304 без тела — ровно ради этого вложение вынесено из меты отдельным маршрутом. Записи нет вовсе → 304 тоже (клиенту незачем отличать «нечего качать» от «нет вложения» — версию он и так знает из меты группы). Без авторизации — по знанию group_id, как остальные капабилити-чтения","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}},{"in":"path","name":"kind","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"прочитать вложение группы, если версия отличается от клиентской"},"put":{"description":"редко меняющееся вложение группы (аватар/значок/полное фото/контент планировщика/область спутник-кэша) — E2E-блоб ОТДЕЛЬНО от меты, чтобы не перекачивать десятки КБ на каждую правку имени. kind — из БЕЛОГО списка (route, avatar, avatar_full, plan_text, sat_area, plan_photo_0..31); неизвестный kind → 400. ТЕЛО: {owner_id, version (int64, МИЛЛИСЕКУНДЫ — System.currentTimeMillis автора, В ОТЛИЧИЕ от updated_at меты/точек, которые в секундах), iv, ciphertext, auth_tag}; version дальше часа в будущее → 400 (сверьте часы). Побеждает БОЛЬШАЯ version (LWW), совпадение/меньше → 409 (заберите чужую версию, не долбите своей). ПОДПИСЬ ОБЯЗАТЕЛЬНА (W770): ?ts=\u0026sig= в СТРОКЕ ЗАПРОСА, канон «blob|\u003cgroup_id\u003e|\u003ckind\u003e|\u003cversion\u003e|\u003cowner_id\u003e|\u003cts\u003e» (см. таблицу W732), проверяется закреплённым ключом owner_id (TOFU) — без неё запись мог занять любой, кто знает group_id, версией без потолка нечем перебить. Писать может только подтверждённый участник группы (403 forbidden иначе); пока в группе нет НИ ОДНОГО подтверждённого участника, членство спрашивать не у кого (то же исключение, что у меты/сторожевых точек). 202 — записано; 409 — на сервере версия не старше присланной","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}},{"in":"path","name":"kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"auth_tag":{"type":"string"},"ciphertext":{"type":"string"},"iv":{"type":"string"},"owner_id":{"type":"string"},"version":{"description":"МИЛЛИсекунды (System.currentTimeMillis автора) — НЕ секунды; побеждает большая version (LWW)","format":"int64","type":"integer"}},"required":["owner_id","version","iv","ciphertext","auth_tag"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"редко меняющееся вложение группы"}},"/api/groups/{groupId}/entitlement":{"post":{"description":"W2281 (Этап 1, docs/design/group-entitlement-rework.md, §3.1) — ЗАПИСАТЬ доказуемое ПРАВО на группу по АТТЕСТАЦИИ уже-entitled участника (путь вступления по ссылке/QR): без раскрытия ключа группы серверу (E2E цел — аттестация НЕ несёт ключ). Тело {owner_id, inviter_owner_id, issued_at, nonce, attest, joiner_sig, ts}. ДВЕ разные подписи (W732 — один канон, одна операция): joiner_sig — proof-of-possession ВСТУПАЮЩЕГО, канон «entitlement-claim|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e», проверяется против его закреплённого ключа; attest — КАПАБИЛИТИ приглашающего, созданное ЛОКАЛЬНО на его устройстве при генерации ссылки/QR (без сетевого раунда — оффлайн-first), канон «group-invite|\u003cgroup_id\u003e|\u003cinviter_owner_id\u003e|\u003cissued_at\u003e|\u003cnonce\u003e», проверяется против закреплённого ключа inviter_owner_id — ⚠ issued_at часть канона, но СРОК НЕ проверяется сервером (решение владельца, §7 техпроекта: аттестация БЕЗ СРОКА, паритет с сегодняшними бессрочными ссылками). Приглашающий обязан САМ иметь доказуемое право на группу (EntitledToGroup — редом код/выпуск кодов/слот владельца/прежняя запись в group_entitlements), иначе 403 inviter_not_entitled: цепочка доверия должна быть укоренена в уже-entitled участнике, посторонний с двумя честными парами ключей не аттестует сам себя. Стёртая группа (надгробие есть) отвечает 403 group_erased — право в неё не пишется, как и слот владельца нельзя перерегистрировать (W1141). ⚠ Этот маршрут ТОЛЬКО ПИШЕТ право (group_entitlements, via='attestation') — он НИЧЕГО не гейтит сам по себе: enforce «verified по праву» остаётся за флагом W1100_ENFORCE_ENTITLEMENT (по умолчанию ВЫКЛ), сейчас EntitledToGroup лишь МЕРИТ. Успех — 200 {group_id, owner_id, entitled:true, via:\"attestation\"}, идемпотентно (ON CONFLICT DO NOTHING — повтор не понижает и не перезаписывает уже записанное право). Отказы, В ПОРЯДКЕ проверки: 400 missing_fields (поля не названы поимённо), 401 stale_timestamp (часы разошлись), 403 bad_signature (joiner_sig не сошлась), 403 bad_attestation (attest не сошлась), 403 key_revoked (любая из двух подписей сделана уже отозванным ключом), 403 inviter_not_entitled, 403 group_erased. ⚠ Это ЕДИНЫЙ путь укоренения права по вступлению: и ссылка/QR, и relay-инвайт (POST /api/invites) — получатель relay в Этапе 2 достаёт аттестацию из ECIES-блоба инвайта и предъявляет её ЗДЕСЬ. Сам relay-инвайт право НЕ пишет (он анонимен — см. описание POST /api/invites)","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"attest":{"description":"base64 ECDSA капабилити приглашающего над «group-invite|\u003cgroup_id\u003e|\u003cinviter_owner_id\u003e|\u003cissued_at\u003e|\u003cnonce\u003e», созданная ЛОКАЛЬНО при генерации ссылки/QR","type":"string"},"inviter_owner_id":{"description":"уже-entitled приглашающий, чья аттестация укореняет право","type":"string"},"issued_at":{"description":"unix секунды создания аттестации (часть канона attest); СРОК НЕ проверяется сервером (§7 техпроекта)","format":"int64","type":"integer"},"joiner_sig":{"description":"base64 ECDSA proof-of-possession вступающего над «entitlement-claim|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e»","type":"string"},"nonce":{"description":"часть канона attest — защищает от подстановки issued_at","type":"string"},"owner_id":{"description":"вступающий — тот, кому пишется право","type":"string"},"ts":{"description":"unix секунды подписи joiner_sig (окно свежести 3600 с)","format":"int64","type":"integer"}},"required":["owner_id","inviter_owner_id","issued_at","nonce","attest","joiner_sig","ts"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"entitled":{"description":"всегда true при 200 — иначе смотрите коды ошибок (bad_signature/bad_attestation/inviter_not_entitled/group_erased)","type":"boolean"},"group_id":{"type":"string"},"owner_id":{"type":"string"},"via":{"description":"источник записанного этим маршрутом права; всегда \"attestation\"","enum":["attestation"],"type":"string"}},"required":["group_id","owner_id","entitled","via"],"type":"object"}}},"description":"Успех — см. описание маршрута (description) для полной семантики полей."},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"W2281"}},"/api/groups/{groupId}/membership":{"delete":{"description":"ВЫЙТИ ИЗ ГРУППЫ одним вызовом (W1031): ?owner_id=\u0026ts=\u0026sig= над «group-leave|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e». Убирает ВСЁ, что вы в неё положили: точки (горячие, пачки и архивные копии), профиль, сторожевые точки, команды, отложенные сообщения, элементы слоёв, статус трансляции и строку ростера. ОТВЕТ 200: {group_id, owner_id, removed:{раздел:сколько}, was_member} — перечень убранного, чтобы «я вышел» не приходилось проверять догадками. ⚠ W2236 (self-description debt) — КЛЮЧИ removed ЧЕЛОВЕКОЧИТАЕМЫЕ, НЕ имена таблиц БД (до этой правки ключами были буквальные points/point_batches/points_archive/profiles/profiles_archive/group_waypoints/group_commands/group_messages/group_overlays/track_status/group_members — реализационная деталь, утёкшая на провод; проверено — официальный Android-клиент их не парсит, только len(removed), переименование безопасно): points — горячие точки; point_batches — пачки точек; archived_points — архивные копии точек; profile — профиль участника; archived_profile — архивная копия профиля; waypoints — сторожевые точки; commands — быстрые команды; messages — отложенные сообщения; overlays — элементы совместных слоёв; broadcast_status — статус трансляции (track_status); roster — строка ростера (было group_members). Раздел ОТСУТСТВУЕТ в removed, если из него ничего не убрано (нулевые не сериализуются) — это то же правило, что и раньше, поменялись только имена ключей. ⚠ W1954 (userflow-аудит 06.09) — was_member = было ли ЧТО убирать хоть из одного раздела В ЭТОТ РАЗ (len(removed)\u003e0): пустой removed:{} сам по себе неотличим от «правда был участником, но ничего не грузил» и от «повторный вызов после уже состоявшегося выхода» — вызов идемпотентен в обоих случаях, а was_member называет, какой именно это раз. Права участника не требуется: человек убирает СВОИ данные. Освобождение ростера важно и для группы: пока ушедший в нём висит, «все подтвердили удаление» не наступит никогда (см. ack-delete)","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"group_id":{"type":"string"},"owner_id":{"type":"string"},"removed":{"additionalProperties":{"description":"","format":"int64","type":"integer"},"description":"W2236 — {раздел: сколько убрано}, ключи человекочитаемые (points/point_batches/archived_points/profile/archived_profile/waypoints/commands/messages/overlays/broadcast_status/roster), НЕ имена таблиц БД","type":"object"},"was_member":{"description":"было ли что убирать хоть из одного раздела В ЭТОТ РАЗ (len(removed)\u003e0) — отличает «реально вышел» от повторного идемпотентного вызова","type":"boolean"}},"required":["group_id","owner_id","removed","was_member"],"type":"object"}}},"description":"Успех — см. описание маршрута (description) для полной семантики полей."},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"ВЫЙТИ ИЗ ГРУППЫ одним вызовом"}},"/api/groups/{groupId}/meta":{"delete":{"description":"забрать СВОЮ запись меты (удаляется, только если последним её писали вы). Подпись: ?owner_id=\u0026ts=\u0026sig= над «meta-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» → 204; 404 not_found — вашей записи здесь уже нет. ⚠ Мета — ОДНА общая строка на группу (имя/аватар), owner_id = последний писавший. Чужую (в т.ч. ушедшего участника) снять нельзя — это стёрло бы имя у всех; чтобы сменить имя/аватар, ПЕРЕЗАПИШИТЕ своей записью через POST …/meta (last-write-wins), а не удаляйте чужую (W1152)","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"забрать СВОЮ запись меты"},"get":{"description":"мета группы: JSON-МАССИВ из 0 или 1 записи (updated_at \u003e since; единообразно с points/profiles), НЕ объект — пустой = []","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"мета группы"},"post":{"description":"загрузить зашифрованную мету группы (имя/аватар). ТЕЛО (W929): {owner_id, updated_at (unix СЕКУНДЫ), iv, ciphertext, auth_tag, sig?}; group_id берётся из пути. Пустые owner_id/iv/ciphertext/auth_tag → 400 missing_fields (пустая мета создала бы неудаляемую запись). last-write-wins по updated_at: 202 — записано (тело {group_id, updated_at, stored:true}), 409 stale_update — на сервере запись новее, повтор идентичной → 202 {duplicate:true}. updated_at дальше часа в будущее → 400. Подпись, если прислана, ОБЯЗАНА сойтись: канон «meta|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cupdated_at\u003e». Писать может только участник группы, доказавший себя подписанной загрузкой (403 forbidden иначе) — иначе знающий group_id занимал запись, после чего у группы чужое нерасшифровываемое имя, а подписанный DELETE законного автора отвечал «вашей записи здесь нет» (W925 + W1014). Исключение: пока в группе нет НИ ОДНОГО подтверждённого участника, членство спрашивать не у кого","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"auth_tag":{"type":"string"},"ciphertext":{"type":"string"},"iv":{"type":"string"},"owner_id":{"type":"string"},"sig":{"type":"string"},"updated_at":{"description":"unix секунды (LWW)","format":"int64","type":"integer"}},"required":["owner_id","updated_at"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"загрузить зашифрованную мету группы"}},"/api/groups/{groupId}/owner-key":{"get":{"description":"кто держит слот владельца группы (W1146): 200 {owner_id, public_key, claimed:true} либо 404, если слот свободен («никто ещё не заявил владельца»). owner_id/ключ здесь не секретнее того, что и так отдают head и надгробие удаления","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"кто держит слот владельца группы"},"post":{"description":"зарегистрировать ключ владельца группы: {owner_id, public_key, ts, sig}, канон подписи «group-owner-key|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» (доказательство владения ключом, которым заявляетесь; проверяется против ПРИСЛАННОГО public_key). Слот один на группу, первый занявший выигрывает; прямой ПЕРЕЗАПИСИ нет, но W2135/W2112 добавили ДВА пути смены держателя (см. ниже): подписанную передачу и освобождение при выходе. ОТВЕТ (W1146): 200 {owner_id, claimed:true, next (W2205, userflow-аудит 15-17.09, аддитивно: «POST /api/profiles» — создание группы это ТРИ вызова в строгом порядке, owner-key→profiles→meta, next подсказывает шаг 2)} — вы заняли слот (или это идемпотентный повтор ВАШЕЙ заявки); 409 {owner_id, claimed:false} — слот уже за ДРУГИМ ключом (owner_id называет победителя). Занять слот у группы, где уже есть участники, может только подтверждённый участник (403 forbidden иначе; см. историю решения — раздел «История решений», W1016). У только что созданной группы (участников нет вовсе) слот занимает первый заявитель — иначе она не смогла бы запереться, ведь профиль уходит позже ключа. ⚠ W2135/W2112 — слот ОСВОБОЖДАЕТСЯ при выходе владельца (DELETE …/membership) и при удалении его аккаунта: после этого оставшийся verified-участник занимает слот этим же маршрутом и может объявить удаление (раньше вышедший владелец оставлял «неудаляемую оболочку»)","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"owner_id":{"type":"string"},"public_key":{"description":"P-256 X.509 SubjectPublicKeyInfo, base64 StdEncoding","type":"string"},"sig":{"description":"base64 ECDSA над «group-owner-key|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e», ПРОТИВ присланного public_key","type":"string"},"ts":{"description":"unix секунды подписи (окно свежести 300 с)","format":"int64","type":"integer"}},"required":["owner_id","public_key","ts","sig"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"claimed":{"description":"true — вы заняли слот (или это ваш идемпотентный повтор); см. также 409 {owner_id,claimed:false} — слот занят другим ключом","type":"boolean"},"next":{"description":"W2205 — «POST /api/profiles» (шаг 2 создания группы, после owner-key)","type":"string"},"owner_id":{"type":"string"}},"required":["owner_id","claimed"],"type":"object"}}},"description":"Успех — см. описание маршрута (description) для полной семантики полей."},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"зарегистрировать ключ владельца группы"}},"/api/groups/{groupId}/owner-key/transfer":{"post":{"description":"W2135/W2112 — ПЕРЕДАТЬ слот владельца другому участнику БЕЗ удаления группы. Тело {from_owner_id, from_ts, from_sig, to_owner_id, to_public_key, to_ts, to_sig}. ДВЕ подписи: from_sig — авторизация ТЕКУЩИМ владельцем, канон «owner-transfer|\u003cgroup_id\u003e|\u003cfrom_owner_id\u003e|\u003cto_owner_id\u003e|\u003cfrom_ts\u003e» (проверяется против public_key слота, при неуспехе — против связки устройств from, как delete-group: смена телефона); to_sig — proof-of-possession НОВОГО владельца, канон «owner-accept|\u003cgroup_id\u003e|\u003cto_owner_id\u003e|\u003cto_ts\u003e» против ПРИСЛАННОГО to_public_key (W149; ОТДЕЛЬНЫЙ от claim-канона group-owner-key). Оба ts — unix-секунды, свежие (окно ±3600с). to_owner_id ОБЯЗАН быть verified-участником (W1016 — постороннему передать нельзя). ОТВЕТ 200 {group_id, owner_id:\u003cto\u003e, transferred:true} (идемпотентно, если слот уже за to); 409 {owner_id, transferred:false} — слот не за from (не текущий владелец / гонка); 404 — слота нет; 403 — подпись/членство. Атомарно (UPDATE WHERE owner_id=from): окна «пустой слот» нет","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"from_owner_id":{"description":"текущий держатель слота","type":"string"},"from_sig":{"description":"base64 ECDSA над «owner-transfer|\u003cgroup_id\u003e|\u003cfrom_owner_id\u003e|\u003cto_owner_id\u003e|\u003cfrom_ts\u003e»; против public_key слота ИЛИ связки устройств from","type":"string"},"from_ts":{"description":"unix секунды подписи from (окно 3600 с)","format":"int64","type":"integer"},"to_owner_id":{"description":"новый владелец; ОБЯЗАН быть verified-участником (W1016)","type":"string"},"to_public_key":{"description":"P-256 X.509 SPKI base64 StdEncoding нового владельца","type":"string"},"to_sig":{"description":"base64 ECDSA над «owner-accept|\u003cgroup_id\u003e|\u003cto_owner_id\u003e|\u003cto_ts\u003e», ПРОТИВ присланного to_public_key (proof-of-possession, W149; отдельный канон от claim)","type":"string"},"to_ts":{"description":"unix секунды подписи to (окно 3600 с)","format":"int64","type":"integer"}},"required":["from_owner_id","from_sig","to_owner_id","to_public_key","to_sig"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"W2135/W2112"}},"/api/groups/{groupId}/quorum-exclude/{ownerId}":{"post":{"description":"W2276 — ВЛАДЕЛЕЦ исключает участника {ownerId} из КВОРУМА удаления (его ack больше не требуется). Зачем: посторонний, знающий group_id, двумя анонимными POST (identity/register+profiles) подсаживает фантом-verified-участника, который никогда не шлёт ack → кворумная очистка не наступит (enforce «verified по праву» невозможен — ломает вступление по ссылке/QR, E2E). Подпись ВЛАДЕЛЬЦА СЛОТА: ?owner_id=\u003csigner\u003e\u0026ts=\u0026sig= над «quorum-exclude|\u003cgroup_id\u003e|\u003ctarget_owner_id\u003e|\u003cts\u003e» (против ключа слота ИЛИ связки устройств владельца, как delete-group; окно свежести 300с). signer обязан держать слот (403 иначе; 404 если слота нет). verified исключённого НЕ меняется (виден в ростере) — снимается только требование ack (excluded_from_quorum). ОТВЕТ 200 {group_id, owner_id:\u003ctarget\u003e, excluded:true, members_count, acked_count}; 404 — участника нет в ростере. Идемпотентно","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}},{"in":"path","name":"ownerId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"W2276"}},"/api/health":{"get":{"description":"машиночитаемый статус для мониторинга (W740): 200 {status:ok, service:Spoor}. Без базы и авторизации","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"машиночитаемый статус для мониторинга"}},"/api/identity/devices":{"get":{"description":"Связка ключей ОДНОГО владельца с метаданными: ?owner_id\u0026ts\u0026sig, подпись любым ключом связки (канон devices|\u003cowner_id\u003e|\u003cts\u003e) → [{public_key, updated_at, is_primary, app_version}], новые первыми. Bearer здесь НЕ подходит: сервер не хранит связь аккаунта с ключами устройств (W1024 — обезличивание), поэтому право доказывается владением ключом. Количество устройств и время активности — метаданные о человеке, поэтому по одному owner_id без подписи не отдаются (W1035)","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"Связка ключей ОДНОГО владельца с метаданными"}},"/api/identity/lookup":{"post":{"description":"поиск ключей идентичности: {owner_ids:[…]} → []. Неизвестные owner_id просто отсутствуют в ответе. ПУБЛИЧНОЕ здесь только public_key и allow_group_add — без них нельзя ни зашифровать приглашение, ни уважить отказ. ⚠ allow_group_add — флаг СОВЕТНЫЙ, а не запрет на сервере: у свежего ключа он false, и это значит «НЕ НАСТРОЕНО», а НЕ «человек запретил» (W1322). Сервер приём приглашений/заявок этим флагом НЕ блокирует — не отказывайтесь звать человека только потому, что флаг false, иначе не пригласить никого и никогда. Сведения о человеке (full_name, avatar, updated_at «последний раз на связи», app_version) отдаются лишь тому, кто (1) назвал себя подписью {owner_id, ts, sig} по канону «identity-lookup|\u003cowner_id\u003e|\u003cts\u003e» и (2) состоит с запрошенным в ОБЩЕЙ ГРУППЕ; про себя спрашивать можно всегда. Иначе поля приходят пустыми — ответ беднее, но не отказ (W937 + W1014: подпись говорит «кто спросил», а не «вправе ли», а завести личность стоит один запрос) W1041: full_name и avatar НЕ возвращаются никому — клиент читает только public_key, отдавать лицо человека по знанию owner_id незачем; app_version и updated_at отдаются лишь при общей группе с подписавшим запрос. ⚠ W1863 (userflow-аудит 03.09) — updated_at:0 в ЭТОМ ответе НИКОГДА не означает «личность никогда не была на связи»: сервер штампует updated_at СВОИМ временем при КАЖДОЙ регистрации/перерегистрации ключа (RegisterKey), то есть у зарегистрированной личности он всегда \u003e 0 в базе; 0 в ответе — это ВСЕГДА приватность-скрабинг (нет общей группы с запросившим), то же самое поле, что описано выше. Читайте updated_at:0 как «скрыто», а не как «мёртв»","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"поиск ключей идентичности"}},"/api/identity/register":{"post":{"description":"TOFU-регистрация ключа идентичности — ПЕРВЫЙ шаг интеграции: без закреплённого ключа не проверится ни одна подпись. ТЕЛО: {owner_id (строка, идентичность устройства из отпечатка ключа — точную формулу официального клиента см. в правиле «owner_id — это ИДЕНТИЧНОСТЬ УСТРОЙСТВА» выше, W1958; ⚠ W2083: сервер эту формулу пока НЕ НАВЯЗЫВАЕТ — на регистрации она только ЗАМЕРЯЕТСЯ (W1370), несовпадение НЕ отвергается, поэтому клиент ОБЯЗАН выводить owner_id из своего ключа сам и не полагаться на серверную сверку), public_key (P-256 в X.509 SPKI, base64 StdEncoding — тот же формат, что в W732), password? (пароль аккаунта, ОБЯЗАТЕЛЕН только при смене/добавлении ключа для уже закреплённого owner_id, W383; при первой привязке и повторе того же ключа не нужен), Bearer? (опц.)}. Первая привязка и повтор того же ключа — без auth; смена/добавление ключа для уже закреплённого owner_id требует password аккаунта (не просто Bearer); отозванный ключ не принимается (403 key_revoked). ОТВЕТ 202: {status} — registered (первая привязка), already_registered (тот же ключ повторно), added_to_keyring (новый ключ добавлен к связке, второе устройство). Три исхода различаются намеренно: первый значит «личность закреплена», второй «ничего не изменилось», третий «у аккаунта теперь два устройства»","requestBody":{"content":{"application/json":{"schema":{"properties":{"owner_id":{"type":"string"},"password":{"type":"string"},"public_key":{"type":"string"}},"required":["owner_id","public_key"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"TOFU-регистрация ключа идентичности"}},"/api/identity/revoke":{"post":{"description":"отозвать ключ устройства НАВСЕГДА. ДВА способа доказать право (одного Bearer НЕ хватает — сервер намеренно НЕ хранит связь аккаунт↔owner_id, W1024): (1) {owner_id, public_key} + Bearer + password аккаунта, к которому привязан owner_id (как у DELETE /api/account/me); (2) САМООТЗЫВ — {owner_id, public_key, ts, sig}, sig над «key-revoke|\u003cowner_id\u003e|\u003cpublic_key\u003e|\u003cts\u003e» самим отзываемым ключом (Bearer не нужен). Только Bearer без пароля/подписи → 403 revoke_not_authorised. ОТВЕТ 200 {status:\"revoked\", message} — не 202 или 204: и W1864, тело — это готовый ИТОГ операции, а не «принято к обработке» (отзыв применяется синхронно, читать заново нечего). ⚠ ПОРЯДОК (W1299): отзывайте ключ ПОСЛЕ того, как вышли из всех групп и удалили свои (DELETE …/groups/{g}). Слот владельца группы подписывается ключом владельца; отозвав ключ раньше, вы оставите «пустую оболочку» группы с занятым слотом, которую уже нечем удалить. ⚠ W2260 (userflow-аудит 17.09) — успешный ответ несёт next[]: заморожен ли owner_id ПОЛНОСТЬЮ (это был последний действующий ключ → под ним больше нельзя писать/стирать/перерегистрировать, W2252) или отозвано лишь одно устройство из нескольких, и что делать дальше (финал — DELETE /api/account/me)","requestBody":{"content":{"application/json":{"schema":{"properties":{"owner_id":{"type":"string"},"public_key":{"type":"string"},"sig":{"description":"base64 ECDSA над «key-revoke|\u003cowner_id\u003e|\u003cpublic_key\u003e|\u003cts\u003e»","type":"string"},"ts":{"format":"int64","type":"integer"}},"required":["owner_id","public_key","ts","sig"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]},{"ownerSig":[]}],"summary":"отозвать ключ устройства НАВСЕГДА"}},"/api/identity/{ownerId}/keys":{"get":{"description":"все действующие ключи owner_id (связка); отозванные не возвращаются","parameters":[{"in":"path","name":"ownerId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"все действующие ключи owner_id"}},"/api/invite-codes":{"post":{"description":"создать код-приглашение. ТРИ требования, все обязательны: (1) Bearer (анти-гриферство), (2) подпись автора, (3) параметры ts/sig — ⚠ В СТРОКЕ ЗАПРОСА ?ts=\u0026sig= (НЕ в теле; W1144). КАНОН ПОДПИСИ (включает max_uses и key_blob, W965): invite-create|\u003cgroup_id\u003e|\u003csender_owner_id\u003e|\u003ccode_hash в нижнем регистре\u003e|\u003ckey_blob\u003e|\u003cttl_seconds\u003e|\u003cmax_uses\u003e|\u003cts\u003e. Тело: {group_id, sender_owner_id, code_hash (64 hex, SHA-256 НОРМАЛИЗОВАННОГО кода — верхний регистр, без пробелов/дефисов, O→0, I/L→1; та же нормализация, что при redeem, W1074/W1145), key_blob, ttl_seconds (\u003e0, ≤ 2 суток), max_uses (0 = без лимита)}. Недостающие поля тела называются одним ответом. Ответ 201 — полная запись кода (W1074). ⚠ Сервер видит только хеш и НЕ может проверить, от нормализованного ли кода он: если клиент захеширует ненормализованный код, redeem не совпадёт НИКОГДА, а списка/отзыва по коду в API пока нет — код-тупик (W1145). W2307: выпускать коды вправе только причастный к группе (verified-участник, держатель слота владельца или с правом в group_entitlements / по погашенному коду); при включённом W1100_ENFORCE_ENTITLEMENT постороннему — 403 not_group_member, пока флаг выключен — только запись в журнал. Сам выпуск кода права на группу больше НЕ даёт. Отозвать: POST /api/invite-codes/{code}/revoke","requestBody":{"content":{"application/json":{"schema":{"properties":{"code_hash":{"type":"string"},"group_id":{"type":"string"},"key_blob":{"type":"string"},"max_uses":{"type":"integer"},"sender_owner_id":{"type":"string"},"ttl_seconds":{"format":"int64","type":"integer"}},"required":["group_id","sender_owner_id","code_hash","ttl_seconds"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[],"ownerSig":[]}],"summary":"создать код-приглашение"}},"/api/invite-codes/{code}":{"get":{"description":"АВТОР кода смотрит его состояние и счётчик погашений (W939): ?owner_id=\u0026ts=\u0026sig= над «invite-code-info|\u003ccode_hash\u003e|\u003cowner_id\u003e|\u003cts\u003e»; owner_id обязан совпасть с sender_owner_id кода — иначе 404, а НЕ 403 (см. W1201 ниже: ошибиться в подписи/owner_id и «кода нет вовсе» должны быть неразличимы). В пути — код или его SHA-256. 200 {code_hash, group_id, sender_owner_id, created_at, expires_at, revoked, max_uses, uses}. key_blob НЕ отдаётся. W1201: не-автору (и на несуществующий код) — ОДИН ответ 404, чтобы по коду ответа нельзя было узнать, существует ли чужой код; частота ограничена (энумерация хешей). ⚠ W1952 (userflow-аудит 06.09) — АВТОРУ С ВЕРНОЙ ПОДПИСЬЮ, чей код физически удалён (группа закрыта, purge, или обычный прунинг истёкших/отозванных), больше НЕ отвечаем тем же 404 «кода нет»: по надгробию (переживает саму строку) отличаем «был ваш код, погашен закрытием группы» — 410 invalid_code «Код погашен: группа удалена» — от «такого кода никогда не было». Не-автору (или неверной подписи) — по-прежнему единый 404, оракул существования кода для чужих не открывается и через надгробие. Так создатель узнаёт, СКОЛЬКО раз воспользовались приглашением (uses), но НЕ КЕМ: код — предъявительский, сервер не связывает его с личностью (W646). Кто именно вступил, приглашающий видит в РОСТЕРЕ группы — новый участник появляется в manifest/profiles (GET /api/profiles/{groupId}/manifest) при первой загрузке своего профиля, уже расшифрованным на клиенте по ключу группы","parameters":[{"in":"path","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"АВТОР кода смотрит его состояние и счётчик погашений"}},"/api/invite-codes/{code}/redeem":{"post":{"description":"погасить код-приглашение: ОБЯЗАТЕЛЬНО {owner_id} в теле — без него 400 owner_id_required (код всегда засчитывается на конкретного получателя, ЭТИМ И достигается идемпотентность повтора, а НЕ только у одноразовых кодов, W1953). ⚠ W1952 (userflow-аудит 06.09) — ТРИ ОТДЕЛЬНЫХ 410-кода вместо одного обтекаемого code_unusable, когда причину можно назвать БЕЗ утечки решения автора: code_expired (истёк срок — попросите пригласившего выдать новый), code_exhausted (исчерпан max_uses — попросите поднять лимит или выдать новый), code_unusable (отозван ИЛИ причина не установлена — единый нейтральный ответ, чтобы не выдавать, что автор отозвал код именно для вас). ⚠ W2256 (userflow-аудит 15-17.09) — code_group_deleted (410): группа, в которую ведёт код, уже ОБЪЯВЛЕНА к удалению (надгробие group_erasures есть, физические данные могут ещё храниться до последнего ack — W141/W2107) — код формально ещё погашаем (uses может уже увеличиться этим вызовом), но вступление отклонено, вступать больше некуда. Неизвестный код/битый формат — 404 invalid_code. В пути — САМ код (как диктуют) ЛИБО его SHA-256 (64 hex). Нормализация кода ЕДИНАЯ у всех клиентов и сервера (W1074): верхний регистр, убрать пробелы и дефисы, заменить неразличимые на слух знаки O-\u003e0, I/L-\u003e1, затем SHA-256. ОТВЕТ 200: {joined:true (W1327 — явное подтверждение вступления), group_id, key_blob?, key_received? (W1409 — true, если key_blob в ответе есть; смысловой двойник «key_blob присутствует»/«не key_pending»), next? (W1409 — подсказка СЛЕДУЮЩЕГО шага словами, напр. «Загрузите подписанный профиль…»; двойник hint), key_pending?, hint?}. key_blob возвращается, только если отправитель его туда положил; сам ключ группы сервер не хранит и не выдаёт. Если ключа в ответе нет, приходит key_pending:true и hint словами — иначе новичок решает, что уже вступил, а расшифровать не может ничего и идёт искать ошибку в криптографии. redeem НЕ уведомляет приглашающего, КТО погасил код (код предъявительский, W646): вступивший становится виден создателю позже — в ростере группы (manifest/profiles), когда загрузит свой профиль под ключом группы","parameters":[{"in":"path","name":"code","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"owner_id":{"description":"обязателен: код всегда засчитывается на конкретного получателя — этим достигается идемпотентность повтора, для любого кода, не только одноразового","type":"string"}},"required":["owner_id"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"group_id":{"type":"string"},"hint":{"description":"человекочитаемое пояснение к key_pending","type":"string"},"joined":{"description":"устаревший алиас key_received (W1327) — код принят, ДОСТУП к группе получен; членом делает только последующая загрузка профиля","type":"boolean"},"key_blob":{"description":"присутствует, только если отправитель положил ключ в это приглашение","type":"string"},"key_pending":{"description":"true, когда key_blob отсутствует — ключ придёт отдельным E2E-каналом","type":"boolean"},"key_received":{"description":"true ⇔ key_blob присутствует","type":"boolean"},"next":{"description":"подсказка следующего шага словами (обычно — POST /api/profiles)","type":"string"}},"required":["joined","group_id","key_received","next"],"type":"object"}}},"description":"Успех — см. описание маршрута (description) для полной семантики полей."},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"погасить код-приглашение"}},"/api/invite-codes/{code}/revoke":{"post":{"description":"отозвать СВОЙ код-приглашение (W928): ?owner_id=\u0026ts=\u0026sig= над «invite-revoke|\u003ccode_hash\u003e|\u003cowner_id\u003e|\u003cts\u003e» + Bearer. Гасит только тот, кто выдал (несовпадение неотличимо от «кода нет»). 204","parameters":[{"in":"path","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[],"ownerSig":[]}],"summary":"отозвать СВОЙ код-приглашение"}},"/api/invites":{"post":{"description":"отправить приглашение: {id, recipient_owner_id, ciphertext, created_at}. ⚠ created_at ОБЯЗАТЕЛЕН (unix-СЕКУНДЫ, \u003e 0, W1150): получатель находит приглашение по created_at \u003e since, без него (или с 0) оно не доходит НИ ДО КОГО. ciphertext зашифрован ECIES на ключ получателя (group_id + ключ группы ВНУТРИ него), сервер его не читает. Ответ 202 {id} (W1320 — эхо id как подтверждение; список приглашений отправителю закрыт, другого подтверждения нет). Приглашения истекают ЛЕНИВО по возрасту (нет поля expires_at). БЕЗ Bearer и БЕЗ подписи — ОСОЗНАННО (W961: пригласить можно того, кого в лицо не знаешь, сервер не должен видеть, кто кого зовёт); защита от заваливания чужого ящика — не аутентификация, а ДВЕ квоты: хранение ограничено 200 непрочитанными на получателя (W1731), а реал-тайм-пробуждение телефона получателя — отдельно 30/час на получателя (W2255, по образцу /api/contact-requests) поверх общего IP-потолка отправителя (60/мин). ⚠ W2281 — relay-инвайт право НЕ даёт (маршрут анонимен: назвать чужого entitled sender тривиально, security-ревью Этапа 1). Право получатель укореняет подписанным POST /api/groups/{groupId}/entitlement, достав аттестацию приглашающего из ciphertext (Этап 2 клиента)","requestBody":{"content":{"application/json":{"schema":{"properties":{"ciphertext":{"description":"ECIES на ключ получателя; group_id и ключ группы — ВНУТРИ, сервер не читает","type":"string"},"created_at":{"description":"unix секунды, ОБЯЗАТЕЛЕН и \u003e 0 (W1150) — по нему получатель находит приглашение (created_at \u003e since)","format":"int64","type":"integer"},"id":{"type":"string"},"recipient_owner_id":{"type":"string"}},"required":["id","recipient_owner_id","ciphertext","created_at"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"отправить приглашение"}},"/api/invites/{id}":{"delete":{"description":"убрать приглашение, когда оно обработано. Удаляет ПОЛУЧАТЕЛЬ, не отправитель: иначе узнавший id мог бы тихо погасить чужое приглашение и сорвать передачу ключа или вступление в группу. Подпись: ?owner_id=\u0026ts=\u0026sig= над «invite-delete|\u003cid\u003e|\u003crecipient_owner_id\u003e|\u003cts\u003e», owner_id обязан совпасть с получателем. Неизвестный id (или получатель без закреплённого ключа) → 204 без действия, идемпотентно; существующее приглашение с плохой подписью → 403 bad_signature. ⚠ W2257 (userflow-аудит 15-17.09) — если присланный owner_id НЕ совпадает с фактическим получателем (например, зовёт отправитель своим честным ключом), это отдельный 403 forbidden ДО попытки проверить подпись — причина «вы не получатель», не «подпись не сошлась» (они лечатся по-разному)","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"убрать приглашение, когда оно обработано"}},"/api/invites/{recipientId}":{"get":{"description":"входящие приглашения. Нужна подпись получателя: ?owner_id=\u0026ts=\u0026sig= над «invite-list|\u003crecipientId\u003e|\u003cts\u003e». Bearer не подходит → 403 signature_required. Запись: {id, recipient_owner_id, sender_owner_id, group_id, ciphertext, created_at}. ⚠ W1320: sender_owner_id и group_id обычно ПУСТЫ — клиент их не шлёт (и group_id, и кто пригласил лежат ВНУТРИ ciphertext, E2E). expires_at НЕ отдаётся (поля нет): приглашения истекают лениво по возрасту при чтении. «Истекает через N часов» показать нельзя","parameters":[{"in":"path","name":"recipientId","required":true,"schema":{"type":"string"}},{"description":"идентичность читателя (подписанное чтение)","in":"query","name":"owner_id","required":false,"schema":{"type":"string"}},{"description":"unix-секунды подписи (окно свежести)","in":"query","name":"ts","required":false,"schema":{"type":"integer"}},{"description":"base64 ECDSA над каноном чтения (см. таблицу подписей W732)","in":"query","name":"sig","required":false,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"входящие приглашения"}},"/api/languages":{"get":{"description":"агрегат языков устройств (какие локали переводить). POST /api/languages/{lang} — устройство сообщает свою","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"агрегат языков устройств"}},"/api/messages":{"post":{"description":"поставить в очередь отложенное сообщение: массив {id, group_id, owner_id, created_at, deliver_after, expires_at, iv, ciphertext, auth_tag, sig}. Текст, адресат и УСЛОВИЕ (в т.ч. точка и радиус для «по прибытии») зашифрованы ключом группы — условие по месту проверяет телефон получателя, сервер про координаты не знает ничего. Открыты только два времени: раньше deliver_after не отдаём (не гоняем по радио то, что нельзя показать), после expires_at удаляем. Подпись ОБЯЗАТЕЛЬНА и покрывает ОБА времени, канон «message|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003ccreated_at\u003e|\u003cdeliver_after\u003e|\u003cexpires_at\u003e»; иначе посредник сдвинул бы срок созревания, не тронув содержимое. expires_at дальше 7 суток или уже в прошлом на приёме — ОТКАЗ (сервер не переписывает подписанное поле, W922). W1793 — НЕВАЛИДНАЯ запись массива больше НЕ обрывает разбор остальных: отбрасывается индивидуально (bad_signature/malformed/owner_mismatch/future_timestamp/ttl_too_long/expired), разбор продолжается. ОТВЕТ 202: {accepted, rejected?, rejected_ids?} — та же форма, что у /api/commands","requestBody":{"content":{"application/json":{"schema":{"properties":{"auth_tag":{"type":"string"},"ciphertext":{"type":"string"},"created_at":{"format":"int64","type":"integer"},"deliver_after":{"format":"int64","type":"integer"},"expires_at":{"format":"int64","type":"integer"},"group_id":{"type":"string"},"id":{"type":"string"},"iv":{"type":"string"},"owner_id":{"type":"string"},"sig":{"type":"string"}},"required":["id","owner_id","group_id"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"поставить в очередь отложенное сообщение"}},"/api/messages/{groupId}":{"get":{"description":"СОЗРЕВШИЕ сообщения группы новее ?since=\u003cunix\u003e (до 200): deliver_after ≤ сейчас и не истёкшие. ЧТЕНИЕ ПОДПИСАНО и по членству (W1534): ?owner_id\u0026ts\u0026sig, канон «messages-list|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e»; без подписи 403 signature_required, не участник — 403 forbidden. Клиент обычно берёт их общим обменом (messages_since), отдельный запрос — для внешней сверки","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}},{"description":"с какого unix-времени отдавать (единицы — см. правило по маршрутам; по умолчанию 0)","in":"query","name":"since","required":false,"schema":{"type":"integer"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"СОЗРЕВШИЕ сообщения группы новее ?since=\u003cunix\u003e"}},"/api/messages/{id}":{"delete":{"description":"отменить СВОЁ, ещё не сработавшее сообщение: ?owner_id=\u0026ts=\u0026sig= над «message-cancel|\u003cid\u003e|\u003cowner_id\u003e|\u003cts\u003e» (W1110, ts свежий — иначе перехваченную отмену можно повторить; старая форма без ts принимается переходно) → 204. Чужое — 403 forbidden: иначе можно погасить чужое предупреждение. Уже удалённое/истёкшее → 204, цель достигнута","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"отменить СВОЁ, ещё не сработавшее сообщение"}},"/api/openapi.json":{"get":{"description":"машиночитаемая схема OpenAPI 3.1 (W1328), СГЕНЕРИРОВАННАЯ из этого же каталога — не отдельная копия, разъехаться с прозой нечему. Слой структурный: пути, методы, описание маршрутов, единая схема ошибок Error, общий текст (аутентификация/правила/коды) в info.description. Типизированные тела запросов/ответов — инкрементально; их канон пока в тексте описаний. Без базы и авторизации","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"машиночитаемая схема OpenAPI 3.1"}},"/api/overlays":{"post":{"description":"правка совместного слоя группы: массив {id, group_id, owner_id, updated_at (unix-МС), deleted, iv, ciphertext, auth_tag, sig}. Тип фигуры, геометрия, подпись и цвет зашифрованы ключом группы — сервер не знает даже формы: по контуру участка поиска видно, где ищут человека. LWW по updated_at; удаление — НАДГРОБИЕ (deleted=true, без содержимого), потому что участник, который был офлайн, обязан узнать, что фигуру убрали. Подпись ОБЯЗАТЕЛЬНА, канон «overlay|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003cupdated_at\u003e|\u003cdeleted\u003e» — флаг удаления ВХОДИТ в подпись, иначе посредник превратил бы правку в удаление, не тронув содержимое. В отличие от сторожевых точек, чужой owner_id в существующей строке — НЕ ошибка: слой общий, и правка соседней фигуры это работа, а не присвоение; owner_id означает «кто писал последним». Перенос фигуры в ДРУГУЮ группу невозможен","requestBody":{"content":{"application/json":{"schema":{"description":"Совместные слои; updated_at в МИЛЛИсекундах.","items":{"properties":{"auth_tag":{"type":"string"},"ciphertext":{"type":"string"},"deleted":{"type":"boolean"},"group_id":{"type":"string"},"id":{"type":"string"},"iv":{"type":"string"},"owner_id":{"type":"string"},"sig":{"type":"string"},"updated_at":{"description":"МИЛЛИсекунды (W1399)","format":"int64","type":"integer"}},"type":"object"},"type":"array"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"правка совместного слоя группы"}},"/api/overlays/{groupId}":{"get":{"description":"элементы слоя группы новее ?since=\u003cunix-МС\u003e (до 2000), ВКЛЮЧАЯ надгробия. ЧТЕНИЕ ПОДПИСАНО и по членству (W1517): ?owner_id\u0026ts\u0026sig, канон подписи «overlays-list|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e»; без подписи 403 signature_required, не участник группы с verified-составом — 403 forbidden. Клиент обычно берёт слой общим обменом (overlays_since), этот маршрут — для внешней сверки","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}},{"description":"с какого unix-времени отдавать (единицы — см. правило по маршрутам; по умолчанию 0)","in":"query","name":"since","required":false,"schema":{"type":"integer"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"элементы слоя группы новее ?since=\u003cunix-МС\u003e"}},"/api/ping":{"get":{"description":"самый дешёвый ответ: text/plain «ok», без базы и авторизации — проверить «жив ли канал» после обрыва, не платя за полный обмен","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"самый дешёвый ответ"}},"/api/points":{"post":{"description":"загрузить массив зашифрованных точек: {id, owner_id, group_id, timestamp, iv, ciphertext, auth_tag, sig?, track_id?, seq?, app_version_code?} — схема EncryptedPoint в /api/openapi.json. W1854 — track_id (необязательно) связывает точку с записанным треком (POST /api/tracks): это ЕДИНСТВЕННАЯ связь, GET /api/tracks/{groupId}/{trackId} фильтрует по нему; точки без трека (общий обмен вне записи) шлют его пустым/опущенным. seq — сквозной номер внутри трека отправителя (используется докачкой P5/Backfill). Оба поля также ВХОДЯТ в канон подписи V2, когда track_id непуст (см. SigMessages ниже). Членство в группе не проверяется (сервер E2E-слеп). КАНОН ПОДПИСИ ТОЧКИ: «point|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003ctimestamp\u003e» (timestamp — секунды, десятичное число; поле, содержащее символ «|», заменяется в подписываемой строке на « НЕДОПУСТИМОЕ_ПОЛЕ » — иначе подобранное значение сдвинуло бы смысл утверждения). ⚠ W1945 (userflow-аудит 06.09) — КОГДА ПОДПИСЬ ОБЯЗАТЕЛЬНА. Подпись точки (поле sig) сверяется, ТОЛЬКО если у owner_id уже ЗАКРЕПЛЁН ключ (POST /api/identity/register, TOFU): тогда подпись ОБЯЗАТЕЛЬНА для КАЖДОЙ точки этого owner_id — и неверная подпись, И её ПОЛНОЕ ОТСУТСТВИЕ отбраковываются ОДИНАКОВО (соответственно reason bad_signature / unsigned, W1140 — downgrade запрещён тем же правилом, что у клиента), точка тихо не сохраняется (см. rejected ниже), НЕ 403 (иначе заклинило бы очередь отправки). Прежний текст этой строки говорил «неподписанные точки принимаются» БЕЗ оговорки — верно это ТОЛЬКО для owner_id, у которого НИ ОДНО устройство ещё не закрепило ключ (свежая личность или клиент ≤43 сборки без подписи вовсе): для них подписанные и неподписанные точки принимаются одинаково, сверять подпись не с чем, настоящая граница подлинности — у приёмников (TOFU-пиннинг), а не у сервера. Итог: 202 на POST /api/points означает «запрос принят к ОБРАБОТКЕ», а НЕ «каждая точка сохранена» — судьбу конкретной точки читайте по её id в rejected (см. W1956 ниже), а не по коду ответа. Ограничение частоты по IP. ОТВЕТ 202: {received, inserted, duplicates, accepted_ids, duplicate_ids?, rejected_ids?, checksum, uploaded_mask?, rejected_mask?, rejected_reasons?}. received — сколько точек было в теле; inserted — сколько записалось впервые; duplicates — повторно присланные из УЖЕ сохранённых (идемпотентная перевыгрузка, НЕ ошибка). ⚠ W2231 — duplicate_ids называет ЭТИ id поимённо (длина списка равна duplicates; поле опускается, когда duplicates=0): в том числе когда id прислан ПОВТОРНО С ДРУГИМ блобом — сервер блобы не сравнивает и не перезаписывает (ON CONFLICT DO NOTHING), старая запись остаётся как была, а duplicate_ids честно называет этот случай тоже. duplicate_ids — ЧИСТО ИНФОРМАЦИОННОЕ поле: accepted_ids им НЕ сужается, id дубля там как был, так и остаётся (клиентской идемпотентной очереди для снятия id с повтора нужно «сервер это подтверждает, что держит», а не было ли содержимое новым). W1346 — инвариант с учётом отбраковки: received = inserted + duplicates + rejected (когда есть отброшенные по форме/подписи, «received − inserted» НЕ равно duplicates — вычтите ещё rejected); accepted_ids — те, что сервер теперь держит (новые и уже бывшие), по возрастанию; rejected_ids — отброшенные id (поле опускается, когда таких нет). W930: рядом едет rejected — тот же список, но С ПРИЧИНОЙ: [{id, reason}], reason ∈ {malformed (структурно негодна, повтор не спасёт), bad_signature (подпись не сошлась — перерегистрируйте ключ/переподпишите), unsigned (ключ владельца закреплён, а подписи нет — downgrade)}. Причины лечатся по-разному, поэтому названы кодом, а не текстом. ⚠ W2232 — КАНОН ОТКАЗА РОВНО ОДИН: читайте rejected ([{id, reason}]) — это единственное поле, которое сторонний клиент ОБЯЗАН разбирать. rejected_ids, rejected_reasons, rejected_mask и uploaded_mask — ТА ЖЕ САМАЯ информация в других обёртках, ТРАНСПОРТНАЯ ОПТИМИЗАЦИЯ ради обратной совместимости со старыми клиентами и экономии байт на слабом канале (маски — W1834а/W1835): их можно полностью игнорировать, ничего сверх rejected они не сообщают. ⚠ W1956 (userflow-аудит 06.09) — rejected: [{id, reason}] (в коде — единственный источник, см. model.RejectedPoint); rejected_ids (плоский список id без причины) и rejected_reasons (та же причина по ИНДЕКСУ входного массива, W1835) — оба ВЫВОДЯТСЯ из того же списка ради старых клиентов и НЕ несут собственной информации — читайте reason из rejected, а не гадайте по rejected_ids, есть ли там подделка или временная нехватка подписи. W1835 (протокол V3, этап 1) — РЯДОМ с accepted_ids/rejected/rejected_ids едет их компактная битовая форма: uploaded_mask/rejected_mask (base64 ⌈n/8⌉ байт, бит big-endian, индекс i = ПОЗИЦИЯ i-й точки во входном МАССИВЕ тела запроса, НЕ алфавитный порядок accepted_ids и не порядок вставки) и rejected_reasons ([{i, reason}] — та же причина, что в rejected, но по индексу). Старый клиент этих трёх полей не видит и не ломается. checksum — ПРО ДРУГОЕ, не про отказ (не путать с транспортной оптимизацией выше): sha256 по ВСЕМУ ПРИСЛАННОМУ набору (включая отброшенные): он отвечает на вопрос «дошло ли тело целым по плохому каналу», а не «что сохранено», поэтому считается до всякой отбраковки — иначе клиент принял бы отказ от подделки за порчу передачи и повторял бы батч вечно. ФОРМУЛА ДО БАЙТА: для каждой точки строка «\u003cid\u003e:\u003ctimestamp\u003e» (timestamp — десятичное число секунд без ведущих нулей); строки сортируются лексикографически (побайтово, не по локали); затем в sha256 подаётся каждая строка И СРАЗУ ЗА НЕЙ разделитель «;» — то есть точка с запятой стоит и ПОСЛЕ последней пары; результат — нижний регистр hex. Пустой набор даёт sha256 пустого ввода (e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855). Пример: точки (b,20) и (a,10) → sha256(«a:10;b:20;»)","requestBody":{"content":{"application/json":{"schema":{"description":"Массив шифроточек; идемпотентная вставка по id.","items":{"$ref":"#/components/schemas/EncryptedPoint"},"type":"array"}}},"required":true},"responses":{"202":{"content":{"application/json":{"schema":{"properties":{"accepted_ids":{"description":"id, которые сервер теперь держит (новые и уже бывшие), по возрастанию — duplicate_ids НЕ сужает этот список","items":{"type":"string"},"type":"array"},"checksum":{"description":"ПРО ДРУГОЕ, не про отказ — sha256 по всему присланному набору (включая отброшенные), транспортная целостность — см. формулу в описании маршрута","type":"string"},"duplicate_ids":{"description":"W2231 — подмножество accepted_ids, которое уже было сохранено ДО этой загрузки (длина = duplicates; опускается при duplicates=0); включает id, присланный повторно с ДРУГИМ блобом — сервер блобы не сравнивает (ON CONFLICT DO NOTHING), старая запись остаётся, а этот список честно называет и такой случай. Чисто информационное поле","items":{"type":"string"},"type":"array"},"duplicates":{"description":"повторно присланные из уже сохранённых — идемпотентная перевыгрузка, не ошибка","format":"int64","type":"integer"},"inserted":{"description":"сколько записалось впервые","format":"int64","type":"integer"},"received":{"description":"сколько точек было в теле","format":"int64","type":"integer"},"rejected":{"description":"W2232 — КАНОНИЧЕСКОЕ представление отказа, единственное поле, которое сторонний клиент обязан разбирать: id + машиночитаемая причина (malformed/bad_signature/unsigned). rejected_ids/rejected_reasons/rejected_mask/uploaded_mask — ТА ЖЕ информация в других обёртках (транспортная оптимизация ради старых клиентов и экономии байт, W1834а/W1835) — можно игнорировать","items":{"properties":{"id":{"type":"string"},"reason":{"enum":["malformed","bad_signature","unsigned"],"type":"string"}},"type":"object"},"type":"array"},"rejected_ids":{"description":"транспортная оптимизация (не канон) — тот же список id, что и в rejected, без причины — опускается, если отказов нет","items":{"type":"string"},"type":"array"}},"required":["received","inserted","duplicates","accepted_ids","checksum"],"type":"object"}}},"description":"Успех — см. описание маршрута (description) для полной семантики полей."},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"загрузить массив зашифрованных точек"}},"/api/points/{groupId}":{"get":{"description":"точки группы новее ?since=\u003cunix\u003e (по умолчанию 0 — с начала; ?limit=N, 0=все). Нечисловой since/limit → 400 bad_query. ⚠ ПОЧЕМУ ЭТОТ GET ОТКРЫТ, А POST /api/sync/batch ТРЕБУЕТ ПОДПИСЬ (W1617): один и тот же ресурс, два правила доступа — не баг. GET работает по капабилити-модели «group_id = секрет» (кто знает идентификатор — читает шифроблобы; расшифровать без ключа группы нельзя, см. правило про group_id). sync/batch — новый путь, на нём поэтапно вводится подписанное чтение по членству (фаза W1042): при включённой фазе C он режет разделы по членству, а GET пока оставлен открытым для совместимости. Сведение к одному правилу — на подходе. Точка: {id, owner_id, group_id, timestamp, iv, ciphertext, auth_tag, seq, app_version_code}. seq — сквозной номер точки в треке отправителя (открытым текстом: номер не выдаёт места, зато по нему видно, дыра это НЕДОСЛАННОЕ или пауза в записи); app_version_code — сборка отправителя на момент загрузки. Оба поля необязательны для клиента и могут отсутствовать у старых записей. ⚠ Пустой ответ [] сам по себе неотличим от «в группе тишина», НО у СТЁРТОЙ группы (W1348) ответ несёт заголовок X-Group-Erased: 1 — по нему клиент понимает «группа удалена, перестань опрашивать», не дожидаясь erased_check. Полная сводка удаления (кто/сколько подтвердили, надгробие с подписью) — по-прежнему через erased_check в POST /api/sync/batch (erased_detail); заголовок — дешёвый машинный признак прямо на этом маршруте (W1326). ⚠ W1852 — «удалена» ≠ «стёрта»: между DELETE /api/groups/{gid} и физическим стиранием (набором кворума ack-delete от ВСЕХ участников) снимок группы намеренно ЖИВ (W141 — это лид для поиска), и ЭТОТ маршрут честно продолжает отдавать те же точки. В ЭТОМ окне непустой ответ несёт заголовок X-Group-Deleted: 1 (тело БЕЗ ИЗМЕНЕНИЙ — те же точки, аддитивно) — клиент, привыкший читать «непусто = группа жива», узнаёт, что удаление УЖЕ объявлено и точки, которые он получает, доживают последние дни/подтверждения; за числом «N из M подтвердили» — POST /api/groups/erased. X-Group-Erased и X-Group-Deleted взаимоисключающие (первый только на пустом ответе после ПОЛНОГО стирания, второй — только на непустом, до него). ⚠ W1410: у НИКОГДА не существовавшей группы (опечатка/обрезанный group_id) признака НЕТ — ответ [] без X-Group-Erased, как «тишина». Это осознанно: сервер не подтверждает существование group_id (он секрет-капабилити), иначе перебором можно было бы искать живые группы. Клиент, впервые получивший [] по свежей ссылке, должен сам проверить, что group_id скопирован ЦЕЛИКОМ","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}},{"description":"с какого unix-времени отдавать (единицы — см. правило по маршрутам; по умолчанию 0)","in":"query","name":"since","required":false,"schema":{"type":"integer"}},{"description":"максимум записей за один ответ (0 или больше потолка = потолок сервера, 20000; отрицательное — ошибка 400)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"точки группы новее ?since=\u003cunix\u003e"}},"/api/points/{groupId}/head":{"get":{"description":"последняя (last) + первая (first) точка каждого участника; first ОПУСКАЕТСЯ, когда совпадает с last (у участника одна точка) — экономия трафика, клиент берёт last как первую","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"последняя"}},"/api/points/{groupId}/track-status":{"get":{"description":"наблюдателю: статусы всех треков группы (open/finished + max_seq), чтобы знать длину-цель и растёт ли трек. ⚠ W1906 (userflow-аудит 06.09) — ОТКРЫТ ПО ЗНАНИЮ group_id, БЕЗ Bearer и БЕЗ подписи (третья модель доступа в семье tracks: см. GET /api/tracks/{groupId} — Bearer, GET /api/tracks/{groupId}/{trackId} — капабилити group_id). status/max_seq — routing-метаданные без координат, задуманы читаемыми любым участником обмена без лишней аутентификации; асимметрия с соседями по семье — та же поэтапная миграция W1042, не рассинхрон","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"наблюдателю"}},"/api/points/{groupId}/{ownerId}":{"delete":{"description":"забрать СВОИ точки из группы (уход из группы, право на забвение). Подпись: ?owner_id=\u0026ts=\u0026sig= над «points-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» → 204. Идемпотентно","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}},{"in":"path","name":"ownerId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"забрать СВОИ точки из группы"}},"/api/profiles":{"post":{"description":"загрузить зашифрованные профили участников: массив {owner_id, group_id, updated_at (unix СЕКУНДЫ, W1204 — как хранение; вне окна [01.01.2020; сейчас+1 час] → 400), iv, ciphertext, auth_tag, sig}. Пустые owner_id/group_id/iv/ciphertext/auth_tag отвергаются целиком (400 missing_fields): такую запись потом нельзя было бы удалить — подписывать нечего. КАНОН ПОДПИСИ (W966, основной): «profile|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003cupdated_at\u003e|\u003cblob_fp\u003e», где blob_fp = hex(sha256(«\u003civ\u003e|\u003cciphertext\u003e|\u003cauth_tag\u003e»)) в НИЖНЕМ регистре — отпечаток связывает подпись с СОДЕРЖИМЫМ (иначе посредник подставил бы чужой/старый блоб, оставив подпись верной). ПЕРЕХОДНО принимается и короткий канон «profile|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003cupdated_at\u003e» (без отпечатка) — для сборок ≤756; он слабее и будет снят, новый клиент ДОЛЖЕН слать канон с отпечатком. Подпись НЕОБЯЗАТЕЛЬНА, но именно она даёт ЧЛЕНСТВО: неподписанный профиль хранится (сервер слеп к содержимому), однако в кворум на удаление группы не считается. Подпись, которая ПРИСУТСТВУЕТ и не сходится ни с одним каноном, — это подделка, и весь запрос отвергается с 403 bad_signature. ОТВЕТ 202 {received, accepted, members, unsigned, stored, discarded} (W1820 — stored/discarded ДОБАВЛЕНЫ в код W1740, но раньше не попали в это описание): received — сколько записей в теле; accepted — сколько дали ЧЛЕНСТВО (подпись сошлась) из ПРИСЛАННЫХ (НЕ размер группы!); members — устаревший алиас accepted (W1037); unsigned — принято без подписи (хранятся, членства не дают); stored — сколько ФАКТИЧЕСКИ записано на диск (прошло LWW по updated_at); discarded — принято, но отброшено как НЕ новее уже сохранённого (received − stored, при отсутствии structурного 400). ⚠ accepted ≠ stored: подписанный, но устаревший по LWW профиль попадёт в accepted (подпись сошлась), но НЕ в stored (запись не легла) — клиент отличает «зачтено для членства» от «реально сохранено» по РАЗНЫМ полям. ⚠ W2242 (userflow-аудит 16.09) — В ТЕЛЕ ЕСТЬ ЕЩЁ ДВА ПОЛЯ (раньше не описаны): membership (bool — стал ли звонящий подтверждённым участником хоть одной названной группы: подпись сошлась) и owner_slots ([{group_id, owner_id, owner_slot:\"claimed\"|\"taken\"}] — по каждой группе: claimed = слот владельца за ВАМИ, taken = за ДРУГИМ; чтобы после загрузки профиля сразу знать, можете ли объявлять удаление, не дёргая GET .../owner-key). ⚠ W2205 (userflow-аудит 15-17.09) — ЕЩЁ ОДНО АДДИТИВНОЕ ПОЛЕ: next (\"POST /api/groups/{groupId}/meta\", ТОЛЬКО когда membership=true) — создание группы это ТРИ вызова в строгом порядке (owner-key→profiles→meta), next подсказывает шаг 3. Когда membership=false (есть неподписанные/не сошедшиеся записи), тело НЕ меняется (см. Hint), но ответ несёт заголовок X-Warning: membership=false — чтобы это было видно и без разбора тела","requestBody":{"content":{"application/json":{"schema":{"description":"Массив профилей участников (E2E-блоб + подпись sig).","items":{"$ref":"#/components/schemas/EncryptedProfile"},"type":"array"}}},"required":true},"responses":{"202":{"content":{"application/json":{"schema":{"properties":{"accepted":{"description":"сколько дали ЧЛЕНСТВО (подпись сошлась) из присланных","format":"int64","type":"integer"},"discarded":{"description":"принято, но отброшено как НЕ новее уже сохранённого","format":"int64","type":"integer"},"hint":{"description":"W1856 — почему нет, и что сделать (когда membership=false)","type":"string"},"members":{"description":"устаревший алиас accepted","format":"int64","type":"integer"},"membership":{"description":"W1856 — вся пачка целиком дала членство?","type":"boolean"},"next":{"description":"W2205 — «POST /api/groups/{groupId}/meta», ТОЛЬКО при membership=true (шаг 3 создания группы, после owner-key→profiles)","type":"string"},"received":{"description":"сколько записей в теле","format":"int64","type":"integer"},"stored":{"description":"сколько фактически записано на диск (прошло LWW по updated_at)","format":"int64","type":"integer"},"unsigned":{"description":"принято без подписи — хранятся, членства не дают","format":"int64","type":"integer"}},"required":["received","accepted","stored"],"type":"object"}}},"description":"Успех — см. описание маршрута (description) для полной семантики полей."},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"загрузить зашифрованные профили участников"}},"/api/profiles/{groupId}":{"get":{"description":"профили участников группы (updated_at \u003e ?since, по умолчанию 0 — с начала). ⚠ ЕДИНИЦЫ: updated_at и ?since здесь в unix-СЕКУНДАХ — как хранение, как точки и как мета группы (W1204 исправил зеркальный промах W1142, который ошибочно требовал миллисекунды и рубил любой валидный курсор). Мусорный since → 400 bad_query; since РАЗМЕРА МИЛЛИсекунд (\u003e 10^12) → 400 с указанием единиц (похоже на ошибку в тысячу раз), а НЕ молчаливый пустой список. Профиль с updated_at ≤ 0 в выборку не попадёт — см. правила. ЧТЕНИЕ читается по знанию group_id; фаза A: клиент уже подписывает ?owner_id\u0026ts\u0026sig, канон «profiles-list|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» (см. таблицу W732), сейчас фаза B — сервер МЕРИТ долю подписанных, но ещё НЕ режет по членству (гейт включится позже, как у sync/batch). ⚠ W1820 — помимо ТЕЛА (JSON-массив профилей) ответ несёт ДВА ЗАГОЛОВКА, которых в этом описании раньше не было: X-Profiles-Total (сколько записей в вернувшемся массиве) и X-Profiles-Signed (сколько из них подписаны — симметрия со счётчиками записи W1756); формат тела-массива они не меняют, это дополнительная сводка для клиента, который не хочет пересчитывать sig!=\"\" сам","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}},{"description":"с какого unix-времени отдавать (единицы — см. правило по маршрутам; по умолчанию 0)","in":"query","name":"since","required":false,"schema":{"type":"integer"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"профили участников группы"}},"/api/profiles/{groupId}/manifest":{"get":{"description":"ростер группы: пары (owner_id, updated_at) без шифрополей. Клиент сверяет со своей копией и тянет только изменившиеся профили — авторитетная сверка вместо отравляемого курсора since. Как и /profiles, читается по знанию group_id и в ФАЗЕ B подписанного чтения (канон «profiles-list|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e», см. таблицу W732): сервер мерит, не режет","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"ростер группы"}},"/api/profiles/{groupId}/{ownerId}":{"delete":{"description":"удалить СВОЙ профиль в группе. Подпись: ?owner_id=\u0026ts=\u0026sig= над «profile-delete|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» → 204. Идемпотентно","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}},{"in":"path","name":"ownerId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"удалить СВОЙ профиль в группе"}},"/api/public-tracks":{"get":{"description":"лента сообщества — ДЛЯ ВОШЕДШИХ (требует Bearer; без токена → 401 unauthorized). Это ограничение АУДИТОРИИ (сообщество вошедших, а не весь интернет), а не шифрование: содержимое (gpx/имя) открыто по замыслу — см. пометку выше. ⚠ W1891 — ПОЛЯ ПО ФАКТУ (без gpx, для списка): {id, owner_id, author, name, distance_m, point_count, format, published_at}. Прежнее описание называло started_at/ended_at/group_id и «имя — E2E-блоб» — то был текст ДРУГОГО маршрута (см. врезку выше), сюда он попал по ошибке. ?since=\u003cunix\u003e — новые/изменённые публикации; по умолчанию 0","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"лента сообщества"},"post":{"description":"опубликовать трек в ленте сообщества (Bearer): {id, gpx, name?, distance_m?, point_count?}. ⚠ ЭТО ОТКРЫТАЯ ВИТРИНА, А НЕ E2E-ХРАНИЛИЩЕ (W1891): gpx и name хранятся ОТКРЫТЫМ ТЕКСТОМ намеренно — витрина по определению публичная (opt-in: пользователь сам решает опубликовать готовый файл), это НЕ то же самое, что «сервер никогда не видит координаты» у обычных треков/точек, и не должно читаться как дыра в E2E. author в ответе БЕРЁТСЯ С АККАУНТА по Bearer (полное имя из профиля аккаунта), а НЕ из тела запроса — опубликовать под чужим именем нельзя (W1023). Повторный POST того же id — идемпотентная перепубликация СВОЕГО трека (owner_id сверяется с токеном, чужой id → 403). 200 {status:\"ok\"}","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"summary":"опубликовать трек в ленте сообщества"}},"/api/public-tracks/{id}":{"delete":{"description":"снять СВОЮ публикацию (Bearer, owner_id должен совпасть с токеном). Чужая или неизвестная запись → 403 (сервер не подтверждает существование чужой публикации отдельным кодом). 200","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"summary":"снять СВОЮ публикацию"},"get":{"description":"одна публикация ЦЕЛИКОМ, включая gpx (для просмотра/экспорта). Bearer тот же, что у списка; неизвестный id → 404","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"summary":"одна публикация ЦЕЛИКОМ, включая gpx"}},"/api/sos":{"post":{"description":"разослать сигнал бедствия: {id, owner_id, group_ids?, plain, blob?, created_at?}. ОБЯЗАТЕЛЬНЫ id и ХОТЯ БЫ ОДНО из plain/blob (все недостающие поля называются ОДНИМ ответом, W1143). ⚠ СИГНАЛ БЕДСТВИЯ НЕ ШИФРУЕТСЯ, и это осознанное решение: plain — открытый сигнал в виде JSON-СТРОКИ (поле plain несёт СТРОКУ с JSON-текстом, НЕ вложенный объект: вложенный объект даёт 400 bad_request — W1414). Внутри строки: {owner_id, nickname, lat, lon, ts, reason?, alt?, alt_src?, pub_key, signature}. ⚠ ПОЛЕ ПОДПИСИ ВНУТРИ plain НАЗЫВАЕТСЯ signature (НЕ sig): обязательны pub_key и signature, и owner_id внутри обязан совпасть с owner_id запроса. Человек, нажавший кнопку, требует помощи, и помочь может тот, кто не в его группе и не знает его ключей; прежняя схема шифровала общим для всех ключом из APK, то есть защиты не давала, а вид создавала. Пользователя предупреждают об открытости ДО нажатия. Подпись проверяется ПРИЁМНИКАМИ (не сервером) по закреплённому ключу автора — подать сигнал от чужого имени нельзя. КАНОН подписи (W1550): ECDSA P-256 (SHA256withECDSA, DER, base64) над UTF-8-байтами ДЕТЕРМИНИРОВАННОГО JSON полей plain БЕЗ поля signature, порядок: {lat, lon, nickname, owner_id, pub_key, [reason,] [alt, alt_src,] ts} (reason и alt/alt_src включаются только когда заданы; alt/alt_src идут ПОСЛЕ reason перед ts). Числа — как есть, строки — в JSON-кавычках с экранированием. blob — прежнее зашифрованное представление, НЕОБЯЗАТЕЛЬНО (уходящее): заполняется параллельно ради сборок, умеющих читать только его, и будет убрано (W831). group_ids можно опустить — сигнал уйдёт под глобальный маяк ближним посторонним","requestBody":{"content":{"application/json":{"schema":{"properties":{"blob":{"type":"string"},"group_ids":{"items":{"type":"string"},"type":"array"},"id":{"type":"string"},"owner_id":{"type":"string"},"plain":{"description":"JSON-СТРОКА подписанного сигнала (не объект, W1414)","type":"string"}},"required":["id"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"разослать сигнал бедствия"}},"/api/sos/active":{"get":{"description":"лента активных сигналов бедствия: ?since=\u003cunix\u003e. Открыта без авторизации намеренно — приложение работает без аккаунта, а помочь может посторонний рядом. Отдаётся ТОЛЬКО изменившееся за последний час (архива нет, since глубже часа не уводит), и у погашенного сигнала тело пустое: гасить запись можно по id. Кому показывать сигнал, решает клиент: участнику группы — на любом расстоянии, постороннему — в пределах 10 км","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"лента активных сигналов бедствия"}},"/api/sos/{groupId}":{"get":{"description":"активные SOS группы","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"активные SOS группы"}},"/api/sos/{id}/cancel":{"post":{"description":"отменить СВОЙ SOS. Подпись передаётся В ТЕЛЕ (не в query): {owner_id, canceled_at, signature} — signature над «sos-cancel|\u003cid\u003e|\u003cowner_id\u003e|\u003ccanceled_at\u003e». Bearer не подходит: сигнал бедствия гасит только тот, кто его подал. Неизвестный id тоже даёт 403 (чтобы не выдавать существование сигнала)","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"canceled_at":{"description":"unix секунды, часть подписываемой строки","format":"int64","type":"integer"},"owner_id":{"type":"string"},"sig":{"description":"синоним signature (W1383) — примите ОДНО из двух полей","type":"string"},"signature":{"description":"base64 ECDSA над «sos-cancel|\u003cid\u003e|\u003cowner_id\u003e|\u003ccanceled_at\u003e» (каноническое имя поля здесь — signature; sig принимается как синоним, W1383/W1816)","type":"string"}},"required":["owner_id","canceled_at"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"отменить СВОЙ SOS"}},"/api/sync/batch":{"post":{"description":"ОДИН обмен вместо десятка запросов — то, чем клиент живёт в поле: подъём радио стоит дороже самих данных, поэтому всё, что нужно за цикл, спрашивается разом. Тело: {upload:[точки], upload_v3:[вёдра], app_version_code, groups:[слот на группу], sos_since, owner_id, list_sig, list_ts, cr_sig, cr_ts, invite_sig, invite_ts, erased_check:[group_id], accept_points}. accept_points (W1835, протокол V3) — какую версию формата точки клиент готов ПРИНЯТЬ в ответе (по умолчанию 2, сегодняшний V1/V2); ≥3 — сервер ДОПОЛНИТЕЛЬНО отдаёт в каждом слоте группы points_v3/heads_v3 (ведёрная форма по (group_id,track_id), см. ниже), а Points/Heads несут ТОЛЬКО оставшиеся (не-V3-порождённые) точки — ни одна точка не едет в обоих представлениях; \u003c3 или поле не прислано — ответ БЕЗ ИЗМЕНЕНИЙ (points_v3/heads_v3 не появляются вовсе), сервер лишь замеряет значение в [NET] для оценки готовности парка перед сменой формата (урок W1833). upload_v3 (W1835, протокол V3, этап 2) — та же выгрузка своих точек, что upload, но ВЕДРОМ по (group_id,track_id): {g,o,t,sv:3,p:[{s,ts,iv,c,sig}]} — id НЕ передаётся (сервер и клиент выводят его сами как uuid5(NSSpoorPoint,\"\u003cowner_id\u003e#\u003ctrack_id\u003e#\u003cseq\u003e\"), см. docs/design/wire-protocol-v3.md), c = base64(ciphertext||16-байтный тег), sig подписан каноном «point3|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003cts\u003e|\u003ctrack_id\u003e|\u003cs\u003e» (ЕДИНСТВЕННЫЙ канон для sv=3, без перебора V1/V2). app_version_code в теле — ЗАГОЛОВОК конверта (не поле точки): используется ТОЛЬКО для точек из upload_v3 (у них своего app_version_code нет, экономия байт); upload/upload_batches несут его как раньше, на каждой записи. Можно слать upload и upload_v3 в одном запросе — независимые поля. ⚠ ГЛАВНЫЙ ГЕЙТ ЧТЕНИЯ ГРУПП (W1613): при включённой фазе C (W1042_ENFORCE_GROUP_READ) без подписи list_sig/list_ts разделы группы вернутся как «access_denied»:true. Подпишите канон «sync-list|\u003cowner_id\u003e|\u003cts\u003e» (owner_id — тот же, что в теле; ts — unix-СЕКУНДЫ, окно ±3600 с) закреплённым ключом идентичности (см. таблицу W732). Просроченный ts при ВЕРНОЙ подписи → 403 stale_timestamp (сверьте часы, W1614), а не access_denied. СЛОТ ГРУППЫ: {group_id, since (курсор точек), limit (0 → серверный предел), heads (false → не отдавать голову трека каждого участника), points (false → ТОЛЬКО статус трансляции: группу не показываем на карте, но знать, кто в эфире, всё равно надо), manifest (true → ростер: owner_id + updated_at профиля), meta_since (мета группы: имя/аватар/подписанный ростер), waypoints_since (сторожевые точки, включая надгробия), commands_since (быстрые команды W57), messages_since (созревшие отложенные сообщения W58), overlays_since (элементы совместных слоёв W56, с надгробиями)}. Все *_since необязательны: опущенное поле означает «этого не спрашиваю», и это НЕ то же, что 0 («спрашиваю с начала»). ОТВЕТ: {uploaded_ids (что сервер принял из upload — удалять из своей очереди можно ровно это; повторно присланная и уже сохранённая точка тоже попадает сюда, иначе она вечно висела бы в очереди), rejected/rejected_ids (W896 — что НЕ легло: форма/подпись; при этом id всё равно в uploaded_ids, чтобы очередь не заклинило — но если ваш id тут, точка потеряна: чаще всего подпись разошлась с закреплённым ключом, надо переподписать и дослать. Старый клиент поля не видит), uploaded_mask/rejected_mask (W1835, протокол V3, этап 1 — компактная битовая форма ТОЙ ЖЕ квитанции, РЯДОМ со старыми полями, ничего не заменяет: base64 ⌈n/8⌉ байт, бит big-endian, индекс i = ПОЗИЦИЯ upload[i] во входном массиве, НЕ порядок вставки/алфавит; ⚠ семантика отличается от uploaded_ids — тот квитирует ВСЕ id включая отвергнутые (W717, анти-петля), а бит uploaded_mask установлен, только если точка ДЕЙСТВИТЕЛЬНО принята и сохранена, rejected_mask — зеркало для отказов), rejected_reasons (та же причина, что в rejected, но по ИНДЕКСУ: [{i, reason}]), uploaded_v3_ids/rejected_v3/rejected_v3_ids (W1835, протокол V3, этап 2 — квитанция upload_v3, зеркало uploaded_ids/rejected для точек, но ids здесь ВЫЧИСЛЕННЫЕ (uuid5), не присланные; ведро, у которого пусты g/o/t или sv≠3, не квитируется вовсе — сервер не может назвать честный id, это клиентский баг конверта, повтор не поможет), groups:[{group_id, heads, points, points_v3, heads_v3, track_status, manifest, meta, waypoints, commands, messages}]. points_v3/heads_v3 (W1835, протокол V3, этап 2) присутствуют, ТОЛЬКО когда запрос нёс accept_points≥3: EncryptedPointV3Bucket — точки того же слота группы, что попали в points_v3 вместо points (для heads — та же голова целиком в heads_v3 вместо heads, только если И last, И first V3-порождены, иначе голова остаётся в heads целиком, см. схему EncryptedPointV3Bucket в /api/openapi.json). sos, contact_requests, invites, erased, erased_detail}. erased_detail: [{group_id, signer_owner_id, signature (канон «delete-group|\u003cgroup_id\u003e» — переподтвердить удаление своим ростером), acked_count, members_count (приняли N из M; в этом обмене оба нуля, живой подсчёт «N из M» отдаёт только POST /api/groups/erased по подписи участника), purged (true → снимок группы стёрт физически, потому что подтвердили ВСЕ: тогда acked_count и members_count — финальные и равны, читать как «удалено у всех», а не «0 из N»)}]. Слоты обрабатываются параллельно, порядок групп в ответе совпадает с запросом. Подписи (owner_id+cr_sig/cr_ts, invite_sig/invite_ts) нужны только для приглашений и заявок в контакты — без них эти два раздела ответа просто пусты, остальное работает. ТРАНСПОРТ (W1835, протокол V3, этап 3): тело можно прислать как application/json (по умолчанию) ИЛИ application/cbor (Content-Type: application/cbor — та же схема, ключи полей те же, что в JSON); ответ кодируется CBOR'ом, ТОЛЬКО если запрос нёс заголовок Accept: application/cbor, иначе — обычный JSON. Оба заголовка независимы (можно прислать JSON и попросить CBOR-ответ, и наоборот). Аварийный откат: если оператор выключил транспорт (app_meta.cbor_enabled=0), любое упоминание application/cbor в Content-Type или Accept получает 415 unsupported_media_type — повторите запрос как обычный JSON без этих заголовков. gzip (Content-Encoding: gzip) работает поверх обоих форматов тела без изменений","requestBody":{"content":{"application/json":{"schema":{"properties":{"accept_points":{"description":"W1835 (протокол V3): версия формата точки, которую клиент готов ПРИНЯТЬ в ответе (по умолчанию 2). При значении ≥3 сервер ДОПОЛНИТЕЛЬНО отдаёт points_v3/heads_v3 по каждой группе (ведёрная форма для V3-порождённых точек), Points/Heads при этом несут ОСТАВШИЕСЯ (не-V3) точки — ни одна точка не дублируется между представлениями","format":"int64","type":"integer"},"app_version_code":{"description":"W1835 (протокол V3, этап 2): сборка отправителя — ЗАГОЛОВОК конверта, используется ТОЛЬКО для точек из upload_v3 (у них нет своего app_version_code, в отличие от upload/upload_batches)","format":"int64","type":"integer"},"groups":{"description":"срез, который клиент запрашивает по каждой группе","items":{"properties":{"exclude_owner":{"description":"W1785: не отдавать этого owner_id в heads/points/batches этой группы (типично — свой собственный, чтобы не получать назад только что отправленное); пусто — никто не исключается","type":"string"},"group_id":{"type":"string"},"heads":{"description":"false — пропустить head-выборку этой группы","type":"boolean"},"heads_since":{"description":"W1785: отдать head владельца, только если его last.timestamp новее курсора (unix секунды); nil — все головы","format":"int64","type":"integer"},"limit":{"type":"integer"},"points":{"description":"false — только track_status, без heads/points","type":"boolean"},"since":{"description":"курсор points (unix секунды)","format":"int64","type":"integer"},"want_first":{"description":"W1785: false — не отдавать TrackHead.first (пропускает второй DISTINCT ON), nil/true — как раньше, first отдаётся","type":"boolean"}},"required":["group_id"],"type":"object"},"type":"array"},"list_sig":{"description":"base64 ECDSA над «sync-list|\u003cowner_id\u003e|\u003cts\u003e» — доказывает owner_id читателя (W1545)","type":"string"},"list_ts":{"description":"unix секунды подписи списка","format":"int64","type":"integer"},"owner_id":{"type":"string"},"upload":{"description":"новые точки на выгрузку (идемпотентно по id)","items":{"$ref":"#/components/schemas/EncryptedPoint"},"type":"array"},"upload_v3":{"description":"W1835 (протокол V3, этап 2): та же выгрузка ведром по (group_id,track_id) — id не передаётся (uuid5), auth_tag в хвосте c. Независимо от upload — можно слать оба поля в одном запросе","items":{"$ref":"#/components/schemas/EncryptedPointV3Bucket"},"type":"array"}},"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"ОДИН обмен вместо десятка запросов"}},"/api/tracks":{"get":{"description":"СВОИ треки (Bearer, W934), новые первыми — и personal (без group_id), и групповые. Тот же ответ, что GET /api/account/tracks (алиас для находимости); раньше своих треков нельзя было перечислить вовсе","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"СВОИ треки"},"post":{"description":"сохранить метаданные трека (Bearer): обязательны id и owner_id (created_at проставляется сам, если 0). Имя трека — E2E-блоб, серверу непрозрачно. point_count — необязательное клиентское поле (сколько точек в треке НА МОМЕНТ ЭТОГО POST); W1854 — сервер САМ увеличивает его при каждой реально вставленной точке/пачке, чей track_id совпадает с id этого трека (POST /api/points, /api/sync/batch upload/upload_v3, /api/points/batches) — поле больше не застревает на значении времени создания. Повторный POST того же id снова ПОБЕЖДАЕТ своим point_count (last-write-wins, как и раньше) — присланное значение перекрывает накопленное сервером, если пришло позже. Ответ 202 {id} — запись СОХРАНЕНА (202 = принято, итог читается отдельно, см. правило W827); если трек уже принадлежит ДРУГОМУ аккаунту — 403. ⚠ ЧТО ЧИТАТЬ ПОТОМ (W1149): маршрута GET /api/tracks/{ownerId} НЕТ — под {groupId} он ищет треки ГРУППЫ, а personal-трек (без group_id) там не покажется. Свои треки читать через GET /api/account/tracks (ниже), групповые — GET /api/tracks/{groupId} с Bearer","requestBody":{"content":{"application/json":{"schema":{"properties":{"created_at":{"description":"unix секунды; проставляется сервером, если 0 или опущено","format":"int64","type":"integer"},"ended_at":{"format":"int64","type":"integer"},"group_id":{"type":"string"},"id":{"type":"string"},"name":{"description":"E2E-блоб имени трека — сервер содержимого не видит; может быть опущено/пустым","type":"string"},"owner_id":{"type":"string"},"point_count":{"type":"integer"},"started_at":{"format":"int64","type":"integer"}},"required":["id","owner_id"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"сохранить метаданные трека"}},"/api/tracks/{groupId}":{"get":{"description":"метаданные треков ГРУППЫ, новые первыми (Bearer — иначе утечёт owner_id и счётчики по знанию group_id, W111). Текущий клиент этот маршрут не зовёт (метаданные едут другими каналами)","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"метаданные треков ГРУППЫ, новые первыми"}},"/api/tracks/{groupId}/{trackId}":{"get":{"description":"зашифрованные точки трека в группе: ?since=\u0026limit= как у /api/points/{groupId}. ⚠ W1906 (userflow-аудит 06.09) — ОТКРЫТ ПО ЗНАНИЮ group_id, БЕЗ Bearer (та же капабилити-модель, что у /api/points/{groupId}) — В ОТЛИЧИЕ от соседнего GET /api/tracks/{groupId} (метаданные ВСЕХ треков группы, тот под Bearer, см. выше). Это НЕ противоречие, а разная СТАДИЯ одного и того же плана (W1042): метаданные (владелец, счётчики) закрыты Bearer раньше, чтение ТОЧЕК конкретного уже известного трека — ещё нет; сведение к одному правилу идёт поэтапно, как и у /api/points/{groupId}","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}},{"in":"path","name":"trackId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"зашифрованные точки трека в группе"}},"/api/tracks/{trackId}":{"delete":{"description":"удалить СВОЙ трек и его точки (Bearer). Чужой трек → 403; неизвестный → 204 (идемпотентно). consent_log сохраняется (аудит GDPR)","parameters":[{"in":"path","name":"trackId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"удалить СВОЙ трек и его точки"}},"/api/tracks/{trackId}/consent":{"post":{"description":"выдать/ОТОЗВАТЬ согласие на публикацию трека (Bearer + владение треком): тело {action: \"grant\"|\"revoke\", consent_version?}. ⚠ W1821 — СИНОНИМЫ action (аддитивно, канон не отменяют): {\"consent\": true|false} и {\"publish_consent\": true|false} (true≡grant, false≡revoke) — второе имя специально совпадает с полем ОТВЕТА этого же маршрута, чтобы естественная догадка «отправить то же имя, что я прочитал» сработала. Если action прислан, он побеждает; иначе берётся первый из синонимов. Ни один опознаваемый вариант → 400 bad_request. W935: и выдача, и отзыв — оба обязательны по GDPR (право отозвать согласие). publish_consent по умолчанию false (privacy by default, никаких предустановленных галочек). owner_id для неизменяемого журнала согласий берётся из ПРОВЕРЕННОГО трека, не из тела. 202 {publish_consent, action} — ФАКТИЧЕСКОЕ состояние согласия (W827: 202 = принято, публикация проверяется отдельно). ⚠ W1416: сам по себе grant НЕ гарантирует публикацию — ПУСТОЙ трек (point_count 0) в /api/public-tracks не попадает намеренно; проверять фактическую публикацию через GET /api/public-tracks, а не по коду 202","parameters":[{"in":"path","name":"trackId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"action":{"description":"\"grant\" — дать согласие на публикацию, \"revoke\" — отозвать. Канонический способ; см. также consent/publish_consent ниже","enum":["grant","revoke"],"type":"string"},"consent":{"description":"W1821: синоним action — true≡grant, false≡revoke. Аддитивно, не заменяет action","type":"boolean"},"consent_version":{"description":"версия текста согласия, показанного пользователю (необязательно)","type":"string"},"publish_consent":{"description":"W1821: второй синоним action, названный как поле в ОТВЕТЕ этого же маршрута (true≡grant, false≡revoke)","type":"boolean"}},"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"выдать/ОТОЗВАТЬ согласие на публикацию трека"}},"/api/tracks/{trackId}/status":{"put":{"description":"рекордер сообщает статус своего трека: тело {owner_id, group_id, status: open|finished, updated_at, signature}, подпись «track-status|\u003ctrack_id\u003e|\u003cowner_id\u003e|\u003cstatus\u003e|\u003cupdated_at\u003e». 202. LWW по updated_at","parameters":[{"in":"path","name":"trackId","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"рекордер сообщает статус своего трека"}},"/api/waypoints":{"post":{"description":"массив сторожевых точек группы (W199): {id, group_id, owner_id, updated_at (unix СЕКУНДЫ), iv, ciphertext, auth_tag, deleted, sig}. КАНОН ПОДПИСИ: waypoint|\u003cid\u003e|\u003cowner_id\u003e|\u003cgroup_id\u003e|\u003cupdated_at\u003e|\u003cdeleted\u003e — флаг удаления ВХОДИТ в подпись, иначе посредник превратил бы правку в надгробие, не тронув содержимого (W927). updated_at вне окна [01.01.2020; сейчас+1 час] → 400: метка из будущего уводила курсор получателя и глушила канал всей группе навсегда (W890). Чужой id (запись существует под другим owner_id) → 403","requestBody":{"content":{"application/json":{"schema":{"description":"Массив сторожевых точек (E2E-блоб + sig).","items":{"properties":{"auth_tag":{"type":"string"},"ciphertext":{"type":"string"},"deleted":{"type":"boolean"},"group_id":{"type":"string"},"id":{"type":"string"},"iv":{"type":"string"},"owner_id":{"type":"string"},"sig":{"type":"string"},"updated_at":{"format":"int64","type":"integer"}},"type":"object"},"type":"array"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"массив сторожевых точек группы"}},"/api/waypoints/{groupId}":{"get":{"description":"сторожевые точки группы новее ?since=\u003cunix СЕКУНДЫ\u003e (по умолчанию 0). Отдаются те же шифроблобы, что приняты, — содержимое серверу непрозрачно. ПОДПИСАННОЕ ЧТЕНИЕ (W1017): обязательны ?owner_id=\u003cowner_id\u003e\u0026ts=\u003cunix секунды\u003e\u0026sig=\u003cподпись\u003e, канон «waypoints-list|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» (W1899, userflow-аудит 06.09 — КАНОНИЧНОЕ имя параметра owner_id, как у всех остальных подписанных чтений; ?owner= тоже принимается, но только как СОВМЕСТИМЫЙ синоним, W1418 — прежний текст называл owner= каноном именно для waypoints, это было неточно: синоним общий для ВСЕХ маршрутов через requireSignedMemberRead, не особенность этого канала); без подписи 403 signature_required, с разошедшимися часами (\u003e5 мин) — 403 bad_signature. Спрашивающий обязан быть участником группы, доказавшим себя подписанной загрузкой (403 forbidden иначе): по одному знанию group_id список авторов и времён правок больше не отдаётся — по нему восстанавливается социальный граф (тот же довод, что у GET /api/invites, W111) и настраивается отравление курсора (W1013). Исключение ровно одно: пока в группе нет НИ ОДНОГО подтверждённого участника, членство спрашивать не у кого","parameters":[{"in":"path","name":"groupId","required":true,"schema":{"type":"string"}},{"description":"с какого unix-времени отдавать (единицы — см. правило по маршрутам; по умолчанию 0)","in":"query","name":"since","required":false,"schema":{"type":"integer"}},{"description":"идентичность читателя (подписанное чтение)","in":"query","name":"owner_id","required":false,"schema":{"type":"string"}},{"description":"unix-секунды подписи (окно свежести)","in":"query","name":"ts","required":false,"schema":{"type":"integer"}},{"description":"base64 ECDSA над каноном чтения (см. таблицу подписей W732)","in":"query","name":"sig","required":false,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"ownerSig":[]}],"summary":"сторожевые точки группы новее ?since=\u003cunix СЕКУНДЫ\u003e"}},"/api/web-shares":{"post":{"description":"⚠ W1890 (userflow-аудит 06.09) — ТРЕБУЕТ Bearer (проверено по коду, WebShareHandler.Create): устаревшая формулировка «открыт по знанию group_id» (была в общем разделе Auth и в других местах каталога) сюда НЕ относится и снята — без Bearer 401 unauthorized. W1353 — создать ОТЗЫВНУЮ веб-ссылку просмотра группы («конверт»). Тело: {group_id, blob}, где blob = base64(iv‖ciphertext‖tag) группового ключа, ОБЁРНУТОГО разовым секретом ссылки (сервер секрета НЕ видит — E2E цел). Ответ 201 {token}. Ссылка вида /view/#t=\u003ctoken\u003e\u0026s=\u003cсекрет\u003e: браузер тянет blob по токену и распаковывает его секретом из фрагмента. Отзыв = удаление строки: вручную (POST /api/web-shares/revoke, по всей группе), автоматически при закрытии группы, и (W2253) автоматически при выходе автора из группы (DELETE /api/groups/{groupId}/membership). ⚠ W2253 — автор ссылки записывается СЕРВЕРОМ из Bearer (uid аккаунта = owner_id личности при штатной регистрации), НЕ из тела, поэтому «Выйти из группы» снимает именно свои ссылки без правки клиента и без подделки авторства; ссылки, созданные до появления owner_id, гасятся только Revoke/закрытием группы. Частотный потолок по IP","requestBody":{"content":{"application/json":{"schema":{"properties":{"blob":{"type":"string"},"group_id":{"type":"string"}},"required":["group_id","blob"],"type":"object"}}},"required":true},"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[]}],"summary":"⚠ W1890"}},"/api/web-shares/revoke":{"post":{"description":"W1353 — отозвать веб-ссылку(и) группы (удалить строку). Параметры В СТРОКЕ ЗАПРОСА (не в теле): ?group_id=\u0026owner_id=\u0026ts=\u0026sig=. Требует Bearer владельца И подпись: sig над «webshare-revoke|\u003cgroup_id\u003e|\u003cowner_id\u003e|\u003cts\u003e» закреплённым ключом (двухфакторно — асимметрия с create, который открыт по знанию group_id, W1367). Отзыв также происходит автоматически при закрытии группы. → 200 {revoked:\u003cсколько строк удалено\u003e}","responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[{"bearerAuth":[],"ownerSig":[]}],"summary":"W1353"}},"/api/web-shares/{token}":{"get":{"description":"W1353 — получить обёрнутый ключ веб-ссылки: {group_id, blob}. 404, если ссылка отозвана или группа закрыта (blob серверу непрозрачен, распаковывается только секретом из фрагмента ссылки)","parameters":[{"in":"path","name":"token","required":true,"schema":{"type":"string"}}],"responses":{"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Успех — по описанию маршрута; ошибка — единая схема Error (ветвиться по error, не по message)."}},"security":[],"summary":"W1353"}}},"x-note":"Сгенерировано из каталога GET /api (единый источник). W1749 — покрытие расширено: тела ЗАПРОСОВ добавлены и для profiles, sync/batch, groups/{id}/meta, tracks, waypoints, commands, messages, overlays, invite-codes/redeem, identity/revoke (схема EncryptedProfile); добавлены query-параметры чтений (since/limit) и подписанных чтений (owner_id/ts/sig); securitySchemes больше не пуст (bearerAuth + ownerSig-в-query). Полную семантику подписи стандарт не выражает — она в description маршрута и таблице подписей W732. Тела ОТВЕТОВ (кроме Error) типизируются инкрементально."}