Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions docs/adr/0015-google-calendar-connection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# ADR-0015: Google Calendar подключается через отдельный Connection Service

- Status: accepted
- Date: 2026-09-26

## Контекст

`calendar-mcp` уже выполняет подтверждённую команду, но сейчас пишет событие только в память.
Настоящий Google Calendar требует OAuth-доступ конкретного пользователя. Refresh token нельзя хранить
в Telegram Adapter, Action Service или MCP-коннекторе: иначе смена канала либо провайдера переносит
секреты в бизнес-сервисы.

Action Service уже сохраняет проверенные `tenantId` и `actorId` из JWT. При выполнении Temporal
использует service account, поэтому обычный `sub` этого токена обозначает сервис, а не владельца
календаря. Нельзя молча подменять его пользовательским идентификатором.

## Решение

### Connection Service

Создаём отдельный `connection-service`. Он отвечает только за подключения внешних аккаунтов:

- запускает Google OAuth 2.0 Authorization Code flow;
- проверяет одноразовый `state` и использует PKCE;
- запрашивает `access_type=offline` и минимальный scope `calendar.events`;
- связывает подключение с `tenantId` и `actorId` из проверенного Portable Agent JWT;
- хранит refresh token в PostgreSQL только в зашифрованном виде;
- получает новый короткоживущий access token и отдаёт его только разрешённому сервису;
- отключает подключение и обрабатывает истёкший либо отозванный token.

Ключ шифрования приходит из secret manager. В Git, логах, событиях и Temporal history нет Google
client secret, authorization code, access token или refresh token.

### Доверенный контекст выполнения

Модель предлагает только данные встречи и не выбирает внешний аккаунт. Action Service загружает
сохранённое действие и передаёт `tenantId`, `actorId`, `actionId` и `requestKey` как отдельный
доверенный execution context. MCP Gateway принимает этот контекст только от service account Action
Service. Пользовательские JSON-поля не могут переопределить его.

Calendar MCP передаёт `tenantId` и `actorId` в Connection Service. Тот находит активное подключение
Google Calendar этого пользователя и возвращает короткоживущий access token. Refresh token никогда
не покидает Connection Service.

Если подключений нет или их несколько без выбранного default, действие не выполняется скрытно.
Пользователь получает понятный результат `connection_required` или выбирает календарь до нового
подтверждения.

### Провайдер календаря

В `calendar-mcp` остаётся один use case `create_event` и две стратегии:

- `fake-calendar` — детерминированный CI и локальная разработка без внешнего аккаунта;
- `google-calendar` — ручной sandbox N2N и дальнейшие окружения.

Google-адаптер использует Calendar API `events.insert` и scope
`https://www.googleapis.com/auth/calendar.events`. Идентификатор события вычисляется из стабильного
`tenantId + actorId + requestKey`, кодируется в допустимый Google формат base32hex и передаётся как
`event.id`. Повтор после сетевой ошибки делает `events.get`: он возвращает прежнее событие вместо
создания дубля. В `extendedProperties.private` сохраняется обезличенный hash request key для
диагностики, но не исходная пользовательская фраза.

## Границы API

- Публичные OAuth start/callback/disconnect принадлежат Connection Service.
- Внутренний token endpoint требует service JWT с audience `connection-service` и scope
`connection:token`.
- Calendar MCP не принимает refresh token через MCP tool.
- MCP Gateway не хранит токены и не выбирает календарь.
- Action Service не шифрует provider credentials и не вызывает Google API.
- Telegram показывает ссылку подключения, но не становится владельцем Google OAuth-сессии.

## Порядок реализации

1. Добавить контракт доверенного execution context и тест, что его нельзя подменить payload-ом.
2. Создать `connection-service` с PostgreSQL, jOOQ, шифрованием и Google OAuth stub.
3. Добавить contract-тесты start/callback/refresh/revoke без настоящего Google аккаунта.
4. Разделить `CalendarRepository` на хранилище fake-событий и стратегию внешнего провайдера.
5. Добавить Google strategy и WireMock-совместимый stub Calendar API.
6. Включить ручной sandbox N2N только через локальные secrets.
7. После подтверждённого N2N добавить trace и безопасные логи по всему пути.

## Последствия

