Перейти к основному содержимому

Публичный API и MCP

Как MBC Dash управляется снаружи: версионированный публичный REST API, MCP-сервер для агентов, ключи и OAuth-соединения, несущие скоупы, и один журнал аудита, который говорит, кто что сделал и через какую поверхность.

Этот документ фиксирует решения и рассуждения за ними. Он написан раньше кода, чтобы коду было с чем спорить.


Общая форма​

┌─────────────────────────────────────────────┐
браузер ────────▶│ внутренний REST (JWT, без обещаний версии) │──┐
└─────────────────────────────────────────────┘ │
┌─────────────────────────────────────────────┐ │ ┌────────────────────┐
интеграции ─────▶│ /api/v1 (ключи, только дополнение) │──┼──▶│ сервисы приложения │
└─────────────────────────────────────────────┘ │ │ (UserId accessor) │
┌─────────────────────────────────────────────┐ │ └────────────────────┘
агенты ─────────▶│ /mcp (OAuth 2.1, инструменты) │──┘ │
└─────────────────────────────────────────────┘ ▼
▲ ┌────────────────────┐
│ │ контекст вызова │
реестр операций объявляет │ + журнал аудита │
обе нижние поверхности └────────────────────┘

Три точки входа, один слой приложения, один журнал аудита.


1. Две поверхности, а не одна​

Существующие контроллеры остаются как есть и продолжают обслуживать фронтенд. Они не дают никаких обещаний совместимости — DTO может поменять форму, как только это понадобится интерфейсу.

Публичная поверхность — /api/v1, со своими DTO и контрактом «только дополнение» (§9).

Очевидное возражение — дублирование: два контроллера на фичу, и новую фичу легко добавить в один и забыть в другом. Ответ на это возражение — §2: публичная поверхность не пишется руками.

Внутренние DTO никогда не переиспользуются в /api/v1. Иначе рефакторинг ради фронтенда молча ломал бы внешних клиентов, и вся политика контракта была бы украшением.

/api/v1 был уже занят — контекстный путь переехал на /api​

server.servlet.context-path был /api/v1, и baseURL фронтенда ему соответствовал, так что внутренний API занимал путь, который должна была получить публичная поверхность, — и занимал номер версии, обещаний которой не выполняет.

Контекстный путь теперь /api. Внутренние контроллеры сохраняют свои маппинги и попадают на /api/tasks/…; маршруты реестра объявят /v1/… и попадут на /api/v1/…. Сделано до появления внешних клиентов, пока ломать было ещё только своё.

Это единственное изменение не целиком в репозитории. Внешний маршрут в панели Dokploy настраивается руками и всё ещё говорит /api/v1. Его нужно расширить до /api в том же деплое, который везёт эту версию, иначе бэкенд станет недоступен. Strip Path остаётся выключен.

2. Реестр операций​

/api/v1 и MCP — обе проекции одного объявления.

Операция — это бин, описывающий одну вызываемую снаружи вещь:

ПолеЗначение
idустойчивое имя, task.create — используется в аудите, мета-вызовах MCP, текстах ошибок
method + pathформа REST, POST /api/v1/tasks
scopeтребуемый скоуп из закрытого каталога (§4)
mutatingопределяет аудит, идемпотентность и класс лимита
input / outputпубличные record-типы, источник и JSON-схемы, и OpenAPI
descriptionпроза, которую читают люди в документации и модели в схемах инструментов
mcpTOOL (собственный инструмент MCP) или META (достижима через call_operation)
handlerвызывает существующий входящий порт модуля

Из реестра на старте порождаются три вещи: маршруты REST (программный RouterFunction), спецификации инструментов MCP (McpToolUtils.toSyncToolSpecifications) и раздел /api/v1 документа OpenAPI.

Добавить возможность — значит добавить одно объявление. Добавить её в API и забыть про MCP невозможно, потому что это один и тот же объект.

Связывание механическое: переменные пути, параметры запроса и тело сливаются в один объект и читаются в input-record — компонент по имени task заполняется переменной пути {task}, откуда бы она ни пришла. Настоящее ограничение поэтому — именование, а не форма URL: переменная пути должна называться так же, как компонент, который она заполняет. (План говорил, что форма ограничена одним суффиксом действия; связывателю, как выяснилось, всё равно, поэтому /tasks/{task}/assignees/{user} в порядке, а правило было снято, а не соблюдено ради себя самого.)

