Публичный 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 | проза, которую читают люди в документации и модели в схемах инструментов |
mcp | TOOL (собственный инструмент 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. Колонка есть (changeset0022), а поля фильтра нет. Без него нет инкрементального «что изменилось с прошлого запуска», есть только перечитывание всего. Подборкам оно тоже нужно («изменено на этой неделе»).- Свои поля, отвечаемые всякий раз, когда поиск сводится к одному проекту, и отклоняемые с
объяснением иначе: условие над описанием поля, локальным для проекта, бессмысленно поперёк
многих. Правило оказалось про фактическое множество проектов, а не про наличие условия по
проекту: человек с доступом к одному проекту задаёт корректный вопрос, не говоря об этом. Поле
называется идентификатором, ключом в 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 | Мягкое удаление, корзина, чистка outbox | MINOR |
| 3 | Контекст вызова, журнал аудита, источник в ленте активности | MINOR |
| ↳ экран аудита пространства ждёт шага 6: пока нет ключей, каждая строка говорит WEB | ||
| 4 | DSL фильтров: UPDATED, CREATED, свои поля + GIN-индекс | MINOR |
| 5 | Реестр операций, /api/v1, эталонный тест OpenAPI (пока на JWT) | MINOR |
↳ docs/API.md ждёт шага 6: руководство, которое не может показать аутентификацию, — половина руководства | ||
| 6 | Ключи и скоупы; интерфейс ключей и доступа к пространству | MINOR |
| 7 | Сервер авторизации OAuth, страницы входа и согласия, интерфейс соединений | MINOR |
| 8 | MCP: инструменты, мета-инструменты, подсказки, ресурсы, elicitation | MINOR |
Аудит идёт до внешних поверхностей, а не после, — иначе он рождается с неучтённым вебом, и обнаруживается это в момент, когда он впервые понадобился. Мягкое удаление идёт до ключей, а не вместе с ними.
mcp.enabled по умолчанию выключен, поэтому эндпойнт протокола никогда не торчит из промежуточного
деплоя. Публичный API в итоге остался без флага: он едет целиком и за той же аутентификацией,
что и веб-приложение, а переключатель, включённый во всех окружениях, — не предохранитель, а вещь,
которую забывают.
Поверхности фронтенда
- Личные настройки — ключи: список, выпуск (скоупы, проекты, срок), одноразовый показ секрета, отзыв и лента последних вызовов по каждому ключу (первый вопрос про любую интеграцию — «она вообще работает?»).
- Личные настройки — соединения: что подключено, с какого времени, с какими правами, отзыв.
- Страницы OAuth: вход и согласие с выбором проектов.
- Настройки пространства — аудит: фильтры по актору, источнику, ключу, периоду.
- Настройки пространства — доступ: ключи и соединения, дотягивающиеся до этого пространства (§11).
- Корзина: восстановление удалённых объектов.
- Лента активности задачи: значок источника.
- Инструкции по подключению: копируемый эндпойнт MCP, короткое руководство по API.
На обоих языках, как и остальной интерфейс. По объёму это сопоставимо с работой на бэкенде.