- появляется отдельный stateful security-сервис и ещё одна БД;
- настоящий календарь нельзя завершить одним изменением только в `calendar-mcp`;
- fake-сценарий остаётся быстрым и доступным каждому contributor;
- новые Jira, Outlook и другие OAuth-подключения используют тот же Connection Service, но отдельные
provider adapters;
- первый ручной запуск потребует создать Google OAuth client и разрешённый redirect URI вне Git.

## Источники

- [Google OAuth для server-side приложений](https://developers.google.com/identity/protocols/oauth2/web-server)
- [Google OAuth best practices](https://developers.google.com/identity/protocols/oauth2/resources/best-practices)
- [Google Calendar: создание события](https://developers.google.com/workspace/calendar/api/v3/reference/events/insert)
- [Google Calendar: свои event ID](https://developers.google.com/workspace/calendar/api/guides/create-events)
16 changes: 9 additions & 7 deletions docs/delivery/CURRENT_STATUS.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
# Текущее состояние по репозиториям

Дата среза: 25 сентября 2026 года. Этот файл обновляется после проверенного локального этапа или мержа,
Дата среза: 26 сентября 2026 года. Этот файл обновляется после проверенного локального этапа или мержа,
а не после незавершённого эксперимента.

| Репа | Что меняем или добавляем | Ожидаемый результат | Фактический результат | Следующий шаг |
|---|---|---|---|---|
| `.github` | Общие workflows и правила | Одинаковый CI/CD для всех сервисов | Java, Python, Node, docs, security и container workflows работают; чистый Trivy runner исправлен | Подключать правила к каждой новой репе |
| `contracts` | Версионируемые внешние и внутренние API | Один проверяемый источник сетевых DTO | Release `2.5.0` опубликован с checksum и provenance; общий MessageContext устраняет циклические OpenAPI-ссылки | Использовать release во всех новых адаптерах |
| `action-service` | Подтверждение и надёжное выполнение | `AWAITING_APPROVAL → APPROVED → EXECUTING → SUCCEEDED/FAILED` | jOOQ, outbox, Temporal worker и вызов MCP Gateway работают; решение приходит через Channel Gateway | Оставить источником истины для решения и статуса |
| `calendar-mcp` | `create_event` | Идемпотентное создание fake-события | Fake Calendar, OIDC и защита от дублей работают в общем сценарии | Добавить Google provider за тем же контрактом |
| `calendar-mcp` | `create_event` | Идемпотентное создание fake-события | Fake Calendar, OIDC и защита от дублей работают в общем сценарии | Добавить Google provider после trusted execution context и Connection Service |
| `mcp-gateway` | OIDC, allowlist и MCP client | Безопасный stateless-маршрутизатор | Foundation смержен и проверен вызовом Calendar MCP | Добавлять коннекторы только по контракту |
| `deploy` | Приложения в локальном Compose | Одна команда поднимает вертикальный backend-срез | Реальный Telegram webhook, Keycloak Device Flow и полный backend работают локально | Сохранить real Telegram N2N как ручной smoke |
| `test-lab` | Календарный acceptance-тест | Один JWT и один контракт на всём пути | Карточка, решение через Gateway, Temporal, offset и отсутствие дубля проверены | Добавить black-box сценарий Telegram и Google stub |
| `agent-runtime` | Текст в предложение действия | Типизированное предложение встречи без скрытого выполнения | Технический ISO-формат работает, но не является пользовательским интерфейсом | Добавить `IntentModel`, естественный язык и structured output |
| `deploy` | Приложения в локальном Compose | Одна команда поднимает вертикальный backend-срез | Реальный Telegram и opt-in Ollama/Qwen профиль работают отдельными командами | Подключить Connection Service и Google stub |
| `test-lab` | Календарный acceptance-тест | Один JWT и один контракт на всём пути | Fake E2E проверяет 19 условий; opt-in AI E2E понимает обычную русскую фразу | Добавить Google OAuth и Calendar API stub |
| `agent-runtime` | Текст в предложение действия | Типизированное предложение встречи без скрытого выполнения | `IntentModel`, demo и OpenAI-compatible provider работают; Qwen 7B проверена локально | Добавить уточнение при неполных данных |
| `channel-gateway` | Общий вход каналов | Web и Telegram используют один контракт | Сообщение идёт через Conversation; решение канала — через Gateway в Action | Проверить Telegram в общем Compose-сценарии |
| `conversation-service` | Состояние диалога и прикладная оркестрация | Повтор сообщения не создаёт второе действие | PostgreSQL, lease, Agent → Action, карточка и сохранение offset проверены общим E2E | Оставить владельцем диалога при подключении адаптера |
| `widget-sdk` | Карточка подтверждения | Один UI-контракт для разных каналов | Renderer-neutral core создаёт проверенную decision command; Telegram использует тот же сетевой контракт | Добавить готовые helpers для следующих UI-каналов |
Expand Down Expand Up @@ -60,8 +60,10 @@ Telegram Adapter отдельно проверен unit-, contract- и PostgreSQ
-> [готово] публичная репа, CI и container image
-> [готово] Keycloak client, fake Telegram API и Compose E2E
-> [готово] настоящий Telegram bot, webhook и Device Flow
-> [следом] естественный язык и уточнения в Agent Runtime
-> [затем] Google Calendar provider и OAuth
-> [готово] естественный язык через OpenAI-compatible provider и локальную Qwen
-> [решено] Google OAuth принадлежит Connection Service, а не каналу или MCP
-> [следом] trusted execution context и каркас Connection Service
-> [затем] Google Calendar provider и OAuth stub
-> [после real N2N] trace в Tempo и связанные логи в Loki/Grafana
```

Expand Down
10 changes: 7 additions & 3 deletions docs/delivery/MVP_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@

Правила первого сценария подтверждены. Начинаем с `fake-calendar`; обязательны название, начало, конец
и часовой пояс; создание всегда требует явного подтверждения. Описание и участники необязательны.
Fake-сценарий и настоящий Telegram уже доказали транспортный путь. Следующие продуктовые решения —
разбор естественного языка и подключение настоящего календаря. Speech-to-text остаётся следующим
адаптером того же текстового сценария.
Fake-сценарий и настоящий Telegram уже доказали транспортный путь. Естественная русская фраза проверена
через локальную Qwen с typed structured output. Следующий продуктовый этап — Connection Service и
настоящий Google Calendar. Speech-to-text остаётся следующим адаптером того же текстового сценария.

Переводы денег не входят в MVP. До отдельного threat model они доступны только как будущие
`read-only` или `simulation` сценарии.
Expand Down Expand Up @@ -86,6 +86,10 @@ Fake-сценарий и настоящий Telegram уже доказали т
- покрыть Google API contract-тестами на stub и отдельным ручным sandbox N2N;
- оставить fake-провайдер обязательным для автономного CI и внешних contributors.

Граница OAuth и передача пользователя зафиксированы в ADR-0015: refresh token принадлежит отдельному
Connection Service, а Calendar MCP получает только короткоживущий access token для доверенного
`tenantId + actorId`.

Результат: подтверждённая команда из Telegram создаёт реальное событие Google Calendar, а тесты не требуют
внешнего аккаунта.

Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ nav:
- Contract-first: adr/0012-contract-first.md
- Conversation Service: adr/0013-conversation-orchestration.md
- Привязка Telegram: adr/0014-telegram-account-link.md
- Подключение Google Calendar: adr/0015-google-calendar-connection.md
18 changes: 17 additions & 1 deletion scripts/check-docs.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ $requiredFiles = @(
"docs/index.md",
"docs/development.md",
"docs/runbook.md",
"docs/architecture/PLATFORM.md"
"docs/architecture/PLATFORM.md",
"docs/adr/0015-google-calendar-connection.md"
)

$missingFiles = $requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) }
Expand Down Expand Up @@ -40,4 +41,19 @@ if ($mkdocsText -notmatch "(?m)^docs_dir:\s*docs\s*$") {
throw "mkdocs.yml must contain docs_dir: docs."
}

$googleAdr = Get-Content -LiteralPath "docs/adr/0015-google-calendar-connection.md" -Raw
foreach ($required in @(
"connection-service",
"Authorization Code",
"refresh token",
"actorId",
"calendar.events",
"base32hex",
"fake-calendar"
)) {
if ($googleAdr -notmatch [regex]::Escape($required)) {
throw "Google Calendar ADR does not contain $required."
}
}

Write-Host "Documentation follows the project standard."
Loading