mutating объявляется, а не выводится из метода. Поиск — это POST, потому что его фильтр — документ, и он ничего не меняет; аудит, идемпотентность и более строгий лимит смотрят на объявление. Точно так же 201 принадлежит только операциям, которые приводят в существование что-то новое, а не каждому POST.

Проверки на старте: скоуп каждой операции должен существовать в каталоге; идентификатор каждой операции должен быть уникален; для каждого input-record должна порождаться схема. Кривое объявление роняет контекст, а не первый запрос.

3. Личность: персональные токены​

Ключ принадлежит пользователю и действует как этот пользователь. До сервисов приложения доходит тот же UserId, поэтому вся существующая авторизация работает без изменений — весь бэкенд и так авторизует по UserId accessor.

Скоупы всегда только сужают то, что может владелец. Ключ также несёт необязательный список рабочих пространств и проектов, до которых ему можно дотягиваться; вне этого списка ему отказывают даже там, где у владельца есть доступ. Без этого сужения персональный токен неизбежно оказывается токеном на всё, до чего дотягивается владелец, — стандартная беда модели PAT.

Ограда применяется в двух местах, которые и так разрешают доступ, — SpaceService.getSpace и accessibleSpaces, — и огороженный проект отвечает ровно так же, как несуществующий. Второе оказалось важнее первого: accessibleSpaces — это то, чем ограничен каждый межпроектный экран и каждый поиск, так что огородить только поиск по имени значило бы остановить ключ на открытии одного проекта, оставив ему свободу искать по всем.

«Ограничен» — это флаг, а не пустой список. Пустые списки означают «везде», поэтому администратор, отрезающий ключ от единственного названного им пространства, снял бы его последнее ограничение и выпустил на волю. Грант несёт limited явно: ключ, ограниченный ничем, дотягивается до ничего.

Принципал — интерфейс, а не класс:

interface CallerPrincipal {
UserId userId(); // фактический доступ
Actor actor(); // кто, для аудита
Source source(); // WEB | API | MCP
}

Служебные учётные записи, если они когда-нибудь понадобятся, — это новая реализация этого интерфейса, а не переписывание авторизации и аудита.

Ключи истекают. expiresAt обязателен при выпуске, по умолчанию 90 дней, максимум год. Иначе год работы оставляет груду бессмертных ключей неизвестного происхождения.

Формат и хранение ключа​

mbc_<id>_<secret> — id находит строку одним индексированным поиском, secret — 32 случайных байта. Хранится только SHA-256(secret), зеркально тому, что уже делают refresh-токены. Показывается один раз при выпуске, больше никогда.

Bcrypt намеренно не используется: секрет и так высокоэнтропийный, поэтому медленный хэш ничего не даёт и стоит миллисекунд на каждом вызове агента. (Штатный InMemoryApiKeyEntityRepository из Spring AI использует bcrypt, и его же документация называет его непригодным для трафика — мы реализуем ApiKeyEntityRepository сами.)

lastUsedAt пишется не чаще раза в минуту на ключ, иначе чтения означали бы запись на каждое чтение.

4. Скоупы​

Закрытый каталог. Разрушительные действия отделены от записи, потому что «может править задачи» и «может удалять проекты» не должны быть одной галочкой.

СкоупПокрывает
tasks:read / tasks:writeзадачи, статусы, значения полей, метки, исполнителей, подзадачи, связи, журнал работ
tasks:deleteудаление задач
comments:read / comments:writeкомментарии
attachments:read / attachments:writeвложения
structure:read / structure:writeрабочее пространство, проект, папка, список, описания полей, статусы
structure:deleteудаление и архивирование структурных объектов
views:read / views:writeподборки, раскладка главной
members:readучастники пространства (чтобы агент мог назначать по имени)
audit:readчтение журнала аудита

Выпуск ключей не является скоупом. Ключ никогда не может выпустить другой ключ — иначе утёкший ключ перевыпускает себя с более широкими правами, и отзыв перестаёт что-либо значить.

