From 4e8b8eafa05d61456e92a79af72bdd4f860767e7 Mon Sep 17 00:00:00 2001 From: DanliaQwerty20 Date: Sun, 27 Sep 2026 17:14:21 +0300 Subject: [PATCH] feat: define external connection API --- .github/workflows/ci.yml | 1 + README.md | 3 +- asyncapi/action-events.yaml | 2 +- docs/connection-service.md | 38 +++++ examples/connection.valid.json | 6 + mkdocs.yml | 1 + openapi/action-api.yaml | 2 +- openapi/agent-runtime-api.yaml | 2 +- openapi/channel-gateway-api.yaml | 2 +- openapi/connection-api.yaml | 267 +++++++++++++++++++++++++++++++ openapi/conversation-api.yaml | 2 +- openapi/mcp-gateway-api.yaml | 2 +- package.json | 4 +- test/connection-service.test.mjs | 59 +++++++ test/schemas.test.mjs | 6 +- 15 files changed, 387 insertions(+), 10 deletions(-) create mode 100644 docs/connection-service.md create mode 100644 examples/connection.valid.json create mode 100644 openapi/connection-api.yaml create mode 100644 test/connection-service.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6d5402..01f209c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: agent-runtime-api, channel-gateway-api, conversation-api, + connection-api, ] steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 diff --git a/README.md b/README.md index eb8339d..c527a72 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ OpenAPI в pull request с `main` и блокирует нарушение эт ## Версии и release -Текущая версия — `2.6.0`. Переход с `1.x` описан в +Текущая версия — `2.7.0`. Переход с `1.x` описан в [migration guide](docs/migrations/2.0.0.md). Версия в `package.json`, OpenAPI и AsyncAPI должна совпадать. Тег `vX.Y.Z` запускает release workflow. @@ -45,6 +45,7 @@ Bundle получает SHA-256 и GitHub artifact attestation. Сервисы - [Контракт Agent Runtime](docs/agent-runtime.md) - [Контракт Channel Gateway](docs/channel-gateway.md) - [Контракт Conversation Service](docs/conversation-service.md) +- [Контракт внешних подключений](docs/connection-service.md) - [Разработка](docs/development.md) - [Диагностика](docs/runbook.md) - [Правила для AI-агентов](AGENTS.md) diff --git a/asyncapi/action-events.yaml b/asyncapi/action-events.yaml index b18672e..6f7da8f 100644 --- a/asyncapi/action-events.yaml +++ b/asyncapi/action-events.yaml @@ -1,7 +1,7 @@ asyncapi: 3.0.0 info: title: Portable Agent Action Events - version: 2.6.0 + version: 2.7.0 description: События жизненного цикла безопасных действий. defaultContentType: application/json servers: diff --git a/docs/connection-service.md b/docs/connection-service.md new file mode 100644 index 0000000..f75b562 --- /dev/null +++ b/docs/connection-service.md @@ -0,0 +1,38 @@ +# Внешние подключения + +Контракт `openapi/connection-api.yaml` добавлен в bundle 2.7.0 без изменения существующих запросов. +Это описание будущего API: текущий каркас Connection Service ещё не реализует эти endpoints. + +| Метод и путь | Вход | Результат | +| ----------------------------------------- | -------------------------------- | ------------------------------- | +| POST /api/v1/connections/start | provider и пользовательский JWT | URL авторизации и срок ссылки | +| GET /api/v1/connections/callback | state и ровно один из code/error | Статическая страница результата | +| GET /api/v1/connections | Пользовательский JWT | Метаданные своих подключений | +| DELETE /api/v1/connections/{connectionId} | ID и пользовательский JWT | Отключение своего аккаунта | +| POST /internal/v1/tokens | actorId, provider и service JWT | Временный access token | + +## Владение и секреты + +Публичный API берёт tenant и actor из проверенного JWT. Клиент не задаёт владельца, scope или +redirect URI. Callback не требует пользовательского JWT: владельца определяет сохранённый +одноразовый state, связанный с PKCE. Проверка code XOR error и атомарность state — требования +реализации, которые нельзя выразить только схемой query parameters OpenAPI. + +Внутренний API требует audience `connection-service`, scope `connection:token` и разрешённую +сервисную identity. Tenant берётся из JWT; actor передаётся из доверенного контекста действия. +Refresh token не возвращается ни одним endpoint. Ответ с access token и ссылка авторизации имеют +`Cache-Control: no-store`. Query callback и тела ответов с секретами не логируются. + +Если подключения нет или нужна повторная авторизация, token endpoint возвращает 409 с problem type +`https://portable-agent.dev/problems/connection_required`. Если подходят несколько аккаунтов — +`https://portable-agent.dev/problems/connection_ambiguous`. Сервис не выбирает аккаунт скрытно. + +Первый provider — `google-calendar`. Новые провайдеры добавляются отдельными контрактными изменениями +и адаптерами; произвольные URL и scopes в запросах не поддерживаются. + +## Следующие шаги + +1. Сгенерировать Java DTO и интерфейсы из опубликованного bundle. +2. Реализовать state/PKCE и зашифрованное хранилище с PostgreSQL-тестами. +3. Проверить callback, refresh и отключение на OAuth stub. +4. Подключить Calendar MCP и сквозной тест, затем реальный Google sandbox. diff --git a/examples/connection.valid.json b/examples/connection.valid.json new file mode 100644 index 0000000..3bcec74 --- /dev/null +++ b/examples/connection.valid.json @@ -0,0 +1,6 @@ +{ + "id": "28efc74e-e82b-4ea2-9143-4dc24c13fe0d", + "provider": "google-calendar", + "status": "active", + "createdAt": "2026-09-27T12:00:00Z" +} diff --git a/mkdocs.yml b/mkdocs.yml index 2cb8737..b6dd6da 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,6 +9,7 @@ nav: - MCP Gateway API: mcp-gateway.md - Agent Runtime API: agent-runtime.md - Channel Gateway API: channel-gateway.md + - Connection Service API: connection-service.md - Разработка: development.md - Эксплуатация: runbook.md - Миграции: diff --git a/openapi/action-api.yaml b/openapi/action-api.yaml index a19bdab..114c248 100644 --- a/openapi/action-api.yaml +++ b/openapi/action-api.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Portable Agent Action API - version: 2.6.0 + version: 2.7.0 description: Создание, просмотр и подтверждение безопасных действий агента. license: name: Apache-2.0 diff --git a/openapi/agent-runtime-api.yaml b/openapi/agent-runtime-api.yaml index 0313bef..d4c5a4f 100644 --- a/openapi/agent-runtime-api.yaml +++ b/openapi/agent-runtime-api.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Portable Agent Runtime API - version: 2.6.0 + version: 2.7.0 description: Подготовка предложения действия из текста пользователя. license: name: Apache-2.0 diff --git a/openapi/channel-gateway-api.yaml b/openapi/channel-gateway-api.yaml index 05ba1d2..c213950 100644 --- a/openapi/channel-gateway-api.yaml +++ b/openapi/channel-gateway-api.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Portable Agent Channel Gateway API - version: 2.6.0 + version: 2.7.0 description: Единый текстовый вход для независимых каналов пользователя. license: name: Apache-2.0 diff --git a/openapi/connection-api.yaml b/openapi/connection-api.yaml new file mode 100644 index 0000000..7100469 --- /dev/null +++ b/openapi/connection-api.yaml @@ -0,0 +1,267 @@ +openapi: 3.1.0 +info: + title: Portable Agent Connection API + version: 2.7.0 + description: Подключения внешних аккаунтов. Публичные операции используют владельца из JWT. + license: + name: Apache-2.0 + identifier: Apache-2.0 +servers: + - url: https://api.portable-agent.dev/connections +security: + - bearerAuth: [] +paths: + /api/v1/connections: + get: + tags: [Connections] + operationId: listConnections + summary: Получить подключения текущего пользователя + responses: + "200": + description: Подключения только для tenant и sub проверенного JWT + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Connection" + "401": + $ref: "#/components/responses/Problem" + /api/v1/connections/start: + post: + tags: [Connections] + operationId: startConnection + summary: Начать подключение внешнего аккаунта + description: Создаёт одноразовый state и PKCE. Redirect URI и scopes берутся из конфигурации сервера. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StartRequest" + responses: + "200": + description: Ссылка для открытия в браузере; ответ нельзя кешировать или логировать + headers: + Cache-Control: + $ref: "#/components/headers/NoStore" + content: + application/json: + schema: + $ref: "#/components/schemas/StartResponse" + "400": + $ref: "#/components/responses/Problem" + "401": + $ref: "#/components/responses/Problem" + /api/v1/connections/callback: + get: + tags: [Connections] + operationId: completeConnection + summary: Обработать возврат OAuth-провайдера + description: | + Владельца определяет одноразовый state из серверного хранилища. + Требуется ровно один из code или error. State проверяется и атомарно погашается, + включая отказ пользователя. Code обменивается с сохранённым PKCE verifier. + Ответ не содержит токенов; code и state запрещено писать в access logs. + security: [] + parameters: + - name: state + in: query + required: true + schema: + type: string + minLength: 32 + maxLength: 512 + - name: code + in: query + schema: + type: string + minLength: 1 + maxLength: 4096 + - name: error + in: query + schema: + type: string + minLength: 1 + maxLength: 256 + responses: + "200": + description: Статическая страница результата без внешнего redirect и секретов + headers: + Cache-Control: + $ref: "#/components/headers/NoStore" + content: + text/html: + schema: + type: string + "400": + $ref: "#/components/responses/Problem" + "502": + $ref: "#/components/responses/Problem" + /api/v1/connections/{connectionId}: + delete: + tags: [Connections] + operationId: disconnectConnection + summary: Отключить своё подключение + description: | + Немедленно запрещает локальную выдачу токенов. + Credentials удаляются после provider revoke. При временной ошибке возвращается 502, + подключение остаётся отключённым; повтор DELETE повторяет revoke. + Для чужого или отсутствующего подключения возвращается 404. + parameters: + - name: connectionId + in: path + required: true + schema: + type: string + format: uuid + responses: + "204": + description: Подключение отключено + "401": + $ref: "#/components/responses/Problem" + "404": + $ref: "#/components/responses/Problem" + "502": + $ref: "#/components/responses/Problem" + /internal/v1/tokens: + post: + tags: [Tokens] + operationId: getConnectionToken + summary: Получить короткоживущий токен для выполнения действия + description: | + Требует service JWT с audience connection-service, scope connection:token + и разрешённым client identity. Tenant берётся из проверенного JWT. + Actor приходит только из доверенного контекста Action Service. + Нет активного подключения — 409 connection_required. + Несколько подходящих подключений — 409 connection_ambiguous; скрытый выбор запрещён. + security: + - serviceAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TokenRequest" + responses: + "200": + description: Только access token; тело запрещено логировать + headers: + Cache-Control: + $ref: "#/components/headers/NoStore" + content: + application/json: + schema: + $ref: "#/components/schemas/TokenResponse" + "400": + $ref: "#/components/responses/Problem" + "401": + $ref: "#/components/responses/Problem" + "403": + $ref: "#/components/responses/Problem" + "409": + $ref: "#/components/responses/Problem" + "502": + $ref: "#/components/responses/Problem" +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Пользовательский JWT; tenant_id и sub определяют владельца. + serviceAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Service JWT с audience connection-service и scope connection:token. + headers: + NoStore: + required: true + schema: + type: string + const: no-store + responses: + Problem: + description: Ошибка без секретов и внутренних данных + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + schemas: + StartRequest: + type: object + additionalProperties: false + required: [provider] + properties: + provider: + type: string + enum: [google-calendar] + StartResponse: + type: object + additionalProperties: false + required: [url, expiresAt] + properties: + url: + type: string + format: uri + description: Ссылка авторизации настроенного провайдера. + expiresAt: + type: string + format: date-time + Connection: + type: object + additionalProperties: false + required: [id, provider, status, createdAt] + properties: + id: + type: string + format: uuid + provider: + type: string + enum: [google-calendar] + status: + type: string + enum: [active, reconnect_required, disconnected] + createdAt: + type: string + format: date-time + TokenRequest: + type: object + additionalProperties: false + required: [actorId, provider] + properties: + actorId: + type: string + format: uuid + provider: + type: string + enum: [google-calendar] + TokenResponse: + type: object + additionalProperties: false + required: [accessToken, tokenType, expiresAt] + properties: + accessToken: + type: string + minLength: 1 + tokenType: + type: string + enum: [Bearer] + expiresAt: + type: string + format: date-time + Problem: + type: object + additionalProperties: false + required: [type, title, status] + properties: + type: + type: string + format: uri-reference + title: + type: string + status: + type: integer + minimum: 400 + maximum: 599 diff --git a/openapi/conversation-api.yaml b/openapi/conversation-api.yaml index f577e03..8abf043 100644 --- a/openapi/conversation-api.yaml +++ b/openapi/conversation-api.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Portable Agent Conversation API - version: 2.6.0 + version: 2.7.0 description: Внутренний API состояния диалога и оркестрации предложения действия. license: name: Apache-2.0 diff --git a/openapi/mcp-gateway-api.yaml b/openapi/mcp-gateway-api.yaml index e87cc12..527d437 100644 --- a/openapi/mcp-gateway-api.yaml +++ b/openapi/mcp-gateway-api.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Portable Agent MCP Gateway API - version: 2.6.0 + version: 2.7.0 description: Безопасный внутренний вызов разрешённых tools в настроенных MCP-сервисах. license: name: Apache-2.0 diff --git a/package.json b/package.json index 928a5d7..ac7688c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@portable-agent/contracts", - "version": "2.6.0", + "version": "2.7.0", "private": true, "files": [ "openapi", @@ -17,7 +17,7 @@ "scripts": { "lint": "redocly lint openapi/*.yaml && prettier --check .", "test": "node --test test/*.test.mjs", - "build": "redocly build-docs openapi/action-api.yaml --output dist/action-api.html && redocly build-docs openapi/mcp-gateway-api.yaml --output dist/mcp-gateway-api.html && redocly build-docs openapi/agent-runtime-api.yaml --output dist/agent-runtime-api.html && redocly build-docs openapi/channel-gateway-api.yaml --output dist/channel-gateway-api.html && redocly build-docs openapi/conversation-api.yaml --output dist/conversation-api.html", + "build": "redocly build-docs openapi/action-api.yaml --output dist/action-api.html && redocly build-docs openapi/mcp-gateway-api.yaml --output dist/mcp-gateway-api.html && redocly build-docs openapi/agent-runtime-api.yaml --output dist/agent-runtime-api.html && redocly build-docs openapi/channel-gateway-api.yaml --output dist/channel-gateway-api.html && redocly build-docs openapi/conversation-api.yaml --output dist/conversation-api.html && redocly build-docs openapi/connection-api.yaml --output dist/connection-api.html", "pack:contracts": "pnpm pack --pack-destination dist", "format:check": "prettier --check ." }, diff --git a/test/connection-service.test.mjs b/test/connection-service.test.mjs new file mode 100644 index 0000000..34a4574 --- /dev/null +++ b/test/connection-service.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import YAML from "yaml"; + +const readApi = async () => + YAML.parse(await readFile("openapi/connection-api.yaml", "utf8")); + +test("public connection requests cannot choose an owner or redirect URL", async () => { + const api = await readApi(); + const ajv = new Ajv2020(); + addFormats(ajv); + const validate = ajv.compile(api.components.schemas.StartRequest); + assert.equal(validate({ provider: "google-calendar" }), true); + for (const field of [ + "actorId", + "tenantId", + "redirectUri", + "scope", + "clientSecret", + ]) { + assert.equal( + validate({ provider: "google-calendar", [field]: "untrusted" }), + false, + ); + } +}); + +test("public connection response contains metadata only", async () => { + const api = await readApi(); + const ajv = new Ajv2020(); + addFormats(ajv); + const validate = ajv.compile(api.components.schemas.Connection); + const example = JSON.parse( + await readFile("examples/connection.valid.json", "utf8"), + ); + assert.equal(validate(example), true, JSON.stringify(validate.errors)); + for (const field of ["accessToken", "refreshToken", "clientSecret"]) { + assert.equal(validate({ ...example, [field]: "secret" }), false); + } +}); + +test("callback is state protected and token exchange requires service auth", async () => { + const api = await readApi(); + const callback = api.paths["/api/v1/connections/callback"].get; + assert.deepEqual(callback.security, []); + assert.ok(callback.parameters.some((p) => p.name === "state" && p.required)); + const token = api.paths["/internal/v1/tokens"].post; + assert.deepEqual(token.security, [{ serviceAuth: [] }]); + assert.ok(token.responses["403"]); + assert.ok(token.responses["409"]); + assert.ok(token.responses["200"].headers["Cache-Control"]); + assert.equal( + api.components.schemas.TokenResponse.properties.refreshToken, + undefined, + ); +}); diff --git a/test/schemas.test.mjs b/test/schemas.test.mjs index 3aed460..445ee7c 100644 --- a/test/schemas.test.mjs +++ b/test/schemas.test.mjs @@ -13,7 +13,7 @@ const readJson = async (path) => JSON.parse(await readFile(path, "utf8")); test("breaking policy reads the real OpenAPI version", async () => { const source = await readFile("openapi/action-api.yaml", "utf8"); - assert.equal(readVersion(source), "2.6.0"); + assert.equal(readVersion(source), "2.7.0"); }); test("compatibility workflow skips policy when oasdiff finds no breaking changes", async () => { @@ -80,6 +80,10 @@ test("all contract files use the package version", async () => { assert.equal(agentApi.info.version, packageData.version); assert.equal(channelApi.info.version, packageData.version); assert.equal(conversationApi.info.version, packageData.version); + const connectionApi = YAML.parse( + await readFile("openapi/connection-api.yaml", "utf8"), + ); + assert.equal(connectionApi.info.version, packageData.version); assert.equal(asyncApi.info.version, packageData.version); });