Новая операция должна уместиться в существующий скоуп. Если она честно не умещается, ей нужен новый скоуп, и ранее выпущенные ключи его, соответственно, не получают. Это правильное поведение по умолчанию: новая возможность не должна появляться в ключах, выданных до её появления.

5. Грант — источник истины​

Скоупы, ограничения по пространствам и отзыв читаются из базы на каждом вызове. Токены и ключи только опознают строку гранта.

Альтернатива — скоупы, запечённые в подписанный токен, — делает так, что «отозвать» и «сузить» вступают в силу только по истечении токена, а значит кнопка в настройках врёт. Один индексированный поиск на вызов — цена честной кнопки. Кэш не вводится, пока нет измеренной проблемы, потому что кэш здесь — буквально задержка отзыва.

Благодаря этому ключи API и OAuth-соединения ведут себя одинаково, и то и другое отзывается обновлением одной строки.

6. MCP​

Транспорт и авторизация​

MCP-сервер работает внутри бэкенда на /mcp (WebMvcStreamableServerTransportProvider), поэтому инструменты строятся из реестра в памяти и не могут разойтись с API.

Авторизация — OAuth 2.1, потому что цель — коннектор в чат-клиенте на телефоне, а такие не принимают статический заголовок.

Справочная документация подаёт MCP Security как часть Spring AI. Это не так: эти модули публикуются как org.springaicommunity в версии 0.1.x, собранные против более новой Spring Security, чем пинит Boot. Пре-1.0 артефакт сообщества, скомпилированный не против того, на чём будет работать, в коде, который решает, кого пускать, — поэтому мы берём Spring Authorization Server той версии, которой управляет Boot, и написали единственное, чего ему не хватает: эндпойнт Dynamic Client Registration (RFC 7591). Регистрация — это страница валидации; зависимость была постоянным риском.

Регистрация намеренно открыта — регистрироваться может кто угодно, потому что регистрация ничего не даёт. Она выдаёт client id и набор redirect URI; что соединению реально можно, решается после, человеком, на экране согласия. Чего регистрация допускать не должна — это redirect URI, способного унести код кому-то другому, поэтому принимаются только https, loopback и private-use схемы: никаких wildcard, никаких фрагментов, никакого простого http куда-либо, кроме localhost.

Две вещи, которые discovery-документ по умолчанию не объявляет, хотя коннектор на обе опирается: наш эндпойнт регистрации (он принадлежит нам, а не серверу, поэтому ничто его не анонсирует — а клиент, который не может его обнаружить, не может стать клиентом) и none как метод аутентификации на token-эндпойнте, единственный, которым пользуются наши клиенты. Это публичные приложения: секрет, поставляемый внутри приложения, — не секрет, а код с клиентом, который его запросил, связывает именно PKCE.

Ключи API остаются полноправным входом для скриптов и CI — и на /api/v1, и на /mcp.

Коннекторы обращаются к публичному HTTPS-эндпойнту. MCP нельзя проверить с телефона против localhost — это тестируется на развёрнутом окружении; локально возьмите Claude Code с ключом.

Вход и согласие​

Spring Authorization Server хочет сессию сервлета; остальной бэкенд — stateless JWT. Поэтому /oauth2/authorize перенаправляет неаутентифицированного вызывающего на нашу собственную страницу на React, которая входит обычным потоком — включая MBC ID — и затем поднимает сессию. Согласие — тоже наша страница (consentPage("/oauth/consent")).

От штатных страниц, отрисованных сервером, отказались по одной решающей причине: у пользователей, зарегистрировавшихся через MBC ID, нет пароля, и штатная форма попросту закрыла бы им доступ к коннекторам.

Согласие выбирает скоупы и пространства тем же селектором, что и выпуск ключа, поэтому OAuth-соединение и ручной ключ дают строку гранта одного вида, и ничему ниже по течению не нужно их различать.

Две вещи про редирект на вход, которые вылезли, только когда поток действительно запустили. Он строится из настроенного публичного адреса, а не из запроса: за любым прокси — Vite в разработке, Traefik в проде — getRequestURL() сообщает адрес, по которому дозвонился прокси, и отправка туда человека переносит его на другой origin посреди потока. И отправка согласия, пришедшая без аутентификации, не возобновляема: тело её формы уже потеряно, поэтому человека просят начать заново, а не возвращают на голый authorization URL, который читался бы как сломанный коннектор. Согласие показывается на каждое новое соединение, а активные соединения перечислены и отзываемы в настройках — иначе потерянный телефон нечем отрезать.

Поверхность инструментов​

95 инструментов — это десятки тысяч токенов контекста на запрос, измеримо худший выбор инструмента и превышение лимитов нескольких клиентов. Поэтому MCP двухъярусный:

  • Первоклассные инструменты — маленький отобранный набор, более крупный, чем REST: find_tasks, get_task, create_task, update_task, comment, list_structure, describe_space, create_space_object, whoami.
  • Мета-инструменты — list_operations, describe_operation, call_operation дотягиваются до всего остального в реестре.

Новая операция по умолчанию META. Она становится доступной агентам в тот же момент, когда появляется, автоматически, не удлиняя список инструментов. Повышение до первоклассного инструмента — осознанный поступок.

Первоклассные инструменты не обязаны повторять REST. update_task принимает необязательные статус, исполнителей, метки, срок, приоритет и свои поля одним вызовом, а внутри выполняет те же операции, что и клики человека, — поэтому оставляет тот же аудит и те же записи в ленте активности.

Каждый инструмент несёт readOnlyHint и destructiveHint, взятые из объявления, а не написанные руками.

План говорил, что список инструментов будет фильтроваться по скоупам гранта, чтобы соединение только на чтение вовсе не видело инструментов записи. Это не так и не может быть так без форка сервера: MCP SDK для Java держит один список инструментов на весь сервер (addTool / removeTool / listTools) без точки расширения на сессию или обмен. Вместо этого вызов вне скоупов соединения отклоняется по имени — «этому соединению не хватает скоупа tasks:write», — и это та фраза, которую читает человек в чате и по которой может действовать. Хуже, чем не предлагать, лучше, чем код.

Одна поправка к тому, что утверждают порождённые схемы. Spring AI помечает каждый компонент record обязательным, а это сообщило бы клиенту, что для смены названия задачи требуются также её приоритет, срок и тип, — и клиент тогда отклоняет вызов, не доводя его до нас, что ровно и случилось. Обязательно то, что называет путь: объект, над которым действуют. Всё остальное — изменение, которое вызывающий может вносить, а может и нет, а по-настоящему недостающее отклоняется доменом с фразой, которая об этом говорит.

Подсказки, ресурсы, elicitation​

Подсказки поставляются: «разбери входящие», «спланируй день», «что просрочено». На телефоне это разница между «набрать абзац» и «нажать один раз».

Ресурсы намеренно узкие: подборки (mbc://view/{id}) и задачи по ключу (mbc://task/PROD-12). Подборка — уже названный, устойчивый, осмысленный набор данных, а именно это протокол и понимает под ресурсом. Всё остальное — инструмент.

Elicitation охраняет разрушительные инструменты: сервер спрашивает в чате — «удалить 14 задач в PROD? перечислить их?» — и действует только по ответу. Подтверждение происходит там, где идёт разговор.

Не всякий клиент поддерживает elicitation. Когда клиент её не объявил, разрушительный инструмент отказывает и просит явный второй вызов с флагом подтверждения. Деградировать в сторону безопасности, никогда — в сторону удобства.

Тот же затвор стоит на call_operation — очевидная дыра: всё разрушительное находится в одной косвенности, а затвор, который косвенность обходит, не затвор.

7. Аудит​

Где он снимается​

На краю HTTP, не в реестре операций и не в сервисах — это единственное место, где построенная версия расходится с планом выше, и по причине, которую стоит записать.

Реестр отклонили первым: он видит только внешние вызовы, поэтому написанный там журнал содержал бы исключительно трафик API, тогда как требование состоит ровно в том, чтобы отличать собственное действие человека от действия агента.

Сервисы приложения были планом и проиграли двум фактам. HTTP — это место, где сходятся все поверхности, поэтому один фильтр покрывает веб-приложение, ключи и MCP разом. И запрос, отклонённый из-за нехватки скоупа или истёкшего токена, вообще не доходит до сервиса — его транзакция уже откачена к тому моменту, когда что-нибудь внутри могло бы написать строку. Отказы — ровно то, ради чего открывают журнал аудита, поэтому регистратор должен сидеть там, откуда их видно.

Что сервисы всё же за собой оставляют — это рабочее пространство: путь не говорит, какому из них принадлежит задача, поэтому SpaceService и WorkspaceService попутно его записывают, разрешая доступ. Вызов, отклонённый до того, как дошёл до любого из них, откатывается к пространству, названному в пути, — поэтому попытка в отношении пространства попадает в журнал этого пространства. Вызывающий, таким образом, может положить строку в чужой журнал, угадывая идентификаторы, — это зондирование, и видеть его как раз и требуется.

Контекст вызова (актор, источник, ключ/соединение, имя инструмента, идентификатор запроса) привязывается к потоку запроса на самом краю и дополняется тем фильтром, который докажет личность: JWT-фильтр ставит актора, проверка ключа поставит API, вызов инструмента MCP — MCP. Актор записывается там, где он доказан, а не считывается в конце: Spring Security очищает свой контекст на выходе из цепочки, и называть к тому моменту уже некого.

Два журнала, разные задачи​

Лента активности задачи — продуктовая возможность: человеческие формулировки, хранится вечно, показывается на карточке. Она получает источник и соединение, поэтому строка читается как «Иван сменил статус — через MCP».

Журнал аудита — новая техническая таблица уровня рабочего пространства, только на добавление: время, актор, источник, ключ или соединение, идентификатор операции, объект, имена изменённых полей, результат или код ошибки, IP, user-agent, идентификатор запроса. Он записывает и неудачные, и отклонённые попытки — вызов, отклонённый из-за нехватки скоупа, обязан оставить след, иначе журнал бесполезен ровно тогда, когда нужен.

С одним честным ограничением. Запись относится к рабочему пространству только тогда, когда вызов дошёл достаточно далеко, чтобы сказать, о каком именно речь, а вызов, отклонённый из-за нехватки скоупа, останавливается раньше. Такой отказ записывается полностью — с ключом, который его совершил, — но появляется в собственном следе актора (/audit/me), а не в журнале пространства. Отказы, называющие пространство в пути, — например, посторонний, пытающийся добавить себя в участники, — в журнал пространства всё же попадают, через откат, описанный в §7. Разрешать пространство всё равно, исключительно ради того чтобы подшить запись, означало бы, что слой аудита умеет искать задачи и проекты, — размен хуже, чем этот пробел.

Что записывается​

Изменения — со всех поверхностей. Чтения — только с API и MCP.

Человек, открывающий карточку, виден в продукте и в строке не нуждается; фронтенд выдаёт сотни GET за сессию и сделал бы таблицу аудита самой большой в базе и на 99% состоящей из шума. Агент же, прочитавший каждую задачу в проекте, — событие, о котором стоит знать; и это первый вопрос, который задают, когда ключ скомпрометирован.

Тела запросов не хранятся — только имена изменённых полей и значения из белого списка. Описания и комментарии содержат суть работы, и журнал аудита не должен становиться её второй, бессмертной копией под другими правилами доступа.

Механика​

Пишется вне действующей транзакции. Иначе неудачная запись аудита откатывала бы успешное действие, а отклонённые попытки нельзя было бы записать вовсе — их транзакция откатывается по определению.

Срок хранения настраивается: изменения 365 дней, чтения 90, чистятся по расписанию. Индекс по (workspace_id, created_at desc); секционирование ждёт первых миллионов строк.

Чтение: OWNER и ADMIN пространства видят всё, MEMBER — свои действия; по API требуется audit:read.

8. Чтение и адресация​

Идентификаторы​

Везде, где публичный API принимает идентификатор, годится любая форма: PROD-12 или UUID для задачи, PROD для проекта, имя для статуса или поля внутри его проекта, почта для пользователя.

Иначе «поставь PROD-12 в работу» стоит агенту трёх вызовов и шанса ошибиться. Продукт и так склоняется в эту сторону — URL строятся на ключах, а useResolvedTaskId разрешает обе формы.

Разрешение ограничено проектом объекта и не зависит от регистра. Неоднозначность — это ошибка со списком кандидатов, никогда не молчаливый выбор первого совпадения. Ответы несут обе формы, поэтому клиент, которому нужна устойчивость к переименованиям, может хранить UUID.

Каждый объект в ответе несёт также ссылку в веб-интерфейс, чтобы агент отвечал чем-то, на что можно нажать, а не a3f2….

Фильтры​

Публичный API использует тот же DSL фильтров, что и подборки, — он уже плоский и закрытый (12 полей × 10 операторов, соединяемых по И), и это чисто отображается в схему инструмента. Закрытость здесь достоинство: агент не может выразить условие, которого не может продукт, а любое выраженное условие можно сохранить как подборку и рассмотреть человеком.

В рамках этой работы закрываются два пробела:

  • UPDATED и CREATED. Колонка есть (changeset 0022), а поля фильтра нет. Без него нет инкрементального «что изменилось с прошлого запуска», есть только перечитывание всего. Подборкам оно тоже нужно («изменено на этой неделе»).
  • Свои поля, отвечаемые всякий раз, когда поиск сводится к одному проекту, и отклоняемые с объяснением иначе: условие над описанием поля, локальным для проекта, бессмысленно поперёк многих. Правило оказалось про фактическое множество проектов, а не про наличие условия по проекту: человек с доступом к одному проекту задаёт корректный вопрос, не говоря об этом. Поле называется идентификатором, ключом в snake_case или подписью, поэтому агент может написать «Клиент». Операторы — IN, NOT_IN, EMPTY, сопоставляемые через @> с GIN-индексом: предикат jsonb_extract_path_text(...) = '…' читается яснее, но индексом воспользоваться не может. Диапазоны по числовым и датным своим полям оставлены за бортом: половина сложности ради малой доли случаев, и добавить их потом легко — в отличие от того, чтобы убрать.

9. Политика контракта​

/api/v1 — только дополнение. Разрешено: новые эндпойнты, новые необязательные поля запроса, новые поля ответа. Запрещено: удалять и переименовывать поля, сужать типы, делать необязательное поле обязательным, менять смысл значения. Ломающее изменение — это /api/v2 рядом, со старой версией, живущей объявленный срок.

Две механические опоры, потому что политика без проверки — благое намерение:

  • Тест совместимости в сборке. Порождённый документ OpenAPI для /api/v1 лежит в репозитории как эталон; тест падает на любом несовместимом изменении. Обновление эталона — осознанный коммит, видимый на ревью.
  • Об отказе от поддержки объявляют заголовки Deprecated и Sunset и пометка в спецификации, а не письмо.

Ошибки​

RFC 7807 с машиночитаемым code, как уже реализовано, — переиспользуется без изменений.

Для агентов этого мало. Модель не сверяется с каталогом ошибок, она читает текст. Поэтому отказ инструмента говорит, что делать: не 422 validation.failed, а «статус „В работе“ не найден в PROD; доступны: Открыта, В работе, На проверке, Готово». Отказ по правам называет недостающий скоуп, чтобы человек в чате знал, что чинить. Без этого агент повторяет один и тот же неверный вызов по кругу.

10. Страховочные поручни​

deleteTask сейчас — жёсткое удаление, каскадом уходящее в подзадачи, без корзины и без отмены. Это терпимо, когда его делает человек с диалогом подтверждения. Вручённое агенту с tasks:delete и неверно прочитанным фильтром, это необратимая потеря данных.

  • Мягкое удаление задач и структурных объектов (deletedAt), физическая чистка через 30 дней, с экраном корзины. Приземляется до включения MCP. Оно того стоит и само по себе.
  • Идемпотентность. Агенты повторяют по таймауту, и без неё «создай задачу» превращается в три задачи. Изменения принимают Idempotency-Key; повтор в течение суток возвращает первый результат. Для MCP ключ выводится на сервере из идентификатора вызова инструмента.
  • Лимиты. Существующий RateLimiter (сейчас только на эндпойнтах аутентификации) расширяется на ключ: общий лимит, более строгий для изменений, строгий для удалений.
  • Ограничения объёма. Максимальный размер страницы и полное отсутствие массового удаления в API — удаление всегда по одному, поэтому «удали всё выполненное» стоит сотен вызовов и упирается в лимит задолго до конца списка.
  • Режим предложений на ключ («этот коннектор только предлагает») оставлен как будущая возможность, а не общее правило: агент, спрашивающий разрешения на каждое действие в чужом приложении, медленнее, чем сделать работу руками.

11. Видимость для администраторов пространства​

Ключи персональны, но действуют внутри общих пространств. OWNER/ADMIN рабочего пространства видит ключи и соединения, имеющие доступ к его пространству — владелец, имя, скоупы, последнее использование, — и может отозвать доступ к своему пространству, не трогая сам ключ, который продолжает работать в других местах.

Выпуск остаётся мгновенным и неблокируемым; предварительное согласование на этом размере — трение без выгоды, и оставлено как возможная настройка пространства на потом.

12. Исходящие события​

Вебхуки вне рамок этой работы, и намеренно: задача и так везёт обновление платформы, реестр, ключи, сервер авторизации с двумя собственными страницами, MCP-сервер с четырьмя видами примитивов, аудит, мягкое удаление и расширения фильтров. Добавить сверху надёжную доставку с повторами — это гарантированно сделать хуже и то, и другое.

Два решения всё же принимаются сейчас, потому что касаются уже существующего кода:

  • Outbox сейчас только пишется — его никто не читает, то есть это таблица, тихо накапливающая строки. Пока вебхуков нет, она получает чистку по расписанию для событий старше 30 дней и метрику размера.
  • События outbox получают тот же контекст вызова, что и аудит. Иначе будущий вебхук не сможет сказать подписчику, кто вызвал изменение, а добавлять это задним числом хуже, чем заложить сейчас.

13. Платформа​

Spring Boot 4.x со Spring AI 2.0.x (который требует Boot 4.0/4.1 и MCP Java SDK 1.0). Это настоящий прыжок с 3.4.5 — Spring Framework 7 и Spring Security 7, поверх нетривиальной конфигурации безопасности, — и поэтому он едет первым, отдельно, без новых возможностей, чтобы поломки от обновления и поломки от разработки никогда не разбирались одновременно.


Порядок поставки​

Каждый шаг приземляется в master рабочим и двигает версию по правилам самого проекта.

#ШагВерсия
1Платформа: Boot 4 + Spring AI 2, без новых возможностейPATCH
2Мягкое удаление, корзина, чистка outboxMINOR
3Контекст вызова, журнал аудита, источник в ленте активностиMINOR
↳ экран аудита пространства ждёт шага 6: пока нет ключей, каждая строка говорит WEB
4DSL фильтров: UPDATED, CREATED, свои поля + GIN-индексMINOR
5Реестр операций, /api/v1, эталонный тест OpenAPI (пока на JWT)MINOR
↳ docs/API.md ждёт шага 6: руководство, которое не может показать аутентификацию, — половина руководства
6Ключи и скоупы; интерфейс ключей и доступа к пространствуMINOR
7Сервер авторизации OAuth, страницы входа и согласия, интерфейс соединенийMINOR
8MCP: инструменты, мета-инструменты, подсказки, ресурсы, elicitationMINOR

Аудит идёт до внешних поверхностей, а не после, — иначе он рождается с неучтённым вебом, и обнаруживается это в момент, когда он впервые понадобился. Мягкое удаление идёт до ключей, а не вместе с ними.

mcp.enabled по умолчанию выключен, поэтому эндпойнт протокола никогда не торчит из промежуточного деплоя. Публичный API в итоге остался без флага: он едет целиком и за той же аутентификацией, что и веб-приложение, а переключатель, включённый во всех окружениях, — не предохранитель, а вещь, которую забывают.

Поверхности фронтенда​

  • Личные настройки — ключи: список, выпуск (скоупы, проекты, срок), одноразовый показ секрета, отзыв и лента последних вызовов по каждому ключу (первый вопрос про любую интеграцию — «она вообще работает?»).
  • Личные настройки — соединения: что подключено, с какого времени, с какими правами, отзыв.
  • Страницы OAuth: вход и согласие с выбором проектов.
  • Настройки пространства — аудит: фильтры по актору, источнику, ключу, периоду.
  • Настройки пространства — доступ: ключи и соединения, дотягивающиеся до этого пространства (§11).
  • Корзина: восстановление удалённых объектов.
  • Лента активности задачи: значок источника.
  • Инструкции по подключению: копируемый эндпойнт MCP, короткое руководство по API.

На обоих языках, как и остальной интерфейс. По объёму это сопоставимо с работой на бэкенде.